Compare commits

..
Author SHA1 Message Date
NarayanBavisetti 3c6b106e5c chore: renamed intake triage 2025-11-28 16:28:16 +05:30
66 changed files with 570 additions and 2497 deletions
+5 -3
View File
@@ -1,7 +1,7 @@
# Module imports
from .base import BaseSerializer
from .issue import IssueExpandSerializer
from plane.db.models import IntakeIssue, Issue, State, StateGroup
from plane.db.models import IntakeIssue, Issue
from rest_framework import serializers
@@ -108,6 +108,7 @@ class IntakeIssueUpdateSerializer(BaseSerializer):
Validate that if status is being changed to accepted (1),
the project has a default state to transition to.
"""
from plane.db.models import State
# Check if status is being updated to accepted
if attrs.get("status") == 1:
@@ -115,7 +116,7 @@ class IntakeIssueUpdateSerializer(BaseSerializer):
issue = intake_issue.issue
# Check if issue is in TRIAGE state
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
if issue.state and issue.state.group == State.TRIAGE:
# Verify default state exists before allowing the update
default_state = State.objects.filter(
workspace=intake_issue.workspace, project=intake_issue.project, default=True
@@ -132,6 +133,7 @@ class IntakeIssueUpdateSerializer(BaseSerializer):
"""
Update intake issue and transition associated issue state if accepted.
"""
from plane.db.models import State
# Update the intake issue with validated data
instance = super().update(instance, validated_data)
@@ -139,7 +141,7 @@ class IntakeIssueUpdateSerializer(BaseSerializer):
# If status is accepted (1), update the associated issue state from TRIAGE to default
if validated_data.get("status") == 1:
issue = instance.issue
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
if issue.state and issue.state.group == State.TRIAGE:
# Get the default project state
default_state = State.objects.filter(
workspace=instance.workspace, project=instance.project, default=True
+2 -2
View File
@@ -1,6 +1,6 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import State, StateGroup
from plane.db.models import State
from rest_framework import serializers
@@ -17,7 +17,7 @@ class StateSerializer(BaseSerializer):
if data.get("default", False):
State.objects.filter(project_id=self.context.get("project_id")).update(default=False)
if data.get("group", None) == StateGroup.TRIAGE.value:
if data.get("group", None) == State.TRIAGE:
raise serializers.ValidationError("Cannot create triage state")
return data
+2 -2
View File
@@ -23,7 +23,7 @@ from plane.api.serializers import (
)
from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State, StateGroup
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.db.models.intake import SourceType
@@ -171,7 +171,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
group=State.TRIAGE,
project_id=project_id,
workspace_id=project.workspace_id,
color="#4E5355",
+42 -2
View File
@@ -24,7 +24,6 @@ from plane.db.models import (
DeployBoard,
ProjectMember,
State,
DEFAULT_STATES,
Workspace,
UserFavorite,
)
@@ -233,6 +232,47 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
user_id=serializer.instance.project_lead,
)
# Default states
states = [
{
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": "cancelled",
},
{
"name": "Triage",
"color": "#4E5355",
"sequence": 65000,
"group": State.TRIAGE,
},
]
State.objects.bulk_create(
[
State(
@@ -245,7 +285,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
default=state.get("default", False),
created_by=request.user,
)
for state in DEFAULT_STATES
for state in states
]
)
+9 -4
View File
@@ -7,7 +7,7 @@ from .issue import IssueIntakeSerializer, LabelLiteSerializer, IssueDetailSerial
from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
from plane.db.models import Intake, IntakeIssue, Issue, StateGroup, State
from plane.db.models import Intake, IntakeIssue, Issue
class IntakeSerializer(BaseSerializer):
@@ -41,6 +41,7 @@ class IntakeIssueSerializer(BaseSerializer):
Validate that if status is being changed to accepted (1),
the project has a default state to transition to.
"""
from plane.db.models import State
# Check if status is being updated to accepted
if attrs.get("status") == 1:
@@ -48,7 +49,7 @@ class IntakeIssueSerializer(BaseSerializer):
issue = intake_issue.issue
# Check if issue is in TRIAGE state
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
if issue.state and issue.state.group == State.TRIAGE:
# Verify default state exists before allowing the update
default_state = State.objects.filter(
workspace=intake_issue.workspace, project=intake_issue.project, default=True
@@ -62,16 +63,20 @@ class IntakeIssueSerializer(BaseSerializer):
return attrs
def update(self, instance, validated_data):
from plane.db.models import State
# Update the intake issue
instance = super().update(instance, validated_data)
# If status is accepted (1), transition the issue state from TRIAGE to default
if validated_data.get("status") == 1:
issue = instance.issue
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
if issue.state and issue.state.group == State.TRIAGE:
# Get the default project state
default_state = State.objects.filter(
workspace=instance.workspace, project=instance.project, default=True
workspace=instance.workspace,
project=instance.project,
default=True
).first()
if default_state:
issue.state = default_state
+3 -2
View File
@@ -2,7 +2,7 @@
from .base import BaseSerializer
from rest_framework import serializers
from plane.db.models import State, StateGroup
from plane.db.models import State
class StateSerializer(BaseSerializer):
@@ -25,7 +25,8 @@ class StateSerializer(BaseSerializer):
read_only_fields = ["workspace", "project"]
def validate(self, attrs):
if attrs.get("group") == StateGroup.TRIAGE.value:
if attrs.get("group") == State.TRIAGE:
raise serializers.ValidationError("Cannot create triage state")
return attrs
+1 -2
View File
@@ -22,7 +22,6 @@ from plane.db.models import (
IntakeIssue,
Issue,
State,
StateGroup,
IssueLink,
FileAsset,
Project,
@@ -236,7 +235,7 @@ class IntakeIssueViewSet(BaseViewSet):
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
group=State.TRIAGE,
project_id=project_id,
workspace_id=project.workspace_id,
color="#4E5355",
+42 -2
View File
@@ -31,7 +31,6 @@ from plane.db.models import (
ProjectIdentifier,
ProjectMember,
State,
DEFAULT_STATES,
Workspace,
WorkspaceMember,
)
@@ -265,6 +264,47 @@ class ProjectViewSet(BaseViewSet):
user_id=serializer.data["project_lead"],
)
# Default states
states = [
{
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": "cancelled",
},
{
"name": "Triage",
"color": "#4E5355",
"sequence": 65000,
"group": State.TRIAGE,
},
]
State.objects.bulk_create(
[
State(
@@ -277,7 +317,7 @@ class ProjectViewSet(BaseViewSet):
default=state.get("default", False),
created_by=request.user,
)
for state in DEFAULT_STATES
for state in states
]
)
+1 -1
View File
@@ -135,7 +135,7 @@ class IntakeStateEndpoint(BaseAPIView):
state = State.triage_objects.filter(workspace__slug=slug, project_id=project_id).first()
if not state:
return Response(
{"error": "Triage state not found"},
{"error": "Intake triage state not found"},
status=status.HTTP_404_NOT_FOUND,
)
+1 -4
View File
@@ -17,7 +17,6 @@ from plane.db.models import (
Project,
ProjectMember,
State,
StateGroup,
Label,
Cycle,
Module,
@@ -265,9 +264,7 @@ def create_issues(workspace, project, user_id, issue_count):
Faker.seed(0)
states = (
State.objects.filter(workspace=workspace, project=project)
.exclude(group=StateGroup.TRIAGE.value)
.values_list("id", flat=True)
State.objects.filter(workspace=workspace, project=project).exclude(group=State.TRIAGE).values_list("id", flat=True)
)
creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True)
+1 -1
View File
@@ -56,7 +56,7 @@ from .project import (
)
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State, StateGroup, DEFAULT_STATES
from .state import State
from .user import Account, Profile, User
from .view import IssueView
from .webhook import Webhook, WebhookLog
+2 -2
View File
@@ -19,7 +19,7 @@ from .project import ProjectBaseModel
from plane.utils.uuid import convert_uuid_to_integer
from .description import Description
from plane.db.mixins import ChangeTrackerMixin
from .state import StateGroup
from .state import State
def get_default_properties():
@@ -98,7 +98,7 @@ class IssueManager(SoftDeletionManager):
)
.filter(deleted_at__isnull=True)
.filter(state__is_triage=False)
.exclude(state__group=StateGroup.TRIAGE.value)
.exclude(state__group=State.TRIAGE)
.exclude(archived_at__isnull=False)
.exclude(project__archived_at__isnull=False)
.exclude(is_draft=True)
+26 -55
View File
@@ -7,80 +7,51 @@ from django.db.models import Q
from .project import ProjectBaseModel
class StateGroup(models.TextChoices):
BACKLOG = "backlog", "Backlog"
UNSTARTED = "unstarted", "Unstarted"
STARTED = "started", "Started"
COMPLETED = "completed", "Completed"
CANCELLED = "cancelled", "Cancelled"
TRIAGE = "triage", "Triage"
# Default states
DEFAULT_STATES = [
{
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": StateGroup.BACKLOG.value,
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": StateGroup.UNSTARTED.value,
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": StateGroup.STARTED.value,
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": StateGroup.COMPLETED.value,
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": StateGroup.CANCELLED.value,
},
{
"name": "Triage",
"color": "#4E5355",
"sequence": 65000,
"group": StateGroup.TRIAGE.value,
},
]
class StateManager(models.Manager):
"""Default manager - excludes triage states"""
def get_queryset(self):
return super().get_queryset().exclude(group=StateGroup.TRIAGE.value)
return super().get_queryset().exclude(group=State.TRIAGE)
class TriageStateManager(models.Manager):
"""Manager for triage states only"""
def get_queryset(self):
return super().get_queryset().filter(group=StateGroup.TRIAGE.value)
return super().get_queryset().filter(group=State.TRIAGE)
class State(ProjectBaseModel):
BACKLOG = "backlog"
UNSTARTED = "unstarted"
STARTED = "started"
COMPLETED = "completed"
CANCELLED = "cancelled"
TRIAGE = "triage"
GROUP_CHOICES = (
(BACKLOG, "Backlog"),
(UNSTARTED, "Unstarted"),
(STARTED, "Started"),
(COMPLETED, "Completed"),
(CANCELLED, "Cancelled"),
(TRIAGE, "Triage"),
)
name = models.CharField(max_length=255, verbose_name="State Name")
description = models.TextField(verbose_name="State Description", blank=True)
color = models.CharField(max_length=255, verbose_name="State Color")
slug = models.SlugField(max_length=100, blank=True)
sequence = models.FloatField(default=65535)
group = models.CharField(
choices=StateGroup.choices,
default=StateGroup.BACKLOG,
choices=(
("backlog", "Backlog"),
("unstarted", "Unstarted"),
("started", "Started"),
("completed", "Completed"),
("cancelled", "Cancelled"),
("triage", "Triage"),
),
default="backlog",
max_length=20,
)
is_triage = models.BooleanField(default=False)
+2 -2
View File
@@ -12,7 +12,7 @@ from rest_framework.response import Response
# Module imports
from .base import BaseViewSet
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard, State, StateGroup
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard, State
from plane.app.serializers import (
IssueSerializer,
IntakeIssueSerializer,
@@ -129,7 +129,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
group=State.TRIAGE,
project_id=project_deploy_board.project_id,
workspace_id=project_deploy_board.workspace_id,
color="#4E5355",
+2 -15
View File
@@ -27,17 +27,6 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
const pageDetails = await service.fetchDetails(pageId);
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
if (convertedBinaryData) {
// save the converted binary data back to the database
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
convertedBinaryData,
true
);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await service.updateDescriptionBinary(pageId, payload);
return convertedBinaryData;
}
}
@@ -63,10 +52,8 @@ const storeDocument = async ({
try {
const service = getPageService(context.documentType, context);
// convert binary data to all formats
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
pageBinaryData,
true
);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(pageBinaryData);
// create payload
const payload = {
description_binary: contentBinaryEncoded,
-176
View File
@@ -1,176 +0,0 @@
// hocuspocus
import type { Extension, Hocuspocus, Document } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import type * as Y from "yjs";
// editor extensions
import { TITLE_EDITOR_EXTENSIONS, createRealtimeEvent } from "@plane/editor";
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
// helpers
import { getPageService } from "@/services/page/handler";
import type { HocusPocusServerContext, OnLoadDocumentPayloadWithContext } from "@/types";
import { generateTitleProsemirrorJson } from "@/utils";
import { broadcastMessageToPage } from "@/utils/broadcast-message";
import { TitleUpdateManager } from "./title-update/title-update-manager";
import { extractTextFromHTML } from "./title-update/title-utils";
/**
* Hocuspocus extension for synchronizing document titles
*/
export class TitleSyncExtension implements Extension {
// Maps document names to their observers and update managers
private titleObservers: Map<string, (events: Y.YEvent<any>[]) => void> = new Map();
private titleUpdateManagers: Map<string, TitleUpdateManager> = new Map();
// Store minimal data needed for each document's title observer (prevents closure memory leaks)
private titleObserverData: Map<
string,
{
parentId?: string | null;
userId: string;
workspaceSlug: string | null;
instance: Hocuspocus;
}
> = new Map();
/**
* Handle document loading - migrate old titles if needed
*/
async onLoadDocument({ context, document, documentName }: OnLoadDocumentPayloadWithContext) {
try {
// initially for on demand migration of old titles to a new title field
// in the yjs binary
if (document.isEmpty("title")) {
const service = getPageService(context.documentType, context);
// const title = await service.fe
const title = (await service.fetchDetails?.(documentName)).name;
if (title == null) return;
const titleField = TiptapTransformer.toYdoc(
generateTitleProsemirrorJson(title),
"title",
// editor
TITLE_EDITOR_EXTENSIONS as any
);
document.merge(titleField);
}
} catch (error) {
const appError = new AppError(error, {
context: { operation: "onLoadDocument", documentName },
});
logger.error("Error loading document title", appError);
}
}
/**
* Set up title synchronization for a document after it's loaded
*/
async afterLoadDocument({
document,
documentName,
context,
instance,
}: {
document: Document;
documentName: string;
context: HocusPocusServerContext;
instance: Hocuspocus;
}) {
// Create a title update manager for this document
const updateManager = new TitleUpdateManager(documentName, context);
// Store the manager
this.titleUpdateManagers.set(documentName, updateManager);
// Store minimal data needed for the observer (prevents closure memory leak)
this.titleObserverData.set(documentName, {
parentId: context.parentId,
userId: context.userId,
workspaceSlug: context.workspaceSlug,
instance: instance,
});
// Create observer using bound method to avoid closure capturing heavy objects
const titleObserver = this.handleTitleChange.bind(this, documentName);
// Observe the title field
document.getXmlFragment("title").observeDeep(titleObserver);
this.titleObservers.set(documentName, titleObserver);
}
/**
* Handle title changes for a document
* This is a separate method to avoid closure memory leaks
*/
private handleTitleChange(documentName: string, events: Y.YEvent<any>[]) {
let title = "";
events.forEach((event) => {
title = extractTextFromHTML(event.currentTarget.toJSON());
});
// Get the manager for this document
const manager = this.titleUpdateManagers.get(documentName);
// Get the stored data for this document
const data = this.titleObserverData.get(documentName);
// Broadcast to parent page if it exists
if (data?.parentId && data.workspaceSlug && data.instance) {
const event = createRealtimeEvent({
user_id: data.userId,
workspace_slug: data.workspaceSlug,
action: "property_updated",
page_id: documentName,
data: { name: title },
descendants_ids: [],
});
// Use the instance from stored data (guaranteed to be set)
broadcastMessageToPage(data.instance, data.parentId, event);
}
// Schedule the title update
if (manager) {
manager.scheduleUpdate(title);
}
}
/**
* Force save title before unloading the document
*/
async beforeUnloadDocument({ documentName }: { documentName: string }) {
const updateManager = this.titleUpdateManagers.get(documentName);
if (updateManager) {
// Force immediate save and wait for it to complete
await updateManager.forceSave();
// Clean up the manager
this.titleUpdateManagers.delete(documentName);
}
}
/**
* Remove observers after document unload
*/
async afterUnloadDocument({ documentName, document }: { documentName: string; document?: Document }) {
// Clean up observer when document is unloaded
const observer = this.titleObservers.get(documentName);
if (observer) {
// unregister observer from Y.js document to prevent memory leak
if (document) {
try {
document.getXmlFragment("title").unobserveDeep(observer);
} catch (error) {
logger.error("Failed to unobserve title field", new AppError(error, { context: { documentName } }));
}
}
this.titleObservers.delete(documentName);
}
// Clean up the observer data map to prevent memory leak
this.titleObserverData.delete(documentName);
// Ensure manager is cleaned up if beforeUnloadDocument somehow didn't run
if (this.titleUpdateManagers.has(documentName)) {
const manager = this.titleUpdateManagers.get(documentName)!;
manager.cancel();
this.titleUpdateManagers.delete(documentName);
}
}
}
@@ -1,277 +0,0 @@
import { logger } from "@plane/logger";
/**
* DebounceState - Tracks the state of a debounced function
*/
export interface DebounceState {
lastArgs: any[] | null;
timerId: ReturnType<typeof setTimeout> | null;
lastCallTime: number | undefined;
lastExecutionTime: number;
inProgress: boolean;
abortController: AbortController | null;
}
/**
* Creates a new DebounceState object
*/
export const createDebounceState = (): DebounceState => ({
lastArgs: null,
timerId: null,
lastCallTime: undefined,
lastExecutionTime: 0,
inProgress: false,
abortController: null,
});
/**
* DebounceOptions - Configuration options for debounce
*/
export interface DebounceOptions {
/** The wait time in milliseconds */
wait: number;
/** Optional logging prefix for debug messages */
logPrefix?: string;
}
/**
* Enhanced debounce manager with abort support
* Manages the state and timing of debounced function calls
*/
export class DebounceManager {
private state: DebounceState;
private wait: number;
private logPrefix: string;
/**
* Creates a new DebounceManager
* @param options Debounce configuration options
*/
constructor(options: DebounceOptions) {
this.state = createDebounceState();
this.wait = options.wait;
this.logPrefix = options.logPrefix || "";
}
/**
* Schedule a debounced function call
* @param func The function to call
* @param args The arguments to pass to the function
*/
schedule(func: (...args: any[]) => Promise<void>, ...args: any[]): void {
// Always update the last arguments
this.state.lastArgs = args;
const time = Date.now();
this.state.lastCallTime = time;
// If an operation is in progress, just store the new args and start the timer
if (this.state.inProgress) {
// Always restart the timer for the new call, even if an operation is in progress
if (this.state.timerId) {
clearTimeout(this.state.timerId);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// If already scheduled, update the args and restart the timer
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// Start the timer for the trailing edge execution
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
/**
* Called when the timer expires
*/
private timerExpired(func: (...args: any[]) => Promise<void>): void {
const time = Date.now();
// Check if this timer expiration represents the end of the debounce period
if (this.shouldInvoke(time)) {
// Execute the function
this.executeFunction(func, time);
return;
}
// Otherwise restart the timer
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.remainingWait(time));
}
/**
* Execute the debounced function
*/
private executeFunction(func: (...args: any[]) => Promise<void>, time: number): void {
this.state.timerId = null;
this.state.lastExecutionTime = time;
// Execute the function asynchronously
this.performFunction(func).catch((error) => {
logger.error(`${this.logPrefix}: Error in execution:`, error);
});
}
/**
* Perform the actual function call, handling any in-progress operations
*/
private async performFunction(func: (...args: any[]) => Promise<void>): Promise<void> {
const args = this.state.lastArgs;
if (!args) return;
// Store the args we're about to use
const currentArgs = [...args];
// If another operation is in progress, abort it
await this.abortOngoingOperation();
// Mark that we're starting a new operation
this.state.inProgress = true;
this.state.abortController = new AbortController();
try {
// Add the abort signal to the arguments if the function can use it
const execArgs = [...currentArgs];
execArgs.push(this.state.abortController.signal);
await func(...execArgs);
// Only clear lastArgs if they haven't been changed during this operation
if (this.state.lastArgs && this.arraysEqual(this.state.lastArgs, currentArgs)) {
this.state.lastArgs = null;
// Clear any timer as we've successfully processed the latest args
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
} else if (this.state.lastArgs) {
// If lastArgs have changed during this operation, the timer should already be running
// but let's make sure it is
if (!this.state.timerId) {
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// Nothing to do here, the new operation will be triggered by the timer expiration
} else {
logger.error(`${this.logPrefix}: Error during operation:`, error);
// On error (not abort), make sure we have a timer running to retry
if (!this.state.timerId && this.state.lastArgs) {
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} finally {
this.state.inProgress = false;
this.state.abortController = null;
}
}
/**
* Abort any ongoing operation
*/
private async abortOngoingOperation(): Promise<void> {
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
// Small delay to ensure the abort has had time to propagate
await new Promise((resolve) => setTimeout(resolve, 20));
// Double-check that state has been reset, force it if not
if (this.state.inProgress || this.state.abortController) {
this.state.inProgress = false;
this.state.abortController = null;
}
}
}
/**
* Determine if we should invoke the function now
*/
private shouldInvoke(time: number): boolean {
// Either this is the first call, or we've waited long enough since the last call
return this.state.lastCallTime === undefined || time - this.state.lastCallTime >= this.wait;
}
/**
* Calculate how much longer we should wait
*/
private remainingWait(time: number): number {
const timeSinceLastCall = time - (this.state.lastCallTime || 0);
return Math.max(0, this.wait - timeSinceLastCall);
}
/**
* Force immediate execution
*/
async flush(func: (...args: any[]) => Promise<void>): Promise<void> {
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Perform the function immediately
if (this.state.lastArgs) {
await this.performFunction(func);
}
}
/**
* Cancel any pending operations without executing
*/
cancel(): void {
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Abort any in-progress operation
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
this.state.inProgress = false;
this.state.abortController = null;
}
// Clear args
this.state.lastArgs = null;
}
/**
* Compare two arrays for equality
*/
private arraysEqual(a: any[], b: any[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
}
@@ -1,90 +0,0 @@
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
import { getPageService } from "@/services/page/handler";
import type { HocusPocusServerContext } from "@/types";
import { DebounceManager } from "./debounce";
/**
* Manages title update operations for a single document
* Handles debouncing, aborting, and force saving title updates
*/
export class TitleUpdateManager {
private documentName: string;
private context: HocusPocusServerContext;
private debounceManager: DebounceManager;
private lastTitle: string | null = null;
/**
* Create a new TitleUpdateManager instance
*/
constructor(documentName: string, context: HocusPocusServerContext, wait: number = 5000) {
this.documentName = documentName;
this.context = context;
// Set up debounce manager with logging
this.debounceManager = new DebounceManager({
wait,
logPrefix: `TitleManager[${documentName.substring(0, 8)}]`,
});
}
/**
* Schedule a debounced title update
*/
scheduleUpdate(title: string): void {
// Store the latest title
this.lastTitle = title;
// Schedule the update with the debounce manager
this.debounceManager.schedule(this.updateTitle.bind(this), title);
}
/**
* Update the title - will be called by the debounce manager
*/
private async updateTitle(title: string, signal?: AbortSignal): Promise<void> {
const service = getPageService(this.context.documentType, this.context);
if (!service.updatePageProperties) {
logger.warn(`No updateTitle method found for document ${this.documentName}`);
return;
}
try {
await service.updatePageProperties(this.documentName, {
data: { name: title },
abortSignal: signal,
});
// Clear last title only if it matches what we just updated
if (this.lastTitle === title) {
this.lastTitle = null;
}
} catch (error) {
const appError = new AppError(error, {
context: { operation: "updateTitle", documentName: this.documentName },
});
logger.error("Error updating title", appError);
}
}
/**
* Force save the current title immediately
*/
async forceSave(): Promise<void> {
// Ensure we have the current title
if (!this.lastTitle) {
return;
}
// Use the debounce manager to flush the operation
await this.debounceManager.flush(this.updateTitle.bind(this));
}
/**
* Cancel any pending updates
*/
cancel(): void {
this.debounceManager.cancel();
this.lastTitle = null;
}
}
@@ -1,8 +0,0 @@
/**
* Utility function to extract text from HTML content
*/
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
@@ -76,7 +76,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
EUserPermissionsLevel.WORKSPACE
);
const handleClose = useCallback(() => toggleExtendedProjectSidebar(false), [toggleExtendedProjectSidebar]);
const handleClose = () => toggleExtendedProjectSidebar(false);
const handleCopyText = (projectId: string) => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
@@ -102,9 +102,8 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
extendedSidebarRef={extendedProjectSidebarRef}
handleClose={handleClose}
excludedElementId="extended-project-sidebar-toggle"
className="px-0"
>
<div className="flex flex-col gap-1 w-full sticky top-4 px-4">
<div className="flex flex-col gap-1 w-full sticky top-4 pt-0">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-custom-text-300 py-1.5">Projects</span>
{isAuthorizedUser && (
@@ -112,7 +111,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
<button
type="button"
data-ph-element={PROJECT_TRACKER_ELEMENTS.EXTENDED_SIDEBAR_ADD_BUTTON}
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0 text-custom-text-300 hover:text-custom-text-200 transition-colors"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
onClick={() => {
setIsProjectModalOpen(true);
}}
@@ -134,7 +133,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
</div>
</div>
{filteredProjects.length === 0 ? (
<div className="flex flex-col items-center mt-4 p-10">
<div className="flex flex-col items-center mt-4 px-6 pt-10">
<EmptyStateCompact
title={t("common_empty_state.search.title")}
description={t("common_empty_state.search.description")}
@@ -144,7 +143,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
/>
</div>
) : (
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 pl-9 pr-2">
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 pl-4">
{filteredProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
@@ -1,16 +1,14 @@
import React, { useEffect } from "react";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { EXTENDED_SIDEBAR_WIDTH, SIDEBAR_WIDTH } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { cn } from "@plane/utils";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
// hooks
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
type Props = {
className?: string;
children: React.ReactNode;
extendedSidebarRef: React.RefObject<HTMLDivElement>;
isExtendedSidebarOpened: boolean;
@@ -19,35 +17,26 @@ type Props = {
};
export const ExtendedSidebarWrapper = observer(function ExtendedSidebarWrapper(props: Props) {
const { className, children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props;
const { children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props;
// store hooks
const { sidebarCollapsed } = useAppTheme();
// local storage
const { storedValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH);
useExtendedSidebarOutsideClickDetector(extendedSidebarRef, handleClose, excludedElementId);
useEffect(() => {
if (sidebarCollapsed) {
handleClose();
}
}, [sidebarCollapsed, handleClose]);
return (
<div
id={excludedElementId}
ref={extendedSidebarRef}
className={cn(
"absolute h-full z-[21] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm",
`absolute h-full z-[21] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm`,
{
"opacity-100": isExtendedSidebarOpened,
"opacity-0 hidden": !isExtendedSidebarOpened,
},
className
"translate-x-0 opacity-100": isExtendedSidebarOpened,
[`-translate-x-[${EXTENDED_SIDEBAR_WIDTH}px] opacity-0 hidden`]: !isExtendedSidebarOpened,
}
)}
style={{
left: `${storedValue ?? SIDEBAR_WIDTH}px`,
width: `${EXTENDED_SIDEBAR_WIDTH}px`,
width: `${isExtendedSidebarOpened ? EXTENDED_SIDEBAR_WIDTH : 0}px`,
}}
>
{children}
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from "react";
import React, { useMemo, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
@@ -107,7 +107,7 @@ export const ExtendedAppSidebar = observer(function ExtendedAppSidebar() {
if (updatedSortOrder != undefined) updateWorkspaceItemSortOrder(sourceId, updatedSortOrder);
};
const handleClose = useCallback(() => toggleExtendedSidebar(false), [toggleExtendedSidebar]);
const handleClose = () => toggleExtendedSidebar(false);
return (
<ExtendedSidebarWrapper
@@ -10,7 +10,6 @@ import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { PageAccessIcon } from "@/components/common/page-access-icon";
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
import { PageHeaderActions } from "@/components/pages/header/actions";
import { PageSyncingBadge } from "@/components/pages/header/syncing-badge";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
@@ -96,7 +95,6 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
</div>
</Header.LeftItem>
<Header.RightItem>
<PageSyncingBadge syncStatus={page.isSyncingWithServer} />
<PageDetailsHeaderExtraActions page={page} storeType={storeType} />
<PageHeaderActions page={page} storeType={storeType} />
</Header.RightItem>
-1
View File
@@ -1 +0,0 @@
export const isSidebarToggleVisible = () => true;
-2
View File
@@ -1,2 +0,0 @@
export * from "./helper";
export * from "./sidebar-workspace-menu";
@@ -1,3 +0,0 @@
export function DesktopSidebarWorkspaceMenu() {
return null;
}
@@ -20,7 +20,7 @@ export const TopNavigationRoot = observer(() => {
>
{/* Workspace Menu */}
<div className="shrink-0 flex-1">
<WorkspaceMenuRoot variant="top-navigation" />
<WorkspaceMenuRoot />
</div>
{/* Power K Search */}
<div className="shrink-0">
@@ -17,7 +17,6 @@ import { DateDropdown } from "@/components/dropdowns/date";
import { IntakeStateDropdown } from "@/components/dropdowns/intake-state/dropdown";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
import type { TIssueOperations } from "@/components/issues/issue-detail";
import { IssueLabel } from "@/components/issues/issue-detail/label";
// hooks
@@ -31,12 +30,10 @@ type Props = {
issueOperations: TIssueOperations;
isEditable: boolean;
duplicateIssueDetails: TInboxDuplicateIssueDetails | undefined;
isIntakeAccepted: boolean;
};
export const InboxIssueContentProperties = observer(function InboxIssueContentProperties(props: Props) {
const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails, isIntakeAccepted } =
props;
const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails } = props;
const router = useAppRouter();
// store hooks
@@ -53,7 +50,6 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
projectIdentifier: currentProjectDetails?.identifier,
sequenceId: duplicateIssueDetails?.sequence_id,
});
const DropdownComponent = isIntakeAccepted ? StateDropdown : IntakeStateDropdown;
return (
<div className="flex w-full flex-col divide-y-2 divide-custom-border-200">
@@ -68,11 +64,11 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
<span>State</span>
</div>
{issue?.state_id && (
<DropdownComponent
<IntakeStateDropdown
value={issue?.state_id}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
disabled
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="w-3/5 flex-grow group"
buttonContainerClassName="w-full text-left"
@@ -6,7 +6,7 @@ import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import type { EditorRefApi } from "@plane/editor";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType, EInboxIssueSource, EInboxIssueStatus } from "@plane/types";
import { EFileAssetType, EInboxIssueSource } from "@plane/types";
import { getTextContent } from "@plane/utils";
// components
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
@@ -74,7 +74,6 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
// derived values
const issue = inboxIssue.issue;
const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined;
const isIntakeAccepted = inboxIssue.status === EInboxIssueStatus.ACCEPTED;
// debounced duplicate issues swr
const { duplicateIssues } = useDebouncedDuplicateIssues(
@@ -263,7 +262,6 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
issueOperations={issueOperations}
isEditable={isEditable}
duplicateIssueDetails={inboxIssue?.duplicate_issue_detail}
isIntakeAccepted={isIntakeAccepted}
/>
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} isIntakeIssue />
+2 -6
View File
@@ -1,3 +1,4 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react";
@@ -29,17 +30,12 @@ export const InboxIssueRoot = observer(function InboxIssueRoot(props: TInboxIssu
// plane hooks
const { t } = useTranslation();
// hooks
const { loader, error, currentTab, currentInboxProjectId, handleCurrentTab, fetchInboxIssues } = useProjectInbox();
const { loader, error, currentTab, handleCurrentTab, fetchInboxIssues } = useProjectInbox();
useEffect(() => {
if (!inboxAccessible || !workspaceSlug || !projectId) return;
// Check if project has changed
const hasProjectChanged = currentInboxProjectId && currentInboxProjectId !== projectId;
if (navigationTab && navigationTab !== currentTab) {
handleCurrentTab(workspaceSlug, projectId, navigationTab);
} else if (hasProjectChanged) {
handleCurrentTab(workspaceSlug, projectId, EInboxIssueCurrentTab.OPEN);
} else {
fetchInboxIssues(
workspaceSlug.toString(),
@@ -1,3 +1,4 @@
import type { FC } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
@@ -137,7 +138,6 @@ export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarP
title={t("common_empty_state.search.title")}
description={t("common_empty_state.search.description")}
assetClassName="size-20"
rootClassName="px-page-x"
/>
) : currentTab === EInboxIssueCurrentTab.OPEN ? (
<EmptyStateDetailed
@@ -152,7 +152,6 @@ export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarP
variant: "primary",
},
]}
rootClassName="px-page-x"
/>
) : (
// TODO: Add translation
@@ -8,8 +8,6 @@ import { cn } from "@plane/utils";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
// hooks
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
// plane web imports
import { DesktopSidebarWorkspaceMenu } from "@/plane-web/components/desktop";
// local imports
import { AppSidebarItemsRoot } from "./items-root";
@@ -41,7 +39,6 @@ export const AppRailRoot = observer(() => {
"gap-3": !showLabel,
})}
>
<DesktopSidebarWorkspaceMenu />
<AppSidebarItemsRoot showLabel={showLabel} />
<div className="border-t border-custom-sidebar-border-300 mx-2" />
<AppSidebarItem
@@ -1,35 +0,0 @@
import { TriangleAlert } from "lucide-react";
import { cn } from "@plane/utils";
type Props = {
className?: string;
onDismiss?: () => void;
};
export const ContentLimitBanner: React.FC<Props> = ({ className, onDismiss }) => (
<div
className={cn(
"flex items-center gap-2 bg-custom-background-80 border-b border-custom-border-200 px-4 py-2.5 text-sm",
className
)}
>
<div className="flex items-center gap-2 text-custom-text-200 mx-auto">
<span className="text-amber-500">
<TriangleAlert />
</span>
<span className="font-medium">
Content limit reached and live sync is off. Create a new page or use nested pages to continue syncing.
</span>
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className="ml-auto text-custom-text-300 hover:text-custom-text-200"
aria-label="Dismiss content limit warning"
>
</button>
)}
</div>
);
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
import { CollaborativeDocumentEditorWithRef } from "@plane/editor";
import type {
CollaborationState,
EditorRefApi,
EditorTitleRefApi,
TAIMenuProps,
TDisplayConfig,
TFileHandler,
@@ -27,7 +26,6 @@ import { useUser } from "@/hooks/store/user";
import { usePageFilters } from "@/hooks/use-page-filters";
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
// plane web imports
import type { TCustomEventHandlers } from "@/hooks/use-realtime-page-events";
import { EditorAIMenu } from "@/plane-web/components/pages";
import type { TExtendedEditorExtensionsConfig } from "@/plane-web/hooks/pages";
import type { EPageStoreType } from "@/plane-web/hooks/store";
@@ -53,6 +51,7 @@ type Props = {
config: TEditorBodyConfig;
editorReady: boolean;
editorForwardRef: React.RefObject<EditorRefApi>;
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
handleEditorReady: (status: boolean) => void;
handleOpenNavigationPane: () => void;
handlers: TEditorBodyHandlers;
@@ -62,16 +61,14 @@ type Props = {
projectId?: string;
workspaceSlug: string;
storeType: EPageStoreType;
customRealtimeEventHandlers?: TCustomEventHandlers;
extendedEditorProps: TExtendedEditorExtensionsConfig;
isFetchingFallbackBinary?: boolean;
onCollaborationStateChange?: (state: CollaborationState) => void;
};
export const PageEditorBody = observer(function PageEditorBody(props: Props) {
const {
config,
editorForwardRef,
handleConnectionStatus,
handleEditorReady,
handleOpenNavigationPane,
handlers,
@@ -82,11 +79,7 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
projectId,
workspaceSlug,
extendedEditorProps,
isFetchingFallbackBinary,
onCollaborationStateChange,
} = props;
// refs
const titleEditorRef = useRef<EditorTitleRefApi>(null);
// store hooks
const { data: currentUser } = useUser();
const { getWorkspaceBySlug } = useWorkspace();
@@ -98,7 +91,6 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
isContentEditable,
updateTitle,
editor: { editorRef, updateAssetsList },
setSyncingStatus,
} = page;
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
// use editor mention
@@ -144,35 +136,20 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
[editorRef, workspaceId, workspaceSlug]
);
// Set syncing status when page changes and reset collaboration state
useEffect(() => {
setSyncingStatus("syncing");
onCollaborationStateChange?.({
stage: { kind: "connecting" },
isServerSynced: false,
isServerDisconnected: false,
});
}, [pageId, setSyncingStatus, onCollaborationStateChange]);
const handleServerConnect = useCallback(() => {
handleConnectionStatus(false);
}, [handleConnectionStatus]);
const handleServerError = useCallback(() => {
handleConnectionStatus(true);
}, [handleConnectionStatus]);
const serverHandler: TServerHandler = useMemo(
() => ({
onStateChange: (state) => {
// Pass full state to parent
onCollaborationStateChange?.(state);
// Map collaboration stage to UI syncing status
// Stage → UI mapping: disconnected → error | synced → synced | all others → syncing
if (state.stage.kind === "disconnected") {
setSyncingStatus("error");
} else if (state.stage.kind === "synced") {
setSyncingStatus("synced");
} else {
// initial, connecting, awaiting-sync, reconnecting → show as syncing
setSyncingStatus("syncing");
}
},
onConnect: handleServerConnect,
onServerError: handleServerError,
}),
[setSyncingStatus, onCollaborationStateChange]
[handleServerConnect, handleServerError]
);
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
@@ -217,9 +194,7 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
}
);
const isPageLoading = pageId === undefined || !realtimeConfig;
if (isPageLoading) return <PageContentLoader className={blockWidthClassName} />;
if (pageId === undefined || !realtimeConfig) return <PageContentLoader className={blockWidthClassName} />;
return (
<Row
@@ -250,6 +225,12 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
<div className="page-header-container group/page-header">
<div className={blockWidthClassName}>
<PageEditorHeaderRoot page={page} projectId={projectId} />
<PageEditorTitle
editorRef={editorRef}
readOnly={!isContentEditable}
title={pageTitle}
updateTitle={updateTitle}
/>
</div>
</div>
<CollaborativeDocumentEditorWithRef
@@ -258,7 +239,6 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
fileHandler={config.fileHandler}
handleEditorReady={handleEditorReady}
ref={editorForwardRef}
titleRef={titleEditorRef}
containerClassName="h-full p-0 pb-64"
displayConfig={displayConfig}
getEditorMetaData={getEditorMetaData}
@@ -281,7 +261,6 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
}}
onAssetChange={updateAssetsList}
extendedEditorProps={extendedEditorProps}
isFetchingFallbackBinary={isFetchingFallbackBinary}
/>
</div>
</Row>
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import type { CollaborationState, EditorRefApi } from "@plane/editor";
import type { EditorRefApi } from "@plane/editor";
import type { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types";
// hooks
import { useAppRouter } from "@/hooks/use-app-router";
import { usePageFallback } from "@/hooks/use-page-fallback";
// plane web import
import type { PageUpdateHandler, TCustomEventHandlers } from "@/hooks/use-realtime-page-events";
import { PageModals } from "@/plane-web/components/pages";
import { usePagesPaneExtensions, useExtendedEditorProps } from "@/plane-web/hooks/pages";
import type { EPageStoreType } from "@/plane-web/hooks/store";
@@ -16,7 +16,6 @@ import type { TPageInstance } from "@/store/pages/base-page";
import { PageNavigationPaneRoot } from "../navigation-pane";
import { PageVersionsOverlay } from "../version";
import { PagesVersionEditor } from "../version/editor";
import { ContentLimitBanner } from "./content-limit-banner";
import { PageEditorBody } from "./editor-body";
import type { TEditorBodyConfig, TEditorBodyHandlers } from "./editor-body";
import { PageEditorToolbarRoot } from "./toolbar";
@@ -24,7 +23,7 @@ import { PageEditorToolbarRoot } from "./toolbar";
export type TPageRootHandlers = {
create: (payload: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
fetchDescriptionBinary: () => Promise<ArrayBuffer>;
fetchDescriptionBinary: () => Promise<any>;
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
restoreVersion: (pageId: string, versionId: string) => Promise<void>;
updateDescription: (document: TDocumentPayload) => Promise<void>;
@@ -40,36 +39,27 @@ type TPageRootProps = {
webhookConnectionParams: TWebhookConnectionQueryParams;
projectId?: string;
workspaceSlug: string;
customRealtimeEventHandlers?: TCustomEventHandlers;
};
export const PageRoot = observer((props: TPageRootProps) => {
const {
config,
handlers,
page,
projectId,
storeType,
webhookConnectionParams,
workspaceSlug,
customRealtimeEventHandlers,
} = props;
export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
const { config, handlers, page, projectId, storeType, webhookConnectionParams, workspaceSlug } = props;
// states
const [editorReady, setEditorReady] = useState(false);
const [collaborationState, setCollaborationState] = useState<CollaborationState | null>(null);
const [showContentTooLargeBanner, setShowContentTooLargeBanner] = useState(false);
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
// router
const router = useAppRouter();
// derived values
const {
isContentEditable,
editor: { setEditorRef },
} = page;
// page fallback
const { isFetchingFallbackBinary } = usePageFallback({
usePageFallback({
editorRef,
fetchPageDescription: handlers.fetchDescriptionBinary,
collaborationState,
hasConnectionFailed,
updatePageDescription: handlers.updateDescription,
});
@@ -101,24 +91,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
editorRef,
});
// Type-safe error handler for content too large errors
const errorHandler: PageUpdateHandler<"error"> = (params) => {
const { data } = params;
// Check if it's content too large error
if (data.error_code === "content_too_large") {
setShowContentTooLargeBanner(true);
}
// Call original error handler if exists
customRealtimeEventHandlers?.error?.(params);
};
const mergedCustomEventHandlers: TCustomEventHandlers = {
...customRealtimeEventHandlers,
error: errorHandler,
};
// Get extended editor extensions configuration
const extendedEditorProps = useExtendedEditorProps({
workspaceSlug,
@@ -162,12 +134,11 @@ export const PageRoot = observer((props: TPageRootProps) => {
isNavigationPaneOpen={isNavigationPaneOpen}
page={page}
/>
{showContentTooLargeBanner && <ContentLimitBanner className="px-page-x" />}
<PageEditorBody
config={config}
customRealtimeEventHandlers={mergedCustomEventHandlers}
editorReady={editorReady}
editorForwardRef={editorRef}
handleConnectionStatus={setHasConnectionFailed}
handleEditorReady={handleEditorReady}
handleOpenNavigationPane={handleOpenNavigationPane}
handlers={handlers}
@@ -178,8 +149,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug}
extendedEditorProps={extendedEditorProps}
isFetchingFallbackBinary={isFetchingFallbackBinary}
onCollaborationStateChange={setCollaborationState}
/>
</div>
<PageNavigationPaneRoot
@@ -1,72 +0,0 @@
import { useState, useEffect } from "react";
import { CloudOff } from "lucide-react";
import { Tooltip } from "@plane/ui";
type Props = {
syncStatus: "syncing" | "synced" | "error";
};
export const PageSyncingBadge = ({ syncStatus }: Props) => {
const [prevSyncStatus, setPrevSyncStatus] = useState<"syncing" | "synced" | "error" | null>(null);
const [isVisible, setIsVisible] = useState(syncStatus !== "synced");
useEffect(() => {
// Only handle transitions when there's a change
if (prevSyncStatus !== syncStatus) {
if (syncStatus === "synced") {
// Delay hiding to allow exit animation to complete
setTimeout(() => {
setIsVisible(false);
}, 300); // match animation duration
} else {
setIsVisible(true);
}
setPrevSyncStatus(syncStatus);
}
}, [syncStatus, prevSyncStatus]);
if (!isVisible || syncStatus === "synced") return null;
const badgeContent = {
syncing: {
label: "Syncing...",
tooltipHeading: "Syncing...",
tooltipContent: "Your changes are being synced with the server. You can continue making changes.",
bgColor: "bg-custom-primary-100/20",
textColor: "text-custom-primary-100",
pulseColor: "bg-custom-primary-100",
pulseBgColor: "bg-custom-primary-100/30",
icon: null,
},
error: {
label: "Connection lost",
tooltipHeading: "Connection lost",
tooltipContent:
"We're having trouble connecting to the websocket server. Your changes will be synced and saved every 10 seconds.",
bgColor: "bg-red-500/20",
textColor: "text-red-500",
icon: <CloudOff className="size-3" />,
},
};
// This way we guarantee badgeContent is defined
const content = badgeContent[syncStatus];
return (
<Tooltip tooltipHeading={content.tooltipHeading} tooltipContent={content.tooltipContent}>
<div
className={`flex-shrink-0 h-6 flex items-center gap-1.5 px-2 rounded ${content.textColor} ${content.bgColor} animate-quickFadeIn`}
>
{syncStatus === "syncing" ? (
<div className="relative flex-shrink-0">
<div className="absolute -inset-0.5 rounded-full bg-custom-primary-100/30 animate-ping" />
<div className="relative h-1.5 w-1.5 rounded-full bg-custom-primary-100" />
</div>
) : (
content.icon
)}
<span className="text-xs font-medium">{content.label}</span>
</div>
</Tooltip>
);
};
@@ -2,13 +2,11 @@ import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { isSidebarToggleVisible } from "@/plane-web/components/desktop";
export const AppSidebarToggleButton = observer(function AppSidebarToggleButton() {
// store hooks
const { toggleSidebar, sidebarPeek, toggleSidebarPeek } = useAppTheme();
if (!isSidebarToggleVisible()) return null;
return (
<button
className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90"
@@ -0,0 +1,10 @@
// hooks
import { UserMenuRoot } from "./user-menu-root";
import { WorkspaceMenuRoot } from "./workspace-menu-root";
export const SidebarDropdown = () => (
<div className="flex items-center justify-center gap-1.5 w-full">
<WorkspaceMenuRoot />
<UserMenuRoot />
</div>
);
@@ -25,11 +25,11 @@ import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
type WorkspaceMenuRootProps = {
variant: "sidebar" | "top-navigation";
renderLogoOnly?: boolean;
};
export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: WorkspaceMenuRootProps) {
const { variant } = props;
const { renderLogoOnly } = props;
// store hooks
const { toggleSidebar, toggleAnySidebarDropdown } = useAppTheme();
const { data: currentUser } = useUser();
@@ -72,8 +72,8 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<Menu
as="div"
className={cn("relative h-full flex max-w-48 w-fit whitespace-nowrap truncate", {
"w-full justify-center text-center": variant === "sidebar",
"flex-grow justify-stretch text-left truncate": variant === "top-navigation",
"justify-center text-center": renderLogoOnly,
"flex-grow justify-stretch text-left truncate": !renderLogoOnly,
})}
>
{({ open, close }: { open: boolean; close: () => void }) => {
@@ -84,9 +84,9 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
return (
<>
{variant === "sidebar" && (
{renderLogoOnly ? (
<Menu.Button
className={cn("flex w-full items-center justify-center size-8 rounded-md", {
className={cn("flex items-center justify-center size-8 rounded", {
"bg-custom-sidebar-background-80": open,
})}
>
@@ -97,29 +97,26 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<WorkspaceLogo
logo={activeWorkspace?.logo_url}
name={activeWorkspace?.name}
classNames="size-8 rounded-md border border-custom-border-200"
classNames="size-8 rounded-md"
/>
),
}}
/>
</Menu.Button>
)}
{variant === "top-navigation" && (
) : (
<Menu.Button
className={cn(
"group/menu-button flex items-center gap-1 p-1 truncate rounded text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:outline-none justify-between flex-grow",
"group/menu-button flex items-center gap-1 p-1 truncate rounded text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:outline-none ",
{
"justify-center text-center": renderLogoOnly,
"justify-between flex-grow": !renderLogoOnly,
"bg-custom-sidebar-background-80": open,
}
)}
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
>
<div className="flex-grow flex items-center gap-2 truncate">
<WorkspaceLogo
logo={activeWorkspace?.logo_url}
name={activeWorkspace?.name}
classNames="border border-custom-border-200 size-7"
/>
<WorkspaceLogo logo={activeWorkspace?.logo_url} name={activeWorkspace?.name} />
<h4 className="truncate text-base font-medium text-custom-text-100">
{activeWorkspace?.name ?? t("loading")}
</h4>
@@ -131,6 +128,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
/>
</Menu.Button>
)}
<Transition
as={Fragment}
enter="transition ease-out duration-100"
@@ -141,15 +139,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items as={Fragment}>
<div
className={cn(
"fixed z-[21] mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none",
{
"top-11 left-14": variant === "sidebar",
"top-10 left-4": variant === "top-navigation",
}
)}
>
<div className="fixed top-10 left-4 z-[21] mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
<div className="overflow-x-hidden vertical-scrollbar scrollbar-sm flex max-h-96 flex-col items-start justify-start overflow-y-scroll">
<span className="rounded-md text-left px-4 sticky top-0 z-[21] h-full w-full bg-custom-sidebar-background-100 pb-1 pt-3 text-sm font-medium text-custom-text-400 truncate flex-shrink-0">
{currentUser?.email}
+6 -37
View File
@@ -1,9 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { EditorRefApi, CollaborationState } from "@plane/editor";
import { useCallback, useEffect } from "react";
// plane editor
import { convertBinaryDataToBase64String, getBinaryDataFromDocumentEditorHTMLString } from "@plane/editor";
// plane propel
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import type { EditorRefApi } from "@plane/editor";
// plane types
import type { TDocumentPayload } from "@plane/types";
// hooks
@@ -12,37 +10,19 @@ import useAutoSave from "@/hooks/use-auto-save";
type TArgs = {
editorRef: React.RefObject<EditorRefApi>;
fetchPageDescription: () => Promise<ArrayBuffer>;
collaborationState: CollaborationState | null;
hasConnectionFailed: boolean;
updatePageDescription: (data: TDocumentPayload) => Promise<void>;
};
export const usePageFallback = (args: TArgs) => {
const { editorRef, fetchPageDescription, collaborationState, updatePageDescription } = args;
const hasShownFallbackToast = useRef(false);
const [isFetchingFallbackBinary, setIsFetchingFallbackBinary] = useState(false);
// Derive connection failure from collaboration state
const hasConnectionFailed = collaborationState?.stage.kind === "disconnected";
const { editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription } = args;
const handleUpdateDescription = useCallback(async () => {
if (!hasConnectionFailed) return;
const editor = editorRef.current;
if (!editor) return;
// Show toast notification when fallback mechanism kicks in (only once)
if (!hasShownFallbackToast.current) {
setToast({
type: TOAST_TYPE.WARNING,
title: "Connection lost",
message: "Your changes are being saved using backup mechanism. ",
});
hasShownFallbackToast.current = true;
}
try {
setIsFetchingFallbackBinary(true);
const latestEncodedDescription = await fetchPageDescription();
let latestDecodedDescription: Uint8Array;
if (latestEncodedDescription && latestEncodedDescription.byteLength > 0) {
@@ -61,27 +41,16 @@ export const usePageFallback = (args: TArgs) => {
description_html: html,
description: json,
});
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error",
message: `Failed to update description using backup mechanism, ${error?.message}`,
});
} finally {
setIsFetchingFallbackBinary(false);
} catch (error) {
console.error("Error in updating description using fallback logic:", error);
}
}, [editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription]);
useEffect(() => {
if (hasConnectionFailed) {
handleUpdateDescription();
} else {
// Reset toast flag when connection is restored
hasShownFallbackToast.current = false;
}
}, [handleUpdateDescription, hasConnectionFailed]);
useAutoSave(handleUpdateDescription);
return { isFetchingFallbackBinary };
};
@@ -125,16 +125,18 @@ export class ProjectInboxStore implements IProjectInboxStore {
* @description computed project inbox filters
*/
get inboxFilters() {
if (!this.currentInboxProjectId) return {} as TInboxIssueFilter;
return this.filtersMap?.[this.currentInboxProjectId];
const { projectId } = this.store.router;
if (!projectId) return {} as TInboxIssueFilter;
return this.filtersMap?.[projectId];
}
/**
* @description computed project inbox sorting
*/
get inboxSorting() {
if (!this.currentInboxProjectId) return {} as TInboxIssueSorting;
return this.sortingMap?.[this.currentInboxProjectId];
const { projectId } = this.store.router;
if (!projectId) return {} as TInboxIssueSorting;
return this.sortingMap?.[projectId];
}
get getAppliedFiltersCount() {
@@ -272,8 +274,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
};
handleInboxIssueFilters = <T extends keyof TInboxIssueFilter>(key: T, value: TInboxIssueFilter[T]) => {
const { workspaceSlug } = this.store.router;
const projectId = this.currentInboxProjectId;
const { workspaceSlug, projectId } = this.store.router;
if (workspaceSlug && projectId) {
runInAction(() => {
set(this.filtersMap, [projectId, key], value);
@@ -284,8 +285,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
};
handleInboxIssueSorting = <T extends keyof TInboxIssueSorting>(key: T, value: TInboxIssueSorting[T]) => {
const { workspaceSlug } = this.store.router;
const projectId = this.currentInboxProjectId;
const { workspaceSlug, projectId } = this.store.router;
if (workspaceSlug && projectId) {
runInAction(() => {
set(this.sortingMap, [projectId, key], value);
-10
View File
@@ -13,7 +13,6 @@ import { PageEditorInstance } from "./page-editor-info";
export type TBasePage = TPage & {
// observables
isSubmitting: TNameDescriptionLoader;
isSyncingWithServer: "syncing" | "synced" | "error";
// computed
asJSON: TPage | undefined;
isCurrentUserOwner: boolean;
@@ -36,7 +35,6 @@ export type TBasePage = TPage & {
removePageFromFavorites: () => Promise<void>;
duplicate: () => Promise<TPage | undefined>;
mutateProperties: (data: Partial<TPage>, shouldUpdateName?: boolean) => void;
setSyncingStatus: (status: "syncing" | "synced" | "error") => void;
// sub-store
editor: PageEditorInstance;
};
@@ -75,7 +73,6 @@ export type TPageInstance = TBasePage &
export class BasePage extends ExtendedBasePage implements TBasePage {
// loaders
isSubmitting: TNameDescriptionLoader = "saved";
isSyncingWithServer: "syncing" | "synced" | "error" = "syncing";
// page properties
id: string | undefined;
name: string | undefined;
@@ -158,7 +155,6 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
created_at: observable.ref,
updated_at: observable.ref,
deleted_at: observable.ref,
isSyncingWithServer: observable.ref,
// helpers
oldName: observable.ref,
setIsSubmitting: action,
@@ -539,10 +535,4 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
set(this, key, value);
});
};
setSyncingStatus = (status: "syncing" | "synced" | "error") => {
runInAction(() => {
this.isSyncingWithServer = status;
});
};
}
-3
View File
@@ -44,16 +44,13 @@
"@tiptap/extension-blockquote": "^2.22.3",
"@tiptap/extension-character-count": "^2.22.3",
"@tiptap/extension-collaboration": "^2.22.3",
"@tiptap/extension-document": "^3.2.0",
"@tiptap/extension-emoji": "^2.22.3",
"@tiptap/extension-heading": "^3.4.3",
"@tiptap/extension-image": "^2.22.3",
"@tiptap/extension-list-item": "^2.22.3",
"@tiptap/extension-mention": "^2.22.3",
"@tiptap/extension-placeholder": "^2.22.3",
"@tiptap/extension-task-item": "^2.22.3",
"@tiptap/extension-task-list": "^2.22.3",
"@tiptap/extension-text": "^3.2.0",
"@tiptap/extension-text-align": "^2.22.3",
"@tiptap/extension-text-style": "^2.22.3",
"@tiptap/extension-underline": "^2.22.3",
@@ -1,21 +1,20 @@
import React, { useMemo } from "react";
import React from "react";
// plane imports
import { cn } from "@plane/utils";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// contexts
import { CollaborationProvider, useCollaboration } from "@/contexts/collaboration-context";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// constants
import { DocumentEditorSideEffects } from "@/plane-editor/components/document-editor-side-effects";
// types
import type { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
import { DocumentEditorSideEffects } from "@/plane-editor/components/document-editor-side-effects";
function CollaborativeDocumentEditorInner(props: ICollaborativeDocumentEditorProps) {
function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
const {
aiHandler,
bubbleMenuEnabled = true,
@@ -42,20 +41,15 @@ function CollaborativeDocumentEditorInner(props: ICollaborativeDocumentEditorPro
onEditorFocus,
onTransaction,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
extendedDocumentEditorProps,
titleRef,
updatePageProperties,
isFetchingFallbackBinary,
} = props;
// Get non-null provider from context
const { provider, state, actions } = useCollaboration();
// Editor initialization with guaranteed non-null provider
const { editor, titleEditor } = useCollaborativeEditor({
provider,
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
disabledExtensions,
editable,
editorClassName,
@@ -76,10 +70,11 @@ function CollaborativeDocumentEditorInner(props: ICollaborativeDocumentEditorPro
onEditorFocus,
onTransaction,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
titleRef,
user,
actions,
extendedDocumentEditorProps,
});
const editorContainerClassNames = getEditorClassNames({
@@ -88,72 +83,37 @@ function CollaborativeDocumentEditorInner(props: ICollaborativeDocumentEditorPro
containerClassName,
});
// Show loader ONLY when cache is known empty and server hasn't synced yet
const shouldShowSyncLoader = state.isCacheReady && !state.hasCachedContent && !state.isServerSynced;
const shouldWaitForFallbackBinary = isFetchingFallbackBinary && !state.hasCachedContent && state.isServerDisconnected;
const isLoading = shouldShowSyncLoader || shouldWaitForFallbackBinary;
// Gate content rendering on isDocReady to prevent empty editor flash
const showContentSkeleton = !state.isDocReady;
if (!editor || !titleEditor) return null;
if (!editor) return null;
return (
<>
<div
className={cn(
"transition-opacity duration-200",
showContentSkeleton && !isLoading && "opacity-0 pointer-events-none"
)}
>
<DocumentEditorSideEffects editor={editor} id={id} extendedEditorProps={extendedEditorProps} />
<PageRenderer
aiHandler={aiHandler}
bubbleMenuEnabled={bubbleMenuEnabled}
displayConfig={displayConfig}
documentLoaderClassName={documentLoaderClassName}
disabledExtensions={disabledExtensions}
extendedDocumentEditorProps={extendedDocumentEditorProps}
editor={editor}
flaggedExtensions={flaggedExtensions}
titleEditor={titleEditor}
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
extendedEditorProps={extendedEditorProps}
id={id}
isLoading={isLoading}
isTouchDevice={!!isTouchDevice}
tabIndex={tabIndex}
provider={provider}
state={state}
/>
</div>
<DocumentEditorSideEffects editor={editor} id={id} extendedEditorProps={extendedEditorProps} />
<PageRenderer
aiHandler={aiHandler}
bubbleMenuEnabled={bubbleMenuEnabled}
displayConfig={displayConfig}
documentLoaderClassName={documentLoaderClassName}
editor={editor}
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
extendedEditorProps={extendedEditorProps}
id={id}
isTouchDevice={!!isTouchDevice}
isLoading={!hasServerSynced && !hasServerConnectionFailed}
tabIndex={tabIndex}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
extendedDocumentEditorProps={extendedDocumentEditorProps}
/>
</>
);
}
// Outer component that provides collaboration context
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
const { id, realtimeConfig, serverHandler, user } = props;
const token = useMemo(() => JSON.stringify(user), [user]);
return (
<CollaborationProvider
docId={id}
serverUrl={realtimeConfig.url}
authToken={token}
onStateChange={serverHandler?.onStateChange}
>
<CollaborativeDocumentEditorInner {...props} />
</CollaborationProvider>
);
};
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
(props, ref) => (
<CollaborativeDocumentEditor key={props.id} {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi>} />
)
);
const CollaborativeDocumentEditorWithRef = React.forwardRef(function CollaborativeDocumentEditorWithRef(
props: ICollaborativeDocumentEditorProps,
ref: React.ForwardedRef<EditorRefApi>
) {
return <CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />;
});
CollaborativeDocumentEditorWithRef.displayName = "CollaborativeDocumentEditorWithRef";
@@ -1,12 +1,10 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Editor } from "@tiptap/react";
// plane imports
import { cn } from "@plane/utils";
// components
import { DocumentContentLoader, EditorContainer, EditorContentWrapper } from "@/components/editors";
import { BlockMenu, EditorBubbleMenu } from "@/components/menus";
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
// types
import type { TCollabValue } from "@/contexts";
import type {
ICollaborativeDocumentEditorPropsExtended,
IEditorProps,
@@ -22,7 +20,6 @@ type Props = {
displayConfig: TDisplayConfig;
documentLoaderClassName?: string;
editor: Editor;
titleEditor?: Editor;
editorContainerClassName: string;
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
extendedEditorProps: IEditorPropsExtended;
@@ -30,13 +27,12 @@ type Props = {
id: string;
isLoading?: boolean;
isTouchDevice: boolean;
provider?: HocuspocusProvider;
state?: TCollabValue["state"];
tabIndex?: number;
};
export function PageRenderer(props: Props) {
const {
aiHandler,
bubbleMenuEnabled,
disabledExtensions,
displayConfig,
@@ -49,10 +45,8 @@ export function PageRenderer(props: Props) {
isLoading,
isTouchDevice,
tabIndex,
provider,
state,
titleEditor
} = props;
return (
<div
className={cn("frame-renderer flex-grow w-full", {
@@ -62,47 +56,33 @@ export function PageRenderer(props: Props) {
{isLoading ? (
<DocumentContentLoader className={documentLoaderClassName} />
) : (
<>
{titleEditor && (
<div className="relative w-full py-3">
<EditorContainer
editor={titleEditor}
id={id + "-title"}
isTouchDevice={isTouchDevice}
editorContainerClassName="page-title-editor bg-transparent py-3 border-none"
displayConfig={displayConfig}
>
<EditorContentWrapper
editor={titleEditor}
id={id + "-title"}
tabIndex={tabIndex}
className="no-scrollbar placeholder-custom-text-400 bg-transparent tracking-[-2%] font-bold text-[2rem] leading-[2.375rem] w-full outline-none p-0 border-none resize-none rounded-none"
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
isTouchDevice={isTouchDevice}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && !isTouchDevice && (
<div>
{bubbleMenuEnabled && (
<EditorBubbleMenu
disabledExtensions={disabledExtensions}
editor={editor}
extendedEditorProps={extendedEditorProps}
flaggedExtensions={flaggedExtensions}
/>
</EditorContainer>
)}
<BlockMenu
editor={editor}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
/>
<AIFeaturesMenu menu={aiHandler?.menu} />
</div>
)}
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
isTouchDevice={isTouchDevice}
provider={provider}
state={state}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && !isTouchDevice && (
<div>
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} disabledExtensions={disabledExtensions} extendedEditorProps={extendedEditorProps} flaggedExtensions={flaggedExtensions}/>}
<BlockMenu
editor={editor}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
/>
</div>
)}
</EditorContainer>
</>
</EditorContainer>
)}
</div>
);
@@ -1,17 +1,13 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Editor } from "@tiptap/react";
import type { FC, ReactNode } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// components
import type { TCollabValue } from "@/contexts";
import { LinkContainer } from "@/plane-editor/components/link-container";
// plugins
import { nodeHighlightPluginKey } from "@/plugins/highlight";
// types
import type { TDisplayConfig } from "@/types";
@@ -22,85 +18,12 @@ type Props = {
editorContainerClassName: string;
id: string;
isTouchDevice: boolean;
provider?: HocuspocusProvider | undefined;
state?: TCollabValue["state"];
};
export const EditorContainer: FC<Props> = (props) => {
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice, provider, state } = props;
export function EditorContainer(props: Props) {
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice } = props;
// refs
const containerRef = useRef<HTMLDivElement>(null);
const hasScrolledOnce = useRef(false);
const scrollToNode = useCallback(
(nodeId: string) => {
if (!editor) return false;
const doc = editor.state.doc;
let pos: number | null = null;
doc.descendants((node, position) => {
if (node.attrs && node.attrs.id === nodeId) {
pos = position;
return false;
}
});
if (pos === null) {
return false;
}
const nodePosition = pos;
const tr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId });
editor.view.dispatch(tr);
requestAnimationFrame(() => {
const domNode = editor.view.nodeDOM(nodePosition);
if (domNode instanceof HTMLElement) {
domNode.scrollIntoView({ behavior: "instant", block: "center" });
}
});
editor.once("focus", () => {
const clearTr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId: null });
editor.view.dispatch(clearTr);
});
hasScrolledOnce.current = true;
return true;
},
[editor]
);
useEffect(() => {
const nodeId = window.location.href.split("#")[1];
const handleSynced = () => scrollToNode(nodeId);
if (nodeId && !hasScrolledOnce.current) {
if (provider && state) {
const { hasCachedContent } = state;
// If the provider is synced or the cached content is available and the server is disconnected, scroll to the node
if (hasCachedContent) {
const hasScrolled = handleSynced();
if (!hasScrolled) {
provider.on("synced", handleSynced);
}
} else if (provider.isSynced) {
handleSynced();
} else {
provider.on("synced", handleSynced);
}
} else {
handleSynced();
}
return () => {
if (provider) {
provider.off("synced", handleSynced);
}
};
}
}, [scrollToNode, provider, state]);
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (event.target !== event.currentTarget) return;
@@ -165,6 +88,7 @@ export const EditorContainer: FC<Props> = (props) => {
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
{
"active-editor": editor?.isFocused && editor?.isEditable,
"wide-layout": displayConfig.wideLayout,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
@@ -3,22 +3,17 @@ import type { Editor } from "@tiptap/react";
import type { FC, ReactNode } from "react";
type Props = {
className?: string;
children?: ReactNode;
editor: Editor | null;
id: string;
tabIndex?: number;
};
export const EditorContentWrapper: FC<Props> = (props) => {
const { editor, className, children, tabIndex, id } = props;
export function EditorContentWrapper(props: Props) {
const { editor, children, tabIndex, id } = props;
return (
<div
tabIndex={tabIndex}
onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}
className={className}
>
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
<EditorContent editor={editor} id={id} />
{children}
</div>
@@ -1,32 +0,0 @@
import React, { createContext, useContext } from "react";
// hooks
import { useYjsSetup } from "@/hooks/use-yjs-setup";
export type TCollabValue = NonNullable<ReturnType<typeof useYjsSetup>>;
const CollabContext = createContext<TCollabValue | null>(null);
type CollabProviderProps = Parameters<typeof useYjsSetup>[0] & {
fallback?: React.ReactNode;
children: React.ReactNode;
};
export function CollaborationProvider({ fallback = null, children, ...args }: CollabProviderProps) {
const setup = useYjsSetup(args);
// Only wait for provider setup, not content ready
// Consumers can check state.isDocReady to gate content rendering
if (!setup) {
return <>{fallback}</>;
}
return <CollabContext.Provider value={setup}>{children}</CollabContext.Provider>;
}
export function useCollaboration(): TCollabValue {
const ctx = useContext(CollabContext);
if (!ctx) {
throw new Error("useCollaboration must be used inside <CollaborationProvider>");
}
return ctx; // guaranteed non-null
}
@@ -1 +0,0 @@
export * from "./collaboration-context";
@@ -1,14 +0,0 @@
import type { AnyExtension, Extensions } from "@tiptap/core";
import Document from "@tiptap/extension-document";
import Heading from "@tiptap/extension-heading";
import Text from "@tiptap/extension-text";
export const TitleExtensions: Extensions = [
Document.extend({
content: "heading",
}),
Heading.configure({
levels: [1],
}) as AnyExtension,
Text,
];
+5 -14
View File
@@ -2,27 +2,25 @@ import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Editor } from "@tiptap/core";
import { DOMSerializer } from "@tiptap/pm/model";
import * as Y from "yjs";
// plane imports
import { convertHTMLToMarkdown } from "@plane/utils";
// components
import { getEditorMenuItems } from "@/components/menus";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import { CORE_EDITOR_META } from "@/constants/meta";
// types
import type { EditorRefApi, IEditorProps, TEditorCommands } from "@/types";
import type { EditorRefApi, TEditorCommands } from "@/types";
// local imports
import { getParagraphCount } from "./common";
import { insertContentAtSavedSelection } from "./insert-content-at-cursor-position";
import { scrollSummary, scrollToNodeViaDOMCoordinates } from "./scroll-to-node";
type TArgs = Pick<IEditorProps, "getEditorMetaData"> & {
type TArgs = {
editor: Editor | null;
provider: HocuspocusProvider | undefined;
};
export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
const { editor, getEditorMetaData, provider } = args;
const { editor, provider } = args;
return {
blur: () => editor?.commands.blur(),
@@ -79,15 +77,8 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
}),
getHeadings: () => (editor ? editor.storage.headingsList?.headings : []),
getMarkDown: () => {
if (!editor) return "";
const editorHTML = editor.getHTML();
const metaData = getEditorMetaData(editorHTML);
// convert to markdown
const markdown = convertHTMLToMarkdown({
description_html: editorHTML,
metaData,
});
return markdown;
const markdownOutput = editor?.storage?.markdown?.getMarkdown?.() ?? "";
return markdownOutput;
},
isAnyDropbarOpen: () => {
if (!editor) return false;
+10 -37
View File
@@ -1,5 +1,4 @@
import { Buffer } from "buffer";
import type { Extensions } from "@tiptap/core";
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
@@ -10,12 +9,10 @@ import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
} from "@/extensions/core-without-props";
import { TitleExtensions } from "@/extensions/title-extension";
// editor extension configs
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
export const TITLE_EDITOR_EXTENSIONS: Extensions = TitleExtensions;
// editor schemas
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
@@ -48,10 +45,9 @@ export const convertBinaryDataToBase64String = (document: Uint8Array): string =>
/**
* @description this function decodes base64 string to binary data
* @param {string} document
* @returns {Buffer<ArrayBuffer>}
* @returns {ArrayBuffer}
*/
export const convertBase64StringToBinaryData = (document: string): Buffer<ArrayBuffer> =>
Buffer.from(document, "base64");
export const convertBase64StringToBinaryData = (document: string): ArrayBuffer => Buffer.from(document, "base64");
/**
* @description this function generates the binary equivalent of html content for the rich text editor
@@ -118,13 +114,11 @@ export const getAllDocumentFormatsFromRichTextEditorBinaryData = (
* @returns
*/
export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
description: Uint8Array,
updateTitle: boolean
description: Uint8Array
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
titleHTML?: string;
} => {
// encode binary description data
const base64Data = convertBinaryDataToBase64String(description);
@@ -136,24 +130,11 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
if (updateTitle) {
const title = yDoc.getXmlFragment("title");
const titleJSON = yXmlFragmentToProseMirrorRootNode(title, documentEditorSchema).toJSON();
const titleHTML = extractTextFromHTML(generateHTML(titleJSON, DOCUMENT_EDITOR_EXTENSIONS));
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
titleHTML,
};
} else {
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
}
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
};
type TConvertHTMLDocumentToAllFormatsArgs = {
@@ -189,10 +170,8 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
// Convert HTML to binary format for document editor
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
// Generate all document formats from the binary data
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
contentBinary,
false
);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
@@ -204,9 +183,3 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
return allFormats;
};
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
@@ -1,9 +1,7 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Extensions } from "@tiptap/core";
import { HocuspocusProvider } from "@hocuspocus/provider";
import Collaboration from "@tiptap/extension-collaboration";
// react
import type React from "react";
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb";
// extensions
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
// hooks
@@ -11,28 +9,10 @@ import { useEditor } from "@/hooks/use-editor";
// plane editor extensions
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import type {
TCollaborativeEditorHookProps,
ICollaborativeDocumentEditorProps,
IEditorPropsExtended,
TEditorHookProps,
EditorTitleRefApi,
} from "@/types";
// local imports
import { useEditorNavigation } from "./use-editor-navigation";
import { useTitleEditor } from "./use-title-editor";
import type { TCollaborativeEditorHookProps } from "@/types";
type UseCollaborativeEditorArgs = Omit<TCollaborativeEditorHookProps, "realtimeConfig" | "serverHandler" | "user"> & {
provider: HocuspocusProvider;
user: TCollaborativeEditorHookProps["user"];
actions: {
signalForcedClose: (value: boolean) => void;
};
};
export const useCollaborativeEditor = (props: UseCollaborativeEditorArgs) => {
export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => {
const {
provider,
onAssetChange,
onChange,
onTransaction,
@@ -44,27 +24,70 @@ export const useCollaborativeEditor = (props: UseCollaborativeEditorArgs) => {
extensions = [],
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
dragDropEnabled = true,
isTouchDevice,
mentionHandler,
onEditorFocus,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
titleRef,
updatePageProperties,
user,
actions,
} = props;
// states
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
const [hasServerSynced, setHasServerSynced] = useState(false);
// initialize Hocuspocus provider
const provider = useMemo(
() =>
new HocuspocusProvider({
name: id,
// using user id as a token to verify the user on the server
token: JSON.stringify(user),
url: realtimeConfig.url,
onAuthenticationFailed: () => {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
},
onConnect: () => serverHandler?.onConnect?.(),
onClose: (data) => {
if (data.event.code === 1006) {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
}
},
onSynced: () => setHasServerSynced(true),
}),
[id, realtimeConfig, serverHandler, user]
);
const { mainNavigationExtension, titleNavigationExtension, setMainEditor, setTitleEditor } = useEditorNavigation();
const localProvider = useMemo(
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
[id, provider]
);
// Memoize extensions to avoid unnecessary editor recreations
const editorExtensions = useMemo(
() => [
// destroy and disconnect all providers connection on unmount
useEffect(
() => () => {
provider?.destroy();
localProvider?.destroy();
},
[provider, localProvider]
);
const editor = useEditor({
disabledExtensions,
extendedEditorProps,
id,
editable,
editorProps,
editorClassName,
enableHistory: false,
extensions: [
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
dragDropEnabled,
@@ -72,7 +95,6 @@ export const useCollaborativeEditor = (props: UseCollaborativeEditorArgs) => {
HeadingListExtension,
Collaboration.configure({
document: provider.document,
field: "default",
}),
...extensions,
...DocumentEditorAdditionalExtensions({
@@ -84,118 +106,26 @@ export const useCollaborativeEditor = (props: UseCollaborativeEditorArgs) => {
provider,
userDetails: user,
}),
mainNavigationExtension,
],
[
provider,
disabledExtensions,
dragDropEnabled,
extensions,
extendedEditorProps,
fileHandler,
flaggedExtensions,
editable,
user,
mainNavigationExtension,
]
);
// Editor configuration
const editorConfig = useMemo<TEditorHookProps>(
() => ({
disabledExtensions,
extendedEditorProps,
id,
editable,
editorProps,
editorClassName,
enableHistory: false,
extensions: editorExtensions,
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
isTouchDevice,
mentionHandler,
onAssetChange,
onChange,
onEditorFocus,
onTransaction,
placeholder,
provider,
tabIndex,
}),
[
provider,
disabledExtensions,
extendedEditorProps,
id,
editable,
editorProps,
editorClassName,
editorExtensions,
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
isTouchDevice,
mentionHandler,
onAssetChange,
onChange,
onEditorFocus,
onTransaction,
placeholder,
tabIndex,
]
);
const editor = useEditor(editorConfig);
const titleExtensions = useMemo(
() => [
Collaboration.configure({
document: provider.document,
field: "title",
}),
titleNavigationExtension,
],
[provider, titleNavigationExtension]
);
const titleEditorConfig = useMemo<{
id: string;
editable: boolean;
provider: HocuspocusProvider;
titleRef?: React.MutableRefObject<EditorTitleRefApi | null>;
updatePageProperties?: ICollaborativeDocumentEditorProps["updatePageProperties"];
extensions: Extensions;
extendedEditorProps?: IEditorPropsExtended;
}>(
() => ({
id,
editable,
provider,
titleRef,
updatePageProperties,
extensions: titleExtensions,
extendedEditorProps,
}),
[provider, id, editable, titleRef, updatePageProperties, titleExtensions, extendedEditorProps]
);
const titleEditor = useTitleEditor(titleEditorConfig as Parameters<typeof useTitleEditor>[0]);
useEffect(() => {
if (editor && titleEditor) {
setMainEditor(editor);
setTitleEditor(titleEditor);
}
}, [editor, titleEditor, setMainEditor, setTitleEditor]);
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
isTouchDevice,
mentionHandler,
onAssetChange,
onChange,
onEditorFocus,
onTransaction,
placeholder,
provider,
tabIndex,
});
return {
editor,
titleEditor,
hasServerConnectionFailed,
hasServerSynced,
};
};
@@ -1,169 +0,0 @@
import type { Editor } from "@tiptap/core";
import { Extension } from "@tiptap/core";
import { useCallback, useRef } from "react";
/**
* Creates a title editor extension that enables keyboard navigation to the main editor
*
* @param getMainEditor Function to get the main editor instance
* @returns A Tiptap extension with keyboard shortcuts
*/
export const createTitleNavigationExtension = (getMainEditor: () => Editor | null) =>
Extension.create({
name: "titleEditorNavigation",
priority: 10,
addKeyboardShortcuts() {
return {
// Arrow down at end of title - Move to main editor
ArrowDown: () => {
const mainEditor = getMainEditor();
if (!mainEditor) return false;
// If cursor is at the end of the title
mainEditor.commands.focus("start");
return true;
},
// Right arrow at end of title - Move to main editor
ArrowRight: ({ editor: titleEditor }) => {
const mainEditor = getMainEditor();
if (!mainEditor) return false;
const { from, to } = titleEditor.state.selection;
const documentLength = titleEditor.state.doc.content.size;
// If cursor is at the end of the title
if (from === to && to === documentLength - 1) {
mainEditor.commands.focus("start");
return true;
}
return false;
},
// Enter - Create new line in main editor and focus
Enter: () => {
const mainEditor = getMainEditor();
if (!mainEditor) return false;
// Focus at the start of the main editor
mainEditor.chain().focus().insertContentAt(0, { type: "paragraph" }).run();
return true;
},
};
},
});
/**
* Creates a main editor extension that enables keyboard navigation to the title editor
*
* @param getTitleEditor Function to get the title editor instance
* @returns A Tiptap extension with keyboard shortcuts
*/
export const createMainNavigationExtension = (getTitleEditor: () => Editor | null) =>
Extension.create({
name: "mainEditorNavigation",
priority: 10,
addKeyboardShortcuts() {
return {
// Arrow up at start of main editor - Move to title editor
ArrowUp: ({ editor: mainEditor }) => {
const titleEditor = getTitleEditor();
if (!titleEditor) return false;
const { from, to } = mainEditor.state.selection;
// If cursor is at the start of the main editor
if (from === 1 && to === 1) {
titleEditor.commands.focus("end");
return true;
}
return false;
},
// Left arrow at start of main editor - Move to title editor
ArrowLeft: ({ editor: mainEditor }) => {
const titleEditor = getTitleEditor();
if (!titleEditor) return false;
const { from, to } = mainEditor.state.selection;
// If cursor is at the absolute start of the main editor
if (from === 1 && to === 1) {
titleEditor.commands.focus("end");
return true;
}
return false;
},
// Backspace - Special handling for first paragraph
Backspace: ({ editor }) => {
const titleEditor = getTitleEditor();
if (!titleEditor) return false;
const { from, to, empty } = editor.state.selection;
// Only handle when cursor is at position 1 with empty selection
if (from === 1 && to === 1 && empty) {
const firstNode = editor.state.doc.firstChild;
// If first node is a paragraph
if (firstNode && firstNode.type.name === "paragraph") {
// If paragraph is already empty, delete it and focus title editor
if (firstNode.content.size === 0) {
editor.commands.deleteNode("paragraph");
// Use setTimeout to ensure the node is deleted before changing focus
setTimeout(() => titleEditor.commands.focus("end"), 0);
return true;
}
// If paragraph is not empty, just move focus to title editor
else {
titleEditor.commands.focus("end");
return true;
}
}
}
return false;
},
};
},
});
/**
* Hook to manage navigation between title and main editors
*
* Creates extension factories for keyboard navigation between editors
* and maintains references to both editors
*
* @returns Object with editor setters and extensions
*/
export const useEditorNavigation = () => {
// Create refs to store editor instances
const titleEditorRef = useRef<Editor | null>(null);
const mainEditorRef = useRef<Editor | null>(null);
// Create stable getter functions
const getTitleEditor = useCallback(() => titleEditorRef.current, []);
const getMainEditor = useCallback(() => mainEditorRef.current, []);
// Create stable setter functions
const setTitleEditor = useCallback((editor: Editor | null) => {
titleEditorRef.current = editor;
}, []);
const setMainEditor = useCallback((editor: Editor | null) => {
mainEditorRef.current = editor;
}, []);
// Create extension factories that access editor refs
const titleNavigationExtension = createTitleNavigationExtension(getMainEditor);
const mainNavigationExtension = createMainNavigationExtension(getTitleEditor);
return {
setTitleEditor,
setMainEditor,
titleNavigationExtension,
mainNavigationExtension,
};
};
+1 -10
View File
@@ -132,16 +132,7 @@ export const useEditor = (props: TEditorHookProps) => {
onAssetChange(assets);
}, [assetsList?.assets, onAssetChange]);
useImperativeHandle(
forwardedRef,
() =>
getEditorRefHelpers({
editor,
getEditorMetaData,
provider,
}),
[editor, getEditorMetaData, provider]
);
useImperativeHandle(forwardedRef, () => getEditorRefHelpers({ editor, provider }), [editor, provider]);
if (!editor) {
return null;
@@ -1,80 +0,0 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Extensions } from "@tiptap/core";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useEditor } from "@tiptap/react";
import { useImperativeHandle } from "react";
// constants
import { CORE_EDITOR_META } from "@/constants/meta";
// extensions
import { TitleExtensions } from "@/extensions/title-extension";
// helpers
import { getEditorRefHelpers } from "@/helpers/editor-ref";
// types
import type { IEditorPropsExtended } from "@/types";
import type { EditorTitleRefApi, ICollaborativeDocumentEditorProps } from "@/types/editor";
type Props = {
editable?: boolean;
provider: HocuspocusProvider;
titleRef?: React.MutableRefObject<EditorTitleRefApi | null>;
extensions?: Extensions;
initialValue?: string;
field?: string;
placeholder?: string;
updatePageProperties?: ICollaborativeDocumentEditorProps["updatePageProperties"];
id: string;
extendedEditorProps?: IEditorPropsExtended;
};
/**
* A hook that creates a title editor with collaboration features
* Uses the same Y.Doc as the main editor but a different field
*/
export const useTitleEditor = (props: Props) => {
const { editable = true, id, initialValue = "", extensions, provider, updatePageProperties, titleRef } = props;
// Force editor recreation when Y.Doc changes (provider.document.guid)
const docKey = provider?.document?.guid ?? id;
const editor = useEditor(
{
onUpdate: () => {
updatePageProperties?.(id, "property_updated", { name: editor?.getText() });
},
editable,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
extensions: [
...TitleExtensions,
...(extensions ?? []),
Placeholder.configure({
placeholder: () => "Untitled",
includeChildren: true,
showOnlyWhenEditable: false,
}),
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<h1></h1>",
},
[editable, initialValue, docKey]
);
useImperativeHandle(titleRef, () => ({
...getEditorRefHelpers({
editor,
provider,
}),
clearEditor: (emitUpdate = false) => {
editor
?.chain()
.setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true)
.setMeta(CORE_EDITOR_META.INTENTIONAL_DELETION, true)
.clearContent(emitUpdate)
.run();
},
setEditorValue: (content: string) => {
editor?.commands.setContent(content, false);
},
}));
return editor;
};
@@ -1,369 +0,0 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
// react
import { useCallback, useEffect, useRef, useState } from "react";
// indexeddb
import { IndexeddbPersistence } from "y-indexeddb";
// yjs
import type * as Y from "yjs";
// types
import type { CollaborationState, CollabStage, CollaborationError } from "@/types/collaboration";
// Helper to check if a close code indicates a forced close
const isForcedCloseCode = (code: number | undefined): boolean => {
if (!code) return false;
// All custom close codes (4000-4003) are treated as forced closes
return code >= 4000 && code <= 4003;
};
type UseYjsSetupArgs = {
docId: string;
serverUrl: string;
authToken: string;
onStateChange?: (state: CollaborationState) => void;
options?: {
maxConnectionAttempts?: number;
};
};
const DEFAULT_MAX_RETRIES = 3;
export const useYjsSetup = ({ docId, serverUrl, authToken, onStateChange }: UseYjsSetupArgs) => {
// Current collaboration stage
const [stage, setStage] = useState<CollabStage>({ kind: "initial" });
// Cache readiness state
const [hasCachedContent, setHasCachedContent] = useState(false);
const [isCacheReady, setIsCacheReady] = useState(false);
// Provider and Y.Doc in state (nullable until effect runs)
const [yjsSession, setYjsSession] = useState<{ provider: HocuspocusProvider; ydoc: Y.Doc } | null>(null);
// Use refs for values that need to be mutated from callbacks
const retryCountRef = useRef(0);
const forcedCloseSignalRef = useRef(false);
const isDisposedRef = useRef(false);
const stageRef = useRef<CollabStage>({ kind: "initial" });
const lastReconnectTimeRef = useRef(0);
// Create/destroy provider in effect (not during render)
useEffect(() => {
// Reset refs when creating new provider (e.g., document switch)
retryCountRef.current = 0;
isDisposedRef.current = false;
forcedCloseSignalRef.current = false;
stageRef.current = { kind: "initial" };
const provider = new HocuspocusProvider({
name: docId,
token: authToken,
url: serverUrl,
onAuthenticationFailed: () => {
if (isDisposedRef.current) return;
const error: CollaborationError = { type: "auth-failed", message: "Authentication failed" };
const newStage = { kind: "disconnected" as const, error };
stageRef.current = newStage;
setStage(newStage);
},
onConnect: () => {
if (isDisposedRef.current) {
provider?.disconnect();
return;
}
retryCountRef.current = 0;
// After successful connection, transition to awaiting-sync (onSynced will move to synced)
const newStage = { kind: "awaiting-sync" as const };
stageRef.current = newStage;
setStage(newStage);
},
onStatus: ({ status: providerStatus }) => {
if (isDisposedRef.current) return;
if (providerStatus === "connecting") {
// Derive whether this is initial connect or reconnection from retry count
const isReconnecting = retryCountRef.current > 0;
setStage(isReconnecting ? { kind: "reconnecting", attempt: retryCountRef.current } : { kind: "connecting" });
} else if (providerStatus === "disconnected") {
// Do not transition here; let handleClose decide the final stage
} else if (providerStatus === "connected") {
// Connection succeeded, move to awaiting-sync
const newStage = { kind: "awaiting-sync" as const };
stageRef.current = newStage;
setStage(newStage);
}
},
onSynced: () => {
if (isDisposedRef.current) return;
retryCountRef.current = 0;
// Document sync complete
const newStage = { kind: "synced" as const };
stageRef.current = newStage;
setStage(newStage);
},
});
const pauseProvider = () => {
const wsProvider = provider.configuration.websocketProvider;
if (wsProvider) {
try {
wsProvider.shouldConnect = false;
wsProvider.disconnect();
} catch (error) {
console.error(`Error pausing websocketProvider:`, error);
}
}
};
const permanentlyStopProvider = () => {
isDisposedRef.current = true;
const wsProvider = provider.configuration.websocketProvider;
if (wsProvider) {
try {
wsProvider.shouldConnect = false;
wsProvider.disconnect();
wsProvider.destroy();
} catch (error) {
console.error(`Error tearing down websocketProvider:`, error);
}
}
try {
provider.destroy();
} catch (error) {
console.error(`Error destroying provider:`, error);
}
};
const handleClose = (closeEvent: { event?: { code?: number; reason?: string } }) => {
if (isDisposedRef.current) return;
const closeCode = closeEvent.event?.code;
const wsProvider = provider.configuration.websocketProvider;
const shouldConnect = wsProvider.shouldConnect;
const isForcedClose = isForcedCloseCode(closeCode) || forcedCloseSignalRef.current || shouldConnect === false;
if (isForcedClose) {
// Determine if this is a manual disconnect or a permanent error
const isManualDisconnect = shouldConnect === false;
const error: CollaborationError = {
type: "forced-close",
code: closeCode || 0,
message: isManualDisconnect ? "Manually disconnected" : "Server forced connection close",
};
const newStage = { kind: "disconnected" as const, error };
stageRef.current = newStage;
setStage(newStage);
retryCountRef.current = 0;
forcedCloseSignalRef.current = false;
// Only pause if it's a real forced close (not manual disconnect)
// Manual disconnect leaves it as is (shouldConnect=false already set if manual)
if (!isManualDisconnect) {
pauseProvider();
}
} else {
// Transient connection loss: attempt reconnection
retryCountRef.current++;
if (retryCountRef.current >= DEFAULT_MAX_RETRIES) {
// Exceeded max retry attempts
const error: CollaborationError = {
type: "max-retries",
message: `Failed to connect after ${DEFAULT_MAX_RETRIES} attempts`,
};
const newStage = { kind: "disconnected" as const, error };
stageRef.current = newStage;
setStage(newStage);
pauseProvider();
} else {
// Still have retries left, move to reconnecting
const newStage = { kind: "reconnecting" as const, attempt: retryCountRef.current };
stageRef.current = newStage;
setStage(newStage);
}
}
};
provider.on("close", handleClose);
setYjsSession({ provider, ydoc: provider.document as Y.Doc });
// Handle page visibility changes (sleep/wake, tab switching)
const handleVisibilityChange = (event?: Event) => {
if (isDisposedRef.current) return;
const isVisible = document.visibilityState === "visible";
const isFocus = event?.type === "focus";
if (isVisible || isFocus) {
// Throttle reconnection attempts to avoid double-firing (visibility + focus)
const now = Date.now();
if (now - lastReconnectTimeRef.current < 1000) {
return;
}
const wsProvider = provider.configuration.websocketProvider;
if (!wsProvider) return;
const ws = wsProvider.webSocket;
const isStale = ws?.readyState === WebSocket.CLOSED || ws?.readyState === WebSocket.CLOSING;
// If disconnected or stale, re-enable reconnection and force reconnect
if (isStale || stageRef.current.kind === "disconnected") {
lastReconnectTimeRef.current = now;
// Re-enable connection on tab focus (even if manually disconnected before sleep)
wsProvider.shouldConnect = true;
// Reset retry count for fresh reconnection attempt
retryCountRef.current = 0;
// Move to connecting state
const newStage = { kind: "connecting" as const };
stageRef.current = newStage;
setStage(newStage);
wsProvider.disconnect();
wsProvider.connect();
}
}
};
// Handle online/offline events
const handleOnline = () => {
if (isDisposedRef.current) return;
const wsProvider = provider.configuration.websocketProvider;
if (wsProvider) {
wsProvider.shouldConnect = true;
wsProvider.disconnect();
wsProvider.connect();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("focus", handleVisibilityChange);
window.addEventListener("online", handleOnline);
return () => {
try {
provider.off("close", handleClose);
} catch (error) {
console.error(`Error unregistering close handler:`, error);
}
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("focus", handleVisibilityChange);
window.removeEventListener("online", handleOnline);
permanentlyStopProvider();
};
}, [docId, serverUrl, authToken]);
// IndexedDB persistence lifecycle
useEffect(() => {
if (!yjsSession) return;
const idbPersistence = new IndexeddbPersistence(docId, yjsSession.provider.document);
const onIdbSynced = () => {
const yFragment = idbPersistence.doc.getXmlFragment("default");
const docLength = yFragment?.length ?? 0;
setIsCacheReady(true);
setHasCachedContent(docLength > 0);
};
idbPersistence.on("synced", onIdbSynced);
return () => {
idbPersistence.off("synced", onIdbSynced);
try {
idbPersistence.destroy();
} catch (error) {
console.error(`Error destroying local provider:`, error);
}
};
}, [docId, yjsSession]);
// Observe Y.Doc content changes to update hasCachedContent (catches fallback scenario)
useEffect(() => {
if (!yjsSession || !isCacheReady) return;
const fragment = yjsSession.ydoc.getXmlFragment("default");
let lastHasContent = false;
const updateCachedContentFlag = () => {
const len = fragment?.length ?? 0;
const hasContent = len > 0;
// Only update state if the boolean value actually changed
if (hasContent !== lastHasContent) {
lastHasContent = hasContent;
setHasCachedContent(hasContent);
}
};
// Initial check (handles fallback content loaded before this effect runs)
updateCachedContentFlag();
// Use observeDeep to catch nested changes (keystrokes modify Y.XmlText inside Y.XmlElement)
fragment.observeDeep(updateCachedContentFlag);
return () => {
try {
fragment.unobserveDeep(updateCachedContentFlag);
} catch (error) {
console.error("Error unobserving fragment:", error);
}
};
}, [yjsSession, isCacheReady]);
// Notify state changes callback (use ref to avoid dependency on handler)
const stateChangeCallbackRef = useRef(onStateChange);
stateChangeCallbackRef.current = onStateChange;
useEffect(() => {
if (!stateChangeCallbackRef.current) return;
const isServerSynced = stage.kind === "synced";
const isServerDisconnected = stage.kind === "disconnected";
const state: CollaborationState = {
stage,
isServerSynced,
isServerDisconnected,
};
stateChangeCallbackRef.current(state);
}, [stage]);
// Derived values for convenience
const isServerSynced = stage.kind === "synced";
const isServerDisconnected = stage.kind === "disconnected";
const isDocReady = isServerSynced || isServerDisconnected || (isCacheReady && hasCachedContent);
const signalForcedClose = useCallback((value: boolean) => {
forcedCloseSignalRef.current = value;
}, []);
// Don't return anything until provider is ready - guarantees non-null provider
if (!yjsSession) {
return null;
}
return {
provider: yjsSession.provider,
ydoc: yjsSession.ydoc,
state: {
stage,
hasCachedContent,
isCacheReady,
isServerSynced,
isServerDisconnected,
isDocReady,
},
actions: {
signalForcedClose,
},
};
};
@@ -1,92 +0,0 @@
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
type NodeHighlightState = {
highlightedNodeId: string | null;
decorations: DecorationSet;
};
type NodeHighlightMeta = {
nodeId?: string | null;
};
export const nodeHighlightPluginKey = new PluginKey<NodeHighlightState>("nodeHighlight");
const buildDecorations = (doc: Parameters<typeof DecorationSet.create>[0], highlightedNodeId: string | null) => {
if (!highlightedNodeId) {
return DecorationSet.empty;
}
const decorations: Decoration[] = [];
const highlightClassNames = ["bg-custom-primary-100/20", "transition-all", "duration-300", "rounded"];
doc.descendants((node, pos) => {
// Check if this node has the id we're looking for
if (node.attrs && node.attrs.id === highlightedNodeId) {
const decorationAttrs: Record<string, string> = {
"data-node-highlighted": "true",
class: highlightClassNames.join(" "),
};
// For text nodes, highlight the inline content
if (node.isText) {
decorations.push(
Decoration.inline(pos, pos + node.nodeSize, decorationAttrs, {
inclusiveStart: true,
inclusiveEnd: true,
})
);
} else {
// For block nodes, add a node decoration
decorations.push(Decoration.node(pos, pos + node.nodeSize, decorationAttrs));
}
return false; // Stop searching once we found the node
}
return true;
});
return DecorationSet.create(doc, decorations);
};
export const NodeHighlightPlugin = () =>
new Plugin<NodeHighlightState>({
key: nodeHighlightPluginKey,
state: {
init: () => ({
highlightedNodeId: null,
decorations: DecorationSet.empty,
}),
apply: (tr, value, _oldState, newState) => {
let highlightedNodeId = value.highlightedNodeId;
let decorations = value.decorations;
const meta = tr.getMeta(nodeHighlightPluginKey) as NodeHighlightMeta | undefined;
let shouldRecalculate = tr.docChanged;
if (meta) {
if (meta.nodeId !== undefined) {
highlightedNodeId = typeof meta.nodeId === "string" && meta.nodeId.length > 0 ? meta.nodeId : null;
shouldRecalculate = true;
}
}
if (shouldRecalculate) {
decorations = buildDecorations(newState.doc, highlightedNodeId);
} else if (tr.docChanged) {
decorations = decorations.map(tr.mapping, newState.doc);
}
return {
highlightedNodeId,
decorations,
};
},
},
props: {
decorations(state) {
return nodeHighlightPluginKey.getState(state)?.decorations ?? DecorationSet.empty;
},
},
});
@@ -1,37 +1,4 @@
export type CollaborationError =
| { type: "auth-failed"; message: string }
| { type: "network-error"; message: string }
| { type: "forced-close"; code: number; message: string }
| { type: "max-retries"; message: string };
/**
* Single-stage state machine for collaboration lifecycle.
* Stages represent the sequential progression: initial → connecting → awaiting-sync → synced
*
* Invariants:
* - "awaiting-sync" only occurs when connection is successful and sync is pending
* - "synced" occurs only after connection success and onSynced callback
* - "reconnecting" with attempt > 0 when retrying after a connection drop
* - "disconnected" is terminal (connection failed or forced close)
*/
export type CollabStage =
| { kind: "initial" }
| { kind: "connecting" }
| { kind: "awaiting-sync" }
| { kind: "synced" }
| { kind: "reconnecting"; attempt: number }
| { kind: "disconnected"; error: CollaborationError };
/**
* Public collaboration state exposed to consumers.
* Contains the current stage and derived booleans for convenience.
*/
export type CollaborationState = {
stage: CollabStage;
isServerSynced: boolean;
isServerDisconnected: boolean;
};
export type TServerHandler = {
onStateChange: (state: CollaborationState) => void;
onConnect?: () => void;
onServerError?: () => void;
};
+1 -16
View File
@@ -27,8 +27,6 @@ import type {
TRealtimeConfig,
TServerHandler,
TUserDetails,
TExtendedEditorRefApi,
EventToPayloadMap,
} from "@/types";
export type TEditorCommands =
@@ -99,7 +97,7 @@ export type TDocumentInfo = {
words: number;
};
export type CoreEditorRefApi = {
export type EditorRefApi = {
blur: () => void;
clearEditor: (emitUpdate?: boolean) => void;
createSelectionAtCursorPosition: () => void;
@@ -140,10 +138,6 @@ export type CoreEditorRefApi = {
undo: () => void;
};
export type EditorRefApi = CoreEditorRefApi & TExtendedEditorRefApi;
export type EditorTitleRefApi = EditorRefApi;
// editor props
export type IEditorProps = {
autofocus?: boolean;
@@ -191,15 +185,6 @@ export type ICollaborativeDocumentEditorProps = Omit<IEditorProps, "initialValue
serverHandler?: TServerHandler;
user: TUserDetails;
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
updatePageProperties?: <T extends keyof EventToPayloadMap>(
pageIds: string | string[],
actionType: T,
data: EventToPayloadMap[T],
performAction?: boolean
) => void;
pageRestorationInProgress?: boolean;
titleRef?: React.MutableRefObject<EditorTitleRefApi | null>;
isFetchingFallbackBinary?: boolean;
};
export type IDocumentEditorProps = Omit<IEditorProps, "initialValue" | "onEnterKeyPress" | "value"> & {
+1 -4
View File
@@ -55,7 +55,4 @@ export type TCollaborativeEditorHookProps = TCoreHookProps &
Pick<
ICollaborativeDocumentEditorProps,
"dragDropEnabled" | "extendedDocumentEditorProps" | "realtimeConfig" | "serverHandler" | "user"
> & {
titleRef?: ICollaborativeDocumentEditorProps["titleRef"];
updatePageProperties?: ICollaborativeDocumentEditorProps["updatePageProperties"];
};
>;
-1
View File
@@ -3,4 +3,3 @@
@import "./table.css";
@import "./github-dark.css";
@import "./drag-drop.css";
@import "./title-editor.css";
@@ -1,49 +0,0 @@
/* Title editor styles */
.page-title-editor {
width: 100%;
outline: none;
resize: none;
border-radius: 0;
}
.page-title-editor .ProseMirror {
background-color: transparent;
font-weight: bold;
letter-spacing: -2%;
padding: 0;
margin-bottom: 0;
}
/* Handle font sizes */
.page-title-editor.small-font .ProseMirror h1 {
font-size: 1.6rem;
line-height: 1.9rem;
}
.page-title-editor.large-font .ProseMirror h1 {
font-size: 2rem;
line-height: 2.375rem;
}
/* Focus state */
.page-title-editor.active-editor .ProseMirror {
box-shadow: none;
outline: none;
}
/* Placeholder */
.page-title-editor .ProseMirror h1.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--color-placeholder);
pointer-events: none;
height: 0;
}
.page-title-editor .ProseMirror h1.is-empty::before {
content: attr(data-placeholder);
float: left;
color: var(--color-placeholder);
pointer-events: none;
height: 0;
}
@@ -108,19 +108,6 @@ export function EmojiPicker(props: TCustomEmojiPicker) {
align={finalAlign}
sideOffset={8}
data-prevent-outside-click="true"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onFocus={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Tab") {
return;
}
if (e.key === "Escape") {
handleToggle(false);
return;
}
e.stopPropagation();
}}
>
<Tabs.Root defaultValue={defaultOpen}>
<Tabs.List className="grid grid-cols-2 gap-1 px-3.5 pt-3">
@@ -1,4 +1,3 @@
import { useEffect, useRef } from "react";
import { EmojiPicker } from "frimousse";
import { cn } from "../../utils";
@@ -10,21 +9,6 @@ type EmojiRootProps = {
export function EmojiRoot(props: EmojiRootProps) {
const { onChange, searchPlaceholder = "Search", searchDisabled = false } = props;
const searchWrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const focusInput = () => {
const searchWrapper = searchWrapperRef.current;
if (searchWrapper) {
const inputElement = searchWrapper.querySelector("input");
if (inputElement) {
inputElement.removeAttribute("disabled");
inputElement.focus();
}
}
};
focusInput();
}, []);
return (
<EmojiPicker.Root
data-slot="emoji-picker"
@@ -32,7 +16,7 @@ export function EmojiRoot(props: EmojiRootProps) {
onEmojiSelect={(val) => onChange(val.emoji)}
>
<div className="flex items-center gap-2 justify-between [&>[data-slot='emoji-picker-search-wrapper']]:flex-grow [&>[data-slot='emoji-picker-search-wrapper']]:p-0 px-1.5 py-2 sticky top-0 z-10 bg-custom-background-100">
<div ref={searchWrapperRef} data-slot="emoji-picker-search-wrapper" className="p-2">
<div data-slot="emoji-picker-search-wrapper" className="p-2">
<EmojiPicker.Search
placeholder={searchPlaceholder}
disabled={searchDisabled}
+171 -207
View File
@@ -186,7 +186,7 @@ importers:
version: 9.1.1([email protected])([email protected]([email protected]))([email protected])
next-themes:
specifier: ^0.2.1
version: 0.2.1([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
version: 0.2.1([email protected](@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
react:
specifier: 'catalog:'
version: 18.3.1
@@ -446,7 +446,7 @@ importers:
version: 6.0.8([email protected])
next-themes:
specifier: ^0.2.1
version: 0.2.1([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
version: 0.2.1([email protected](@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
react:
specifier: 'catalog:'
version: 18.3.1
@@ -624,7 +624,7 @@ importers:
version: 6.0.8([email protected])
next-themes:
specifier: ^0.2.1
version: 0.2.1([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
version: 0.2.1([email protected](@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
posthog-js:
specifier: ^1.255.1
version: 1.255.1
@@ -845,70 +845,61 @@ importers:
version: link:../utils
'@tiptap/core':
specifier: 'catalog:'
version: 2.26.3(@tiptap/[email protected].1)
version: 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-blockquote':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-character-count':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)
'@tiptap/extension-collaboration':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)([email protected]([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected]))
'@tiptap/extension-document':
specifier: ^3.2.0
version: 3.2.0(@tiptap/[email protected](@tiptap/[email protected]))
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)([email protected]([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected]))
'@tiptap/extension-emoji':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)(@tiptap/[email protected].1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1))([email protected])
'@tiptap/extension-heading':
specifier: ^3.4.3
version: 3.4.3(@tiptap/[email protected](@tiptap/[email protected]))
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)(@tiptap/[email protected].2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3))([email protected])
'@tiptap/extension-image':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-list-item':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-mention':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)(@tiptap/[email protected].1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1))
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)(@tiptap/[email protected].2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3))
'@tiptap/extension-placeholder':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)
'@tiptap/extension-task-item':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)
'@tiptap/extension-task-list':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-text':
specifier: ^3.2.0
version: 3.2.0(@tiptap/[email protected](@tiptap/[email protected]))
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-text-align':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-text-style':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-underline':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/html':
specifier: 'catalog:'
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)
'@tiptap/pm':
specifier: ^2.22.3
version: 2.26.1
version: 2.26.3
'@tiptap/react':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)([email protected]([email protected]))([email protected])
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)([email protected]([email protected]))([email protected])
'@tiptap/starter-kit':
specifier: ^2.22.3
version: 2.26.1
'@tiptap/suggestion':
specifier: ^2.22.3
version: 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)
version: 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)
buffer:
specifier: ^6.0.3
version: 6.0.3
@@ -947,7 +938,7 @@ importers:
version: 6.3.7
tiptap-markdown:
specifier: ^0.8.10
version: 0.8.10(@tiptap/[email protected](@tiptap/[email protected].1))
version: 0.8.10(@tiptap/[email protected](@tiptap/[email protected].3))
uuid:
specifier: 'catalog:'
version: 13.0.0
@@ -3724,8 +3715,8 @@ packages:
peerDependencies:
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected].1':
resolution: {integrity: sha512-viQ6AHRhjCYYipKK6ZepBzwZpkuMvO9yhRHeUZDvlSOAh8rvsUTSre0y74nu8QRYUt4a44lJJ6BpphJK7bEgYA==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-SQNMX2rkWdAOYT6pM9KZ4bZK07YiCqX6wkHiKbLSZ8GMLi35dhkiSBxvY2I72q5ucIjgC9asGf8knA/2fbVypA==}
peerDependencies:
'@tiptap/core': ^2.7.0
@@ -3762,31 +3753,26 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected].1':
resolution: {integrity: sha512-ozCrGW5IAzi/18Ngdi0v/Q175D7J3ZGoTffDDyPxnTK/oksfROajoe+ZIEgoDGXPeI/I7TTlTONuqQ6LZT5r7Q==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-kvlzKnkhH57YgpuDIOu/h9iOuoIRZf8Bc+yAy/ldbCz9EnT8/k4BMaixb98lrYExaQfozGxuAAOZmK0sR95j4Q==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
y-prosemirror: ^1.2.11
'@tiptap/[email protected].1':
resolution: {integrity: sha512-2P2IZp1NRAE+21mRuFBiP3X2WKfZ6kUC23NJKpn8bcOamY3obYqCt0ltGPhE4eR8n8QAl2fI/3jIgjR07dC8ow==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-s0/P3A8zxWL/h3e20xWMTT/rcwD0+57I6mT9JgNBPtvhPePy8d698G6/qFK+x+GdIyjJylfsq2BrSE9H+QhIBg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-2SQoew2HtOwuORpk8l7NjKDzsCBotMsij9vRSTp4nwoa4ZFGwmlVMsNIaKq/E/GuS7oiejmUUROsiD3w1stPKw==}
peerDependencies:
'@tiptap/core': ^3.2.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-o5j4Gkurb/WBu1wP2tihYnZ8dENzmlxFWWMx++g6abEpn9xdud7VxHT5Ny7mBSBptI8uMwKT53weYC0on38n3g==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected].1':
resolution: {integrity: sha512-CtK10GF80Qr4lgJ7P6W6tVThOjpq1lh8oyoBospZ+CjD4GYcY73bdl+FP0uxhZdJsMHzaqzMP5wWQ54zHsIaIg==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-NeYU3H2arrjx7fFPnWl4IqjTMID8k5BKyDRgWarKQ1ApJpeq+8EzTGIffflHNcrIiZxezTtx4anhsC1NL+/Gdw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
@@ -3809,16 +3795,11 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected].1':
resolution: {integrity: sha512-KSzL8WZV3pjJG9ke4RaU70+B5UlYR2S6olNt5UCAawM+fi11mobVztiBoC19xtpSVqIXC1AmXOqUgnuSvmE4ZA==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-0VAr1l1QKFJ0B2l4D4wV0LRlyFYeJt0S09mz+HPF2TqKF4twmPjaGD6o5zzXWl8c4cQj1CmM8P+9an3SKRjOaA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-s9X0pvkpE/Tfz/Ui5ETcmSj41MXj78UoWGfZHf3lKqtoDbmIlLEKZRBYuKUMC7812gig5AeqryPBqvEtjPSq8A==}
peerDependencies:
'@tiptap/core': ^3.4.3
'@tiptap/[email protected]':
resolution: {integrity: sha512-m6YR1gkkauIDo3PRl0gP+7Oc4n5OqDzcjVh6LvWREmZP8nmi94hfseYbqOXUb6RPHIc0JKF02eiRifT4MSd2nw==}
peerDependencies:
@@ -3831,8 +3812,8 @@ packages:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected].1':
resolution: {integrity: sha512-96+MaYBJebQlR/ik5W72GLUfXdEoxFs+6jsoERxbM5qEdhb7TEnodBFtWZOwgDO27kFd6rSNZuW9r5KJNtljEg==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-3gK+ETLiWGAUdyPDXDheNJ38OgQabSzZJ+1nQo9KWjI7P3LQ7/ctxLtT+hAFpxX0qMK4bnu5vZaItSXxE3ZtpQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
@@ -3841,13 +3822,13 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected].1':
resolution: {integrity: sha512-quOXckC73Luc3x+Dcm88YAEBW+Crh3x5uvtQOQtn2GEG91AshrvbnhGRiYnfvEN7UhWIS+FYI5liHFcRKSUKrQ==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-T1dFfx1JjRRX0iyStSTwMNajMyT+OE7XEggn+DON1g+zbgA+4cJ11WQpfrfA9VM2H5QWYyKGfHFigoFcJ8rjog==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected].1':
resolution: {integrity: sha512-sBrlJ9nWjFx7oWCtt0hV192FgCBXva1zwImWbgXTCGPAjv0d5EoPymIfRgoeanAmuQjOHoKzzZnJ6bELTZhkGw==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-fezYJQlewhfaIXBQ85LTDRBMsWfynb9xSsEYC8EeRhbwI5fFlWKEAi876oQiSqqJTg6NV/pda9dUn+wZkV7U4w==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
@@ -3874,39 +3855,34 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected].1':
resolution: {integrity: sha512-b7JNeOsBqEd1p2oQ5N6Msz9fr2o73WR1WsYDC0WhECg07Goud2gQEkwWkQaLsvfcwuS746eMJK/nrT2pVEngYA==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-KFfl9QraNldIR9ayP7t+b69BvjmdGeE9Mr+u/6h6jOmUVFpnES45geuEBSYuWWpK4EeD3ZUKXo7WGEWnTLSyfQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected].1':
resolution: {integrity: sha512-xR4LMpMPZ6bpkZNmFvIojmNGtdGKNlKFbpvyIOgs4qhlWskbFQQVevglHjV1R8xJLic5c+byJQaAmQdQudqGng==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-mQdB0BVULJiiFPGdst4JbQPm/ZGZMIeYafVu6iGSMYxdP4piMvMbY4txtLV8zN8oUEAO5zooiXXbS3OFFBDZyQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected].1':
resolution: {integrity: sha512-x6mpNGELy2QtSPBoQqNgiXO9PjZoB+O2EAfXA9YRiBDSIRNOrw+7vOVpi+IgzswFmhMNgIYUVfQRud4FHUCNew==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-fra+weXocCINVPGjykteTiQ+p34POSWyxw+Y+KFBegw24uecHhB5NCK6aXa68tvEMLC9EdvZRHuoKRfkxBQ9rw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected].1':
resolution: {integrity: sha512-t9Nc/UkrbCfnSHEUi1gvUQ2ZPzvfdYFT5TExoV2DTiUCkhG6+mecT5bTVFGW3QkPmbToL+nFhGn4ZRMDD0SP3Q==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-rNkV3dgT3nTISEf3Ax/DdqQsSy9p46n2fOBkD8FCtdrwsWNH5N4uUh4jI/q0exYKJUyZGvl60uXwCkZiQ3pVBA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/[email protected].1':
resolution: {integrity: sha512-p2n8WVMd/2vckdJlol24acaTDIZAhI7qle5cM75bn01sOEZoFlSw6SwINOULrUCzNJsYb43qrLEibZb4j2LeQw==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-Rb8Le/Li+EixQNc/pGkEJpLjozTjWYP9glaYfnjPtRVw4tHcd7khVm5mer0TQjonbBUjVC1zSuXv9gifXOv6DQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-[email protected]':
resolution: {integrity: sha512-VDQPjVr6zd3MGh8+xPIIj+fuIhnws9tpAmP7jvBep+0tL7P2RYpN2NSj0zqeStHPcGfv9iMuXEuuMkEsi5gIZg==}
peerDependencies:
'@tiptap/core': ^3.2.0
'@tiptap/[email protected]':
resolution: {integrity: sha512-/fufv41WDMdf0a4xmFAxONoAz08TonJXX6NEoSJmuGKO59M/Y0Pz8DTK1g32Wk44kn7dyScDiPlvvndl+UOv0A==}
'@tiptap/extension-[email protected]':
resolution: {integrity: sha512-kpKJSfsn1+b8l96YPg2GRn3aaN78pLqSeyzfA5FYVbN0lyptbqRVOrNM8p3n6l0LbAkiEjc/TgOMwNNEL93kyA==}
peerDependencies:
'@tiptap/core': ^2.7.0
@@ -3916,14 +3892,14 @@ packages:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/[email protected].1':
resolution: {integrity: sha512-8aF+mY/vSHbGFqyG663ds84b+vca5Lge3tHdTMTKazxCnhXR9dn2oQJMnZ78YZvdRbkPkMJJHti9h3K7u2UQvw==}
'@tiptap/[email protected].3':
resolution: {integrity: sha512-8gUmdxWlUevmgq2mNvGxvf2CpDW097tVKECMWKEn8sf846kXv3CoqaGRhI3db4kfR+09uWZeRM7rtrjRBmUThg==}
'@tiptap/[email protected]':
resolution: {integrity: sha512-E/rtpPEqPiQJrchdOUDcMPR69x96a+JeMWLL12fos4KfF7YVzQ5oUIij21RffV+qeHxug7HMUpQKBtCuJfek/Q==}
'@tiptap/[email protected].1':
resolution: {integrity: sha512-Zxlwzi1iML7aELa+PyysFD2ncVo2mEcjTkhoDok9iTbMGpm1oU8hgR1i6iHrcSNQLfaRiW6M7HNhZZQPKIC9yw==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-p7jv0sltCC2L4iHIVNthtjv/CIxajOalb7ytjLx6ijx5q2J564VIny0U7O33Ymbo2cV0dJoB+Bo5aeaJ5SfHGg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
@@ -3933,8 +3909,8 @@ packages:
'@tiptap/[email protected]':
resolution: {integrity: sha512-oziMGCds8SVQ3s5dRpBxVdEKZAmO/O//BjZ69mhA3q4vJdR0rnfLb5fTxSeQvHiqB878HBNn76kNaJrHrV35GA==}
'@tiptap/[email protected].1':
resolution: {integrity: sha512-iNWJdQN7h01keNoVwyCsdI7ZX11YkrexZjCnutWK17Dd72s3NYVTmQXu7saftwddT4nDdlczNxAFosrt0zMhcg==}
'@tiptap/[email protected].2':
resolution: {integrity: sha512-BtigI3xOJQbdNh2OeKN5wQDI/EPGN4GXdpoMHhENZeXKrX6PvWNaqjyLVsV3QiLgv6P352nZWcgo51wPXY2JgQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
@@ -11612,177 +11588,165 @@ snapshots:
dependencies:
'@testing-library/dom': 10.4.0
'@tiptap/[email protected](@tiptap/[email protected].1)':
'@tiptap/[email protected](@tiptap/[email protected].3)':
dependencies:
'@tiptap/pm': 2.26.1
'@tiptap/pm': 2.26.3
'@tiptap/[email protected](@tiptap/[email protected])':
dependencies:
'@tiptap/pm': 3.6.6
'@tiptap/[email protected].1(@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/[email protected].2(@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)':
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
tippy.js: 6.3.7
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)':
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)':
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/[email protected].1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)([email protected]([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected]))':
'@tiptap/[email protected].2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)([email protected]([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected]))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
y-prosemirror: 1.3.7([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected])
'@tiptap/[email protected].1(@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/[email protected].2(@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-d[email protected](@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/extension-d[email protected](@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
'@tiptap/extension-dropcursor@2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)':
'@tiptap/extension-[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])(@tiptap/suggestion@2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3))([email protected])':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])(@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))([email protected])':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected])
'@tiptap/pm': 2.26.1
'@tiptap/suggestion': 2.26.1(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
'@tiptap/suggestion': 2.26.2(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
emoji-regex: 10.5.0
emojibase-data: 15.3.2([email protected])
is-emoji-supported: 0.0.5
transitivePeerDependencies:
- emojibase
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)':
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
tippy.js: 6.3.7
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)':
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/[email protected].1(@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/[email protected].2(@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-h[email protected](@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/extension-h[email protected](@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
'@tiptap/extension-history@2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)':
'@tiptap/extension-horizontal-rule@2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
'@tiptap/extension-horizontal-rul[email protected](@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected])':
'@tiptap/extension-imag[email protected](@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-image@2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/extension-italic@2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-italic@2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/extension-list-item@2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-[email protected](@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/extension-[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])(@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
'@tiptap/suggestion': 2.26.2(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/extension-[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])(@tiptap/suggestion@2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected]))':
'@tiptap/extension-ordered-list@2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/suggestion': 2.26.1(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-ordered-list@2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/extension-paragraph@2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-paragraph@2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/extension-placeholder@2.26.1(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
'@tiptap/extension-placeholder@2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected])':
'@tiptap/extension-strike@2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-strike@2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/extension-task-item@2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
'@tiptap/extension-task-item@2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected])':
'@tiptap/extension-task-list@2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-task-list@2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/extension-text-align@2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-text-align@2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/extension-text-style@2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-text-style@2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/[email protected].2(@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-text@2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/extension-underline@2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].1))':
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected])
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected])
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
zeed-dom: 0.15.1
'@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])':
@@ -11791,7 +11755,7 @@ snapshots:
'@tiptap/pm': 3.6.6
zeed-dom: 0.15.1
'@tiptap/[email protected].1':
'@tiptap/[email protected].3':
dependencies:
prosemirror-changeset: 2.3.1
prosemirror-collab: 1.3.1
@@ -11833,12 +11797,12 @@ snapshots:
prosemirror-transform: 1.10.4
prosemirror-view: 1.40.0
'@tiptap/[email protected].1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)([email protected]([email protected]))([email protected])':
'@tiptap/[email protected].2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)([email protected]([email protected]))([email protected])':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/extension-bubble-menu': 2.26.3(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)
'@tiptap/extension-floating-menu': 2.26.3(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-bubble-menu': 2.26.3(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)
'@tiptap/extension-floating-menu': 2.26.3(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
'@types/use-sync-external-store': 0.0.6
fast-deep-equal: 3.1.3
react: 18.3.1
@@ -11847,32 +11811,32 @@ snapshots:
'@tiptap/[email protected]':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/extension-blockquote': 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-bold': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-bullet-list': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-code': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-code-block': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)
'@tiptap/extension-document': 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-dropcursor': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)
'@tiptap/extension-gapcursor': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)
'@tiptap/extension-hard-break': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-heading': 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-history': 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)
'@tiptap/extension-horizontal-rule': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)
'@tiptap/extension-italic': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-list-item': 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-ordered-list': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-paragraph': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-strike': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-text': 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/extension-text-style': 2.26.1(@tiptap/[email protected](@tiptap/[email protected].1))
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/extension-blockquote': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-bold': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-bullet-list': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-code': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-code-block': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)
'@tiptap/extension-document': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-dropcursor': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)
'@tiptap/extension-gapcursor': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)
'@tiptap/extension-hard-break': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-heading': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-history': 2.26.1(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)
'@tiptap/extension-horizontal-rule': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)
'@tiptap/extension-italic': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-list-item': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-ordered-list': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-paragraph': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-strike': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-text': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/extension-text-style': 2.26.2(@tiptap/[email protected](@tiptap/[email protected].3))
'@tiptap/pm': 2.26.3
'@tiptap/[email protected].1(@tiptap/[email protected](@tiptap/[email protected].1))(@tiptap/[email protected].1)':
'@tiptap/[email protected].2(@tiptap/[email protected](@tiptap/[email protected].3))(@tiptap/[email protected].3)':
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/pm': 2.26.1
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@tiptap/pm': 2.26.3
'@tokenizer/[email protected]':
dependencies:
@@ -15579,7 +15543,7 @@ snapshots:
[email protected]: {}
[email protected]([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]):
[email protected]([email protected](@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]):
dependencies:
next: 14.2.32(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react: 18.3.1
@@ -17292,9 +17256,9 @@ snapshots:
dependencies:
'@popperjs/core': 2.11.8
[email protected](@tiptap/[email protected](@tiptap/[email protected].1)):
[email protected](@tiptap/[email protected](@tiptap/[email protected].3)):
dependencies:
'@tiptap/core': 2.26.3(@tiptap/[email protected].1)
'@tiptap/core': 2.26.3(@tiptap/[email protected].3)
'@types/markdown-it': 13.0.9
markdown-it: 14.1.0
markdown-it-task-lists: 2.1.1