[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:
Aaryan Khandelwal 2024-05-26 16:37:10 +05:30 committed by sriram veeraghanta
parent 2b2f667868
commit a3ac6fa498
42 changed files with 1127 additions and 509 deletions

View File

@ -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):

View File

@ -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",
),
] ]

View File

@ -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

View File

@ -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"})

View File

@ -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"

View File

@ -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) {

View File

@ -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);

View File

@ -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";

View File

@ -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,
};
};

View File

@ -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;

View 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,
}),
];

View File

@ -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,
},
};
},
});

View File

@ -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();
},
};
},
},
});

View File

@ -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",

View File

@ -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;
};

View File

@ -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";

View File

@ -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);
}
}

View File

@ -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,
}),
]; ];

View File

@ -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,
}); });

View 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;
};

View File

@ -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,

View File

@ -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,

View File

@ -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>
); );

View File

@ -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,
}} }}

View File

@ -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>
); );

View File

@ -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)}>

View File

@ -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}
/> />

View File

@ -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

View File

@ -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}

View File

@ -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)}

View File

@ -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";

View File

@ -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>
); );

View File

@ -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",
}, },
}); });

View File

@ -1,3 +0,0 @@
export * from "./loader";
export * from "./root";

View File

@ -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>
);

View File

@ -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>
);
});

View 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,
};
};

View File

@ -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}
/> />

View File

@ -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 = {}) {

View File

@ -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;
});
}
} }

View File

@ -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
*/ */

View File

@ -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"