mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
[WEB-1322] dev: conflict free pages collaboration (#4463)
* chore: pages realtime * chore: empty binary response * chore: added a ypy package * feat: pages collaboration * chore: update fetching logic * chore: degrade ypy version * chore: replace useEffect fetch logic with useSWR * chore: move all the update logic to the page store * refactor: remove react-hook-form * chore: save description_html as well * chore: migrate old data logic * fix: added description_binary as field name * fix: code cleanup * refactor: create separate hook to handle page description * fix: build errors * chore: combine updates instead of using the whole document * chore: removed ypy package * chore: added conflict resolving logic to the client side * chore: add a save changes button * chore: add read-only validation * chore: remove saving state information * chore: added permission class * chore: removed the migration file * chore: corrected the model field * chore: rename pageStore to page * chore: update collaboration provider * chore: add try catch to handle error --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
parent
2b2f667868
commit
a3ac6fa498
@ -106,7 +106,9 @@ class PageDetailSerializer(PageSerializer):
|
|||||||
description_html = serializers.CharField()
|
description_html = serializers.CharField()
|
||||||
|
|
||||||
class Meta(PageSerializer.Meta):
|
class Meta(PageSerializer.Meta):
|
||||||
fields = PageSerializer.Meta.fields + ["description_html"]
|
fields = PageSerializer.Meta.fields + [
|
||||||
|
"description_html",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class SubPageSerializer(BaseSerializer):
|
class SubPageSerializer(BaseSerializer):
|
||||||
|
@ -6,6 +6,7 @@ from plane.app.views import (
|
|||||||
PageFavoriteViewSet,
|
PageFavoriteViewSet,
|
||||||
PageLogEndpoint,
|
PageLogEndpoint,
|
||||||
SubPagesEndpoint,
|
SubPagesEndpoint,
|
||||||
|
PagesDescriptionViewSet,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -79,4 +80,14 @@ urlpatterns = [
|
|||||||
SubPagesEndpoint.as_view(),
|
SubPagesEndpoint.as_view(),
|
||||||
name="sub-page",
|
name="sub-page",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/description/",
|
||||||
|
PagesDescriptionViewSet.as_view(
|
||||||
|
{
|
||||||
|
"get": "retrieve",
|
||||||
|
"patch": "partial_update",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="page-description",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -177,6 +177,7 @@ from .page.base import (
|
|||||||
PageFavoriteViewSet,
|
PageFavoriteViewSet,
|
||||||
PageLogEndpoint,
|
PageLogEndpoint,
|
||||||
SubPagesEndpoint,
|
SubPagesEndpoint,
|
||||||
|
PagesDescriptionViewSet,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
|
||||||
@ -8,6 +9,7 @@ from django.db import connection
|
|||||||
from django.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.gzip import gzip_page
|
from django.views.decorators.gzip import gzip_page
|
||||||
|
from django.http import StreamingHttpResponse
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@ -388,3 +390,48 @@ class SubPagesEndpoint(BaseAPIView):
|
|||||||
return Response(
|
return Response(
|
||||||
SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK
|
SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PagesDescriptionViewSet(BaseViewSet):
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def retrieve(self, request, slug, project_id, pk):
|
||||||
|
page = Page.objects.get(
|
||||||
|
pk=pk, workspace__slug=slug, project_id=project_id
|
||||||
|
)
|
||||||
|
binary_data = page.description_binary
|
||||||
|
|
||||||
|
def stream_data():
|
||||||
|
if binary_data:
|
||||||
|
yield binary_data
|
||||||
|
else:
|
||||||
|
yield b""
|
||||||
|
|
||||||
|
response = StreamingHttpResponse(
|
||||||
|
stream_data(), content_type="application/octet-stream"
|
||||||
|
)
|
||||||
|
response["Content-Disposition"] = (
|
||||||
|
'attachment; filename="page_description.bin"'
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def partial_update(self, request, slug, project_id, pk):
|
||||||
|
page = Page.objects.get(
|
||||||
|
pk=pk, workspace__slug=slug, project_id=project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
base64_data = request.data.get("description_binary")
|
||||||
|
|
||||||
|
if base64_data:
|
||||||
|
# Decode the base64 data to bytes
|
||||||
|
new_binary_data = base64.b64decode(base64_data)
|
||||||
|
|
||||||
|
# Store the updated binary data
|
||||||
|
page.description_binary = new_binary_data
|
||||||
|
page.description_html = request.data.get("description_html")
|
||||||
|
page.save()
|
||||||
|
return Response({"message": "Updated successfully"})
|
||||||
|
else:
|
||||||
|
return Response({"error": "No binary data provided"})
|
||||||
|
@ -18,6 +18,7 @@ def get_view_props():
|
|||||||
class Page(ProjectBaseModel):
|
class Page(ProjectBaseModel):
|
||||||
name = models.CharField(max_length=255, blank=True)
|
name = models.CharField(max_length=255, blank=True)
|
||||||
description = models.JSONField(default=dict, blank=True)
|
description = models.JSONField(default=dict, blank=True)
|
||||||
|
description_binary = models.BinaryField(null=True)
|
||||||
description_html = models.TextField(blank=True, default="<p></p>")
|
description_html = models.TextField(blank=True, default="<p></p>")
|
||||||
description_stripped = models.TextField(blank=True, null=True)
|
description_stripped = models.TextField(blank=True, null=True)
|
||||||
owned_by = models.ForeignKey(
|
owned_by = models.ForeignKey(
|
||||||
@ -43,7 +44,6 @@ class Page(ProjectBaseModel):
|
|||||||
is_locked = models.BooleanField(default=False)
|
is_locked = models.BooleanField(default=False)
|
||||||
view_props = models.JSONField(default=get_view_props)
|
view_props = models.JSONField(default=get_view_props)
|
||||||
logo_props = models.JSONField(default=dict)
|
logo_props = models.JSONField(default=dict)
|
||||||
description_binary = models.BinaryField(null=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Page"
|
verbose_name = "Page"
|
||||||
|
@ -13,17 +13,21 @@ import { EditorMenuItemNames, getEditorMenuItems } from "src/ui/menus/menu-items
|
|||||||
import { EditorRefApi } from "src/types/editor-ref-api";
|
import { EditorRefApi } from "src/types/editor-ref-api";
|
||||||
import { IMarking, scrollSummary } from "src/helpers/scroll-to-node";
|
import { IMarking, scrollSummary } from "src/helpers/scroll-to-node";
|
||||||
|
|
||||||
interface CustomEditorProps {
|
export type TFileHandler = {
|
||||||
|
cancel: () => void;
|
||||||
|
delete: DeleteImage;
|
||||||
|
upload: UploadImage;
|
||||||
|
restore: RestoreImage;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CustomEditorProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
uploadFile: UploadImage;
|
fileHandler: TFileHandler;
|
||||||
restoreFile: RestoreImage;
|
initialValue?: string;
|
||||||
deleteFile: DeleteImage;
|
|
||||||
cancelUploadImage?: () => void;
|
|
||||||
initialValue: string;
|
|
||||||
editorClassName: string;
|
editorClassName: string;
|
||||||
// undefined when prop is not passed, null if intentionally passed to stop
|
// undefined when prop is not passed, null if intentionally passed to stop
|
||||||
// swr syncing
|
// swr syncing
|
||||||
value: string | null | undefined;
|
value?: string | null | undefined;
|
||||||
onChange?: (json: object, html: string) => void;
|
onChange?: (json: object, html: string) => void;
|
||||||
extensions?: any;
|
extensions?: any;
|
||||||
editorProps?: EditorProps;
|
editorProps?: EditorProps;
|
||||||
@ -38,19 +42,16 @@ interface CustomEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useEditor = ({
|
export const useEditor = ({
|
||||||
uploadFile,
|
|
||||||
id = "",
|
id = "",
|
||||||
deleteFile,
|
|
||||||
cancelUploadImage,
|
|
||||||
editorProps = {},
|
editorProps = {},
|
||||||
initialValue,
|
initialValue,
|
||||||
editorClassName,
|
editorClassName,
|
||||||
value,
|
value,
|
||||||
extensions = [],
|
extensions = [],
|
||||||
|
fileHandler,
|
||||||
onChange,
|
onChange,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
restoreFile,
|
|
||||||
handleEditorReady,
|
handleEditorReady,
|
||||||
mentionHandler,
|
mentionHandler,
|
||||||
placeholder,
|
placeholder,
|
||||||
@ -67,10 +68,10 @@ export const useEditor = ({
|
|||||||
mentionHighlights: mentionHandler.highlights ?? [],
|
mentionHighlights: mentionHandler.highlights ?? [],
|
||||||
},
|
},
|
||||||
fileConfig: {
|
fileConfig: {
|
||||||
deleteFile,
|
uploadFile: fileHandler.upload,
|
||||||
restoreFile,
|
deleteFile: fileHandler.delete,
|
||||||
cancelUploadImage,
|
restoreFile: fileHandler.restore,
|
||||||
uploadFile,
|
cancelUploadImage: fileHandler.cancel,
|
||||||
},
|
},
|
||||||
placeholder,
|
placeholder,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
@ -139,7 +140,7 @@ export const useEditor = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
|
executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
|
||||||
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
|
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
|
||||||
|
|
||||||
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
|
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
|
||||||
|
|
||||||
@ -155,7 +156,7 @@ export const useEditor = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
|
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
|
||||||
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
|
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
|
||||||
|
|
||||||
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
|
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName);
|
||||||
const item = getEditorMenuItem(itemName);
|
const item = getEditorMenuItem(itemName);
|
||||||
@ -177,6 +178,10 @@ export const useEditor = ({
|
|||||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||||
return markdownOutput;
|
return markdownOutput;
|
||||||
},
|
},
|
||||||
|
getHTML: (): string => {
|
||||||
|
const htmlOutput = editorRef.current?.getHTML() ?? "<p></p>";
|
||||||
|
return htmlOutput;
|
||||||
|
},
|
||||||
scrollSummary: (marking: IMarking): void => {
|
scrollSummary: (marking: IMarking): void => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
scrollSummary(editorRef.current, marking);
|
scrollSummary(editorRef.current, marking);
|
||||||
@ -199,7 +204,7 @@ export const useEditor = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[editorRef, savedSelection, uploadFile]
|
[editorRef, savedSelection, fileHandler.upload]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
|
@ -68,6 +68,10 @@ export const useReadOnlyEditor = ({
|
|||||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||||
return markdownOutput;
|
return markdownOutput;
|
||||||
},
|
},
|
||||||
|
getHTML: (): string => {
|
||||||
|
const htmlOutput = editorRef.current?.getHTML() ?? "<p></p>";
|
||||||
|
return htmlOutput;
|
||||||
|
},
|
||||||
scrollSummary: (marking: IMarking): void => {
|
scrollSummary: (marking: IMarking): void => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
scrollSummary(editorRef.current, marking);
|
scrollSummary(editorRef.current, marking);
|
||||||
|
@ -24,6 +24,7 @@ export * from "src/ui/menus/menu-items";
|
|||||||
export * from "src/lib/editor-commands";
|
export * from "src/lib/editor-commands";
|
||||||
|
|
||||||
// types
|
// types
|
||||||
|
export type { CustomEditorProps, TFileHandler } from "src/hooks/use-editor";
|
||||||
export type { DeleteImage } from "src/types/delete-image";
|
export type { DeleteImage } from "src/types/delete-image";
|
||||||
export type { UploadImage } from "src/types/upload-image";
|
export type { UploadImage } from "src/types/upload-image";
|
||||||
export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api";
|
export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api";
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
import { Extensions, generateJSON, getSchema } from "@tiptap/core";
|
||||||
import { Selection } from "@tiptap/pm/state";
|
import { Selection } from "@tiptap/pm/state";
|
||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { CoreEditorExtensionsWithoutProps } from "src/ui/extensions/core-without-props";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
interface EditorClassNames {
|
interface EditorClassNames {
|
||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
@ -58,3 +60,20 @@ export const isValidHttpUrl = (string: string): boolean => {
|
|||||||
|
|
||||||
return url.protocol === "http:" || url.protocol === "https:";
|
return url.protocol === "http:" || url.protocol === "https:";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description return an object with contentJSON and editorSchema
|
||||||
|
* @description contentJSON- ProseMirror JSON from HTML content
|
||||||
|
* @description editorSchema- editor schema from extensions
|
||||||
|
* @param {string} html
|
||||||
|
* @returns {object} {contentJSON, editorSchema}
|
||||||
|
*/
|
||||||
|
export const generateJSONfromHTML = (html: string) => {
|
||||||
|
const extensions = CoreEditorExtensionsWithoutProps();
|
||||||
|
const contentJSON = generateJSON(html ?? "<p></p>", extensions as Extensions);
|
||||||
|
const editorSchema = getSchema(extensions as Extensions);
|
||||||
|
return {
|
||||||
|
contentJSON,
|
||||||
|
editorSchema,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@ -3,6 +3,7 @@ import { EditorMenuItemNames } from "src/ui/menus/menu-items";
|
|||||||
|
|
||||||
export type EditorReadOnlyRefApi = {
|
export type EditorReadOnlyRefApi = {
|
||||||
getMarkDown: () => string;
|
getMarkDown: () => string;
|
||||||
|
getHTML: () => string;
|
||||||
clearEditor: () => void;
|
clearEditor: () => void;
|
||||||
setEditorValue: (content: string) => void;
|
setEditorValue: (content: string) => void;
|
||||||
scrollSummary: (marking: IMarking) => void;
|
scrollSummary: (marking: IMarking) => void;
|
||||||
|
121
packages/editor/core/src/ui/extensions/core-without-props.tsx
Normal file
121
packages/editor/core/src/ui/extensions/core-without-props.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
|
import TextStyle from "@tiptap/extension-text-style";
|
||||||
|
import TiptapUnderline from "@tiptap/extension-underline";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { Markdown } from "tiptap-markdown";
|
||||||
|
|
||||||
|
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";
|
||||||
|
import { TableRow } from "src/ui/extensions/table/table-row/table-row";
|
||||||
|
|
||||||
|
import { isValidHttpUrl } from "src/lib/utils";
|
||||||
|
|
||||||
|
import { CustomCodeBlockExtension } from "src/ui/extensions/code";
|
||||||
|
import { CustomKeymap } from "src/ui/extensions/keymap";
|
||||||
|
import { CustomQuoteExtension } from "src/ui/extensions/quote";
|
||||||
|
|
||||||
|
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 "src/ui/extensions/custom-code-inline/inline-code-plugin";
|
||||||
|
import { MentionsWithoutProps } from "src/ui/mentions/mention-without-props";
|
||||||
|
import { ImageExtensionWithoutProps } from "src/ui/extensions/image/image-extension-without-props";
|
||||||
|
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
|
||||||
|
export const CoreEditorExtensionsWithoutProps = () => [
|
||||||
|
StarterKit.configure({
|
||||||
|
bulletList: {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "list-disc pl-7 space-y-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderedList: {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "list-decimal pl-7 space-y-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
listItem: {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "not-prose space-y-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
code: false,
|
||||||
|
codeBlock: false,
|
||||||
|
horizontalRule: false,
|
||||||
|
blockquote: false,
|
||||||
|
dropcursor: {
|
||||||
|
color: "rgba(var(--color-text-100))",
|
||||||
|
width: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
CustomQuoteExtension,
|
||||||
|
CustomHorizontalRule.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "my-4 border-custom-border-400",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
CustomKeymap,
|
||||||
|
// ListKeymap,
|
||||||
|
CustomLinkExtension.configure({
|
||||||
|
openOnClick: true,
|
||||||
|
autolink: true,
|
||||||
|
linkOnPaste: true,
|
||||||
|
protocols: ["http", "https"],
|
||||||
|
validate: (url: string) => isValidHttpUrl(url),
|
||||||
|
HTMLAttributes: {
|
||||||
|
class:
|
||||||
|
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
CustomTypographyExtension,
|
||||||
|
ImageExtensionWithoutProps().configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "rounded-md",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TiptapUnderline,
|
||||||
|
TextStyle,
|
||||||
|
TaskList.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "not-prose pl-2 space-y-2",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TaskItem.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "flex",
|
||||||
|
},
|
||||||
|
nested: true,
|
||||||
|
}),
|
||||||
|
CustomCodeBlockExtension.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
CustomCodeMarkPlugin,
|
||||||
|
CustomCodeInlineExtension,
|
||||||
|
Markdown.configure({
|
||||||
|
html: true,
|
||||||
|
transformPastedText: true,
|
||||||
|
}),
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
MentionsWithoutProps(),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: ({ editor, node }) => {
|
||||||
|
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
||||||
|
|
||||||
|
const shouldHidePlaceholder =
|
||||||
|
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
|
||||||
|
if (shouldHidePlaceholder) return "";
|
||||||
|
|
||||||
|
return "Press '/' for commands...";
|
||||||
|
},
|
||||||
|
includeChildren: true,
|
||||||
|
}),
|
||||||
|
];
|
@ -0,0 +1,33 @@
|
|||||||
|
import ImageExt from "@tiptap/extension-image";
|
||||||
|
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
|
||||||
|
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
|
||||||
|
|
||||||
|
export const ImageExtensionWithoutProps = () =>
|
||||||
|
ImageExt.extend({
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
ArrowDown: insertLineBelowImageAction,
|
||||||
|
ArrowUp: insertLineAboveImageAction,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// storage to keep track of image states Map<src, isDeleted>
|
||||||
|
addStorage() {
|
||||||
|
return {
|
||||||
|
images: new Map<string, boolean>(),
|
||||||
|
uploadInProgress: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
width: {
|
||||||
|
default: "35%",
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
@ -0,0 +1,79 @@
|
|||||||
|
import { CustomMention } from "./custom";
|
||||||
|
import { ReactRenderer } from "@tiptap/react";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import tippy from "tippy.js";
|
||||||
|
|
||||||
|
import { MentionList } from "./mention-list";
|
||||||
|
|
||||||
|
export const MentionsWithoutProps = () =>
|
||||||
|
CustomMention.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "mention",
|
||||||
|
},
|
||||||
|
// mentionHighlights: mentionHighlights,
|
||||||
|
suggestion: {
|
||||||
|
// @ts-expect-error - Tiptap types are incorrect
|
||||||
|
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 },
|
||||||
|
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") ?? document.querySelector("#editor-container"),
|
||||||
|
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-expect-error - Tippy types are incorrect
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
@ -34,12 +34,17 @@
|
|||||||
"@plane/ui": "*",
|
"@plane/ui": "*",
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"@tiptap/core": "^2.1.13",
|
"@tiptap/core": "^2.1.13",
|
||||||
|
"@tiptap/extension-collaboration": "^2.3.2",
|
||||||
"@tiptap/pm": "^2.1.13",
|
"@tiptap/pm": "^2.1.13",
|
||||||
"@tiptap/suggestion": "^2.1.13",
|
"@tiptap/suggestion": "^2.1.13",
|
||||||
"lucide-react": "^0.378.0",
|
"lucide-react": "^0.378.0",
|
||||||
"react-popper": "^2.3.0",
|
"react-popper": "^2.3.0",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1",
|
||||||
|
"y-indexeddb": "^9.0.12",
|
||||||
|
"y-prosemirror": "^1.2.5",
|
||||||
|
"y-protocols": "^1.0.6",
|
||||||
|
"yjs": "^13.6.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "18.15.3",
|
"@types/node": "18.15.3",
|
||||||
|
@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useMemo } from "react";
|
||||||
|
import { EditorProps } from "@tiptap/pm/view";
|
||||||
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
// editor-core
|
||||||
|
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TFileHandler, useEditor } from "@plane/editor-core";
|
||||||
|
// custom provider
|
||||||
|
import { CollaborationProvider } from "src/providers/collaboration-provider";
|
||||||
|
// extensions
|
||||||
|
import { DocumentEditorExtensions } from "src/ui/extensions";
|
||||||
|
|
||||||
|
type DocumentEditorProps = {
|
||||||
|
id: string;
|
||||||
|
fileHandler: TFileHandler;
|
||||||
|
value: Uint8Array;
|
||||||
|
editorClassName: string;
|
||||||
|
onChange: (updates: Uint8Array) => void;
|
||||||
|
editorProps?: EditorProps;
|
||||||
|
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||||
|
mentionHandler: {
|
||||||
|
highlights: () => Promise<IMentionHighlight[]>;
|
||||||
|
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||||
|
};
|
||||||
|
handleEditorReady?: (value: boolean) => void;
|
||||||
|
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||||
|
setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||||
|
tabIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDocumentEditor = ({
|
||||||
|
id,
|
||||||
|
editorProps = {},
|
||||||
|
value,
|
||||||
|
editorClassName,
|
||||||
|
fileHandler,
|
||||||
|
onChange,
|
||||||
|
forwardedRef,
|
||||||
|
tabIndex,
|
||||||
|
handleEditorReady,
|
||||||
|
mentionHandler,
|
||||||
|
placeholder,
|
||||||
|
setHideDragHandleFunction,
|
||||||
|
}: DocumentEditorProps) => {
|
||||||
|
const provider = useMemo(
|
||||||
|
() =>
|
||||||
|
new CollaborationProvider({
|
||||||
|
name: id,
|
||||||
|
onChange,
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// update document on value change
|
||||||
|
useEffect(() => {
|
||||||
|
if (value.byteLength > 0) Y.applyUpdate(provider.document, value);
|
||||||
|
}, [value, provider.document]);
|
||||||
|
|
||||||
|
// indexedDB provider
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||||
|
return () => {
|
||||||
|
localProvider?.destroy();
|
||||||
|
};
|
||||||
|
}, [provider, id]);
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
id,
|
||||||
|
editorProps,
|
||||||
|
editorClassName,
|
||||||
|
fileHandler,
|
||||||
|
handleEditorReady,
|
||||||
|
forwardedRef,
|
||||||
|
mentionHandler,
|
||||||
|
extensions: DocumentEditorExtensions({
|
||||||
|
uploadFile: fileHandler.upload,
|
||||||
|
setHideDragHandle: setHideDragHandleFunction,
|
||||||
|
provider,
|
||||||
|
}),
|
||||||
|
placeholder,
|
||||||
|
tabIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
@ -3,6 +3,8 @@ export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "src/ui/re
|
|||||||
|
|
||||||
// hooks
|
// hooks
|
||||||
export { useEditorMarkings } from "src/hooks/use-editor-markings";
|
export { useEditorMarkings } from "src/hooks/use-editor-markings";
|
||||||
|
// utils
|
||||||
|
export { proseMirrorJSONToBinaryString, applyUpdates, mergeUpdates } from "src/utils/yjs";
|
||||||
|
|
||||||
export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core";
|
export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core";
|
||||||
|
|
||||||
|
@ -0,0 +1,60 @@
|
|||||||
|
import * as Y from "yjs";
|
||||||
|
|
||||||
|
export interface CompleteCollaboratorProviderConfiguration {
|
||||||
|
/**
|
||||||
|
* The identifier/name of your document
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* The actual Y.js document
|
||||||
|
*/
|
||||||
|
document: Y.Doc;
|
||||||
|
/**
|
||||||
|
* onChange callback
|
||||||
|
*/
|
||||||
|
onChange: (updates: Uint8Array) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
|
||||||
|
Partial<CompleteCollaboratorProviderConfiguration>;
|
||||||
|
|
||||||
|
export class CollaborationProvider {
|
||||||
|
public configuration: CompleteCollaboratorProviderConfiguration = {
|
||||||
|
name: "",
|
||||||
|
// @ts-expect-error cannot be undefined
|
||||||
|
document: undefined,
|
||||||
|
onChange: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(configuration: CollaborationProviderConfiguration) {
|
||||||
|
this.setConfiguration(configuration);
|
||||||
|
|
||||||
|
this.configuration.document = configuration.document ?? new Y.Doc();
|
||||||
|
this.document.on("update", this.documentUpdateHandler.bind(this));
|
||||||
|
this.document.on("destroy", this.documentDestroyHandler.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
|
||||||
|
this.configuration = {
|
||||||
|
...this.configuration,
|
||||||
|
...configuration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get document() {
|
||||||
|
return this.configuration.document;
|
||||||
|
}
|
||||||
|
|
||||||
|
documentUpdateHandler(update: Uint8Array, origin: any) {
|
||||||
|
// return if the update is from the provider itself
|
||||||
|
if (origin === this) return;
|
||||||
|
|
||||||
|
// call onChange with the update
|
||||||
|
this.configuration.onChange?.(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
documentDestroyHandler() {
|
||||||
|
this.document.off("update", this.documentUpdateHandler);
|
||||||
|
this.document.off("destroy", this.documentDestroyHandler);
|
||||||
|
}
|
||||||
|
}
|
@ -2,14 +2,20 @@ import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-wi
|
|||||||
|
|
||||||
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
|
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
|
||||||
import { UploadImage } from "@plane/editor-core";
|
import { UploadImage } from "@plane/editor-core";
|
||||||
|
import { CollaborationProvider } from "src/providers/collaboration-provider";
|
||||||
|
import Collaboration from "@tiptap/extension-collaboration";
|
||||||
|
|
||||||
type TArguments = {
|
type TArguments = {
|
||||||
uploadFile: UploadImage;
|
uploadFile: UploadImage;
|
||||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
|
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||||
|
provider: CollaborationProvider;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle }: TArguments) => [
|
export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle, provider }: TArguments) => [
|
||||||
SlashCommand(uploadFile),
|
SlashCommand(uploadFile),
|
||||||
DragAndDrop(setHideDragHandle),
|
DragAndDrop(setHideDragHandle),
|
||||||
IssueWidgetPlaceholder(),
|
IssueWidgetPlaceholder(),
|
||||||
|
Collaboration.configure({
|
||||||
|
document: provider.document,
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
@ -1,30 +1,25 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
// editor-core
|
||||||
import {
|
import {
|
||||||
UploadImage,
|
|
||||||
DeleteImage,
|
|
||||||
RestoreImage,
|
|
||||||
getEditorClassNames,
|
getEditorClassNames,
|
||||||
useEditor,
|
|
||||||
EditorRefApi,
|
EditorRefApi,
|
||||||
IMentionHighlight,
|
IMentionHighlight,
|
||||||
IMentionSuggestion,
|
IMentionSuggestion,
|
||||||
|
TFileHandler,
|
||||||
} from "@plane/editor-core";
|
} from "@plane/editor-core";
|
||||||
import { DocumentEditorExtensions } from "src/ui/extensions";
|
// components
|
||||||
import { PageRenderer } from "src/ui/components/page-renderer";
|
import { PageRenderer } from "src/ui/components/page-renderer";
|
||||||
|
// hooks
|
||||||
|
import { useDocumentEditor } from "src/hooks/use-document-editor";
|
||||||
|
|
||||||
interface IDocumentEditor {
|
interface IDocumentEditor {
|
||||||
initialValue: string;
|
id: string;
|
||||||
value?: string;
|
value: Uint8Array;
|
||||||
fileHandler: {
|
fileHandler: TFileHandler;
|
||||||
cancel: () => void;
|
|
||||||
delete: DeleteImage;
|
|
||||||
upload: UploadImage;
|
|
||||||
restore: RestoreImage;
|
|
||||||
};
|
|
||||||
handleEditorReady?: (value: boolean) => void;
|
handleEditorReady?: (value: boolean) => void;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
editorClassName?: string;
|
editorClassName?: string;
|
||||||
onChange: (json: object, html: string) => void;
|
onChange: (updates: Uint8Array) => void;
|
||||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||||
mentionHandler: {
|
mentionHandler: {
|
||||||
highlights: () => Promise<IMentionHighlight[]>;
|
highlights: () => Promise<IMentionHighlight[]>;
|
||||||
@ -37,7 +32,7 @@ interface IDocumentEditor {
|
|||||||
const DocumentEditor = (props: IDocumentEditor) => {
|
const DocumentEditor = (props: IDocumentEditor) => {
|
||||||
const {
|
const {
|
||||||
onChange,
|
onChange,
|
||||||
initialValue,
|
id,
|
||||||
value,
|
value,
|
||||||
fileHandler,
|
fileHandler,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
@ -50,32 +45,24 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
|||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
|
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
|
||||||
|
|
||||||
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
|
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
|
||||||
// loads such that we can invoke it from react when the cursor leaves the container
|
// loads such that we can invoke it from react when the cursor leaves the container
|
||||||
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
||||||
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
||||||
};
|
};
|
||||||
// use editor
|
|
||||||
const editor = useEditor({
|
// use document editor
|
||||||
onChange(json, html) {
|
const editor = useDocumentEditor({
|
||||||
onChange(json, html);
|
id,
|
||||||
},
|
|
||||||
editorClassName,
|
editorClassName,
|
||||||
restoreFile: fileHandler.restore,
|
fileHandler,
|
||||||
uploadFile: fileHandler.upload,
|
|
||||||
deleteFile: fileHandler.delete,
|
|
||||||
cancelUploadImage: fileHandler.cancel,
|
|
||||||
initialValue,
|
|
||||||
value,
|
value,
|
||||||
|
onChange,
|
||||||
handleEditorReady,
|
handleEditorReady,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
mentionHandler,
|
mentionHandler,
|
||||||
extensions: DocumentEditorExtensions({
|
|
||||||
uploadFile: fileHandler.upload,
|
|
||||||
setHideDragHandle: setHideDragHandleFunction,
|
|
||||||
}),
|
|
||||||
placeholder,
|
placeholder,
|
||||||
|
setHideDragHandleFunction,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
76
packages/editor/document-editor/src/utils/yjs.ts
Normal file
76
packages/editor/document-editor/src/utils/yjs.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { Schema } from "@tiptap/pm/model";
|
||||||
|
import { prosemirrorJSONToYDoc } from "y-prosemirror";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
|
||||||
|
const defaultSchema: Schema = new Schema({
|
||||||
|
nodes: {
|
||||||
|
text: {},
|
||||||
|
doc: { content: "text*" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description converts ProseMirror JSON to Yjs document
|
||||||
|
* @param document prosemirror JSON
|
||||||
|
* @param fieldName
|
||||||
|
* @param schema
|
||||||
|
* @returns {Y.Doc} Yjs document
|
||||||
|
*/
|
||||||
|
export const proseMirrorJSONToBinaryString = (
|
||||||
|
document: any,
|
||||||
|
fieldName: string | Array<string> = "default",
|
||||||
|
schema?: Schema
|
||||||
|
): string => {
|
||||||
|
if (!document) {
|
||||||
|
throw new Error(
|
||||||
|
`You've passed an empty or invalid document to the Transformer. Make sure to pass ProseMirror-compatible JSON. Actually passed JSON: ${document}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow a single field name
|
||||||
|
if (typeof fieldName === "string") {
|
||||||
|
const yDoc = prosemirrorJSONToYDoc(schema ?? defaultSchema, document, fieldName);
|
||||||
|
const docAsUint8Array = Y.encodeStateAsUpdate(yDoc);
|
||||||
|
const base64Doc = Buffer.from(docAsUint8Array).toString("base64");
|
||||||
|
return base64Doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yDoc = new Y.Doc();
|
||||||
|
|
||||||
|
fieldName.forEach((field) => {
|
||||||
|
const update = Y.encodeStateAsUpdate(prosemirrorJSONToYDoc(schema ?? defaultSchema, document, field));
|
||||||
|
|
||||||
|
Y.applyUpdate(yDoc, update);
|
||||||
|
});
|
||||||
|
|
||||||
|
const docAsUint8Array = Y.encodeStateAsUpdate(yDoc);
|
||||||
|
const base64Doc = Buffer.from(docAsUint8Array).toString("base64");
|
||||||
|
|
||||||
|
return base64Doc;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description apply updates to a doc and return the updated doc in base64(binary) format
|
||||||
|
* @param {Uint8Array} document
|
||||||
|
* @param {Uint8Array} updates
|
||||||
|
* @returns {string} base64(binary) form of the updated doc
|
||||||
|
*/
|
||||||
|
export const applyUpdates = (document: Uint8Array, updates: Uint8Array): string => {
|
||||||
|
const yDoc = new Y.Doc();
|
||||||
|
Y.applyUpdate(yDoc, document);
|
||||||
|
Y.applyUpdate(yDoc, updates);
|
||||||
|
|
||||||
|
const encodedDoc = Y.encodeStateAsUpdate(yDoc);
|
||||||
|
const base64Updates = Buffer.from(encodedDoc).toString("base64");
|
||||||
|
return base64Updates;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description merge multiple updates into one single update
|
||||||
|
* @param {Uint8Array[]} updates
|
||||||
|
* @returns {Uint8Array} merged updates
|
||||||
|
*/
|
||||||
|
export const mergeUpdates = (updates: Uint8Array[]): Uint8Array => {
|
||||||
|
const mergedUpdates = Y.mergeUpdates(updates);
|
||||||
|
return mergedUpdates;
|
||||||
|
};
|
@ -1,27 +1,22 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
// editor-core
|
||||||
import {
|
import {
|
||||||
UploadImage,
|
|
||||||
DeleteImage,
|
|
||||||
IMentionSuggestion,
|
IMentionSuggestion,
|
||||||
RestoreImage,
|
|
||||||
EditorContainer,
|
EditorContainer,
|
||||||
EditorContentWrapper,
|
EditorContentWrapper,
|
||||||
getEditorClassNames,
|
getEditorClassNames,
|
||||||
useEditor,
|
useEditor,
|
||||||
IMentionHighlight,
|
IMentionHighlight,
|
||||||
EditorRefApi,
|
EditorRefApi,
|
||||||
|
TFileHandler,
|
||||||
} from "@plane/editor-core";
|
} from "@plane/editor-core";
|
||||||
|
// extensions
|
||||||
import { LiteTextEditorExtensions } from "src/ui/extensions";
|
import { LiteTextEditorExtensions } from "src/ui/extensions";
|
||||||
|
|
||||||
export interface ILiteTextEditor {
|
export interface ILiteTextEditor {
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
fileHandler: {
|
fileHandler: TFileHandler;
|
||||||
cancel: () => void;
|
|
||||||
delete: DeleteImage;
|
|
||||||
upload: UploadImage;
|
|
||||||
restore: RestoreImage;
|
|
||||||
};
|
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
editorClassName?: string;
|
editorClassName?: string;
|
||||||
onChange?: (json: object, html: string) => void;
|
onChange?: (json: object, html: string) => void;
|
||||||
@ -58,10 +53,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
|
|||||||
value,
|
value,
|
||||||
id,
|
id,
|
||||||
editorClassName,
|
editorClassName,
|
||||||
restoreFile: fileHandler.restore,
|
fileHandler,
|
||||||
uploadFile: fileHandler.upload,
|
|
||||||
deleteFile: fileHandler.delete,
|
|
||||||
cancelUploadImage: fileHandler.cancel,
|
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
extensions: LiteTextEditorExtensions(onEnterKeyPress),
|
extensions: LiteTextEditorExtensions(onEnterKeyPress),
|
||||||
mentionHandler,
|
mentionHandler,
|
||||||
|
@ -1,30 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
|
// editor-core
|
||||||
import {
|
import {
|
||||||
DeleteImage,
|
|
||||||
EditorContainer,
|
EditorContainer,
|
||||||
EditorContentWrapper,
|
EditorContentWrapper,
|
||||||
getEditorClassNames,
|
getEditorClassNames,
|
||||||
IMentionHighlight,
|
IMentionHighlight,
|
||||||
IMentionSuggestion,
|
IMentionSuggestion,
|
||||||
RestoreImage,
|
|
||||||
UploadImage,
|
|
||||||
useEditor,
|
useEditor,
|
||||||
EditorRefApi,
|
EditorRefApi,
|
||||||
|
TFileHandler,
|
||||||
} from "@plane/editor-core";
|
} from "@plane/editor-core";
|
||||||
import * as React from "react";
|
// extensions
|
||||||
import { RichTextEditorExtensions } from "src/ui/extensions";
|
import { RichTextEditorExtensions } from "src/ui/extensions";
|
||||||
|
// components
|
||||||
import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
|
import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
|
||||||
|
|
||||||
export type IRichTextEditor = {
|
export type IRichTextEditor = {
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
dragDropEnabled?: boolean;
|
dragDropEnabled?: boolean;
|
||||||
fileHandler: {
|
fileHandler: TFileHandler;
|
||||||
cancel: () => void;
|
|
||||||
delete: DeleteImage;
|
|
||||||
upload: UploadImage;
|
|
||||||
restore: RestoreImage;
|
|
||||||
};
|
|
||||||
id?: string;
|
id?: string;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
editorClassName?: string;
|
editorClassName?: string;
|
||||||
@ -67,10 +63,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
|||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
id,
|
id,
|
||||||
editorClassName,
|
editorClassName,
|
||||||
restoreFile: fileHandler.restore,
|
fileHandler,
|
||||||
uploadFile: fileHandler.upload,
|
|
||||||
deleteFile: fileHandler.delete,
|
|
||||||
cancelUploadImage: fileHandler.cancel,
|
|
||||||
onChange,
|
onChange,
|
||||||
initialValue,
|
initialValue,
|
||||||
value,
|
value,
|
||||||
|
@ -1,30 +1,26 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { FileText } from "lucide-react";
|
import { FileText } from "lucide-react";
|
||||||
// hooks
|
|
||||||
// ui
|
// ui
|
||||||
import { Breadcrumbs, Button } from "@plane/ui";
|
import { Breadcrumbs, Button } from "@plane/ui";
|
||||||
// helpers
|
|
||||||
import { BreadcrumbLink } from "@/components/common";
|
|
||||||
// components
|
// components
|
||||||
|
import { BreadcrumbLink } from "@/components/common";
|
||||||
import { ProjectLogo } from "@/components/project";
|
import { ProjectLogo } from "@/components/project";
|
||||||
import { useCommandPalette, usePage, useProject } from "@/hooks/store";
|
// hooks
|
||||||
|
import { usePage, useProject } from "@/hooks/store";
|
||||||
|
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||||
|
|
||||||
export interface IPagesHeaderProps {
|
export const PageDetailsHeader = observer(() => {
|
||||||
showButton?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
|
|
||||||
const { showButton = false } = props;
|
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, pageId } = router.query;
|
const { workspaceSlug, pageId } = router.query;
|
||||||
// store hooks
|
// store hooks
|
||||||
const { toggleCreatePageModal } = useCommandPalette();
|
|
||||||
const { currentProjectDetails } = useProject();
|
const { currentProjectDetails } = useProject();
|
||||||
|
const { isContentEditable, isSubmitting, name } = usePage(pageId?.toString() ?? "");
|
||||||
const { name } = usePage(pageId?.toString() ?? "");
|
// use platform
|
||||||
|
const { platform } = usePlatformOS();
|
||||||
|
// derived values
|
||||||
|
const isMac = platform === "MacOS";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
|
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
|
||||||
@ -77,12 +73,24 @@ export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
|
|||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showButton && (
|
{isContentEditable && (
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
<Button variant="primary" size="sm" onClick={() => toggleCreatePageModal(true)}>
|
variant="primary"
|
||||||
Add Page
|
size="sm"
|
||||||
</Button>
|
onClick={() => {
|
||||||
</div>
|
// ctrl/cmd + s to save the changes
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: "s",
|
||||||
|
ctrlKey: !isMac,
|
||||||
|
metaKey: isMac,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
loading={isSubmitting === "submitting"}
|
||||||
|
>
|
||||||
|
{isSubmitting === "submitting" ? "Saving" : "Save changes"}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Control, Controller } from "react-hook-form";
|
// document-editor
|
||||||
// document editor
|
|
||||||
import {
|
import {
|
||||||
DocumentEditorWithRef,
|
DocumentEditorWithRef,
|
||||||
DocumentReadOnlyEditorWithRef,
|
DocumentReadOnlyEditorWithRef,
|
||||||
@ -11,15 +10,15 @@ import {
|
|||||||
IMarking,
|
IMarking,
|
||||||
} from "@plane/document-editor";
|
} from "@plane/document-editor";
|
||||||
// types
|
// types
|
||||||
import { IUserLite, TPage } from "@plane/types";
|
import { IUserLite } from "@plane/types";
|
||||||
// components
|
// components
|
||||||
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
|
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
|
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
|
||||||
|
import { usePageDescription } from "@/hooks/use-page-description";
|
||||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
|
||||||
// services
|
// services
|
||||||
import { FileService } from "@/services/file.service";
|
import { FileService } from "@/services/file.service";
|
||||||
// store
|
// store
|
||||||
@ -28,13 +27,10 @@ import { IPageStore } from "@/store/pages/page.store";
|
|||||||
const fileService = new FileService();
|
const fileService = new FileService();
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
control: Control<TPage, any>;
|
|
||||||
editorRef: React.RefObject<EditorRefApi>;
|
editorRef: React.RefObject<EditorRefApi>;
|
||||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||||
swrPageDetails: TPage | undefined;
|
|
||||||
handleSubmit: () => void;
|
|
||||||
markings: IMarking[];
|
markings: IMarking[];
|
||||||
pageStore: IPageStore;
|
page: IPageStore;
|
||||||
sidePeekVisible: boolean;
|
sidePeekVisible: boolean;
|
||||||
handleEditorReady: (value: boolean) => void;
|
handleEditorReady: (value: boolean) => void;
|
||||||
handleReadOnlyEditorReady: (value: boolean) => void;
|
handleReadOnlyEditorReady: (value: boolean) => void;
|
||||||
@ -43,15 +39,12 @@ type Props = {
|
|||||||
|
|
||||||
export const PageEditorBody: React.FC<Props> = observer((props) => {
|
export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||||
const {
|
const {
|
||||||
control,
|
|
||||||
handleReadOnlyEditorReady,
|
handleReadOnlyEditorReady,
|
||||||
handleEditorReady,
|
handleEditorReady,
|
||||||
editorRef,
|
editorRef,
|
||||||
markings,
|
markings,
|
||||||
readOnlyEditorRef,
|
readOnlyEditorRef,
|
||||||
handleSubmit,
|
page,
|
||||||
pageStore,
|
|
||||||
swrPageDetails,
|
|
||||||
sidePeekVisible,
|
sidePeekVisible,
|
||||||
updateMarkings,
|
updateMarkings,
|
||||||
} = props;
|
} = props;
|
||||||
@ -67,11 +60,19 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
|||||||
} = useMember();
|
} = useMember();
|
||||||
// derived values
|
// derived values
|
||||||
const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : "";
|
const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : "";
|
||||||
const pageTitle = pageStore?.name ?? "";
|
const pageId = page?.id;
|
||||||
const pageDescription = pageStore?.description_html;
|
const pageTitle = page?.name ?? "";
|
||||||
const { description_html, isContentEditable, updateTitle, isSubmitting, setIsSubmitting } = pageStore;
|
const pageDescription = page?.description_html;
|
||||||
|
const { isContentEditable, updateTitle, setIsSubmitting } = page;
|
||||||
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
|
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
|
||||||
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
|
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
|
||||||
|
// project-description
|
||||||
|
const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS } = usePageDescription({
|
||||||
|
editorRef,
|
||||||
|
page,
|
||||||
|
projectId,
|
||||||
|
workspaceSlug,
|
||||||
|
});
|
||||||
// use-mention
|
// use-mention
|
||||||
const { mentionHighlights, mentionSuggestions } = useMention({
|
const { mentionHighlights, mentionSuggestions } = useMention({
|
||||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||||
@ -82,13 +83,11 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
|||||||
// page filters
|
// page filters
|
||||||
const { isFullWidth } = usePageFilters();
|
const { isFullWidth } = usePageFilters();
|
||||||
|
|
||||||
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateMarkings(description_html ?? "<p></p>");
|
updateMarkings(pageDescription ?? "<p></p>");
|
||||||
}, [description_html, updateMarkings]);
|
}, [pageDescription, updateMarkings]);
|
||||||
|
|
||||||
if (pageDescription === undefined) return <PageContentLoader />;
|
if (pageId === undefined || !pageDescriptionYJS || !isDescriptionReady) return <PageContentLoader />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-full w-full overflow-y-auto">
|
<div className="flex items-center h-full w-full overflow-y-auto">
|
||||||
@ -122,35 +121,24 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isContentEditable ? (
|
{isContentEditable ? (
|
||||||
<Controller
|
<DocumentEditorWithRef
|
||||||
name="description_html"
|
id={pageId}
|
||||||
control={control}
|
fileHandler={{
|
||||||
render={({ field: { onChange } }) => (
|
cancel: fileService.cancelUpload,
|
||||||
<DocumentEditorWithRef
|
delete: fileService.getDeleteImageFunction(workspaceId),
|
||||||
fileHandler={{
|
restore: fileService.getRestoreImageFunction(workspaceId),
|
||||||
cancel: fileService.cancelUpload,
|
upload: fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting),
|
||||||
delete: fileService.getDeleteImageFunction(workspaceId),
|
}}
|
||||||
restore: fileService.getRestoreImageFunction(workspaceId),
|
handleEditorReady={handleEditorReady}
|
||||||
upload: fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting),
|
value={pageDescriptionYJS}
|
||||||
}}
|
ref={editorRef}
|
||||||
handleEditorReady={handleEditorReady}
|
containerClassName="p-0 pb-64"
|
||||||
initialValue={pageDescription ?? "<p></p>"}
|
editorClassName="pl-10"
|
||||||
value={swrPageDetails?.description_html ?? "<p></p>"}
|
onChange={handleDescriptionChange}
|
||||||
ref={editorRef}
|
mentionHandler={{
|
||||||
containerClassName="p-0 pb-64"
|
highlights: mentionHighlights,
|
||||||
editorClassName="lg:px-10 pl-8"
|
suggestions: mentionSuggestions,
|
||||||
onChange={(_description_json, description_html) => {
|
}}
|
||||||
setIsSubmitting("submitting");
|
|
||||||
setShowAlert(true);
|
|
||||||
onChange(description_html);
|
|
||||||
handleSubmit();
|
|
||||||
}}
|
|
||||||
mentionHandler={{
|
|
||||||
highlights: mentionHighlights,
|
|
||||||
suggestions: mentionSuggestions,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DocumentReadOnlyEditorWithRef
|
<DocumentReadOnlyEditorWithRef
|
||||||
@ -158,7 +146,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
|||||||
initialValue={pageDescription ?? "<p></p>"}
|
initialValue={pageDescription ?? "<p></p>"}
|
||||||
handleEditorReady={handleReadOnlyEditorReady}
|
handleEditorReady={handleReadOnlyEditorReady}
|
||||||
containerClassName="p-0 pb-64 border-none"
|
containerClassName="p-0 pb-64 border-none"
|
||||||
editorClassName="lg:px-10 pl-8"
|
editorClassName="pl-10"
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
highlights: mentionHighlights,
|
highlights: mentionHighlights,
|
||||||
}}
|
}}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Lock, RefreshCw, Sparkle } from "lucide-react";
|
import { Lock, Sparkle } from "lucide-react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/document-editor";
|
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/document-editor";
|
||||||
// ui
|
// ui
|
||||||
@ -9,7 +9,6 @@ import { ArchiveIcon } from "@plane/ui";
|
|||||||
import { GptAssistantPopover } from "@/components/core";
|
import { GptAssistantPopover } from "@/components/core";
|
||||||
import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages";
|
import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
|
||||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useInstance } from "@/hooks/store";
|
import { useInstance } from "@/hooks/store";
|
||||||
@ -19,20 +18,19 @@ import { IPageStore } from "@/store/pages/page.store";
|
|||||||
type Props = {
|
type Props = {
|
||||||
editorRef: React.RefObject<EditorRefApi>;
|
editorRef: React.RefObject<EditorRefApi>;
|
||||||
handleDuplicatePage: () => void;
|
handleDuplicatePage: () => void;
|
||||||
isSyncing: boolean;
|
page: IPageStore;
|
||||||
pageStore: IPageStore;
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||||
const { editorRef, handleDuplicatePage, isSyncing, pageStore, projectId, readOnlyEditorRef } = props;
|
const { editorRef, handleDuplicatePage, page, projectId, readOnlyEditorRef } = props;
|
||||||
// states
|
// states
|
||||||
const [gptModalOpen, setGptModal] = useState(false);
|
const [gptModalOpen, setGptModal] = useState(false);
|
||||||
// store hooks
|
// store hooks
|
||||||
const { config } = useInstance();
|
const { config } = useInstance();
|
||||||
// derived values
|
// derived values
|
||||||
const { archived_at, isContentEditable, isSubmitting, is_locked } = pageStore;
|
const { archived_at, isContentEditable, is_locked } = page;
|
||||||
|
|
||||||
const handleAiAssistance = async (response: string) => {
|
const handleAiAssistance = async (response: string) => {
|
||||||
if (!editorRef) return;
|
if (!editorRef) return;
|
||||||
@ -41,22 +39,6 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-grow items-center justify-end gap-3">
|
<div className="flex flex-grow items-center justify-end gap-3">
|
||||||
{isContentEditable && (
|
|
||||||
<div
|
|
||||||
className={cn("fade-in flex items-center gap-x-2 transition-all duration-300", {
|
|
||||||
"fade-out": isSubmitting === "saved",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isSubmitting === "submitting" && <RefreshCw className="h-4 w-4 stroke-custom-text-300" />}
|
|
||||||
<span className="text-sm text-custom-text-300">{isSubmitting === "submitting" ? "Saving..." : "Saved"}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isSyncing && (
|
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
<RefreshCw className="h-4 w-4 stroke-custom-text-300" />
|
|
||||||
<span className="text-sm text-custom-text-300">Syncing...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{is_locked && (
|
{is_locked && (
|
||||||
<div className="flex h-7 items-center gap-2 rounded-full bg-custom-background-80 px-3 py-0.5 text-xs font-medium text-custom-text-300">
|
<div className="flex h-7 items-center gap-2 rounded-full bg-custom-background-80 px-3 py-0.5 text-xs font-medium text-custom-text-300">
|
||||||
<Lock className="h-3 w-3" />
|
<Lock className="h-3 w-3" />
|
||||||
@ -93,11 +75,11 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
|||||||
className="!min-w-[38rem]"
|
className="!min-w-[38rem]"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PageInfoPopover pageStore={pageStore} />
|
<PageInfoPopover page={page} />
|
||||||
<PageOptionsDropdown
|
<PageOptionsDropdown
|
||||||
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
|
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
|
||||||
handleDuplicatePage={handleDuplicatePage}
|
handleDuplicatePage={handleDuplicatePage}
|
||||||
pageStore={pageStore}
|
page={page}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -7,11 +7,11 @@ import { renderFormattedDate } from "@/helpers/date-time.helper";
|
|||||||
import { IPageStore } from "@/store/pages/page.store";
|
import { IPageStore } from "@/store/pages/page.store";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
pageStore: IPageStore;
|
page: IPageStore;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageInfoPopover: React.FC<Props> = (props) => {
|
export const PageInfoPopover: React.FC<Props> = (props) => {
|
||||||
const { pageStore } = props;
|
const { page } = props;
|
||||||
// states
|
// states
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||||
// refs
|
// refs
|
||||||
@ -22,7 +22,7 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
|
|||||||
placement: "bottom-start",
|
placement: "bottom-start",
|
||||||
});
|
});
|
||||||
// derived values
|
// derived values
|
||||||
const { created_at, updated_at } = pageStore;
|
const { created_at, updated_at } = page;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onMouseEnter={() => setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)}>
|
<div onMouseEnter={() => setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)}>
|
||||||
|
@ -11,9 +11,8 @@ type Props = {
|
|||||||
editorRef: React.RefObject<EditorRefApi>;
|
editorRef: React.RefObject<EditorRefApi>;
|
||||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||||
handleDuplicatePage: () => void;
|
handleDuplicatePage: () => void;
|
||||||
isSyncing: boolean;
|
|
||||||
markings: IMarking[];
|
markings: IMarking[];
|
||||||
pageStore: IPageStore;
|
page: IPageStore;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
sidePeekVisible: boolean;
|
sidePeekVisible: boolean;
|
||||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||||
@ -29,14 +28,13 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
|||||||
markings,
|
markings,
|
||||||
readOnlyEditorReady,
|
readOnlyEditorReady,
|
||||||
handleDuplicatePage,
|
handleDuplicatePage,
|
||||||
isSyncing,
|
page,
|
||||||
pageStore,
|
|
||||||
projectId,
|
projectId,
|
||||||
sidePeekVisible,
|
sidePeekVisible,
|
||||||
setSidePeekVisible,
|
setSidePeekVisible,
|
||||||
} = props;
|
} = props;
|
||||||
// derived values
|
// derived values
|
||||||
const { isContentEditable } = pageStore;
|
const { isContentEditable } = page;
|
||||||
// page filters
|
// page filters
|
||||||
const { isFullWidth } = usePageFilters();
|
const { isFullWidth } = usePageFilters();
|
||||||
|
|
||||||
@ -57,8 +55,7 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
|||||||
<PageExtraOptions
|
<PageExtraOptions
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
handleDuplicatePage={handleDuplicatePage}
|
handleDuplicatePage={handleDuplicatePage}
|
||||||
isSyncing={isSyncing}
|
page={page}
|
||||||
pageStore={pageStore}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
readOnlyEditorRef={readOnlyEditorRef}
|
readOnlyEditorRef={readOnlyEditorRef}
|
||||||
/>
|
/>
|
||||||
|
@ -16,11 +16,11 @@ import { IPageStore } from "@/store/pages/page.store";
|
|||||||
type Props = {
|
type Props = {
|
||||||
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
|
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||||
handleDuplicatePage: () => void;
|
handleDuplicatePage: () => void;
|
||||||
pageStore: IPageStore;
|
page: IPageStore;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||||
const { editorRef, handleDuplicatePage, pageStore } = props;
|
const { editorRef, handleDuplicatePage, page } = props;
|
||||||
// store values
|
// store values
|
||||||
const {
|
const {
|
||||||
archived_at,
|
archived_at,
|
||||||
@ -33,7 +33,7 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
|||||||
canCurrentUserDuplicatePage,
|
canCurrentUserDuplicatePage,
|
||||||
canCurrentUserLockPage,
|
canCurrentUserLockPage,
|
||||||
restore,
|
restore,
|
||||||
} = pageStore;
|
} = page;
|
||||||
// store hooks
|
// store hooks
|
||||||
const { workspaceSlug, projectId } = useAppRouter();
|
const { workspaceSlug, projectId } = useAppRouter();
|
||||||
// page filters
|
// page filters
|
||||||
|
@ -13,9 +13,8 @@ type Props = {
|
|||||||
editorRef: React.RefObject<EditorRefApi>;
|
editorRef: React.RefObject<EditorRefApi>;
|
||||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||||
handleDuplicatePage: () => void;
|
handleDuplicatePage: () => void;
|
||||||
isSyncing: boolean;
|
|
||||||
markings: IMarking[];
|
markings: IMarking[];
|
||||||
pageStore: IPageStore;
|
page: IPageStore;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
sidePeekVisible: boolean;
|
sidePeekVisible: boolean;
|
||||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||||
@ -31,14 +30,13 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
|||||||
markings,
|
markings,
|
||||||
readOnlyEditorReady,
|
readOnlyEditorReady,
|
||||||
handleDuplicatePage,
|
handleDuplicatePage,
|
||||||
isSyncing,
|
page,
|
||||||
pageStore,
|
|
||||||
projectId,
|
projectId,
|
||||||
sidePeekVisible,
|
sidePeekVisible,
|
||||||
setSidePeekVisible,
|
setSidePeekVisible,
|
||||||
} = props;
|
} = props;
|
||||||
// derived values
|
// derived values
|
||||||
const { isContentEditable } = pageStore;
|
const { isContentEditable } = page;
|
||||||
// page filters
|
// page filters
|
||||||
const { isFullWidth } = usePageFilters();
|
const { isFullWidth } = usePageFilters();
|
||||||
|
|
||||||
@ -67,8 +65,7 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
|||||||
<PageExtraOptions
|
<PageExtraOptions
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
handleDuplicatePage={handleDuplicatePage}
|
handleDuplicatePage={handleDuplicatePage}
|
||||||
isSyncing={isSyncing}
|
page={page}
|
||||||
pageStore={pageStore}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
readOnlyEditorRef={readOnlyEditorRef}
|
readOnlyEditorRef={readOnlyEditorRef}
|
||||||
/>
|
/>
|
||||||
@ -81,8 +78,7 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
|||||||
readOnlyEditorReady={readOnlyEditorReady}
|
readOnlyEditorReady={readOnlyEditorReady}
|
||||||
markings={markings}
|
markings={markings}
|
||||||
handleDuplicatePage={handleDuplicatePage}
|
handleDuplicatePage={handleDuplicatePage}
|
||||||
isSyncing={isSyncing}
|
page={page}
|
||||||
pageStore={pageStore}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
sidePeekVisible={sidePeekVisible}
|
sidePeekVisible={sidePeekVisible}
|
||||||
setSidePeekVisible={setSidePeekVisible}
|
setSidePeekVisible={setSidePeekVisible}
|
||||||
|
@ -33,7 +33,6 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<TextArea
|
<TextArea
|
||||||
onChange={(e) => updateTitle(e.target.value)}
|
|
||||||
className="w-full bg-custom-background text-[1.75rem] font-semibold outline-none p-0 border-none resize-none rounded-none"
|
className="w-full bg-custom-background text-[1.75rem] font-semibold outline-none p-0 border-none resize-none rounded-none"
|
||||||
style={{
|
style={{
|
||||||
lineHeight: "1.2",
|
lineHeight: "1.2",
|
||||||
@ -46,6 +45,7 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
value={title}
|
value={title}
|
||||||
|
onChange={(e) => updateTitle(e.target.value)}
|
||||||
maxLength={255}
|
maxLength={255}
|
||||||
onFocus={() => setIsLengthVisible(true)}
|
onFocus={() => setIsLengthVisible(true)}
|
||||||
onBlur={() => setIsLengthVisible(false)}
|
onBlur={() => setIsLengthVisible(false)}
|
||||||
|
@ -4,6 +4,5 @@ export * from "./header";
|
|||||||
export * from "./list";
|
export * from "./list";
|
||||||
export * from "./loaders";
|
export * from "./loaders";
|
||||||
export * from "./modals";
|
export * from "./modals";
|
||||||
export * from "./page-detail";
|
|
||||||
export * from "./pages-list-main-content";
|
export * from "./pages-list-main-content";
|
||||||
export * from "./pages-list-view";
|
export * from "./pages-list-view";
|
||||||
|
@ -2,27 +2,116 @@
|
|||||||
import { Loader } from "@plane/ui";
|
import { Loader } from "@plane/ui";
|
||||||
|
|
||||||
export const PageContentLoader = () => (
|
export const PageContentLoader = () => (
|
||||||
<div className="flex">
|
<div className="relative w-full h-full flex flex-col">
|
||||||
<div className="w-[5%]" />
|
{/* header */}
|
||||||
<Loader className="flex-shrink-0 flex-grow">
|
<div className="px-4 flex-shrink-0 relative flex items-center justify-between h-12 border-b border-custom-border-100">
|
||||||
<div className="mt-10 space-y-2">
|
{/* left options */}
|
||||||
<Loader.Item height="20px" />
|
<Loader className="flex-shrink-0 w-[280px]">
|
||||||
<Loader.Item height="20px" width="80%" />
|
<Loader.Item width="26px" height="26px" />
|
||||||
<Loader.Item height="20px" width="80%" />
|
</Loader>
|
||||||
|
|
||||||
|
{/* editor options */}
|
||||||
|
<div className="w-full relative flex items-center divide-x divide-custom-border-100">
|
||||||
|
<Loader className="relative flex items-center gap-1 pr-2">
|
||||||
|
<Loader.Item width="26px" height="26px" />
|
||||||
|
<Loader.Item width="26px" height="26px" />
|
||||||
|
<Loader.Item width="26px" height="26px" />
|
||||||
|
<Loader.Item width="26px" height="26px" />
|
||||||
|
<Loader.Item width="26px" height="26px" />
|
||||||
|
<Loader.Item width="26px" height="26px" />
|
||||||
|
<Loader.Item width="26px" height="26px" />
|
||||||
|
</Loader>
|
||||||
|
<Loader className="relative flex items-center gap-1 px-2">
|
||||||
|
<Loader.Item width="26px" height="26px" />
|
||||||
|
<Loader.Item width="26px" height="26px" />
|
||||||
|
</Loader>
|
||||||
|
<Loader className="relative flex items-center gap-1 px-2">
|
||||||
|
<Loader.Item width="26px" height="26px" />
|
||||||
|
<Loader.Item width="26px" height="26px" />
|
||||||
|
</Loader>
|
||||||
|
<Loader className="relative flex items-center gap-1 pl-2">
|
||||||
|
<Loader.Item width="26px" height="26px" />
|
||||||
|
<Loader.Item width="26px" height="26px" />
|
||||||
|
</Loader>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-12 space-y-10">
|
|
||||||
{Array.from(Array(4)).map((i) => (
|
{/* right options */}
|
||||||
<div key={i}>
|
<Loader className="w-full relative flex justify-end items-center gap-1">
|
||||||
<Loader.Item height="25px" width="20%" />
|
<Loader.Item width="60px" height="26px" />
|
||||||
<div className="mt-5 space-y-3">
|
<Loader.Item width="40px" height="26px" />
|
||||||
<Loader.Item height="15px" width="40%" />
|
<Loader.Item width="26px" height="26px" />
|
||||||
<Loader.Item height="15px" width="30%" />
|
<Loader.Item width="26px" height="26px" />
|
||||||
<Loader.Item height="15px" width="35%" />
|
</Loader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* content */}
|
||||||
|
<div className="px-4 w-full h-full overflow-hidden relative flex">
|
||||||
|
{/* table of content loader */}
|
||||||
|
<div className="flex-shrink-0 w-[280px] pr-5 py-5">
|
||||||
|
<Loader className="w-full space-y-4">
|
||||||
|
<Loader.Item width="100%" height="24px" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Loader.Item width="60%" height="12px" />
|
||||||
|
<div className="ml-6 space-y-2">
|
||||||
|
<Loader.Item width="80%" height="12px" />
|
||||||
|
<Loader.Item width="100%" height="12px" />
|
||||||
|
</div>
|
||||||
|
<Loader.Item width="60%" height="12px" />
|
||||||
|
<div className="ml-6 space-y-2">
|
||||||
|
<Loader.Item width="80%" height="12px" />
|
||||||
|
<Loader.Item width="100%" height="12px" />
|
||||||
|
</div>
|
||||||
|
<Loader.Item width="100%" height="12px" />
|
||||||
|
<Loader.Item width="60%" height="12px" />
|
||||||
|
<div className="ml-6 space-y-2">
|
||||||
|
<Loader.Item width="80%" height="12px" />
|
||||||
|
<Loader.Item width="100%" height="12px" />
|
||||||
|
</div>
|
||||||
|
<Loader.Item width="80%" height="12px" />
|
||||||
|
<Loader.Item width="100%" height="12px" />
|
||||||
|
</div>
|
||||||
|
</Loader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* editor loader */}
|
||||||
|
<div className="w-full h-full py-5">
|
||||||
|
<Loader className="relative space-y-4">
|
||||||
|
<Loader.Item width="50%" height="36px" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="py-2">
|
||||||
|
<Loader.Item width="100%" height="36px" />
|
||||||
|
</div>
|
||||||
|
<Loader.Item width="80%" height="22px" />
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
<Loader.Item width="30px" height="30px" />
|
||||||
|
<Loader.Item width="30%" height="22px" />
|
||||||
|
</div>
|
||||||
|
<div className="py-2">
|
||||||
|
<Loader.Item width="60%" height="36px" />
|
||||||
|
</div>
|
||||||
|
<Loader.Item width="70%" height="22px" />
|
||||||
|
<Loader.Item width="30%" height="22px" />
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
<Loader.Item width="30px" height="30px" />
|
||||||
|
<Loader.Item width="30%" height="22px" />
|
||||||
|
</div>
|
||||||
|
<div className="py-2">
|
||||||
|
<Loader.Item width="50%" height="30px" />
|
||||||
|
</div>
|
||||||
|
<Loader.Item width="100%" height="22px" />
|
||||||
|
<div className="py-2">
|
||||||
|
<Loader.Item width="30%" height="30px" />
|
||||||
|
</div>
|
||||||
|
<Loader.Item width="30%" height="22px" />
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
<div className="py-2">
|
||||||
|
<Loader.Item width="30px" height="30px" />
|
||||||
|
</div>
|
||||||
|
<Loader.Item width="30%" height="22px" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</Loader>
|
||||||
</div>
|
</div>
|
||||||
</Loader>
|
</div>
|
||||||
<div className="w-[5%]" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -23,11 +23,11 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
|
|||||||
// store hooks
|
// store hooks
|
||||||
const { removePage } = useProjectPages(projectId);
|
const { removePage } = useProjectPages(projectId);
|
||||||
const { capturePageEvent } = useEventTracker();
|
const { capturePageEvent } = useEventTracker();
|
||||||
const pageStore = usePage(pageId);
|
const page = usePage(pageId);
|
||||||
|
|
||||||
if (!pageStore) return null;
|
if (!page) return null;
|
||||||
|
|
||||||
const { name } = pageStore;
|
const { name } = page;
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
@ -41,7 +41,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
|
|||||||
capturePageEvent({
|
capturePageEvent({
|
||||||
eventName: PAGE_DELETED,
|
eventName: PAGE_DELETED,
|
||||||
payload: {
|
payload: {
|
||||||
...pageStore,
|
...page,
|
||||||
state: "SUCCESS",
|
state: "SUCCESS",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -56,7 +56,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
|
|||||||
capturePageEvent({
|
capturePageEvent({
|
||||||
eventName: PAGE_DELETED,
|
eventName: PAGE_DELETED,
|
||||||
payload: {
|
payload: {
|
||||||
...pageStore,
|
...page,
|
||||||
state: "FAILED",
|
state: "FAILED",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
export * from "./loader";
|
|
||||||
|
|
||||||
export * from "./root";
|
|
@ -1,118 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
// components/ui
|
|
||||||
import { Loader } from "@plane/ui";
|
|
||||||
|
|
||||||
export const PageDetailRootLoader: FC = () => (
|
|
||||||
<div className=" relative w-full h-full flex flex-col">
|
|
||||||
{/* header */}
|
|
||||||
<div className="px-4 flex-shrink-0 relative flex items-center justify-between h-12 border-b border-custom-border-100">
|
|
||||||
{/* left options */}
|
|
||||||
<Loader className="flex-shrink-0 w-[280px]">
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
</Loader>
|
|
||||||
|
|
||||||
{/* editor options */}
|
|
||||||
<div className="w-full relative flex items-center divide-x divide-custom-border-100">
|
|
||||||
<Loader className="relative flex items-center gap-1 pr-2">
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
</Loader>
|
|
||||||
<Loader className="relative flex items-center gap-1 px-2">
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
</Loader>
|
|
||||||
<Loader className="relative flex items-center gap-1 px-2">
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
</Loader>
|
|
||||||
<Loader className="relative flex items-center gap-1 pl-2">
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
</Loader>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* right options */}
|
|
||||||
<Loader className="w-full relative flex justify-end items-center gap-1">
|
|
||||||
<Loader.Item width="60px" height="26px" />
|
|
||||||
<Loader.Item width="40px" height="26px" />
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
<Loader.Item width="26px" height="26px" />
|
|
||||||
</Loader>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* content */}
|
|
||||||
<div className="px-4 w-full h-full overflow-hidden relative flex">
|
|
||||||
{/* table of content loader */}
|
|
||||||
<div className="flex-shrink-0 w-[280px] pr-5 py-5">
|
|
||||||
<Loader className="w-full space-y-4">
|
|
||||||
<Loader.Item width="100%" height="24px" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Loader.Item width="60%" height="12px" />
|
|
||||||
<div className="ml-6 space-y-2">
|
|
||||||
<Loader.Item width="80%" height="12px" />
|
|
||||||
<Loader.Item width="100%" height="12px" />
|
|
||||||
</div>
|
|
||||||
<Loader.Item width="60%" height="12px" />
|
|
||||||
<div className="ml-6 space-y-2">
|
|
||||||
<Loader.Item width="80%" height="12px" />
|
|
||||||
<Loader.Item width="100%" height="12px" />
|
|
||||||
</div>
|
|
||||||
<Loader.Item width="100%" height="12px" />
|
|
||||||
<Loader.Item width="60%" height="12px" />
|
|
||||||
<div className="ml-6 space-y-2">
|
|
||||||
<Loader.Item width="80%" height="12px" />
|
|
||||||
<Loader.Item width="100%" height="12px" />
|
|
||||||
</div>
|
|
||||||
<Loader.Item width="80%" height="12px" />
|
|
||||||
<Loader.Item width="100%" height="12px" />
|
|
||||||
</div>
|
|
||||||
</Loader>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* editor loader */}
|
|
||||||
<div className="w-full h-full py-5">
|
|
||||||
<Loader className="relative space-y-4">
|
|
||||||
<Loader.Item width="50%" height="36px" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="py-2">
|
|
||||||
<Loader.Item width="100%" height="36px" />
|
|
||||||
</div>
|
|
||||||
<Loader.Item width="80%" height="22px" />
|
|
||||||
<div className="relative flex items-center gap-2">
|
|
||||||
<Loader.Item width="30px" height="30px" />
|
|
||||||
<Loader.Item width="30%" height="22px" />
|
|
||||||
</div>
|
|
||||||
<div className="py-2">
|
|
||||||
<Loader.Item width="60%" height="36px" />
|
|
||||||
</div>
|
|
||||||
<Loader.Item width="70%" height="22px" />
|
|
||||||
<Loader.Item width="30%" height="22px" />
|
|
||||||
<div className="relative flex items-center gap-2">
|
|
||||||
<Loader.Item width="30px" height="30px" />
|
|
||||||
<Loader.Item width="30%" height="22px" />
|
|
||||||
</div>
|
|
||||||
<div className="py-2">
|
|
||||||
<Loader.Item width="50%" height="30px" />
|
|
||||||
</div>
|
|
||||||
<Loader.Item width="100%" height="22px" />
|
|
||||||
<div className="py-2">
|
|
||||||
<Loader.Item width="30%" height="30px" />
|
|
||||||
</div>
|
|
||||||
<Loader.Item width="30%" height="22px" />
|
|
||||||
<div className="relative flex items-center gap-2">
|
|
||||||
<div className="py-2">
|
|
||||||
<Loader.Item width="30px" height="30px" />
|
|
||||||
</div>
|
|
||||||
<Loader.Item width="30%" height="22px" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Loader>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
@ -1,54 +0,0 @@
|
|||||||
import { FC, Fragment } from "react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
// hooks
|
|
||||||
import { PageHead } from "@/components/core";
|
|
||||||
import { useProjectPages, usePage } from "@/hooks/store";
|
|
||||||
// components
|
|
||||||
import { PageDetailRootLoader } from "./";
|
|
||||||
|
|
||||||
type TPageDetailRoot = {
|
|
||||||
projectId: string;
|
|
||||||
pageId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PageDetailRoot: FC<TPageDetailRoot> = observer((props) => {
|
|
||||||
const { projectId, pageId } = props;
|
|
||||||
// hooks
|
|
||||||
const { loader } = useProjectPages(projectId);
|
|
||||||
const { id, name } = usePage(pageId);
|
|
||||||
|
|
||||||
if (loader === "init-loader") return <PageDetailRootLoader />;
|
|
||||||
|
|
||||||
if (!id) return <div className="">No page is available.</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<PageHead title={name || "Pages"} />
|
|
||||||
|
|
||||||
<div className="relative w-full h-full flex flex-col">
|
|
||||||
<div className="flex-shrink-0 px-4 relative flex items-center justify-between h-12 border-b border-custom-border-100">
|
|
||||||
{/* header left container */}
|
|
||||||
<div className="flex-shrink-0 w-[280px]">Icon</div>
|
|
||||||
{/* header editor tool container */}
|
|
||||||
<div className="w-full relative hidden md:flex items-center divide-x divide-custom-border-100 ">
|
|
||||||
Editor keys
|
|
||||||
</div>
|
|
||||||
{/* header right operations container */}
|
|
||||||
<div className="w-full relative flex justify-end">right saved</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* editor container for small screens */}
|
|
||||||
<div className="px-4 h-12 relative flex md:hidden items-center border-b border-custom-border-100">
|
|
||||||
Editor keys
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-4 w-full h-full overflow-hidden relative flex">
|
|
||||||
{/* editor table of content content container */}
|
|
||||||
<div className="flex-shrink-0 w-[280px] pr-5 py-5">Table of content</div>
|
|
||||||
{/* editor container */}
|
|
||||||
<div className="w-full h-full py-5">Editor Container</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
});
|
|
153
web/hooks/use-page-description.ts
Normal file
153
web/hooks/use-page-description.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
// editor
|
||||||
|
import { applyUpdates, mergeUpdates, proseMirrorJSONToBinaryString } from "@plane/document-editor";
|
||||||
|
import { EditorRefApi, generateJSONfromHTML } from "@plane/editor-core";
|
||||||
|
// hooks
|
||||||
|
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||||
|
// services
|
||||||
|
import { PageService } from "@/services/page.service";
|
||||||
|
import { IPageStore } from "@/store/pages/page.store";
|
||||||
|
const pageService = new PageService();
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
editorRef: React.RefObject<EditorRefApi>;
|
||||||
|
page: IPageStore;
|
||||||
|
projectId: string | string[] | undefined;
|
||||||
|
workspaceSlug: string | string[] | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUTO_SAVE_TIME = 10000;
|
||||||
|
|
||||||
|
export const usePageDescription = (props: Props) => {
|
||||||
|
const { editorRef, page, projectId, workspaceSlug } = props;
|
||||||
|
// states
|
||||||
|
const [isDescriptionReady, setIsDescriptionReady] = useState(false);
|
||||||
|
const [descriptionUpdates, setDescriptionUpdates] = useState<Uint8Array[]>([]);
|
||||||
|
// derived values
|
||||||
|
const { isContentEditable, isSubmitting, updateDescription, setIsSubmitting } = page;
|
||||||
|
const pageDescription = page.description_html;
|
||||||
|
const pageId = page.id;
|
||||||
|
|
||||||
|
const { data: descriptionYJS, mutate: mutateDescriptionYJS } = useSWR(
|
||||||
|
workspaceSlug && projectId && pageId ? `PAGE_DESCRIPTION_${workspaceSlug}_${projectId}_${pageId}` : null,
|
||||||
|
workspaceSlug && projectId && pageId
|
||||||
|
? () => pageService.fetchDescriptionYJS(workspaceSlug.toString(), projectId.toString(), pageId.toString())
|
||||||
|
: null,
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: false,
|
||||||
|
revalidateIfStale: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// description in Uint8Array format
|
||||||
|
const pageDescriptionYJS = useMemo(
|
||||||
|
() => (descriptionYJS ? new Uint8Array(descriptionYJS) : undefined),
|
||||||
|
[descriptionYJS]
|
||||||
|
);
|
||||||
|
|
||||||
|
// push the new updates to the updates array
|
||||||
|
const handleDescriptionChange = useCallback((updates: Uint8Array) => {
|
||||||
|
setDescriptionUpdates((prev) => [...prev, updates]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// if description_binary field is empty, convert description_html to yDoc and update the DB
|
||||||
|
// TODO: this is a one-time operation, and needs to be removed once all the pages are updated
|
||||||
|
useEffect(() => {
|
||||||
|
const changeHTMLToBinary = async () => {
|
||||||
|
if (!pageDescriptionYJS || !pageDescription) return;
|
||||||
|
if (pageDescriptionYJS.byteLength === 0) {
|
||||||
|
const { contentJSON, editorSchema } = generateJSONfromHTML(pageDescription ?? "<p></p>");
|
||||||
|
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
|
||||||
|
await updateDescription(yDocBinaryString, pageDescription ?? "<p></p>");
|
||||||
|
await mutateDescriptionYJS();
|
||||||
|
setIsDescriptionReady(true);
|
||||||
|
} else setIsDescriptionReady(true);
|
||||||
|
};
|
||||||
|
changeHTMLToBinary();
|
||||||
|
}, [mutateDescriptionYJS, pageDescription, pageDescriptionYJS, updateDescription]);
|
||||||
|
|
||||||
|
const handleSaveDescription = useCallback(async () => {
|
||||||
|
if (!isContentEditable) return;
|
||||||
|
|
||||||
|
const applyUpdatesAndSave = async (latestDescription: any, updates: Uint8Array) => {
|
||||||
|
if (!workspaceSlug || !projectId || !pageId || !latestDescription) return;
|
||||||
|
// convert description to Uint8Array
|
||||||
|
const descriptionArray = new Uint8Array(latestDescription);
|
||||||
|
// apply the updates to the description
|
||||||
|
const combinedBinaryString = applyUpdates(descriptionArray, updates);
|
||||||
|
// get the latest html content
|
||||||
|
const descriptionHTML = editorRef.current?.getHTML() ?? "<p></p>";
|
||||||
|
// make a request to update the descriptions
|
||||||
|
await updateDescription(combinedBinaryString, descriptionHTML).finally(() => setIsSubmitting("saved"));
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting("submitting");
|
||||||
|
// fetch the latest description
|
||||||
|
const latestDescription = await mutateDescriptionYJS();
|
||||||
|
// return if there are no updates
|
||||||
|
if (descriptionUpdates.length <= 0) {
|
||||||
|
setIsSubmitting("saved");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// merge the updates array into one single update
|
||||||
|
const mergedUpdates = mergeUpdates(descriptionUpdates);
|
||||||
|
await applyUpdatesAndSave(latestDescription, mergedUpdates);
|
||||||
|
// reset the updates array to empty
|
||||||
|
setDescriptionUpdates([]);
|
||||||
|
} catch (error) {
|
||||||
|
setIsSubmitting("saved");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
descriptionUpdates,
|
||||||
|
editorRef,
|
||||||
|
isContentEditable,
|
||||||
|
mutateDescriptionYJS,
|
||||||
|
pageId,
|
||||||
|
projectId,
|
||||||
|
setIsSubmitting,
|
||||||
|
updateDescription,
|
||||||
|
workspaceSlug,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// auto-save updates every 10 seconds
|
||||||
|
// handle ctrl/cmd + S to save the description
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId = setInterval(handleSaveDescription, AUTO_SAVE_TIME);
|
||||||
|
|
||||||
|
const handleSave = (e: KeyboardEvent) => {
|
||||||
|
const { ctrlKey, metaKey, key } = e;
|
||||||
|
const cmdClicked = ctrlKey || metaKey;
|
||||||
|
|
||||||
|
if (cmdClicked && key.toLowerCase() === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSaveDescription();
|
||||||
|
|
||||||
|
// reset interval timer
|
||||||
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleSave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
window.removeEventListener("keydown", handleSave);
|
||||||
|
};
|
||||||
|
}, [handleSaveDescription]);
|
||||||
|
|
||||||
|
// show a confirm dialog if there are any unsaved changes, or saving is going on
|
||||||
|
const { setShowAlert } = useReloadConfirmations(descriptionUpdates.length > 0 || isSubmitting === "submitting");
|
||||||
|
useEffect(() => {
|
||||||
|
if (descriptionUpdates.length > 0 || isSubmitting === "submitting") setShowAlert(true);
|
||||||
|
else setShowAlert(false);
|
||||||
|
}, [descriptionUpdates, isSubmitting, setShowAlert]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleDescriptionChange,
|
||||||
|
isDescriptionReady,
|
||||||
|
pageDescriptionYJS,
|
||||||
|
};
|
||||||
|
};
|
@ -1,8 +1,7 @@
|
|||||||
import { ReactElement, useEffect, useRef, useState } from "react";
|
import { ReactElement, useRef, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
// document-editor
|
// document-editor
|
||||||
import { EditorRefApi, useEditorMarkings } from "@plane/document-editor";
|
import { EditorRefApi, useEditorMarkings } from "@plane/document-editor";
|
||||||
@ -38,38 +37,24 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
const { workspaceSlug, projectId, pageId } = router.query;
|
const { workspaceSlug, projectId, pageId } = router.query;
|
||||||
// store hooks
|
// store hooks
|
||||||
const { createPage, getPageById } = useProjectPages(projectId?.toString() ?? "");
|
const { createPage, getPageById } = useProjectPages(projectId?.toString() ?? "");
|
||||||
const pageStore = usePage(pageId?.toString() ?? "");
|
const page = usePage(pageId?.toString() ?? "");
|
||||||
|
const { description_html, id, name } = page;
|
||||||
// editor markings hook
|
// editor markings hook
|
||||||
const { markings, updateMarkings } = useEditorMarkings();
|
const { markings, updateMarkings } = useEditorMarkings();
|
||||||
// form info
|
// fetch page details
|
||||||
const { handleSubmit, getValues, control } = useForm<TPage>({
|
const { error: pageDetailsError } = useSWR(
|
||||||
defaultValues: {
|
pageId ? `PAGE_DETAILS_${pageId}` : null,
|
||||||
name: "",
|
pageId ? () => getPageById(pageId.toString()) : null,
|
||||||
description_html: "",
|
{
|
||||||
},
|
revalidateIfStale: false,
|
||||||
});
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: false,
|
||||||
// fetching page details
|
}
|
||||||
const {
|
|
||||||
data: swrPageDetails,
|
|
||||||
isValidating,
|
|
||||||
error: pageDetailsError,
|
|
||||||
} = useSWR(pageId ? `PAGE_DETAILS_${pageId}` : null, pageId ? () => getPageById(pageId.toString()) : null, {
|
|
||||||
revalidateIfStale: false,
|
|
||||||
revalidateOnFocus: true,
|
|
||||||
revalidateOnReconnect: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
if (pageStore.cleanup) pageStore.cleanup();
|
|
||||||
},
|
|
||||||
[pageStore]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if ((!pageStore || !pageStore.id) && !pageDetailsError)
|
if ((!page || !id) && !pageDetailsError)
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full grid place-items-center">
|
<div className="size-full grid place-items-center">
|
||||||
<LogoSpinner />
|
<LogoSpinner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -90,28 +75,12 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// we need to get the values of title and description from the page store but we don't have to subscribe to those values
|
|
||||||
const pageTitle = pageStore?.name;
|
|
||||||
|
|
||||||
const handleCreatePage = async (payload: Partial<TPage>) => await createPage(payload);
|
const handleCreatePage = async (payload: Partial<TPage>) => await createPage(payload);
|
||||||
|
|
||||||
const handleUpdatePage = async (formData: TPage) => {
|
|
||||||
let updatedDescription = formData.description_html;
|
|
||||||
if (!updatedDescription || updatedDescription.trim() === "") updatedDescription = "<p></p>";
|
|
||||||
pageStore.updateDescription(updatedDescription);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDuplicatePage = async () => {
|
const handleDuplicatePage = async () => {
|
||||||
const currentPageValues = getValues();
|
|
||||||
|
|
||||||
if (!currentPageValues?.description_html) {
|
|
||||||
// TODO: We need to get latest data the above variable will give us stale data
|
|
||||||
currentPageValues.description_html = pageStore.description_html;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData: Partial<TPage> = {
|
const formData: Partial<TPage> = {
|
||||||
name: "Copy of " + pageStore.name,
|
name: "Copy of " + name,
|
||||||
description_html: currentPageValues.description_html,
|
description_html: description_html ?? "<p></p>",
|
||||||
};
|
};
|
||||||
|
|
||||||
await handleCreatePage(formData)
|
await handleCreatePage(formData)
|
||||||
@ -127,7 +96,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead title={pageTitle} />
|
<PageHead title={name} />
|
||||||
<div className="flex h-full flex-col justify-between">
|
<div className="flex h-full flex-col justify-between">
|
||||||
<div className="h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
|
<div className="h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
|
||||||
{projectId && (
|
{projectId && (
|
||||||
@ -137,24 +106,20 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
editorReady={editorReady}
|
editorReady={editorReady}
|
||||||
readOnlyEditorReady={readOnlyEditorReady}
|
readOnlyEditorReady={readOnlyEditorReady}
|
||||||
handleDuplicatePage={handleDuplicatePage}
|
handleDuplicatePage={handleDuplicatePage}
|
||||||
isSyncing={isValidating}
|
|
||||||
markings={markings}
|
markings={markings}
|
||||||
pageStore={pageStore}
|
page={page}
|
||||||
projectId={projectId.toString()}
|
projectId={projectId.toString()}
|
||||||
sidePeekVisible={sidePeekVisible}
|
sidePeekVisible={sidePeekVisible}
|
||||||
setSidePeekVisible={(state) => setSidePeekVisible(state)}
|
setSidePeekVisible={(state) => setSidePeekVisible(state)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PageEditorBody
|
<PageEditorBody
|
||||||
swrPageDetails={swrPageDetails}
|
|
||||||
control={control}
|
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
handleEditorReady={(val) => setEditorReady(val)}
|
handleEditorReady={(val) => setEditorReady(val)}
|
||||||
readOnlyEditorRef={readOnlyEditorRef}
|
readOnlyEditorRef={readOnlyEditorRef}
|
||||||
handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)}
|
handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)}
|
||||||
handleSubmit={() => handleSubmit(handleUpdatePage)()}
|
|
||||||
markings={markings}
|
markings={markings}
|
||||||
pageStore={pageStore}
|
page={page}
|
||||||
sidePeekVisible={sidePeekVisible}
|
sidePeekVisible={sidePeekVisible}
|
||||||
updateMarkings={updateMarkings}
|
updateMarkings={updateMarkings}
|
||||||
/>
|
/>
|
||||||
|
@ -31,8 +31,11 @@ export abstract class APIService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(url: string, params = {}) {
|
get(url: string, params = {}, config = {}) {
|
||||||
return this.axiosInstance.get(url, params);
|
return this.axiosInstance.get(url, {
|
||||||
|
...params,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
post(url: string, data = {}, config = {}) {
|
post(url: string, data = {}, config = {}) {
|
||||||
|
@ -119,4 +119,33 @@ export class PageService extends APIService {
|
|||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchDescriptionYJS(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
|
||||||
|
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
},
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
})
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDescriptionYJS(
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
pageId: string,
|
||||||
|
data: {
|
||||||
|
description_binary: string;
|
||||||
|
description_html: string;
|
||||||
|
}
|
||||||
|
): Promise<any> {
|
||||||
|
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,12 +9,11 @@ import { EUserProjectRoles } from "@/constants/project";
|
|||||||
import { PageService } from "@/services/page.service";
|
import { PageService } from "@/services/page.service";
|
||||||
import { RootStore } from "../root.store";
|
import { RootStore } from "../root.store";
|
||||||
|
|
||||||
export type TLoader = "submitting" | "submitted" | "saved" | undefined;
|
export type TLoader = "submitting" | "submitted" | "saved";
|
||||||
|
|
||||||
export interface IPageStore extends TPage {
|
export interface IPageStore extends TPage {
|
||||||
// observables
|
// observables
|
||||||
isSubmitting: "submitting" | "submitted" | "saved";
|
isSubmitting: TLoader;
|
||||||
loader: TLoader;
|
|
||||||
// computed
|
// computed
|
||||||
asJSON: TPage | undefined;
|
asJSON: TPage | undefined;
|
||||||
isCurrentUserOwner: boolean; // it will give the user is the owner of the page or not
|
isCurrentUserOwner: boolean; // it will give the user is the owner of the page or not
|
||||||
@ -27,12 +26,12 @@ export interface IPageStore extends TPage {
|
|||||||
isContentEditable: boolean;
|
isContentEditable: boolean;
|
||||||
// helpers
|
// helpers
|
||||||
oldName: string;
|
oldName: string;
|
||||||
updateTitle: (name: string) => void;
|
setIsSubmitting: (value: TLoader) => void;
|
||||||
updateDescription: (description: string) => void;
|
|
||||||
setIsSubmitting: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
// actions
|
// actions
|
||||||
update: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
|
update: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
|
||||||
|
updateTitle: (title: string) => void;
|
||||||
|
updateDescription: (binaryString: string, descriptionHTML: string) => Promise<void>;
|
||||||
makePublic: () => Promise<void>;
|
makePublic: () => Promise<void>;
|
||||||
makePrivate: () => Promise<void>;
|
makePrivate: () => Promise<void>;
|
||||||
lock: () => Promise<void>;
|
lock: () => Promise<void>;
|
||||||
@ -45,8 +44,7 @@ export interface IPageStore extends TPage {
|
|||||||
|
|
||||||
export class PageStore implements IPageStore {
|
export class PageStore implements IPageStore {
|
||||||
// loaders
|
// loaders
|
||||||
isSubmitting: "submitting" | "submitted" | "saved" = "saved";
|
isSubmitting: TLoader = "saved";
|
||||||
loader: TLoader = undefined;
|
|
||||||
// page properties
|
// page properties
|
||||||
id: string | undefined;
|
id: string | undefined;
|
||||||
name: string | undefined;
|
name: string | undefined;
|
||||||
@ -68,7 +66,7 @@ export class PageStore implements IPageStore {
|
|||||||
oldName: string = "";
|
oldName: string = "";
|
||||||
// reactions
|
// reactions
|
||||||
disposers: Array<() => void> = [];
|
disposers: Array<() => void> = [];
|
||||||
// service
|
// services
|
||||||
pageService: PageService;
|
pageService: PageService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -96,7 +94,6 @@ export class PageStore implements IPageStore {
|
|||||||
makeObservable(this, {
|
makeObservable(this, {
|
||||||
// loaders
|
// loaders
|
||||||
isSubmitting: observable.ref,
|
isSubmitting: observable.ref,
|
||||||
loader: observable.ref,
|
|
||||||
// page properties
|
// page properties
|
||||||
id: observable.ref,
|
id: observable.ref,
|
||||||
name: observable.ref,
|
name: observable.ref,
|
||||||
@ -115,7 +112,9 @@ export class PageStore implements IPageStore {
|
|||||||
created_at: observable.ref,
|
created_at: observable.ref,
|
||||||
updated_at: observable.ref,
|
updated_at: observable.ref,
|
||||||
// helpers
|
// helpers
|
||||||
oldName: observable,
|
oldName: observable.ref,
|
||||||
|
setIsSubmitting: action,
|
||||||
|
cleanup: action,
|
||||||
// computed
|
// computed
|
||||||
asJSON: computed,
|
asJSON: computed,
|
||||||
isCurrentUserOwner: computed,
|
isCurrentUserOwner: computed,
|
||||||
@ -126,13 +125,10 @@ export class PageStore implements IPageStore {
|
|||||||
canCurrentUserArchivePage: computed,
|
canCurrentUserArchivePage: computed,
|
||||||
canCurrentUserDeletePage: computed,
|
canCurrentUserDeletePage: computed,
|
||||||
isContentEditable: computed,
|
isContentEditable: computed,
|
||||||
// helper actions
|
|
||||||
updateTitle: action,
|
|
||||||
updateDescription: action.bound,
|
|
||||||
setIsSubmitting: action,
|
|
||||||
cleanup: action,
|
|
||||||
// actions
|
// actions
|
||||||
update: action,
|
update: action,
|
||||||
|
updateTitle: action,
|
||||||
|
updateDescription: action,
|
||||||
makePublic: action,
|
makePublic: action,
|
||||||
makePrivate: action,
|
makePrivate: action,
|
||||||
lock: action,
|
lock: action,
|
||||||
@ -169,27 +165,7 @@ export class PageStore implements IPageStore {
|
|||||||
{ delay: 2000 }
|
{ delay: 2000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
const descriptionDisposer = reaction(
|
this.disposers.push(titleDisposer);
|
||||||
() => this.description_html,
|
|
||||||
(description_html) => {
|
|
||||||
//TODO: Fix reaction to only run when the data is changed, not when the page is loaded
|
|
||||||
const { workspaceSlug, projectId } = this.store.router;
|
|
||||||
if (!workspaceSlug || !projectId || !this.id) return;
|
|
||||||
this.isSubmitting = "submitting";
|
|
||||||
this.pageService
|
|
||||||
.update(workspaceSlug, projectId, this.id, {
|
|
||||||
description_html,
|
|
||||||
})
|
|
||||||
.finally(() =>
|
|
||||||
runInAction(() => {
|
|
||||||
this.isSubmitting = "submitted";
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ delay: 3000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
this.disposers.push(titleDisposer, descriptionDisposer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// computed
|
// computed
|
||||||
@ -284,24 +260,21 @@ export class PageStore implements IPageStore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTitle = action("updateTitle", (name: string) => {
|
/**
|
||||||
this.oldName = this.name ?? "";
|
* @description update the submitting state
|
||||||
this.name = name;
|
* @param value
|
||||||
});
|
*/
|
||||||
|
setIsSubmitting = (value: TLoader) => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.isSubmitting = value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
updateDescription = action("updateDescription", (description_html: string) => {
|
cleanup = () => {
|
||||||
this.description_html = description_html;
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsSubmitting = action("setIsSubmitting", (isSubmitting: "submitting" | "submitted" | "saved") => {
|
|
||||||
this.isSubmitting = isSubmitting;
|
|
||||||
});
|
|
||||||
|
|
||||||
cleanup = action("cleanup", () => {
|
|
||||||
this.disposers.forEach((disposer) => {
|
this.disposers.forEach((disposer) => {
|
||||||
disposer();
|
disposer();
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description update the page
|
* @description update the page
|
||||||
@ -313,14 +286,14 @@ export class PageStore implements IPageStore {
|
|||||||
|
|
||||||
const currentPage = this.asJSON;
|
const currentPage = this.asJSON;
|
||||||
try {
|
try {
|
||||||
const currentPageResponse = await this.pageService.update(workspaceSlug, projectId, this.id, currentPage);
|
runInAction(() => {
|
||||||
if (currentPageResponse)
|
Object.keys(pageData).forEach((key) => {
|
||||||
runInAction(() => {
|
const currentPageKey = key as keyof TPage;
|
||||||
Object.keys(pageData).forEach((key) => {
|
set(this, key, pageData[currentPageKey] || undefined);
|
||||||
const currentPageKey = key as keyof TPage;
|
|
||||||
set(this, key, currentPageResponse?.[currentPageKey] || undefined);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.pageService.update(workspaceSlug, projectId, this.id, currentPage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
Object.keys(pageData).forEach((key) => {
|
Object.keys(pageData).forEach((key) => {
|
||||||
@ -332,6 +305,42 @@ export class PageStore implements IPageStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description update the page title
|
||||||
|
* @param title
|
||||||
|
*/
|
||||||
|
updateTitle = (title: string) => {
|
||||||
|
this.oldName = this.name ?? "";
|
||||||
|
this.name = title;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description update the page description
|
||||||
|
* @param {string} binaryString
|
||||||
|
* @param {string} descriptionHTML
|
||||||
|
*/
|
||||||
|
updateDescription = async (binaryString: string, descriptionHTML: string) => {
|
||||||
|
const { workspaceSlug, projectId } = this.store.router;
|
||||||
|
if (!workspaceSlug || !projectId || !this.id) return undefined;
|
||||||
|
|
||||||
|
const currentDescription = this.description_html;
|
||||||
|
runInAction(() => {
|
||||||
|
this.description_html = descriptionHTML;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.pageService.updateDescriptionYJS(workspaceSlug, projectId, this.id, {
|
||||||
|
description_binary: binaryString,
|
||||||
|
description_html: descriptionHTML,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
runInAction(() => {
|
||||||
|
this.description_html = currentDescription;
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description make the page public
|
* @description make the page public
|
||||||
*/
|
*/
|
||||||
|
45
yarn.lock
45
yarn.lock
@ -3496,6 +3496,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.4.0.tgz#3a9fed3585bf49f445505c2e9ad71fd66e117304"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.4.0.tgz#3a9fed3585bf49f445505c2e9ad71fd66e117304"
|
||||||
integrity sha512-wjhBukuiyJMq4cTcK3RBTzUPV24k5n1eEPlpmzku6ThwwkMdwynnMGMAmSF3fErh3AOyOUPoTTjgMYN2d10SJA==
|
integrity sha512-wjhBukuiyJMq4cTcK3RBTzUPV24k5n1eEPlpmzku6ThwwkMdwynnMGMAmSF3fErh3AOyOUPoTTjgMYN2d10SJA==
|
||||||
|
|
||||||
|
"@tiptap/extension-collaboration@^2.3.2":
|
||||||
|
version "2.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-collaboration/-/extension-collaboration-2.4.0.tgz#d830694ac61a4b9857ffb77f24585e13a9cd6a0c"
|
||||||
|
integrity sha512-achU+GU9tqxn3zsU61CbwWrCausf0U23MJIpo8vnywOIx6E955by6okHEHoUazLIGVFXVc5DBzBP7bf+Snzk0Q==
|
||||||
|
|
||||||
"@tiptap/extension-document@^2.4.0":
|
"@tiptap/extension-document@^2.4.0":
|
||||||
version "2.4.0"
|
version "2.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.4.0.tgz#a396b2cbcc8708aa2a0a41d0be481fda4b61c77b"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.4.0.tgz#a396b2cbcc8708aa2a0a41d0be481fda4b61c77b"
|
||||||
@ -8341,6 +8346,11 @@ isobject@^3.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
|
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
|
||||||
integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==
|
integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==
|
||||||
|
|
||||||
|
isomorphic.js@^0.2.4:
|
||||||
|
version "0.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/isomorphic.js/-/isomorphic.js-0.2.5.tgz#13eecf36f2dba53e85d355e11bf9d4208c6f7f88"
|
||||||
|
integrity sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==
|
||||||
|
|
||||||
iterator.prototype@^1.1.2:
|
iterator.prototype@^1.1.2:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.2.tgz#5e29c8924f01916cb9335f1ff80619dcff22b0c0"
|
resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.2.tgz#5e29c8924f01916cb9335f1ff80619dcff22b0c0"
|
||||||
@ -8605,6 +8615,13 @@ levn@^0.4.1:
|
|||||||
prelude-ls "^1.2.1"
|
prelude-ls "^1.2.1"
|
||||||
type-check "~0.4.0"
|
type-check "~0.4.0"
|
||||||
|
|
||||||
|
lib0@^0.2.42, lib0@^0.2.74, lib0@^0.2.85, lib0@^0.2.86:
|
||||||
|
version "0.2.93"
|
||||||
|
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.93.tgz#95487c2a97657313cb1d91fbcf9f6d64b7fcd062"
|
||||||
|
integrity sha512-M5IKsiFJYulS+8Eal8f+zAqf5ckm1vffW0fFDxfgxJ+uiVopvDdd3PxJmz0GsVi3YNO7QCFSq0nAsiDmNhLj9Q==
|
||||||
|
dependencies:
|
||||||
|
isomorphic.js "^0.2.4"
|
||||||
|
|
||||||
lie@3.1.1:
|
lie@3.1.1:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
|
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
|
||||||
@ -13152,6 +13169,27 @@ xtend@~4.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
||||||
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
||||||
|
|
||||||
|
y-indexeddb@^9.0.12:
|
||||||
|
version "9.0.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/y-indexeddb/-/y-indexeddb-9.0.12.tgz#73657f31d52886d7532256610babf5cca4ad5e58"
|
||||||
|
integrity sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==
|
||||||
|
dependencies:
|
||||||
|
lib0 "^0.2.74"
|
||||||
|
|
||||||
|
y-prosemirror@^1.2.5:
|
||||||
|
version "1.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/y-prosemirror/-/y-prosemirror-1.2.5.tgz#c448f80a6017190bc69a30a33f3930e9924fad3a"
|
||||||
|
integrity sha512-T/JATxC8P2Dbvq/dAiaiztD1a8KEwRP8oLRlT8YlaZdNlLGE1Ea0IJ8If25UlDYmk+4+uqLbqT/S+dzUmwwgbA==
|
||||||
|
dependencies:
|
||||||
|
lib0 "^0.2.42"
|
||||||
|
|
||||||
|
y-protocols@^1.0.6:
|
||||||
|
version "1.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.6.tgz#66dad8a95752623443e8e28c0e923682d2c0d495"
|
||||||
|
integrity sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==
|
||||||
|
dependencies:
|
||||||
|
lib0 "^0.2.85"
|
||||||
|
|
||||||
y18n@^5.0.5:
|
y18n@^5.0.5:
|
||||||
version "5.0.8"
|
version "5.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
|
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
|
||||||
@ -13195,6 +13233,13 @@ yargs@^17.0.0:
|
|||||||
y18n "^5.0.5"
|
y18n "^5.0.5"
|
||||||
yargs-parser "^21.1.1"
|
yargs-parser "^21.1.1"
|
||||||
|
|
||||||
|
yjs@^13.6.15:
|
||||||
|
version "13.6.15"
|
||||||
|
resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.6.15.tgz#5a2402632aabf83e5baf56342b4c82fe40859306"
|
||||||
|
integrity sha512-moFv4uNYhp8BFxIk3AkpoAnnjts7gwdpiG8RtyFiKbMtxKCS0zVZ5wPaaGpwC3V2N/K8TK8MwtSI3+WO9CHWjQ==
|
||||||
|
dependencies:
|
||||||
|
lib0 "^0.2.86"
|
||||||
|
|
||||||
yocto-queue@^0.1.0:
|
yocto-queue@^0.1.0:
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||||
|
Loading…
Reference in New Issue
Block a user