diff --git a/surfsense_backend/alembic/versions/101_add_source_markdown_to_documents.py b/surfsense_backend/alembic/versions/101_add_source_markdown_to_documents.py new file mode 100644 index 000000000..4a9f3d8f7 --- /dev/null +++ b/surfsense_backend/alembic/versions/101_add_source_markdown_to_documents.py @@ -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") diff --git a/surfsense_backend/app/celery_app.py b/surfsense_backend/app/celery_app.py index a6637b1bd..62414775a 100644 --- a/surfsense_backend/app/celery_app.py +++ b/surfsense_backend/app/celery_app.py @@ -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", ], diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index e232f0e14..1c9181ed2 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -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") ) diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py index a0e7b59c1..84846ef38 100644 --- a/surfsense_backend/app/routes/editor_routes.py +++ b/surfsense_backend/app/routes/editor_routes.py @@ -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 diff --git a/surfsense_backend/app/routes/notes_routes.py b/surfsense_backend/app/routes/notes_routes.py index 47cf96d04..76518de08 100644 --- a/surfsense_backend/app/routes/notes_routes.py +++ b/surfsense_backend/app/routes/notes_routes.py @@ -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 diff --git a/surfsense_backend/app/routes/reports_routes.py b/surfsense_backend/app/routes/reports_routes.py index c9974ddb0..e32d7adcd 100644 --- a/surfsense_backend/app/routes/reports_routes.py +++ b/surfsense_backend/app/routes/reports_routes.py @@ -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 (Markdown→Typst) + typst-py (Typst→PDF); 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, diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index e3f03b29c..7e3ba1936 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -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", diff --git a/surfsense_backend/app/schemas/reports.py b/surfsense_backend/app/schemas/reports.py index 9909d8601..9a7765507 100644 --- a/surfsense_backend/app/schemas/reports.py +++ b/surfsense_backend/app/schemas/reports.py @@ -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 diff --git a/surfsense_backend/app/tasks/celery_tasks/blocknote_migration_tasks.py b/surfsense_backend/app/tasks/celery_tasks/blocknote_migration_tasks.py deleted file mode 100644 index c945bcb04..000000000 --- a/surfsense_backend/app/tasks/celery_tasks/blocknote_migration_tasks.py +++ /dev/null @@ -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 diff --git a/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py index b9d4c3b95..a2a0d635d 100644 --- a/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py @@ -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 diff --git a/surfsense_backend/app/tasks/document_processors/circleback_processor.py b/surfsense_backend/app/tasks/document_processors/circleback_processor.py index a513bcaf0..a86b64499 100644 --- a/surfsense_backend/app/tasks/document_processors/circleback_processor.py +++ b/surfsense_backend/app/tasks/document_processors/circleback_processor.py @@ -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() diff --git a/surfsense_backend/app/tasks/document_processors/extension_processor.py b/surfsense_backend/app/tasks/document_processors/extension_processor.py index 9ddab4ec6..a6e482e15 100644 --- a/surfsense_backend/app/tasks/document_processors/extension_processor.py +++ b/surfsense_backend/app/tasks/document_processors/extension_processor.py @@ -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, ) diff --git a/surfsense_backend/app/tasks/document_processors/file_processors.py b/surfsense_backend/app/tasks/document_processors/file_processors.py index 3fa57e998..041053a04 100644 --- a/surfsense_backend/app/tasks/document_processors/file_processors.py +++ b/surfsense_backend/app/tasks/document_processors/file_processors.py @@ -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 diff --git a/surfsense_backend/app/tasks/document_processors/markdown_processor.py b/surfsense_backend/app/tasks/document_processors/markdown_processor.py index 8ecbb1370..a8d20c062 100644 --- a/surfsense_backend/app/tasks/document_processors/markdown_processor.py +++ b/surfsense_backend/app/tasks/document_processors/markdown_processor.py @@ -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, diff --git a/surfsense_backend/app/tasks/document_processors/youtube_processor.py b/surfsense_backend/app/tasks/document_processors/youtube_processor.py index 80cdaae4d..13b969fb6 100644 --- a/surfsense_backend/app/tasks/document_processors/youtube_processor.py +++ b/surfsense_backend/app/tasks/document_processors/youtube_processor.py @@ -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() diff --git a/surfsense_backend/app/utils/blocknote_converter.py b/surfsense_backend/app/utils/blocknote_converter.py deleted file mode 100644 index b57a82996..000000000 --- a/surfsense_backend/app/utils/blocknote_converter.py +++ /dev/null @@ -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 diff --git a/surfsense_backend/app/utils/blocknote_to_markdown.py b/surfsense_backend/app/utils/blocknote_to_markdown.py new file mode 100644 index 000000000..3731b4b3c --- /dev/null +++ b/surfsense_backend/app/utils/blocknote_to_markdown.py @@ -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 diff --git a/surfsense_web/app/api/convert-to-blocknote/route.ts b/surfsense_web/app/api/convert-to-blocknote/route.ts deleted file mode 100644 index e11c9cb47..000000000 --- a/surfsense_web/app/api/convert-to-blocknote/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/surfsense_web/app/api/convert-to-markdown/route.ts b/surfsense_web/app/api/convert-to-markdown/route.ts deleted file mode 100644 index 7005a800f..000000000 --- a/surfsense_web/app/api/convert-to-markdown/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx index 137c02f27..ba252bd16 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx @@ -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" : "" } > - + Edit {shouldShowDelete && ( @@ -170,7 +170,7 @@ export function RowActions({ isEditDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : "" } > - + Edit {shouldShowDelete && ( diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index 74104f450..765bbf098 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -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 }; - -interface BlockNoteBlock { - type: string; - content?: BlockNoteInlineContent[]; - children?: BlockNoteBlock[]; - props?: Record; -} - -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: () => ( +
+ +
+ + + +
+
+ + + +
+
+ + +
+
+ ), + } +); 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(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [editorContent, setEditorContent] = useState(null); const [error, setError] = useState(null); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); + // Store the latest markdown from the editor + const markdownRef = useRef(""); + 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 ( -
- - - -

Loading editor

-
-
+
+ {/* Top bar skeleton — real back button & file icon, skeleton title */} +
+
+ + + +
+
+ + {/* Fixed toolbar placeholder — matches real toolbar styling */} +
+ + {/* Content area skeleton — mimics document text lines */} +
+
+ {/* Title-like line */} + + {/* Paragraph lines */} +
+ + + +
+
+ + + +
+
+ + +
+
+
); } - if (error) { + if (error && !document) { return (
{/* Toolbar */} -
-
+
+
+

{displayTitle}

@@ -418,60 +447,33 @@ export default function EditorPage() { )}
- -
- - -
{/* Editor Container */} -
-
- {error && ( - -
- -

{error}

-
-
- )} -
- -
+
+ {error && ( + +
+ +

{error}

+
+
+ )} +
+
@@ -491,7 +493,13 @@ export default function EditorPage() { Cancel - OK + Save + + Leave without saving + diff --git a/surfsense_web/app/globals.css b/surfsense_web/app/globals.css index 4095fc660..c192a27be 100644 --- a/surfsense_web/app/globals.css +++ b/surfsense_web/app/globals.css @@ -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 { diff --git a/surfsense_web/components.json b/surfsense_web/components.json index 6e57ca9e3..6086c498b 100644 --- a/surfsense_web/components.json +++ b/surfsense_web/components.json @@ -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" + } } diff --git a/surfsense_web/components/BlockNoteEditor.tsx b/surfsense_web/components/BlockNoteEditor.tsx deleted file mode 100644 index 440c63625..000000000 --- a/surfsense_web/components/BlockNoteEditor.tsx +++ /dev/null @@ -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(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 ( -
- - -
- ); -} diff --git a/surfsense_web/components/DynamicBlockNoteEditor.tsx b/surfsense_web/components/DynamicBlockNoteEditor.tsx deleted file mode 100644 index 60fc6b11c..000000000 --- a/surfsense_web/components/DynamicBlockNoteEditor.tsx +++ /dev/null @@ -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 }); diff --git a/surfsense_web/components/editor/editor-save-context.tsx b/surfsense_web/components/editor/editor-save-context.tsx new file mode 100644 index 000000000..d53a4adce --- /dev/null +++ b/surfsense_web/components/editor/editor-save-context.tsx @@ -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({ + hasUnsavedChanges: false, + isSaving: false, + canToggleMode: false, +}); + +export function useEditorSave() { + return useContext(EditorSaveContext); +} diff --git a/surfsense_web/components/editor/plate-editor.tsx b/surfsense_web/components/editor/plate-editor.tsx new file mode 100644 index 000000000..a0c316d88 --- /dev/null +++ b/surfsense_web/components/editor/plate-editor.tsx @@ -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 ( + + { + if (onMarkdownChange) { + const md = editor.getApi(MarkdownPlugin).markdown.serialize({ value }); + lastMarkdownRef.current = md; + onMarkdownChange(md); + } + }} + > + + + + + + ); +} diff --git a/surfsense_web/components/editor/plugins/autoformat-kit.tsx b/surfsense_web/components/editor/plugins/autoformat-kit.tsx new file mode 100644 index 000000000..a145fbb94 --- /dev/null +++ b/surfsense_web/components/editor/plugins/autoformat-kit.tsx @@ -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) }, + }), + }) + ), + }, + }), +]; diff --git a/surfsense_web/components/editor/plugins/basic-blocks-kit.tsx b/surfsense_web/components/editor/plugins/basic-blocks-kit.tsx new file mode 100644 index 000000000..660648baf --- /dev/null +++ b/surfsense_web/components/editor/plugins/basic-blocks-kit.tsx @@ -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), +]; diff --git a/surfsense_web/components/editor/plugins/basic-marks-kit.tsx b/surfsense_web/components/editor/plugins/basic-marks-kit.tsx new file mode 100644 index 000000000..308fb9031 --- /dev/null +++ b/surfsense_web/components/editor/plugins/basic-marks-kit.tsx @@ -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" } }, + }), +]; diff --git a/surfsense_web/components/editor/plugins/basic-nodes-kit.tsx b/surfsense_web/components/editor/plugins/basic-nodes-kit.tsx new file mode 100644 index 000000000..6f61868ed --- /dev/null +++ b/surfsense_web/components/editor/plugins/basic-nodes-kit.tsx @@ -0,0 +1,6 @@ +"use client"; + +import { BasicBlocksKit } from "./basic-blocks-kit"; +import { BasicMarksKit } from "./basic-marks-kit"; + +export const BasicNodesKit = [...BasicBlocksKit, ...BasicMarksKit]; diff --git a/surfsense_web/components/editor/plugins/callout-kit.tsx b/surfsense_web/components/editor/plugins/callout-kit.tsx new file mode 100644 index 000000000..7c3b8b188 --- /dev/null +++ b/surfsense_web/components/editor/plugins/callout-kit.tsx @@ -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)]; diff --git a/surfsense_web/components/editor/plugins/code-block-kit.tsx b/surfsense_web/components/editor/plugins/code-block-kit.tsx new file mode 100644 index 000000000..95b60c073 --- /dev/null +++ b/surfsense_web/components/editor/plugins/code-block-kit.tsx @@ -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), +]; diff --git a/surfsense_web/components/editor/plugins/dnd-kit.tsx b/surfsense_web/components/editor/plugins/dnd-kit.tsx new file mode 100644 index 000000000..0d02c855e --- /dev/null +++ b/surfsense_web/components/editor/plugins/dnd-kit.tsx @@ -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 }) => {children}, + }, + }), +]; diff --git a/surfsense_web/components/editor/plugins/fixed-toolbar-kit.tsx b/surfsense_web/components/editor/plugins/fixed-toolbar-kit.tsx new file mode 100644 index 000000000..85e0a08f2 --- /dev/null +++ b/surfsense_web/components/editor/plugins/fixed-toolbar-kit.tsx @@ -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: () => ( + + + + ), + }, + }), +]; diff --git a/surfsense_web/components/editor/plugins/floating-toolbar-kit.tsx b/surfsense_web/components/editor/plugins/floating-toolbar-kit.tsx new file mode 100644 index 000000000..e0a73e3c2 --- /dev/null +++ b/surfsense_web/components/editor/plugins/floating-toolbar-kit.tsx @@ -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: () => ( + + + + ), + }, + }), +]; diff --git a/surfsense_web/components/editor/plugins/indent-kit.tsx b/surfsense_web/components/editor/plugins/indent-kit.tsx new file mode 100644 index 000000000..0a86be4ad --- /dev/null +++ b/surfsense_web/components/editor/plugins/indent-kit.tsx @@ -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], + }, + }), +]; diff --git a/surfsense_web/components/editor/plugins/link-kit.tsx b/surfsense_web/components/editor/plugins/link-kit.tsx new file mode 100644 index 000000000..62e18a60d --- /dev/null +++ b/surfsense_web/components/editor/plugins/link-kit.tsx @@ -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: () => , + }, + }), +]; diff --git a/surfsense_web/components/editor/plugins/list-kit.tsx b/surfsense_web/components/editor/plugins/list-kit.tsx new file mode 100644 index 000000000..40b31bbf1 --- /dev/null +++ b/surfsense_web/components/editor/plugins/list-kit.tsx @@ -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, + }, + }), +]; diff --git a/surfsense_web/components/editor/plugins/math-kit.tsx b/surfsense_web/components/editor/plugins/math-kit.tsx new file mode 100644 index 000000000..9f31df374 --- /dev/null +++ b/surfsense_web/components/editor/plugins/math-kit.tsx @@ -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), +]; diff --git a/surfsense_web/components/editor/plugins/selection-kit.tsx b/surfsense_web/components/editor/plugins/selection-kit.tsx new file mode 100644 index 000000000..824426b23 --- /dev/null +++ b/surfsense_web/components/editor/plugins/selection-kit.tsx @@ -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; + }, + }, + }), +]; diff --git a/surfsense_web/components/editor/plugins/slash-command-kit.tsx b/surfsense_web/components/editor/plugins/slash-command-kit.tsx new file mode 100644 index 000000000..ba07c6182 --- /dev/null +++ b/surfsense_web/components/editor/plugins/slash-command-kit.tsx @@ -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), +]; diff --git a/surfsense_web/components/editor/plugins/table-kit.tsx b/surfsense_web/components/editor/plugins/table-kit.tsx new file mode 100644 index 000000000..ad111358b --- /dev/null +++ b/surfsense_web/components/editor/plugins/table-kit.tsx @@ -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), +]; diff --git a/surfsense_web/components/editor/plugins/toggle-kit.tsx b/surfsense_web/components/editor/plugins/toggle-kit.tsx new file mode 100644 index 000000000..60f71724c --- /dev/null +++ b/surfsense_web/components/editor/plugins/toggle-kit.tsx @@ -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" } }, + }), +]; diff --git a/surfsense_web/components/editor/presets.ts b/surfsense_web/components/editor/presets.ts new file mode 100644 index 000000000..7800f7c7d --- /dev/null +++ b/surfsense_web/components/editor/presets.ts @@ -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 = { + full: fullPreset, + minimal: minimalPreset, + readonly: readonlyPreset, +}; diff --git a/surfsense_web/components/editor/transforms.ts b/surfsense_web/components/editor/transforms.ts new file mode 100644 index 000000000..5f74c9673 --- /dev/null +++ b/surfsense_web/components/editor/transforms.ts @@ -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 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 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) => { + editor.tf.setNodes( + editor.api.create.block({ + indent: 1, + listStyleType: type, + }), + { + at: entry[1], + } + ); +}; + +const setBlockMap: Record< + string, + (editor: PlateEditor, type: string, entry: NodeEntry) => 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) => { + 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(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; +}; diff --git a/surfsense_web/components/editor/utils/escape-mdx.ts b/surfsense_web/components/editor/utils/escape-mdx.ts new file mode 100644 index 000000000..41e8c9d0a --- /dev/null +++ b/surfsense_web/components/editor/utils/escape-mdx.ts @@ -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 +// , , , 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(/(? { const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "b" && (event.metaKey || event.ctrlKey)) { + if (event.key === "\\" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); toggleCollapsed(); } diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx index 44f05249c..3985e93e0 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx @@ -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 = (
- {/* Report content — skeleton/error/content shown only in this area */} -
+ {/* Report content — skeleton/error/viewer/editor shown only in this area */} +
{isLoading ? ( ) : error || !reportContent ? ( @@ -381,13 +431,27 @@ function ReportPanelContent({

{error || "An unknown error occurred"}

+ ) : reportContent.content ? ( + isReadOnly ? ( +
+ +
+ ) : ( + + ) ) : (
- {reportContent.content ? ( - - ) : ( -

No content available.

- )} +

No content available.

)}
@@ -448,8 +512,12 @@ function MobileReportDrawer() { }} shouldScaleBackground={false} > - + + {panelState.title || "Report"}
{ + 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) => ; +}; + +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 ( +
{ + if (isDragging) return; + setDragButtonTop(calcDragButtonTop(editor, element)); + }} + > + {!isInTable && ( + +
+
+ +
+
+
+ )} + + + ); +} + +function Gutter({ children, className, ...props }: React.ComponentProps<"div">) { + const editor = useEditorRef(); + const element = useElement(); + const isSelectionAreaVisible = usePluginOption(BlockSelectionPlugin, "isSelectionAreaVisible"); + const selected = useSelected(); + + return ( +
+ {children} +
+ ); +} + +const DragHandle = React.memo(function DragHandle({ + isDragging, + previewRef, + resetPreview, + setPreviewTop, +}: { + isDragging: boolean; + previewRef: React.RefObject; + resetPreview: () => void; + setPreviewTop: (top: number) => void; +}) { + const editor = useEditorRef(); + const element = useElement(); + + return ( + + +
{ + 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" + > + +
+
+
+ ); +}); + +const DropLine = React.memo(function DropLine({ + className, + ...props +}: React.ComponentProps<"div">) { + const { dropLine } = useDropLine(); + + if (!dropLine) return null; + + return ( +
+ ); +}); + +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; +}; diff --git a/surfsense_web/components/ui/block-list.tsx b/surfsense_web/components/ui/block-list.tsx new file mode 100644 index 000000000..88bfc0d37 --- /dev/null +++ b/surfsense_web/components/ui/block-list.tsx @@ -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; + Marker: React.FC; + } +> = { + todo: { + Li: TodoLi, + Marker: TodoMarker, + }, +}; + +export const BlockList: RenderNodeWrapper = (props) => { + if (!props.element.listStyleType) return; + + return (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 ( + + {Marker && } + {Li ?
  • :
  • {props.children}
  • } +
    + ); +} + +function TodoMarker(props: PlateElementProps) { + const state = useTodoListElementState({ element: props.element }); + const { checkboxProps } = useTodoListElement(state); + const readOnly = useReadOnly(); + + return ( +
    + +
    + ); +} + +function TodoLi(props: PlateElementProps) { + return ( +
  • + {props.children} +
  • + ); +} diff --git a/surfsense_web/components/ui/block-selection.tsx b/surfsense_web/components/ui/block-selection.tsx new file mode 100644 index 000000000..b8ffdda15 --- /dev/null +++ b/surfsense_web/components/ui/block-selection.tsx @@ -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 ( +
    + ); +} diff --git a/surfsense_web/components/ui/blockquote-node.tsx b/surfsense_web/components/ui/blockquote-node.tsx new file mode 100644 index 000000000..1de9b19cf --- /dev/null +++ b/surfsense_web/components/ui/blockquote-node.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { type PlateElementProps, PlateElement } from "platejs/react"; + +export function BlockquoteElement(props: PlateElementProps) { + return ; +} diff --git a/surfsense_web/components/ui/callout-node.tsx b/surfsense_web/components/ui/callout-node.tsx new file mode 100644 index 000000000..0972fdf7f --- /dev/null +++ b/surfsense_web/components/ui/callout-node.tsx @@ -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 = { + info: "💡", + warning: "⚠️", + error: "🚨", + success: "✅", + note: "📝", + tip: "💜", +}; + +const variantCycle = ["info", "warning", "error", "success", "note", "tip"] as const; + +export function CalloutElement({ children, ...props }: PlateElementProps) { + 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 ( + + +
    {children}
    +
    + ); +} diff --git a/surfsense_web/components/ui/checkbox.tsx b/surfsense_web/components/ui/checkbox.tsx index 789a6b68b..465783c79 100644 --- a/surfsense_web/components/ui/checkbox.tsx +++ b/surfsense_web/components/ui/checkbox.tsx @@ -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 - - + ); diff --git a/surfsense_web/components/ui/code-block-node.tsx b/surfsense_web/components/ui/code-block-node.tsx new file mode 100644 index 000000000..f0f1c6db7 --- /dev/null +++ b/surfsense_web/components/ui/code-block-node.tsx @@ -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) { + const { editor, element } = props; + + return ( + +
    +
    +					{props.children}
    +				
    + +
    + {isLangSupported(element.lang) && ( + + )} + + + + NodeApi.string(element)} + /> +
    +
    +
    + ); +} + +function CodeBlockCombobox() { + const [open, setOpen] = React.useState(false); + const readOnly = useReadOnly(); + const editor = useEditorRef(); + const element = useElement(); + 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 ( + + + + + setSearchValue("")}> + + setSearchValue(value)} + placeholder="Search language..." + /> + No language found. + + + + {items.map((language) => ( + { + editor.tf.setNodes({ lang: value }, { at: element }); + setSearchValue(value); + setOpen(false); + }} + > + + {language.label} + + ))} + + + + + + ); +} + +function CopyButton({ + value, + ...props +}: { value: (() => string) | string } & Omit, "value">) { + const [hasCopied, setHasCopied] = React.useState(false); + + React.useEffect(() => { + setTimeout(() => { + setHasCopied(false); + }, 2000); + }, [hasCopied]); + + return ( + + ); +} + +export function CodeLineElement(props: PlateElementProps) { + return ; +} + +export function CodeSyntaxLeaf(props: PlateLeafProps) { + const tokenClassName = props.leaf.className as string; + + return ; +} + +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" }, +]; diff --git a/surfsense_web/components/ui/code-node.tsx b/surfsense_web/components/ui/code-node.tsx new file mode 100644 index 000000000..d68cbd4ec --- /dev/null +++ b/surfsense_web/components/ui/code-node.tsx @@ -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 ( + + {props.children} + + ); +} diff --git a/surfsense_web/components/ui/dropdown-menu.tsx b/surfsense_web/components/ui/dropdown-menu.tsx index a9df1a5b2..723a9466e 100644 --- a/surfsense_web/components/ui/dropdown-menu.tsx +++ b/surfsense_web/components/ui/dropdown-menu.tsx @@ -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({ {children} - + ); } @@ -201,7 +201,7 @@ function DropdownMenuSubContent({ & VariantProps) { + return ( + + ); +} + +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; + +export const Editor = ({ + className, + disabled, + focused, + variant, + ref, + ...props +}: EditorProps & { ref?: React.RefObject }) => ( + +); + +Editor.displayName = "Editor"; + +export function EditorView({ + className, + variant, + ...props +}: PlateViewProps & VariantProps) { + return ; +} + +EditorView.displayName = "EditorView"; diff --git a/surfsense_web/components/ui/equation-node.tsx b/surfsense_web/components/ui/equation-node.tsx new file mode 100644 index 000000000..34c3ac329 --- /dev/null +++ b/surfsense_web/components/ui/equation-node.tsx @@ -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) { + const element = props.element; + const selected = useSelected(); + const katexRef = React.useRef(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 ( + +
    setIsEditing(true)} + > + {element.texExpression ? ( +
    + ) : ( +
    + + Add an equation +
    + )} +
    + + {isEditing && ( +
    +