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()
|
||||
|
||||
class Meta(PageSerializer.Meta):
|
||||
fields = PageSerializer.Meta.fields + ["description_html"]
|
||||
fields = PageSerializer.Meta.fields + [
|
||||
"description_html",
|
||||
]
|
||||
|
||||
|
||||
class SubPageSerializer(BaseSerializer):
|
||||
|
@ -6,6 +6,7 @@ from plane.app.views import (
|
||||
PageFavoriteViewSet,
|
||||
PageLogEndpoint,
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
)
|
||||
|
||||
|
||||
@ -79,4 +80,14 @@ urlpatterns = [
|
||||
SubPagesEndpoint.as_view(),
|
||||
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,
|
||||
PageLogEndpoint,
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
)
|
||||
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
|
@ -1,5 +1,6 @@
|
||||
# Python imports
|
||||
import json
|
||||
import base64
|
||||
from datetime import datetime
|
||||
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.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.http import StreamingHttpResponse
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@ -388,3 +390,48 @@ class SubPagesEndpoint(BaseAPIView):
|
||||
return Response(
|
||||
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):
|
||||
name = models.CharField(max_length=255, 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_stripped = models.TextField(blank=True, null=True)
|
||||
owned_by = models.ForeignKey(
|
||||
@ -43,7 +44,6 @@ class Page(ProjectBaseModel):
|
||||
is_locked = models.BooleanField(default=False)
|
||||
view_props = models.JSONField(default=get_view_props)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
description_binary = models.BinaryField(null=True)
|
||||
|
||||
class Meta:
|
||||
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 { 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;
|
||||
uploadFile: UploadImage;
|
||||
restoreFile: RestoreImage;
|
||||
deleteFile: DeleteImage;
|
||||
cancelUploadImage?: () => void;
|
||||
initialValue: string;
|
||||
fileHandler: TFileHandler;
|
||||
initialValue?: string;
|
||||
editorClassName: string;
|
||||
// undefined when prop is not passed, null if intentionally passed to stop
|
||||
// swr syncing
|
||||
value: string | null | undefined;
|
||||
value?: string | null | undefined;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
@ -38,19 +42,16 @@ interface CustomEditorProps {
|
||||
}
|
||||
|
||||
export const useEditor = ({
|
||||
uploadFile,
|
||||
id = "",
|
||||
deleteFile,
|
||||
cancelUploadImage,
|
||||
editorProps = {},
|
||||
initialValue,
|
||||
editorClassName,
|
||||
value,
|
||||
extensions = [],
|
||||
fileHandler,
|
||||
onChange,
|
||||
forwardedRef,
|
||||
tabIndex,
|
||||
restoreFile,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
@ -67,10 +68,10 @@ export const useEditor = ({
|
||||
mentionHighlights: mentionHandler.highlights ?? [],
|
||||
},
|
||||
fileConfig: {
|
||||
deleteFile,
|
||||
restoreFile,
|
||||
cancelUploadImage,
|
||||
uploadFile,
|
||||
uploadFile: fileHandler.upload,
|
||||
deleteFile: fileHandler.delete,
|
||||
restoreFile: fileHandler.restore,
|
||||
cancelUploadImage: fileHandler.cancel,
|
||||
},
|
||||
placeholder,
|
||||
tabIndex,
|
||||
@ -139,7 +140,7 @@ export const useEditor = ({
|
||||
}
|
||||
},
|
||||
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);
|
||||
|
||||
@ -155,7 +156,7 @@ export const useEditor = ({
|
||||
}
|
||||
},
|
||||
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 item = getEditorMenuItem(itemName);
|
||||
@ -177,6 +178,10 @@ export const useEditor = ({
|
||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
},
|
||||
getHTML: (): string => {
|
||||
const htmlOutput = editorRef.current?.getHTML() ?? "<p></p>";
|
||||
return htmlOutput;
|
||||
},
|
||||
scrollSummary: (marking: IMarking): void => {
|
||||
if (!editorRef.current) return;
|
||||
scrollSummary(editorRef.current, marking);
|
||||
@ -199,7 +204,7 @@ export const useEditor = ({
|
||||
}
|
||||
},
|
||||
}),
|
||||
[editorRef, savedSelection, uploadFile]
|
||||
[editorRef, savedSelection, fileHandler.upload]
|
||||
);
|
||||
|
||||
if (!editor) {
|
||||
|
@ -68,6 +68,10 @@ export const useReadOnlyEditor = ({
|
||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
},
|
||||
getHTML: (): string => {
|
||||
const htmlOutput = editorRef.current?.getHTML() ?? "<p></p>";
|
||||
return htmlOutput;
|
||||
},
|
||||
scrollSummary: (marking: IMarking): void => {
|
||||
if (!editorRef.current) return;
|
||||
scrollSummary(editorRef.current, marking);
|
||||
|
@ -24,6 +24,7 @@ export * from "src/ui/menus/menu-items";
|
||||
export * from "src/lib/editor-commands";
|
||||
|
||||
// types
|
||||
export type { CustomEditorProps, TFileHandler } from "src/hooks/use-editor";
|
||||
export type { DeleteImage } from "src/types/delete-image";
|
||||
export type { UploadImage } from "src/types/upload-image";
|
||||
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 { clsx, type ClassValue } from "clsx";
|
||||
import { CoreEditorExtensionsWithoutProps } from "src/ui/extensions/core-without-props";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
interface EditorClassNames {
|
||||
noBorder?: boolean;
|
||||
@ -58,3 +60,20 @@ export const isValidHttpUrl = (string: string): boolean => {
|
||||
|
||||
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 = {
|
||||
getMarkDown: () => string;
|
||||
getHTML: () => string;
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => 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": "*",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"@tiptap/extension-collaboration": "^2.3.2",
|
||||
"@tiptap/pm": "^2.1.13",
|
||||
"@tiptap/suggestion": "^2.1.13",
|
||||
"lucide-react": "^0.378.0",
|
||||
"react-popper": "^2.3.0",
|
||||
"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": {
|
||||
"@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
|
||||
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";
|
||||
|
||||
|
@ -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 { UploadImage } from "@plane/editor-core";
|
||||
import { CollaborationProvider } from "src/providers/collaboration-provider";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
|
||||
type TArguments = {
|
||||
uploadFile: UploadImage;
|
||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||
provider: CollaborationProvider;
|
||||
};
|
||||
|
||||
export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle }: TArguments) => [
|
||||
export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle, provider }: TArguments) => [
|
||||
SlashCommand(uploadFile),
|
||||
DragAndDrop(setHideDragHandle),
|
||||
IssueWidgetPlaceholder(),
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
}),
|
||||
];
|
||||
|
@ -1,30 +1,25 @@
|
||||
import React, { useState } from "react";
|
||||
// editor-core
|
||||
import {
|
||||
UploadImage,
|
||||
DeleteImage,
|
||||
RestoreImage,
|
||||
getEditorClassNames,
|
||||
useEditor,
|
||||
EditorRefApi,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TFileHandler,
|
||||
} from "@plane/editor-core";
|
||||
import { DocumentEditorExtensions } from "src/ui/extensions";
|
||||
// components
|
||||
import { PageRenderer } from "src/ui/components/page-renderer";
|
||||
// hooks
|
||||
import { useDocumentEditor } from "src/hooks/use-document-editor";
|
||||
|
||||
interface IDocumentEditor {
|
||||
initialValue: string;
|
||||
value?: string;
|
||||
fileHandler: {
|
||||
cancel: () => void;
|
||||
delete: DeleteImage;
|
||||
upload: UploadImage;
|
||||
restore: RestoreImage;
|
||||
};
|
||||
id: string;
|
||||
value: Uint8Array;
|
||||
fileHandler: TFileHandler;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
onChange: (json: object, html: string) => void;
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
@ -37,7 +32,7 @@ interface IDocumentEditor {
|
||||
const DocumentEditor = (props: IDocumentEditor) => {
|
||||
const {
|
||||
onChange,
|
||||
initialValue,
|
||||
id,
|
||||
value,
|
||||
fileHandler,
|
||||
containerClassName,
|
||||
@ -50,32 +45,24 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
||||
} = props;
|
||||
// states
|
||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
|
||||
|
||||
// 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
|
||||
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
||||
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
||||
};
|
||||
// use editor
|
||||
const editor = useEditor({
|
||||
onChange(json, html) {
|
||||
onChange(json, html);
|
||||
},
|
||||
|
||||
// use document editor
|
||||
const editor = useDocumentEditor({
|
||||
id,
|
||||
editorClassName,
|
||||
restoreFile: fileHandler.restore,
|
||||
uploadFile: fileHandler.upload,
|
||||
deleteFile: fileHandler.delete,
|
||||
cancelUploadImage: fileHandler.cancel,
|
||||
initialValue,
|
||||
fileHandler,
|
||||
value,
|
||||
onChange,
|
||||
handleEditorReady,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
extensions: DocumentEditorExtensions({
|
||||
uploadFile: fileHandler.upload,
|
||||
setHideDragHandle: setHideDragHandleFunction,
|
||||
}),
|
||||
placeholder,
|
||||
setHideDragHandleFunction,
|
||||
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";
|
||||
// editor-core
|
||||
import {
|
||||
UploadImage,
|
||||
DeleteImage,
|
||||
IMentionSuggestion,
|
||||
RestoreImage,
|
||||
EditorContainer,
|
||||
EditorContentWrapper,
|
||||
getEditorClassNames,
|
||||
useEditor,
|
||||
IMentionHighlight,
|
||||
EditorRefApi,
|
||||
TFileHandler,
|
||||
} from "@plane/editor-core";
|
||||
// extensions
|
||||
import { LiteTextEditorExtensions } from "src/ui/extensions";
|
||||
|
||||
export interface ILiteTextEditor {
|
||||
initialValue: string;
|
||||
value?: string | null;
|
||||
fileHandler: {
|
||||
cancel: () => void;
|
||||
delete: DeleteImage;
|
||||
upload: UploadImage;
|
||||
restore: RestoreImage;
|
||||
};
|
||||
fileHandler: TFileHandler;
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
@ -58,10 +53,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
value,
|
||||
id,
|
||||
editorClassName,
|
||||
restoreFile: fileHandler.restore,
|
||||
uploadFile: fileHandler.upload,
|
||||
deleteFile: fileHandler.delete,
|
||||
cancelUploadImage: fileHandler.cancel,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
extensions: LiteTextEditorExtensions(onEnterKeyPress),
|
||||
mentionHandler,
|
||||
|
@ -1,30 +1,26 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
// editor-core
|
||||
import {
|
||||
DeleteImage,
|
||||
EditorContainer,
|
||||
EditorContentWrapper,
|
||||
getEditorClassNames,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
RestoreImage,
|
||||
UploadImage,
|
||||
useEditor,
|
||||
EditorRefApi,
|
||||
TFileHandler,
|
||||
} from "@plane/editor-core";
|
||||
import * as React from "react";
|
||||
// extensions
|
||||
import { RichTextEditorExtensions } from "src/ui/extensions";
|
||||
// components
|
||||
import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
|
||||
|
||||
export type IRichTextEditor = {
|
||||
initialValue: string;
|
||||
value?: string | null;
|
||||
dragDropEnabled?: boolean;
|
||||
fileHandler: {
|
||||
cancel: () => void;
|
||||
delete: DeleteImage;
|
||||
upload: UploadImage;
|
||||
restore: RestoreImage;
|
||||
};
|
||||
fileHandler: TFileHandler;
|
||||
id?: string;
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
@ -67,10 +63,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const editor = useEditor({
|
||||
id,
|
||||
editorClassName,
|
||||
restoreFile: fileHandler.restore,
|
||||
uploadFile: fileHandler.upload,
|
||||
deleteFile: fileHandler.delete,
|
||||
cancelUploadImage: fileHandler.cancel,
|
||||
fileHandler,
|
||||
onChange,
|
||||
initialValue,
|
||||
value,
|
||||
|
@ -1,30 +1,26 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { FileText } from "lucide-react";
|
||||
// hooks
|
||||
// ui
|
||||
import { Breadcrumbs, Button } from "@plane/ui";
|
||||
// helpers
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
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 {
|
||||
showButton?: boolean;
|
||||
}
|
||||
|
||||
export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
|
||||
const { showButton = false } = props;
|
||||
export const PageDetailsHeader = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, pageId } = router.query;
|
||||
// store hooks
|
||||
const { toggleCreatePageModal } = useCommandPalette();
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
const { name } = usePage(pageId?.toString() ?? "");
|
||||
const { isContentEditable, isSubmitting, name } = usePage(pageId?.toString() ?? "");
|
||||
// use platform
|
||||
const { platform } = usePlatformOS();
|
||||
// derived values
|
||||
const isMac = platform === "MacOS";
|
||||
|
||||
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">
|
||||
@ -77,12 +73,24 @@ export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
{showButton && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={() => toggleCreatePageModal(true)}>
|
||||
Add Page
|
||||
</Button>
|
||||
</div>
|
||||
{isContentEditable && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// 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>
|
||||
);
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
// document editor
|
||||
// document-editor
|
||||
import {
|
||||
DocumentEditorWithRef,
|
||||
DocumentReadOnlyEditorWithRef,
|
||||
@ -11,15 +10,15 @@ import {
|
||||
IMarking,
|
||||
} from "@plane/document-editor";
|
||||
// types
|
||||
import { IUserLite, TPage } from "@plane/types";
|
||||
import { IUserLite } from "@plane/types";
|
||||
// components
|
||||
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
|
||||
import { usePageDescription } from "@/hooks/use-page-description";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
// store
|
||||
@ -28,13 +27,10 @@ import { IPageStore } from "@/store/pages/page.store";
|
||||
const fileService = new FileService();
|
||||
|
||||
type Props = {
|
||||
control: Control<TPage, any>;
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
swrPageDetails: TPage | undefined;
|
||||
handleSubmit: () => void;
|
||||
markings: IMarking[];
|
||||
pageStore: IPageStore;
|
||||
page: IPageStore;
|
||||
sidePeekVisible: boolean;
|
||||
handleEditorReady: (value: boolean) => void;
|
||||
handleReadOnlyEditorReady: (value: boolean) => void;
|
||||
@ -43,15 +39,12 @@ type Props = {
|
||||
|
||||
export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
control,
|
||||
handleReadOnlyEditorReady,
|
||||
handleEditorReady,
|
||||
editorRef,
|
||||
markings,
|
||||
readOnlyEditorRef,
|
||||
handleSubmit,
|
||||
pageStore,
|
||||
swrPageDetails,
|
||||
page,
|
||||
sidePeekVisible,
|
||||
updateMarkings,
|
||||
} = props;
|
||||
@ -67,11 +60,19 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
} = useMember();
|
||||
// derived values
|
||||
const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : "";
|
||||
const pageTitle = pageStore?.name ?? "";
|
||||
const pageDescription = pageStore?.description_html;
|
||||
const { description_html, isContentEditable, updateTitle, isSubmitting, setIsSubmitting } = pageStore;
|
||||
const pageId = page?.id;
|
||||
const pageTitle = page?.name ?? "";
|
||||
const pageDescription = page?.description_html;
|
||||
const { isContentEditable, updateTitle, setIsSubmitting } = page;
|
||||
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
|
||||
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
|
||||
// project-description
|
||||
const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS } = usePageDescription({
|
||||
editorRef,
|
||||
page,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
});
|
||||
// use-mention
|
||||
const { mentionHighlights, mentionSuggestions } = useMention({
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
@ -82,13 +83,11 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
// page filters
|
||||
const { isFullWidth } = usePageFilters();
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
|
||||
|
||||
useEffect(() => {
|
||||
updateMarkings(description_html ?? "<p></p>");
|
||||
}, [description_html, updateMarkings]);
|
||||
updateMarkings(pageDescription ?? "<p></p>");
|
||||
}, [pageDescription, updateMarkings]);
|
||||
|
||||
if (pageDescription === undefined) return <PageContentLoader />;
|
||||
if (pageId === undefined || !pageDescriptionYJS || !isDescriptionReady) return <PageContentLoader />;
|
||||
|
||||
return (
|
||||
<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>
|
||||
{isContentEditable ? (
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { onChange } }) => (
|
||||
<DocumentEditorWithRef
|
||||
fileHandler={{
|
||||
cancel: fileService.cancelUpload,
|
||||
delete: fileService.getDeleteImageFunction(workspaceId),
|
||||
restore: fileService.getRestoreImageFunction(workspaceId),
|
||||
upload: fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting),
|
||||
}}
|
||||
handleEditorReady={handleEditorReady}
|
||||
initialValue={pageDescription ?? "<p></p>"}
|
||||
value={swrPageDetails?.description_html ?? "<p></p>"}
|
||||
ref={editorRef}
|
||||
containerClassName="p-0 pb-64"
|
||||
editorClassName="lg:px-10 pl-8"
|
||||
onChange={(_description_json, description_html) => {
|
||||
setIsSubmitting("submitting");
|
||||
setShowAlert(true);
|
||||
onChange(description_html);
|
||||
handleSubmit();
|
||||
}}
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
suggestions: mentionSuggestions,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DocumentEditorWithRef
|
||||
id={pageId}
|
||||
fileHandler={{
|
||||
cancel: fileService.cancelUpload,
|
||||
delete: fileService.getDeleteImageFunction(workspaceId),
|
||||
restore: fileService.getRestoreImageFunction(workspaceId),
|
||||
upload: fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting),
|
||||
}}
|
||||
handleEditorReady={handleEditorReady}
|
||||
value={pageDescriptionYJS}
|
||||
ref={editorRef}
|
||||
containerClassName="p-0 pb-64"
|
||||
editorClassName="pl-10"
|
||||
onChange={handleDescriptionChange}
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
suggestions: mentionSuggestions,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DocumentReadOnlyEditorWithRef
|
||||
@ -158,7 +146,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
initialValue={pageDescription ?? "<p></p>"}
|
||||
handleEditorReady={handleReadOnlyEditorReady}
|
||||
containerClassName="p-0 pb-64 border-none"
|
||||
editorClassName="lg:px-10 pl-8"
|
||||
editorClassName="pl-10"
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
}}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Lock, RefreshCw, Sparkle } from "lucide-react";
|
||||
import { Lock, Sparkle } from "lucide-react";
|
||||
// editor
|
||||
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/document-editor";
|
||||
// ui
|
||||
@ -9,7 +9,6 @@ import { ArchiveIcon } from "@plane/ui";
|
||||
import { GptAssistantPopover } from "@/components/core";
|
||||
import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
@ -19,20 +18,19 @@ import { IPageStore } from "@/store/pages/page.store";
|
||||
type Props = {
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
isSyncing: boolean;
|
||||
pageStore: IPageStore;
|
||||
page: IPageStore;
|
||||
projectId: string;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
};
|
||||
|
||||
export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, handleDuplicatePage, isSyncing, pageStore, projectId, readOnlyEditorRef } = props;
|
||||
const { editorRef, handleDuplicatePage, page, projectId, readOnlyEditorRef } = props;
|
||||
// states
|
||||
const [gptModalOpen, setGptModal] = useState(false);
|
||||
// store hooks
|
||||
const { config } = useInstance();
|
||||
// derived values
|
||||
const { archived_at, isContentEditable, isSubmitting, is_locked } = pageStore;
|
||||
const { archived_at, isContentEditable, is_locked } = page;
|
||||
|
||||
const handleAiAssistance = async (response: string) => {
|
||||
if (!editorRef) return;
|
||||
@ -41,22 +39,6 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<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" />
|
||||
@ -93,11 +75,11 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
className="!min-w-[38rem]"
|
||||
/>
|
||||
)}
|
||||
<PageInfoPopover pageStore={pageStore} />
|
||||
<PageInfoPopover page={page} />
|
||||
<PageOptionsDropdown
|
||||
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
pageStore={pageStore}
|
||||
page={page}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -7,11 +7,11 @@ import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { IPageStore } from "@/store/pages/page.store";
|
||||
|
||||
type Props = {
|
||||
pageStore: IPageStore;
|
||||
page: IPageStore;
|
||||
};
|
||||
|
||||
export const PageInfoPopover: React.FC<Props> = (props) => {
|
||||
const { pageStore } = props;
|
||||
const { page } = props;
|
||||
// states
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
// refs
|
||||
@ -22,7 +22,7 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
|
||||
placement: "bottom-start",
|
||||
});
|
||||
// derived values
|
||||
const { created_at, updated_at } = pageStore;
|
||||
const { created_at, updated_at } = page;
|
||||
|
||||
return (
|
||||
<div onMouseEnter={() => setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)}>
|
||||
|
@ -11,9 +11,8 @@ type Props = {
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
isSyncing: boolean;
|
||||
markings: IMarking[];
|
||||
pageStore: IPageStore;
|
||||
page: IPageStore;
|
||||
projectId: string;
|
||||
sidePeekVisible: boolean;
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
@ -29,14 +28,13 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
markings,
|
||||
readOnlyEditorReady,
|
||||
handleDuplicatePage,
|
||||
isSyncing,
|
||||
pageStore,
|
||||
page,
|
||||
projectId,
|
||||
sidePeekVisible,
|
||||
setSidePeekVisible,
|
||||
} = props;
|
||||
// derived values
|
||||
const { isContentEditable } = pageStore;
|
||||
const { isContentEditable } = page;
|
||||
// page filters
|
||||
const { isFullWidth } = usePageFilters();
|
||||
|
||||
@ -57,8 +55,7 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
<PageExtraOptions
|
||||
editorRef={editorRef}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
isSyncing={isSyncing}
|
||||
pageStore={pageStore}
|
||||
page={page}
|
||||
projectId={projectId}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
/>
|
||||
|
@ -16,11 +16,11 @@ import { IPageStore } from "@/store/pages/page.store";
|
||||
type Props = {
|
||||
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||
handleDuplicatePage: () => void;
|
||||
pageStore: IPageStore;
|
||||
page: IPageStore;
|
||||
};
|
||||
|
||||
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, handleDuplicatePage, pageStore } = props;
|
||||
const { editorRef, handleDuplicatePage, page } = props;
|
||||
// store values
|
||||
const {
|
||||
archived_at,
|
||||
@ -33,7 +33,7 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
canCurrentUserDuplicatePage,
|
||||
canCurrentUserLockPage,
|
||||
restore,
|
||||
} = pageStore;
|
||||
} = page;
|
||||
// store hooks
|
||||
const { workspaceSlug, projectId } = useAppRouter();
|
||||
// page filters
|
||||
|
@ -13,9 +13,8 @@ type Props = {
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
isSyncing: boolean;
|
||||
markings: IMarking[];
|
||||
pageStore: IPageStore;
|
||||
page: IPageStore;
|
||||
projectId: string;
|
||||
sidePeekVisible: boolean;
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
@ -31,14 +30,13 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
markings,
|
||||
readOnlyEditorReady,
|
||||
handleDuplicatePage,
|
||||
isSyncing,
|
||||
pageStore,
|
||||
page,
|
||||
projectId,
|
||||
sidePeekVisible,
|
||||
setSidePeekVisible,
|
||||
} = props;
|
||||
// derived values
|
||||
const { isContentEditable } = pageStore;
|
||||
const { isContentEditable } = page;
|
||||
// page filters
|
||||
const { isFullWidth } = usePageFilters();
|
||||
|
||||
@ -67,8 +65,7 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
<PageExtraOptions
|
||||
editorRef={editorRef}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
isSyncing={isSyncing}
|
||||
pageStore={pageStore}
|
||||
page={page}
|
||||
projectId={projectId}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
/>
|
||||
@ -81,8 +78,7 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
readOnlyEditorReady={readOnlyEditorReady}
|
||||
markings={markings}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
isSyncing={isSyncing}
|
||||
pageStore={pageStore}
|
||||
page={page}
|
||||
projectId={projectId}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
setSidePeekVisible={setSidePeekVisible}
|
||||
|
@ -33,7 +33,6 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
|
||||
) : (
|
||||
<>
|
||||
<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"
|
||||
style={{
|
||||
lineHeight: "1.2",
|
||||
@ -46,6 +45,7 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
|
||||
}
|
||||
}}
|
||||
value={title}
|
||||
onChange={(e) => updateTitle(e.target.value)}
|
||||
maxLength={255}
|
||||
onFocus={() => setIsLengthVisible(true)}
|
||||
onBlur={() => setIsLengthVisible(false)}
|
||||
|
@ -4,6 +4,5 @@ export * from "./header";
|
||||
export * from "./list";
|
||||
export * from "./loaders";
|
||||
export * from "./modals";
|
||||
export * from "./page-detail";
|
||||
export * from "./pages-list-main-content";
|
||||
export * from "./pages-list-view";
|
||||
|
@ -2,27 +2,116 @@
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const PageContentLoader = () => (
|
||||
<div className="flex">
|
||||
<div className="w-[5%]" />
|
||||
<Loader className="flex-shrink-0 flex-grow">
|
||||
<div className="mt-10 space-y-2">
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
<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>
|
||||
<div className="mt-12 space-y-10">
|
||||
{Array.from(Array(4)).map((i) => (
|
||||
<div key={i}>
|
||||
<Loader.Item height="25px" width="20%" />
|
||||
<div className="mt-5 space-y-3">
|
||||
<Loader.Item height="15px" width="40%" />
|
||||
<Loader.Item height="15px" width="30%" />
|
||||
<Loader.Item height="15px" width="35%" />
|
||||
|
||||
{/* 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>
|
||||
</Loader>
|
||||
<div className="w-[5%]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -23,11 +23,11 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
|
||||
// store hooks
|
||||
const { removePage } = useProjectPages(projectId);
|
||||
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 = () => {
|
||||
setIsDeleting(false);
|
||||
@ -41,7 +41,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
|
||||
capturePageEvent({
|
||||
eventName: PAGE_DELETED,
|
||||
payload: {
|
||||
...pageStore,
|
||||
...page,
|
||||
state: "SUCCESS",
|
||||
},
|
||||
});
|
||||
@ -56,7 +56,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
|
||||
capturePageEvent({
|
||||
eventName: PAGE_DELETED,
|
||||
payload: {
|
||||
...pageStore,
|
||||
...page,
|
||||
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 Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useForm } from "react-hook-form";
|
||||
import useSWR from "swr";
|
||||
// document-editor
|
||||
import { EditorRefApi, useEditorMarkings } from "@plane/document-editor";
|
||||
@ -38,38 +37,24 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||
const { workspaceSlug, projectId, pageId } = router.query;
|
||||
// store hooks
|
||||
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
|
||||
const { markings, updateMarkings } = useEditorMarkings();
|
||||
// form info
|
||||
const { handleSubmit, getValues, control } = useForm<TPage>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description_html: "",
|
||||
},
|
||||
});
|
||||
|
||||
// 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]
|
||||
// fetch page details
|
||||
const { error: pageDetailsError } = useSWR(
|
||||
pageId ? `PAGE_DETAILS_${pageId}` : null,
|
||||
pageId ? () => getPageById(pageId.toString()) : null,
|
||||
{
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
if ((!pageStore || !pageStore.id) && !pageDetailsError)
|
||||
if ((!page || !id) && !pageDetailsError)
|
||||
return (
|
||||
<div className="h-full w-full grid place-items-center">
|
||||
<div className="size-full grid place-items-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
@ -90,28 +75,12 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||
</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 handleUpdatePage = async (formData: TPage) => {
|
||||
let updatedDescription = formData.description_html;
|
||||
if (!updatedDescription || updatedDescription.trim() === "") updatedDescription = "<p></p>";
|
||||
pageStore.updateDescription(updatedDescription);
|
||||
};
|
||||
|
||||
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> = {
|
||||
name: "Copy of " + pageStore.name,
|
||||
description_html: currentPageValues.description_html,
|
||||
name: "Copy of " + name,
|
||||
description_html: description_html ?? "<p></p>",
|
||||
};
|
||||
|
||||
await handleCreatePage(formData)
|
||||
@ -127,7 +96,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<PageHead title={name} />
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div className="h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
|
||||
{projectId && (
|
||||
@ -137,24 +106,20 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
||||
editorReady={editorReady}
|
||||
readOnlyEditorReady={readOnlyEditorReady}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
isSyncing={isValidating}
|
||||
markings={markings}
|
||||
pageStore={pageStore}
|
||||
page={page}
|
||||
projectId={projectId.toString()}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
setSidePeekVisible={(state) => setSidePeekVisible(state)}
|
||||
/>
|
||||
)}
|
||||
<PageEditorBody
|
||||
swrPageDetails={swrPageDetails}
|
||||
control={control}
|
||||
editorRef={editorRef}
|
||||
handleEditorReady={(val) => setEditorReady(val)}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)}
|
||||
handleSubmit={() => handleSubmit(handleUpdatePage)()}
|
||||
markings={markings}
|
||||
pageStore={pageStore}
|
||||
page={page}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
updateMarkings={updateMarkings}
|
||||
/>
|
||||
|
@ -31,8 +31,11 @@ export abstract class APIService {
|
||||
);
|
||||
}
|
||||
|
||||
get(url: string, params = {}) {
|
||||
return this.axiosInstance.get(url, params);
|
||||
get(url: string, params = {}, config = {}) {
|
||||
return this.axiosInstance.get(url, {
|
||||
...params,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
post(url: string, data = {}, config = {}) {
|
||||
|
@ -119,4 +119,33 @@ export class PageService extends APIService {
|
||||
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 { RootStore } from "../root.store";
|
||||
|
||||
export type TLoader = "submitting" | "submitted" | "saved" | undefined;
|
||||
export type TLoader = "submitting" | "submitted" | "saved";
|
||||
|
||||
export interface IPageStore extends TPage {
|
||||
// observables
|
||||
isSubmitting: "submitting" | "submitted" | "saved";
|
||||
loader: TLoader;
|
||||
isSubmitting: TLoader;
|
||||
// computed
|
||||
asJSON: TPage | undefined;
|
||||
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;
|
||||
// helpers
|
||||
oldName: string;
|
||||
updateTitle: (name: string) => void;
|
||||
updateDescription: (description: string) => void;
|
||||
setIsSubmitting: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setIsSubmitting: (value: TLoader) => void;
|
||||
cleanup: () => void;
|
||||
// actions
|
||||
update: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
|
||||
updateTitle: (title: string) => void;
|
||||
updateDescription: (binaryString: string, descriptionHTML: string) => Promise<void>;
|
||||
makePublic: () => Promise<void>;
|
||||
makePrivate: () => Promise<void>;
|
||||
lock: () => Promise<void>;
|
||||
@ -45,8 +44,7 @@ export interface IPageStore extends TPage {
|
||||
|
||||
export class PageStore implements IPageStore {
|
||||
// loaders
|
||||
isSubmitting: "submitting" | "submitted" | "saved" = "saved";
|
||||
loader: TLoader = undefined;
|
||||
isSubmitting: TLoader = "saved";
|
||||
// page properties
|
||||
id: string | undefined;
|
||||
name: string | undefined;
|
||||
@ -68,7 +66,7 @@ export class PageStore implements IPageStore {
|
||||
oldName: string = "";
|
||||
// reactions
|
||||
disposers: Array<() => void> = [];
|
||||
// service
|
||||
// services
|
||||
pageService: PageService;
|
||||
|
||||
constructor(
|
||||
@ -96,7 +94,6 @@ export class PageStore implements IPageStore {
|
||||
makeObservable(this, {
|
||||
// loaders
|
||||
isSubmitting: observable.ref,
|
||||
loader: observable.ref,
|
||||
// page properties
|
||||
id: observable.ref,
|
||||
name: observable.ref,
|
||||
@ -115,7 +112,9 @@ export class PageStore implements IPageStore {
|
||||
created_at: observable.ref,
|
||||
updated_at: observable.ref,
|
||||
// helpers
|
||||
oldName: observable,
|
||||
oldName: observable.ref,
|
||||
setIsSubmitting: action,
|
||||
cleanup: action,
|
||||
// computed
|
||||
asJSON: computed,
|
||||
isCurrentUserOwner: computed,
|
||||
@ -126,13 +125,10 @@ export class PageStore implements IPageStore {
|
||||
canCurrentUserArchivePage: computed,
|
||||
canCurrentUserDeletePage: computed,
|
||||
isContentEditable: computed,
|
||||
// helper actions
|
||||
updateTitle: action,
|
||||
updateDescription: action.bound,
|
||||
setIsSubmitting: action,
|
||||
cleanup: action,
|
||||
// actions
|
||||
update: action,
|
||||
updateTitle: action,
|
||||
updateDescription: action,
|
||||
makePublic: action,
|
||||
makePrivate: action,
|
||||
lock: action,
|
||||
@ -169,27 +165,7 @@ export class PageStore implements IPageStore {
|
||||
{ delay: 2000 }
|
||||
);
|
||||
|
||||
const descriptionDisposer = reaction(
|
||||
() => 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);
|
||||
this.disposers.push(titleDisposer);
|
||||
}
|
||||
|
||||
// computed
|
||||
@ -284,24 +260,21 @@ export class PageStore implements IPageStore {
|
||||
);
|
||||
}
|
||||
|
||||
updateTitle = action("updateTitle", (name: string) => {
|
||||
this.oldName = this.name ?? "";
|
||||
this.name = name;
|
||||
});
|
||||
/**
|
||||
* @description update the submitting state
|
||||
* @param value
|
||||
*/
|
||||
setIsSubmitting = (value: TLoader) => {
|
||||
runInAction(() => {
|
||||
this.isSubmitting = value;
|
||||
});
|
||||
};
|
||||
|
||||
updateDescription = action("updateDescription", (description_html: string) => {
|
||||
this.description_html = description_html;
|
||||
});
|
||||
|
||||
setIsSubmitting = action("setIsSubmitting", (isSubmitting: "submitting" | "submitted" | "saved") => {
|
||||
this.isSubmitting = isSubmitting;
|
||||
});
|
||||
|
||||
cleanup = action("cleanup", () => {
|
||||
cleanup = () => {
|
||||
this.disposers.forEach((disposer) => {
|
||||
disposer();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update the page
|
||||
@ -313,14 +286,14 @@ export class PageStore implements IPageStore {
|
||||
|
||||
const currentPage = this.asJSON;
|
||||
try {
|
||||
const currentPageResponse = await this.pageService.update(workspaceSlug, projectId, this.id, currentPage);
|
||||
if (currentPageResponse)
|
||||
runInAction(() => {
|
||||
Object.keys(pageData).forEach((key) => {
|
||||
const currentPageKey = key as keyof TPage;
|
||||
set(this, key, currentPageResponse?.[currentPageKey] || undefined);
|
||||
});
|
||||
runInAction(() => {
|
||||
Object.keys(pageData).forEach((key) => {
|
||||
const currentPageKey = key as keyof TPage;
|
||||
set(this, key, pageData[currentPageKey] || undefined);
|
||||
});
|
||||
});
|
||||
|
||||
await this.pageService.update(workspaceSlug, projectId, this.id, currentPage);
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
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
|
||||
*/
|
||||
|
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"
|
||||
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":
|
||||
version "2.4.0"
|
||||
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"
|
||||
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:
|
||||
version "1.1.2"
|
||||
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"
|
||||
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:
|
||||
version "3.1.1"
|
||||
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"
|
||||
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:
|
||||
version "5.0.8"
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
|
||||
@ -13195,6 +13233,13 @@ yargs@^17.0.0:
|
||||
y18n "^5.0.5"
|
||||
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:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
|
Loading…
Reference in New Issue
Block a user