Merge pull request #821 from AnishSarkar22/fix/ui

feat: introduce platejs and remove blocknote editor
This commit is contained in:
Rohan Verma 2026-02-19 19:09:35 -08:00 committed by GitHub
commit bad114734a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 8006 additions and 24218 deletions

View file

@ -0,0 +1,141 @@
"""101_add_source_markdown_to_documents
Revision ID: 101
Revises: 100
Create Date: 2026-02-17
Adds source_markdown column and converts only documents that have
blocknote_document data. Uses a pure-Python BlockNote JSON Markdown
converter without external dependencies.
Documents without blocknote_document keep source_markdown = NULL and
get populated lazily by the editor route when a user first opens them.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "101"
down_revision: str | None = "100"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
logger = logging.getLogger("alembic.migration.101")
def upgrade() -> None:
"""Add source_markdown column and populate it for existing documents."""
conn = op.get_bind()
existing_columns = [
col["name"] for col in sa.inspect(conn).get_columns("documents")
]
# 1. Add the column
if "source_markdown" not in existing_columns:
op.add_column(
"documents",
sa.Column("source_markdown", sa.Text(), nullable=True),
)
# 2. Convert only documents that have blocknote_document data
_populate_source_markdown(conn)
def _populate_source_markdown(conn, batch_size: int = 500) -> None:
"""Populate source_markdown only for documents that have blocknote_document.
Processes in batches to avoid long-running transactions and high memory usage.
"""
from app.utils.blocknote_to_markdown import blocknote_to_markdown
# Get total count first
count_result = conn.execute(
sa.text("""
SELECT count(*)
FROM documents
WHERE source_markdown IS NULL
AND blocknote_document IS NOT NULL
""")
)
total = count_result.scalar()
if total == 0:
print("No documents with blocknote_document need migration")
return
print(
f" Migrating {total} documents (with blocknote_document) to source_markdown..."
)
migrated = 0
failed = 0
offset = 0
while offset < total:
# Fetch one batch at a time
result = conn.execute(
sa.text("""
SELECT id, title, blocknote_document
FROM documents
WHERE source_markdown IS NULL
AND blocknote_document IS NOT NULL
ORDER BY id
LIMIT :limit OFFSET :offset
"""),
{"limit": batch_size, "offset": offset},
)
rows = result.fetchall()
if not rows:
break
for row in rows:
doc_id = row[0]
doc_title = row[1]
blocknote_doc = row[2]
try:
if isinstance(blocknote_doc, str):
blocknote_doc = json.loads(blocknote_doc)
markdown = blocknote_to_markdown(blocknote_doc)
if markdown:
conn.execute(
sa.text("""
UPDATE documents SET source_markdown = :md WHERE id = :doc_id
"""),
{"md": markdown, "doc_id": doc_id},
)
migrated += 1
else:
logger.warning(
f" Doc {doc_id} ({doc_title}): blocknote conversion produced empty result"
)
failed += 1
except Exception as e:
logger.warning(
f" Doc {doc_id} ({doc_title}): blocknote conversion failed ({e})"
)
failed += 1
print(f" Batch complete: processed {min(offset + batch_size, total)}/{total}")
offset += batch_size
print(
f"source_markdown migration complete: {migrated} migrated, "
f"{failed} failed out of {total} total"
)
def downgrade() -> None:
"""Remove source_markdown column."""
op.drop_column("documents", "source_markdown")

View file

@ -79,7 +79,6 @@ celery_app = Celery(
"app.tasks.celery_tasks.podcast_tasks",
"app.tasks.celery_tasks.connector_tasks",
"app.tasks.celery_tasks.schedule_checker_task",
"app.tasks.celery_tasks.blocknote_migration_tasks",
"app.tasks.celery_tasks.document_reindex_tasks",
"app.tasks.celery_tasks.stale_notification_cleanup_task",
],

View file

@ -894,9 +894,15 @@ class Document(BaseModel, TimestampMixin):
embedding = Column(Vector(config.embedding_model_instance.dimension))
# BlockNote live editing state (NULL when never edited)
# DEPRECATED: Will be removed in a future migration. Use source_markdown instead.
blocknote_document = Column(JSONB, nullable=True)
# blocknote background reindex flag
# Full raw markdown content for the Plate.js editor.
# This is the source of truth for document content in the editor.
# Populated from markdown at ingestion time, or from blocknote_document migration.
source_markdown = Column(Text, nullable=True)
# Background reindex flag (set when editor content is saved)
content_needs_reindexing = Column(
Boolean, nullable=False, default=False, server_default=text("false")
)

View file

@ -1,5 +1,5 @@
"""
Editor routes for BlockNote document editing.
Editor routes for document editing with markdown (Plate.js frontend).
"""
from datetime import UTC, datetime
@ -27,8 +27,8 @@ async def get_editor_content(
"""
Get document content for editing.
Returns BlockNote JSON document. If blocknote_document is NULL,
attempts to generate it from chunks (lazy migration).
Returns source_markdown for the Plate.js editor.
Falls back to blocknote_document markdown conversion, then chunk reconstruction.
Requires DOCUMENTS_READ permission.
"""
@ -54,54 +54,61 @@ async def get_editor_content(
if not document:
raise HTTPException(status_code=404, detail="Document not found")
# If blocknote_document exists, return it
# Priority 1: Return source_markdown if it exists (check `is not None` to allow empty strings)
if document.source_markdown is not None:
return {
"document_id": document.id,
"title": document.title,
"document_type": document.document_type.value,
"source_markdown": document.source_markdown,
"updated_at": document.updated_at.isoformat()
if document.updated_at
else None,
}
# Priority 2: Lazy-migrate from blocknote_document (pure Python, no external deps)
if document.blocknote_document:
return {
"document_id": document.id,
"title": document.title,
"document_type": document.document_type.value,
"blocknote_document": document.blocknote_document,
"updated_at": document.updated_at.isoformat()
if document.updated_at
else None,
}
from app.utils.blocknote_to_markdown import blocknote_to_markdown
# For NOTE type documents, return empty BlockNote structure if no content exists
if document.document_type == DocumentType.NOTE:
# Return empty BlockNote structure
empty_blocknote = [
{
"type": "paragraph",
"content": [],
"children": [],
}
]
# Save empty structure if not already saved
if not document.blocknote_document:
document.blocknote_document = empty_blocknote
markdown = blocknote_to_markdown(document.blocknote_document)
if markdown:
# Persist the migration so we don't repeat it
document.source_markdown = markdown
await session.commit()
return {
"document_id": document.id,
"title": document.title,
"document_type": document.document_type.value,
"source_markdown": markdown,
"updated_at": document.updated_at.isoformat()
if document.updated_at
else None,
}
# Priority 3: For NOTE type with no content, return empty markdown
if document.document_type == DocumentType.NOTE:
empty_markdown = ""
document.source_markdown = empty_markdown
await session.commit()
return {
"document_id": document.id,
"title": document.title,
"document_type": document.document_type.value,
"blocknote_document": empty_blocknote,
"source_markdown": empty_markdown,
"updated_at": document.updated_at.isoformat()
if document.updated_at
else None,
}
# Lazy migration: Try to generate blocknote_document from chunks (for other document types)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Priority 4: Reconstruct from chunks
chunks = sorted(document.chunks, key=lambda c: c.id)
if not chunks:
raise HTTPException(
status_code=400,
detail="This document has no chunks and cannot be edited. Please re-upload to enable editing.",
detail="This document has no content and cannot be edited. Please re-upload to enable editing.",
)
# Reconstruct markdown from chunks
markdown_content = "\n\n".join(chunk.content for chunk in chunks)
if not markdown_content.strip():
@ -110,25 +117,15 @@ async def get_editor_content(
detail="This document has empty content and cannot be edited.",
)
# Convert to BlockNote
blocknote_json = await convert_markdown_to_blocknote(markdown_content)
if not blocknote_json:
raise HTTPException(
status_code=500,
detail="Failed to convert document to editable format. Please try again later.",
)
# Save the generated blocknote_document (lazy migration)
document.blocknote_document = blocknote_json
document.content_needs_reindexing = False
# Persist the lazy migration
document.source_markdown = markdown_content
await session.commit()
return {
"document_id": document.id,
"title": document.title,
"document_type": document.document_type.value,
"blocknote_document": blocknote_json,
"source_markdown": markdown_content,
"updated_at": document.updated_at.isoformat() if document.updated_at else None,
}
@ -142,9 +139,11 @@ async def save_document(
user: User = Depends(current_active_user),
):
"""
Save BlockNote document and trigger reindexing.
Save document markdown and trigger reindexing.
Called when user clicks 'Save & Exit'.
Accepts { "source_markdown": "...", "title": "..." (optional) }.
Requires DOCUMENTS_UPDATE permission.
"""
from app.tasks.celery_tasks.document_reindex_tasks import reindex_document_task
@ -169,49 +168,36 @@ async def save_document(
if not document:
raise HTTPException(status_code=404, detail="Document not found")
blocknote_document = data.get("blocknote_document")
if not blocknote_document:
raise HTTPException(status_code=400, detail="blocknote_document is required")
source_markdown = data.get("source_markdown")
if source_markdown is None:
raise HTTPException(status_code=400, detail="source_markdown is required")
# Add type validation
if not isinstance(blocknote_document, list):
raise HTTPException(status_code=400, detail="blocknote_document must be a list")
if not isinstance(source_markdown, str):
raise HTTPException(status_code=400, detail="source_markdown must be a string")
# For NOTE type documents, extract title from first block (heading)
if (
document.document_type == DocumentType.NOTE
and blocknote_document
and len(blocknote_document) > 0
):
first_block = blocknote_document[0]
if (
first_block
and first_block.get("content")
and isinstance(first_block["content"], list)
):
# Extract text from first block content
# Match the frontend extractTitleFromBlockNote logic exactly
title_parts = []
for item in first_block["content"]:
if isinstance(item, str):
title_parts.append(item)
elif (
isinstance(item, dict)
and "text" in item
and isinstance(item["text"], str)
):
# BlockNote structure: {"type": "text", "text": "...", "styles": {}}
title_parts.append(item["text"])
# For NOTE type, extract title from first heading line if present
if document.document_type == DocumentType.NOTE:
# If the frontend sends a title, use it; otherwise extract from markdown
new_title = data.get("title")
if not new_title:
# Extract title from the first line of markdown (# Heading)
for line in source_markdown.split("\n"):
stripped = line.strip()
if stripped.startswith("# "):
new_title = stripped[2:].strip()
break
elif stripped:
# First non-empty non-heading line
new_title = stripped[:100]
break
new_title = "".join(title_parts).strip()
if new_title:
document.title = new_title
else:
# Only set to "Untitled" if content exists but is empty
document.title = "Untitled"
if new_title:
document.title = new_title.strip()
else:
document.title = "Untitled"
# Save BlockNote document
document.blocknote_document = blocknote_document
# Save source_markdown
document.source_markdown = source_markdown
document.updated_at = datetime.now(UTC)
document.content_needs_reindexing = True

View file

@ -1,9 +1,8 @@
"""
Notes routes for creating and managing BlockNote documents.
Notes routes for creating and managing note documents.
"""
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
@ -20,7 +19,7 @@ router = APIRouter()
class CreateNoteRequest(BaseModel):
title: str
blocknote_document: list[dict[str, Any]] | None = None
source_markdown: str | None = None
@router.post("/search-spaces/{search_space_id}/notes", response_model=DocumentRead)
@ -31,7 +30,7 @@ async def create_note(
user: User = Depends(current_active_user),
):
"""
Create a new note (BlockNote document).
Create a new note document.
Requires DOCUMENTS_CREATE permission.
"""
@ -47,16 +46,8 @@ async def create_note(
if not request.title or not request.title.strip():
raise HTTPException(status_code=400, detail="Title is required")
# Default empty BlockNote structure if not provided
blocknote_document = request.blocknote_document
if blocknote_document is None:
blocknote_document = [
{
"type": "paragraph",
"content": [],
"children": [],
}
]
# Default empty markdown if not provided
source_markdown = request.source_markdown if request.source_markdown else ""
# Generate content hash (use title for now, will be updated on save)
import hashlib
@ -64,14 +55,13 @@ async def create_note(
content_hash = hashlib.sha256(request.title.encode()).hexdigest()
# Create document with NOTE type
document = Document(
search_space_id=search_space_id,
title=request.title.strip(),
document_type=DocumentType.NOTE,
content="", # Empty initially, will be populated on first save/reindex
content_hash=content_hash,
blocknote_document=blocknote_document,
source_markdown=source_markdown,
content_needs_reindexing=False, # Will be set to True on first save
document_metadata={"NOTE": True},
embedding=None, # Will be generated on first reindex

View file

@ -1,8 +1,9 @@
"""
Report routes for read, export (PDF/DOCX), and delete operations.
Report routes for read, update, export (PDF/DOCX), and delete operations.
No create or update endpoints here reports are generated inline by the
agent tool during chat and stored as Markdown in the database.
Reports are generated inline by the agent tool during chat and stored as
Markdown in the database. Users can edit report content via the Plate editor
and save changes through the PUT endpoint.
Export to PDF/DOCX is on-demand PDF uses pypandoc (MarkdownTypst) + typst-py
(TypstPDF); DOCX uses pypandoc directly.
@ -33,7 +34,7 @@ from app.db import (
User,
get_async_session,
)
from app.schemas import ReportContentRead, ReportRead
from app.schemas import ReportContentRead, ReportContentUpdate, ReportRead
from app.schemas.reports import ReportVersionInfo
from app.users import current_active_user
from app.utils.rbac import check_search_space_access
@ -259,6 +260,47 @@ async def read_report_content(
) from None
@router.put("/reports/{report_id}/content", response_model=ReportContentRead)
async def update_report_content(
report_id: int,
body: ReportContentUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Update the Markdown content of a report.
The caller must be a member of the search space the report belongs to.
Returns the updated report content including version siblings.
"""
try:
report = await _get_report_with_access(report_id, session, user)
report.content = body.content
session.add(report)
await session.commit()
await session.refresh(report)
versions = await _get_version_siblings(session, report)
return ReportContentRead(
id=report.id,
title=report.title,
content=report.content,
report_metadata=report.report_metadata,
report_group_id=report.report_group_id,
versions=versions,
)
except HTTPException:
raise
except SQLAlchemyError:
await session.rollback()
raise HTTPException(
status_code=500,
detail="Database error occurred while updating report content",
) from None
@router.get("/reports/{report_id}/export")
async def export_report(
report_id: int,

View file

@ -76,7 +76,13 @@ from .rbac_schemas import (
RoleUpdate,
UserSearchSpaceAccess,
)
from .reports import ReportBase, ReportContentRead, ReportRead, ReportVersionInfo
from .reports import (
ReportBase,
ReportContentRead,
ReportContentUpdate,
ReportRead,
ReportVersionInfo,
)
from .search_source_connector import (
MCPConnectorCreate,
MCPConnectorRead,
@ -189,6 +195,7 @@ __all__ = [
# Report schemas
"ReportBase",
"ReportContentRead",
"ReportContentUpdate",
"ReportRead",
"ReportVersionInfo",
"RoleCreate",

View file

@ -51,3 +51,9 @@ class ReportContentRead(BaseModel):
class Config:
from_attributes = True
class ReportContentUpdate(BaseModel):
"""Schema for updating a report's Markdown content."""
content: str

View file

@ -1,168 +0,0 @@
"""Celery tasks for populating blocknote_document for existing documents."""
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload
from sqlalchemy.pool import NullPool
from app.celery_app import celery_app
from app.config import config
from app.db import Document
from app.utils.blocknote_converter import convert_markdown_to_blocknote
logger = logging.getLogger(__name__)
def get_celery_session_maker():
"""
Create a new async session maker for Celery tasks.
This is necessary because Celery tasks run in a new event loop,
and the default session maker is bound to the main app's event loop.
"""
engine = create_async_engine(
config.DATABASE_URL,
poolclass=NullPool,
echo=False,
)
return async_sessionmaker(engine, expire_on_commit=False)
@celery_app.task(name="populate_blocknote_for_documents", bind=True)
def populate_blocknote_for_documents_task(
self, document_ids: list[int] | None = None, batch_size: int = 50
):
"""
Celery task to populate blocknote_document for existing documents.
Args:
document_ids: Optional list of specific document IDs to process.
If None, processes all documents with blocknote_document IS NULL.
batch_size: Number of documents to process in each batch (default: 50)
"""
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(
_populate_blocknote_for_documents(document_ids, batch_size)
)
finally:
loop.close()
async def _populate_blocknote_for_documents(
document_ids: list[int] | None = None, batch_size: int = 50
):
"""
Async function to populate blocknote_document for documents.
Args:
document_ids: Optional list of specific document IDs to process
batch_size: Number of documents to process per batch
"""
async with get_celery_session_maker()() as session:
try:
# Build query for documents that need blocknote_document populated
query = select(Document).where(Document.blocknote_document.is_(None))
# If specific document IDs provided, filter by them
if document_ids:
query = query.where(Document.id.in_(document_ids))
# Load chunks relationship to avoid N+1 queries
query = query.options(selectinload(Document.chunks))
# Execute query
result = await session.execute(query)
documents = result.scalars().all()
total_documents = len(documents)
logger.info(f"Found {total_documents} documents to process")
if total_documents == 0:
logger.info("No documents to process")
return
# Process documents in batches
processed = 0
failed = 0
for i in range(0, total_documents, batch_size):
batch = documents[i : i + batch_size]
logger.info(
f"Processing batch {i // batch_size + 1}: documents {i + 1}-{min(i + batch_size, total_documents)}"
)
for document in batch:
try:
# Use preloaded chunks from selectinload - no need to query again
chunks = sorted(document.chunks, key=lambda c: c.id)
if not chunks:
logger.warning(
f"Document {document.id} ({document.title}) has no chunks, skipping"
)
failed += 1
continue
# Reconstruct markdown by concatenating chunk contents
markdown_content = "\n\n".join(
chunk.content for chunk in chunks
)
if not markdown_content or not markdown_content.strip():
logger.warning(
f"Document {document.id} ({document.title}) has empty markdown content, skipping"
)
failed += 1
continue
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(
markdown_content
)
if not blocknote_json:
logger.warning(
f"Failed to convert markdown to BlockNote for document {document.id} ({document.title})"
)
failed += 1
continue
# Update document with blocknote_document (other fields already have correct defaults)
document.blocknote_document = blocknote_json
processed += 1
# Commit every batch_size documents to avoid long transactions
if processed % batch_size == 0:
await session.commit()
logger.info(
f"Committed batch: {processed} documents processed so far"
)
except Exception as e:
logger.error(
f"Error processing document {document.id} ({document.title}): {e}",
exc_info=True,
)
failed += 1
# Continue with next document instead of failing entire batch
continue
# Commit remaining changes in the batch
await session.commit()
logger.info(f"Completed batch {i // batch_size + 1}")
logger.info(
f"Migration complete: {processed} documents processed, {failed} failed"
)
except Exception as e:
await session.rollback()
logger.error(f"Error in blocknote migration task: {e}", exc_info=True)
raise

View file

@ -13,7 +13,6 @@ from app.config import config
from app.db import Document
from app.services.llm_service import get_user_long_context_llm
from app.services.task_logging_service import TaskLoggingService
from app.utils.blocknote_converter import convert_blocknote_to_markdown
from app.utils.document_converters import (
create_document_chunks,
generate_document_summary,
@ -84,48 +83,37 @@ async def _reindex_document(document_id: int, user_id: str):
)
try:
if not document.blocknote_document:
# Read markdown directly from source_markdown
markdown_content = document.source_markdown
if not markdown_content:
await task_logger.log_task_failure(
log_entry,
f"Document {document_id} has no BlockNote content to reindex",
"No BlockNote content",
{"error_type": "NoBlockNoteContent"},
f"Document {document_id} has no source_markdown to reindex",
"No source_markdown content",
{"error_type": "NoSourceMarkdown"},
)
return
logger.info(f"Reindexing document {document_id} ({document.title})")
# 1. Convert BlockNote → Markdown
markdown_content = await convert_blocknote_to_markdown(
document.blocknote_document
)
if not markdown_content:
await task_logger.log_task_failure(
log_entry,
f"Failed to convert document {document_id} to markdown",
"Markdown conversion failed",
{"error_type": "ConversionError"},
)
return
# 2. Delete old chunks explicitly
# 1. Delete old chunks explicitly
from app.db import Chunk
await session.execute(delete(Chunk).where(Chunk.document_id == document_id))
await session.flush() # Ensure old chunks are deleted
# 3. Create new chunks
# 2. Create new chunks from source_markdown
new_chunks = await create_document_chunks(markdown_content)
# 4. Add new chunks to session
# 3. Add new chunks to session
for chunk in new_chunks:
chunk.document_id = document_id
session.add(chunk)
logger.info(f"Created {len(new_chunks)} chunks for document {document_id}")
# 5. Regenerate summary
# 4. Regenerate summary
user_llm = await get_user_long_context_llm(
session, user_id, document.search_space_id
)
@ -139,7 +127,7 @@ async def _reindex_document(document_id: int, user_id: str):
markdown_content, user_llm, document_metadata
)
# 6. Update document
# 5. Update document
document.content = summary_content
document.embedding = summary_embedding
document.content_needs_reindexing = False

View file

@ -208,14 +208,7 @@ async def add_circleback_meeting_document(
# Process chunks
chunks = await create_document_chunks(markdown_content)
# Convert to BlockNote JSON for editing capability
from app.utils.blocknote_converter import convert_markdown_to_blocknote
blocknote_json = await convert_markdown_to_blocknote(markdown_content)
if not blocknote_json:
logger.warning(
f"Failed to convert Circleback meeting {meeting_id} to BlockNote JSON, document will not be editable"
)
# No BlockNote conversion needed — store raw markdown for Plate.js editor
# Prepare final document metadata
document_metadata = {
@ -235,7 +228,7 @@ async def add_circleback_meeting_document(
document.embedding = summary_embedding
document.document_metadata = document_metadata
safe_set_chunks(document, chunks)
document.blocknote_document = blocknote_json
document.source_markdown = markdown_content
document.content_needs_reindexing = False
document.updated_at = get_current_timestamp()
document.status = DocumentStatus.ready()

View file

@ -146,16 +146,6 @@ async def add_extension_received_document(
# Process chunks
chunks = await create_document_chunks(content.pageContent)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(combined_document_string)
if not blocknote_json:
logging.warning(
f"Failed to convert extension document '{content.metadata.VisitedWebPageTitle}' "
f"to BlockNote JSON, document will not be editable"
)
# Update or create document
if existing_document:
# Update existing document
@ -165,7 +155,7 @@ async def add_extension_received_document(
existing_document.embedding = summary_embedding
existing_document.document_metadata = content.metadata.model_dump()
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
existing_document.source_markdown = combined_document_string
existing_document.updated_at = get_current_timestamp()
await session.commit()
@ -183,7 +173,7 @@ async def add_extension_received_document(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=unique_identifier_hash,
blocknote_document=blocknote_json,
source_markdown=combined_document_string,
updated_at=get_current_timestamp(),
created_by_id=user_id,
)

View file

@ -476,15 +476,6 @@ async def add_received_file_document_using_unstructured(
# Process chunks
chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json:
logging.warning(
f"Failed to convert {file_name} to BlockNote JSON, document will not be editable"
)
# Update or create document
if existing_document:
# Update existing document
@ -497,7 +488,7 @@ async def add_received_file_document_using_unstructured(
"ETL_SERVICE": "UNSTRUCTURED",
}
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
existing_document.source_markdown = file_in_markdown
existing_document.content_needs_reindexing = False
existing_document.updated_at = get_current_timestamp()
existing_document.status = DocumentStatus.ready() # Mark as ready
@ -525,7 +516,7 @@ async def add_received_file_document_using_unstructured(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=primary_hash,
blocknote_document=blocknote_json,
source_markdown=file_in_markdown,
content_needs_reindexing=False,
updated_at=get_current_timestamp(),
created_by_id=user_id,
@ -619,15 +610,6 @@ async def add_received_file_document_using_llamacloud(
# Process chunks
chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json:
logging.warning(
f"Failed to convert {file_name} to BlockNote JSON, document will not be editable"
)
# Update or create document
if existing_document:
# Update existing document
@ -640,7 +622,7 @@ async def add_received_file_document_using_llamacloud(
"ETL_SERVICE": "LLAMACLOUD",
}
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
existing_document.source_markdown = file_in_markdown
existing_document.content_needs_reindexing = False
existing_document.updated_at = get_current_timestamp()
existing_document.status = DocumentStatus.ready() # Mark as ready
@ -668,7 +650,7 @@ async def add_received_file_document_using_llamacloud(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=primary_hash,
blocknote_document=blocknote_json,
source_markdown=file_in_markdown,
content_needs_reindexing=False,
updated_at=get_current_timestamp(),
created_by_id=user_id,
@ -787,15 +769,6 @@ async def add_received_file_document_using_docling(
# Process chunks
chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert markdown to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json:
logging.warning(
f"Failed to convert {file_name} to BlockNote JSON, document will not be editable"
)
# Update or create document
if existing_document:
# Update existing document
@ -808,7 +781,7 @@ async def add_received_file_document_using_docling(
"ETL_SERVICE": "DOCLING",
}
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
existing_document.source_markdown = file_in_markdown
existing_document.content_needs_reindexing = False
existing_document.updated_at = get_current_timestamp()
existing_document.status = DocumentStatus.ready() # Mark as ready
@ -836,7 +809,7 @@ async def add_received_file_document_using_docling(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=primary_hash,
blocknote_document=blocknote_json,
source_markdown=file_in_markdown,
content_needs_reindexing=False,
updated_at=get_current_timestamp(),
created_by_id=user_id,
@ -1658,7 +1631,6 @@ async def process_file_in_background_with_document(
from app.config import config as app_config
from app.services.llm_service import get_user_long_context_llm
from app.utils.blocknote_converter import convert_markdown_to_blocknote
try:
markdown_content = None
@ -1917,9 +1889,6 @@ async def process_file_in_background_with_document(
chunks = await create_document_chunks(markdown_content)
# Convert to BlockNote for editing
blocknote_json = await convert_markdown_to_blocknote(markdown_content)
# ===== STEP 4: Update document to READY =====
from sqlalchemy.orm.attributes import flag_modified
@ -1937,7 +1906,7 @@ async def process_file_in_background_with_document(
# Use safe_set_chunks to avoid async issues
safe_set_chunks(document, chunks)
document.blocknote_document = blocknote_json
document.source_markdown = markdown_content
document.content_needs_reindexing = False
document.updated_at = get_current_timestamp()
document.status = DocumentStatus.ready() # Shows checkmark in UI

View file

@ -248,15 +248,6 @@ async def add_received_markdown_file_document(
# Process chunks
chunks = await create_document_chunks(file_in_markdown)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(file_in_markdown)
if not blocknote_json:
logging.warning(
f"Failed to convert {file_name} to BlockNote JSON, document will not be editable"
)
# Update or create document
if existing_document:
# Update existing document
@ -268,7 +259,7 @@ async def add_received_markdown_file_document(
"FILE_NAME": file_name,
}
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
existing_document.source_markdown = file_in_markdown
existing_document.updated_at = get_current_timestamp()
existing_document.status = DocumentStatus.ready() # Mark as ready
@ -294,7 +285,7 @@ async def add_received_markdown_file_document(
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=primary_hash,
blocknote_document=blocknote_json,
source_markdown=file_in_markdown,
updated_at=get_current_timestamp(),
created_by_id=user_id,
connector_id=connector.get("connector_id") if connector else None,

View file

@ -397,16 +397,6 @@ async def add_youtube_video_document(
{"stage": "chunk_processing"},
)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
# Convert transcript to BlockNote JSON
blocknote_json = await convert_markdown_to_blocknote(combined_document_string)
if not blocknote_json:
logging.warning(
f"Failed to convert YouTube video '{video_id}' to BlockNote JSON, "
"document will not be editable"
)
chunks = await create_document_chunks(combined_document_string)
# =======================================================================
@ -430,7 +420,7 @@ async def add_youtube_video_document(
"thumbnail": video_data.get("thumbnail_url", ""),
}
safe_set_chunks(document, chunks)
document.blocknote_document = blocknote_json
document.source_markdown = combined_document_string
document.status = DocumentStatus.ready() # READY status - fully processed
document.updated_at = get_current_timestamp()

View file

@ -1,123 +0,0 @@
import logging
from typing import Any
import httpx
from app.config import config
logger = logging.getLogger(__name__)
async def convert_markdown_to_blocknote(markdown: str) -> dict[str, Any] | None:
"""
Convert markdown to BlockNote JSON via Next.js API.
Args:
markdown: Markdown string to convert
Returns:
BlockNote document as dict, or None if conversion fails
"""
if not markdown or not markdown.strip():
logger.warning("Empty markdown provided for conversion")
return None
if not markdown or len(markdown) < 10:
logger.warning("Markdown became too short after sanitization")
# Return a minimal BlockNote document
return [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Document content could not be converted for editing.",
"styles": {},
}
],
"children": [],
}
]
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{config.NEXT_FRONTEND_URL}/api/convert-to-blocknote",
json={"markdown": markdown},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
blocknote_document = data.get("blocknote_document")
if blocknote_document:
logger.info(
f"Successfully converted markdown to BlockNote (original: {len(markdown)} chars, sanitized: {len(markdown)} chars)"
)
return blocknote_document
else:
logger.warning("Next.js API returned empty blocknote_document")
return None
except httpx.TimeoutException:
logger.error("Timeout converting markdown to BlockNote after 30s")
return None
except httpx.HTTPStatusError as e:
logger.error(
f"HTTP error converting markdown to BlockNote: {e.response.status_code} - {e.response.text}"
)
# Log first 1000 chars of problematic markdown for debugging
logger.debug(f"Problematic markdown sample: {markdown[:1000]}")
return None
except Exception as e:
logger.error(f"Failed to convert markdown to BlockNote: {e}", exc_info=True)
return None
async def convert_blocknote_to_markdown(
blocknote_document: dict[str, Any] | list[dict[str, Any]],
) -> str | None:
"""
Convert BlockNote JSON to markdown via Next.js API.
Args:
blocknote_document: BlockNote document as dict or list of blocks
Returns:
Markdown string, or None if conversion fails
"""
if not blocknote_document:
logger.warning("Empty BlockNote document provided for conversion")
return None
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{config.NEXT_FRONTEND_URL}/api/convert-to-markdown",
json={"blocknote_document": blocknote_document},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
markdown = data.get("markdown")
if markdown:
logger.info(
f"Successfully converted BlockNote to markdown ({len(markdown)} chars)"
)
return markdown
else:
logger.warning("Next.js API returned empty markdown")
return None
except httpx.TimeoutException:
logger.error("Timeout converting BlockNote to markdown after 30s")
return None
except httpx.HTTPStatusError as e:
logger.error(
f"HTTP error converting BlockNote to markdown: {e.response.status_code} - {e.response.text}"
)
return None
except Exception as e:
logger.error(f"Failed to convert BlockNote to markdown: {e}", exc_info=True)
return None

View file

@ -0,0 +1,291 @@
"""Pure-Python converter: BlockNote JSON → Markdown.
No external dependencies (no Node.js, no npm packages, no HTTP calls).
Handles all standard BlockNote block types. Produces output equivalent to
BlockNote's own ``blocksToMarkdownLossy()``.
Usage:
from app.utils.blocknote_to_markdown import blocknote_to_markdown
markdown = blocknote_to_markdown(blocknote_json)
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Inline content → markdown text
# ---------------------------------------------------------------------------
def _render_inline_content(content: list[dict[str, Any]] | None) -> str:
"""Convert BlockNote inline content array to a markdown string."""
if not content:
return ""
parts: list[str] = []
for item in content:
if not isinstance(item, dict):
continue
item_type = item.get("type", "text")
if item_type == "text":
text = item.get("text", "")
styles: dict[str, Any] = item.get("styles", {})
# Apply inline styles (order: code first so nested marks don't break it)
if styles.get("code"):
text = f"`{text}`"
else:
if styles.get("bold"):
text = f"**{text}**"
if styles.get("italic"):
text = f"*{text}*"
if styles.get("strikethrough"):
text = f"~~{text}~~"
# underline has no markdown equivalent — keep as plain text (lossy)
parts.append(text)
elif item_type == "link":
href = item.get("href", "")
link_content = item.get("content", [])
link_text = _render_inline_content(link_content) if link_content else href
parts.append(f"[{link_text}]({href})")
else:
# Unknown inline type — extract text if possible
text = item.get("text", "")
if text:
parts.append(text)
return "".join(parts)
# ---------------------------------------------------------------------------
# Block → markdown lines
# ---------------------------------------------------------------------------
def _render_block(
block: dict[str, Any], indent: int = 0, numbered_list_counter: int = 0
) -> tuple[list[str], int]:
"""Convert a single BlockNote block (and its children) to markdown lines.
Args:
block: A BlockNote block dict.
indent: Current indentation level (for nested children).
numbered_list_counter: Current counter for consecutive numbered list items.
Returns:
A tuple of (list of markdown lines without trailing newlines,
updated numbered_list_counter).
"""
block_type = block.get("type", "paragraph")
props: dict[str, Any] = block.get("props", {})
content = block.get("content")
children: list[dict[str, Any]] = block.get("children", [])
prefix = " " * indent # 2-space indent per nesting level
lines: list[str] = []
# --- Block type handlers ---
if block_type == "paragraph":
text = _render_inline_content(content) if content else ""
lines.append(f"{prefix}{text}")
elif block_type == "heading":
level = props.get("level", 1)
hashes = "#" * min(max(level, 1), 6)
text = _render_inline_content(content) if content else ""
lines.append(f"{prefix}{hashes} {text}")
elif block_type == "bulletListItem":
text = _render_inline_content(content) if content else ""
lines.append(f"{prefix}- {text}")
elif block_type == "numberedListItem":
# Use props.start if present, otherwise increment counter
start = props.get("start")
if start is not None:
numbered_list_counter = int(start)
else:
numbered_list_counter += 1
text = _render_inline_content(content) if content else ""
lines.append(f"{prefix}{numbered_list_counter}. {text}")
elif block_type == "checkListItem":
checked = props.get("checked", False)
marker = "[x]" if checked else "[ ]"
text = _render_inline_content(content) if content else ""
lines.append(f"{prefix}- {marker} {text}")
elif block_type == "codeBlock":
language = props.get("language", "")
# Code blocks store content as a single text item
code_text = _render_inline_content(content) if content else ""
lines.append(f"{prefix}```{language}")
for code_line in code_text.split("\n"):
lines.append(f"{prefix}{code_line}")
lines.append(f"{prefix}```")
elif block_type == "table":
# Table content is a nested structure: content.rows[].cells[][]
table_content = block.get("content", {})
rows: list[dict[str, Any]] = []
if isinstance(table_content, dict):
rows = table_content.get("rows", [])
elif isinstance(table_content, list):
# Some versions store rows directly as a list
rows = table_content
if rows:
for row_idx, row in enumerate(rows):
cells = row.get("cells", []) if isinstance(row, dict) else row
cell_texts: list[str] = []
for cell in cells:
if isinstance(cell, list):
# Cell is a list of inline content
cell_texts.append(_render_inline_content(cell))
elif isinstance(cell, dict):
# Cell is a tableCell object with its own content
cell_content = cell.get("content")
if isinstance(cell_content, list):
cell_texts.append(_render_inline_content(cell_content))
else:
cell_texts.append("")
elif isinstance(cell, str):
cell_texts.append(cell)
else:
cell_texts.append(str(cell))
lines.append(f"{prefix}| {' | '.join(cell_texts)} |")
# Add header separator after first row
if row_idx == 0:
lines.append(f"{prefix}| {' | '.join('---' for _ in cell_texts)} |")
elif block_type == "image":
url = props.get("url", "")
caption = props.get("caption", "") or props.get("name", "")
if url:
lines.append(f"{prefix}![{caption}]({url})")
elif block_type == "video":
url = props.get("url", "")
caption = props.get("caption", "") or "video"
if url:
lines.append(f"{prefix}[{caption}]({url})")
elif block_type == "audio":
url = props.get("url", "")
caption = props.get("caption", "") or "audio"
if url:
lines.append(f"{prefix}[{caption}]({url})")
elif block_type == "file":
url = props.get("url", "")
name = props.get("name", "") or props.get("caption", "") or "file"
if url:
lines.append(f"{prefix}[{name}]({url})")
else:
# Unknown block type — extract text content if possible, skip otherwise
if content:
text = _render_inline_content(content) if isinstance(content, list) else ""
if text:
lines.append(f"{prefix}{text}")
# If no content at all, silently skip (lossy)
# --- Render nested children (indented) ---
if children:
for child in children:
child_lines, numbered_list_counter = _render_block(
child, indent=indent + 1, numbered_list_counter=numbered_list_counter
)
lines.extend(child_lines)
return lines, numbered_list_counter
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def blocknote_to_markdown(
blocks: list[dict[str, Any]] | dict[str, Any] | None,
) -> str | None:
"""Convert a BlockNote document (list of blocks) to a markdown string.
Args:
blocks: BlockNote JSON either a list of block dicts, or a single
block dict, or None.
Returns:
Markdown string, or None if input is empty / unconvertible.
Examples:
>>> blocknote_to_markdown([
... {"type": "heading", "props": {"level": 2},
... "content": [{"type": "text", "text": "Hello", "styles": {}}],
... "children": []},
... {"type": "paragraph",
... "content": [{"type": "text", "text": "World", "styles": {"bold": True}}],
... "children": []},
... ])
'## Hello\\n\\nWorld'
"""
if not blocks:
return None
# Normalise: accept a single block as well as a list
if isinstance(blocks, dict):
blocks = [blocks]
if not isinstance(blocks, list):
logger.warning(
f"blocknote_to_markdown received unexpected type: {type(blocks)}"
)
return None
all_lines: list[str] = []
prev_type: str | None = None
numbered_list_counter: int = 0
for block in blocks:
if not isinstance(block, dict):
continue
block_type = block.get("type", "paragraph")
# Reset numbered list counter when we leave a numbered list run
if block_type != "numberedListItem" and prev_type == "numberedListItem":
numbered_list_counter = 0
block_lines, numbered_list_counter = _render_block(
block, numbered_list_counter=numbered_list_counter
)
# Add a blank line between blocks (standard markdown spacing)
# Exception: consecutive list items of the same type don't get extra blank lines
if all_lines and block_lines:
same_list = block_type == prev_type and block_type in (
"bulletListItem",
"numberedListItem",
"checkListItem",
)
if not same_list:
all_lines.append("")
all_lines.extend(block_lines)
prev_type = block_type
result = "\n".join(all_lines).strip()
return result if result else None

View file

@ -1,40 +0,0 @@
import { ServerBlockNoteEditor } from "@blocknote/server-util";
import { type NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const { markdown } = await request.json();
if (!markdown || typeof markdown !== "string") {
return NextResponse.json({ error: "Markdown string is required" }, { status: 400 });
}
// Log raw markdown input before conversion
// console.log(`\n${"=".repeat(80)}`);
// console.log("RAW MARKDOWN INPUT (BEFORE CONVERSION):");
// console.log("=".repeat(80));
// console.log(markdown);
// console.log(`${"=".repeat(80)}\n`);
// Create server-side editor instance
const editor = ServerBlockNoteEditor.create();
// Convert markdown directly to BlockNote blocks
const blocks = await editor.tryParseMarkdownToBlocks(markdown);
if (!blocks || blocks.length === 0) {
throw new Error("Markdown parsing returned no blocks");
}
return NextResponse.json({ blocknote_document: blocks });
} catch (error: any) {
console.error("Failed to convert markdown to BlockNote:", error);
return NextResponse.json(
{
error: "Failed to convert markdown to BlockNote blocks",
details: error.message,
},
{ status: 500 }
);
}
}

View file

@ -1,28 +0,0 @@
import { ServerBlockNoteEditor } from "@blocknote/server-util";
import { type NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const { blocknote_document } = await request.json();
if (!blocknote_document || !Array.isArray(blocknote_document)) {
return NextResponse.json({ error: "BlockNote document array is required" }, { status: 400 });
}
// Create server-side editor instance
const editor = ServerBlockNoteEditor.create();
// Convert BlockNote blocks to markdown
const markdown = await editor.blocksToMarkdownLossy(blocknote_document);
return NextResponse.json({
markdown,
});
} catch (error) {
console.error("Failed to convert BlockNote to markdown:", error);
return NextResponse.json(
{ error: "Failed to convert BlockNote blocks to markdown" },
{ status: 500 }
);
}
}

View file

@ -1,6 +1,6 @@
"use client";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { MoreHorizontal, PenLine, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
@ -115,7 +115,7 @@ export function RowActions({
isEditDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
}
>
<Pencil className="mr-2 h-4 w-4" />
<PenLine className="mr-2 h-4 w-4" />
<span>Edit</span>
</DropdownMenuItem>
{shouldShowDelete && (
@ -170,7 +170,7 @@ export function RowActions({
isEditDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
}
>
<Pencil className="mr-2 h-4 w-4" />
<PenLine className="mr-2 h-4 w-4" />
<span>Edit</span>
</DropdownMenuItem>
{shouldShowDelete && (

View file

@ -1,13 +1,13 @@
"use client";
import { useAtom } from "jotai";
import { AlertCircle, ArrowLeft, FileText, Save } from "lucide-react";
import { AlertCircle, ArrowLeft, FileText } from "lucide-react";
import { motion } from "motion/react";
import dynamic from "next/dynamic";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
import {
AlertDialog,
AlertDialogAction,
@ -20,58 +20,53 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Skeleton } from "@/components/ui/skeleton";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
// BlockNote types
type BlockNoteInlineContent =
| string
| { text?: string; type?: string; styles?: Record<string, unknown> };
interface BlockNoteBlock {
type: string;
content?: BlockNoteInlineContent[];
children?: BlockNoteBlock[];
props?: Record<string, unknown>;
}
type BlockNoteDocument = BlockNoteBlock[] | null | undefined;
// Dynamically import PlateEditor (uses 'use client' internally)
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((mod) => ({ default: mod.PlateEditor })),
{
ssr: false,
loading: () => (
<div className="mx-auto w-full max-w-[900px] px-6 md:px-12 pt-10 space-y-4">
<Skeleton className="h-8 w-3/5 rounded" />
<div className="space-y-3 pt-4">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-4/5 rounded" />
</div>
<div className="space-y-3 pt-3">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-5/6 rounded" />
<Skeleton className="h-4 w-3/4 rounded" />
</div>
<div className="space-y-3 pt-3">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-2/3 rounded" />
</div>
</div>
),
}
);
interface EditorContent {
document_id: number;
title: string;
document_type?: string;
blocknote_document: BlockNoteDocument;
source_markdown: string;
updated_at: string | null;
}
// Helper function to extract title from BlockNote document
// Takes the text content from the first block (should be a heading for notes)
function extractTitleFromBlockNote(blocknoteDocument: BlockNoteDocument): string {
if (!blocknoteDocument || !Array.isArray(blocknoteDocument) || blocknoteDocument.length === 0) {
return "Untitled";
/** Extract title from markdown: first # heading, or first non-empty line. */
function extractTitleFromMarkdown(markdown: string | null | undefined): string {
if (!markdown) return "Untitled";
for (const line of markdown.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("# ")) return trimmed.slice(2).trim() || "Untitled";
if (trimmed) return trimmed.slice(0, 100);
}
const firstBlock = blocknoteDocument[0];
if (!firstBlock) {
return "Untitled";
}
// Extract text from block content
// BlockNote blocks have a content array with inline content
if (firstBlock.content && Array.isArray(firstBlock.content)) {
const textContent = firstBlock.content
.map((item: BlockNoteInlineContent) => {
if (typeof item === "string") return item;
if (typeof item === "object" && item?.text) return item.text;
return "";
})
.join("")
.trim();
return textContent || "Untitled";
}
return "Untitled";
}
@ -85,11 +80,14 @@ export default function EditorPage() {
const [document, setDocument] = useState<EditorContent | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editorContent, setEditorContent] = useState<BlockNoteDocument>(null);
const [error, setError] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
// Store the latest markdown from the editor
const markdownRef = useRef<string>("");
const initialLoadDone = useRef(false);
// Global state for cross-component communication
const [, setGlobalHasUnsavedChanges] = useAtom(hasUnsavedEditorChangesAtom);
const [pendingNavigation, setPendingNavigation] = useAtom(pendingEditorNavigationAtom);
@ -107,51 +105,46 @@ export default function EditorPage() {
};
}, [setGlobalHasUnsavedChanges, setPendingNavigation]);
// Handle pending navigation from sidebar (e.g., when user clicks "+" to create new note)
// Handle pending navigation from sidebar
useEffect(() => {
if (pendingNavigation) {
if (hasUnsavedChanges) {
// Show dialog to confirm navigation
setShowUnsavedDialog(true);
} else {
// No unsaved changes, navigate immediately
router.push(pendingNavigation);
setPendingNavigation(null);
}
}
}, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]);
// Reset state when documentId changes (e.g., navigating from existing note to new note)
// Reset state when documentId changes
useEffect(() => {
setDocument(null);
setEditorContent(null);
setError(null);
setHasUnsavedChanges(false);
setLoading(true);
}, []);
initialLoadDone.current = false;
}, [documentId]);
// Fetch document content - DIRECT CALL TO FASTAPI
// Skip fetching if this is a new note
// Fetch document content
useEffect(() => {
async function fetchDocument() {
// For new notes, initialize with empty state
if (isNewNote) {
markdownRef.current = "";
setDocument({
document_id: 0,
title: "Untitled",
document_type: "NOTE",
blocknote_document: null,
source_markdown: "",
updated_at: null,
});
setEditorContent(null);
setLoading(false);
initialLoadDone.current = true;
return;
}
const token = getBearerToken();
if (!token) {
console.error("No auth token found");
// Redirect to login with current path saved
redirectToLogin();
return;
}
@ -166,29 +159,28 @@ export default function EditorPage() {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to fetch document" }));
const errorMessage = errorData.detail || "Failed to fetch document";
throw new Error(errorMessage);
throw new Error(errorData.detail || "Failed to fetch document");
}
const data = await response.json();
// Check if blocknote_document exists
if (!data.blocknote_document) {
const errorMsg =
"This document does not have BlockNote content. Please re-upload the document to enable editing.";
setError(errorMsg);
if (data.source_markdown === undefined || data.source_markdown === null) {
setError(
"This document does not have editable content. Please re-upload to enable editing."
);
setLoading(false);
return;
}
markdownRef.current = data.source_markdown;
setDocument(data);
setEditorContent(data.blocknote_document);
setError(null);
initialLoadDone.current = true;
} catch (error) {
console.error("Error fetching document:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to fetch document. Please try again.";
setError(errorMessage);
setError(
error instanceof Error ? error.message : "Failed to fetch document. Please try again."
);
} finally {
setLoading(false);
}
@ -199,29 +191,27 @@ export default function EditorPage() {
}
}, [documentId, params.search_space_id, isNewNote]);
// Track changes to mark as unsaved
useEffect(() => {
if (editorContent && document) {
setHasUnsavedChanges(true);
}
}, [editorContent, document]);
// Check if this is a NOTE type document
const isNote = isNewNote || document?.document_type === "NOTE";
// Extract title dynamically from editor content for notes, otherwise use document title
// Extract title dynamically from current markdown for notes
const displayTitle = useMemo(() => {
if (isNote && editorContent) {
return extractTitleFromBlockNote(editorContent);
if (isNote) {
return extractTitleFromMarkdown(markdownRef.current || document?.source_markdown);
}
return document?.title || "Untitled";
}, [isNote, editorContent, document?.title]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNote, document?.title, document?.source_markdown, hasUnsavedChanges]);
// TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI
// Handle markdown changes from the Plate editor
const handleMarkdownChange = useCallback((md: string) => {
markdownRef.current = md;
if (initialLoadDone.current) {
setHasUnsavedChanges(true);
}
}, []);
// Save and exit - DIRECT CALL TO FASTAPI
// For new notes, create the note first, then save
const handleSave = async () => {
// Save handler
const handleSave = useCallback(async () => {
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
@ -233,25 +223,26 @@ export default function EditorPage() {
setError(null);
try {
// If this is a new note, create it first
if (isNewNote) {
const title = extractTitleFromBlockNote(editorContent);
const currentMarkdown = markdownRef.current;
// Create the note first
if (isNewNote) {
const title = extractTitleFromMarkdown(currentMarkdown);
// Create the note
const note = await notesApiService.createNote({
search_space_id: searchSpaceId,
title: title,
blocknote_document: editorContent || undefined,
title,
source_markdown: currentMarkdown || undefined,
});
// If there's content, save it properly and trigger reindexing
if (editorContent) {
// If there's content, save & trigger reindexing
if (currentMarkdown) {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${note.id}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blocknote_document: editorContent }),
body: JSON.stringify({ source_markdown: currentMarkdown }),
}
);
@ -265,24 +256,15 @@ export default function EditorPage() {
setHasUnsavedChanges(false);
toast.success("Note created successfully! Reindexing in background...");
// Redirect to documents page after successful save
router.push(`/dashboard/${searchSpaceId}/documents`);
} else {
// Existing document - save normally
if (!editorContent) {
toast.error("No content to save");
setSaving(false);
return;
}
// Save blocknote_document and trigger reindexing in background
// Existing document — save
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blocknote_document: editorContent }),
body: JSON.stringify({ source_markdown: currentMarkdown }),
}
);
@ -295,8 +277,6 @@ export default function EditorPage() {
setHasUnsavedChanges(false);
toast.success("Document saved! Reindexing in background...");
// Redirect to documents page after successful save
router.push(`/dashboard/${searchSpaceId}/documents`);
}
} catch (error) {
@ -312,7 +292,7 @@ export default function EditorPage() {
} finally {
setSaving(false);
}
};
}, [isNewNote, searchSpaceId, documentId, params.search_space_id, router]);
const handleBack = () => {
if (hasUnsavedChanges) {
@ -324,11 +304,9 @@ export default function EditorPage() {
const handleConfirmLeave = () => {
setShowUnsavedDialog(false);
// Clear global unsaved state
setGlobalHasUnsavedChanges(false);
setHasUnsavedChanges(false);
// If there's a pending navigation (from sidebar), use that; otherwise go back to documents
if (pendingNavigation) {
router.push(pendingNavigation);
setPendingNavigation(null);
@ -337,26 +315,67 @@ export default function EditorPage() {
}
};
const handleSaveAndLeave = async () => {
setShowUnsavedDialog(false);
setPendingNavigation(null);
await handleSave();
};
const handleCancelLeave = () => {
setShowUnsavedDialog(false);
// Clear pending navigation if user cancels
setPendingNavigation(null);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px] p-6">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center justify-center py-12">
<Spinner size="xl" className="text-primary mb-4" />
<p className="text-muted-foreground">Loading editor</p>
</CardContent>
</Card>
<div className="flex flex-col h-screen w-full overflow-hidden">
{/* Top bar skeleton — real back button & file icon, skeleton title */}
<div className="flex h-14 md:h-16 shrink-0 items-center border-b bg-background pl-1.5 pr-3 md:pl-3 md:pr-6">
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/${searchSpaceId}/documents`)}
className="h-7 w-7 shrink-0 p-0"
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Back</span>
</Button>
<FileText className="h-4 w-4 md:h-5 md:w-5 text-muted-foreground shrink-0" />
<Skeleton className="h-5 w-40 rounded" />
</div>
</div>
{/* Fixed toolbar placeholder — matches real toolbar styling */}
<div className="sticky top-0 left-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 h-10" />
{/* Content area skeleton — mimics document text lines */}
<div className="flex-1 min-h-0 overflow-hidden">
<div className="mx-auto w-full max-w-[900px] px-6 md:px-12 pt-10 space-y-4">
{/* Title-like line */}
<Skeleton className="h-8 w-3/5 rounded" />
{/* Paragraph lines */}
<div className="space-y-3 pt-4">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-4/5 rounded" />
</div>
<div className="space-y-3 pt-3">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-5/6 rounded" />
<Skeleton className="h-4 w-3/4 rounded" />
</div>
<div className="space-y-3 pt-3">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-2/3 rounded" />
</div>
</div>
</div>
</div>
);
}
if (error) {
if (error && !document) {
return (
<div className="flex items-center justify-center min-h-[400px] p-6">
<motion.div
@ -405,11 +424,21 @@ export default function EditorPage() {
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col min-h-screen w-full"
className="flex flex-col h-screen w-full overflow-hidden"
>
{/* Toolbar */}
<div className="sticky top-0 z-40 flex h-14 md:h-16 shrink-0 items-center gap-2 md:gap-4 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-3 md:px-6">
<div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0">
<div className="flex h-14 md:h-16 shrink-0 items-center border-b bg-background pl-1.5 pr-3 md:pl-3 md:pr-6">
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
<Button
variant="ghost"
size="icon"
onClick={handleBack}
disabled={saving}
className="h-7 w-7 shrink-0 p-0"
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Back</span>
</Button>
<FileText className="h-4 w-4 md:h-5 md:w-5 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0">
<h1 className="text-base md:text-lg font-semibold truncate">{displayTitle}</h1>
@ -418,60 +447,33 @@ export default function EditorPage() {
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleBack}
disabled={saving}
className="gap-1 md:gap-2 px-2 md:px-4 h-8 md:h-10"
>
<ArrowLeft className="h-3.5 w-3.5 md:h-4 md:w-4" />
<span className="text-xs md:text-sm">Back</span>
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="gap-1 md:gap-2 px-2 md:px-4 h-8 md:h-10"
>
{saving ? (
<>
<Spinner size="sm" className="h-3.5 w-3.5 md:h-4 md:w-4" />
<span className="text-xs md:text-sm">{isNewNote ? "Creating" : "Saving"}</span>
</>
) : (
<>
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
<span className="text-xs md:text-sm">Save</span>
</>
)}
</Button>
</div>
</div>
{/* Editor Container */}
<div className="flex-1 min-h-0 overflow-hidden relative">
<div className="h-full w-full overflow-auto p-3 md:p-6">
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 max-w-4xl mx-auto"
>
<div className="flex items-center gap-2 p-4 rounded-lg border border-destructive/50 bg-destructive/10 text-destructive">
<AlertCircle className="h-5 w-5 shrink-0" />
<p className="text-sm">{error}</p>
</div>
</motion.div>
)}
<div className="max-w-4xl mx-auto">
<BlockNoteEditor
key={documentId} // Force re-mount when document changes
initialContent={isNewNote ? undefined : editorContent}
onChange={setEditorContent}
useTitleBlock={isNote}
/>
</div>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden relative">
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="px-3 md:px-6 pt-3 md:pt-6"
>
<div className="flex items-center gap-2 p-4 rounded-lg border border-destructive/50 bg-destructive/10 text-destructive max-w-4xl mx-auto">
<AlertCircle className="h-5 w-5 shrink-0" />
<p className="text-sm">{error}</p>
</div>
</motion.div>
)}
<div className="flex-1 min-h-0">
<PlateEditor
key={documentId}
preset="full"
markdown={document?.source_markdown ?? ""}
onMarkdownChange={handleMarkdownChange}
onSave={handleSave}
hasUnsavedChanges={hasUnsavedChanges}
isSaving={saving}
defaultEditing={true}
/>
</div>
</div>
@ -491,7 +493,13 @@ export default function EditorPage() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelLeave}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmLeave}>OK</AlertDialogAction>
<AlertDialogAction onClick={handleSaveAndLeave}>Save</AlertDialogAction>
<AlertDialogAction
onClick={handleConfirmLeave}
className="border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground"
>
Leave without saving
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View file

@ -4,6 +4,8 @@
@plugin "tailwindcss-animate";
@plugin "tailwind-scrollbar-hide";
@custom-variant dark (&:is(.dark *));
@theme {
@ -46,6 +48,8 @@
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--syntax-bg: #f5f5f5;
--brand: oklch(0.623 0.214 259.815);
--highlight: oklch(0.852 0.199 91.936);
}
.dark {
@ -82,6 +86,8 @@
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
--syntax-bg: #1e1e1e;
--brand: oklch(0.707 0.165 254.624);
--highlight: oklch(0.852 0.199 91.936);
}
@theme inline {
@ -123,6 +129,8 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-brand: var(--brand);
--color-highlight: var(--highlight);
}
@layer base {

View file

@ -10,6 +10,7 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@ -17,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"registries": {
"@plate": "https://platejs.org/r/{name}.json"
}
}

View file

@ -1,213 +0,0 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useMemo, useRef } from "react";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";
import { BlockNoteView } from "@blocknote/mantine";
import { useCreateBlockNote } from "@blocknote/react";
interface BlockNoteEditorProps {
initialContent?: any;
onChange?: (content: any) => void;
useTitleBlock?: boolean; // Whether to use first block as title (Notion-style)
}
// Helper to ensure first block is a heading for title
function ensureTitleBlock(content: any[] | undefined): any[] {
if (!content || content.length === 0) {
// Return empty heading block for new notes
return [
{
type: "heading",
props: { level: 1 },
content: [],
children: [],
},
];
}
// If first block is not a heading, convert it to one
const firstBlock = content[0];
if (firstBlock?.type !== "heading") {
// Extract text from first block
let titleText = "";
if (firstBlock?.content && Array.isArray(firstBlock.content)) {
titleText = firstBlock.content
.map((item: any) => {
if (typeof item === "string") return item;
if (item?.text) return item.text;
return "";
})
.join("")
.trim();
}
// Create heading block with extracted text
const titleBlock = {
type: "heading",
props: { level: 1 },
content: titleText
? [
{
type: "text",
text: titleText,
styles: {},
},
]
: [],
children: [],
};
// Replace first block with heading, keep rest
return [titleBlock, ...content.slice(1)];
}
return content;
}
export default function BlockNoteEditor({
initialContent,
onChange,
useTitleBlock = false,
}: BlockNoteEditorProps) {
const { resolvedTheme } = useTheme();
// Track the initial content to prevent re-initialization
const initialContentRef = useRef<any>(null);
const isInitializedRef = useRef(false);
// Prepare initial content - ensure first block is a heading if useTitleBlock is true
const preparedInitialContent = useMemo(() => {
if (initialContentRef.current !== null) {
return undefined; // Already initialized
}
if (initialContent === undefined) {
// New note - create empty heading block
return useTitleBlock
? [
{
type: "heading",
props: { level: 1 },
content: [],
children: [],
},
]
: undefined;
}
// Existing note - ensure first block is heading
return useTitleBlock ? ensureTitleBlock(initialContent) : initialContent;
}, [initialContent, useTitleBlock]);
// Creates a new editor instance - only use initialContent on first render
const editor = useCreateBlockNote({
initialContent: initialContentRef.current === null ? preparedInitialContent : undefined,
});
// Store initial content on first render only
useEffect(() => {
if (preparedInitialContent !== undefined && initialContentRef.current === null) {
initialContentRef.current = preparedInitialContent;
isInitializedRef.current = true;
} else if (preparedInitialContent === undefined && initialContentRef.current === null) {
// Mark as initialized even when initialContent is undefined (for new notes)
isInitializedRef.current = true;
}
}, [preparedInitialContent]);
// Call onChange when document changes (but don't update from props)
useEffect(() => {
if (!onChange || !editor) return;
// For new notes (no initialContent), we need to wait for editor to be ready
// Use a small delay to ensure editor is fully initialized
if (!isInitializedRef.current) {
const timer = setTimeout(() => {
isInitializedRef.current = true;
}, 100);
return () => clearTimeout(timer);
}
const handleChange = () => {
onChange(editor.document);
};
// Subscribe to document changes
const unsubscribe = editor.onChange(handleChange);
// Also call onChange once with current document to capture initial state
// This ensures we capture content even if user doesn't make changes
if (editor.document) {
onChange(editor.document);
}
return () => {
unsubscribe();
};
}, [editor, onChange]);
// Determine theme for BlockNote with custom dark mode background
const blockNoteTheme = useMemo(() => {
if (resolvedTheme === "dark") {
// Custom dark theme - only override editor background, let BlockNote handle the rest
return {
colors: {
editor: {
background: "#0A0A0A", // Custom dark background
},
},
};
}
return "light" as const;
}, [resolvedTheme]);
// Renders the editor instance
return (
<div className="bn-container">
<style>{`
@media (max-width: 640px) {
.bn-container .bn-editor {
padding-inline: 12px !important;
}
/* Heading Level 1 (Title) */
.bn-container [data-content-type="heading"][data-level="1"] {
font-size: 1.75rem !important;
line-height: 1.2 !important;
margin-top: 1rem !important;
margin-bottom: 0.5rem !important;
}
/* Heading Level 2 */
.bn-container [data-content-type="heading"][data-level="2"] {
font-size: 1.5rem !important;
line-height: 1.2 !important;
margin-top: 0.875rem !important;
margin-bottom: 0.375rem !important;
}
/* Heading Level 3 */
.bn-container [data-content-type="heading"][data-level="3"] {
font-size: 1.25rem !important;
line-height: 1.2 !important;
margin-top: 0.75rem !important;
margin-bottom: 0.25rem !important;
}
/* Paragraphs and regular content */
.bn-container .bn-block-content {
font-size: 0.9375rem !important;
line-height: 1.5 !important;
}
/* Adjust lists */
.bn-container ul,
.bn-container ol {
padding-left: 1.25rem !important;
}
}
`}</style>
<BlockNoteView editor={editor} theme={blockNoteTheme} />
</div>
);
}

View file

@ -1,6 +0,0 @@
"use client";
import dynamic from "next/dynamic";
// Dynamically import BlockNote editor with SSR disabled
export const BlockNoteEditor = dynamic(() => import("./BlockNoteEditor"), { ssr: false });

View file

@ -0,0 +1,24 @@
"use client";
import { createContext, useContext } from "react";
interface EditorSaveContextValue {
/** Callback to save the current editor content */
onSave?: () => void;
/** Whether there are unsaved changes */
hasUnsavedChanges: boolean;
/** Whether a save operation is in progress */
isSaving: boolean;
/** Whether the user can toggle between editing and viewing modes */
canToggleMode: boolean;
}
export const EditorSaveContext = createContext<EditorSaveContextValue>({
hasUnsavedChanges: false,
isSaving: false,
canToggleMode: false,
});
export function useEditorSave() {
return useContext(EditorSaveContext);
}

View file

@ -0,0 +1,174 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { MarkdownPlugin, remarkMdx } from "@platejs/markdown";
import type { AnyPluginConfig } from "platejs";
import { createPlatePlugin, Key, Plate, usePlateEditor } from "platejs/react";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { type EditorPreset, presetMap } from "@/components/editor/presets";
import { Editor, EditorContainer } from "@/components/ui/editor";
import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
import { EditorSaveContext } from "@/components/editor/editor-save-context";
export interface PlateEditorProps {
/** Markdown string to load as initial content */
markdown?: string;
/** Called when the editor content changes, with serialized markdown */
onMarkdownChange?: (markdown: string) => void;
/**
* Force permanent read-only mode (e.g. public/shared view).
* When true, the editor cannot be toggled to editing mode.
* When false (default), the editor starts in viewing mode but
* the user can switch to editing via the mode toolbar button.
*/
readOnly?: boolean;
/** Placeholder text */
placeholder?: string;
/** Editor container variant */
variant?: "default" | "demo" | "comment" | "select";
/** Editor text variant */
editorVariant?: "default" | "demo" | "fullWidth" | "none";
/** Additional className for the container */
className?: string;
/** Save callback. When provided, ⌘+S / Ctrl+S shortcut is registered and save button appears. */
onSave?: () => void;
/** Whether there are unsaved changes */
hasUnsavedChanges?: boolean;
/** Whether a save is in progress */
isSaving?: boolean;
/** Start the editor in editing mode instead of viewing mode. Ignored when readOnly is true. */
defaultEditing?: boolean;
/**
* Plugin preset to use. Controls which plugin kits are loaded.
* - "full" all plugins (toolbars, slash commands, DnD, etc.)
* - "minimal" core formatting only (no fixed toolbar, slash commands, DnD, block selection)
* - "readonly" rendering support for all rich content, no editing UI
* @default "full"
*/
preset?: EditorPreset;
/**
* Additional plugins to append after the preset plugins.
* Use this to inject feature-specific plugins (e.g. approve/reject blocks)
* without modifying the core editor component.
*/
extraPlugins?: AnyPluginConfig[];
}
export function PlateEditor({
markdown,
onMarkdownChange,
readOnly = false,
placeholder = "Type...",
variant = "default",
editorVariant = "default",
className,
onSave,
hasUnsavedChanges = false,
isSaving = false,
defaultEditing = false,
preset = "full",
extraPlugins = [],
}: PlateEditorProps) {
const lastMarkdownRef = useRef(markdown);
// Keep a stable ref to the latest onSave callback so the plugin shortcut
// always calls the most recent version without re-creating the editor.
const onSaveRef = useRef(onSave);
useEffect(() => {
onSaveRef.current = onSave;
}, [onSave]);
// Stable Plate plugin for ⌘+S / Ctrl+S save shortcut.
// Only included when onSave is provided.
const SaveShortcutPlugin = useMemo(
() =>
createPlatePlugin({
key: "save-shortcut",
shortcuts: {
save: {
keys: [[Key.Mod, "s"]],
handler: () => {
onSaveRef.current?.();
},
preventDefault: true,
},
},
}),
[]
);
// Resolve the plugin set from the chosen preset
const presetPlugins = presetMap[preset];
// When readOnly is forced, always start in readOnly.
// Otherwise, respect defaultEditing to decide initial mode.
// The user can still toggle between editing/viewing via ModeToolbarButton.
const editor = usePlateEditor({
readOnly: readOnly || !defaultEditing,
plugins: [
...presetPlugins,
// Only register save shortcut when a save handler is provided
...(onSave ? [SaveShortcutPlugin] : []),
// Consumer-provided extra plugins
...extraPlugins,
MarkdownPlugin.configure({
options: {
remarkPlugins: [remarkGfm, remarkMath, remarkMdx],
},
}),
],
// Use markdown deserialization for initial value if provided
value: markdown
? (editor) =>
editor.getApi(MarkdownPlugin).markdown.deserialize(escapeMdxExpressions(markdown))
: undefined,
});
// Update editor content when markdown prop changes externally
// (e.g., version switching in report panel)
useEffect(() => {
if (markdown !== undefined && markdown !== lastMarkdownRef.current) {
lastMarkdownRef.current = markdown;
const newValue = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(markdown));
editor.tf.reset();
editor.tf.setValue(newValue);
}
}, [markdown, editor]);
// When not forced read-only, the user can toggle between editing/viewing.
const canToggleMode = !readOnly;
return (
<EditorSaveContext.Provider
value={{
onSave,
hasUnsavedChanges,
isSaving,
canToggleMode,
}}
>
<Plate
editor={editor}
// Only pass readOnly as a controlled prop when forced (permanently read-only).
// For non-forced mode, the Plate store manages readOnly internally
// (initialized to true via usePlateEditor, toggled via ModeToolbarButton).
{...(readOnly ? { readOnly: true } : {})}
onChange={({ value }) => {
if (onMarkdownChange) {
const md = editor.getApi(MarkdownPlugin).markdown.serialize({ value });
lastMarkdownRef.current = md;
onMarkdownChange(md);
}
}}
>
<EditorContainer variant={variant} className={className}>
<Editor variant={editorVariant} placeholder={placeholder} />
</EditorContainer>
</Plate>
</EditorSaveContext.Provider>
);
}

View file

@ -0,0 +1,237 @@
"use client";
import type { AutoformatRule } from "@platejs/autoformat";
import {
autoformatArrow,
autoformatLegal,
autoformatLegalHtml,
autoformatMath,
AutoformatPlugin,
autoformatPunctuation,
autoformatSmartQuotes,
} from "@platejs/autoformat";
import { insertEmptyCodeBlock } from "@platejs/code-block";
import { toggleList } from "@platejs/list";
import { openNextToggles } from "@platejs/toggle/react";
import { KEYS } from "platejs";
const autoformatMarks: AutoformatRule[] = [
{
match: "***",
mode: "mark",
type: [KEYS.bold, KEYS.italic],
},
{
match: "__*",
mode: "mark",
type: [KEYS.underline, KEYS.italic],
},
{
match: "__**",
mode: "mark",
type: [KEYS.underline, KEYS.bold],
},
{
match: "___***",
mode: "mark",
type: [KEYS.underline, KEYS.bold, KEYS.italic],
},
{
match: "**",
mode: "mark",
type: KEYS.bold,
},
{
match: "__",
mode: "mark",
type: KEYS.underline,
},
{
match: "*",
mode: "mark",
type: KEYS.italic,
},
{
match: "_",
mode: "mark",
type: KEYS.italic,
},
{
match: "~~",
mode: "mark",
type: KEYS.strikethrough,
},
{
match: "^",
mode: "mark",
type: KEYS.sup,
},
{
match: "~",
mode: "mark",
type: KEYS.sub,
},
{
match: "==",
mode: "mark",
type: KEYS.highlight,
},
{
match: "≡",
mode: "mark",
type: KEYS.highlight,
},
{
match: "`",
mode: "mark",
type: KEYS.code,
},
];
const autoformatBlocks: AutoformatRule[] = [
{
match: "# ",
mode: "block",
type: KEYS.h1,
},
{
match: "## ",
mode: "block",
type: KEYS.h2,
},
{
match: "### ",
mode: "block",
type: KEYS.h3,
},
{
match: "#### ",
mode: "block",
type: KEYS.h4,
},
{
match: "##### ",
mode: "block",
type: KEYS.h5,
},
{
match: "###### ",
mode: "block",
type: KEYS.h6,
},
{
match: "> ",
mode: "block",
type: KEYS.blockquote,
},
{
match: "```",
mode: "block",
type: KEYS.codeBlock,
format: (editor) => {
insertEmptyCodeBlock(editor, {
defaultType: KEYS.p,
insertNodesOptions: { select: true },
});
},
},
{
match: "+ ",
mode: "block",
preFormat: openNextToggles,
type: KEYS.toggle,
},
{
match: ["---", "—-", "___ "],
mode: "block",
type: KEYS.hr,
format: (editor) => {
editor.tf.setNodes({ type: KEYS.hr });
editor.tf.insertNodes({
children: [{ text: "" }],
type: KEYS.p,
});
},
},
];
const autoformatLists: AutoformatRule[] = [
{
match: ["* ", "- "],
mode: "block",
type: "list",
format: (editor) => {
toggleList(editor, {
listStyleType: KEYS.ul,
});
},
},
{
match: [String.raw`^\d+\.$ `, String.raw`^\d+\)$ `],
matchByRegex: true,
mode: "block",
type: "list",
format: (editor, { matchString }) => {
toggleList(editor, {
listRestartPolite: Number(matchString) || 1,
listStyleType: KEYS.ol,
});
},
},
{
match: ["[] "],
mode: "block",
type: "list",
format: (editor) => {
toggleList(editor, {
listStyleType: KEYS.listTodo,
});
editor.tf.setNodes({
checked: false,
listStyleType: KEYS.listTodo,
});
},
},
{
match: ["[x] "],
mode: "block",
type: "list",
format: (editor) => {
toggleList(editor, {
listStyleType: KEYS.listTodo,
});
editor.tf.setNodes({
checked: true,
listStyleType: KEYS.listTodo,
});
},
},
];
export const AutoformatKit = [
AutoformatPlugin.configure({
options: {
enableUndoOnDelete: true,
rules: [
...autoformatBlocks,
...autoformatMarks,
...autoformatSmartQuotes,
...autoformatPunctuation,
...autoformatLegal,
...autoformatLegalHtml,
...autoformatArrow,
...autoformatMath,
...autoformatLists,
].map(
(rule): AutoformatRule => ({
...rule,
query: (editor) =>
!editor.api.some({
match: { type: editor.getType(KEYS.codeBlock) },
}),
})
),
},
}),
];

View file

@ -0,0 +1,86 @@
"use client";
import {
BlockquotePlugin,
H1Plugin,
H2Plugin,
H3Plugin,
H4Plugin,
H5Plugin,
H6Plugin,
HorizontalRulePlugin,
} from "@platejs/basic-nodes/react";
import { ParagraphPlugin } from "platejs/react";
import { BlockquoteElement } from "@/components/ui/blockquote-node";
import {
H1Element,
H2Element,
H3Element,
H4Element,
H5Element,
H6Element,
} from "@/components/ui/heading-node";
import { HrElement } from "@/components/ui/hr-node";
import { ParagraphElement } from "@/components/ui/paragraph-node";
export const BasicBlocksKit = [
ParagraphPlugin.withComponent(ParagraphElement),
H1Plugin.configure({
node: {
component: H1Element,
},
rules: {
break: { empty: "reset" },
},
shortcuts: { toggle: { keys: "mod+alt+1" } },
}),
H2Plugin.configure({
node: {
component: H2Element,
},
rules: {
break: { empty: "reset" },
},
shortcuts: { toggle: { keys: "mod+alt+2" } },
}),
H3Plugin.configure({
node: {
component: H3Element,
},
rules: {
break: { empty: "reset" },
},
shortcuts: { toggle: { keys: "mod+alt+3" } },
}),
H4Plugin.configure({
node: {
component: H4Element,
},
rules: {
break: { empty: "reset" },
},
shortcuts: { toggle: { keys: "mod+alt+4" } },
}),
H5Plugin.configure({
node: {
component: H5Element,
},
rules: {
break: { empty: "reset" },
},
}),
H6Plugin.configure({
node: {
component: H6Element,
},
rules: {
break: { empty: "reset" },
},
}),
BlockquotePlugin.configure({
node: { component: BlockquoteElement },
shortcuts: { toggle: { keys: "mod+shift+period" } },
}),
HorizontalRulePlugin.withComponent(HrElement),
];

View file

@ -0,0 +1,38 @@
"use client";
import {
BoldPlugin,
CodePlugin,
HighlightPlugin,
ItalicPlugin,
StrikethroughPlugin,
SubscriptPlugin,
SuperscriptPlugin,
UnderlinePlugin,
} from "@platejs/basic-nodes/react";
import { CodeLeaf } from "@/components/ui/code-node";
import { HighlightLeaf } from "@/components/ui/highlight-node";
export const BasicMarksKit = [
BoldPlugin,
ItalicPlugin,
UnderlinePlugin,
CodePlugin.configure({
node: { component: CodeLeaf },
shortcuts: { toggle: { keys: "mod+e" } },
}),
StrikethroughPlugin.configure({
shortcuts: { toggle: { keys: "mod+shift+x" } },
}),
SubscriptPlugin.configure({
shortcuts: { toggle: { keys: "mod+comma" } },
}),
SuperscriptPlugin.configure({
shortcuts: { toggle: { keys: "mod+period" } },
}),
HighlightPlugin.configure({
node: { component: HighlightLeaf },
shortcuts: { toggle: { keys: "mod+shift+h" } },
}),
];

View file

@ -0,0 +1,6 @@
"use client";
import { BasicBlocksKit } from "./basic-blocks-kit";
import { BasicMarksKit } from "./basic-marks-kit";
export const BasicNodesKit = [...BasicBlocksKit, ...BasicMarksKit];

View file

@ -0,0 +1,7 @@
"use client";
import { CalloutPlugin } from "@platejs/callout/react";
import { CalloutElement } from "@/components/ui/callout-node";
export const CalloutKit = [CalloutPlugin.withComponent(CalloutElement)];

View file

@ -0,0 +1,18 @@
"use client";
import { CodeBlockPlugin, CodeLinePlugin, CodeSyntaxPlugin } from "@platejs/code-block/react";
import { all, createLowlight } from "lowlight";
import { CodeBlockElement, CodeLineElement, CodeSyntaxLeaf } from "@/components/ui/code-block-node";
const lowlight = createLowlight(all);
export const CodeBlockKit = [
CodeBlockPlugin.configure({
node: { component: CodeBlockElement },
options: { lowlight },
shortcuts: { toggle: { keys: "mod+alt+8" } },
}),
CodeLinePlugin.withComponent(CodeLineElement),
CodeSyntaxPlugin.withComponent(CodeSyntaxLeaf),
];

View file

@ -0,0 +1,20 @@
"use client";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { DndPlugin } from "@platejs/dnd";
import { BlockDraggable } from "@/components/ui/block-draggable";
export const DndKit = [
DndPlugin.configure({
options: {
enableScroller: true,
},
render: {
aboveNodes: BlockDraggable,
aboveSlate: ({ children }) => <DndProvider backend={HTML5Backend}>{children}</DndProvider>,
},
}),
];

View file

@ -0,0 +1,19 @@
"use client";
import { createPlatePlugin } from "platejs/react";
import { FixedToolbar } from "@/components/ui/fixed-toolbar";
import { FixedToolbarButtons } from "@/components/ui/fixed-toolbar-buttons";
export const FixedToolbarKit = [
createPlatePlugin({
key: "fixed-toolbar",
render: {
beforeEditable: () => (
<FixedToolbar>
<FixedToolbarButtons />
</FixedToolbar>
),
},
}),
];

View file

@ -0,0 +1,19 @@
"use client";
import { createPlatePlugin } from "platejs/react";
import { FloatingToolbar } from "@/components/ui/floating-toolbar";
import { FloatingToolbarButtons } from "@/components/ui/floating-toolbar-buttons";
export const FloatingToolbarKit = [
createPlatePlugin({
key: "floating-toolbar",
render: {
afterEditable: () => (
<FloatingToolbar>
<FloatingToolbarButtons />
</FloatingToolbar>
),
},
}),
];

View file

@ -0,0 +1,12 @@
"use client";
import { IndentPlugin } from "@platejs/indent/react";
import { KEYS } from "platejs";
export const IndentKit = [
IndentPlugin.configure({
inject: {
targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote, KEYS.codeBlock, KEYS.toggle],
},
}),
];

View file

@ -0,0 +1,15 @@
"use client";
import { LinkPlugin } from "@platejs/link/react";
import { LinkElement } from "@/components/ui/link-node";
import { LinkFloatingToolbar } from "@/components/ui/link-toolbar";
export const LinkKit = [
LinkPlugin.configure({
render: {
node: LinkElement,
afterEditable: () => <LinkFloatingToolbar />,
},
}),
];

View file

@ -0,0 +1,19 @@
"use client";
import { ListPlugin } from "@platejs/list/react";
import { KEYS } from "platejs";
import { IndentKit } from "@/components/editor/plugins/indent-kit";
import { BlockList } from "@/components/ui/block-list";
export const ListKit = [
...IndentKit,
ListPlugin.configure({
inject: {
targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote, KEYS.codeBlock, KEYS.toggle],
},
render: {
belowNodes: BlockList,
},
}),
];

View file

@ -0,0 +1,10 @@
"use client";
import { EquationPlugin, InlineEquationPlugin } from "@platejs/math/react";
import { EquationElement, InlineEquationElement } from "@/components/ui/equation-node";
export const MathKit = [
EquationPlugin.withComponent(EquationElement),
InlineEquationPlugin.withComponent(InlineEquationElement),
];

View file

@ -0,0 +1,23 @@
"use client";
import { BlockSelectionPlugin } from "@platejs/selection/react";
import { BlockSelection } from "@/components/ui/block-selection";
export const SelectionKit = [
BlockSelectionPlugin.configure({
render: {
belowRootNodes: BlockSelection as any,
},
options: {
isSelectable: (element) => {
// Exclude specific block types from selection
if (["code_line", "td", "th"].includes(element.type as string)) {
return false;
}
return true;
},
},
}),
];

View file

@ -0,0 +1,20 @@
"use client";
import { SlashInputPlugin, SlashPlugin } from "@platejs/slash-command/react";
import { KEYS } from "platejs";
import { SlashInputElement } from "@/components/ui/slash-node";
export const SlashCommandKit = [
SlashPlugin.configure({
options: {
trigger: "/",
triggerPreviousCharPattern: /^\s?$/,
triggerQuery: (editor) =>
!editor.api.some({
match: { type: editor.getType(KEYS.codeBlock) },
}),
},
}),
SlashInputPlugin.withComponent(SlashInputElement),
];

View file

@ -0,0 +1,22 @@
"use client";
import {
TableCellHeaderPlugin,
TableCellPlugin,
TablePlugin,
TableRowPlugin,
} from "@platejs/table/react";
import {
TableCellElement,
TableCellHeaderElement,
TableElement,
TableRowElement,
} from "@/components/ui/table-node";
export const TableKit = [
TablePlugin.withComponent(TableElement),
TableRowPlugin.withComponent(TableRowElement),
TableCellPlugin.withComponent(TableCellElement),
TableCellHeaderPlugin.withComponent(TableCellHeaderElement),
];

View file

@ -0,0 +1,12 @@
"use client";
import { TogglePlugin } from "@platejs/toggle/react";
import { ToggleElement } from "@/components/ui/toggle-node";
export const ToggleKit = [
TogglePlugin.configure({
node: { component: ToggleElement },
shortcuts: { toggle: { keys: "mod+alt+9" } },
}),
];

View file

@ -0,0 +1,79 @@
"use client";
import type { AnyPluginConfig } from "platejs";
import { AutoformatKit } from "@/components/editor/plugins/autoformat-kit";
import { BasicNodesKit } from "@/components/editor/plugins/basic-nodes-kit";
import { CalloutKit } from "@/components/editor/plugins/callout-kit";
import { CodeBlockKit } from "@/components/editor/plugins/code-block-kit";
import { DndKit } from "@/components/editor/plugins/dnd-kit";
import { FixedToolbarKit } from "@/components/editor/plugins/fixed-toolbar-kit";
import { FloatingToolbarKit } from "@/components/editor/plugins/floating-toolbar-kit";
import { LinkKit } from "@/components/editor/plugins/link-kit";
import { ListKit } from "@/components/editor/plugins/list-kit";
import { MathKit } from "@/components/editor/plugins/math-kit";
import { SelectionKit } from "@/components/editor/plugins/selection-kit";
import { SlashCommandKit } from "@/components/editor/plugins/slash-command-kit";
import { TableKit } from "@/components/editor/plugins/table-kit";
import { ToggleKit } from "@/components/editor/plugins/toggle-kit";
/**
* Full preset every plugin kit enabled.
* Used by the Documents editor and Reports editor (rich editing experience).
*/
export const fullPreset: AnyPluginConfig[] = [
...BasicNodesKit,
...TableKit,
...ListKit,
...CodeBlockKit,
...LinkKit,
...CalloutKit,
...ToggleKit,
...MathKit,
...SelectionKit,
...SlashCommandKit,
...FixedToolbarKit,
...FloatingToolbarKit,
...AutoformatKit,
...DndKit,
];
/**
* Minimal preset lightweight editing with core formatting only.
* No fixed toolbar, no slash commands, no DnD, no block selection.
* Ideal for inline editors like human-in-the-loop agent actions.
*/
export const minimalPreset: AnyPluginConfig[] = [
...BasicNodesKit,
...ListKit,
...CodeBlockKit,
...LinkKit,
...FloatingToolbarKit,
...AutoformatKit,
];
/**
* Read-only preset rendering support for all rich content, but no editing UI.
* No toolbars, no autoformat, no DnD, no slash commands, no block selection.
* Ideal for pure display / viewer contexts.
*/
export const readonlyPreset: AnyPluginConfig[] = [
...BasicNodesKit,
...TableKit,
...ListKit,
...CodeBlockKit,
...LinkKit,
...CalloutKit,
...ToggleKit,
...MathKit,
];
/** All available preset names */
export type EditorPreset = "full" | "minimal" | "readonly";
/** Map from preset name to plugin array */
export const presetMap: Record<EditorPreset, AnyPluginConfig[]> = {
full: fullPreset,
minimal: minimalPreset,
readonly: readonlyPreset,
};

View file

@ -0,0 +1,160 @@
"use client";
import type { PlateEditor } from "platejs/react";
import { insertCallout } from "@platejs/callout";
import { insertCodeBlock, toggleCodeBlock } from "@platejs/code-block";
import { triggerFloatingLink } from "@platejs/link/react";
import { insertInlineEquation } from "@platejs/math";
import { TablePlugin } from "@platejs/table/react";
import { type NodeEntry, type Path, type TElement, KEYS, PathApi } from "platejs";
const insertList = (editor: PlateEditor, type: string) => {
editor.tf.insertNodes(
editor.api.create.block({
indent: 1,
listStyleType: type,
}),
{ select: true }
);
};
const insertBlockMap: Record<string, (editor: PlateEditor, type: string) => void> = {
[KEYS.listTodo]: insertList,
[KEYS.ol]: insertList,
[KEYS.ul]: insertList,
[KEYS.codeBlock]: (editor) => insertCodeBlock(editor, { select: true }),
[KEYS.table]: (editor) => editor.getTransforms(TablePlugin).insert.table({}, { select: true }),
[KEYS.callout]: (editor) => insertCallout(editor, { select: true }),
[KEYS.toggle]: (editor) => {
editor.tf.insertNodes(editor.api.create.block({ type: KEYS.toggle }), { select: true });
},
};
const insertInlineMap: Record<string, (editor: PlateEditor, type: string) => void> = {
[KEYS.link]: (editor) => triggerFloatingLink(editor, { focused: true }),
[KEYS.equation]: (editor) => insertInlineEquation(editor),
};
type InsertBlockOptions = {
upsert?: boolean;
};
export const insertBlock = (
editor: PlateEditor,
type: string,
options: InsertBlockOptions = {}
) => {
const { upsert = false } = options;
editor.tf.withoutNormalizing(() => {
const block = editor.api.block();
if (!block) return;
const [currentNode, path] = block;
const isCurrentBlockEmpty = editor.api.isEmpty(currentNode);
const currentBlockType = getBlockType(currentNode);
const isSameBlockType = type === currentBlockType;
if (upsert && isCurrentBlockEmpty && isSameBlockType) {
return;
}
if (type in insertBlockMap) {
insertBlockMap[type](editor, type);
} else {
editor.tf.insertNodes(editor.api.create.block({ type }), {
at: PathApi.next(path),
select: true,
});
}
if (!isSameBlockType) {
editor.tf.removeNodes({ previousEmptyBlock: true });
}
});
};
export const insertInlineElement = (editor: PlateEditor, type: string) => {
if (insertInlineMap[type]) {
insertInlineMap[type](editor, type);
}
};
const setList = (editor: PlateEditor, type: string, entry: NodeEntry<TElement>) => {
editor.tf.setNodes(
editor.api.create.block({
indent: 1,
listStyleType: type,
}),
{
at: entry[1],
}
);
};
const setBlockMap: Record<
string,
(editor: PlateEditor, type: string, entry: NodeEntry<TElement>) => void
> = {
[KEYS.listTodo]: setList,
[KEYS.ol]: setList,
[KEYS.ul]: setList,
[KEYS.codeBlock]: (editor) => toggleCodeBlock(editor),
[KEYS.callout]: (editor, _type, entry) => {
editor.tf.setNodes({ type: KEYS.callout }, { at: entry[1] });
},
[KEYS.toggle]: (editor, _type, entry) => {
editor.tf.setNodes({ type: KEYS.toggle }, { at: entry[1] });
},
};
export const setBlockType = (editor: PlateEditor, type: string, { at }: { at?: Path } = {}) => {
editor.tf.withoutNormalizing(() => {
const setEntry = (entry: NodeEntry<TElement>) => {
const [node, path] = entry;
if (node[KEYS.listType]) {
editor.tf.unsetNodes([KEYS.listType, "indent"], { at: path });
}
if (type in setBlockMap) {
return setBlockMap[type](editor, type, entry);
}
if (node.type !== type) {
editor.tf.setNodes({ type }, { at: path });
}
};
if (at) {
const entry = editor.api.node<TElement>(at);
if (entry) {
setEntry(entry);
return;
}
}
const entries = editor.api.blocks({ mode: "lowest" });
entries.forEach((entry) => {
setEntry(entry);
});
});
};
export const getBlockType = (block: TElement) => {
if (block[KEYS.listType]) {
if (block[KEYS.listType] === KEYS.ol) {
return KEYS.ol;
}
if (block[KEYS.listType] === KEYS.listTodo) {
return KEYS.listTodo;
}
return KEYS.ul;
}
return block.type;
};

View file

@ -0,0 +1,25 @@
// ---------------------------------------------------------------------------
// MDX curly-brace escaping helper
// ---------------------------------------------------------------------------
// remarkMdx treats { } as JSX expression delimiters. Arbitrary markdown
// (e.g. AI-generated reports) can contain curly braces that are NOT valid JS
// expressions, which makes acorn throw "Could not parse expression".
// We escape unescaped { and } *outside* of fenced code blocks and inline code
// so remarkMdx treats them as literal characters while still parsing
// <mark>, <u>, <kbd>, etc. tags correctly.
// ---------------------------------------------------------------------------
const FENCED_OR_INLINE_CODE = /(```[\s\S]*?```|`[^`\n]+`)/g;
export function escapeMdxExpressions(md: string): string {
const parts = md.split(FENCED_OR_INLINE_CODE);
return parts
.map((part, i) => {
// Odd indices are code blocks / inline code leave untouched
if (i % 2 === 1) return part;
// Escape { and } that are NOT already escaped (no preceding \)
return part.replace(/(?<!\\)\{/g, "\\{").replace(/(?<!\\)\}/g, "\\}");
})
.join("");
}

View file

@ -40,10 +40,10 @@ export function useSidebarState(defaultCollapsed = false): UseSidebarStateReturn
setIsCollapsed(!isCollapsed);
}, [isCollapsed, setIsCollapsed]);
// Keyboard shortcut: Cmd/Ctrl + B
// Keyboard shortcut: Cmd/Ctrl + \
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "b" && (event.metaKey || event.ctrlKey)) {
if (event.key === "\\" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleCollapsed();
}

View file

@ -4,6 +4,7 @@ import { PanelLeft, PanelLeftClose } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
interface SidebarCollapseButtonProps {
isCollapsed: boolean;
@ -17,6 +18,7 @@ export function SidebarCollapseButton({
disableTooltip = false,
}: SidebarCollapseButtonProps) {
const t = useTranslations("sidebar");
const { shortcut } = usePlatformShortcut();
const button = (
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
@ -33,7 +35,9 @@ export function SidebarCollapseButton({
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
{isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`}
{isCollapsed
? `${t("expand_sidebar")} ${shortcut("Mod", "\\")}`
: `${t("collapse_sidebar")} ${shortcut("Mod", "\\")}`}
</TooltipContent>
</Tooltip>
);

View file

@ -3,11 +3,14 @@
import { useAtomValue, useSetAtom } from "jotai";
import { ChevronDownIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle } from "@/components/ui/drawer";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
@ -112,6 +115,14 @@ function ReportPanelContent({
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [exporting, setExporting] = useState<"pdf" | "docx" | "md" | null>(null);
const [saving, setSaving] = useState(false);
// Editor state — tracks the latest markdown from the Plate editor
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
// Read-only when public (shareToken) OR shared (SEARCH_SPACE visibility)
const currentThreadState = useAtomValue(currentThreadAtom);
const isReadOnly = !!shareToken || currentThreadState.visibility === "SEARCH_SPACE";
// Version state
const [activeReportId, setActiveReportId] = useState(reportId);
@ -165,17 +176,25 @@ function ReportPanelContent({
};
}, [activeReportId, shareToken]);
// Copy markdown content
// The current markdown: use edited version if available, otherwise original
const currentMarkdown = editedMarkdown ?? reportContent?.content ?? null;
// Reset edited markdown when switching versions or reports
useEffect(() => {
setEditedMarkdown(null);
}, [activeReportId]);
// Copy markdown content (uses latest editor content)
const handleCopy = useCallback(async () => {
if (!reportContent?.content) return;
if (!currentMarkdown) return;
try {
await navigator.clipboard.writeText(reportContent.content);
await navigator.clipboard.writeText(currentMarkdown);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}, [reportContent?.content]);
}, [currentMarkdown]);
// Export report
const handleExport = useCallback(
@ -188,9 +207,9 @@ function ReportPanelContent({
.slice(0, 80) || "report";
try {
if (format === "md") {
// Download markdown content directly as a .md file
if (!reportContent?.content) return;
const blob = new Blob([reportContent.content], {
// Download markdown content directly as a .md file (uses latest editor content)
if (!currentMarkdown) return;
const blob = new Blob([currentMarkdown], {
type: "text/markdown;charset=utf-8",
});
const url = URL.createObjectURL(blob);
@ -227,9 +246,40 @@ function ReportPanelContent({
setExporting(null);
}
},
[activeReportId, title, reportContent?.content]
[activeReportId, title, currentMarkdown]
);
// Save edited report content
const handleSave = useCallback(async () => {
if (!currentMarkdown || !activeReportId) return;
setSaving(true);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/content`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: currentMarkdown }),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Failed to save report" }));
throw new Error(errorData.detail || "Failed to save report");
}
// Update local state to reflect saved content
setReportContent((prev) => (prev ? { ...prev, content: currentMarkdown } : prev));
setEditedMarkdown(null);
toast.success("Report saved successfully");
} catch (err) {
console.error("Error saving report:", err);
toast.error(err instanceof Error ? err.message : "Failed to save report");
} finally {
setSaving(false);
}
}, [activeReportId, currentMarkdown]);
// Show full-page skeleton only on initial load (no data loaded yet).
// Once we have versions/content from a prior fetch, keep the action bar visible.
const hasLoadedBefore = versions.length > 0 || reportContent !== null;
@ -284,7 +334,7 @@ function ReportPanelContent({
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={`min-w-[180px]${insideDrawer ? " z-[100]" : ""}`}
className={`min-w-[180px] dark:bg-neutral-800 dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
>
<DropdownMenuItem onClick={() => handleExport("md")}>
Download Markdown
@ -370,8 +420,8 @@ function ReportPanelContent({
)}
</div>
{/* Report content — skeleton/error/content shown only in this area */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
{/* Report content — skeleton/error/viewer/editor shown only in this area */}
<div className="flex-1 overflow-hidden">
{isLoading ? (
<ReportPanelSkeleton />
) : error || !reportContent ? (
@ -381,13 +431,27 @@ function ReportPanelContent({
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
</div>
</div>
) : reportContent.content ? (
isReadOnly ? (
<div className="h-full overflow-y-auto px-5 py-4">
<MarkdownViewer content={reportContent.content} />
</div>
) : (
<PlateEditor
preset="full"
markdown={reportContent.content}
onMarkdownChange={setEditedMarkdown}
readOnly={false}
placeholder="Report content..."
editorVariant="default"
onSave={handleSave}
hasUnsavedChanges={editedMarkdown !== null}
isSaving={saving}
/>
)
) : (
<div className="px-5 py-5">
{reportContent.content ? (
<MarkdownViewer content={reportContent.content} />
) : (
<p className="text-muted-foreground italic">No content available.</p>
)}
<p className="text-muted-foreground italic">No content available.</p>
</div>
)}
</div>
@ -448,8 +512,12 @@ function MobileReportDrawer() {
}}
shouldScaleBackground={false}
>
<DrawerContent className="h-[90vh] max-h-[90vh] z-80" overlayClassName="z-80">
<DrawerContent
className="h-[90vh] max-h-[90vh] z-80 !rounded-none border-none"
overlayClassName="z-80"
>
<DrawerHandle />
<DrawerTitle className="sr-only">{panelState.title || "Report"}</DrawerTitle>
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<ReportPanelContent
reportId={panelState.reportId}

View file

@ -0,0 +1,467 @@
"use client";
import * as React from "react";
import { DndPlugin, useDraggable, useDropLine } from "@platejs/dnd";
import { expandListItemsWithChildren } from "@platejs/list";
import { BlockSelectionPlugin } from "@platejs/selection/react";
import { GripVertical } from "lucide-react";
import { type TElement, getPluginByType, isType, KEYS } from "platejs";
import {
type PlateEditor,
type PlateElementProps,
type RenderNodeWrapper,
MemoizedChildren,
useEditorRef,
useElement,
usePluginOption,
} from "platejs/react";
import { useSelected } from "platejs/react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
const UNDRAGGABLE_KEYS = [KEYS.column, KEYS.tr, KEYS.td];
export const BlockDraggable: RenderNodeWrapper = (props) => {
const { editor, element, path } = props;
const enabled = React.useMemo(() => {
if (editor.dom.readOnly) return false;
if (path.length === 1 && !isType(editor, element, UNDRAGGABLE_KEYS)) {
return true;
}
if (path.length === 3 && !isType(editor, element, UNDRAGGABLE_KEYS)) {
const block = editor.api.some({
at: path,
match: {
type: editor.getType(KEYS.column),
},
});
if (block) {
return true;
}
}
if (path.length === 4 && !isType(editor, element, UNDRAGGABLE_KEYS)) {
const block = editor.api.some({
at: path,
match: {
type: editor.getType(KEYS.table),
},
});
if (block) {
return true;
}
}
return false;
}, [editor, element, path]);
if (!enabled) return;
return (props) => <Draggable {...props} />;
};
function Draggable(props: PlateElementProps) {
const { children, editor, element, path } = props;
const blockSelectionApi = editor.getApi(BlockSelectionPlugin).blockSelection;
const { isAboutToDrag, isDragging, nodeRef, previewRef, handleRef } = useDraggable({
element,
onDropHandler: (_, { dragItem }) => {
const id = (dragItem as { id: string[] | string }).id;
if (blockSelectionApi) {
blockSelectionApi.add(id);
}
resetPreview();
},
});
const isInColumn = path.length === 3;
const isInTable = path.length === 4;
const [previewTop, setPreviewTop] = React.useState(0);
const resetPreview = () => {
if (previewRef.current) {
previewRef.current.replaceChildren();
previewRef.current?.classList.add("hidden");
}
};
// clear up virtual multiple preview when drag end
React.useEffect(() => {
if (!isDragging) {
resetPreview();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDragging]);
React.useEffect(() => {
if (isAboutToDrag) {
previewRef.current?.classList.remove("opacity-0");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAboutToDrag]);
const [dragButtonTop, setDragButtonTop] = React.useState(0);
return (
<div
className={cn(
"relative",
isDragging && "opacity-50",
getPluginByType(editor, element.type)?.node.isContainer ? "group/container" : "group"
)}
onMouseEnter={() => {
if (isDragging) return;
setDragButtonTop(calcDragButtonTop(editor, element));
}}
>
{!isInTable && (
<Gutter>
<div className={cn("slate-blockToolbarWrapper", "flex h-[1.5em]", isInColumn && "h-4")}>
<div
className={cn(
"slate-blockToolbar relative w-4.5",
"pointer-events-auto mr-1 flex items-center",
isInColumn && "mr-1.5"
)}
>
<Button
ref={handleRef}
variant="ghost"
className="-left-0 absolute h-6 w-full p-0"
style={{ top: `${dragButtonTop + 3}px` }}
data-plate-prevent-deselect
>
<DragHandle
isDragging={isDragging}
previewRef={previewRef}
resetPreview={resetPreview}
setPreviewTop={setPreviewTop}
/>
</Button>
</div>
</div>
</Gutter>
)}
<div
ref={previewRef}
className={cn("-left-0 absolute hidden w-full")}
style={{ top: `${-previewTop}px` }}
contentEditable={false}
/>
<div
ref={nodeRef}
className="slate-blockWrapper flow-root"
onContextMenu={(event) =>
editor.getApi(BlockSelectionPlugin).blockSelection.addOnContextMenu({ element, event })
}
>
<MemoizedChildren>{children}</MemoizedChildren>
<DropLine />
</div>
</div>
);
}
function Gutter({ children, className, ...props }: React.ComponentProps<"div">) {
const editor = useEditorRef();
const element = useElement();
const isSelectionAreaVisible = usePluginOption(BlockSelectionPlugin, "isSelectionAreaVisible");
const selected = useSelected();
return (
<div
{...props}
className={cn(
"slate-gutterLeft",
"-translate-x-full absolute top-0 z-50 flex h-full cursor-text hover:opacity-100 sm:opacity-0",
getPluginByType(editor, element.type)?.node.isContainer
? "group-hover/container:opacity-100"
: "group-hover:opacity-100",
isSelectionAreaVisible && "hidden",
!selected && "opacity-0",
className
)}
contentEditable={false}
>
{children}
</div>
);
}
const DragHandle = React.memo(function DragHandle({
isDragging,
previewRef,
resetPreview,
setPreviewTop,
}: {
isDragging: boolean;
previewRef: React.RefObject<HTMLDivElement | null>;
resetPreview: () => void;
setPreviewTop: (top: number) => void;
}) {
const editor = useEditorRef();
const element = useElement();
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex size-full items-center justify-center"
onClick={(e) => {
e.preventDefault();
editor.getApi(BlockSelectionPlugin).blockSelection.focus();
}}
onMouseDown={(e) => {
resetPreview();
if ((e.button !== 0 && e.button !== 2) || e.shiftKey) return;
const blockSelection = editor
.getApi(BlockSelectionPlugin)
.blockSelection.getNodes({ sort: true });
let selectionNodes =
blockSelection.length > 0 ? blockSelection : editor.api.blocks({ mode: "highest" });
// If current block is not in selection, use it as the starting point
if (!selectionNodes.some(([node]) => node.id === element.id)) {
selectionNodes = [[element, editor.api.findPath(element)!]];
}
// Process selection nodes to include list children
const blocks = expandListItemsWithChildren(editor, selectionNodes).map(
([node]) => node
);
if (blockSelection.length === 0) {
editor.tf.blur();
editor.tf.collapse();
}
const elements = createDragPreviewElements(editor, blocks);
previewRef.current?.append(...elements);
previewRef.current?.classList.remove("hidden");
previewRef.current?.classList.add("opacity-0");
editor.setOption(DndPlugin, "multiplePreviewRef", previewRef);
editor
.getApi(BlockSelectionPlugin)
.blockSelection.set(blocks.map((block) => block.id as string));
}}
onMouseEnter={() => {
if (isDragging) return;
const blockSelection = editor
.getApi(BlockSelectionPlugin)
.blockSelection.getNodes({ sort: true });
let selectedBlocks =
blockSelection.length > 0 ? blockSelection : editor.api.blocks({ mode: "highest" });
// If current block is not in selection, use it as the starting point
if (!selectedBlocks.some(([node]) => node.id === element.id)) {
selectedBlocks = [[element, editor.api.findPath(element)!]];
}
// Process selection to include list children
const processedBlocks = expandListItemsWithChildren(editor, selectedBlocks);
const ids = processedBlocks.map((block) => block[0].id as string);
if (ids.length > 1 && ids.includes(element.id as string)) {
const previewTop = calculatePreviewTop(editor, {
blocks: processedBlocks.map((block) => block[0]),
element,
});
setPreviewTop(previewTop);
} else {
setPreviewTop(0);
}
}}
onMouseUp={() => {
resetPreview();
}}
data-plate-prevent-deselect
role="button"
>
<GripVertical className="text-muted-foreground" />
</div>
</TooltipTrigger>
</Tooltip>
);
});
const DropLine = React.memo(function DropLine({
className,
...props
}: React.ComponentProps<"div">) {
const { dropLine } = useDropLine();
if (!dropLine) return null;
return (
<div
{...props}
className={cn(
"slate-dropLine",
"absolute inset-x-0 h-0.5 opacity-100 transition-opacity",
"bg-brand/50",
dropLine === "top" && "-top-px",
dropLine === "bottom" && "-bottom-px",
className
)}
/>
);
});
const createDragPreviewElements = (editor: PlateEditor, blocks: TElement[]): HTMLElement[] => {
const elements: HTMLElement[] = [];
const ids: string[] = [];
/**
* Remove data attributes from the element to avoid recognized as slate
* elements incorrectly.
*/
const removeDataAttributes = (element: HTMLElement) => {
Array.from(element.attributes).forEach((attr) => {
if (attr.name.startsWith("data-slate") || attr.name.startsWith("data-block-id")) {
element.removeAttribute(attr.name);
}
});
Array.from(element.children).forEach((child) => {
removeDataAttributes(child as HTMLElement);
});
};
const resolveElement = (node: TElement, index: number) => {
const domNode = editor.api.toDOMNode(node)!;
const newDomNode = domNode.cloneNode(true) as HTMLElement;
// Apply visual compensation for horizontal scroll
const applyScrollCompensation = (original: Element, cloned: HTMLElement) => {
const scrollLeft = original.scrollLeft;
if (scrollLeft > 0) {
// Create a wrapper to handle the scroll offset
const scrollWrapper = document.createElement("div");
scrollWrapper.style.overflow = "hidden";
scrollWrapper.style.width = `${original.clientWidth}px`;
// Create inner container with the full content
const innerContainer = document.createElement("div");
innerContainer.style.transform = `translateX(-${scrollLeft}px)`;
innerContainer.style.width = `${original.scrollWidth}px`;
// Move all children to the inner container
while (cloned.firstChild) {
innerContainer.append(cloned.firstChild);
}
// Apply the original element's styles to maintain appearance
const originalStyles = window.getComputedStyle(original);
cloned.style.padding = "0";
innerContainer.style.padding = originalStyles.padding;
scrollWrapper.append(innerContainer);
cloned.append(scrollWrapper);
}
};
applyScrollCompensation(domNode, newDomNode);
ids.push(node.id as string);
const wrapper = document.createElement("div");
wrapper.append(newDomNode);
wrapper.style.display = "flow-root";
const lastDomNode = blocks[index - 1];
if (lastDomNode) {
const lastDomNodeRect = editor.api
.toDOMNode(lastDomNode)!
.parentElement!.getBoundingClientRect();
const domNodeRect = domNode.parentElement!.getBoundingClientRect();
const distance = domNodeRect.top - lastDomNodeRect.bottom;
// Check if the two elements are adjacent (touching each other)
if (distance > 15) {
wrapper.style.marginTop = `${distance}px`;
}
}
removeDataAttributes(newDomNode);
elements.push(wrapper);
};
blocks.forEach((node, index) => {
resolveElement(node, index);
});
editor.setOption(DndPlugin, "draggingId", ids);
return elements;
};
const calculatePreviewTop = (
editor: PlateEditor,
{
blocks,
element,
}: {
blocks: TElement[];
element: TElement;
}
): number => {
const child = editor.api.toDOMNode(element)!;
const editable = editor.api.toDOMNode(editor)!;
const firstSelectedChild = blocks[0];
const firstDomNode = editor.api.toDOMNode(firstSelectedChild)!;
// Get editor's top padding
const editorPaddingTop = Number(window.getComputedStyle(editable).paddingTop.replace("px", ""));
// Calculate distance from first selected node to editor top
const firstNodeToEditorDistance =
firstDomNode.getBoundingClientRect().top -
editable.getBoundingClientRect().top -
editorPaddingTop;
// Get margin top of first selected node
const firstMarginTopString = window.getComputedStyle(firstDomNode).marginTop;
const marginTop = Number(firstMarginTopString.replace("px", ""));
// Calculate distance from current node to editor top
const currentToEditorDistance =
child.getBoundingClientRect().top - editable.getBoundingClientRect().top - editorPaddingTop;
const currentMarginTopString = window.getComputedStyle(child).marginTop;
const currentMarginTop = Number(currentMarginTopString.replace("px", ""));
const previewElementsTopDistance =
currentToEditorDistance - firstNodeToEditorDistance + marginTop - currentMarginTop;
return previewElementsTopDistance;
};
const calcDragButtonTop = (editor: PlateEditor, element: TElement): number => {
const child = editor.api.toDOMNode(element)!;
const currentMarginTopString = window.getComputedStyle(child).marginTop;
const currentMarginTop = Number(currentMarginTopString.replace("px", ""));
return currentMarginTop;
};

View file

@ -0,0 +1,72 @@
"use client";
import React from "react";
import type { TListElement } from "platejs";
import { isOrderedList } from "@platejs/list";
import { useTodoListElement, useTodoListElementState } from "@platejs/list/react";
import { type PlateElementProps, type RenderNodeWrapper, useReadOnly } from "platejs/react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
const config: Record<
string,
{
Li: React.FC<PlateElementProps>;
Marker: React.FC<PlateElementProps>;
}
> = {
todo: {
Li: TodoLi,
Marker: TodoMarker,
},
};
export const BlockList: RenderNodeWrapper = (props) => {
if (!props.element.listStyleType) return;
return (props) => <List {...props} />;
};
function List(props: PlateElementProps) {
const { listStart, listStyleType } = props.element as TListElement;
const { Li, Marker } = config[listStyleType] ?? {};
const List = isOrderedList(props.element) ? "ol" : "ul";
return (
<List className="relative m-0 p-0" style={{ listStyleType }} start={listStart}>
{Marker && <Marker {...props} />}
{Li ? <Li {...props} /> : <li>{props.children}</li>}
</List>
);
}
function TodoMarker(props: PlateElementProps) {
const state = useTodoListElementState({ element: props.element });
const { checkboxProps } = useTodoListElement(state);
const readOnly = useReadOnly();
return (
<div contentEditable={false}>
<Checkbox
className={cn("-left-6 absolute top-1", readOnly && "pointer-events-none")}
{...checkboxProps}
/>
</div>
);
}
function TodoLi(props: PlateElementProps) {
return (
<li
className={cn(
"list-none",
(props.element.checked as boolean) && "text-muted-foreground line-through"
)}
>
{props.children}
</li>
);
}

View file

@ -0,0 +1,39 @@
"use client";
import * as React from "react";
import { DndPlugin } from "@platejs/dnd";
import { useBlockSelected } from "@platejs/selection/react";
import { cva } from "class-variance-authority";
import { type PlateElementProps, usePluginOption } from "platejs/react";
export const blockSelectionVariants = cva(
"pointer-events-none absolute inset-0 z-1 bg-brand/[.13] transition-opacity",
{
defaultVariants: {
active: true,
},
variants: {
active: {
false: "opacity-0",
true: "opacity-100",
},
},
}
);
export function BlockSelection(props: PlateElementProps) {
const isBlockSelected = useBlockSelected();
const isDragging = usePluginOption(DndPlugin, "isDragging");
if (!isBlockSelected || props.plugin.key === "tr" || props.plugin.key === "table") return null;
return (
<div
className={blockSelectionVariants({
active: isBlockSelected && !isDragging,
})}
data-slot="block-selection"
/>
);
}

View file

@ -0,0 +1,7 @@
"use client";
import { type PlateElementProps, PlateElement } from "platejs/react";
export function BlockquoteElement(props: PlateElementProps) {
return <PlateElement as="blockquote" className="my-1 border-l-2 pl-6 italic" {...props} />;
}

View file

@ -0,0 +1,77 @@
"use client";
import * as React from "react";
import type { TCalloutElement } from "platejs";
import { CalloutPlugin } from "@platejs/callout/react";
import { cva } from "class-variance-authority";
import { type PlateElementProps, PlateElement, useEditorPlugin } from "platejs/react";
import { cn } from "@/lib/utils";
const calloutVariants = cva("my-1 flex w-full items-start gap-2 rounded-lg border p-4", {
defaultVariants: {
variant: "info",
},
variants: {
variant: {
info: "border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/50",
warning: "border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-950/50",
error: "border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/50",
success: "border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/50",
note: "border-muted bg-muted/50",
tip: "border-purple-200 bg-purple-50 dark:border-purple-800 dark:bg-purple-950/50",
},
},
});
const calloutIcons: Record<string, string> = {
info: "💡",
warning: "⚠️",
error: "🚨",
success: "✅",
note: "📝",
tip: "💜",
};
const variantCycle = ["info", "warning", "error", "success", "note", "tip"] as const;
export function CalloutElement({ children, ...props }: PlateElementProps<TCalloutElement>) {
const { editor } = useEditorPlugin(CalloutPlugin);
const element = props.element;
const variant = element.variant || "info";
const icon = element.icon || calloutIcons[variant] || "💡";
const cycleVariant = React.useCallback(() => {
const currentIndex = variantCycle.indexOf(variant as (typeof variantCycle)[number]);
const nextIndex = (currentIndex + 1) % variantCycle.length;
const nextVariant = variantCycle[nextIndex];
editor.tf.setNodes(
{
variant: nextVariant,
icon: calloutIcons[nextVariant],
},
{ at: props.path }
);
}, [editor, variant, props.path]);
return (
<PlateElement
{...props}
className={cn(calloutVariants({ variant: variant as any }), props.className)}
>
<button
className="mt-0.5 shrink-0 cursor-pointer select-none text-lg leading-none"
contentEditable={false}
onClick={cycleVariant}
type="button"
aria-label="Change callout type"
>
{icon}
</button>
<div className="min-w-0 flex-1">{children}</div>
</PlateElement>
);
}

View file

@ -1,8 +1,8 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon, MinusIcon } from "lucide-react";
import type * as React from "react";
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
@ -11,17 +11,16 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary data-[state=indeterminate]:bg-transparent data-[state=indeterminate]:text-foreground data-[state=indeterminate]:border-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="group flex items-center justify-center text-current transition-none"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5 hidden group-data-[state=checked]:block" />
<MinusIcon className="size-3.5 hidden group-data-[state=indeterminate]:block" />
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);

View file

@ -0,0 +1,264 @@
"use client";
import * as React from "react";
import { formatCodeBlock, isLangSupported } from "@platejs/code-block";
import { BracesIcon, Check, CheckIcon, CopyIcon } from "lucide-react";
import { type TCodeBlockElement, type TCodeSyntaxLeaf, NodeApi } from "platejs";
import {
type PlateElementProps,
type PlateLeafProps,
PlateElement,
PlateLeaf,
} from "platejs/react";
import { useEditorRef, useElement, useReadOnly } from "platejs/react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export function CodeBlockElement(props: PlateElementProps<TCodeBlockElement>) {
const { editor, element } = props;
return (
<PlateElement
className="py-1 **:[.hljs-addition]:bg-[#f0fff4] **:[.hljs-addition]:text-[#22863a] dark:**:[.hljs-addition]:bg-[#3c5743] dark:**:[.hljs-addition]:text-[#ceead5] **:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#005cc5] dark:**:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#6596cf] **:[.hljs-built\\\\_in,.hljs-symbol]:text-[#e36209] dark:**:[.hljs-built\\\\_in,.hljs-symbol]:text-[#c3854e] **:[.hljs-bullet]:text-[#735c0f] **:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] dark:**:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] **:[.hljs-deletion]:bg-[#ffeef0] **:[.hljs-deletion]:text-[#b31d28] dark:**:[.hljs-deletion]:bg-[#473235] dark:**:[.hljs-deletion]:text-[#e7c7cb] **:[.hljs-emphasis]:italic **:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#d73a49] dark:**:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#ee6960] **:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#22863a] dark:**:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#36a84f] **:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#032f62] dark:**:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#3593ff] **:[.hljs-section]:font-bold **:[.hljs-section]:text-[#005cc5] dark:**:[.hljs-section]:text-[#61a5f2] **:[.hljs-strong]:font-bold **:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#6f42c1] dark:**:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#a77bfa]"
{...props}
>
<div className="relative rounded-md bg-muted/50">
<pre className="overflow-x-auto p-8 pr-4 font-mono text-sm leading-[normal] [tab-size:2] print:break-inside-avoid">
<code>{props.children}</code>
</pre>
<div
className="absolute top-1 right-1 z-10 flex select-none gap-0.5"
contentEditable={false}
>
{isLangSupported(element.lang) && (
<Button
size="icon"
variant="ghost"
className="size-6 text-xs"
onClick={() => formatCodeBlock(editor, { element })}
title="Format code"
>
<BracesIcon className="!size-3.5 text-muted-foreground" />
</Button>
)}
<CodeBlockCombobox />
<CopyButton
size="icon"
variant="ghost"
className="size-6 gap-1 text-muted-foreground text-xs"
value={() => NodeApi.string(element)}
/>
</div>
</div>
</PlateElement>
);
}
function CodeBlockCombobox() {
const [open, setOpen] = React.useState(false);
const readOnly = useReadOnly();
const editor = useEditorRef();
const element = useElement<TCodeBlockElement>();
const value = element.lang || "plaintext";
const [searchValue, setSearchValue] = React.useState("");
const items = React.useMemo(
() =>
languages.filter(
(language) =>
!searchValue || language.label.toLowerCase().includes(searchValue.toLowerCase())
),
[searchValue]
);
if (readOnly) return null;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 select-none justify-between gap-1 px-2 text-muted-foreground text-xs"
aria-expanded={open}
role="combobox"
>
{languages.find((language) => language.value === value)?.label ?? "Plain Text"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" onCloseAutoFocus={() => setSearchValue("")}>
<Command shouldFilter={false}>
<CommandInput
className="h-9"
value={searchValue}
onValueChange={(value) => setSearchValue(value)}
placeholder="Search language..."
/>
<CommandEmpty>No language found.</CommandEmpty>
<CommandList className="h-[344px] overflow-y-auto">
<CommandGroup>
{items.map((language) => (
<CommandItem
key={language.label}
className="cursor-pointer"
value={language.value}
onSelect={(value) => {
editor.tf.setNodes<TCodeBlockElement>({ lang: value }, { at: element });
setSearchValue(value);
setOpen(false);
}}
>
<Check className={cn(value === language.value ? "opacity-100" : "opacity-0")} />
{language.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function CopyButton({
value,
...props
}: { value: (() => string) | string } & Omit<React.ComponentProps<typeof Button>, "value">) {
const [hasCopied, setHasCopied] = React.useState(false);
React.useEffect(() => {
setTimeout(() => {
setHasCopied(false);
}, 2000);
}, [hasCopied]);
return (
<Button
onClick={() => {
void navigator.clipboard.writeText(typeof value === "function" ? value() : value);
setHasCopied(true);
}}
{...props}
>
<span className="sr-only">Copy</span>
{hasCopied ? <CheckIcon className="!size-3" /> : <CopyIcon className="!size-3" />}
</Button>
);
}
export function CodeLineElement(props: PlateElementProps) {
return <PlateElement {...props} />;
}
export function CodeSyntaxLeaf(props: PlateLeafProps<TCodeSyntaxLeaf>) {
const tokenClassName = props.leaf.className as string;
return <PlateLeaf className={tokenClassName} {...props} />;
}
const languages: { label: string; value: string }[] = [
{ label: "Auto", value: "auto" },
{ label: "Plain Text", value: "plaintext" },
{ label: "ABAP", value: "abap" },
{ label: "Agda", value: "agda" },
{ label: "Arduino", value: "arduino" },
{ label: "ASCII Art", value: "ascii" },
{ label: "Assembly", value: "x86asm" },
{ label: "Bash", value: "bash" },
{ label: "BASIC", value: "basic" },
{ label: "BNF", value: "bnf" },
{ label: "C", value: "c" },
{ label: "C#", value: "csharp" },
{ label: "C++", value: "cpp" },
{ label: "Clojure", value: "clojure" },
{ label: "CoffeeScript", value: "coffeescript" },
{ label: "Coq", value: "coq" },
{ label: "CSS", value: "css" },
{ label: "Dart", value: "dart" },
{ label: "Dhall", value: "dhall" },
{ label: "Diff", value: "diff" },
{ label: "Docker", value: "dockerfile" },
{ label: "EBNF", value: "ebnf" },
{ label: "Elixir", value: "elixir" },
{ label: "Elm", value: "elm" },
{ label: "Erlang", value: "erlang" },
{ label: "F#", value: "fsharp" },
{ label: "Flow", value: "flow" },
{ label: "Fortran", value: "fortran" },
{ label: "Gherkin", value: "gherkin" },
{ label: "GLSL", value: "glsl" },
{ label: "Go", value: "go" },
{ label: "GraphQL", value: "graphql" },
{ label: "Groovy", value: "groovy" },
{ label: "Haskell", value: "haskell" },
{ label: "HCL", value: "hcl" },
{ label: "HTML", value: "html" },
{ label: "Idris", value: "idris" },
{ label: "Java", value: "java" },
{ label: "JavaScript", value: "javascript" },
{ label: "JSON", value: "json" },
{ label: "Julia", value: "julia" },
{ label: "Kotlin", value: "kotlin" },
{ label: "LaTeX", value: "latex" },
{ label: "Less", value: "less" },
{ label: "Lisp", value: "lisp" },
{ label: "LiveScript", value: "livescript" },
{ label: "LLVM IR", value: "llvm" },
{ label: "Lua", value: "lua" },
{ label: "Makefile", value: "makefile" },
{ label: "Markdown", value: "markdown" },
{ label: "Markup", value: "markup" },
{ label: "MATLAB", value: "matlab" },
{ label: "Mathematica", value: "mathematica" },
{ label: "Mermaid", value: "mermaid" },
{ label: "Nix", value: "nix" },
{ label: "Notion Formula", value: "notion" },
{ label: "Objective-C", value: "objectivec" },
{ label: "OCaml", value: "ocaml" },
{ label: "Pascal", value: "pascal" },
{ label: "Perl", value: "perl" },
{ label: "PHP", value: "php" },
{ label: "PowerShell", value: "powershell" },
{ label: "Prolog", value: "prolog" },
{ label: "Protocol Buffers", value: "protobuf" },
{ label: "PureScript", value: "purescript" },
{ label: "Python", value: "python" },
{ label: "R", value: "r" },
{ label: "Racket", value: "racket" },
{ label: "Reason", value: "reasonml" },
{ label: "Ruby", value: "ruby" },
{ label: "Rust", value: "rust" },
{ label: "Sass", value: "scss" },
{ label: "Scala", value: "scala" },
{ label: "Scheme", value: "scheme" },
{ label: "SCSS", value: "scss" },
{ label: "Shell", value: "shell" },
{ label: "Smalltalk", value: "smalltalk" },
{ label: "Solidity", value: "solidity" },
{ label: "SQL", value: "sql" },
{ label: "Swift", value: "swift" },
{ label: "TOML", value: "toml" },
{ label: "TypeScript", value: "typescript" },
{ label: "VB.Net", value: "vbnet" },
{ label: "Verilog", value: "verilog" },
{ label: "VHDL", value: "vhdl" },
{ label: "Visual Basic", value: "vbnet" },
{ label: "WebAssembly", value: "wasm" },
{ label: "XML", value: "xml" },
{ label: "YAML", value: "yaml" },
];

View file

@ -0,0 +1,19 @@
"use client";
import * as React from "react";
import type { PlateLeafProps } from "platejs/react";
import { PlateLeaf } from "platejs/react";
export function CodeLeaf(props: PlateLeafProps) {
return (
<PlateLeaf
{...props}
as="code"
className="whitespace-pre-wrap rounded-md bg-muted px-[0.3em] py-[0.2em] font-mono text-sm"
>
{props.children}
</PlateLeaf>
);
}

View file

@ -1,8 +1,8 @@
"use client";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
@ -33,7 +33,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
@ -61,7 +61,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 ",
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@ -79,7 +79,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
@ -110,7 +110,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@ -182,13 +182,13 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground dark:data-[state=open]:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4 text-muted-foreground" />
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
@ -201,7 +201,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}

View file

@ -0,0 +1,124 @@
"use client";
import * as React from "react";
import type { VariantProps } from "class-variance-authority";
import type { PlateContentProps, PlateViewProps } from "platejs/react";
import { cva } from "class-variance-authority";
import { PlateContainer, PlateContent, PlateView } from "platejs/react";
import { cn } from "@/lib/utils";
const editorContainerVariants = cva(
"relative w-full cursor-text select-text overflow-y-auto caret-primary selection:bg-brand/25 focus-visible:outline-none [&_.slate-selection-area]:z-50 [&_.slate-selection-area]:border [&_.slate-selection-area]:border-brand/25 [&_.slate-selection-area]:bg-brand/15",
{
defaultVariants: {
variant: "default",
},
variants: {
variant: {
comment: cn(
"flex flex-wrap justify-between gap-1 px-1 py-0.5 text-sm",
"rounded-md border-[1.5px] border-transparent bg-transparent",
"has-[[data-slate-editor]:focus]:border-brand/50 has-[[data-slate-editor]:focus]:ring-2 has-[[data-slate-editor]:focus]:ring-brand/30",
"has-aria-disabled:border-input has-aria-disabled:bg-muted"
),
default: "h-full",
demo: "h-[650px]",
select: cn(
"group rounded-md border border-input ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
"has-data-readonly:w-fit has-data-readonly:cursor-default has-data-readonly:border-transparent has-data-readonly:focus-within:[box-shadow:none]"
),
},
},
}
);
export function EditorContainer({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof editorContainerVariants>) {
return (
<PlateContainer
className={cn(
"ignore-click-outside/toolbar",
editorContainerVariants({ variant }),
className
)}
{...props}
/>
);
}
const editorVariants = cva(
cn(
"group/editor",
"relative w-full cursor-text select-text overflow-x-hidden whitespace-pre-wrap break-words",
"rounded-md ring-offset-background focus-visible:outline-none",
"**:data-slate-placeholder:!top-1/2 **:data-slate-placeholder:-translate-y-1/2 placeholder:text-muted-foreground/80 **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!",
"[&_strong]:font-bold"
),
{
defaultVariants: {
variant: "default",
},
variants: {
disabled: {
true: "cursor-not-allowed opacity-50",
},
focused: {
true: "ring-2 ring-ring ring-offset-2",
},
variant: {
ai: "w-full px-0 text-base md:text-sm",
aiChat: "max-h-[min(70vh,320px)] w-full overflow-y-auto px-3 py-2 text-base md:text-sm",
comment: cn("rounded-none border-none bg-transparent text-sm"),
default: "size-full px-6 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]",
demo: "size-full px-6 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]",
fullWidth: "size-full px-6 pt-4 pb-72 text-base sm:px-24",
none: "",
select: "px-3 py-2 text-base data-readonly:w-fit",
},
},
}
);
export type EditorProps = PlateContentProps & VariantProps<typeof editorVariants>;
export const Editor = ({
className,
disabled,
focused,
variant,
ref,
...props
}: EditorProps & { ref?: React.RefObject<HTMLDivElement | null> }) => (
<PlateContent
ref={ref}
className={cn(
editorVariants({
disabled,
focused,
variant,
}),
className
)}
disabled={disabled}
disableDefaultStyles
{...props}
/>
);
Editor.displayName = "Editor";
export function EditorView({
className,
variant,
...props
}: PlateViewProps & VariantProps<typeof editorVariants>) {
return <PlateView {...props} className={cn(editorVariants({ variant }), className)} />;
}
EditorView.displayName = "EditorView";

View file

@ -0,0 +1,176 @@
"use client";
import * as React from "react";
import type { TEquationElement } from "platejs";
import { useEquationElement, useEquationInput } from "@platejs/math/react";
import { RadicalIcon } from "lucide-react";
import { type PlateElementProps, PlateElement, useSelected } from "platejs/react";
import { cn } from "@/lib/utils";
export function EquationElement({ children, ...props }: PlateElementProps<TEquationElement>) {
const element = props.element;
const selected = useSelected();
const katexRef = React.useRef<HTMLDivElement | null>(null);
const [isEditing, setIsEditing] = React.useState(false);
useEquationElement({
element,
katexRef,
options: {
displayMode: true,
throwOnError: false,
},
});
const {
props: inputProps,
ref: inputRef,
onDismiss,
onSubmit,
} = useEquationInput({
isInline: false,
open: isEditing,
onClose: () => setIsEditing(false),
});
return (
<PlateElement
{...props}
className={cn(
"my-3 rounded-md py-2",
selected && "ring-2 ring-ring ring-offset-2",
props.className
)}
>
<div
className="flex cursor-pointer items-center justify-center"
contentEditable={false}
onDoubleClick={() => setIsEditing(true)}
>
{element.texExpression ? (
<div ref={katexRef} className="text-center" />
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RadicalIcon className="size-4" />
<span>Add an equation</span>
</div>
)}
</div>
{isEditing && (
<div className="mt-2 rounded-md border bg-muted/50 p-2" contentEditable={false}>
<textarea
ref={inputRef}
className="w-full resize-none rounded border-none bg-transparent p-2 font-mono text-sm outline-none"
placeholder="E = mc^2"
rows={3}
{...inputProps}
/>
<div className="mt-1 flex justify-end gap-1">
<button
className="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
onClick={onDismiss}
type="button"
>
Cancel
</button>
<button
className="rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:bg-primary/90"
onClick={onSubmit}
type="button"
>
Done
</button>
</div>
</div>
)}
{children}
</PlateElement>
);
}
export function InlineEquationElement({ children, ...props }: PlateElementProps<TEquationElement>) {
const element = props.element;
const selected = useSelected();
const katexRef = React.useRef<HTMLDivElement | null>(null);
const [isEditing, setIsEditing] = React.useState(false);
useEquationElement({
element,
katexRef,
options: {
displayMode: false,
throwOnError: false,
},
});
const {
props: inputProps,
ref: inputRef,
onDismiss,
onSubmit,
} = useEquationInput({
isInline: true,
open: isEditing,
onClose: () => setIsEditing(false),
});
return (
<PlateElement
{...props}
as="span"
className={cn("inline rounded-sm px-0.5", selected && "bg-brand/15", props.className)}
>
<span
className="cursor-pointer"
contentEditable={false}
onDoubleClick={() => setIsEditing(true)}
>
{element.texExpression ? (
<span ref={katexRef} />
) : (
<span className="text-sm text-muted-foreground">
<RadicalIcon className="inline size-3.5" />
</span>
)}
</span>
{isEditing && (
<span
className="absolute z-50 mt-1 rounded-md border bg-popover p-2 shadow-md"
contentEditable={false}
>
<textarea
ref={inputRef}
className="w-48 resize-none rounded border-none bg-transparent p-1 font-mono text-sm outline-none"
placeholder="x^2"
rows={1}
{...inputProps}
/>
<span className="mt-1 flex justify-end gap-1">
<button
className="rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent"
onClick={onDismiss}
type="button"
>
Cancel
</button>
<button
className="rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground hover:bg-primary/90"
onClick={onSubmit}
type="button"
>
Done
</button>
</span>
</span>
)}
{children}
</PlateElement>
);
}

View file

@ -0,0 +1,136 @@
"use client";
import * as React from "react";
import {
BoldIcon,
Code2Icon,
HighlighterIcon,
ItalicIcon,
RedoIcon,
SaveIcon,
StrikethroughIcon,
UnderlineIcon,
UndoIcon,
} from "lucide-react";
import { KEYS } from "platejs";
import { useEditorReadOnly, useEditorRef } from "platejs/react";
import { useEditorSave } from "@/components/editor/editor-save-context";
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
import { Spinner } from "@/components/ui/spinner";
import { InsertToolbarButton } from "./insert-toolbar-button";
import { LinkToolbarButton } from "./link-toolbar-button";
import { MarkToolbarButton } from "./mark-toolbar-button";
import { ModeToolbarButton } from "./mode-toolbar-button";
import { ToolbarButton, ToolbarGroup } from "./toolbar";
import { TurnIntoToolbarButton } from "./turn-into-toolbar-button";
export function FixedToolbarButtons() {
const readOnly = useEditorReadOnly();
const editor = useEditorRef();
const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave();
const { shortcut } = usePlatformShortcut();
return (
<div className="flex w-full items-center">
{/* Scrollable editing buttons */}
<div className="flex flex-1 min-w-0 overflow-x-auto scrollbar-hide">
{!readOnly && (
<>
<ToolbarGroup>
<ToolbarButton
tooltip={`Undo ${shortcut("Mod", "Z")}`}
onClick={() => {
editor.undo();
editor.tf.focus();
}}
>
<UndoIcon />
</ToolbarButton>
<ToolbarButton
tooltip={`Redo ${shortcut("Mod", "Shift", "Z")}`}
onClick={() => {
editor.redo();
editor.tf.focus();
}}
>
<RedoIcon />
</ToolbarButton>
</ToolbarGroup>
<ToolbarGroup>
<InsertToolbarButton />
<TurnIntoToolbarButton />
</ToolbarGroup>
<ToolbarGroup>
<MarkToolbarButton nodeType={KEYS.bold} tooltip={`Bold ${shortcut("Mod", "B")}`}>
<BoldIcon />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.italic} tooltip={`Italic ${shortcut("Mod", "I")}`}>
<ItalicIcon />
</MarkToolbarButton>
<MarkToolbarButton
nodeType={KEYS.underline}
tooltip={`Underline ${shortcut("Mod", "U")}`}
>
<UnderlineIcon />
</MarkToolbarButton>
<MarkToolbarButton
nodeType={KEYS.strikethrough}
tooltip={`Strikethrough ${shortcut("Mod", "Shift", "X")}`}
>
<StrikethroughIcon />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.code} tooltip={`Code ${shortcut("Mod", "E")}`}>
<Code2Icon />
</MarkToolbarButton>
<MarkToolbarButton
nodeType={KEYS.highlight}
tooltip={`Highlight ${shortcut("Mod", "Shift", "H")}`}
>
<HighlighterIcon />
</MarkToolbarButton>
</ToolbarGroup>
<ToolbarGroup>
<LinkToolbarButton />
</ToolbarGroup>
</>
)}
</div>
{/* Fixed right-side buttons (Save + Mode) */}
<div className="flex shrink-0 items-center">
{/* Save button — only in edit mode with unsaved changes */}
{!readOnly && onSave && hasUnsavedChanges && (
<ToolbarGroup>
<ToolbarButton
tooltip={isSaving ? "Saving..." : `Save ${shortcut("Mod", "S")}`}
onClick={onSave}
disabled={isSaving}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{isSaving ? <Spinner size="xs" /> : <SaveIcon />}
</ToolbarButton>
</ToolbarGroup>
)}
{/* Mode toggle */}
{canToggleMode && (
<ToolbarGroup>
<ModeToolbarButton />
</ToolbarGroup>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,25 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Toolbar } from "./toolbar";
export function FixedToolbar({
children,
className,
...props
}: React.ComponentProps<typeof Toolbar>) {
return (
<Toolbar
className={cn(
"scrollbar-hide sticky top-0 left-0 z-50 w-full justify-between overflow-x-auto rounded-t-lg border-b bg-background/95 p-1 backdrop-blur supports-backdrop-filter:bg-background/60",
className
)}
{...props}
>
{children}
</Toolbar>
);
}

View file

@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import { BoldIcon, Code2Icon, ItalicIcon, StrikethroughIcon, UnderlineIcon } from "lucide-react";
import { KEYS } from "platejs";
import { useEditorReadOnly } from "platejs/react";
import { LinkToolbarButton } from "./link-toolbar-button";
import { MarkToolbarButton } from "./mark-toolbar-button";
import { ToolbarGroup } from "./toolbar";
import { TurnIntoToolbarButton } from "./turn-into-toolbar-button";
export function FloatingToolbarButtons() {
const readOnly = useEditorReadOnly();
if (readOnly) return null;
return (
<>
<ToolbarGroup>
<TurnIntoToolbarButton tooltip={false} />
<MarkToolbarButton nodeType={KEYS.bold}>
<BoldIcon />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.italic}>
<ItalicIcon />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.underline}>
<UnderlineIcon />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.strikethrough}>
<StrikethroughIcon />
</MarkToolbarButton>
<MarkToolbarButton nodeType={KEYS.code}>
<Code2Icon />
</MarkToolbarButton>
<LinkToolbarButton tooltip={false} />
</ToolbarGroup>
</>
);
}

View file

@ -0,0 +1,79 @@
"use client";
import * as React from "react";
import {
type FloatingToolbarState,
flip,
offset,
useFloatingToolbar,
useFloatingToolbarState,
} from "@platejs/floating";
import { useComposedRef } from "@udecode/cn";
import { KEYS } from "platejs";
import { useEditorId, useEventEditorValue, usePluginOption } from "platejs/react";
import { cn } from "@/lib/utils";
import { useIsMobile } from "@/hooks/use-mobile";
import { Toolbar } from "./toolbar";
export function FloatingToolbar({
children,
className,
state,
...props
}: React.ComponentProps<typeof Toolbar> & {
state?: FloatingToolbarState;
}) {
const editorId = useEditorId();
const focusedEditorId = useEventEditorValue("focus");
const isFloatingLinkOpen = !!usePluginOption({ key: KEYS.link }, "mode");
const isMobile = useIsMobile();
const floatingToolbarState = useFloatingToolbarState({
editorId,
focusedEditorId,
hideToolbar: isFloatingLinkOpen,
...state,
floatingOptions: {
middleware: [
offset(12),
flip({
fallbackPlacements: ["top-start", "top-end", "bottom-start", "bottom-end"],
padding: 12,
}),
],
placement: "top",
...state?.floatingOptions,
},
});
const {
clickOutsideRef,
hidden,
props: rootProps,
ref: floatingRef,
} = useFloatingToolbar(floatingToolbarState);
const ref = useComposedRef<HTMLDivElement>(props.ref, floatingRef);
if (hidden || isMobile) return null;
return (
<div ref={clickOutsideRef}>
<Toolbar
{...props}
{...rootProps}
ref={ref}
className={cn(
"scrollbar-hide absolute z-50 overflow-x-auto whitespace-nowrap rounded-md border bg-popover p-1 opacity-100 shadow-md print:hidden dark:bg-neutral-800 dark:border-neutral-700",
"max-w-[80vw]",
className
)}
>
{children}
</Toolbar>
</div>
);
}

View file

@ -0,0 +1,56 @@
"use client";
import * as React from "react";
import type { PlateElementProps } from "platejs/react";
import { type VariantProps, cva } from "class-variance-authority";
import { PlateElement } from "platejs/react";
const headingVariants = cva("relative mb-1", {
variants: {
variant: {
h1: "mt-[1.6em] pb-1 font-bold font-heading text-4xl",
h2: "mt-[1.4em] pb-px font-heading font-semibold text-2xl tracking-tight",
h3: "mt-[1em] pb-px font-heading font-semibold text-xl tracking-tight",
h4: "mt-[0.75em] font-heading font-semibold text-lg tracking-tight",
h5: "mt-[0.75em] font-semibold text-lg tracking-tight",
h6: "mt-[0.75em] font-semibold text-base tracking-tight",
},
},
});
export function HeadingElement({
variant = "h1",
...props
}: PlateElementProps & VariantProps<typeof headingVariants>) {
return (
<PlateElement as={variant!} className={headingVariants({ variant })} {...props}>
{props.children}
</PlateElement>
);
}
export function H1Element(props: PlateElementProps) {
return <HeadingElement variant="h1" {...props} />;
}
export function H2Element(props: PlateElementProps) {
return <HeadingElement variant="h2" {...props} />;
}
export function H3Element(props: PlateElementProps) {
return <HeadingElement variant="h3" {...props} />;
}
export function H4Element(props: PlateElementProps) {
return <HeadingElement variant="h4" {...props} />;
}
export function H5Element(props: PlateElementProps) {
return <HeadingElement variant="h5" {...props} />;
}
export function H6Element(props: PlateElementProps) {
return <HeadingElement variant="h6" {...props} />;
}

View file

@ -0,0 +1,15 @@
"use client";
import * as React from "react";
import type { PlateLeafProps } from "platejs/react";
import { PlateLeaf } from "platejs/react";
export function HighlightLeaf(props: PlateLeafProps) {
return (
<PlateLeaf {...props} as="mark" className="bg-yellow-200 dark:bg-yellow-800 text-inherit">
{props.children}
</PlateLeaf>
);
}

View file

@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import type { PlateElementProps } from "platejs/react";
import { PlateElement, useFocused, useReadOnly, useSelected } from "platejs/react";
import { cn } from "@/lib/utils";
export function HrElement(props: PlateElementProps) {
const readOnly = useReadOnly();
const selected = useSelected();
const focused = useFocused();
return (
<PlateElement {...props}>
<div className="py-6" contentEditable={false}>
<hr
className={cn(
"h-0.5 rounded-sm border-none bg-muted bg-clip-content",
selected && focused && "ring-2 ring-ring ring-offset-2",
!readOnly && "cursor-pointer"
)}
/>
</div>
{props.children}
</PlateElement>
);
}

View file

@ -0,0 +1,406 @@
"use client";
import * as React from "react";
import type { Point, TElement } from "platejs";
import {
type ComboboxItemProps,
Combobox,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxItem,
ComboboxPopover,
ComboboxProvider,
ComboboxRow,
Portal,
useComboboxContext,
useComboboxStore,
} from "@ariakit/react";
import { filterWords } from "@platejs/combobox";
import {
type UseComboboxInputResult,
useComboboxInput,
useHTMLInputCursorState,
} from "@platejs/combobox/react";
import { cva } from "class-variance-authority";
import { useComposedRef, useEditorRef } from "platejs/react";
import { cn } from "@/lib/utils";
function useRequiredComboboxContext() {
const context = useComboboxContext();
if (!context) {
throw new Error("InlineCombobox compound components must be rendered within InlineCombobox");
}
return context;
}
type FilterFn = (
item: { value: string; group?: string; keywords?: string[]; label?: string },
search: string
) => boolean;
type InlineComboboxContextValue = {
filter: FilterFn | false;
inputProps: UseComboboxInputResult["props"];
inputRef: React.RefObject<HTMLInputElement | null>;
removeInput: UseComboboxInputResult["removeInput"];
showTrigger: boolean;
trigger: string;
setHasEmpty: (hasEmpty: boolean) => void;
};
const InlineComboboxContext = React.createContext<InlineComboboxContextValue>(
null as unknown as InlineComboboxContextValue
);
const defaultFilter: FilterFn = ({ group, keywords = [], label, value }, search) => {
const uniqueTerms = new Set([value, ...keywords, group, label].filter(Boolean));
return Array.from(uniqueTerms).some((keyword) => filterWords(keyword as string, search));
};
type InlineComboboxProps = {
children: React.ReactNode;
element: TElement;
trigger: string;
filter?: FilterFn | false;
hideWhenNoValue?: boolean;
showTrigger?: boolean;
value?: string;
setValue?: (value: string) => void;
};
const InlineCombobox = ({
children,
element,
filter = defaultFilter,
hideWhenNoValue = false,
setValue: setValueProp,
showTrigger = true,
trigger,
value: valueProp,
}: InlineComboboxProps) => {
const editor = useEditorRef();
const inputRef = React.useRef<HTMLInputElement>(null);
const cursorState = useHTMLInputCursorState(inputRef);
const [valueState, setValueState] = React.useState("");
const hasValueProp = valueProp !== undefined;
const value = hasValueProp ? valueProp : valueState;
// Check if current user is the creator of this element (for Yjs collaboration)
const isCreator = React.useMemo(() => {
const elementUserId = (element as Record<string, unknown>).userId;
const currentUserId = editor.meta.userId;
// If no userId (backwards compatibility or non-Yjs), allow
if (!elementUserId) return true;
return elementUserId === currentUserId;
}, [editor.meta.userId, element]);
const setValue = React.useCallback(
(newValue: string) => {
setValueProp?.(newValue);
if (!hasValueProp) {
setValueState(newValue);
}
},
[setValueProp, hasValueProp]
);
/**
* Track the point just before the input element so we know where to
* insertText if the combobox closes due to a selection change.
*/
const insertPoint = React.useRef<Point | null>(null);
React.useEffect(() => {
const path = editor.api.findPath(element);
if (!path) return;
const point = editor.api.before(path);
if (!point) return;
const pointRef = editor.api.pointRef(point);
insertPoint.current = pointRef.current;
return () => {
pointRef.unref();
};
}, [editor, element]);
const { props: inputProps, removeInput } = useComboboxInput({
cancelInputOnBlur: true,
cursorState,
autoFocus: isCreator,
ref: inputRef,
onCancelInput: (cause) => {
if (cause !== "backspace") {
editor.tf.insertText(trigger + value, {
at: insertPoint?.current ?? undefined,
});
}
if (cause === "arrowLeft" || cause === "arrowRight") {
editor.tf.move({
distance: 1,
reverse: cause === "arrowLeft",
});
}
},
});
const [hasEmpty, setHasEmpty] = React.useState(false);
const contextValue: InlineComboboxContextValue = React.useMemo(
() => ({
filter,
inputProps,
inputRef,
removeInput,
setHasEmpty,
showTrigger,
trigger,
}),
[trigger, showTrigger, filter, inputProps, removeInput]
);
const store = useComboboxStore({
// open: ,
setValue: (newValue) => React.startTransition(() => setValue(newValue)),
});
const items = store.useState("items");
/**
* If there is no active ID and the list of items changes, select the first
* item.
*/
React.useEffect(() => {
if (items.length === 0) return;
if (!store.getState().activeId) {
store.setActiveId(store.first());
}
}, [items, store]);
return (
<span contentEditable={false}>
<ComboboxProvider
open={(items.length > 0 || hasEmpty) && (!hideWhenNoValue || value.length > 0)}
store={store}
>
<InlineComboboxContext.Provider value={contextValue}>
{children}
</InlineComboboxContext.Provider>
</ComboboxProvider>
</span>
);
};
const InlineComboboxInput = ({
className,
ref: propRef,
...props
}: React.HTMLAttributes<HTMLInputElement> & {
ref?: React.RefObject<HTMLInputElement | null>;
}) => {
const {
inputProps,
inputRef: contextRef,
showTrigger,
trigger,
} = React.useContext(InlineComboboxContext);
const store = useRequiredComboboxContext();
const value = store.useState("value");
const ref = useComposedRef(propRef, contextRef);
/**
* To create an auto-resizing input, we render a visually hidden span
* containing the input value and position the input element on top of it.
* This works well for all cases except when input exceeds the width of the
* container.
*/
return (
<>
{showTrigger && trigger}
<span className="relative min-h-[1lh]">
<span className="invisible overflow-hidden text-nowrap" aria-hidden="true">
{value || "\u200B"}
</span>
<Combobox
ref={ref}
className={cn("absolute top-0 left-0 size-full bg-transparent outline-none", className)}
value={value}
autoSelect
{...inputProps}
{...props}
/>
</span>
</>
);
};
InlineComboboxInput.displayName = "InlineComboboxInput";
const InlineComboboxContent: typeof ComboboxPopover = ({ className, ...props }) => {
// Portal prevents CSS from leaking into popover
const store = useComboboxContext();
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (!store) return;
const state = store.getState();
const { items, activeId } = state;
if (!items.length) return;
const currentIndex = items.findIndex((item) => item.id === activeId);
if (event.key === "ArrowUp" && currentIndex <= 0) {
event.preventDefault();
store.setActiveId(store.last());
} else if (event.key === "ArrowDown" && currentIndex >= items.length - 1) {
event.preventDefault();
store.setActiveId(store.first());
}
}
return (
<Portal>
<ComboboxPopover
className={cn(
"z-500 max-h-[288px] w-[300px] overflow-y-auto rounded-md bg-popover shadow-md",
className
)}
onKeyDownCapture={handleKeyDown}
{...props}
/>
</Portal>
);
};
const comboboxItemVariants = cva(
"relative mx-1 flex h-[28px] select-none items-center rounded-sm px-2 text-foreground text-sm outline-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
defaultVariants: {
interactive: true,
},
variants: {
interactive: {
false: "",
true: "cursor-pointer transition-colors hover:bg-accent hover:text-accent-foreground data-[active-item=true]:bg-accent data-[active-item=true]:text-accent-foreground dark:hover:bg-neutral-700 dark:data-[active-item=true]:bg-neutral-700",
},
},
}
);
const InlineComboboxItem = ({
className,
focusEditor = true,
group,
keywords,
label,
onClick,
...props
}: {
focusEditor?: boolean;
group?: string;
keywords?: string[];
label?: string;
} & ComboboxItemProps &
Required<Pick<ComboboxItemProps, "value">>) => {
const { value } = props;
const { filter, removeInput } = React.useContext(InlineComboboxContext);
const store = useRequiredComboboxContext();
// Always call hook unconditionally; only use value if filter is active
const storeValue = store.useState("value");
const search = filter ? storeValue : "";
const visible = React.useMemo(
() => !filter || filter({ group, keywords, label, value }, search),
[filter, group, keywords, label, value, search]
);
if (!visible) return null;
return (
<ComboboxItem
className={cn(comboboxItemVariants(), className)}
onClick={(event) => {
removeInput(focusEditor);
onClick?.(event);
}}
{...props}
/>
);
};
const InlineComboboxEmpty = ({ children, className }: React.HTMLAttributes<HTMLDivElement>) => {
const { setHasEmpty } = React.useContext(InlineComboboxContext);
const store = useRequiredComboboxContext();
const items = store.useState("items");
React.useEffect(() => {
setHasEmpty(true);
return () => {
setHasEmpty(false);
};
}, [setHasEmpty]);
if (items.length > 0) return null;
return (
<div className={cn(comboboxItemVariants({ interactive: false }), className)}>{children}</div>
);
};
const InlineComboboxRow = ComboboxRow;
function InlineComboboxGroup({ className, ...props }: React.ComponentProps<typeof ComboboxGroup>) {
return (
<ComboboxGroup
{...props}
className={cn("hidden not-last:border-b py-1.5 [&:has([role=option])]:block", className)}
/>
);
}
function InlineComboboxGroupLabel({
className,
...props
}: React.ComponentProps<typeof ComboboxGroupLabel>) {
return (
<ComboboxGroupLabel
{...props}
className={cn("mt-1.5 mb-2 px-3 font-medium text-muted-foreground text-xs", className)}
/>
);
}
export {
InlineCombobox,
InlineComboboxContent,
InlineComboboxEmpty,
InlineComboboxGroup,
InlineComboboxGroupLabel,
InlineComboboxInput,
InlineComboboxItem,
InlineComboboxRow,
};

View file

@ -0,0 +1,225 @@
"use client";
import * as React from "react";
import type { DropdownMenuProps } from "@radix-ui/react-dropdown-menu";
import {
ChevronRightIcon,
FileCodeIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
InfoIcon,
ListIcon,
ListOrderedIcon,
MinusIcon,
PilcrowIcon,
PlusIcon,
QuoteIcon,
RadicalIcon,
SquareIcon,
SubscriptIcon,
SuperscriptIcon,
TableIcon,
} from "lucide-react";
import { KEYS } from "platejs";
import { type PlateEditor, useEditorRef } from "platejs/react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { insertBlock, insertInlineElement } from "@/components/editor/transforms";
import { ToolbarButton, ToolbarMenuGroup } from "./toolbar";
type Group = {
group: string;
items: Item[];
};
type Item = {
icon: React.ReactNode;
value: string;
onSelect: (editor: PlateEditor, value: string) => void;
focusEditor?: boolean;
label?: string;
};
const groups: Group[] = [
{
group: "Basic blocks",
items: [
{
icon: <PilcrowIcon />,
label: "Paragraph",
value: KEYS.p,
},
{
icon: <Heading1Icon />,
label: "Heading 1",
value: "h1",
},
{
icon: <Heading2Icon />,
label: "Heading 2",
value: "h2",
},
{
icon: <Heading3Icon />,
label: "Heading 3",
value: "h3",
},
{
icon: <TableIcon />,
label: "Table",
value: KEYS.table,
},
{
icon: <FileCodeIcon />,
label: "Code block",
value: KEYS.codeBlock,
},
{
icon: <QuoteIcon />,
label: "Quote",
value: KEYS.blockquote,
},
{
icon: <MinusIcon />,
label: "Divider",
value: KEYS.hr,
},
].map((item) => ({
...item,
onSelect: (editor: PlateEditor, value: string) => {
insertBlock(editor, value);
},
})),
},
{
group: "Lists",
items: [
{
icon: <ListIcon />,
label: "Bulleted list",
value: KEYS.ul,
},
{
icon: <ListOrderedIcon />,
label: "Numbered list",
value: KEYS.ol,
},
{
icon: <SquareIcon />,
label: "To-do list",
value: KEYS.listTodo,
},
{
icon: <ChevronRightIcon />,
label: "Toggle list",
value: KEYS.toggle,
},
].map((item) => ({
...item,
onSelect: (editor: PlateEditor, value: string) => {
insertBlock(editor, value);
},
})),
},
{
group: "Advanced",
items: [
{
icon: <InfoIcon />,
label: "Callout",
value: KEYS.callout,
},
{
focusEditor: false,
icon: <RadicalIcon />,
label: "Equation",
value: KEYS.equation,
},
].map((item) => ({
...item,
onSelect: (editor: PlateEditor, value: string) => {
if (item.value === KEYS.equation) {
insertInlineElement(editor, value);
} else {
insertBlock(editor, value);
}
},
})),
},
{
group: "Marks",
items: [
{
icon: <SuperscriptIcon />,
label: "Superscript",
value: KEYS.sup,
},
{
icon: <SubscriptIcon />,
label: "Subscript",
value: KEYS.sub,
},
].map((item) => ({
...item,
onSelect: (editor: PlateEditor, value: string) => {
editor.tf.toggleMark(value, {
remove: value === KEYS.sup ? KEYS.sub : KEYS.sup,
});
},
})),
},
];
export function InsertToolbarButton(props: DropdownMenuProps) {
const editor = useEditorRef();
const [open, setOpen] = React.useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
<DropdownMenuTrigger asChild>
<ToolbarButton pressed={open} tooltip="Insert" isDropdown>
<PlusIcon />
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="z-[100] flex max-h-[60vh] min-w-0 flex-col overflow-y-auto dark:bg-neutral-800 dark:border dark:border-neutral-700"
align="start"
>
{groups.map(({ group, items }) => (
<React.Fragment key={group}>
<ToolbarMenuGroup label={group}>
{items.map(({ icon, label, value, onSelect, focusEditor }) => (
<DropdownMenuItem
key={value}
onSelect={() => {
onSelect(editor, value);
if (focusEditor !== false) {
editor.tf.focus();
}
setOpen(false);
}}
className="group"
>
<div className="flex items-center text-sm dark:text-white text-muted-foreground focus:text-accent-foreground group-aria-selected:text-accent-foreground">
{icon}
<span className="ml-2">{label || value}</span>
</div>
</DropdownMenuItem>
))}
</ToolbarMenuGroup>
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import type { TLinkElement } from "platejs";
import type { PlateElementProps } from "platejs/react";
import { getLinkAttributes } from "@platejs/link";
import { PlateElement } from "platejs/react";
import { cn } from "@/lib/utils";
export function LinkElement(props: PlateElementProps<TLinkElement>) {
return (
<PlateElement
{...props}
as="a"
className={cn(
"font-medium text-blue-600 underline decoration-blue-600 underline-offset-4 hover:text-blue-800 dark:text-blue-400 dark:decoration-blue-400 dark:hover:text-blue-300"
)}
attributes={{
...props.attributes,
...getLinkAttributes(props.editor, props.element),
onMouseOver: (e) => {
e.stopPropagation();
},
}}
>
{props.children}
</PlateElement>
);
}

View file

@ -0,0 +1,19 @@
"use client";
import * as React from "react";
import { useLinkToolbarButton, useLinkToolbarButtonState } from "@platejs/link/react";
import { Link } from "lucide-react";
import { ToolbarButton } from "./toolbar";
export function LinkToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {
const state = useLinkToolbarButtonState();
const { props: buttonProps } = useLinkToolbarButton(state);
return (
<ToolbarButton tooltip="Link" {...props} {...buttonProps} data-plate-focus>
<Link />
</ToolbarButton>
);
}

View file

@ -0,0 +1,196 @@
"use client";
import * as React from "react";
import type { TLinkElement } from "platejs";
import { type UseVirtualFloatingOptions, flip, offset } from "@platejs/floating";
import { getLinkAttributes } from "@platejs/link";
import {
type LinkFloatingToolbarState,
FloatingLinkUrlInput,
useFloatingLinkEdit,
useFloatingLinkEditState,
useFloatingLinkInsert,
useFloatingLinkInsertState,
} from "@platejs/link/react";
import { cva } from "class-variance-authority";
import { ExternalLink, Link, Text, Unlink } from "lucide-react";
import { KEYS } from "platejs";
import {
useEditorRef,
useEditorSelection,
useFormInputProps,
usePluginOption,
} from "platejs/react";
import { buttonVariants } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
const popoverVariants = cva(
"z-50 w-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden"
);
const inputVariants = cva(
"flex h-[28px] w-full rounded-md border-none bg-transparent px-1.5 py-1 text-base placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-transparent md:text-sm"
);
export function LinkFloatingToolbar({ state }: { state?: LinkFloatingToolbarState }) {
const activeCommentId = usePluginOption({ key: KEYS.comment }, "activeId");
const activeSuggestionId = usePluginOption({ key: KEYS.suggestion }, "activeId");
const floatingOptions: UseVirtualFloatingOptions = React.useMemo(
() => ({
middleware: [
offset(8),
flip({
fallbackPlacements: ["bottom-end", "top-start", "top-end"],
padding: 12,
}),
],
placement: activeSuggestionId || activeCommentId ? "top-start" : "bottom-start",
}),
[activeCommentId, activeSuggestionId]
);
const insertState = useFloatingLinkInsertState({
...state,
floatingOptions: {
...floatingOptions,
...state?.floatingOptions,
},
});
const {
hidden,
props: insertProps,
ref: insertRef,
textInputProps,
} = useFloatingLinkInsert(insertState);
const editState = useFloatingLinkEditState({
...state,
floatingOptions: {
...floatingOptions,
...state?.floatingOptions,
},
});
const {
editButtonProps,
props: editProps,
ref: editRef,
unlinkButtonProps,
} = useFloatingLinkEdit(editState);
const inputProps = useFormInputProps({
preventDefaultOnEnterKeydown: true,
});
if (hidden) return null;
const input = (
<div className="flex w-[330px] flex-col" {...inputProps}>
<div className="flex items-center">
<div className="flex items-center pr-1 pl-2 text-muted-foreground">
<Link className="size-4" />
</div>
<FloatingLinkUrlInput
className={inputVariants()}
placeholder="Paste link"
data-plate-focus
/>
</div>
<Separator className="my-1" />
<div className="flex items-center">
<div className="flex items-center pr-1 pl-2 text-muted-foreground">
<Text className="size-4" />
</div>
<input
className={inputVariants()}
placeholder="Text to display"
data-plate-focus
{...textInputProps}
/>
</div>
</div>
);
const editContent = editState.isEditing ? (
input
) : (
<div className="box-content flex items-center">
<button
className={buttonVariants({ size: "sm", variant: "ghost" })}
type="button"
{...editButtonProps}
>
Edit link
</button>
<Separator orientation="vertical" />
<LinkOpenButton />
<Separator orientation="vertical" />
<button
className={buttonVariants({
size: "sm",
variant: "ghost",
})}
type="button"
{...unlinkButtonProps}
>
<Unlink width={18} />
</button>
</div>
);
return (
<>
<div ref={insertRef} className={popoverVariants()} {...insertProps}>
{input}
</div>
<div ref={editRef} className={popoverVariants()} {...editProps}>
{editContent}
</div>
</>
);
}
function LinkOpenButton() {
const editor = useEditorRef();
const selection = useEditorSelection();
const attributes = React.useMemo(
() => {
const entry = editor.api.node<TLinkElement>({
match: { type: editor.getType(KEYS.link) },
});
if (!entry) {
return {};
}
const [element] = entry;
return getLinkAttributes(editor, element);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[editor, selection]
);
return (
<a
{...attributes}
className={buttonVariants({
size: "sm",
variant: "ghost",
})}
onMouseOver={(e) => {
e.stopPropagation();
}}
aria-label="Open link in a new tab"
target="_blank"
>
<ExternalLink width={18} />
</a>
);
}

View file

@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import { useMarkToolbarButton, useMarkToolbarButtonState } from "platejs/react";
import { ToolbarButton } from "./toolbar";
export function MarkToolbarButton({
clear,
nodeType,
...props
}: React.ComponentProps<typeof ToolbarButton> & {
nodeType: string;
clear?: string[] | string;
}) {
const state = useMarkToolbarButtonState({ clear, nodeType });
const { props: buttonProps } = useMarkToolbarButton(state);
return (
<ToolbarButton
{...props}
{...buttonProps}
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault();
}}
/>
);
}

View file

@ -0,0 +1,19 @@
"use client";
import { BookOpenIcon, PenLineIcon } from "lucide-react";
import { usePlateState } from "platejs/react";
import { ToolbarButton } from "./toolbar";
export function ModeToolbarButton() {
const [readOnly, setReadOnly] = usePlateState("readOnly");
return (
<ToolbarButton
tooltip={readOnly ? "Click to edit" : "Click to view"}
onClick={() => setReadOnly(!readOnly)}
>
{readOnly ? <BookOpenIcon /> : <PenLineIcon />}
</ToolbarButton>
);
}

View file

@ -0,0 +1,17 @@
"use client";
import * as React from "react";
import type { PlateElementProps } from "platejs/react";
import { PlateElement } from "platejs/react";
import { cn } from "@/lib/utils";
export function ParagraphElement(props: PlateElementProps) {
return (
<PlateElement {...props} className={cn("m-0 px-0 py-1")}>
{props.children}
</PlateElement>
);
}

View file

@ -0,0 +1,79 @@
"use client";
import * as React from "react";
import type { VariantProps } from "class-variance-authority";
import {
type ResizeHandle as ResizeHandlePrimitive,
Resizable as ResizablePrimitive,
useResizeHandle,
useResizeHandleState,
} from "@platejs/resizable";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
export const mediaResizeHandleVariants = cva(
cn(
"top-0 flex w-6 select-none flex-col justify-center",
"after:flex after:h-16 after:w-[3px] after:rounded-[6px] after:bg-ring after:opacity-0 after:content-['_'] group-hover:after:opacity-100"
),
{
variants: {
direction: {
left: "-left-3 -ml-3 pl-3",
right: "-right-3 -mr-3 items-end pr-3",
},
},
}
);
const resizeHandleVariants = cva("absolute z-40", {
variants: {
direction: {
bottom: "w-full cursor-row-resize",
left: "h-full cursor-col-resize",
right: "h-full cursor-col-resize",
top: "w-full cursor-row-resize",
},
},
});
export function ResizeHandle({
className,
options,
...props
}: React.ComponentProps<typeof ResizeHandlePrimitive> & VariantProps<typeof resizeHandleVariants>) {
const state = useResizeHandleState(options ?? {});
const resizeHandle = useResizeHandle(state);
if (state.readOnly) return null;
return (
<div
className={cn(resizeHandleVariants({ direction: options?.direction }), className)}
data-resizing={state.isResizing}
{...resizeHandle.props}
{...props}
/>
);
}
const resizableVariants = cva("", {
variants: {
align: {
center: "mx-auto",
left: "mr-auto",
right: "ml-auto",
},
},
});
export function Resizable({
align,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive> & VariantProps<typeof resizableVariants>) {
return <ResizablePrimitive {...props} className={cn(resizableVariants({ align }), className)} />;
}

View file

@ -1,7 +1,7 @@
"use client";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import type * as React from "react";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
@ -13,7 +13,7 @@ function Separator({
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(

View file

@ -24,7 +24,7 @@ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
const SIDEBAR_KEYBOARD_SHORTCUT = "\\";
type SidebarContext = {
state: "expanded" | "collapsed";

View file

@ -0,0 +1,217 @@
"use client";
import * as React from "react";
import type { PlateElementProps } from "platejs/react";
import { SlashInputPlugin } from "@platejs/slash-command/react";
import {
ChevronRightIcon,
Code2Icon,
FileCodeIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
InfoIcon,
ListIcon,
ListOrderedIcon,
MinusIcon,
PilcrowIcon,
QuoteIcon,
RadicalIcon,
SquareIcon,
TableIcon,
} from "lucide-react";
import { KEYS } from "platejs";
import { PlateElement, useEditorRef } from "platejs/react";
import {
InlineCombobox,
InlineComboboxContent,
InlineComboboxEmpty,
InlineComboboxGroup,
InlineComboboxGroupLabel,
InlineComboboxInput,
InlineComboboxItem,
} from "@/components/ui/inline-combobox";
import { insertBlock, insertInlineElement } from "@/components/editor/transforms";
interface SlashCommandItem {
icon: React.ReactNode;
keywords: string[];
label: string;
value: string;
onSelect: (editor: any) => void;
}
const slashCommandGroups: { heading: string; items: SlashCommandItem[] }[] = [
{
heading: "Basic Blocks",
items: [
{
icon: <PilcrowIcon />,
keywords: ["paragraph", "text", "plain"],
label: "Text",
value: "text",
onSelect: (editor) => insertBlock(editor, KEYS.p),
},
{
icon: <Heading1Icon />,
keywords: ["title", "h1", "heading"],
label: "Heading 1",
value: "heading1",
onSelect: (editor) => insertBlock(editor, "h1"),
},
{
icon: <Heading2Icon />,
keywords: ["subtitle", "h2", "heading"],
label: "Heading 2",
value: "heading2",
onSelect: (editor) => insertBlock(editor, "h2"),
},
{
icon: <Heading3Icon />,
keywords: ["subtitle", "h3", "heading"],
label: "Heading 3",
value: "heading3",
onSelect: (editor) => insertBlock(editor, "h3"),
},
{
icon: <QuoteIcon />,
keywords: ["citation", "blockquote"],
label: "Quote",
value: "quote",
onSelect: (editor) => insertBlock(editor, KEYS.blockquote),
},
{
icon: <MinusIcon />,
keywords: ["divider", "separator", "line"],
label: "Divider",
value: "divider",
onSelect: (editor) => insertBlock(editor, KEYS.hr),
},
],
},
{
heading: "Lists",
items: [
{
icon: <ListIcon />,
keywords: ["unordered", "ul", "bullet"],
label: "Bulleted list",
value: "bulleted-list",
onSelect: (editor) => insertBlock(editor, KEYS.ul),
},
{
icon: <ListOrderedIcon />,
keywords: ["ordered", "ol", "numbered"],
label: "Numbered list",
value: "numbered-list",
onSelect: (editor) => insertBlock(editor, KEYS.ol),
},
{
icon: <SquareIcon />,
keywords: ["checklist", "task", "checkbox", "todo"],
label: "To-do list",
value: "todo-list",
onSelect: (editor) => insertBlock(editor, KEYS.listTodo),
},
],
},
{
heading: "Advanced",
items: [
{
icon: <TableIcon />,
keywords: ["table", "grid"],
label: "Table",
value: "table",
onSelect: (editor) => insertBlock(editor, KEYS.table),
},
{
icon: <FileCodeIcon />,
keywords: ["code", "codeblock", "snippet"],
label: "Code block",
value: "code-block",
onSelect: (editor) => insertBlock(editor, KEYS.codeBlock),
},
{
icon: <InfoIcon />,
keywords: ["callout", "note", "info", "warning", "tip"],
label: "Callout",
value: "callout",
onSelect: (editor) => insertBlock(editor, KEYS.callout),
},
{
icon: <ChevronRightIcon />,
keywords: ["toggle", "collapsible", "expand"],
label: "Toggle",
value: "toggle",
onSelect: (editor) => insertBlock(editor, KEYS.toggle),
},
{
icon: <RadicalIcon />,
keywords: ["equation", "math", "formula", "latex"],
label: "Equation",
value: "equation",
onSelect: (editor) => insertInlineElement(editor, KEYS.equation),
},
],
},
{
heading: "Inline",
items: [
{
icon: <Code2Icon />,
keywords: ["link", "url", "href"],
label: "Link",
value: "link",
onSelect: (editor) => insertInlineElement(editor, KEYS.link),
},
],
},
];
export function SlashInputElement({ children, ...props }: PlateElementProps) {
const editor = useEditorRef();
return (
<PlateElement {...props} as="span">
<InlineCombobox element={props.element} trigger="/">
<InlineComboboxInput />
<InlineComboboxContent className="dark:bg-neutral-800 dark:border dark:border-neutral-700">
<InlineComboboxEmpty>No results found.</InlineComboboxEmpty>
{slashCommandGroups.map(({ heading, items }) => (
<InlineComboboxGroup key={heading}>
<InlineComboboxGroupLabel>{heading}</InlineComboboxGroupLabel>
{items.map(({ icon, keywords, label, value, onSelect }) => (
<InlineComboboxItem
key={value}
className="flex items-center gap-3 px-2 py-1.5"
keywords={keywords}
label={label}
value={value}
group={heading}
onClick={() => {
onSelect(editor);
editor.tf.focus();
}}
>
<span className="flex size-5 items-center justify-center text-muted-foreground">
{icon}
</span>
{label}
</InlineComboboxItem>
))}
</InlineComboboxGroup>
))}
</InlineComboboxContent>
</InlineCombobox>
{children}
</PlateElement>
);
}

View file

@ -0,0 +1,685 @@
"use client";
import type { LucideProps } from "lucide-react";
export function BorderAllIcon(props: LucideProps) {
return (
<svg
fill="none"
height="15"
viewBox="0 0 15 15"
width="15"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Border All</title>
<path
clipRule="evenodd"
d="M0.25 1C0.25 0.585786 0.585786 0.25 1 0.25H14C14.4142 0.25 14.75 0.585786 14.75 1V14C14.75 14.4142 14.4142 14.75 14 14.75H1C0.585786 14.75 0.25 14.4142 0.25 14V1ZM1.75 1.75V13.25H13.25V1.75H1.75Z"
fill="currentColor"
fillRule="evenodd"
/>
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="5" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="3" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="9" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="11" />
</svg>
);
}
export function BorderBottomIcon(props: LucideProps) {
return (
<svg
fill="none"
height="15"
viewBox="0 0 15 15"
width="15"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Border Bottom</title>
<path
clipRule="evenodd"
d="M1 13.25L14 13.25V14.75L1 14.75V13.25Z"
fill="currentColor"
fillRule="evenodd"
/>
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="5" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="5" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="3" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="3" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="9" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="9" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="11" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="11" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="5" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="3" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="7" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="1" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="9" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="11" />
</svg>
);
}
export function BorderLeftIcon(props: LucideProps) {
return (
<svg
fill="none"
height="15"
viewBox="0 0 15 15"
width="15"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Border Left</title>
<path
clipRule="evenodd"
d="M1.75 1L1.75 14L0.249999 14L0.25 1L1.75 1Z"
fill="currentColor"
fillRule="evenodd"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 10 7)"
width="1"
x="10"
y="7"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 10 13)"
width="1"
x="10"
y="13"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 12 7)"
width="1"
x="12"
y="7"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 12 13)"
width="1"
x="12"
y="13"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 7)"
width="1"
x="8"
y="7"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 7)"
width="1"
x="14"
y="7"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 13)"
width="1"
x="8"
y="13"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 13)"
width="1"
x="14"
y="13"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 5)"
width="1"
x="8"
y="5"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 5)"
width="1"
x="14"
y="5"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 3)"
width="1"
x="8"
y="3"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 3)"
width="1"
x="14"
y="3"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 9)"
width="1"
x="8"
y="9"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 9)"
width="1"
x="14"
y="9"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 11)"
width="1"
x="8"
y="11"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 11)"
width="1"
x="14"
y="11"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 6 7)"
width="1"
x="6"
y="7"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 6 13)"
width="1"
x="6"
y="13"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 4 7)"
width="1"
x="4"
y="7"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 4 13)"
width="1"
x="4"
y="13"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 10 1)"
width="1"
x="10"
y="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 12 1)"
width="1"
x="12"
y="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 8 1)"
width="1"
x="8"
y="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 14 1)"
width="1"
x="14"
y="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 6 1)"
width="1"
x="6"
y="1"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(90 4 1)"
width="1"
x="4"
y="1"
/>
</svg>
);
}
export function BorderNoneIcon(props: LucideProps) {
return (
<svg
fill="none"
height="15"
viewBox="0 0 15 15"
width="15"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Border None</title>
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="5.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="5.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="3.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="3.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="7.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="13.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="7.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="13.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="7.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="13.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="7.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="13.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="7.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="13.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="7.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="13.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="9.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="9.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="11.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="11.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="5.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="3.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="7.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="13.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="1.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="9.025" />
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="11.025" />
</svg>
);
}
export function BorderRightIcon(props: LucideProps) {
return (
<svg
fill="none"
height="15"
viewBox="0 0 15 15"
width="15"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Border Right</title>
<path
clipRule="evenodd"
d="M13.25 1L13.25 14L14.75 14L14.75 1L13.25 1Z"
fill="currentColor"
fillRule="evenodd"
/>
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 5 7)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 5 13)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 3 7)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 3 13)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 7)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 7)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 13)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 13)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 5)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 5)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 3)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 3)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 9)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 9)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 11)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 11)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 9 7)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 9 13)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 11 7)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 11 13)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 5 1)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 3 1)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 1)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 1)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 9 1)" width="1" />
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 11 1)" width="1" />
</svg>
);
}
export function BorderTopIcon(props: LucideProps) {
return (
<svg
fill="none"
height="15"
viewBox="0 0 15 15"
width="15"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Border Top</title>
<path
clipRule="evenodd"
d="M14 1.75L1 1.75L1 0.249999L14 0.25L14 1.75Z"
fill="currentColor"
fillRule="evenodd"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 8 10)"
width="1"
x="8"
y="10"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 2 10)"
width="1"
x="2"
y="10"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 8 12)"
width="1"
x="8"
y="12"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 2 12)"
width="1"
x="2"
y="12"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 8 8)"
width="1"
x="8"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 8 14)"
width="1"
x="8"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 2 8)"
width="1"
x="2"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 2 14)"
width="1"
x="2"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 10 8)"
width="1"
x="10"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 10 14)"
width="1"
x="10"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 12 8)"
width="1"
x="12"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 12 14)"
width="1"
x="12"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 6 8)"
width="1"
x="6"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 6 14)"
width="1"
x="6"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 4 8)"
width="1"
x="4"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 4 14)"
width="1"
x="4"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 8 6)"
width="1"
x="8"
y="6"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 2 6)"
width="1"
x="2"
y="6"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 8 4)"
width="1"
x="8"
y="4"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 2 4)"
width="1"
x="2"
y="4"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 14 10)"
width="1"
x="14"
y="10"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 14 12)"
width="1"
x="14"
y="12"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 14 8)"
width="1"
x="14"
y="8"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 14 14)"
width="1"
x="14"
y="14"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 14 6)"
width="1"
x="14"
y="6"
/>
<rect
fill="currentColor"
height="1"
rx=".5"
transform="rotate(-180 14 4)"
width="1"
x="14"
y="4"
/>
</svg>
);
}

View file

@ -0,0 +1,439 @@
"use client";
import * as React from "react";
import { useDraggable, useDropLine } from "@platejs/dnd";
import { BlockSelectionPlugin, useBlockSelected } from "@platejs/selection/react";
import {
TablePlugin,
TableProvider,
useTableCellElement,
useTableCellElementResizable,
useTableElement,
useTableMergeState,
} from "@platejs/table/react";
import { PopoverAnchor } from "@radix-ui/react-popover";
import { cva } from "class-variance-authority";
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
CombineIcon,
GripVertical,
SquareSplitHorizontalIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import {
type TElement,
type TTableCellElement,
type TTableElement,
type TTableRowElement,
KEYS,
PathApi,
} from "platejs";
import {
type PlateElementProps,
PlateElement,
useComposedRef,
useEditorPlugin,
useEditorRef,
useEditorSelector,
useElement,
useFocusedLast,
usePluginOption,
useReadOnly,
useRemoveNodeButton,
useSelected,
withHOC,
} from "platejs/react";
import { useElementSelector } from "platejs/react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { blockSelectionVariants } from "./block-selection";
import { ResizeHandle } from "./resize-handle";
import { Toolbar, ToolbarButton, ToolbarGroup } from "./toolbar";
export const TableElement = withHOC(
TableProvider,
function TableElement({ children, ...props }: PlateElementProps<TTableElement>) {
const readOnly = useReadOnly();
const isSelectionAreaVisible = usePluginOption(BlockSelectionPlugin, "isSelectionAreaVisible");
const hasControls = !readOnly && !isSelectionAreaVisible;
const { isSelectingCell, marginLeft, props: tableProps } = useTableElement();
const isSelectingTable = useBlockSelected(props.element.id as string);
const content = (
<PlateElement
{...props}
className={cn(
"overflow-x-auto py-5",
hasControls && "-ml-2 *:data-[slot=block-selection]:left-2"
)}
style={{ paddingLeft: marginLeft }}
>
<div className="group/table relative w-fit">
<table
className={cn(
"mr-0 ml-px table h-px w-full table-fixed border-collapse",
isSelectingCell && "selection:bg-transparent"
)}
{...tableProps}
>
<tbody className="min-w-full">{children}</tbody>
</table>
{isSelectingTable && <div className={blockSelectionVariants()} contentEditable={false} />}
</div>
</PlateElement>
);
if (readOnly) {
return content;
}
return <TableFloatingToolbar>{content}</TableFloatingToolbar>;
}
);
function TableFloatingToolbar({ children, ...props }: React.ComponentProps<typeof PopoverContent>) {
const { tf } = useEditorPlugin(TablePlugin);
const selected = useSelected();
const element = useElement<TTableElement>();
const { props: buttonProps } = useRemoveNodeButton({ element });
const collapsedInside = useEditorSelector(
(editor) => selected && editor.api.isCollapsed(),
[selected]
);
const isFocusedLast = useFocusedLast();
const { canMerge, canSplit } = useTableMergeState();
return (
<Popover open={isFocusedLast && (canMerge || canSplit || collapsedInside)} modal={false}>
<PopoverAnchor asChild>{children}</PopoverAnchor>
<PopoverContent
asChild
onOpenAutoFocus={(e) => e.preventDefault()}
contentEditable={false}
{...props}
>
<Toolbar
className="scrollbar-hide flex w-auto max-w-[80vw] flex-row overflow-x-auto rounded-md border bg-popover p-1 shadow-md print:hidden"
contentEditable={false}
>
<ToolbarGroup>
{canMerge && (
<ToolbarButton
onClick={() => tf.table.merge()}
onMouseDown={(e) => e.preventDefault()}
tooltip="Merge cells"
>
<CombineIcon />
</ToolbarButton>
)}
{canSplit && (
<ToolbarButton
onClick={() => tf.table.split()}
onMouseDown={(e) => e.preventDefault()}
tooltip="Split cell"
>
<SquareSplitHorizontalIcon />
</ToolbarButton>
)}
{collapsedInside && (
<ToolbarGroup>
<ToolbarButton tooltip="Delete table" {...buttonProps}>
<Trash2Icon />
</ToolbarButton>
</ToolbarGroup>
)}
</ToolbarGroup>
{collapsedInside && (
<ToolbarGroup>
<ToolbarButton
onClick={() => {
tf.insert.tableRow({ before: true });
}}
onMouseDown={(e) => e.preventDefault()}
tooltip="Insert row before"
>
<ArrowUp />
</ToolbarButton>
<ToolbarButton
onClick={() => {
tf.insert.tableRow();
}}
onMouseDown={(e) => e.preventDefault()}
tooltip="Insert row after"
>
<ArrowDown />
</ToolbarButton>
<ToolbarButton
onClick={() => {
tf.remove.tableRow();
}}
onMouseDown={(e) => e.preventDefault()}
tooltip="Delete row"
>
<XIcon />
</ToolbarButton>
</ToolbarGroup>
)}
{collapsedInside && (
<ToolbarGroup>
<ToolbarButton
onClick={() => {
tf.insert.tableColumn({ before: true });
}}
onMouseDown={(e) => e.preventDefault()}
tooltip="Insert column before"
>
<ArrowLeft />
</ToolbarButton>
<ToolbarButton
onClick={() => {
tf.insert.tableColumn();
}}
onMouseDown={(e) => e.preventDefault()}
tooltip="Insert column after"
>
<ArrowRight />
</ToolbarButton>
<ToolbarButton
onClick={() => {
tf.remove.tableColumn();
}}
onMouseDown={(e) => e.preventDefault()}
tooltip="Delete column"
>
<XIcon />
</ToolbarButton>
</ToolbarGroup>
)}
</Toolbar>
</PopoverContent>
</Popover>
);
}
export function TableRowElement({ children, ...props }: PlateElementProps<TTableRowElement>) {
const { element } = props;
const readOnly = useReadOnly();
const selected = useSelected();
const editor = useEditorRef();
const isSelectionAreaVisible = usePluginOption(BlockSelectionPlugin, "isSelectionAreaVisible");
const hasControls = !readOnly && !isSelectionAreaVisible;
const { isDragging, nodeRef, previewRef, handleRef } = useDraggable({
element,
type: element.type,
canDropNode: ({ dragEntry, dropEntry }) =>
PathApi.equals(PathApi.parent(dragEntry[1]), PathApi.parent(dropEntry[1])),
onDropHandler: (_, { dragItem }) => {
const dragElement = (dragItem as { element: TElement }).element;
if (dragElement) {
editor.tf.select(dragElement);
}
},
});
return (
<PlateElement
{...props}
ref={useComposedRef(props.ref, previewRef, nodeRef)}
as="tr"
className={cn("group/row", isDragging && "opacity-50")}
attributes={{
...props.attributes,
"data-selected": selected ? "true" : undefined,
}}
>
{hasControls && (
<td className="w-2 select-none" contentEditable={false}>
<RowDragHandle dragRef={handleRef} />
<RowDropLine />
</td>
)}
{children}
</PlateElement>
);
}
function RowDragHandle({ dragRef }: { dragRef: React.Ref<any> }) {
const editor = useEditorRef();
const element = useElement();
return (
<Button
ref={dragRef}
variant="outline"
className={cn(
"-translate-y-1/2 absolute top-1/2 left-0 z-51 h-6 w-4 p-0 focus-visible:ring-0 focus-visible:ring-offset-0",
"cursor-grab active:cursor-grabbing",
'opacity-0 transition-opacity duration-100 group-hover/row:opacity-100 group-has-data-[resizing="true"]/row:opacity-0'
)}
onClick={() => {
editor.tf.select(element);
}}
>
<GripVertical className="text-muted-foreground" />
</Button>
);
}
function RowDropLine() {
const { dropLine } = useDropLine();
if (!dropLine) return null;
return (
<div
className={cn(
"absolute inset-x-0 left-2 z-50 h-0.5 bg-brand/50",
dropLine === "top" ? "-top-px" : "-bottom-px"
)}
/>
);
}
export function TableCellElement({
isHeader,
...props
}: PlateElementProps<TTableCellElement> & {
isHeader?: boolean;
}) {
const { api } = useEditorPlugin(TablePlugin);
const readOnly = useReadOnly();
const element = props.element;
const tableId = useElementSelector(([node]) => node.id as string, [], {
key: KEYS.table,
});
const rowId = useElementSelector(([node]) => node.id as string, [], {
key: KEYS.tr,
});
const isSelectingTable = useBlockSelected(tableId);
const isSelectingRow = useBlockSelected(rowId) || isSelectingTable;
const isSelectionAreaVisible = usePluginOption(BlockSelectionPlugin, "isSelectionAreaVisible");
const { borders, colIndex, colSpan, minHeight, rowIndex, selected, width } =
useTableCellElement();
const { bottomProps, hiddenLeft, leftProps, rightProps } = useTableCellElementResizable({
colIndex,
colSpan,
rowIndex,
});
return (
<PlateElement
{...props}
as={isHeader ? "th" : "td"}
className={cn(
"h-full overflow-visible border-none bg-background p-0",
element.background ? "bg-(--cellBackground)" : "bg-background",
isHeader && "text-left *:m-0",
"before:size-full",
selected && "before:z-10 before:bg-brand/5",
"before:absolute before:box-border before:select-none before:content-['']",
borders.bottom?.size && "before:border-b before:border-b-border",
borders.right?.size && "before:border-r before:border-r-border",
borders.left?.size && "before:border-l before:border-l-border",
borders.top?.size && "before:border-t before:border-t-border"
)}
style={
{
"--cellBackground": element.background,
minWidth: width || 48,
} as React.CSSProperties
}
attributes={{
...props.attributes,
colSpan: api.table.getColSpan(element),
rowSpan: api.table.getRowSpan(element),
}}
>
<div className="relative z-20 box-border h-full px-3 py-2" style={{ minHeight }}>
{props.children}
</div>
{!isSelectionAreaVisible && (
<div
className="group absolute top-0 size-full select-none"
contentEditable={false}
suppressContentEditableWarning={true}
>
{!readOnly && (
<>
<ResizeHandle
{...rightProps}
className="-top-2 -right-1 h-[calc(100%_+_8px)] w-2"
data-col={colIndex}
/>
<ResizeHandle {...bottomProps} className="-bottom-1 h-2" />
{!hiddenLeft && (
<ResizeHandle
{...leftProps}
className="-left-1 top-0 w-2"
data-resizer-left={colIndex === 0 ? "true" : undefined}
/>
)}
<div
className={cn(
"absolute top-0 z-30 hidden h-full w-1 bg-ring",
"right-[-1.5px]",
columnResizeVariants({ colIndex: colIndex as any })
)}
/>
{colIndex === 0 && (
<div
className={cn(
"absolute top-0 z-30 h-full w-1 bg-ring",
"left-[-1.5px]",
'fade-in hidden animate-in group-has-[[data-resizer-left]:hover]/table:block group-has-[[data-resizer-left][data-resizing="true"]]/table:block'
)}
/>
)}
</>
)}
</div>
)}
{isSelectingRow && <div className={blockSelectionVariants()} contentEditable={false} />}
</PlateElement>
);
}
export function TableCellHeaderElement(props: React.ComponentProps<typeof TableCellElement>) {
return <TableCellElement {...props} isHeader />;
}
const columnResizeVariants = cva("fade-in hidden animate-in", {
variants: {
colIndex: {
0: 'group-has-[[data-col="0"]:hover]/table:block group-has-[[data-col="0"][data-resizing="true"]]/table:block',
1: 'group-has-[[data-col="1"]:hover]/table:block group-has-[[data-col="1"][data-resizing="true"]]/table:block',
2: 'group-has-[[data-col="2"]:hover]/table:block group-has-[[data-col="2"][data-resizing="true"]]/table:block',
3: 'group-has-[[data-col="3"]:hover]/table:block group-has-[[data-col="3"][data-resizing="true"]]/table:block',
4: 'group-has-[[data-col="4"]:hover]/table:block group-has-[[data-col="4"][data-resizing="true"]]/table:block',
5: 'group-has-[[data-col="5"]:hover]/table:block group-has-[[data-col="5"][data-resizing="true"]]/table:block',
6: 'group-has-[[data-col="6"]:hover]/table:block group-has-[[data-col="6"][data-resizing="true"]]/table:block',
7: 'group-has-[[data-col="7"]:hover]/table:block group-has-[[data-col="7"][data-resizing="true"]]/table:block',
8: 'group-has-[[data-col="8"]:hover]/table:block group-has-[[data-col="8"][data-resizing="true"]]/table:block',
9: 'group-has-[[data-col="9"]:hover]/table:block group-has-[[data-col="9"][data-resizing="true"]]/table:block',
10: 'group-has-[[data-col="10"]:hover]/table:block group-has-[[data-col="10"][data-resizing="true"]]/table:block',
},
},
});

View file

@ -0,0 +1,33 @@
"use client";
import * as React from "react";
import { useToggleButton, useToggleButtonState } from "@platejs/toggle/react";
import { ChevronRightIcon } from "lucide-react";
import { type PlateElementProps, PlateElement } from "platejs/react";
import { cn } from "@/lib/utils";
export function ToggleElement({ children, ...props }: PlateElementProps) {
const element = props.element;
const state = useToggleButtonState(element.id as string);
const { buttonProps, open } = useToggleButton(state);
return (
<PlateElement {...props} className="relative py-1 pl-6">
<button
className={cn(
"absolute top-1.5 left-0 flex size-6 cursor-pointer select-none items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
)}
contentEditable={false}
type="button"
{...buttonProps}
>
<ChevronRightIcon
className={cn("size-4 transition-transform duration-200", open && "rotate-90")}
/>
</button>
<div>{children}</div>
</PlateElement>
);
}

View file

@ -0,0 +1,363 @@
"use client";
import * as React from "react";
import * as ToolbarPrimitive from "@radix-ui/react-toolbar";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { type VariantProps, cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import {
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export function Toolbar({
className,
...props
}: React.ComponentProps<typeof ToolbarPrimitive.Root>) {
return (
<ToolbarPrimitive.Root
className={cn("relative flex select-none items-center", className)}
{...props}
/>
);
}
export function ToolbarToggleGroup({
className,
...props
}: React.ComponentProps<typeof ToolbarPrimitive.ToolbarToggleGroup>) {
return (
<ToolbarPrimitive.ToolbarToggleGroup
className={cn("flex items-center", className)}
{...props}
/>
);
}
export function ToolbarLink({
className,
...props
}: React.ComponentProps<typeof ToolbarPrimitive.Link>) {
return (
<ToolbarPrimitive.Link
className={cn("font-medium underline underline-offset-4", className)}
{...props}
/>
);
}
export function ToolbarSeparator({
className,
...props
}: React.ComponentProps<typeof ToolbarPrimitive.Separator>) {
return (
<ToolbarPrimitive.Separator
className={cn("mx-2 my-1 w-px shrink-0 bg-border", className)}
{...props}
/>
);
}
// From toggleVariants
const toolbarButtonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-checked:bg-accent aria-checked:text-accent-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
defaultVariants: {
size: "default",
variant: "default",
},
variants: {
size: {
default: "h-9 min-w-9 px-2",
lg: "h-10 min-w-10 px-2.5",
sm: "h-8 min-w-8 px-1.5",
},
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
},
}
);
const dropdownArrowVariants = cva(
cn(
"inline-flex items-center justify-center rounded-r-md font-medium text-foreground text-sm transition-colors disabled:pointer-events-none disabled:opacity-50"
),
{
defaultVariants: {
size: "sm",
variant: "default",
},
variants: {
size: {
default: "h-9 w-6",
lg: "h-10 w-8",
sm: "h-8 w-4",
},
variant: {
default:
"bg-transparent hover:bg-muted hover:text-muted-foreground aria-checked:bg-accent aria-checked:text-accent-foreground",
outline:
"border border-input border-l-0 bg-transparent hover:bg-accent hover:text-accent-foreground",
},
},
}
);
type ToolbarButtonProps = {
isDropdown?: boolean;
pressed?: boolean;
} & Omit<React.ComponentPropsWithoutRef<typeof ToolbarToggleItem>, "asChild" | "value"> &
VariantProps<typeof toolbarButtonVariants>;
export const ToolbarButton = withTooltip(function ToolbarButton({
children,
className,
isDropdown,
pressed,
size = "sm",
variant,
...props
}: ToolbarButtonProps) {
return typeof pressed === "boolean" ? (
<ToolbarToggleGroup disabled={props.disabled} value="single" type="single">
<ToolbarToggleItem
className={cn(
toolbarButtonVariants({
size,
variant,
}),
isDropdown && "justify-between gap-1 pr-1",
className
)}
value={pressed ? "single" : ""}
{...props}
>
{isDropdown ? (
<>
<div className="flex flex-1 items-center gap-2 whitespace-nowrap">{children}</div>
<div>
<ChevronDown className="size-3.5 text-muted-foreground" data-icon />
</div>
</>
) : (
children
)}
</ToolbarToggleItem>
</ToolbarToggleGroup>
) : (
<ToolbarPrimitive.Button
className={cn(
toolbarButtonVariants({
size,
variant,
}),
isDropdown && "pr-1",
className
)}
{...props}
>
{children}
</ToolbarPrimitive.Button>
);
});
export function ToolbarSplitButton({
className,
...props
}: React.ComponentPropsWithoutRef<typeof ToolbarButton>) {
return (
<ToolbarButton
className={cn("group flex gap-0 px-0 hover:bg-transparent", className)}
{...props}
/>
);
}
type ToolbarSplitButtonPrimaryProps = Omit<
React.ComponentPropsWithoutRef<typeof ToolbarToggleItem>,
"value"
> &
VariantProps<typeof toolbarButtonVariants>;
export function ToolbarSplitButtonPrimary({
children,
className,
size = "sm",
variant,
...props
}: ToolbarSplitButtonPrimaryProps) {
return (
<span
className={cn(
toolbarButtonVariants({
size,
variant,
}),
"rounded-r-none",
"group-data-[pressed=true]:bg-accent group-data-[pressed=true]:text-accent-foreground",
className
)}
{...props}
>
{children}
</span>
);
}
export function ToolbarSplitButtonSecondary({
className,
size,
variant,
...props
}: React.ComponentPropsWithoutRef<"span"> & VariantProps<typeof dropdownArrowVariants>) {
return (
<span
className={cn(
dropdownArrowVariants({
size,
variant,
}),
"group-data-[pressed=true]:bg-accent group-data-[pressed=true]:text-accent-foreground",
className
)}
onClick={(e) => e.stopPropagation()}
role="button"
{...props}
>
<ChevronDown className="size-3.5 text-muted-foreground" data-icon />
</span>
);
}
export function ToolbarToggleItem({
className,
size = "sm",
variant,
...props
}: React.ComponentProps<typeof ToolbarPrimitive.ToggleItem> &
VariantProps<typeof toolbarButtonVariants>) {
return (
<ToolbarPrimitive.ToggleItem
className={cn(toolbarButtonVariants({ size, variant }), className)}
{...props}
/>
);
}
export function ToolbarGroup({ children, className }: React.ComponentProps<"div">) {
return (
<div className={cn("group/toolbar-group", "relative hidden has-[button]:flex", className)}>
<div className="flex items-center">{children}</div>
<div className="group-last/toolbar-group:hidden! mx-1.5 py-0.5">
<Separator orientation="vertical" />
</div>
</div>
);
}
type TooltipProps<T extends React.ElementType> = {
tooltip?: React.ReactNode;
tooltipContentProps?: Omit<React.ComponentPropsWithoutRef<typeof TooltipContent>, "children">;
tooltipProps?: Omit<React.ComponentPropsWithoutRef<typeof Tooltip>, "children">;
tooltipTriggerProps?: React.ComponentPropsWithoutRef<typeof TooltipTrigger>;
} & React.ComponentProps<T>;
function withTooltip<T extends React.ElementType>(Component: T) {
return function ExtendComponent({
tooltip,
tooltipContentProps,
tooltipProps,
tooltipTriggerProps,
...props
}: TooltipProps<T>) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
const component = <Component {...(props as React.ComponentProps<T>)} />;
if (tooltip && mounted) {
return (
<Tooltip {...tooltipProps}>
<TooltipTrigger asChild {...tooltipTriggerProps}>
{component}
</TooltipTrigger>
<TooltipContent {...tooltipContentProps}>{tooltip}</TooltipContent>
</Tooltip>
);
}
return component;
};
}
function TooltipContent({
children,
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className={cn(
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none",
className
)}
data-slot="tooltip-content"
sideOffset={sideOffset}
{...props}
>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export function ToolbarMenuGroup({
children,
className,
label,
...props
}: React.ComponentProps<typeof DropdownMenuRadioGroup> & { label?: string }) {
return (
<>
<DropdownMenuSeparator
className={cn(
"hidden",
"mb-0 mx-2 shrink-0 peer-has-[[role=menuitem]]/menu-group:block peer-has-[[role=menuitemradio]]/menu-group:block peer-has-[[role=option]]/menu-group:block",
"dark:bg-neutral-700"
)}
/>
<DropdownMenuRadioGroup
{...props}
className={cn(
"hidden",
"peer/menu-group group/menu-group my-1.5 has-[[role=menuitem]]:block has-[[role=menuitemradio]]:block has-[[role=option]]:block",
className
)}
>
{label && (
<DropdownMenuLabel className="select-none font-semibold text-muted-foreground text-xs">
{label}
</DropdownMenuLabel>
)}
{children}
</DropdownMenuRadioGroup>
</>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type * as React from "react";
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";

View file

@ -0,0 +1,189 @@
"use client";
import * as React from "react";
import type { DropdownMenuProps } from "@radix-ui/react-dropdown-menu";
import type { TElement } from "platejs";
import { DropdownMenuItemIndicator } from "@radix-ui/react-dropdown-menu";
import {
CheckIcon,
ChevronRightIcon,
FileCodeIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
InfoIcon,
ListIcon,
ListOrderedIcon,
PilcrowIcon,
QuoteIcon,
SquareIcon,
} from "lucide-react";
import { KEYS } from "platejs";
import { useEditorRef, useSelectionFragmentProp } from "platejs/react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getBlockType, setBlockType } from "@/components/editor/transforms";
import { ToolbarButton, ToolbarMenuGroup } from "./toolbar";
export const turnIntoItems = [
{
icon: <PilcrowIcon />,
keywords: ["paragraph"],
label: "Text",
value: KEYS.p,
},
{
icon: <Heading1Icon />,
keywords: ["title", "h1"],
label: "Heading 1",
value: "h1",
},
{
icon: <Heading2Icon />,
keywords: ["subtitle", "h2"],
label: "Heading 2",
value: "h2",
},
{
icon: <Heading3Icon />,
keywords: ["subtitle", "h3"],
label: "Heading 3",
value: "h3",
},
{
icon: <Heading4Icon />,
keywords: ["subtitle", "h4"],
label: "Heading 4",
value: "h4",
},
{
icon: <Heading5Icon />,
keywords: ["subtitle", "h5"],
label: "Heading 5",
value: "h5",
},
{
icon: <Heading6Icon />,
keywords: ["subtitle", "h6"],
label: "Heading 6",
value: "h6",
},
{
icon: <ListIcon />,
keywords: ["unordered", "ul", "-"],
label: "Bulleted list",
value: KEYS.ul,
},
{
icon: <ListOrderedIcon />,
keywords: ["ordered", "ol", "1"],
label: "Numbered list",
value: KEYS.ol,
},
{
icon: <SquareIcon />,
keywords: ["checklist", "task", "checkbox", "[]"],
label: "To-do list",
value: KEYS.listTodo,
},
{
icon: <FileCodeIcon />,
keywords: ["```"],
label: "Code",
value: KEYS.codeBlock,
},
{
icon: <QuoteIcon />,
keywords: ["citation", "blockquote", ">"],
label: "Quote",
value: KEYS.blockquote,
},
{
icon: <InfoIcon />,
keywords: ["callout", "note", "info", "warning", "tip"],
label: "Callout",
value: KEYS.callout,
},
{
icon: <ChevronRightIcon />,
keywords: ["toggle", "collapsible", "expand"],
label: "Toggle",
value: KEYS.toggle,
},
];
export function TurnIntoToolbarButton({
tooltip = "Turn into",
...props
}: DropdownMenuProps & { tooltip?: React.ReactNode }) {
const editor = useEditorRef();
const [open, setOpen] = React.useState(false);
const value = useSelectionFragmentProp({
defaultValue: KEYS.p,
getProp: (node) => getBlockType(node as TElement),
});
const selectedItem = React.useMemo(
() => turnIntoItems.find((item) => item.value === (value ?? KEYS.p)) ?? turnIntoItems[0],
[value]
);
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
<DropdownMenuTrigger asChild>
<ToolbarButton
className="min-w-[80px] sm:min-w-[125px]"
pressed={open}
tooltip={tooltip}
isDropdown
>
{selectedItem.label}
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="z-[100] ignore-click-outside/toolbar min-w-0 max-h-[60vh] overflow-y-auto dark:bg-neutral-800 dark:border dark:border-neutral-700"
onCloseAutoFocus={(e) => {
e.preventDefault();
editor.tf.focus();
}}
align="start"
>
<ToolbarMenuGroup
value={value}
onValueChange={(type) => {
setBlockType(editor, type);
}}
label="Turn into"
>
{turnIntoItems.map(({ icon, label, value: itemValue }) => (
<DropdownMenuRadioItem
key={itemValue}
className="min-w-[180px] pl-2 *:first:[span]:hidden dark:text-white"
value={itemValue}
>
<span className="pointer-events-none absolute right-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<CheckIcon />
</DropdownMenuItemIndicator>
</span>
<span className="text-muted-foreground">{icon}</span>
{label}
</DropdownMenuRadioItem>
))}
</ToolbarMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,18 @@
import * as React from "react";
export const useDebounce = <T>(value: T, delay = 500) => {
const [debouncedValue, setDebouncedValue] = React.useState(value);
React.useEffect(() => {
const handler: NodeJS.Timeout = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cancel the timeout if value changes (also on delay change or unmount)
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};

View file

@ -0,0 +1,11 @@
import * as React from "react";
export function useMounted() {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
return mounted;
}

View file

@ -0,0 +1,57 @@
import { useCallback, useEffect, useState } from "react";
interface NavigatorUAData {
platform: string;
}
function getIsMac() {
if (typeof navigator === "undefined") return false;
// Modern API (Chromium browsers: Chrome, Edge, Opera)
const uaData = (navigator as Navigator & { userAgentData?: NavigatorUAData }).userAgentData;
if (uaData?.platform) {
return uaData.platform === "macOS";
}
// Fallback for Firefox/Safari
return /Mac|iPhone/.test(navigator.platform);
}
/**
* Returns a helper that formats keyboard shortcut strings with
* platform-aware modifier symbols.
*
* SSR-safe: returns an empty string until mounted so there is no hydration
* mismatch.
*/
export function usePlatformShortcut() {
const [ready, setReady] = useState(false);
const [isMac, setIsMac] = useState(false);
useEffect(() => {
setIsMac(getIsMac());
setReady(true);
}, []);
const shortcut = useCallback(
(...keys: string[]) => {
if (!ready) return "";
const mod = isMac ? "⌘" : "Ctrl";
const shift = isMac ? "⇧" : "Shift";
const alt = isMac ? "⌥" : "Alt";
const mapped = keys.map((k) => {
if (k === "Mod") return mod;
if (k === "Shift") return shift;
if (k === "Alt") return alt;
return k;
});
return `(${mapped.join("+")})`;
},
[ready, isMac]
);
return { shortcut, isMac, ready };
}

View file

@ -6,7 +6,7 @@ import { baseApiService } from "./base-api.service";
const createNoteRequest = z.object({
search_space_id: z.number(),
title: z.string().min(1),
blocknote_document: z.array(z.any()).optional(),
source_markdown: z.string().optional(),
});
const createNoteResponse = z.object({
@ -82,12 +82,12 @@ class NotesApiService {
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { search_space_id, title, blocknote_document } = parsedRequest.data;
const { search_space_id, title, source_markdown } = parsedRequest.data;
// Send both title and blocknote_document in request body
// Send both title and source_markdown in request body
const body = {
title,
...(blocknote_document && { blocknote_document }),
...(source_markdown !== undefined && { source_markdown }),
};
return baseApiService.post(

View file

@ -7,7 +7,6 @@ const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
const nextConfig: NextConfig = {
output: "standalone",
// Disable StrictMode for BlockNote compatibility with React 19/Next 15
reactStrictMode: false,
typescript: {
ignoreBuildErrors: true,
@ -20,9 +19,6 @@ const nextConfig: NextConfig = {
},
],
},
// Mark BlockNote server packages as external
serverExternalPackages: ["@blocknote/server-util"],
// Turbopack config (used during `next dev --turbopack`)
turbopack: {
rules: {
@ -33,13 +29,8 @@ const nextConfig: NextConfig = {
},
},
// Configure webpack to handle blocknote packages + SVGR
webpack: (config, { isServer }) => {
if (isServer) {
// Don't bundle these packages on the server
config.externals = [...(config.externals || []), "@blocknote/server-util"];
}
// Configure webpack (SVGR)
webpack: (config) => {
// SVGR: import *.svg as React components
const fileLoaderRule = config.module.rules.find((rule: any) => rule.test?.test?.(".svg"));
config.module.rules.push(

File diff suppressed because it is too large Load diff

View file

@ -22,19 +22,33 @@
},
"dependencies": {
"@ai-sdk/react": "^1.2.12",
"@ariakit/react": "^0.4.21",
"@assistant-ui/react": "^0.11.53",
"@assistant-ui/react-ai-sdk": "^1.1.20",
"@assistant-ui/react-markdown": "^0.11.9",
"@blocknote/core": "^0.45.0",
"@blocknote/mantine": "^0.45.0",
"@blocknote/react": "^0.45.0",
"@blocknote/server-util": "^0.45.0",
"@electric-sql/client": "^1.4.0",
"@electric-sql/pglite": "^0.3.14",
"@electric-sql/pglite-sync": "^0.4.0",
"@electric-sql/react": "^1.0.26",
"@hookform/resolvers": "^5.2.2",
"@number-flow/react": "^0.5.10",
"@platejs/autoformat": "^52.0.11",
"@platejs/basic-nodes": "^52.0.11",
"@platejs/callout": "^52.0.11",
"@platejs/code-block": "^52.0.11",
"@platejs/combobox": "^52.0.15",
"@platejs/dnd": "^52.0.11",
"@platejs/floating": "^52.0.11",
"@platejs/indent": "^52.0.11",
"@platejs/link": "^52.0.11",
"@platejs/list": "^52.0.11",
"@platejs/markdown": "^52.1.0",
"@platejs/math": "^52.0.11",
"@platejs/resizable": "^52.0.11",
"@platejs/selection": "^52.0.16",
"@platejs/slash-command": "^52.0.15",
"@platejs/table": "^52.0.11",
"@platejs/toggle": "^52.0.11",
"@posthog/react": "^1.7.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
@ -56,6 +70,7 @@
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-toolbar": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.7",
"@streamdown/code": "^1.0.2",
"@streamdown/math": "^1.0.2",
@ -66,6 +81,7 @@
"@tanstack/react-table": "^8.21.3",
"@types/mdx": "^2.0.13",
"@types/react-syntax-highlighter": "^15.5.13",
"@udecode/cn": "^52.0.11",
"ai": "^4.3.19",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
@ -83,17 +99,22 @@
"jotai-tanstack-query": "^0.11.0",
"katex": "^0.16.28",
"lenis": "^1.3.17",
"lowlight": "^3.3.0",
"lucide-react": "^0.477.0",
"motion": "^12.23.22",
"next": "^16.1.0",
"next-intl": "^4.6.1",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"platejs": "^52.0.17",
"postgres": "^3.4.7",
"posthog-js": "^1.336.1",
"posthog-node": "^5.24.4",
"radix-ui": "^1.4.3",
"react": "^19.2.3",
"react-day-picker": "^9.8.1",
"react-day-picker": "^9.13.2",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.2.3",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.61.1",
@ -109,6 +130,7 @@
"sonner": "^2.0.6",
"streamdown": "^2.2.0",
"tailwind-merge": "^3.3.1",
"tailwind-scrollbar-hide": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0",
"vaul": "^1.1.2",

File diff suppressed because it is too large Load diff