mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-16 21:05:20 +02:00
Merge pull request #821 from AnishSarkar22/fix/ui
feat: introduce platejs and remove blocknote editor
This commit is contained in:
commit
bad114734a
94 changed files with 8006 additions and 24218 deletions
|
|
@ -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")
|
||||
|
|
@ -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",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
291
surfsense_backend/app/utils/blocknote_to_markdown.py
Normal file
291
surfsense_backend/app/utils/blocknote_to_markdown.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"""Pure-Python converter: BlockNote JSON → Markdown.
|
||||
|
||||
No external dependencies (no Node.js, no npm packages, no HTTP calls).
|
||||
Handles all standard BlockNote block types. Produces output equivalent to
|
||||
BlockNote's own ``blocksToMarkdownLossy()``.
|
||||
|
||||
Usage:
|
||||
from app.utils.blocknote_to_markdown import blocknote_to_markdown
|
||||
|
||||
markdown = blocknote_to_markdown(blocknote_json)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline content → markdown text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_inline_content(content: list[dict[str, Any]] | None) -> str:
|
||||
"""Convert BlockNote inline content array to a markdown string."""
|
||||
if not content:
|
||||
return ""
|
||||
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
item_type = item.get("type", "text")
|
||||
|
||||
if item_type == "text":
|
||||
text = item.get("text", "")
|
||||
styles: dict[str, Any] = item.get("styles", {})
|
||||
|
||||
# Apply inline styles (order: code first so nested marks don't break it)
|
||||
if styles.get("code"):
|
||||
text = f"`{text}`"
|
||||
else:
|
||||
if styles.get("bold"):
|
||||
text = f"**{text}**"
|
||||
if styles.get("italic"):
|
||||
text = f"*{text}*"
|
||||
if styles.get("strikethrough"):
|
||||
text = f"~~{text}~~"
|
||||
# underline has no markdown equivalent — keep as plain text (lossy)
|
||||
|
||||
parts.append(text)
|
||||
|
||||
elif item_type == "link":
|
||||
href = item.get("href", "")
|
||||
link_content = item.get("content", [])
|
||||
link_text = _render_inline_content(link_content) if link_content else href
|
||||
parts.append(f"[{link_text}]({href})")
|
||||
|
||||
else:
|
||||
# Unknown inline type — extract text if possible
|
||||
text = item.get("text", "")
|
||||
if text:
|
||||
parts.append(text)
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Block → markdown lines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_block(
|
||||
block: dict[str, Any], indent: int = 0, numbered_list_counter: int = 0
|
||||
) -> tuple[list[str], int]:
|
||||
"""Convert a single BlockNote block (and its children) to markdown lines.
|
||||
|
||||
Args:
|
||||
block: A BlockNote block dict.
|
||||
indent: Current indentation level (for nested children).
|
||||
numbered_list_counter: Current counter for consecutive numbered list items.
|
||||
|
||||
Returns:
|
||||
A tuple of (list of markdown lines without trailing newlines,
|
||||
updated numbered_list_counter).
|
||||
"""
|
||||
block_type = block.get("type", "paragraph")
|
||||
props: dict[str, Any] = block.get("props", {})
|
||||
content = block.get("content")
|
||||
children: list[dict[str, Any]] = block.get("children", [])
|
||||
prefix = " " * indent # 2-space indent per nesting level
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
# --- Block type handlers ---
|
||||
|
||||
if block_type == "paragraph":
|
||||
text = _render_inline_content(content) if content else ""
|
||||
lines.append(f"{prefix}{text}")
|
||||
|
||||
elif block_type == "heading":
|
||||
level = props.get("level", 1)
|
||||
hashes = "#" * min(max(level, 1), 6)
|
||||
text = _render_inline_content(content) if content else ""
|
||||
lines.append(f"{prefix}{hashes} {text}")
|
||||
|
||||
elif block_type == "bulletListItem":
|
||||
text = _render_inline_content(content) if content else ""
|
||||
lines.append(f"{prefix}- {text}")
|
||||
|
||||
elif block_type == "numberedListItem":
|
||||
# Use props.start if present, otherwise increment counter
|
||||
start = props.get("start")
|
||||
if start is not None:
|
||||
numbered_list_counter = int(start)
|
||||
else:
|
||||
numbered_list_counter += 1
|
||||
text = _render_inline_content(content) if content else ""
|
||||
lines.append(f"{prefix}{numbered_list_counter}. {text}")
|
||||
|
||||
elif block_type == "checkListItem":
|
||||
checked = props.get("checked", False)
|
||||
marker = "[x]" if checked else "[ ]"
|
||||
text = _render_inline_content(content) if content else ""
|
||||
lines.append(f"{prefix}- {marker} {text}")
|
||||
|
||||
elif block_type == "codeBlock":
|
||||
language = props.get("language", "")
|
||||
# Code blocks store content as a single text item
|
||||
code_text = _render_inline_content(content) if content else ""
|
||||
lines.append(f"{prefix}```{language}")
|
||||
for code_line in code_text.split("\n"):
|
||||
lines.append(f"{prefix}{code_line}")
|
||||
lines.append(f"{prefix}```")
|
||||
|
||||
elif block_type == "table":
|
||||
# Table content is a nested structure: content.rows[].cells[][]
|
||||
table_content = block.get("content", {})
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
if isinstance(table_content, dict):
|
||||
rows = table_content.get("rows", [])
|
||||
elif isinstance(table_content, list):
|
||||
# Some versions store rows directly as a list
|
||||
rows = table_content
|
||||
|
||||
if rows:
|
||||
for row_idx, row in enumerate(rows):
|
||||
cells = row.get("cells", []) if isinstance(row, dict) else row
|
||||
cell_texts: list[str] = []
|
||||
for cell in cells:
|
||||
if isinstance(cell, list):
|
||||
# Cell is a list of inline content
|
||||
cell_texts.append(_render_inline_content(cell))
|
||||
elif isinstance(cell, dict):
|
||||
# Cell is a tableCell object with its own content
|
||||
cell_content = cell.get("content")
|
||||
if isinstance(cell_content, list):
|
||||
cell_texts.append(_render_inline_content(cell_content))
|
||||
else:
|
||||
cell_texts.append("")
|
||||
elif isinstance(cell, str):
|
||||
cell_texts.append(cell)
|
||||
else:
|
||||
cell_texts.append(str(cell))
|
||||
lines.append(f"{prefix}| {' | '.join(cell_texts)} |")
|
||||
# Add header separator after first row
|
||||
if row_idx == 0:
|
||||
lines.append(f"{prefix}| {' | '.join('---' for _ in cell_texts)} |")
|
||||
|
||||
elif block_type == "image":
|
||||
url = props.get("url", "")
|
||||
caption = props.get("caption", "") or props.get("name", "")
|
||||
if url:
|
||||
lines.append(f"{prefix}")
|
||||
|
||||
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
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { MoreHorizontal, PenLine, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -115,7 +115,7 @@ export function RowActions({
|
|||
isEditDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
|
||||
}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
{shouldShowDelete && (
|
||||
|
|
@ -170,7 +170,7 @@ export function RowActions({
|
|||
isEditDisabled ? "text-muted-foreground cursor-not-allowed opacity-50" : ""
|
||||
}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
{shouldShowDelete && (
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { AlertCircle, ArrowLeft, FileText, Save } from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft, FileText } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
|
||||
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -20,58 +20,53 @@ import {
|
|||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { notesApiService } from "@/lib/apis/notes-api.service";
|
||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
// BlockNote types
|
||||
type BlockNoteInlineContent =
|
||||
| string
|
||||
| { text?: string; type?: string; styles?: Record<string, unknown> };
|
||||
|
||||
interface BlockNoteBlock {
|
||||
type: string;
|
||||
content?: BlockNoteInlineContent[];
|
||||
children?: BlockNoteBlock[];
|
||||
props?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type BlockNoteDocument = BlockNoteBlock[] | null | undefined;
|
||||
// Dynamically import PlateEditor (uses 'use client' internally)
|
||||
const PlateEditor = dynamic(
|
||||
() => import("@/components/editor/plate-editor").then((mod) => ({ default: mod.PlateEditor })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="mx-auto w-full max-w-[900px] px-6 md:px-12 pt-10 space-y-4">
|
||||
<Skeleton className="h-8 w-3/5 rounded" />
|
||||
<div className="space-y-3 pt-4">
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-4 w-4/5 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3 pt-3">
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-4 w-5/6 rounded" />
|
||||
<Skeleton className="h-4 w-3/4 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3 pt-3">
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-4 w-2/3 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
interface EditorContent {
|
||||
document_id: number;
|
||||
title: string;
|
||||
document_type?: string;
|
||||
blocknote_document: BlockNoteDocument;
|
||||
source_markdown: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
// Helper function to extract title from BlockNote document
|
||||
// Takes the text content from the first block (should be a heading for notes)
|
||||
function extractTitleFromBlockNote(blocknoteDocument: BlockNoteDocument): string {
|
||||
if (!blocknoteDocument || !Array.isArray(blocknoteDocument) || blocknoteDocument.length === 0) {
|
||||
return "Untitled";
|
||||
/** Extract title from markdown: first # heading, or first non-empty line. */
|
||||
function extractTitleFromMarkdown(markdown: string | null | undefined): string {
|
||||
if (!markdown) return "Untitled";
|
||||
for (const line of markdown.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("# ")) return trimmed.slice(2).trim() || "Untitled";
|
||||
if (trimmed) return trimmed.slice(0, 100);
|
||||
}
|
||||
|
||||
const firstBlock = blocknoteDocument[0];
|
||||
if (!firstBlock) {
|
||||
return "Untitled";
|
||||
}
|
||||
|
||||
// Extract text from block content
|
||||
// BlockNote blocks have a content array with inline content
|
||||
if (firstBlock.content && Array.isArray(firstBlock.content)) {
|
||||
const textContent = firstBlock.content
|
||||
.map((item: BlockNoteInlineContent) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (typeof item === "object" && item?.text) return item.text;
|
||||
return "";
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
return textContent || "Untitled";
|
||||
}
|
||||
|
||||
return "Untitled";
|
||||
}
|
||||
|
||||
|
|
@ -85,11 +80,14 @@ export default function EditorPage() {
|
|||
const [document, setDocument] = useState<EditorContent | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editorContent, setEditorContent] = useState<BlockNoteDocument>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
||||
|
||||
// Store the latest markdown from the editor
|
||||
const markdownRef = useRef<string>("");
|
||||
const initialLoadDone = useRef(false);
|
||||
|
||||
// Global state for cross-component communication
|
||||
const [, setGlobalHasUnsavedChanges] = useAtom(hasUnsavedEditorChangesAtom);
|
||||
const [pendingNavigation, setPendingNavigation] = useAtom(pendingEditorNavigationAtom);
|
||||
|
|
@ -107,51 +105,46 @@ export default function EditorPage() {
|
|||
};
|
||||
}, [setGlobalHasUnsavedChanges, setPendingNavigation]);
|
||||
|
||||
// Handle pending navigation from sidebar (e.g., when user clicks "+" to create new note)
|
||||
// Handle pending navigation from sidebar
|
||||
useEffect(() => {
|
||||
if (pendingNavigation) {
|
||||
if (hasUnsavedChanges) {
|
||||
// Show dialog to confirm navigation
|
||||
setShowUnsavedDialog(true);
|
||||
} else {
|
||||
// No unsaved changes, navigate immediately
|
||||
router.push(pendingNavigation);
|
||||
setPendingNavigation(null);
|
||||
}
|
||||
}
|
||||
}, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]);
|
||||
|
||||
// Reset state when documentId changes (e.g., navigating from existing note to new note)
|
||||
// Reset state when documentId changes
|
||||
useEffect(() => {
|
||||
setDocument(null);
|
||||
setEditorContent(null);
|
||||
setError(null);
|
||||
setHasUnsavedChanges(false);
|
||||
setLoading(true);
|
||||
}, []);
|
||||
initialLoadDone.current = false;
|
||||
}, [documentId]);
|
||||
|
||||
// Fetch document content - DIRECT CALL TO FASTAPI
|
||||
// Skip fetching if this is a new note
|
||||
// Fetch document content
|
||||
useEffect(() => {
|
||||
async function fetchDocument() {
|
||||
// For new notes, initialize with empty state
|
||||
if (isNewNote) {
|
||||
markdownRef.current = "";
|
||||
setDocument({
|
||||
document_id: 0,
|
||||
title: "Untitled",
|
||||
document_type: "NOTE",
|
||||
blocknote_document: null,
|
||||
source_markdown: "",
|
||||
updated_at: null,
|
||||
});
|
||||
setEditorContent(null);
|
||||
setLoading(false);
|
||||
initialLoadDone.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
console.error("No auth token found");
|
||||
// Redirect to login with current path saved
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
|
@ -166,29 +159,28 @@ export default function EditorPage() {
|
|||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to fetch document" }));
|
||||
const errorMessage = errorData.detail || "Failed to fetch document";
|
||||
throw new Error(errorMessage);
|
||||
throw new Error(errorData.detail || "Failed to fetch document");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check if blocknote_document exists
|
||||
if (!data.blocknote_document) {
|
||||
const errorMsg =
|
||||
"This document does not have BlockNote content. Please re-upload the document to enable editing.";
|
||||
setError(errorMsg);
|
||||
if (data.source_markdown === undefined || data.source_markdown === null) {
|
||||
setError(
|
||||
"This document does not have editable content. Please re-upload to enable editing."
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
markdownRef.current = data.source_markdown;
|
||||
setDocument(data);
|
||||
setEditorContent(data.blocknote_document);
|
||||
setError(null);
|
||||
initialLoadDone.current = true;
|
||||
} catch (error) {
|
||||
console.error("Error fetching document:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to fetch document. Please try again.";
|
||||
setError(errorMessage);
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Failed to fetch document. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -199,29 +191,27 @@ export default function EditorPage() {
|
|||
}
|
||||
}, [documentId, params.search_space_id, isNewNote]);
|
||||
|
||||
// Track changes to mark as unsaved
|
||||
useEffect(() => {
|
||||
if (editorContent && document) {
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}, [editorContent, document]);
|
||||
|
||||
// Check if this is a NOTE type document
|
||||
const isNote = isNewNote || document?.document_type === "NOTE";
|
||||
|
||||
// Extract title dynamically from editor content for notes, otherwise use document title
|
||||
// Extract title dynamically from current markdown for notes
|
||||
const displayTitle = useMemo(() => {
|
||||
if (isNote && editorContent) {
|
||||
return extractTitleFromBlockNote(editorContent);
|
||||
if (isNote) {
|
||||
return extractTitleFromMarkdown(markdownRef.current || document?.source_markdown);
|
||||
}
|
||||
return document?.title || "Untitled";
|
||||
}, [isNote, editorContent, document?.title]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNote, document?.title, document?.source_markdown, hasUnsavedChanges]);
|
||||
|
||||
// TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI
|
||||
// Handle markdown changes from the Plate editor
|
||||
const handleMarkdownChange = useCallback((md: string) => {
|
||||
markdownRef.current = md;
|
||||
if (initialLoadDone.current) {
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save and exit - DIRECT CALL TO FASTAPI
|
||||
// For new notes, create the note first, then save
|
||||
const handleSave = async () => {
|
||||
// Save handler
|
||||
const handleSave = useCallback(async () => {
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
toast.error("Please login to save");
|
||||
|
|
@ -233,25 +223,26 @@ export default function EditorPage() {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
// If this is a new note, create it first
|
||||
if (isNewNote) {
|
||||
const title = extractTitleFromBlockNote(editorContent);
|
||||
const currentMarkdown = markdownRef.current;
|
||||
|
||||
// Create the note first
|
||||
if (isNewNote) {
|
||||
const title = extractTitleFromMarkdown(currentMarkdown);
|
||||
|
||||
// Create the note
|
||||
const note = await notesApiService.createNote({
|
||||
search_space_id: searchSpaceId,
|
||||
title: title,
|
||||
blocknote_document: editorContent || undefined,
|
||||
title,
|
||||
source_markdown: currentMarkdown || undefined,
|
||||
});
|
||||
|
||||
// If there's content, save it properly and trigger reindexing
|
||||
if (editorContent) {
|
||||
// If there's content, save & trigger reindexing
|
||||
if (currentMarkdown) {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${note.id}/save`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ blocknote_document: editorContent }),
|
||||
body: JSON.stringify({ source_markdown: currentMarkdown }),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -265,24 +256,15 @@ export default function EditorPage() {
|
|||
|
||||
setHasUnsavedChanges(false);
|
||||
toast.success("Note created successfully! Reindexing in background...");
|
||||
|
||||
// Redirect to documents page after successful save
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
} else {
|
||||
// Existing document - save normally
|
||||
if (!editorContent) {
|
||||
toast.error("No content to save");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save blocknote_document and trigger reindexing in background
|
||||
// Existing document — save
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ blocknote_document: editorContent }),
|
||||
body: JSON.stringify({ source_markdown: currentMarkdown }),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -295,8 +277,6 @@ export default function EditorPage() {
|
|||
|
||||
setHasUnsavedChanges(false);
|
||||
toast.success("Document saved! Reindexing in background...");
|
||||
|
||||
// Redirect to documents page after successful save
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -312,7 +292,7 @@ export default function EditorPage() {
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
}, [isNewNote, searchSpaceId, documentId, params.search_space_id, router]);
|
||||
|
||||
const handleBack = () => {
|
||||
if (hasUnsavedChanges) {
|
||||
|
|
@ -324,11 +304,9 @@ export default function EditorPage() {
|
|||
|
||||
const handleConfirmLeave = () => {
|
||||
setShowUnsavedDialog(false);
|
||||
// Clear global unsaved state
|
||||
setGlobalHasUnsavedChanges(false);
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// If there's a pending navigation (from sidebar), use that; otherwise go back to documents
|
||||
if (pendingNavigation) {
|
||||
router.push(pendingNavigation);
|
||||
setPendingNavigation(null);
|
||||
|
|
@ -337,26 +315,67 @@ export default function EditorPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSaveAndLeave = async () => {
|
||||
setShowUnsavedDialog(false);
|
||||
setPendingNavigation(null);
|
||||
await handleSave();
|
||||
};
|
||||
|
||||
const handleCancelLeave = () => {
|
||||
setShowUnsavedDialog(false);
|
||||
// Clear pending navigation if user cancels
|
||||
setPendingNavigation(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Spinner size="xl" className="text-primary mb-4" />
|
||||
<p className="text-muted-foreground">Loading editor</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col h-screen w-full overflow-hidden">
|
||||
{/* Top bar skeleton — real back button & file icon, skeleton title */}
|
||||
<div className="flex h-14 md:h-16 shrink-0 items-center border-b bg-background pl-1.5 pr-3 md:pl-3 md:pr-6">
|
||||
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/documents`)}
|
||||
className="h-7 w-7 shrink-0 p-0"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Back</span>
|
||||
</Button>
|
||||
<FileText className="h-4 w-4 md:h-5 md:w-5 text-muted-foreground shrink-0" />
|
||||
<Skeleton className="h-5 w-40 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed toolbar placeholder — matches real toolbar styling */}
|
||||
<div className="sticky top-0 left-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 h-10" />
|
||||
|
||||
{/* Content area skeleton — mimics document text lines */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<div className="mx-auto w-full max-w-[900px] px-6 md:px-12 pt-10 space-y-4">
|
||||
{/* Title-like line */}
|
||||
<Skeleton className="h-8 w-3/5 rounded" />
|
||||
{/* Paragraph lines */}
|
||||
<div className="space-y-3 pt-4">
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-4 w-4/5 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3 pt-3">
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-4 w-5/6 rounded" />
|
||||
<Skeleton className="h-4 w-3/4 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3 pt-3">
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-4 w-2/3 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error && !document) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
||||
<motion.div
|
||||
|
|
@ -405,11 +424,21 @@ export default function EditorPage() {
|
|||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col min-h-screen w-full"
|
||||
className="flex flex-col h-screen w-full overflow-hidden"
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="sticky top-0 z-40 flex h-14 md:h-16 shrink-0 items-center gap-2 md:gap-4 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-3 md:px-6">
|
||||
<div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0">
|
||||
<div className="flex h-14 md:h-16 shrink-0 items-center border-b bg-background pl-1.5 pr-3 md:pl-3 md:pr-6">
|
||||
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
disabled={saving}
|
||||
className="h-7 w-7 shrink-0 p-0"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Back</span>
|
||||
</Button>
|
||||
<FileText className="h-4 w-4 md:h-5 md:w-5 text-muted-foreground shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<h1 className="text-base md:text-lg font-semibold truncate">{displayTitle}</h1>
|
||||
|
|
@ -418,60 +447,33 @@ export default function EditorPage() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
disabled={saving}
|
||||
className="gap-1 md:gap-2 px-2 md:px-4 h-8 md:h-10"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
<span className="text-xs md:text-sm">Back</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="gap-1 md:gap-2 px-2 md:px-4 h-8 md:h-10"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Spinner size="sm" className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
<span className="text-xs md:text-sm">{isNewNote ? "Creating" : "Saving"}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
<span className="text-xs md:text-sm">Save</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Container */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden relative">
|
||||
<div className="h-full w-full overflow-auto p-3 md:p-6">
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 max-w-4xl mx-auto"
|
||||
>
|
||||
<div className="flex items-center gap-2 p-4 rounded-lg border border-destructive/50 bg-destructive/10 text-destructive">
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<BlockNoteEditor
|
||||
key={documentId} // Force re-mount when document changes
|
||||
initialContent={isNewNote ? undefined : editorContent}
|
||||
onChange={setEditorContent}
|
||||
useTitleBlock={isNote}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden relative">
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="px-3 md:px-6 pt-3 md:pt-6"
|
||||
>
|
||||
<div className="flex items-center gap-2 p-4 rounded-lg border border-destructive/50 bg-destructive/10 text-destructive max-w-4xl mx-auto">
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<PlateEditor
|
||||
key={documentId}
|
||||
preset="full"
|
||||
markdown={document?.source_markdown ?? ""}
|
||||
onMarkdownChange={handleMarkdownChange}
|
||||
onSave={handleSave}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
isSaving={saving}
|
||||
defaultEditing={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -491,7 +493,13 @@ export default function EditorPage() {
|
|||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancelLeave}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmLeave}>OK</AlertDialogAction>
|
||||
<AlertDialogAction onClick={handleSaveAndLeave}>Save</AlertDialogAction>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmLeave}
|
||||
className="border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Leave without saving
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,213 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import "@blocknote/core/fonts/inter.css";
|
||||
import "@blocknote/mantine/style.css";
|
||||
import { BlockNoteView } from "@blocknote/mantine";
|
||||
import { useCreateBlockNote } from "@blocknote/react";
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
initialContent?: any;
|
||||
onChange?: (content: any) => void;
|
||||
useTitleBlock?: boolean; // Whether to use first block as title (Notion-style)
|
||||
}
|
||||
|
||||
// Helper to ensure first block is a heading for title
|
||||
function ensureTitleBlock(content: any[] | undefined): any[] {
|
||||
if (!content || content.length === 0) {
|
||||
// Return empty heading block for new notes
|
||||
return [
|
||||
{
|
||||
type: "heading",
|
||||
props: { level: 1 },
|
||||
content: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// If first block is not a heading, convert it to one
|
||||
const firstBlock = content[0];
|
||||
if (firstBlock?.type !== "heading") {
|
||||
// Extract text from first block
|
||||
let titleText = "";
|
||||
if (firstBlock?.content && Array.isArray(firstBlock.content)) {
|
||||
titleText = firstBlock.content
|
||||
.map((item: any) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (item?.text) return item.text;
|
||||
return "";
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Create heading block with extracted text
|
||||
const titleBlock = {
|
||||
type: "heading",
|
||||
props: { level: 1 },
|
||||
content: titleText
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: titleText,
|
||||
styles: {},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
children: [],
|
||||
};
|
||||
|
||||
// Replace first block with heading, keep rest
|
||||
return [titleBlock, ...content.slice(1)];
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export default function BlockNoteEditor({
|
||||
initialContent,
|
||||
onChange,
|
||||
useTitleBlock = false,
|
||||
}: BlockNoteEditorProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
// Track the initial content to prevent re-initialization
|
||||
const initialContentRef = useRef<any>(null);
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
// Prepare initial content - ensure first block is a heading if useTitleBlock is true
|
||||
const preparedInitialContent = useMemo(() => {
|
||||
if (initialContentRef.current !== null) {
|
||||
return undefined; // Already initialized
|
||||
}
|
||||
if (initialContent === undefined) {
|
||||
// New note - create empty heading block
|
||||
return useTitleBlock
|
||||
? [
|
||||
{
|
||||
type: "heading",
|
||||
props: { level: 1 },
|
||||
content: [],
|
||||
children: [],
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
}
|
||||
// Existing note - ensure first block is heading
|
||||
return useTitleBlock ? ensureTitleBlock(initialContent) : initialContent;
|
||||
}, [initialContent, useTitleBlock]);
|
||||
|
||||
// Creates a new editor instance - only use initialContent on first render
|
||||
const editor = useCreateBlockNote({
|
||||
initialContent: initialContentRef.current === null ? preparedInitialContent : undefined,
|
||||
});
|
||||
|
||||
// Store initial content on first render only
|
||||
useEffect(() => {
|
||||
if (preparedInitialContent !== undefined && initialContentRef.current === null) {
|
||||
initialContentRef.current = preparedInitialContent;
|
||||
isInitializedRef.current = true;
|
||||
} else if (preparedInitialContent === undefined && initialContentRef.current === null) {
|
||||
// Mark as initialized even when initialContent is undefined (for new notes)
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
}, [preparedInitialContent]);
|
||||
|
||||
// Call onChange when document changes (but don't update from props)
|
||||
useEffect(() => {
|
||||
if (!onChange || !editor) return;
|
||||
|
||||
// For new notes (no initialContent), we need to wait for editor to be ready
|
||||
// Use a small delay to ensure editor is fully initialized
|
||||
if (!isInitializedRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
isInitializedRef.current = true;
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
const handleChange = () => {
|
||||
onChange(editor.document);
|
||||
};
|
||||
|
||||
// Subscribe to document changes
|
||||
const unsubscribe = editor.onChange(handleChange);
|
||||
|
||||
// Also call onChange once with current document to capture initial state
|
||||
// This ensures we capture content even if user doesn't make changes
|
||||
if (editor.document) {
|
||||
onChange(editor.document);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [editor, onChange]);
|
||||
|
||||
// Determine theme for BlockNote with custom dark mode background
|
||||
const blockNoteTheme = useMemo(() => {
|
||||
if (resolvedTheme === "dark") {
|
||||
// Custom dark theme - only override editor background, let BlockNote handle the rest
|
||||
return {
|
||||
colors: {
|
||||
editor: {
|
||||
background: "#0A0A0A", // Custom dark background
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return "light" as const;
|
||||
}, [resolvedTheme]);
|
||||
|
||||
// Renders the editor instance
|
||||
return (
|
||||
<div className="bn-container">
|
||||
<style>{`
|
||||
@media (max-width: 640px) {
|
||||
.bn-container .bn-editor {
|
||||
padding-inline: 12px !important;
|
||||
}
|
||||
|
||||
/* Heading Level 1 (Title) */
|
||||
.bn-container [data-content-type="heading"][data-level="1"] {
|
||||
font-size: 1.75rem !important;
|
||||
line-height: 1.2 !important;
|
||||
margin-top: 1rem !important;
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* Heading Level 2 */
|
||||
.bn-container [data-content-type="heading"][data-level="2"] {
|
||||
font-size: 1.5rem !important;
|
||||
line-height: 1.2 !important;
|
||||
margin-top: 0.875rem !important;
|
||||
margin-bottom: 0.375rem !important;
|
||||
}
|
||||
|
||||
/* Heading Level 3 */
|
||||
.bn-container [data-content-type="heading"][data-level="3"] {
|
||||
font-size: 1.25rem !important;
|
||||
line-height: 1.2 !important;
|
||||
margin-top: 0.75rem !important;
|
||||
margin-bottom: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* Paragraphs and regular content */
|
||||
.bn-container .bn-block-content {
|
||||
font-size: 0.9375rem !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
|
||||
/* Adjust lists */
|
||||
.bn-container ul,
|
||||
.bn-container ol {
|
||||
padding-left: 1.25rem !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<BlockNoteView editor={editor} theme={blockNoteTheme} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
24
surfsense_web/components/editor/editor-save-context.tsx
Normal file
24
surfsense_web/components/editor/editor-save-context.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface EditorSaveContextValue {
|
||||
/** Callback to save the current editor content */
|
||||
onSave?: () => void;
|
||||
/** Whether there are unsaved changes */
|
||||
hasUnsavedChanges: boolean;
|
||||
/** Whether a save operation is in progress */
|
||||
isSaving: boolean;
|
||||
/** Whether the user can toggle between editing and viewing modes */
|
||||
canToggleMode: boolean;
|
||||
}
|
||||
|
||||
export const EditorSaveContext = createContext<EditorSaveContextValue>({
|
||||
hasUnsavedChanges: false,
|
||||
isSaving: false,
|
||||
canToggleMode: false,
|
||||
});
|
||||
|
||||
export function useEditorSave() {
|
||||
return useContext(EditorSaveContext);
|
||||
}
|
||||
174
surfsense_web/components/editor/plate-editor.tsx
Normal file
174
surfsense_web/components/editor/plate-editor.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { MarkdownPlugin, remarkMdx } from "@platejs/markdown";
|
||||
import type { AnyPluginConfig } from "platejs";
|
||||
import { createPlatePlugin, Key, Plate, usePlateEditor } from "platejs/react";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
|
||||
import { type EditorPreset, presetMap } from "@/components/editor/presets";
|
||||
import { Editor, EditorContainer } from "@/components/ui/editor";
|
||||
import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
|
||||
import { EditorSaveContext } from "@/components/editor/editor-save-context";
|
||||
|
||||
export interface PlateEditorProps {
|
||||
/** Markdown string to load as initial content */
|
||||
markdown?: string;
|
||||
/** Called when the editor content changes, with serialized markdown */
|
||||
onMarkdownChange?: (markdown: string) => void;
|
||||
/**
|
||||
* Force permanent read-only mode (e.g. public/shared view).
|
||||
* When true, the editor cannot be toggled to editing mode.
|
||||
* When false (default), the editor starts in viewing mode but
|
||||
* the user can switch to editing via the mode toolbar button.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Editor container variant */
|
||||
variant?: "default" | "demo" | "comment" | "select";
|
||||
/** Editor text variant */
|
||||
editorVariant?: "default" | "demo" | "fullWidth" | "none";
|
||||
/** Additional className for the container */
|
||||
className?: string;
|
||||
/** Save callback. When provided, ⌘+S / Ctrl+S shortcut is registered and save button appears. */
|
||||
onSave?: () => void;
|
||||
/** Whether there are unsaved changes */
|
||||
hasUnsavedChanges?: boolean;
|
||||
/** Whether a save is in progress */
|
||||
isSaving?: boolean;
|
||||
/** Start the editor in editing mode instead of viewing mode. Ignored when readOnly is true. */
|
||||
defaultEditing?: boolean;
|
||||
/**
|
||||
* Plugin preset to use. Controls which plugin kits are loaded.
|
||||
* - "full" – all plugins (toolbars, slash commands, DnD, etc.)
|
||||
* - "minimal" – core formatting only (no fixed toolbar, slash commands, DnD, block selection)
|
||||
* - "readonly" – rendering support for all rich content, no editing UI
|
||||
* @default "full"
|
||||
*/
|
||||
preset?: EditorPreset;
|
||||
/**
|
||||
* Additional plugins to append after the preset plugins.
|
||||
* Use this to inject feature-specific plugins (e.g. approve/reject blocks)
|
||||
* without modifying the core editor component.
|
||||
*/
|
||||
extraPlugins?: AnyPluginConfig[];
|
||||
}
|
||||
|
||||
export function PlateEditor({
|
||||
markdown,
|
||||
onMarkdownChange,
|
||||
readOnly = false,
|
||||
placeholder = "Type...",
|
||||
variant = "default",
|
||||
editorVariant = "default",
|
||||
className,
|
||||
onSave,
|
||||
hasUnsavedChanges = false,
|
||||
isSaving = false,
|
||||
defaultEditing = false,
|
||||
preset = "full",
|
||||
extraPlugins = [],
|
||||
}: PlateEditorProps) {
|
||||
const lastMarkdownRef = useRef(markdown);
|
||||
|
||||
// Keep a stable ref to the latest onSave callback so the plugin shortcut
|
||||
// always calls the most recent version without re-creating the editor.
|
||||
const onSaveRef = useRef(onSave);
|
||||
useEffect(() => {
|
||||
onSaveRef.current = onSave;
|
||||
}, [onSave]);
|
||||
|
||||
// Stable Plate plugin for ⌘+S / Ctrl+S save shortcut.
|
||||
// Only included when onSave is provided.
|
||||
const SaveShortcutPlugin = useMemo(
|
||||
() =>
|
||||
createPlatePlugin({
|
||||
key: "save-shortcut",
|
||||
shortcuts: {
|
||||
save: {
|
||||
keys: [[Key.Mod, "s"]],
|
||||
handler: () => {
|
||||
onSaveRef.current?.();
|
||||
},
|
||||
preventDefault: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Resolve the plugin set from the chosen preset
|
||||
const presetPlugins = presetMap[preset];
|
||||
|
||||
// When readOnly is forced, always start in readOnly.
|
||||
// Otherwise, respect defaultEditing to decide initial mode.
|
||||
// The user can still toggle between editing/viewing via ModeToolbarButton.
|
||||
const editor = usePlateEditor({
|
||||
readOnly: readOnly || !defaultEditing,
|
||||
plugins: [
|
||||
...presetPlugins,
|
||||
// Only register save shortcut when a save handler is provided
|
||||
...(onSave ? [SaveShortcutPlugin] : []),
|
||||
// Consumer-provided extra plugins
|
||||
...extraPlugins,
|
||||
MarkdownPlugin.configure({
|
||||
options: {
|
||||
remarkPlugins: [remarkGfm, remarkMath, remarkMdx],
|
||||
},
|
||||
}),
|
||||
],
|
||||
// Use markdown deserialization for initial value if provided
|
||||
value: markdown
|
||||
? (editor) =>
|
||||
editor.getApi(MarkdownPlugin).markdown.deserialize(escapeMdxExpressions(markdown))
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Update editor content when markdown prop changes externally
|
||||
// (e.g., version switching in report panel)
|
||||
useEffect(() => {
|
||||
if (markdown !== undefined && markdown !== lastMarkdownRef.current) {
|
||||
lastMarkdownRef.current = markdown;
|
||||
const newValue = editor
|
||||
.getApi(MarkdownPlugin)
|
||||
.markdown.deserialize(escapeMdxExpressions(markdown));
|
||||
editor.tf.reset();
|
||||
editor.tf.setValue(newValue);
|
||||
}
|
||||
}, [markdown, editor]);
|
||||
|
||||
// When not forced read-only, the user can toggle between editing/viewing.
|
||||
const canToggleMode = !readOnly;
|
||||
|
||||
return (
|
||||
<EditorSaveContext.Provider
|
||||
value={{
|
||||
onSave,
|
||||
hasUnsavedChanges,
|
||||
isSaving,
|
||||
canToggleMode,
|
||||
}}
|
||||
>
|
||||
<Plate
|
||||
editor={editor}
|
||||
// Only pass readOnly as a controlled prop when forced (permanently read-only).
|
||||
// For non-forced mode, the Plate store manages readOnly internally
|
||||
// (initialized to true via usePlateEditor, toggled via ModeToolbarButton).
|
||||
{...(readOnly ? { readOnly: true } : {})}
|
||||
onChange={({ value }) => {
|
||||
if (onMarkdownChange) {
|
||||
const md = editor.getApi(MarkdownPlugin).markdown.serialize({ value });
|
||||
lastMarkdownRef.current = md;
|
||||
onMarkdownChange(md);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EditorContainer variant={variant} className={className}>
|
||||
<Editor variant={editorVariant} placeholder={placeholder} />
|
||||
</EditorContainer>
|
||||
</Plate>
|
||||
</EditorSaveContext.Provider>
|
||||
);
|
||||
}
|
||||
237
surfsense_web/components/editor/plugins/autoformat-kit.tsx
Normal file
237
surfsense_web/components/editor/plugins/autoformat-kit.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"use client";
|
||||
|
||||
import type { AutoformatRule } from "@platejs/autoformat";
|
||||
|
||||
import {
|
||||
autoformatArrow,
|
||||
autoformatLegal,
|
||||
autoformatLegalHtml,
|
||||
autoformatMath,
|
||||
AutoformatPlugin,
|
||||
autoformatPunctuation,
|
||||
autoformatSmartQuotes,
|
||||
} from "@platejs/autoformat";
|
||||
import { insertEmptyCodeBlock } from "@platejs/code-block";
|
||||
import { toggleList } from "@platejs/list";
|
||||
import { openNextToggles } from "@platejs/toggle/react";
|
||||
import { KEYS } from "platejs";
|
||||
|
||||
const autoformatMarks: AutoformatRule[] = [
|
||||
{
|
||||
match: "***",
|
||||
mode: "mark",
|
||||
type: [KEYS.bold, KEYS.italic],
|
||||
},
|
||||
{
|
||||
match: "__*",
|
||||
mode: "mark",
|
||||
type: [KEYS.underline, KEYS.italic],
|
||||
},
|
||||
{
|
||||
match: "__**",
|
||||
mode: "mark",
|
||||
type: [KEYS.underline, KEYS.bold],
|
||||
},
|
||||
{
|
||||
match: "___***",
|
||||
mode: "mark",
|
||||
type: [KEYS.underline, KEYS.bold, KEYS.italic],
|
||||
},
|
||||
{
|
||||
match: "**",
|
||||
mode: "mark",
|
||||
type: KEYS.bold,
|
||||
},
|
||||
{
|
||||
match: "__",
|
||||
mode: "mark",
|
||||
type: KEYS.underline,
|
||||
},
|
||||
{
|
||||
match: "*",
|
||||
mode: "mark",
|
||||
type: KEYS.italic,
|
||||
},
|
||||
{
|
||||
match: "_",
|
||||
mode: "mark",
|
||||
type: KEYS.italic,
|
||||
},
|
||||
{
|
||||
match: "~~",
|
||||
mode: "mark",
|
||||
type: KEYS.strikethrough,
|
||||
},
|
||||
{
|
||||
match: "^",
|
||||
mode: "mark",
|
||||
type: KEYS.sup,
|
||||
},
|
||||
{
|
||||
match: "~",
|
||||
mode: "mark",
|
||||
type: KEYS.sub,
|
||||
},
|
||||
{
|
||||
match: "==",
|
||||
mode: "mark",
|
||||
type: KEYS.highlight,
|
||||
},
|
||||
{
|
||||
match: "≡",
|
||||
mode: "mark",
|
||||
type: KEYS.highlight,
|
||||
},
|
||||
{
|
||||
match: "`",
|
||||
mode: "mark",
|
||||
type: KEYS.code,
|
||||
},
|
||||
];
|
||||
|
||||
const autoformatBlocks: AutoformatRule[] = [
|
||||
{
|
||||
match: "# ",
|
||||
mode: "block",
|
||||
type: KEYS.h1,
|
||||
},
|
||||
{
|
||||
match: "## ",
|
||||
mode: "block",
|
||||
type: KEYS.h2,
|
||||
},
|
||||
{
|
||||
match: "### ",
|
||||
mode: "block",
|
||||
type: KEYS.h3,
|
||||
},
|
||||
{
|
||||
match: "#### ",
|
||||
mode: "block",
|
||||
type: KEYS.h4,
|
||||
},
|
||||
{
|
||||
match: "##### ",
|
||||
mode: "block",
|
||||
type: KEYS.h5,
|
||||
},
|
||||
{
|
||||
match: "###### ",
|
||||
mode: "block",
|
||||
type: KEYS.h6,
|
||||
},
|
||||
{
|
||||
match: "> ",
|
||||
mode: "block",
|
||||
type: KEYS.blockquote,
|
||||
},
|
||||
{
|
||||
match: "```",
|
||||
mode: "block",
|
||||
type: KEYS.codeBlock,
|
||||
format: (editor) => {
|
||||
insertEmptyCodeBlock(editor, {
|
||||
defaultType: KEYS.p,
|
||||
insertNodesOptions: { select: true },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
match: "+ ",
|
||||
mode: "block",
|
||||
preFormat: openNextToggles,
|
||||
type: KEYS.toggle,
|
||||
},
|
||||
{
|
||||
match: ["---", "—-", "___ "],
|
||||
mode: "block",
|
||||
type: KEYS.hr,
|
||||
format: (editor) => {
|
||||
editor.tf.setNodes({ type: KEYS.hr });
|
||||
editor.tf.insertNodes({
|
||||
children: [{ text: "" }],
|
||||
type: KEYS.p,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const autoformatLists: AutoformatRule[] = [
|
||||
{
|
||||
match: ["* ", "- "],
|
||||
mode: "block",
|
||||
type: "list",
|
||||
format: (editor) => {
|
||||
toggleList(editor, {
|
||||
listStyleType: KEYS.ul,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
match: [String.raw`^\d+\.$ `, String.raw`^\d+\)$ `],
|
||||
matchByRegex: true,
|
||||
mode: "block",
|
||||
type: "list",
|
||||
format: (editor, { matchString }) => {
|
||||
toggleList(editor, {
|
||||
listRestartPolite: Number(matchString) || 1,
|
||||
listStyleType: KEYS.ol,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
match: ["[] "],
|
||||
mode: "block",
|
||||
type: "list",
|
||||
format: (editor) => {
|
||||
toggleList(editor, {
|
||||
listStyleType: KEYS.listTodo,
|
||||
});
|
||||
editor.tf.setNodes({
|
||||
checked: false,
|
||||
listStyleType: KEYS.listTodo,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
match: ["[x] "],
|
||||
mode: "block",
|
||||
type: "list",
|
||||
format: (editor) => {
|
||||
toggleList(editor, {
|
||||
listStyleType: KEYS.listTodo,
|
||||
});
|
||||
editor.tf.setNodes({
|
||||
checked: true,
|
||||
listStyleType: KEYS.listTodo,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const AutoformatKit = [
|
||||
AutoformatPlugin.configure({
|
||||
options: {
|
||||
enableUndoOnDelete: true,
|
||||
rules: [
|
||||
...autoformatBlocks,
|
||||
...autoformatMarks,
|
||||
...autoformatSmartQuotes,
|
||||
...autoformatPunctuation,
|
||||
...autoformatLegal,
|
||||
...autoformatLegalHtml,
|
||||
...autoformatArrow,
|
||||
...autoformatMath,
|
||||
...autoformatLists,
|
||||
].map(
|
||||
(rule): AutoformatRule => ({
|
||||
...rule,
|
||||
query: (editor) =>
|
||||
!editor.api.some({
|
||||
match: { type: editor.getType(KEYS.codeBlock) },
|
||||
}),
|
||||
})
|
||||
),
|
||||
},
|
||||
}),
|
||||
];
|
||||
86
surfsense_web/components/editor/plugins/basic-blocks-kit.tsx
Normal file
86
surfsense_web/components/editor/plugins/basic-blocks-kit.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BlockquotePlugin,
|
||||
H1Plugin,
|
||||
H2Plugin,
|
||||
H3Plugin,
|
||||
H4Plugin,
|
||||
H5Plugin,
|
||||
H6Plugin,
|
||||
HorizontalRulePlugin,
|
||||
} from "@platejs/basic-nodes/react";
|
||||
import { ParagraphPlugin } from "platejs/react";
|
||||
|
||||
import { BlockquoteElement } from "@/components/ui/blockquote-node";
|
||||
import {
|
||||
H1Element,
|
||||
H2Element,
|
||||
H3Element,
|
||||
H4Element,
|
||||
H5Element,
|
||||
H6Element,
|
||||
} from "@/components/ui/heading-node";
|
||||
import { HrElement } from "@/components/ui/hr-node";
|
||||
import { ParagraphElement } from "@/components/ui/paragraph-node";
|
||||
|
||||
export const BasicBlocksKit = [
|
||||
ParagraphPlugin.withComponent(ParagraphElement),
|
||||
H1Plugin.configure({
|
||||
node: {
|
||||
component: H1Element,
|
||||
},
|
||||
rules: {
|
||||
break: { empty: "reset" },
|
||||
},
|
||||
shortcuts: { toggle: { keys: "mod+alt+1" } },
|
||||
}),
|
||||
H2Plugin.configure({
|
||||
node: {
|
||||
component: H2Element,
|
||||
},
|
||||
rules: {
|
||||
break: { empty: "reset" },
|
||||
},
|
||||
shortcuts: { toggle: { keys: "mod+alt+2" } },
|
||||
}),
|
||||
H3Plugin.configure({
|
||||
node: {
|
||||
component: H3Element,
|
||||
},
|
||||
rules: {
|
||||
break: { empty: "reset" },
|
||||
},
|
||||
shortcuts: { toggle: { keys: "mod+alt+3" } },
|
||||
}),
|
||||
H4Plugin.configure({
|
||||
node: {
|
||||
component: H4Element,
|
||||
},
|
||||
rules: {
|
||||
break: { empty: "reset" },
|
||||
},
|
||||
shortcuts: { toggle: { keys: "mod+alt+4" } },
|
||||
}),
|
||||
H5Plugin.configure({
|
||||
node: {
|
||||
component: H5Element,
|
||||
},
|
||||
rules: {
|
||||
break: { empty: "reset" },
|
||||
},
|
||||
}),
|
||||
H6Plugin.configure({
|
||||
node: {
|
||||
component: H6Element,
|
||||
},
|
||||
rules: {
|
||||
break: { empty: "reset" },
|
||||
},
|
||||
}),
|
||||
BlockquotePlugin.configure({
|
||||
node: { component: BlockquoteElement },
|
||||
shortcuts: { toggle: { keys: "mod+shift+period" } },
|
||||
}),
|
||||
HorizontalRulePlugin.withComponent(HrElement),
|
||||
];
|
||||
38
surfsense_web/components/editor/plugins/basic-marks-kit.tsx
Normal file
38
surfsense_web/components/editor/plugins/basic-marks-kit.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BoldPlugin,
|
||||
CodePlugin,
|
||||
HighlightPlugin,
|
||||
ItalicPlugin,
|
||||
StrikethroughPlugin,
|
||||
SubscriptPlugin,
|
||||
SuperscriptPlugin,
|
||||
UnderlinePlugin,
|
||||
} from "@platejs/basic-nodes/react";
|
||||
|
||||
import { CodeLeaf } from "@/components/ui/code-node";
|
||||
import { HighlightLeaf } from "@/components/ui/highlight-node";
|
||||
|
||||
export const BasicMarksKit = [
|
||||
BoldPlugin,
|
||||
ItalicPlugin,
|
||||
UnderlinePlugin,
|
||||
CodePlugin.configure({
|
||||
node: { component: CodeLeaf },
|
||||
shortcuts: { toggle: { keys: "mod+e" } },
|
||||
}),
|
||||
StrikethroughPlugin.configure({
|
||||
shortcuts: { toggle: { keys: "mod+shift+x" } },
|
||||
}),
|
||||
SubscriptPlugin.configure({
|
||||
shortcuts: { toggle: { keys: "mod+comma" } },
|
||||
}),
|
||||
SuperscriptPlugin.configure({
|
||||
shortcuts: { toggle: { keys: "mod+period" } },
|
||||
}),
|
||||
HighlightPlugin.configure({
|
||||
node: { component: HighlightLeaf },
|
||||
shortcuts: { toggle: { keys: "mod+shift+h" } },
|
||||
}),
|
||||
];
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { BasicBlocksKit } from "./basic-blocks-kit";
|
||||
import { BasicMarksKit } from "./basic-marks-kit";
|
||||
|
||||
export const BasicNodesKit = [...BasicBlocksKit, ...BasicMarksKit];
|
||||
7
surfsense_web/components/editor/plugins/callout-kit.tsx
Normal file
7
surfsense_web/components/editor/plugins/callout-kit.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { CalloutPlugin } from "@platejs/callout/react";
|
||||
|
||||
import { CalloutElement } from "@/components/ui/callout-node";
|
||||
|
||||
export const CalloutKit = [CalloutPlugin.withComponent(CalloutElement)];
|
||||
18
surfsense_web/components/editor/plugins/code-block-kit.tsx
Normal file
18
surfsense_web/components/editor/plugins/code-block-kit.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { CodeBlockPlugin, CodeLinePlugin, CodeSyntaxPlugin } from "@platejs/code-block/react";
|
||||
import { all, createLowlight } from "lowlight";
|
||||
|
||||
import { CodeBlockElement, CodeLineElement, CodeSyntaxLeaf } from "@/components/ui/code-block-node";
|
||||
|
||||
const lowlight = createLowlight(all);
|
||||
|
||||
export const CodeBlockKit = [
|
||||
CodeBlockPlugin.configure({
|
||||
node: { component: CodeBlockElement },
|
||||
options: { lowlight },
|
||||
shortcuts: { toggle: { keys: "mod+alt+8" } },
|
||||
}),
|
||||
CodeLinePlugin.withComponent(CodeLineElement),
|
||||
CodeSyntaxPlugin.withComponent(CodeSyntaxLeaf),
|
||||
];
|
||||
20
surfsense_web/components/editor/plugins/dnd-kit.tsx
Normal file
20
surfsense_web/components/editor/plugins/dnd-kit.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
|
||||
import { DndPlugin } from "@platejs/dnd";
|
||||
|
||||
import { BlockDraggable } from "@/components/ui/block-draggable";
|
||||
|
||||
export const DndKit = [
|
||||
DndPlugin.configure({
|
||||
options: {
|
||||
enableScroller: true,
|
||||
},
|
||||
render: {
|
||||
aboveNodes: BlockDraggable,
|
||||
aboveSlate: ({ children }) => <DndProvider backend={HTML5Backend}>{children}</DndProvider>,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { createPlatePlugin } from "platejs/react";
|
||||
|
||||
import { FixedToolbar } from "@/components/ui/fixed-toolbar";
|
||||
import { FixedToolbarButtons } from "@/components/ui/fixed-toolbar-buttons";
|
||||
|
||||
export const FixedToolbarKit = [
|
||||
createPlatePlugin({
|
||||
key: "fixed-toolbar",
|
||||
render: {
|
||||
beforeEditable: () => (
|
||||
<FixedToolbar>
|
||||
<FixedToolbarButtons />
|
||||
</FixedToolbar>
|
||||
),
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { createPlatePlugin } from "platejs/react";
|
||||
|
||||
import { FloatingToolbar } from "@/components/ui/floating-toolbar";
|
||||
import { FloatingToolbarButtons } from "@/components/ui/floating-toolbar-buttons";
|
||||
|
||||
export const FloatingToolbarKit = [
|
||||
createPlatePlugin({
|
||||
key: "floating-toolbar",
|
||||
render: {
|
||||
afterEditable: () => (
|
||||
<FloatingToolbar>
|
||||
<FloatingToolbarButtons />
|
||||
</FloatingToolbar>
|
||||
),
|
||||
},
|
||||
}),
|
||||
];
|
||||
12
surfsense_web/components/editor/plugins/indent-kit.tsx
Normal file
12
surfsense_web/components/editor/plugins/indent-kit.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { IndentPlugin } from "@platejs/indent/react";
|
||||
import { KEYS } from "platejs";
|
||||
|
||||
export const IndentKit = [
|
||||
IndentPlugin.configure({
|
||||
inject: {
|
||||
targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote, KEYS.codeBlock, KEYS.toggle],
|
||||
},
|
||||
}),
|
||||
];
|
||||
15
surfsense_web/components/editor/plugins/link-kit.tsx
Normal file
15
surfsense_web/components/editor/plugins/link-kit.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { LinkPlugin } from "@platejs/link/react";
|
||||
|
||||
import { LinkElement } from "@/components/ui/link-node";
|
||||
import { LinkFloatingToolbar } from "@/components/ui/link-toolbar";
|
||||
|
||||
export const LinkKit = [
|
||||
LinkPlugin.configure({
|
||||
render: {
|
||||
node: LinkElement,
|
||||
afterEditable: () => <LinkFloatingToolbar />,
|
||||
},
|
||||
}),
|
||||
];
|
||||
19
surfsense_web/components/editor/plugins/list-kit.tsx
Normal file
19
surfsense_web/components/editor/plugins/list-kit.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { ListPlugin } from "@platejs/list/react";
|
||||
import { KEYS } from "platejs";
|
||||
|
||||
import { IndentKit } from "@/components/editor/plugins/indent-kit";
|
||||
import { BlockList } from "@/components/ui/block-list";
|
||||
|
||||
export const ListKit = [
|
||||
...IndentKit,
|
||||
ListPlugin.configure({
|
||||
inject: {
|
||||
targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote, KEYS.codeBlock, KEYS.toggle],
|
||||
},
|
||||
render: {
|
||||
belowNodes: BlockList,
|
||||
},
|
||||
}),
|
||||
];
|
||||
10
surfsense_web/components/editor/plugins/math-kit.tsx
Normal file
10
surfsense_web/components/editor/plugins/math-kit.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { EquationPlugin, InlineEquationPlugin } from "@platejs/math/react";
|
||||
|
||||
import { EquationElement, InlineEquationElement } from "@/components/ui/equation-node";
|
||||
|
||||
export const MathKit = [
|
||||
EquationPlugin.withComponent(EquationElement),
|
||||
InlineEquationPlugin.withComponent(InlineEquationElement),
|
||||
];
|
||||
23
surfsense_web/components/editor/plugins/selection-kit.tsx
Normal file
23
surfsense_web/components/editor/plugins/selection-kit.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { BlockSelectionPlugin } from "@platejs/selection/react";
|
||||
|
||||
import { BlockSelection } from "@/components/ui/block-selection";
|
||||
|
||||
export const SelectionKit = [
|
||||
BlockSelectionPlugin.configure({
|
||||
render: {
|
||||
belowRootNodes: BlockSelection as any,
|
||||
},
|
||||
options: {
|
||||
isSelectable: (element) => {
|
||||
// Exclude specific block types from selection
|
||||
if (["code_line", "td", "th"].includes(element.type as string)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
@ -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),
|
||||
];
|
||||
22
surfsense_web/components/editor/plugins/table-kit.tsx
Normal file
22
surfsense_web/components/editor/plugins/table-kit.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
TableCellHeaderPlugin,
|
||||
TableCellPlugin,
|
||||
TablePlugin,
|
||||
TableRowPlugin,
|
||||
} from "@platejs/table/react";
|
||||
|
||||
import {
|
||||
TableCellElement,
|
||||
TableCellHeaderElement,
|
||||
TableElement,
|
||||
TableRowElement,
|
||||
} from "@/components/ui/table-node";
|
||||
|
||||
export const TableKit = [
|
||||
TablePlugin.withComponent(TableElement),
|
||||
TableRowPlugin.withComponent(TableRowElement),
|
||||
TableCellPlugin.withComponent(TableCellElement),
|
||||
TableCellHeaderPlugin.withComponent(TableCellHeaderElement),
|
||||
];
|
||||
12
surfsense_web/components/editor/plugins/toggle-kit.tsx
Normal file
12
surfsense_web/components/editor/plugins/toggle-kit.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { TogglePlugin } from "@platejs/toggle/react";
|
||||
|
||||
import { ToggleElement } from "@/components/ui/toggle-node";
|
||||
|
||||
export const ToggleKit = [
|
||||
TogglePlugin.configure({
|
||||
node: { component: ToggleElement },
|
||||
shortcuts: { toggle: { keys: "mod+alt+9" } },
|
||||
}),
|
||||
];
|
||||
79
surfsense_web/components/editor/presets.ts
Normal file
79
surfsense_web/components/editor/presets.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import type { AnyPluginConfig } from "platejs";
|
||||
|
||||
import { AutoformatKit } from "@/components/editor/plugins/autoformat-kit";
|
||||
import { BasicNodesKit } from "@/components/editor/plugins/basic-nodes-kit";
|
||||
import { CalloutKit } from "@/components/editor/plugins/callout-kit";
|
||||
import { CodeBlockKit } from "@/components/editor/plugins/code-block-kit";
|
||||
import { DndKit } from "@/components/editor/plugins/dnd-kit";
|
||||
import { FixedToolbarKit } from "@/components/editor/plugins/fixed-toolbar-kit";
|
||||
import { FloatingToolbarKit } from "@/components/editor/plugins/floating-toolbar-kit";
|
||||
import { LinkKit } from "@/components/editor/plugins/link-kit";
|
||||
import { ListKit } from "@/components/editor/plugins/list-kit";
|
||||
import { MathKit } from "@/components/editor/plugins/math-kit";
|
||||
import { SelectionKit } from "@/components/editor/plugins/selection-kit";
|
||||
import { SlashCommandKit } from "@/components/editor/plugins/slash-command-kit";
|
||||
import { TableKit } from "@/components/editor/plugins/table-kit";
|
||||
import { ToggleKit } from "@/components/editor/plugins/toggle-kit";
|
||||
|
||||
/**
|
||||
* Full preset – every plugin kit enabled.
|
||||
* Used by the Documents editor and Reports editor (rich editing experience).
|
||||
*/
|
||||
export const fullPreset: AnyPluginConfig[] = [
|
||||
...BasicNodesKit,
|
||||
...TableKit,
|
||||
...ListKit,
|
||||
...CodeBlockKit,
|
||||
...LinkKit,
|
||||
...CalloutKit,
|
||||
...ToggleKit,
|
||||
...MathKit,
|
||||
...SelectionKit,
|
||||
...SlashCommandKit,
|
||||
...FixedToolbarKit,
|
||||
...FloatingToolbarKit,
|
||||
...AutoformatKit,
|
||||
...DndKit,
|
||||
];
|
||||
|
||||
/**
|
||||
* Minimal preset – lightweight editing with core formatting only.
|
||||
* No fixed toolbar, no slash commands, no DnD, no block selection.
|
||||
* Ideal for inline editors like human-in-the-loop agent actions.
|
||||
*/
|
||||
export const minimalPreset: AnyPluginConfig[] = [
|
||||
...BasicNodesKit,
|
||||
...ListKit,
|
||||
...CodeBlockKit,
|
||||
...LinkKit,
|
||||
...FloatingToolbarKit,
|
||||
...AutoformatKit,
|
||||
];
|
||||
|
||||
/**
|
||||
* Read-only preset – rendering support for all rich content, but no editing UI.
|
||||
* No toolbars, no autoformat, no DnD, no slash commands, no block selection.
|
||||
* Ideal for pure display / viewer contexts.
|
||||
*/
|
||||
export const readonlyPreset: AnyPluginConfig[] = [
|
||||
...BasicNodesKit,
|
||||
...TableKit,
|
||||
...ListKit,
|
||||
...CodeBlockKit,
|
||||
...LinkKit,
|
||||
...CalloutKit,
|
||||
...ToggleKit,
|
||||
...MathKit,
|
||||
];
|
||||
|
||||
/** All available preset names */
|
||||
export type EditorPreset = "full" | "minimal" | "readonly";
|
||||
|
||||
/** Map from preset name to plugin array */
|
||||
export const presetMap: Record<EditorPreset, AnyPluginConfig[]> = {
|
||||
full: fullPreset,
|
||||
minimal: minimalPreset,
|
||||
readonly: readonlyPreset,
|
||||
};
|
||||
160
surfsense_web/components/editor/transforms.ts
Normal file
160
surfsense_web/components/editor/transforms.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
|
||||
import type { PlateEditor } from "platejs/react";
|
||||
|
||||
import { insertCallout } from "@platejs/callout";
|
||||
import { insertCodeBlock, toggleCodeBlock } from "@platejs/code-block";
|
||||
import { triggerFloatingLink } from "@platejs/link/react";
|
||||
import { insertInlineEquation } from "@platejs/math";
|
||||
import { TablePlugin } from "@platejs/table/react";
|
||||
import { type NodeEntry, type Path, type TElement, KEYS, PathApi } from "platejs";
|
||||
|
||||
const insertList = (editor: PlateEditor, type: string) => {
|
||||
editor.tf.insertNodes(
|
||||
editor.api.create.block({
|
||||
indent: 1,
|
||||
listStyleType: type,
|
||||
}),
|
||||
{ select: true }
|
||||
);
|
||||
};
|
||||
|
||||
const insertBlockMap: Record<string, (editor: PlateEditor, type: string) => void> = {
|
||||
[KEYS.listTodo]: insertList,
|
||||
[KEYS.ol]: insertList,
|
||||
[KEYS.ul]: insertList,
|
||||
[KEYS.codeBlock]: (editor) => insertCodeBlock(editor, { select: true }),
|
||||
[KEYS.table]: (editor) => editor.getTransforms(TablePlugin).insert.table({}, { select: true }),
|
||||
[KEYS.callout]: (editor) => insertCallout(editor, { select: true }),
|
||||
[KEYS.toggle]: (editor) => {
|
||||
editor.tf.insertNodes(editor.api.create.block({ type: KEYS.toggle }), { select: true });
|
||||
},
|
||||
};
|
||||
|
||||
const insertInlineMap: Record<string, (editor: PlateEditor, type: string) => void> = {
|
||||
[KEYS.link]: (editor) => triggerFloatingLink(editor, { focused: true }),
|
||||
[KEYS.equation]: (editor) => insertInlineEquation(editor),
|
||||
};
|
||||
|
||||
type InsertBlockOptions = {
|
||||
upsert?: boolean;
|
||||
};
|
||||
|
||||
export const insertBlock = (
|
||||
editor: PlateEditor,
|
||||
type: string,
|
||||
options: InsertBlockOptions = {}
|
||||
) => {
|
||||
const { upsert = false } = options;
|
||||
|
||||
editor.tf.withoutNormalizing(() => {
|
||||
const block = editor.api.block();
|
||||
|
||||
if (!block) return;
|
||||
|
||||
const [currentNode, path] = block;
|
||||
const isCurrentBlockEmpty = editor.api.isEmpty(currentNode);
|
||||
const currentBlockType = getBlockType(currentNode);
|
||||
|
||||
const isSameBlockType = type === currentBlockType;
|
||||
|
||||
if (upsert && isCurrentBlockEmpty && isSameBlockType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type in insertBlockMap) {
|
||||
insertBlockMap[type](editor, type);
|
||||
} else {
|
||||
editor.tf.insertNodes(editor.api.create.block({ type }), {
|
||||
at: PathApi.next(path),
|
||||
select: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isSameBlockType) {
|
||||
editor.tf.removeNodes({ previousEmptyBlock: true });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const insertInlineElement = (editor: PlateEditor, type: string) => {
|
||||
if (insertInlineMap[type]) {
|
||||
insertInlineMap[type](editor, type);
|
||||
}
|
||||
};
|
||||
|
||||
const setList = (editor: PlateEditor, type: string, entry: NodeEntry<TElement>) => {
|
||||
editor.tf.setNodes(
|
||||
editor.api.create.block({
|
||||
indent: 1,
|
||||
listStyleType: type,
|
||||
}),
|
||||
{
|
||||
at: entry[1],
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const setBlockMap: Record<
|
||||
string,
|
||||
(editor: PlateEditor, type: string, entry: NodeEntry<TElement>) => void
|
||||
> = {
|
||||
[KEYS.listTodo]: setList,
|
||||
[KEYS.ol]: setList,
|
||||
[KEYS.ul]: setList,
|
||||
[KEYS.codeBlock]: (editor) => toggleCodeBlock(editor),
|
||||
[KEYS.callout]: (editor, _type, entry) => {
|
||||
editor.tf.setNodes({ type: KEYS.callout }, { at: entry[1] });
|
||||
},
|
||||
[KEYS.toggle]: (editor, _type, entry) => {
|
||||
editor.tf.setNodes({ type: KEYS.toggle }, { at: entry[1] });
|
||||
},
|
||||
};
|
||||
|
||||
export const setBlockType = (editor: PlateEditor, type: string, { at }: { at?: Path } = {}) => {
|
||||
editor.tf.withoutNormalizing(() => {
|
||||
const setEntry = (entry: NodeEntry<TElement>) => {
|
||||
const [node, path] = entry;
|
||||
|
||||
if (node[KEYS.listType]) {
|
||||
editor.tf.unsetNodes([KEYS.listType, "indent"], { at: path });
|
||||
}
|
||||
if (type in setBlockMap) {
|
||||
return setBlockMap[type](editor, type, entry);
|
||||
}
|
||||
if (node.type !== type) {
|
||||
editor.tf.setNodes({ type }, { at: path });
|
||||
}
|
||||
};
|
||||
|
||||
if (at) {
|
||||
const entry = editor.api.node<TElement>(at);
|
||||
|
||||
if (entry) {
|
||||
setEntry(entry);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const entries = editor.api.blocks({ mode: "lowest" });
|
||||
|
||||
entries.forEach((entry) => {
|
||||
setEntry(entry);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const getBlockType = (block: TElement) => {
|
||||
if (block[KEYS.listType]) {
|
||||
if (block[KEYS.listType] === KEYS.ol) {
|
||||
return KEYS.ol;
|
||||
}
|
||||
if (block[KEYS.listType] === KEYS.listTodo) {
|
||||
return KEYS.listTodo;
|
||||
}
|
||||
return KEYS.ul;
|
||||
}
|
||||
|
||||
return block.type;
|
||||
};
|
||||
25
surfsense_web/components/editor/utils/escape-mdx.ts
Normal file
25
surfsense_web/components/editor/utils/escape-mdx.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// MDX curly-brace escaping helper
|
||||
// ---------------------------------------------------------------------------
|
||||
// remarkMdx treats { } as JSX expression delimiters. Arbitrary markdown
|
||||
// (e.g. AI-generated reports) can contain curly braces that are NOT valid JS
|
||||
// expressions, which makes acorn throw "Could not parse expression".
|
||||
// We escape unescaped { and } *outside* of fenced code blocks and inline code
|
||||
// so remarkMdx treats them as literal characters while still parsing
|
||||
// <mark>, <u>, <kbd>, etc. tags correctly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FENCED_OR_INLINE_CODE = /(```[\s\S]*?```|`[^`\n]+`)/g;
|
||||
|
||||
export function escapeMdxExpressions(md: string): string {
|
||||
const parts = md.split(FENCED_OR_INLINE_CODE);
|
||||
|
||||
return parts
|
||||
.map((part, i) => {
|
||||
// Odd indices are code blocks / inline code – leave untouched
|
||||
if (i % 2 === 1) return part;
|
||||
// Escape { and } that are NOT already escaped (no preceding \)
|
||||
return part.replace(/(?<!\\)\{/g, "\\{").replace(/(?<!\\)\}/g, "\\}");
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
|
@ -40,10 +40,10 @@ export function useSidebarState(defaultCollapsed = false): UseSidebarStateReturn
|
|||
setIsCollapsed(!isCollapsed);
|
||||
}, [isCollapsed, setIsCollapsed]);
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl + B
|
||||
// Keyboard shortcut: Cmd/Ctrl + \
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "b" && (event.metaKey || event.ctrlKey)) {
|
||||
if (event.key === "\\" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
toggleCollapsed();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { PanelLeft, PanelLeftClose } from "lucide-react";
|
|||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
||||
|
||||
interface SidebarCollapseButtonProps {
|
||||
isCollapsed: boolean;
|
||||
|
|
@ -17,6 +18,7 @@ export function SidebarCollapseButton({
|
|||
disableTooltip = false,
|
||||
}: SidebarCollapseButtonProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const { shortcut } = usePlatformShortcut();
|
||||
|
||||
const button = (
|
||||
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
|
||||
|
|
@ -33,7 +35,9 @@ export function SidebarCollapseButton({
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
|
||||
{isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`}
|
||||
{isCollapsed
|
||||
? `${t("expand_sidebar")} ${shortcut("Mod", "\\")}`
|
||||
: `${t("collapse_sidebar")} ${shortcut("Mod", "\\")}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHandle } from "@/components/ui/drawer";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -112,6 +115,14 @@ function ReportPanelContent({
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [exporting, setExporting] = useState<"pdf" | "docx" | "md" | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Editor state — tracks the latest markdown from the Plate editor
|
||||
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
|
||||
|
||||
// Read-only when public (shareToken) OR shared (SEARCH_SPACE visibility)
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
const isReadOnly = !!shareToken || currentThreadState.visibility === "SEARCH_SPACE";
|
||||
|
||||
// Version state
|
||||
const [activeReportId, setActiveReportId] = useState(reportId);
|
||||
|
|
@ -165,17 +176,25 @@ function ReportPanelContent({
|
|||
};
|
||||
}, [activeReportId, shareToken]);
|
||||
|
||||
// Copy markdown content
|
||||
// The current markdown: use edited version if available, otherwise original
|
||||
const currentMarkdown = editedMarkdown ?? reportContent?.content ?? null;
|
||||
|
||||
// Reset edited markdown when switching versions or reports
|
||||
useEffect(() => {
|
||||
setEditedMarkdown(null);
|
||||
}, [activeReportId]);
|
||||
|
||||
// Copy markdown content (uses latest editor content)
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!reportContent?.content) return;
|
||||
if (!currentMarkdown) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(reportContent.content);
|
||||
await navigator.clipboard.writeText(currentMarkdown);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
}, [reportContent?.content]);
|
||||
}, [currentMarkdown]);
|
||||
|
||||
// Export report
|
||||
const handleExport = useCallback(
|
||||
|
|
@ -188,9 +207,9 @@ function ReportPanelContent({
|
|||
.slice(0, 80) || "report";
|
||||
try {
|
||||
if (format === "md") {
|
||||
// Download markdown content directly as a .md file
|
||||
if (!reportContent?.content) return;
|
||||
const blob = new Blob([reportContent.content], {
|
||||
// Download markdown content directly as a .md file (uses latest editor content)
|
||||
if (!currentMarkdown) return;
|
||||
const blob = new Blob([currentMarkdown], {
|
||||
type: "text/markdown;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
@ -227,9 +246,40 @@ function ReportPanelContent({
|
|||
setExporting(null);
|
||||
}
|
||||
},
|
||||
[activeReportId, title, reportContent?.content]
|
||||
[activeReportId, title, currentMarkdown]
|
||||
);
|
||||
|
||||
// Save edited report content
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!currentMarkdown || !activeReportId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/content`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: currentMarkdown }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: "Failed to save report" }));
|
||||
throw new Error(errorData.detail || "Failed to save report");
|
||||
}
|
||||
|
||||
// Update local state to reflect saved content
|
||||
setReportContent((prev) => (prev ? { ...prev, content: currentMarkdown } : prev));
|
||||
setEditedMarkdown(null);
|
||||
toast.success("Report saved successfully");
|
||||
} catch (err) {
|
||||
console.error("Error saving report:", err);
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save report");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activeReportId, currentMarkdown]);
|
||||
|
||||
// Show full-page skeleton only on initial load (no data loaded yet).
|
||||
// Once we have versions/content from a prior fetch, keep the action bar visible.
|
||||
const hasLoadedBefore = versions.length > 0 || reportContent !== null;
|
||||
|
|
@ -284,7 +334,7 @@ function ReportPanelContent({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={`min-w-[180px]${insideDrawer ? " z-[100]" : ""}`}
|
||||
className={`min-w-[180px] dark:bg-neutral-800 dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => handleExport("md")}>
|
||||
Download Markdown
|
||||
|
|
@ -370,8 +420,8 @@ function ReportPanelContent({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Report content — skeleton/error/content shown only in this area */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin">
|
||||
{/* Report content — skeleton/error/viewer/editor shown only in this area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<ReportPanelSkeleton />
|
||||
) : error || !reportContent ? (
|
||||
|
|
@ -381,13 +431,27 @@ function ReportPanelContent({
|
|||
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : reportContent.content ? (
|
||||
isReadOnly ? (
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
<MarkdownViewer content={reportContent.content} />
|
||||
</div>
|
||||
) : (
|
||||
<PlateEditor
|
||||
preset="full"
|
||||
markdown={reportContent.content}
|
||||
onMarkdownChange={setEditedMarkdown}
|
||||
readOnly={false}
|
||||
placeholder="Report content..."
|
||||
editorVariant="default"
|
||||
onSave={handleSave}
|
||||
hasUnsavedChanges={editedMarkdown !== null}
|
||||
isSaving={saving}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="px-5 py-5">
|
||||
{reportContent.content ? (
|
||||
<MarkdownViewer content={reportContent.content} />
|
||||
) : (
|
||||
<p className="text-muted-foreground italic">No content available.</p>
|
||||
)}
|
||||
<p className="text-muted-foreground italic">No content available.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -448,8 +512,12 @@ function MobileReportDrawer() {
|
|||
}}
|
||||
shouldScaleBackground={false}
|
||||
>
|
||||
<DrawerContent className="h-[90vh] max-h-[90vh] z-80" overlayClassName="z-80">
|
||||
<DrawerContent
|
||||
className="h-[90vh] max-h-[90vh] z-80 !rounded-none border-none"
|
||||
overlayClassName="z-80"
|
||||
>
|
||||
<DrawerHandle />
|
||||
<DrawerTitle className="sr-only">{panelState.title || "Report"}</DrawerTitle>
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
<ReportPanelContent
|
||||
reportId={panelState.reportId}
|
||||
|
|
|
|||
467
surfsense_web/components/ui/block-draggable.tsx
Normal file
467
surfsense_web/components/ui/block-draggable.tsx
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { DndPlugin, useDraggable, useDropLine } from "@platejs/dnd";
|
||||
import { expandListItemsWithChildren } from "@platejs/list";
|
||||
import { BlockSelectionPlugin } from "@platejs/selection/react";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { type TElement, getPluginByType, isType, KEYS } from "platejs";
|
||||
import {
|
||||
type PlateEditor,
|
||||
type PlateElementProps,
|
||||
type RenderNodeWrapper,
|
||||
MemoizedChildren,
|
||||
useEditorRef,
|
||||
useElement,
|
||||
usePluginOption,
|
||||
} from "platejs/react";
|
||||
import { useSelected } from "platejs/react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const UNDRAGGABLE_KEYS = [KEYS.column, KEYS.tr, KEYS.td];
|
||||
|
||||
export const BlockDraggable: RenderNodeWrapper = (props) => {
|
||||
const { editor, element, path } = props;
|
||||
|
||||
const enabled = React.useMemo(() => {
|
||||
if (editor.dom.readOnly) return false;
|
||||
|
||||
if (path.length === 1 && !isType(editor, element, UNDRAGGABLE_KEYS)) {
|
||||
return true;
|
||||
}
|
||||
if (path.length === 3 && !isType(editor, element, UNDRAGGABLE_KEYS)) {
|
||||
const block = editor.api.some({
|
||||
at: path,
|
||||
match: {
|
||||
type: editor.getType(KEYS.column),
|
||||
},
|
||||
});
|
||||
|
||||
if (block) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (path.length === 4 && !isType(editor, element, UNDRAGGABLE_KEYS)) {
|
||||
const block = editor.api.some({
|
||||
at: path,
|
||||
match: {
|
||||
type: editor.getType(KEYS.table),
|
||||
},
|
||||
});
|
||||
|
||||
if (block) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [editor, element, path]);
|
||||
|
||||
if (!enabled) return;
|
||||
|
||||
return (props) => <Draggable {...props} />;
|
||||
};
|
||||
|
||||
function Draggable(props: PlateElementProps) {
|
||||
const { children, editor, element, path } = props;
|
||||
const blockSelectionApi = editor.getApi(BlockSelectionPlugin).blockSelection;
|
||||
|
||||
const { isAboutToDrag, isDragging, nodeRef, previewRef, handleRef } = useDraggable({
|
||||
element,
|
||||
onDropHandler: (_, { dragItem }) => {
|
||||
const id = (dragItem as { id: string[] | string }).id;
|
||||
|
||||
if (blockSelectionApi) {
|
||||
blockSelectionApi.add(id);
|
||||
}
|
||||
resetPreview();
|
||||
},
|
||||
});
|
||||
|
||||
const isInColumn = path.length === 3;
|
||||
const isInTable = path.length === 4;
|
||||
|
||||
const [previewTop, setPreviewTop] = React.useState(0);
|
||||
|
||||
const resetPreview = () => {
|
||||
if (previewRef.current) {
|
||||
previewRef.current.replaceChildren();
|
||||
previewRef.current?.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
// clear up virtual multiple preview when drag end
|
||||
React.useEffect(() => {
|
||||
if (!isDragging) {
|
||||
resetPreview();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDragging]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAboutToDrag) {
|
||||
previewRef.current?.classList.remove("opacity-0");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAboutToDrag]);
|
||||
|
||||
const [dragButtonTop, setDragButtonTop] = React.useState(0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
isDragging && "opacity-50",
|
||||
getPluginByType(editor, element.type)?.node.isContainer ? "group/container" : "group"
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (isDragging) return;
|
||||
setDragButtonTop(calcDragButtonTop(editor, element));
|
||||
}}
|
||||
>
|
||||
{!isInTable && (
|
||||
<Gutter>
|
||||
<div className={cn("slate-blockToolbarWrapper", "flex h-[1.5em]", isInColumn && "h-4")}>
|
||||
<div
|
||||
className={cn(
|
||||
"slate-blockToolbar relative w-4.5",
|
||||
"pointer-events-auto mr-1 flex items-center",
|
||||
isInColumn && "mr-1.5"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
ref={handleRef}
|
||||
variant="ghost"
|
||||
className="-left-0 absolute h-6 w-full p-0"
|
||||
style={{ top: `${dragButtonTop + 3}px` }}
|
||||
data-plate-prevent-deselect
|
||||
>
|
||||
<DragHandle
|
||||
isDragging={isDragging}
|
||||
previewRef={previewRef}
|
||||
resetPreview={resetPreview}
|
||||
setPreviewTop={setPreviewTop}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Gutter>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={previewRef}
|
||||
className={cn("-left-0 absolute hidden w-full")}
|
||||
style={{ top: `${-previewTop}px` }}
|
||||
contentEditable={false}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={nodeRef}
|
||||
className="slate-blockWrapper flow-root"
|
||||
onContextMenu={(event) =>
|
||||
editor.getApi(BlockSelectionPlugin).blockSelection.addOnContextMenu({ element, event })
|
||||
}
|
||||
>
|
||||
<MemoizedChildren>{children}</MemoizedChildren>
|
||||
<DropLine />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Gutter({ children, className, ...props }: React.ComponentProps<"div">) {
|
||||
const editor = useEditorRef();
|
||||
const element = useElement();
|
||||
const isSelectionAreaVisible = usePluginOption(BlockSelectionPlugin, "isSelectionAreaVisible");
|
||||
const selected = useSelected();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"slate-gutterLeft",
|
||||
"-translate-x-full absolute top-0 z-50 flex h-full cursor-text hover:opacity-100 sm:opacity-0",
|
||||
getPluginByType(editor, element.type)?.node.isContainer
|
||||
? "group-hover/container:opacity-100"
|
||||
: "group-hover:opacity-100",
|
||||
isSelectionAreaVisible && "hidden",
|
||||
!selected && "opacity-0",
|
||||
className
|
||||
)}
|
||||
contentEditable={false}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DragHandle = React.memo(function DragHandle({
|
||||
isDragging,
|
||||
previewRef,
|
||||
resetPreview,
|
||||
setPreviewTop,
|
||||
}: {
|
||||
isDragging: boolean;
|
||||
previewRef: React.RefObject<HTMLDivElement | null>;
|
||||
resetPreview: () => void;
|
||||
setPreviewTop: (top: number) => void;
|
||||
}) {
|
||||
const editor = useEditorRef();
|
||||
const element = useElement();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="flex size-full items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.getApi(BlockSelectionPlugin).blockSelection.focus();
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
resetPreview();
|
||||
|
||||
if ((e.button !== 0 && e.button !== 2) || e.shiftKey) return;
|
||||
|
||||
const blockSelection = editor
|
||||
.getApi(BlockSelectionPlugin)
|
||||
.blockSelection.getNodes({ sort: true });
|
||||
|
||||
let selectionNodes =
|
||||
blockSelection.length > 0 ? blockSelection : editor.api.blocks({ mode: "highest" });
|
||||
|
||||
// If current block is not in selection, use it as the starting point
|
||||
if (!selectionNodes.some(([node]) => node.id === element.id)) {
|
||||
selectionNodes = [[element, editor.api.findPath(element)!]];
|
||||
}
|
||||
|
||||
// Process selection nodes to include list children
|
||||
const blocks = expandListItemsWithChildren(editor, selectionNodes).map(
|
||||
([node]) => node
|
||||
);
|
||||
|
||||
if (blockSelection.length === 0) {
|
||||
editor.tf.blur();
|
||||
editor.tf.collapse();
|
||||
}
|
||||
|
||||
const elements = createDragPreviewElements(editor, blocks);
|
||||
previewRef.current?.append(...elements);
|
||||
previewRef.current?.classList.remove("hidden");
|
||||
previewRef.current?.classList.add("opacity-0");
|
||||
editor.setOption(DndPlugin, "multiplePreviewRef", previewRef);
|
||||
|
||||
editor
|
||||
.getApi(BlockSelectionPlugin)
|
||||
.blockSelection.set(blocks.map((block) => block.id as string));
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (isDragging) return;
|
||||
|
||||
const blockSelection = editor
|
||||
.getApi(BlockSelectionPlugin)
|
||||
.blockSelection.getNodes({ sort: true });
|
||||
|
||||
let selectedBlocks =
|
||||
blockSelection.length > 0 ? blockSelection : editor.api.blocks({ mode: "highest" });
|
||||
|
||||
// If current block is not in selection, use it as the starting point
|
||||
if (!selectedBlocks.some(([node]) => node.id === element.id)) {
|
||||
selectedBlocks = [[element, editor.api.findPath(element)!]];
|
||||
}
|
||||
|
||||
// Process selection to include list children
|
||||
const processedBlocks = expandListItemsWithChildren(editor, selectedBlocks);
|
||||
|
||||
const ids = processedBlocks.map((block) => block[0].id as string);
|
||||
|
||||
if (ids.length > 1 && ids.includes(element.id as string)) {
|
||||
const previewTop = calculatePreviewTop(editor, {
|
||||
blocks: processedBlocks.map((block) => block[0]),
|
||||
element,
|
||||
});
|
||||
setPreviewTop(previewTop);
|
||||
} else {
|
||||
setPreviewTop(0);
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
resetPreview();
|
||||
}}
|
||||
data-plate-prevent-deselect
|
||||
role="button"
|
||||
>
|
||||
<GripVertical className="text-muted-foreground" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
const DropLine = React.memo(function DropLine({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const { dropLine } = useDropLine();
|
||||
|
||||
if (!dropLine) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"slate-dropLine",
|
||||
"absolute inset-x-0 h-0.5 opacity-100 transition-opacity",
|
||||
"bg-brand/50",
|
||||
dropLine === "top" && "-top-px",
|
||||
dropLine === "bottom" && "-bottom-px",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const createDragPreviewElements = (editor: PlateEditor, blocks: TElement[]): HTMLElement[] => {
|
||||
const elements: HTMLElement[] = [];
|
||||
const ids: string[] = [];
|
||||
|
||||
/**
|
||||
* Remove data attributes from the element to avoid recognized as slate
|
||||
* elements incorrectly.
|
||||
*/
|
||||
const removeDataAttributes = (element: HTMLElement) => {
|
||||
Array.from(element.attributes).forEach((attr) => {
|
||||
if (attr.name.startsWith("data-slate") || attr.name.startsWith("data-block-id")) {
|
||||
element.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(element.children).forEach((child) => {
|
||||
removeDataAttributes(child as HTMLElement);
|
||||
});
|
||||
};
|
||||
|
||||
const resolveElement = (node: TElement, index: number) => {
|
||||
const domNode = editor.api.toDOMNode(node)!;
|
||||
const newDomNode = domNode.cloneNode(true) as HTMLElement;
|
||||
|
||||
// Apply visual compensation for horizontal scroll
|
||||
const applyScrollCompensation = (original: Element, cloned: HTMLElement) => {
|
||||
const scrollLeft = original.scrollLeft;
|
||||
|
||||
if (scrollLeft > 0) {
|
||||
// Create a wrapper to handle the scroll offset
|
||||
const scrollWrapper = document.createElement("div");
|
||||
scrollWrapper.style.overflow = "hidden";
|
||||
scrollWrapper.style.width = `${original.clientWidth}px`;
|
||||
|
||||
// Create inner container with the full content
|
||||
const innerContainer = document.createElement("div");
|
||||
innerContainer.style.transform = `translateX(-${scrollLeft}px)`;
|
||||
innerContainer.style.width = `${original.scrollWidth}px`;
|
||||
|
||||
// Move all children to the inner container
|
||||
while (cloned.firstChild) {
|
||||
innerContainer.append(cloned.firstChild);
|
||||
}
|
||||
|
||||
// Apply the original element's styles to maintain appearance
|
||||
const originalStyles = window.getComputedStyle(original);
|
||||
cloned.style.padding = "0";
|
||||
innerContainer.style.padding = originalStyles.padding;
|
||||
|
||||
scrollWrapper.append(innerContainer);
|
||||
cloned.append(scrollWrapper);
|
||||
}
|
||||
};
|
||||
|
||||
applyScrollCompensation(domNode, newDomNode);
|
||||
|
||||
ids.push(node.id as string);
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.append(newDomNode);
|
||||
wrapper.style.display = "flow-root";
|
||||
|
||||
const lastDomNode = blocks[index - 1];
|
||||
|
||||
if (lastDomNode) {
|
||||
const lastDomNodeRect = editor.api
|
||||
.toDOMNode(lastDomNode)!
|
||||
.parentElement!.getBoundingClientRect();
|
||||
|
||||
const domNodeRect = domNode.parentElement!.getBoundingClientRect();
|
||||
|
||||
const distance = domNodeRect.top - lastDomNodeRect.bottom;
|
||||
|
||||
// Check if the two elements are adjacent (touching each other)
|
||||
if (distance > 15) {
|
||||
wrapper.style.marginTop = `${distance}px`;
|
||||
}
|
||||
}
|
||||
|
||||
removeDataAttributes(newDomNode);
|
||||
elements.push(wrapper);
|
||||
};
|
||||
|
||||
blocks.forEach((node, index) => {
|
||||
resolveElement(node, index);
|
||||
});
|
||||
|
||||
editor.setOption(DndPlugin, "draggingId", ids);
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
const calculatePreviewTop = (
|
||||
editor: PlateEditor,
|
||||
{
|
||||
blocks,
|
||||
element,
|
||||
}: {
|
||||
blocks: TElement[];
|
||||
element: TElement;
|
||||
}
|
||||
): number => {
|
||||
const child = editor.api.toDOMNode(element)!;
|
||||
const editable = editor.api.toDOMNode(editor)!;
|
||||
const firstSelectedChild = blocks[0];
|
||||
|
||||
const firstDomNode = editor.api.toDOMNode(firstSelectedChild)!;
|
||||
// Get editor's top padding
|
||||
const editorPaddingTop = Number(window.getComputedStyle(editable).paddingTop.replace("px", ""));
|
||||
|
||||
// Calculate distance from first selected node to editor top
|
||||
const firstNodeToEditorDistance =
|
||||
firstDomNode.getBoundingClientRect().top -
|
||||
editable.getBoundingClientRect().top -
|
||||
editorPaddingTop;
|
||||
|
||||
// Get margin top of first selected node
|
||||
const firstMarginTopString = window.getComputedStyle(firstDomNode).marginTop;
|
||||
const marginTop = Number(firstMarginTopString.replace("px", ""));
|
||||
|
||||
// Calculate distance from current node to editor top
|
||||
const currentToEditorDistance =
|
||||
child.getBoundingClientRect().top - editable.getBoundingClientRect().top - editorPaddingTop;
|
||||
|
||||
const currentMarginTopString = window.getComputedStyle(child).marginTop;
|
||||
const currentMarginTop = Number(currentMarginTopString.replace("px", ""));
|
||||
|
||||
const previewElementsTopDistance =
|
||||
currentToEditorDistance - firstNodeToEditorDistance + marginTop - currentMarginTop;
|
||||
|
||||
return previewElementsTopDistance;
|
||||
};
|
||||
|
||||
const calcDragButtonTop = (editor: PlateEditor, element: TElement): number => {
|
||||
const child = editor.api.toDOMNode(element)!;
|
||||
|
||||
const currentMarginTopString = window.getComputedStyle(child).marginTop;
|
||||
const currentMarginTop = Number(currentMarginTopString.replace("px", ""));
|
||||
|
||||
return currentMarginTop;
|
||||
};
|
||||
72
surfsense_web/components/ui/block-list.tsx
Normal file
72
surfsense_web/components/ui/block-list.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import type { TListElement } from "platejs";
|
||||
|
||||
import { isOrderedList } from "@platejs/list";
|
||||
import { useTodoListElement, useTodoListElementState } from "@platejs/list/react";
|
||||
import { type PlateElementProps, type RenderNodeWrapper, useReadOnly } from "platejs/react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const config: Record<
|
||||
string,
|
||||
{
|
||||
Li: React.FC<PlateElementProps>;
|
||||
Marker: React.FC<PlateElementProps>;
|
||||
}
|
||||
> = {
|
||||
todo: {
|
||||
Li: TodoLi,
|
||||
Marker: TodoMarker,
|
||||
},
|
||||
};
|
||||
|
||||
export const BlockList: RenderNodeWrapper = (props) => {
|
||||
if (!props.element.listStyleType) return;
|
||||
|
||||
return (props) => <List {...props} />;
|
||||
};
|
||||
|
||||
function List(props: PlateElementProps) {
|
||||
const { listStart, listStyleType } = props.element as TListElement;
|
||||
const { Li, Marker } = config[listStyleType] ?? {};
|
||||
const List = isOrderedList(props.element) ? "ol" : "ul";
|
||||
|
||||
return (
|
||||
<List className="relative m-0 p-0" style={{ listStyleType }} start={listStart}>
|
||||
{Marker && <Marker {...props} />}
|
||||
{Li ? <Li {...props} /> : <li>{props.children}</li>}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoMarker(props: PlateElementProps) {
|
||||
const state = useTodoListElementState({ element: props.element });
|
||||
const { checkboxProps } = useTodoListElement(state);
|
||||
const readOnly = useReadOnly();
|
||||
|
||||
return (
|
||||
<div contentEditable={false}>
|
||||
<Checkbox
|
||||
className={cn("-left-6 absolute top-1", readOnly && "pointer-events-none")}
|
||||
{...checkboxProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoLi(props: PlateElementProps) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"list-none",
|
||||
(props.element.checked as boolean) && "text-muted-foreground line-through"
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
39
surfsense_web/components/ui/block-selection.tsx
Normal file
39
surfsense_web/components/ui/block-selection.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { DndPlugin } from "@platejs/dnd";
|
||||
import { useBlockSelected } from "@platejs/selection/react";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { type PlateElementProps, usePluginOption } from "platejs/react";
|
||||
|
||||
export const blockSelectionVariants = cva(
|
||||
"pointer-events-none absolute inset-0 z-1 bg-brand/[.13] transition-opacity",
|
||||
{
|
||||
defaultVariants: {
|
||||
active: true,
|
||||
},
|
||||
variants: {
|
||||
active: {
|
||||
false: "opacity-0",
|
||||
true: "opacity-100",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export function BlockSelection(props: PlateElementProps) {
|
||||
const isBlockSelected = useBlockSelected();
|
||||
const isDragging = usePluginOption(DndPlugin, "isDragging");
|
||||
|
||||
if (!isBlockSelected || props.plugin.key === "tr" || props.plugin.key === "table") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={blockSelectionVariants({
|
||||
active: isBlockSelected && !isDragging,
|
||||
})}
|
||||
data-slot="block-selection"
|
||||
/>
|
||||
);
|
||||
}
|
||||
7
surfsense_web/components/ui/blockquote-node.tsx
Normal file
7
surfsense_web/components/ui/blockquote-node.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { type PlateElementProps, PlateElement } from "platejs/react";
|
||||
|
||||
export function BlockquoteElement(props: PlateElementProps) {
|
||||
return <PlateElement as="blockquote" className="my-1 border-l-2 pl-6 italic" {...props} />;
|
||||
}
|
||||
77
surfsense_web/components/ui/callout-node.tsx
Normal file
77
surfsense_web/components/ui/callout-node.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { TCalloutElement } from "platejs";
|
||||
|
||||
import { CalloutPlugin } from "@platejs/callout/react";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { type PlateElementProps, PlateElement, useEditorPlugin } from "platejs/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const calloutVariants = cva("my-1 flex w-full items-start gap-2 rounded-lg border p-4", {
|
||||
defaultVariants: {
|
||||
variant: "info",
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
info: "border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/50",
|
||||
warning: "border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-950/50",
|
||||
error: "border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/50",
|
||||
success: "border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/50",
|
||||
note: "border-muted bg-muted/50",
|
||||
tip: "border-purple-200 bg-purple-50 dark:border-purple-800 dark:bg-purple-950/50",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const calloutIcons: Record<string, string> = {
|
||||
info: "💡",
|
||||
warning: "⚠️",
|
||||
error: "🚨",
|
||||
success: "✅",
|
||||
note: "📝",
|
||||
tip: "💜",
|
||||
};
|
||||
|
||||
const variantCycle = ["info", "warning", "error", "success", "note", "tip"] as const;
|
||||
|
||||
export function CalloutElement({ children, ...props }: PlateElementProps<TCalloutElement>) {
|
||||
const { editor } = useEditorPlugin(CalloutPlugin);
|
||||
const element = props.element;
|
||||
const variant = element.variant || "info";
|
||||
const icon = element.icon || calloutIcons[variant] || "💡";
|
||||
|
||||
const cycleVariant = React.useCallback(() => {
|
||||
const currentIndex = variantCycle.indexOf(variant as (typeof variantCycle)[number]);
|
||||
const nextIndex = (currentIndex + 1) % variantCycle.length;
|
||||
const nextVariant = variantCycle[nextIndex];
|
||||
|
||||
editor.tf.setNodes(
|
||||
{
|
||||
variant: nextVariant,
|
||||
icon: calloutIcons[nextVariant],
|
||||
},
|
||||
{ at: props.path }
|
||||
);
|
||||
}, [editor, variant, props.path]);
|
||||
|
||||
return (
|
||||
<PlateElement
|
||||
{...props}
|
||||
className={cn(calloutVariants({ variant: variant as any }), props.className)}
|
||||
>
|
||||
<button
|
||||
className="mt-0.5 shrink-0 cursor-pointer select-none text-lg leading-none"
|
||||
contentEditable={false}
|
||||
onClick={cycleVariant}
|
||||
type="button"
|
||||
aria-label="Change callout type"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon, MinusIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -11,17 +11,16 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
|||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary data-[state=indeterminate]:bg-transparent data-[state=indeterminate]:text-foreground data-[state=indeterminate]:border-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="group flex items-center justify-center text-current transition-none"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5 hidden group-data-[state=checked]:block" />
|
||||
<MinusIcon className="size-3.5 hidden group-data-[state=indeterminate]:block" />
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
|
|
|
|||
264
surfsense_web/components/ui/code-block-node.tsx
Normal file
264
surfsense_web/components/ui/code-block-node.tsx
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { formatCodeBlock, isLangSupported } from "@platejs/code-block";
|
||||
import { BracesIcon, Check, CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { type TCodeBlockElement, type TCodeSyntaxLeaf, NodeApi } from "platejs";
|
||||
import {
|
||||
type PlateElementProps,
|
||||
type PlateLeafProps,
|
||||
PlateElement,
|
||||
PlateLeaf,
|
||||
} from "platejs/react";
|
||||
import { useEditorRef, useElement, useReadOnly } from "platejs/react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function CodeBlockElement(props: PlateElementProps<TCodeBlockElement>) {
|
||||
const { editor, element } = props;
|
||||
|
||||
return (
|
||||
<PlateElement
|
||||
className="py-1 **:[.hljs-addition]:bg-[#f0fff4] **:[.hljs-addition]:text-[#22863a] dark:**:[.hljs-addition]:bg-[#3c5743] dark:**:[.hljs-addition]:text-[#ceead5] **:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#005cc5] dark:**:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#6596cf] **:[.hljs-built\\\\_in,.hljs-symbol]:text-[#e36209] dark:**:[.hljs-built\\\\_in,.hljs-symbol]:text-[#c3854e] **:[.hljs-bullet]:text-[#735c0f] **:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] dark:**:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] **:[.hljs-deletion]:bg-[#ffeef0] **:[.hljs-deletion]:text-[#b31d28] dark:**:[.hljs-deletion]:bg-[#473235] dark:**:[.hljs-deletion]:text-[#e7c7cb] **:[.hljs-emphasis]:italic **:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#d73a49] dark:**:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\_]:text-[#ee6960] **:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#22863a] dark:**:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#36a84f] **:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#032f62] dark:**:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#3593ff] **:[.hljs-section]:font-bold **:[.hljs-section]:text-[#005cc5] dark:**:[.hljs-section]:text-[#61a5f2] **:[.hljs-strong]:font-bold **:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#6f42c1] dark:**:[.hljs-title,.hljs-title.class\\\\_,.hljs-title.class\\\\_.inherited\\\\_\\\\_,.hljs-title.function\\\\_]:text-[#a77bfa]"
|
||||
{...props}
|
||||
>
|
||||
<div className="relative rounded-md bg-muted/50">
|
||||
<pre className="overflow-x-auto p-8 pr-4 font-mono text-sm leading-[normal] [tab-size:2] print:break-inside-avoid">
|
||||
<code>{props.children}</code>
|
||||
</pre>
|
||||
|
||||
<div
|
||||
className="absolute top-1 right-1 z-10 flex select-none gap-0.5"
|
||||
contentEditable={false}
|
||||
>
|
||||
{isLangSupported(element.lang) && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-6 text-xs"
|
||||
onClick={() => formatCodeBlock(editor, { element })}
|
||||
title="Format code"
|
||||
>
|
||||
<BracesIcon className="!size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<CodeBlockCombobox />
|
||||
|
||||
<CopyButton
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-6 gap-1 text-muted-foreground text-xs"
|
||||
value={() => NodeApi.string(element)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlockCombobox() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const readOnly = useReadOnly();
|
||||
const editor = useEditorRef();
|
||||
const element = useElement<TCodeBlockElement>();
|
||||
const value = element.lang || "plaintext";
|
||||
const [searchValue, setSearchValue] = React.useState("");
|
||||
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
languages.filter(
|
||||
(language) =>
|
||||
!searchValue || language.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
),
|
||||
[searchValue]
|
||||
);
|
||||
|
||||
if (readOnly) return null;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 select-none justify-between gap-1 px-2 text-muted-foreground text-xs"
|
||||
aria-expanded={open}
|
||||
role="combobox"
|
||||
>
|
||||
{languages.find((language) => language.value === value)?.label ?? "Plain Text"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" onCloseAutoFocus={() => setSearchValue("")}>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
className="h-9"
|
||||
value={searchValue}
|
||||
onValueChange={(value) => setSearchValue(value)}
|
||||
placeholder="Search language..."
|
||||
/>
|
||||
<CommandEmpty>No language found.</CommandEmpty>
|
||||
|
||||
<CommandList className="h-[344px] overflow-y-auto">
|
||||
<CommandGroup>
|
||||
{items.map((language) => (
|
||||
<CommandItem
|
||||
key={language.label}
|
||||
className="cursor-pointer"
|
||||
value={language.value}
|
||||
onSelect={(value) => {
|
||||
editor.tf.setNodes<TCodeBlockElement>({ lang: value }, { at: element });
|
||||
setSearchValue(value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn(value === language.value ? "opacity-100" : "opacity-0")} />
|
||||
{language.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({
|
||||
value,
|
||||
...props
|
||||
}: { value: (() => string) | string } & Omit<React.ComponentProps<typeof Button>, "value">) {
|
||||
const [hasCopied, setHasCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setHasCopied(false);
|
||||
}, 2000);
|
||||
}, [hasCopied]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(typeof value === "function" ? value() : value);
|
||||
setHasCopied(true);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<span className="sr-only">Copy</span>
|
||||
{hasCopied ? <CheckIcon className="!size-3" /> : <CopyIcon className="!size-3" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeLineElement(props: PlateElementProps) {
|
||||
return <PlateElement {...props} />;
|
||||
}
|
||||
|
||||
export function CodeSyntaxLeaf(props: PlateLeafProps<TCodeSyntaxLeaf>) {
|
||||
const tokenClassName = props.leaf.className as string;
|
||||
|
||||
return <PlateLeaf className={tokenClassName} {...props} />;
|
||||
}
|
||||
|
||||
const languages: { label: string; value: string }[] = [
|
||||
{ label: "Auto", value: "auto" },
|
||||
{ label: "Plain Text", value: "plaintext" },
|
||||
{ label: "ABAP", value: "abap" },
|
||||
{ label: "Agda", value: "agda" },
|
||||
{ label: "Arduino", value: "arduino" },
|
||||
{ label: "ASCII Art", value: "ascii" },
|
||||
{ label: "Assembly", value: "x86asm" },
|
||||
{ label: "Bash", value: "bash" },
|
||||
{ label: "BASIC", value: "basic" },
|
||||
{ label: "BNF", value: "bnf" },
|
||||
{ label: "C", value: "c" },
|
||||
{ label: "C#", value: "csharp" },
|
||||
{ label: "C++", value: "cpp" },
|
||||
{ label: "Clojure", value: "clojure" },
|
||||
{ label: "CoffeeScript", value: "coffeescript" },
|
||||
{ label: "Coq", value: "coq" },
|
||||
{ label: "CSS", value: "css" },
|
||||
{ label: "Dart", value: "dart" },
|
||||
{ label: "Dhall", value: "dhall" },
|
||||
{ label: "Diff", value: "diff" },
|
||||
{ label: "Docker", value: "dockerfile" },
|
||||
{ label: "EBNF", value: "ebnf" },
|
||||
{ label: "Elixir", value: "elixir" },
|
||||
{ label: "Elm", value: "elm" },
|
||||
{ label: "Erlang", value: "erlang" },
|
||||
{ label: "F#", value: "fsharp" },
|
||||
{ label: "Flow", value: "flow" },
|
||||
{ label: "Fortran", value: "fortran" },
|
||||
{ label: "Gherkin", value: "gherkin" },
|
||||
{ label: "GLSL", value: "glsl" },
|
||||
{ label: "Go", value: "go" },
|
||||
{ label: "GraphQL", value: "graphql" },
|
||||
{ label: "Groovy", value: "groovy" },
|
||||
{ label: "Haskell", value: "haskell" },
|
||||
{ label: "HCL", value: "hcl" },
|
||||
{ label: "HTML", value: "html" },
|
||||
{ label: "Idris", value: "idris" },
|
||||
{ label: "Java", value: "java" },
|
||||
{ label: "JavaScript", value: "javascript" },
|
||||
{ label: "JSON", value: "json" },
|
||||
{ label: "Julia", value: "julia" },
|
||||
{ label: "Kotlin", value: "kotlin" },
|
||||
{ label: "LaTeX", value: "latex" },
|
||||
{ label: "Less", value: "less" },
|
||||
{ label: "Lisp", value: "lisp" },
|
||||
{ label: "LiveScript", value: "livescript" },
|
||||
{ label: "LLVM IR", value: "llvm" },
|
||||
{ label: "Lua", value: "lua" },
|
||||
{ label: "Makefile", value: "makefile" },
|
||||
{ label: "Markdown", value: "markdown" },
|
||||
{ label: "Markup", value: "markup" },
|
||||
{ label: "MATLAB", value: "matlab" },
|
||||
{ label: "Mathematica", value: "mathematica" },
|
||||
{ label: "Mermaid", value: "mermaid" },
|
||||
{ label: "Nix", value: "nix" },
|
||||
{ label: "Notion Formula", value: "notion" },
|
||||
{ label: "Objective-C", value: "objectivec" },
|
||||
{ label: "OCaml", value: "ocaml" },
|
||||
{ label: "Pascal", value: "pascal" },
|
||||
{ label: "Perl", value: "perl" },
|
||||
{ label: "PHP", value: "php" },
|
||||
{ label: "PowerShell", value: "powershell" },
|
||||
{ label: "Prolog", value: "prolog" },
|
||||
{ label: "Protocol Buffers", value: "protobuf" },
|
||||
{ label: "PureScript", value: "purescript" },
|
||||
{ label: "Python", value: "python" },
|
||||
{ label: "R", value: "r" },
|
||||
{ label: "Racket", value: "racket" },
|
||||
{ label: "Reason", value: "reasonml" },
|
||||
{ label: "Ruby", value: "ruby" },
|
||||
{ label: "Rust", value: "rust" },
|
||||
{ label: "Sass", value: "scss" },
|
||||
{ label: "Scala", value: "scala" },
|
||||
{ label: "Scheme", value: "scheme" },
|
||||
{ label: "SCSS", value: "scss" },
|
||||
{ label: "Shell", value: "shell" },
|
||||
{ label: "Smalltalk", value: "smalltalk" },
|
||||
{ label: "Solidity", value: "solidity" },
|
||||
{ label: "SQL", value: "sql" },
|
||||
{ label: "Swift", value: "swift" },
|
||||
{ label: "TOML", value: "toml" },
|
||||
{ label: "TypeScript", value: "typescript" },
|
||||
{ label: "VB.Net", value: "vbnet" },
|
||||
{ label: "Verilog", value: "verilog" },
|
||||
{ label: "VHDL", value: "vhdl" },
|
||||
{ label: "Visual Basic", value: "vbnet" },
|
||||
{ label: "WebAssembly", value: "wasm" },
|
||||
{ label: "XML", value: "xml" },
|
||||
{ label: "YAML", value: "yaml" },
|
||||
];
|
||||
19
surfsense_web/components/ui/code-node.tsx
Normal file
19
surfsense_web/components/ui/code-node.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { PlateLeafProps } from "platejs/react";
|
||||
|
||||
import { PlateLeaf } from "platejs/react";
|
||||
|
||||
export function CodeLeaf(props: PlateLeafProps) {
|
||||
return (
|
||||
<PlateLeaf
|
||||
{...props}
|
||||
as="code"
|
||||
className="whitespace-pre-wrap rounded-md bg-muted px-[0.3em] py-[0.2em] font-mono text-sm"
|
||||
>
|
||||
{props.children}
|
||||
</PlateLeaf>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import * as React from "react";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ function DropdownMenuContent({
|
|||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -61,7 +61,7 @@ function DropdownMenuItem({
|
|||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 ",
|
||||
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -79,7 +79,7 @@ function DropdownMenuCheckboxItem({
|
|||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
|
|
@ -110,7 +110,7 @@ function DropdownMenuRadioItem({
|
|||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -182,13 +182,13 @@ function DropdownMenuSubTrigger({
|
|||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground dark:focus:bg-neutral-700 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground dark:data-[state=open]:bg-neutral-700 [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4 text-muted-foreground" />
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
|
@ -201,7 +201,7 @@ function DropdownMenuSubContent({
|
|||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
124
surfsense_web/components/ui/editor.tsx
Normal file
124
surfsense_web/components/ui/editor.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import type { PlateContentProps, PlateViewProps } from "platejs/react";
|
||||
|
||||
import { cva } from "class-variance-authority";
|
||||
import { PlateContainer, PlateContent, PlateView } from "platejs/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const editorContainerVariants = cva(
|
||||
"relative w-full cursor-text select-text overflow-y-auto caret-primary selection:bg-brand/25 focus-visible:outline-none [&_.slate-selection-area]:z-50 [&_.slate-selection-area]:border [&_.slate-selection-area]:border-brand/25 [&_.slate-selection-area]:bg-brand/15",
|
||||
{
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
comment: cn(
|
||||
"flex flex-wrap justify-between gap-1 px-1 py-0.5 text-sm",
|
||||
"rounded-md border-[1.5px] border-transparent bg-transparent",
|
||||
"has-[[data-slate-editor]:focus]:border-brand/50 has-[[data-slate-editor]:focus]:ring-2 has-[[data-slate-editor]:focus]:ring-brand/30",
|
||||
"has-aria-disabled:border-input has-aria-disabled:bg-muted"
|
||||
),
|
||||
default: "h-full",
|
||||
demo: "h-[650px]",
|
||||
select: cn(
|
||||
"group rounded-md border border-input ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
|
||||
"has-data-readonly:w-fit has-data-readonly:cursor-default has-data-readonly:border-transparent has-data-readonly:focus-within:[box-shadow:none]"
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export function EditorContainer({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof editorContainerVariants>) {
|
||||
return (
|
||||
<PlateContainer
|
||||
className={cn(
|
||||
"ignore-click-outside/toolbar",
|
||||
editorContainerVariants({ variant }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const editorVariants = cva(
|
||||
cn(
|
||||
"group/editor",
|
||||
"relative w-full cursor-text select-text overflow-x-hidden whitespace-pre-wrap break-words",
|
||||
"rounded-md ring-offset-background focus-visible:outline-none",
|
||||
"**:data-slate-placeholder:!top-1/2 **:data-slate-placeholder:-translate-y-1/2 placeholder:text-muted-foreground/80 **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!",
|
||||
"[&_strong]:font-bold"
|
||||
),
|
||||
{
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
variants: {
|
||||
disabled: {
|
||||
true: "cursor-not-allowed opacity-50",
|
||||
},
|
||||
focused: {
|
||||
true: "ring-2 ring-ring ring-offset-2",
|
||||
},
|
||||
variant: {
|
||||
ai: "w-full px-0 text-base md:text-sm",
|
||||
aiChat: "max-h-[min(70vh,320px)] w-full overflow-y-auto px-3 py-2 text-base md:text-sm",
|
||||
comment: cn("rounded-none border-none bg-transparent text-sm"),
|
||||
default: "size-full px-6 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]",
|
||||
demo: "size-full px-6 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]",
|
||||
fullWidth: "size-full px-6 pt-4 pb-72 text-base sm:px-24",
|
||||
none: "",
|
||||
select: "px-3 py-2 text-base data-readonly:w-fit",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type EditorProps = PlateContentProps & VariantProps<typeof editorVariants>;
|
||||
|
||||
export const Editor = ({
|
||||
className,
|
||||
disabled,
|
||||
focused,
|
||||
variant,
|
||||
ref,
|
||||
...props
|
||||
}: EditorProps & { ref?: React.RefObject<HTMLDivElement | null> }) => (
|
||||
<PlateContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
editorVariants({
|
||||
disabled,
|
||||
focused,
|
||||
variant,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
disableDefaultStyles
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
Editor.displayName = "Editor";
|
||||
|
||||
export function EditorView({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: PlateViewProps & VariantProps<typeof editorVariants>) {
|
||||
return <PlateView {...props} className={cn(editorVariants({ variant }), className)} />;
|
||||
}
|
||||
|
||||
EditorView.displayName = "EditorView";
|
||||
176
surfsense_web/components/ui/equation-node.tsx
Normal file
176
surfsense_web/components/ui/equation-node.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { TEquationElement } from "platejs";
|
||||
|
||||
import { useEquationElement, useEquationInput } from "@platejs/math/react";
|
||||
import { RadicalIcon } from "lucide-react";
|
||||
import { type PlateElementProps, PlateElement, useSelected } from "platejs/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function EquationElement({ children, ...props }: PlateElementProps<TEquationElement>) {
|
||||
const element = props.element;
|
||||
const selected = useSelected();
|
||||
const katexRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
|
||||
useEquationElement({
|
||||
element,
|
||||
katexRef,
|
||||
options: {
|
||||
displayMode: true,
|
||||
throwOnError: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
props: inputProps,
|
||||
ref: inputRef,
|
||||
onDismiss,
|
||||
onSubmit,
|
||||
} = useEquationInput({
|
||||
isInline: false,
|
||||
open: isEditing,
|
||||
onClose: () => setIsEditing(false),
|
||||
});
|
||||
|
||||
return (
|
||||
<PlateElement
|
||||
{...props}
|
||||
className={cn(
|
||||
"my-3 rounded-md py-2",
|
||||
selected && "ring-2 ring-ring ring-offset-2",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-center"
|
||||
contentEditable={false}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
>
|
||||
{element.texExpression ? (
|
||||
<div ref={katexRef} className="text-center" />
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<RadicalIcon className="size-4" />
|
||||
<span>Add an equation</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="mt-2 rounded-md border bg-muted/50 p-2" contentEditable={false}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="w-full resize-none rounded border-none bg-transparent p-2 font-mono text-sm outline-none"
|
||||
placeholder="E = mc^2"
|
||||
rows={3}
|
||||
{...inputProps}
|
||||
/>
|
||||
<div className="mt-1 flex justify-end gap-1">
|
||||
<button
|
||||
className="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:bg-primary/90"
|
||||
onClick={onSubmit}
|
||||
type="button"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineEquationElement({ children, ...props }: PlateElementProps<TEquationElement>) {
|
||||
const element = props.element;
|
||||
const selected = useSelected();
|
||||
const katexRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
|
||||
useEquationElement({
|
||||
element,
|
||||
katexRef,
|
||||
options: {
|
||||
displayMode: false,
|
||||
throwOnError: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
props: inputProps,
|
||||
ref: inputRef,
|
||||
onDismiss,
|
||||
onSubmit,
|
||||
} = useEquationInput({
|
||||
isInline: true,
|
||||
open: isEditing,
|
||||
onClose: () => setIsEditing(false),
|
||||
});
|
||||
|
||||
return (
|
||||
<PlateElement
|
||||
{...props}
|
||||
as="span"
|
||||
className={cn("inline rounded-sm px-0.5", selected && "bg-brand/15", props.className)}
|
||||
>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
contentEditable={false}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
>
|
||||
{element.texExpression ? (
|
||||
<span ref={katexRef} />
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<RadicalIcon className="inline size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{isEditing && (
|
||||
<span
|
||||
className="absolute z-50 mt-1 rounded-md border bg-popover p-2 shadow-md"
|
||||
contentEditable={false}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="w-48 resize-none rounded border-none bg-transparent p-1 font-mono text-sm outline-none"
|
||||
placeholder="x^2"
|
||||
rows={1}
|
||||
{...inputProps}
|
||||
/>
|
||||
<span className="mt-1 flex justify-end gap-1">
|
||||
<button
|
||||
className="rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground hover:bg-primary/90"
|
||||
onClick={onSubmit}
|
||||
type="button"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
136
surfsense_web/components/ui/fixed-toolbar-buttons.tsx
Normal file
136
surfsense_web/components/ui/fixed-toolbar-buttons.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
BoldIcon,
|
||||
Code2Icon,
|
||||
HighlighterIcon,
|
||||
ItalicIcon,
|
||||
RedoIcon,
|
||||
SaveIcon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
UndoIcon,
|
||||
} from "lucide-react";
|
||||
import { KEYS } from "platejs";
|
||||
import { useEditorReadOnly, useEditorRef } from "platejs/react";
|
||||
|
||||
import { useEditorSave } from "@/components/editor/editor-save-context";
|
||||
import { usePlatformShortcut } from "@/hooks/use-platform-shortcut";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
import { InsertToolbarButton } from "./insert-toolbar-button";
|
||||
import { LinkToolbarButton } from "./link-toolbar-button";
|
||||
import { MarkToolbarButton } from "./mark-toolbar-button";
|
||||
import { ModeToolbarButton } from "./mode-toolbar-button";
|
||||
import { ToolbarButton, ToolbarGroup } from "./toolbar";
|
||||
import { TurnIntoToolbarButton } from "./turn-into-toolbar-button";
|
||||
|
||||
export function FixedToolbarButtons() {
|
||||
const readOnly = useEditorReadOnly();
|
||||
const editor = useEditorRef();
|
||||
const { onSave, hasUnsavedChanges, isSaving, canToggleMode } = useEditorSave();
|
||||
const { shortcut } = usePlatformShortcut();
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center">
|
||||
{/* Scrollable editing buttons */}
|
||||
<div className="flex flex-1 min-w-0 overflow-x-auto scrollbar-hide">
|
||||
{!readOnly && (
|
||||
<>
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
tooltip={`Undo ${shortcut("Mod", "Z")}`}
|
||||
onClick={() => {
|
||||
editor.undo();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<UndoIcon />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
tooltip={`Redo ${shortcut("Mod", "Shift", "Z")}`}
|
||||
onClick={() => {
|
||||
editor.redo();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<RedoIcon />
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
|
||||
<ToolbarGroup>
|
||||
<InsertToolbarButton />
|
||||
<TurnIntoToolbarButton />
|
||||
</ToolbarGroup>
|
||||
|
||||
<ToolbarGroup>
|
||||
<MarkToolbarButton nodeType={KEYS.bold} tooltip={`Bold ${shortcut("Mod", "B")}`}>
|
||||
<BoldIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.italic} tooltip={`Italic ${shortcut("Mod", "I")}`}>
|
||||
<ItalicIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.underline}
|
||||
tooltip={`Underline ${shortcut("Mod", "U")}`}
|
||||
>
|
||||
<UnderlineIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.strikethrough}
|
||||
tooltip={`Strikethrough ${shortcut("Mod", "Shift", "X")}`}
|
||||
>
|
||||
<StrikethroughIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.code} tooltip={`Code ${shortcut("Mod", "E")}`}>
|
||||
<Code2Icon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton
|
||||
nodeType={KEYS.highlight}
|
||||
tooltip={`Highlight ${shortcut("Mod", "Shift", "H")}`}
|
||||
>
|
||||
<HighlighterIcon />
|
||||
</MarkToolbarButton>
|
||||
</ToolbarGroup>
|
||||
|
||||
<ToolbarGroup>
|
||||
<LinkToolbarButton />
|
||||
</ToolbarGroup>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed right-side buttons (Save + Mode) */}
|
||||
<div className="flex shrink-0 items-center">
|
||||
{/* Save button — only in edit mode with unsaved changes */}
|
||||
{!readOnly && onSave && hasUnsavedChanges && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
tooltip={isSaving ? "Saving..." : `Save ${shortcut("Mod", "S")}`}
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{isSaving ? <Spinner size="xs" /> : <SaveIcon />}
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
|
||||
{/* Mode toggle */}
|
||||
{canToggleMode && (
|
||||
<ToolbarGroup>
|
||||
<ModeToolbarButton />
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
surfsense_web/components/ui/fixed-toolbar.tsx
Normal file
25
surfsense_web/components/ui/fixed-toolbar.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Toolbar } from "./toolbar";
|
||||
|
||||
export function FixedToolbar({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Toolbar>) {
|
||||
return (
|
||||
<Toolbar
|
||||
className={cn(
|
||||
"scrollbar-hide sticky top-0 left-0 z-50 w-full justify-between overflow-x-auto rounded-t-lg border-b bg-background/95 p-1 backdrop-blur supports-backdrop-filter:bg-background/60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
48
surfsense_web/components/ui/floating-toolbar-buttons.tsx
Normal file
48
surfsense_web/components/ui/floating-toolbar-buttons.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { BoldIcon, Code2Icon, ItalicIcon, StrikethroughIcon, UnderlineIcon } from "lucide-react";
|
||||
import { KEYS } from "platejs";
|
||||
import { useEditorReadOnly } from "platejs/react";
|
||||
|
||||
import { LinkToolbarButton } from "./link-toolbar-button";
|
||||
import { MarkToolbarButton } from "./mark-toolbar-button";
|
||||
import { ToolbarGroup } from "./toolbar";
|
||||
import { TurnIntoToolbarButton } from "./turn-into-toolbar-button";
|
||||
|
||||
export function FloatingToolbarButtons() {
|
||||
const readOnly = useEditorReadOnly();
|
||||
|
||||
if (readOnly) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolbarGroup>
|
||||
<TurnIntoToolbarButton tooltip={false} />
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.bold}>
|
||||
<BoldIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.italic}>
|
||||
<ItalicIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.underline}>
|
||||
<UnderlineIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.strikethrough}>
|
||||
<StrikethroughIcon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<MarkToolbarButton nodeType={KEYS.code}>
|
||||
<Code2Icon />
|
||||
</MarkToolbarButton>
|
||||
|
||||
<LinkToolbarButton tooltip={false} />
|
||||
</ToolbarGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
79
surfsense_web/components/ui/floating-toolbar.tsx
Normal file
79
surfsense_web/components/ui/floating-toolbar.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
type FloatingToolbarState,
|
||||
flip,
|
||||
offset,
|
||||
useFloatingToolbar,
|
||||
useFloatingToolbarState,
|
||||
} from "@platejs/floating";
|
||||
import { useComposedRef } from "@udecode/cn";
|
||||
import { KEYS } from "platejs";
|
||||
import { useEditorId, useEventEditorValue, usePluginOption } from "platejs/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
||||
import { Toolbar } from "./toolbar";
|
||||
|
||||
export function FloatingToolbar({
|
||||
children,
|
||||
className,
|
||||
state,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Toolbar> & {
|
||||
state?: FloatingToolbarState;
|
||||
}) {
|
||||
const editorId = useEditorId();
|
||||
const focusedEditorId = useEventEditorValue("focus");
|
||||
const isFloatingLinkOpen = !!usePluginOption({ key: KEYS.link }, "mode");
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const floatingToolbarState = useFloatingToolbarState({
|
||||
editorId,
|
||||
focusedEditorId,
|
||||
hideToolbar: isFloatingLinkOpen,
|
||||
...state,
|
||||
floatingOptions: {
|
||||
middleware: [
|
||||
offset(12),
|
||||
flip({
|
||||
fallbackPlacements: ["top-start", "top-end", "bottom-start", "bottom-end"],
|
||||
padding: 12,
|
||||
}),
|
||||
],
|
||||
placement: "top",
|
||||
...state?.floatingOptions,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
clickOutsideRef,
|
||||
hidden,
|
||||
props: rootProps,
|
||||
ref: floatingRef,
|
||||
} = useFloatingToolbar(floatingToolbarState);
|
||||
|
||||
const ref = useComposedRef<HTMLDivElement>(props.ref, floatingRef);
|
||||
|
||||
if (hidden || isMobile) return null;
|
||||
|
||||
return (
|
||||
<div ref={clickOutsideRef}>
|
||||
<Toolbar
|
||||
{...props}
|
||||
{...rootProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"scrollbar-hide absolute z-50 overflow-x-auto whitespace-nowrap rounded-md border bg-popover p-1 opacity-100 shadow-md print:hidden dark:bg-neutral-800 dark:border-neutral-700",
|
||||
"max-w-[80vw]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Toolbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
surfsense_web/components/ui/heading-node.tsx
Normal file
56
surfsense_web/components/ui/heading-node.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { PlateElementProps } from "platejs/react";
|
||||
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { PlateElement } from "platejs/react";
|
||||
|
||||
const headingVariants = cva("relative mb-1", {
|
||||
variants: {
|
||||
variant: {
|
||||
h1: "mt-[1.6em] pb-1 font-bold font-heading text-4xl",
|
||||
h2: "mt-[1.4em] pb-px font-heading font-semibold text-2xl tracking-tight",
|
||||
h3: "mt-[1em] pb-px font-heading font-semibold text-xl tracking-tight",
|
||||
h4: "mt-[0.75em] font-heading font-semibold text-lg tracking-tight",
|
||||
h5: "mt-[0.75em] font-semibold text-lg tracking-tight",
|
||||
h6: "mt-[0.75em] font-semibold text-base tracking-tight",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function HeadingElement({
|
||||
variant = "h1",
|
||||
...props
|
||||
}: PlateElementProps & VariantProps<typeof headingVariants>) {
|
||||
return (
|
||||
<PlateElement as={variant!} className={headingVariants({ variant })} {...props}>
|
||||
{props.children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function H1Element(props: PlateElementProps) {
|
||||
return <HeadingElement variant="h1" {...props} />;
|
||||
}
|
||||
|
||||
export function H2Element(props: PlateElementProps) {
|
||||
return <HeadingElement variant="h2" {...props} />;
|
||||
}
|
||||
|
||||
export function H3Element(props: PlateElementProps) {
|
||||
return <HeadingElement variant="h3" {...props} />;
|
||||
}
|
||||
|
||||
export function H4Element(props: PlateElementProps) {
|
||||
return <HeadingElement variant="h4" {...props} />;
|
||||
}
|
||||
|
||||
export function H5Element(props: PlateElementProps) {
|
||||
return <HeadingElement variant="h5" {...props} />;
|
||||
}
|
||||
|
||||
export function H6Element(props: PlateElementProps) {
|
||||
return <HeadingElement variant="h6" {...props} />;
|
||||
}
|
||||
15
surfsense_web/components/ui/highlight-node.tsx
Normal file
15
surfsense_web/components/ui/highlight-node.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { PlateLeafProps } from "platejs/react";
|
||||
|
||||
import { PlateLeaf } from "platejs/react";
|
||||
|
||||
export function HighlightLeaf(props: PlateLeafProps) {
|
||||
return (
|
||||
<PlateLeaf {...props} as="mark" className="bg-yellow-200 dark:bg-yellow-800 text-inherit">
|
||||
{props.children}
|
||||
</PlateLeaf>
|
||||
);
|
||||
}
|
||||
30
surfsense_web/components/ui/hr-node.tsx
Normal file
30
surfsense_web/components/ui/hr-node.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { PlateElementProps } from "platejs/react";
|
||||
|
||||
import { PlateElement, useFocused, useReadOnly, useSelected } from "platejs/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function HrElement(props: PlateElementProps) {
|
||||
const readOnly = useReadOnly();
|
||||
const selected = useSelected();
|
||||
const focused = useFocused();
|
||||
|
||||
return (
|
||||
<PlateElement {...props}>
|
||||
<div className="py-6" contentEditable={false}>
|
||||
<hr
|
||||
className={cn(
|
||||
"h-0.5 rounded-sm border-none bg-muted bg-clip-content",
|
||||
selected && focused && "ring-2 ring-ring ring-offset-2",
|
||||
!readOnly && "cursor-pointer"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{props.children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
406
surfsense_web/components/ui/inline-combobox.tsx
Normal file
406
surfsense_web/components/ui/inline-combobox.tsx
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { Point, TElement } from "platejs";
|
||||
|
||||
import {
|
||||
type ComboboxItemProps,
|
||||
Combobox,
|
||||
ComboboxGroup,
|
||||
ComboboxGroupLabel,
|
||||
ComboboxItem,
|
||||
ComboboxPopover,
|
||||
ComboboxProvider,
|
||||
ComboboxRow,
|
||||
Portal,
|
||||
useComboboxContext,
|
||||
useComboboxStore,
|
||||
} from "@ariakit/react";
|
||||
import { filterWords } from "@platejs/combobox";
|
||||
import {
|
||||
type UseComboboxInputResult,
|
||||
useComboboxInput,
|
||||
useHTMLInputCursorState,
|
||||
} from "@platejs/combobox/react";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { useComposedRef, useEditorRef } from "platejs/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function useRequiredComboboxContext() {
|
||||
const context = useComboboxContext();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("InlineCombobox compound components must be rendered within InlineCombobox");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
type FilterFn = (
|
||||
item: { value: string; group?: string; keywords?: string[]; label?: string },
|
||||
search: string
|
||||
) => boolean;
|
||||
|
||||
type InlineComboboxContextValue = {
|
||||
filter: FilterFn | false;
|
||||
inputProps: UseComboboxInputResult["props"];
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
removeInput: UseComboboxInputResult["removeInput"];
|
||||
showTrigger: boolean;
|
||||
trigger: string;
|
||||
setHasEmpty: (hasEmpty: boolean) => void;
|
||||
};
|
||||
|
||||
const InlineComboboxContext = React.createContext<InlineComboboxContextValue>(
|
||||
null as unknown as InlineComboboxContextValue
|
||||
);
|
||||
|
||||
const defaultFilter: FilterFn = ({ group, keywords = [], label, value }, search) => {
|
||||
const uniqueTerms = new Set([value, ...keywords, group, label].filter(Boolean));
|
||||
|
||||
return Array.from(uniqueTerms).some((keyword) => filterWords(keyword as string, search));
|
||||
};
|
||||
|
||||
type InlineComboboxProps = {
|
||||
children: React.ReactNode;
|
||||
element: TElement;
|
||||
trigger: string;
|
||||
filter?: FilterFn | false;
|
||||
hideWhenNoValue?: boolean;
|
||||
showTrigger?: boolean;
|
||||
value?: string;
|
||||
setValue?: (value: string) => void;
|
||||
};
|
||||
|
||||
const InlineCombobox = ({
|
||||
children,
|
||||
element,
|
||||
filter = defaultFilter,
|
||||
hideWhenNoValue = false,
|
||||
setValue: setValueProp,
|
||||
showTrigger = true,
|
||||
trigger,
|
||||
value: valueProp,
|
||||
}: InlineComboboxProps) => {
|
||||
const editor = useEditorRef();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const cursorState = useHTMLInputCursorState(inputRef);
|
||||
|
||||
const [valueState, setValueState] = React.useState("");
|
||||
const hasValueProp = valueProp !== undefined;
|
||||
const value = hasValueProp ? valueProp : valueState;
|
||||
|
||||
// Check if current user is the creator of this element (for Yjs collaboration)
|
||||
const isCreator = React.useMemo(() => {
|
||||
const elementUserId = (element as Record<string, unknown>).userId;
|
||||
const currentUserId = editor.meta.userId;
|
||||
|
||||
// If no userId (backwards compatibility or non-Yjs), allow
|
||||
if (!elementUserId) return true;
|
||||
|
||||
return elementUserId === currentUserId;
|
||||
}, [editor.meta.userId, element]);
|
||||
|
||||
const setValue = React.useCallback(
|
||||
(newValue: string) => {
|
||||
setValueProp?.(newValue);
|
||||
|
||||
if (!hasValueProp) {
|
||||
setValueState(newValue);
|
||||
}
|
||||
},
|
||||
[setValueProp, hasValueProp]
|
||||
);
|
||||
|
||||
/**
|
||||
* Track the point just before the input element so we know where to
|
||||
* insertText if the combobox closes due to a selection change.
|
||||
*/
|
||||
const insertPoint = React.useRef<Point | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const path = editor.api.findPath(element);
|
||||
|
||||
if (!path) return;
|
||||
|
||||
const point = editor.api.before(path);
|
||||
|
||||
if (!point) return;
|
||||
|
||||
const pointRef = editor.api.pointRef(point);
|
||||
insertPoint.current = pointRef.current;
|
||||
|
||||
return () => {
|
||||
pointRef.unref();
|
||||
};
|
||||
}, [editor, element]);
|
||||
|
||||
const { props: inputProps, removeInput } = useComboboxInput({
|
||||
cancelInputOnBlur: true,
|
||||
cursorState,
|
||||
autoFocus: isCreator,
|
||||
ref: inputRef,
|
||||
onCancelInput: (cause) => {
|
||||
if (cause !== "backspace") {
|
||||
editor.tf.insertText(trigger + value, {
|
||||
at: insertPoint?.current ?? undefined,
|
||||
});
|
||||
}
|
||||
if (cause === "arrowLeft" || cause === "arrowRight") {
|
||||
editor.tf.move({
|
||||
distance: 1,
|
||||
reverse: cause === "arrowLeft",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [hasEmpty, setHasEmpty] = React.useState(false);
|
||||
|
||||
const contextValue: InlineComboboxContextValue = React.useMemo(
|
||||
() => ({
|
||||
filter,
|
||||
inputProps,
|
||||
inputRef,
|
||||
removeInput,
|
||||
setHasEmpty,
|
||||
showTrigger,
|
||||
trigger,
|
||||
}),
|
||||
[trigger, showTrigger, filter, inputProps, removeInput]
|
||||
);
|
||||
|
||||
const store = useComboboxStore({
|
||||
// open: ,
|
||||
setValue: (newValue) => React.startTransition(() => setValue(newValue)),
|
||||
});
|
||||
|
||||
const items = store.useState("items");
|
||||
|
||||
/**
|
||||
* If there is no active ID and the list of items changes, select the first
|
||||
* item.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (items.length === 0) return;
|
||||
|
||||
if (!store.getState().activeId) {
|
||||
store.setActiveId(store.first());
|
||||
}
|
||||
}, [items, store]);
|
||||
|
||||
return (
|
||||
<span contentEditable={false}>
|
||||
<ComboboxProvider
|
||||
open={(items.length > 0 || hasEmpty) && (!hideWhenNoValue || value.length > 0)}
|
||||
store={store}
|
||||
>
|
||||
<InlineComboboxContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</InlineComboboxContext.Provider>
|
||||
</ComboboxProvider>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const InlineComboboxInput = ({
|
||||
className,
|
||||
ref: propRef,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLInputElement> & {
|
||||
ref?: React.RefObject<HTMLInputElement | null>;
|
||||
}) => {
|
||||
const {
|
||||
inputProps,
|
||||
inputRef: contextRef,
|
||||
showTrigger,
|
||||
trigger,
|
||||
} = React.useContext(InlineComboboxContext);
|
||||
|
||||
const store = useRequiredComboboxContext();
|
||||
const value = store.useState("value");
|
||||
|
||||
const ref = useComposedRef(propRef, contextRef);
|
||||
|
||||
/**
|
||||
* To create an auto-resizing input, we render a visually hidden span
|
||||
* containing the input value and position the input element on top of it.
|
||||
* This works well for all cases except when input exceeds the width of the
|
||||
* container.
|
||||
*/
|
||||
|
||||
return (
|
||||
<>
|
||||
{showTrigger && trigger}
|
||||
|
||||
<span className="relative min-h-[1lh]">
|
||||
<span className="invisible overflow-hidden text-nowrap" aria-hidden="true">
|
||||
{value || "\u200B"}
|
||||
</span>
|
||||
|
||||
<Combobox
|
||||
ref={ref}
|
||||
className={cn("absolute top-0 left-0 size-full bg-transparent outline-none", className)}
|
||||
value={value}
|
||||
autoSelect
|
||||
{...inputProps}
|
||||
{...props}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
InlineComboboxInput.displayName = "InlineComboboxInput";
|
||||
|
||||
const InlineComboboxContent: typeof ComboboxPopover = ({ className, ...props }) => {
|
||||
// Portal prevents CSS from leaking into popover
|
||||
const store = useComboboxContext();
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (!store) return;
|
||||
|
||||
const state = store.getState();
|
||||
const { items, activeId } = state;
|
||||
|
||||
if (!items.length) return;
|
||||
|
||||
const currentIndex = items.findIndex((item) => item.id === activeId);
|
||||
|
||||
if (event.key === "ArrowUp" && currentIndex <= 0) {
|
||||
event.preventDefault();
|
||||
store.setActiveId(store.last());
|
||||
} else if (event.key === "ArrowDown" && currentIndex >= items.length - 1) {
|
||||
event.preventDefault();
|
||||
store.setActiveId(store.first());
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<ComboboxPopover
|
||||
className={cn(
|
||||
"z-500 max-h-[288px] w-[300px] overflow-y-auto rounded-md bg-popover shadow-md",
|
||||
className
|
||||
)}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
const comboboxItemVariants = cva(
|
||||
"relative mx-1 flex h-[28px] select-none items-center rounded-sm px-2 text-foreground text-sm outline-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
defaultVariants: {
|
||||
interactive: true,
|
||||
},
|
||||
variants: {
|
||||
interactive: {
|
||||
false: "",
|
||||
true: "cursor-pointer transition-colors hover:bg-accent hover:text-accent-foreground data-[active-item=true]:bg-accent data-[active-item=true]:text-accent-foreground dark:hover:bg-neutral-700 dark:data-[active-item=true]:bg-neutral-700",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const InlineComboboxItem = ({
|
||||
className,
|
||||
focusEditor = true,
|
||||
group,
|
||||
keywords,
|
||||
label,
|
||||
onClick,
|
||||
...props
|
||||
}: {
|
||||
focusEditor?: boolean;
|
||||
group?: string;
|
||||
keywords?: string[];
|
||||
label?: string;
|
||||
} & ComboboxItemProps &
|
||||
Required<Pick<ComboboxItemProps, "value">>) => {
|
||||
const { value } = props;
|
||||
|
||||
const { filter, removeInput } = React.useContext(InlineComboboxContext);
|
||||
|
||||
const store = useRequiredComboboxContext();
|
||||
|
||||
// Always call hook unconditionally; only use value if filter is active
|
||||
const storeValue = store.useState("value");
|
||||
const search = filter ? storeValue : "";
|
||||
|
||||
const visible = React.useMemo(
|
||||
() => !filter || filter({ group, keywords, label, value }, search),
|
||||
[filter, group, keywords, label, value, search]
|
||||
);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
className={cn(comboboxItemVariants(), className)}
|
||||
onClick={(event) => {
|
||||
removeInput(focusEditor);
|
||||
onClick?.(event);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const InlineComboboxEmpty = ({ children, className }: React.HTMLAttributes<HTMLDivElement>) => {
|
||||
const { setHasEmpty } = React.useContext(InlineComboboxContext);
|
||||
const store = useRequiredComboboxContext();
|
||||
const items = store.useState("items");
|
||||
|
||||
React.useEffect(() => {
|
||||
setHasEmpty(true);
|
||||
|
||||
return () => {
|
||||
setHasEmpty(false);
|
||||
};
|
||||
}, [setHasEmpty]);
|
||||
|
||||
if (items.length > 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn(comboboxItemVariants({ interactive: false }), className)}>{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InlineComboboxRow = ComboboxRow;
|
||||
|
||||
function InlineComboboxGroup({ className, ...props }: React.ComponentProps<typeof ComboboxGroup>) {
|
||||
return (
|
||||
<ComboboxGroup
|
||||
{...props}
|
||||
className={cn("hidden not-last:border-b py-1.5 [&:has([role=option])]:block", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineComboboxGroupLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ComboboxGroupLabel>) {
|
||||
return (
|
||||
<ComboboxGroupLabel
|
||||
{...props}
|
||||
className={cn("mt-1.5 mb-2 px-3 font-medium text-muted-foreground text-xs", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
InlineCombobox,
|
||||
InlineComboboxContent,
|
||||
InlineComboboxEmpty,
|
||||
InlineComboboxGroup,
|
||||
InlineComboboxGroupLabel,
|
||||
InlineComboboxInput,
|
||||
InlineComboboxItem,
|
||||
InlineComboboxRow,
|
||||
};
|
||||
225
surfsense_web/components/ui/insert-toolbar-button.tsx
Normal file
225
surfsense_web/components/ui/insert-toolbar-button.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { DropdownMenuProps } from "@radix-ui/react-dropdown-menu";
|
||||
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
FileCodeIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
InfoIcon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
MinusIcon,
|
||||
PilcrowIcon,
|
||||
PlusIcon,
|
||||
QuoteIcon,
|
||||
RadicalIcon,
|
||||
SquareIcon,
|
||||
SubscriptIcon,
|
||||
SuperscriptIcon,
|
||||
TableIcon,
|
||||
} from "lucide-react";
|
||||
import { KEYS } from "platejs";
|
||||
import { type PlateEditor, useEditorRef } from "platejs/react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { insertBlock, insertInlineElement } from "@/components/editor/transforms";
|
||||
|
||||
import { ToolbarButton, ToolbarMenuGroup } from "./toolbar";
|
||||
|
||||
type Group = {
|
||||
group: string;
|
||||
items: Item[];
|
||||
};
|
||||
|
||||
type Item = {
|
||||
icon: React.ReactNode;
|
||||
value: string;
|
||||
onSelect: (editor: PlateEditor, value: string) => void;
|
||||
focusEditor?: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const groups: Group[] = [
|
||||
{
|
||||
group: "Basic blocks",
|
||||
items: [
|
||||
{
|
||||
icon: <PilcrowIcon />,
|
||||
label: "Paragraph",
|
||||
value: KEYS.p,
|
||||
},
|
||||
{
|
||||
icon: <Heading1Icon />,
|
||||
label: "Heading 1",
|
||||
value: "h1",
|
||||
},
|
||||
{
|
||||
icon: <Heading2Icon />,
|
||||
label: "Heading 2",
|
||||
value: "h2",
|
||||
},
|
||||
{
|
||||
icon: <Heading3Icon />,
|
||||
label: "Heading 3",
|
||||
value: "h3",
|
||||
},
|
||||
{
|
||||
icon: <TableIcon />,
|
||||
label: "Table",
|
||||
value: KEYS.table,
|
||||
},
|
||||
{
|
||||
icon: <FileCodeIcon />,
|
||||
label: "Code block",
|
||||
value: KEYS.codeBlock,
|
||||
},
|
||||
{
|
||||
icon: <QuoteIcon />,
|
||||
label: "Quote",
|
||||
value: KEYS.blockquote,
|
||||
},
|
||||
{
|
||||
icon: <MinusIcon />,
|
||||
label: "Divider",
|
||||
value: KEYS.hr,
|
||||
},
|
||||
].map((item) => ({
|
||||
...item,
|
||||
onSelect: (editor: PlateEditor, value: string) => {
|
||||
insertBlock(editor, value);
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
group: "Lists",
|
||||
items: [
|
||||
{
|
||||
icon: <ListIcon />,
|
||||
label: "Bulleted list",
|
||||
value: KEYS.ul,
|
||||
},
|
||||
{
|
||||
icon: <ListOrderedIcon />,
|
||||
label: "Numbered list",
|
||||
value: KEYS.ol,
|
||||
},
|
||||
{
|
||||
icon: <SquareIcon />,
|
||||
label: "To-do list",
|
||||
value: KEYS.listTodo,
|
||||
},
|
||||
{
|
||||
icon: <ChevronRightIcon />,
|
||||
label: "Toggle list",
|
||||
value: KEYS.toggle,
|
||||
},
|
||||
].map((item) => ({
|
||||
...item,
|
||||
onSelect: (editor: PlateEditor, value: string) => {
|
||||
insertBlock(editor, value);
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
group: "Advanced",
|
||||
items: [
|
||||
{
|
||||
icon: <InfoIcon />,
|
||||
label: "Callout",
|
||||
value: KEYS.callout,
|
||||
},
|
||||
{
|
||||
focusEditor: false,
|
||||
icon: <RadicalIcon />,
|
||||
label: "Equation",
|
||||
value: KEYS.equation,
|
||||
},
|
||||
].map((item) => ({
|
||||
...item,
|
||||
onSelect: (editor: PlateEditor, value: string) => {
|
||||
if (item.value === KEYS.equation) {
|
||||
insertInlineElement(editor, value);
|
||||
} else {
|
||||
insertBlock(editor, value);
|
||||
}
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
group: "Marks",
|
||||
items: [
|
||||
{
|
||||
icon: <SuperscriptIcon />,
|
||||
label: "Superscript",
|
||||
value: KEYS.sup,
|
||||
},
|
||||
{
|
||||
icon: <SubscriptIcon />,
|
||||
label: "Subscript",
|
||||
value: KEYS.sub,
|
||||
},
|
||||
].map((item) => ({
|
||||
...item,
|
||||
onSelect: (editor: PlateEditor, value: string) => {
|
||||
editor.tf.toggleMark(value, {
|
||||
remove: value === KEYS.sup ? KEYS.sub : KEYS.sup,
|
||||
});
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
export function InsertToolbarButton(props: DropdownMenuProps) {
|
||||
const editor = useEditorRef();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ToolbarButton pressed={open} tooltip="Insert" isDropdown>
|
||||
<PlusIcon />
|
||||
</ToolbarButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="z-[100] flex max-h-[60vh] min-w-0 flex-col overflow-y-auto dark:bg-neutral-800 dark:border dark:border-neutral-700"
|
||||
align="start"
|
||||
>
|
||||
{groups.map(({ group, items }) => (
|
||||
<React.Fragment key={group}>
|
||||
<ToolbarMenuGroup label={group}>
|
||||
{items.map(({ icon, label, value, onSelect, focusEditor }) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onSelect={() => {
|
||||
onSelect(editor, value);
|
||||
if (focusEditor !== false) {
|
||||
editor.tf.focus();
|
||||
}
|
||||
setOpen(false);
|
||||
}}
|
||||
className="group"
|
||||
>
|
||||
<div className="flex items-center text-sm dark:text-white text-muted-foreground focus:text-accent-foreground group-aria-selected:text-accent-foreground">
|
||||
{icon}
|
||||
<span className="ml-2">{label || value}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</ToolbarMenuGroup>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
32
surfsense_web/components/ui/link-node.tsx
Normal file
32
surfsense_web/components/ui/link-node.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { TLinkElement } from "platejs";
|
||||
import type { PlateElementProps } from "platejs/react";
|
||||
|
||||
import { getLinkAttributes } from "@platejs/link";
|
||||
import { PlateElement } from "platejs/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function LinkElement(props: PlateElementProps<TLinkElement>) {
|
||||
return (
|
||||
<PlateElement
|
||||
{...props}
|
||||
as="a"
|
||||
className={cn(
|
||||
"font-medium text-blue-600 underline decoration-blue-600 underline-offset-4 hover:text-blue-800 dark:text-blue-400 dark:decoration-blue-400 dark:hover:text-blue-300"
|
||||
)}
|
||||
attributes={{
|
||||
...props.attributes,
|
||||
...getLinkAttributes(props.editor, props.element),
|
||||
onMouseOver: (e) => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
19
surfsense_web/components/ui/link-toolbar-button.tsx
Normal file
19
surfsense_web/components/ui/link-toolbar-button.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useLinkToolbarButton, useLinkToolbarButtonState } from "@platejs/link/react";
|
||||
import { Link } from "lucide-react";
|
||||
|
||||
import { ToolbarButton } from "./toolbar";
|
||||
|
||||
export function LinkToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {
|
||||
const state = useLinkToolbarButtonState();
|
||||
const { props: buttonProps } = useLinkToolbarButton(state);
|
||||
|
||||
return (
|
||||
<ToolbarButton tooltip="Link" {...props} {...buttonProps} data-plate-focus>
|
||||
<Link />
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
196
surfsense_web/components/ui/link-toolbar.tsx
Normal file
196
surfsense_web/components/ui/link-toolbar.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { TLinkElement } from "platejs";
|
||||
|
||||
import { type UseVirtualFloatingOptions, flip, offset } from "@platejs/floating";
|
||||
import { getLinkAttributes } from "@platejs/link";
|
||||
import {
|
||||
type LinkFloatingToolbarState,
|
||||
FloatingLinkUrlInput,
|
||||
useFloatingLinkEdit,
|
||||
useFloatingLinkEditState,
|
||||
useFloatingLinkInsert,
|
||||
useFloatingLinkInsertState,
|
||||
} from "@platejs/link/react";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ExternalLink, Link, Text, Unlink } from "lucide-react";
|
||||
import { KEYS } from "platejs";
|
||||
import {
|
||||
useEditorRef,
|
||||
useEditorSelection,
|
||||
useFormInputProps,
|
||||
usePluginOption,
|
||||
} from "platejs/react";
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
const popoverVariants = cva(
|
||||
"z-50 w-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden"
|
||||
);
|
||||
|
||||
const inputVariants = cva(
|
||||
"flex h-[28px] w-full rounded-md border-none bg-transparent px-1.5 py-1 text-base placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-transparent md:text-sm"
|
||||
);
|
||||
|
||||
export function LinkFloatingToolbar({ state }: { state?: LinkFloatingToolbarState }) {
|
||||
const activeCommentId = usePluginOption({ key: KEYS.comment }, "activeId");
|
||||
const activeSuggestionId = usePluginOption({ key: KEYS.suggestion }, "activeId");
|
||||
|
||||
const floatingOptions: UseVirtualFloatingOptions = React.useMemo(
|
||||
() => ({
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip({
|
||||
fallbackPlacements: ["bottom-end", "top-start", "top-end"],
|
||||
padding: 12,
|
||||
}),
|
||||
],
|
||||
placement: activeSuggestionId || activeCommentId ? "top-start" : "bottom-start",
|
||||
}),
|
||||
[activeCommentId, activeSuggestionId]
|
||||
);
|
||||
|
||||
const insertState = useFloatingLinkInsertState({
|
||||
...state,
|
||||
floatingOptions: {
|
||||
...floatingOptions,
|
||||
...state?.floatingOptions,
|
||||
},
|
||||
});
|
||||
const {
|
||||
hidden,
|
||||
props: insertProps,
|
||||
ref: insertRef,
|
||||
textInputProps,
|
||||
} = useFloatingLinkInsert(insertState);
|
||||
|
||||
const editState = useFloatingLinkEditState({
|
||||
...state,
|
||||
floatingOptions: {
|
||||
...floatingOptions,
|
||||
...state?.floatingOptions,
|
||||
},
|
||||
});
|
||||
const {
|
||||
editButtonProps,
|
||||
props: editProps,
|
||||
ref: editRef,
|
||||
unlinkButtonProps,
|
||||
} = useFloatingLinkEdit(editState);
|
||||
const inputProps = useFormInputProps({
|
||||
preventDefaultOnEnterKeydown: true,
|
||||
});
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
const input = (
|
||||
<div className="flex w-[330px] flex-col" {...inputProps}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center pr-1 pl-2 text-muted-foreground">
|
||||
<Link className="size-4" />
|
||||
</div>
|
||||
|
||||
<FloatingLinkUrlInput
|
||||
className={inputVariants()}
|
||||
placeholder="Paste link"
|
||||
data-plate-focus
|
||||
/>
|
||||
</div>
|
||||
<Separator className="my-1" />
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center pr-1 pl-2 text-muted-foreground">
|
||||
<Text className="size-4" />
|
||||
</div>
|
||||
<input
|
||||
className={inputVariants()}
|
||||
placeholder="Text to display"
|
||||
data-plate-focus
|
||||
{...textInputProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const editContent = editState.isEditing ? (
|
||||
input
|
||||
) : (
|
||||
<div className="box-content flex items-center">
|
||||
<button
|
||||
className={buttonVariants({ size: "sm", variant: "ghost" })}
|
||||
type="button"
|
||||
{...editButtonProps}
|
||||
>
|
||||
Edit link
|
||||
</button>
|
||||
|
||||
<Separator orientation="vertical" />
|
||||
|
||||
<LinkOpenButton />
|
||||
|
||||
<Separator orientation="vertical" />
|
||||
|
||||
<button
|
||||
className={buttonVariants({
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
})}
|
||||
type="button"
|
||||
{...unlinkButtonProps}
|
||||
>
|
||||
<Unlink width={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={insertRef} className={popoverVariants()} {...insertProps}>
|
||||
{input}
|
||||
</div>
|
||||
|
||||
<div ref={editRef} className={popoverVariants()} {...editProps}>
|
||||
{editContent}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkOpenButton() {
|
||||
const editor = useEditorRef();
|
||||
const selection = useEditorSelection();
|
||||
|
||||
const attributes = React.useMemo(
|
||||
() => {
|
||||
const entry = editor.api.node<TLinkElement>({
|
||||
match: { type: editor.getType(KEYS.link) },
|
||||
});
|
||||
if (!entry) {
|
||||
return {};
|
||||
}
|
||||
const [element] = entry;
|
||||
return getLinkAttributes(editor, element);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[editor, selection]
|
||||
);
|
||||
|
||||
return (
|
||||
<a
|
||||
{...attributes}
|
||||
className={buttonVariants({
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
})}
|
||||
onMouseOver={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
aria-label="Open link in a new tab"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink width={18} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
29
surfsense_web/components/ui/mark-toolbar-button.tsx
Normal file
29
surfsense_web/components/ui/mark-toolbar-button.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useMarkToolbarButton, useMarkToolbarButtonState } from "platejs/react";
|
||||
|
||||
import { ToolbarButton } from "./toolbar";
|
||||
|
||||
export function MarkToolbarButton({
|
||||
clear,
|
||||
nodeType,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarButton> & {
|
||||
nodeType: string;
|
||||
clear?: string[] | string;
|
||||
}) {
|
||||
const state = useMarkToolbarButtonState({ clear, nodeType });
|
||||
const { props: buttonProps } = useMarkToolbarButton(state);
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
{...props}
|
||||
{...buttonProps}
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
surfsense_web/components/ui/mode-toolbar-button.tsx
Normal file
19
surfsense_web/components/ui/mode-toolbar-button.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { BookOpenIcon, PenLineIcon } from "lucide-react";
|
||||
import { usePlateState } from "platejs/react";
|
||||
|
||||
import { ToolbarButton } from "./toolbar";
|
||||
|
||||
export function ModeToolbarButton() {
|
||||
const [readOnly, setReadOnly] = usePlateState("readOnly");
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
tooltip={readOnly ? "Click to edit" : "Click to view"}
|
||||
onClick={() => setReadOnly(!readOnly)}
|
||||
>
|
||||
{readOnly ? <BookOpenIcon /> : <PenLineIcon />}
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
17
surfsense_web/components/ui/paragraph-node.tsx
Normal file
17
surfsense_web/components/ui/paragraph-node.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { PlateElementProps } from "platejs/react";
|
||||
|
||||
import { PlateElement } from "platejs/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ParagraphElement(props: PlateElementProps) {
|
||||
return (
|
||||
<PlateElement {...props} className={cn("m-0 px-0 py-1")}>
|
||||
{props.children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
79
surfsense_web/components/ui/resize-handle.tsx
Normal file
79
surfsense_web/components/ui/resize-handle.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
|
||||
import {
|
||||
type ResizeHandle as ResizeHandlePrimitive,
|
||||
Resizable as ResizablePrimitive,
|
||||
useResizeHandle,
|
||||
useResizeHandleState,
|
||||
} from "@platejs/resizable";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const mediaResizeHandleVariants = cva(
|
||||
cn(
|
||||
"top-0 flex w-6 select-none flex-col justify-center",
|
||||
"after:flex after:h-16 after:w-[3px] after:rounded-[6px] after:bg-ring after:opacity-0 after:content-['_'] group-hover:after:opacity-100"
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
direction: {
|
||||
left: "-left-3 -ml-3 pl-3",
|
||||
right: "-right-3 -mr-3 items-end pr-3",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const resizeHandleVariants = cva("absolute z-40", {
|
||||
variants: {
|
||||
direction: {
|
||||
bottom: "w-full cursor-row-resize",
|
||||
left: "h-full cursor-col-resize",
|
||||
right: "h-full cursor-col-resize",
|
||||
top: "w-full cursor-row-resize",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function ResizeHandle({
|
||||
className,
|
||||
options,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizeHandlePrimitive> & VariantProps<typeof resizeHandleVariants>) {
|
||||
const state = useResizeHandleState(options ?? {});
|
||||
const resizeHandle = useResizeHandle(state);
|
||||
|
||||
if (state.readOnly) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(resizeHandleVariants({ direction: options?.direction }), className)}
|
||||
data-resizing={state.isResizing}
|
||||
{...resizeHandle.props}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const resizableVariants = cva("", {
|
||||
variants: {
|
||||
align: {
|
||||
center: "mx-auto",
|
||||
left: "mr-auto",
|
||||
right: "ml-auto",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function Resizable({
|
||||
align,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive> & VariantProps<typeof resizableVariants>) {
|
||||
return <ResizablePrimitive {...props} className={cn(resizableVariants({ align }), className)} />;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ function Separator({
|
|||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
|||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "\\";
|
||||
|
||||
type SidebarContext = {
|
||||
state: "expanded" | "collapsed";
|
||||
|
|
|
|||
217
surfsense_web/components/ui/slash-node.tsx
Normal file
217
surfsense_web/components/ui/slash-node.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { PlateElementProps } from "platejs/react";
|
||||
|
||||
import { SlashInputPlugin } from "@platejs/slash-command/react";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
Code2Icon,
|
||||
FileCodeIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
InfoIcon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
MinusIcon,
|
||||
PilcrowIcon,
|
||||
QuoteIcon,
|
||||
RadicalIcon,
|
||||
SquareIcon,
|
||||
TableIcon,
|
||||
} from "lucide-react";
|
||||
import { KEYS } from "platejs";
|
||||
import { PlateElement, useEditorRef } from "platejs/react";
|
||||
|
||||
import {
|
||||
InlineCombobox,
|
||||
InlineComboboxContent,
|
||||
InlineComboboxEmpty,
|
||||
InlineComboboxGroup,
|
||||
InlineComboboxGroupLabel,
|
||||
InlineComboboxInput,
|
||||
InlineComboboxItem,
|
||||
} from "@/components/ui/inline-combobox";
|
||||
import { insertBlock, insertInlineElement } from "@/components/editor/transforms";
|
||||
|
||||
interface SlashCommandItem {
|
||||
icon: React.ReactNode;
|
||||
keywords: string[];
|
||||
label: string;
|
||||
value: string;
|
||||
onSelect: (editor: any) => void;
|
||||
}
|
||||
|
||||
const slashCommandGroups: { heading: string; items: SlashCommandItem[] }[] = [
|
||||
{
|
||||
heading: "Basic Blocks",
|
||||
items: [
|
||||
{
|
||||
icon: <PilcrowIcon />,
|
||||
keywords: ["paragraph", "text", "plain"],
|
||||
label: "Text",
|
||||
value: "text",
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.p),
|
||||
},
|
||||
{
|
||||
icon: <Heading1Icon />,
|
||||
keywords: ["title", "h1", "heading"],
|
||||
label: "Heading 1",
|
||||
value: "heading1",
|
||||
onSelect: (editor) => insertBlock(editor, "h1"),
|
||||
},
|
||||
{
|
||||
icon: <Heading2Icon />,
|
||||
keywords: ["subtitle", "h2", "heading"],
|
||||
label: "Heading 2",
|
||||
value: "heading2",
|
||||
onSelect: (editor) => insertBlock(editor, "h2"),
|
||||
},
|
||||
{
|
||||
icon: <Heading3Icon />,
|
||||
keywords: ["subtitle", "h3", "heading"],
|
||||
label: "Heading 3",
|
||||
value: "heading3",
|
||||
onSelect: (editor) => insertBlock(editor, "h3"),
|
||||
},
|
||||
{
|
||||
icon: <QuoteIcon />,
|
||||
keywords: ["citation", "blockquote"],
|
||||
label: "Quote",
|
||||
value: "quote",
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.blockquote),
|
||||
},
|
||||
{
|
||||
icon: <MinusIcon />,
|
||||
keywords: ["divider", "separator", "line"],
|
||||
label: "Divider",
|
||||
value: "divider",
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.hr),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Lists",
|
||||
items: [
|
||||
{
|
||||
icon: <ListIcon />,
|
||||
keywords: ["unordered", "ul", "bullet"],
|
||||
label: "Bulleted list",
|
||||
value: "bulleted-list",
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.ul),
|
||||
},
|
||||
{
|
||||
icon: <ListOrderedIcon />,
|
||||
keywords: ["ordered", "ol", "numbered"],
|
||||
label: "Numbered list",
|
||||
value: "numbered-list",
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.ol),
|
||||
},
|
||||
{
|
||||
icon: <SquareIcon />,
|
||||
keywords: ["checklist", "task", "checkbox", "todo"],
|
||||
label: "To-do list",
|
||||
value: "todo-list",
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.listTodo),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Advanced",
|
||||
items: [
|
||||
{
|
||||
icon: <TableIcon />,
|
||||
keywords: ["table", "grid"],
|
||||
label: "Table",
|
||||
value: "table",
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.table),
|
||||
},
|
||||
{
|
||||
icon: <FileCodeIcon />,
|
||||
keywords: ["code", "codeblock", "snippet"],
|
||||
label: "Code block",
|
||||
value: "code-block",
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.codeBlock),
|
||||
},
|
||||
{
|
||||
icon: <InfoIcon />,
|
||||
keywords: ["callout", "note", "info", "warning", "tip"],
|
||||
label: "Callout",
|
||||
value: "callout",
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.callout),
|
||||
},
|
||||
{
|
||||
icon: <ChevronRightIcon />,
|
||||
keywords: ["toggle", "collapsible", "expand"],
|
||||
label: "Toggle",
|
||||
value: "toggle",
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.toggle),
|
||||
},
|
||||
{
|
||||
icon: <RadicalIcon />,
|
||||
keywords: ["equation", "math", "formula", "latex"],
|
||||
label: "Equation",
|
||||
value: "equation",
|
||||
onSelect: (editor) => insertInlineElement(editor, KEYS.equation),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Inline",
|
||||
items: [
|
||||
{
|
||||
icon: <Code2Icon />,
|
||||
keywords: ["link", "url", "href"],
|
||||
label: "Link",
|
||||
value: "link",
|
||||
onSelect: (editor) => insertInlineElement(editor, KEYS.link),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function SlashInputElement({ children, ...props }: PlateElementProps) {
|
||||
const editor = useEditorRef();
|
||||
|
||||
return (
|
||||
<PlateElement {...props} as="span">
|
||||
<InlineCombobox element={props.element} trigger="/">
|
||||
<InlineComboboxInput />
|
||||
|
||||
<InlineComboboxContent className="dark:bg-neutral-800 dark:border dark:border-neutral-700">
|
||||
<InlineComboboxEmpty>No results found.</InlineComboboxEmpty>
|
||||
|
||||
{slashCommandGroups.map(({ heading, items }) => (
|
||||
<InlineComboboxGroup key={heading}>
|
||||
<InlineComboboxGroupLabel>{heading}</InlineComboboxGroupLabel>
|
||||
|
||||
{items.map(({ icon, keywords, label, value, onSelect }) => (
|
||||
<InlineComboboxItem
|
||||
key={value}
|
||||
className="flex items-center gap-3 px-2 py-1.5"
|
||||
keywords={keywords}
|
||||
label={label}
|
||||
value={value}
|
||||
group={heading}
|
||||
onClick={() => {
|
||||
onSelect(editor);
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<span className="flex size-5 items-center justify-center text-muted-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</InlineComboboxItem>
|
||||
))}
|
||||
</InlineComboboxGroup>
|
||||
))}
|
||||
</InlineComboboxContent>
|
||||
</InlineCombobox>
|
||||
|
||||
{children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
685
surfsense_web/components/ui/table-icons.tsx
Normal file
685
surfsense_web/components/ui/table-icons.tsx
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
"use client";
|
||||
|
||||
import type { LucideProps } from "lucide-react";
|
||||
|
||||
export function BorderAllIcon(props: LucideProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Border All</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M0.25 1C0.25 0.585786 0.585786 0.25 1 0.25H14C14.4142 0.25 14.75 0.585786 14.75 1V14C14.75 14.4142 14.4142 14.75 14 14.75H1C0.585786 14.75 0.25 14.4142 0.25 14V1ZM1.75 1.75V13.25H13.25V1.75H1.75Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="5" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="3" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="9" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="11" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BorderBottomIcon(props: LucideProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Border Bottom</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M1 13.25L14 13.25V14.75L1 14.75V13.25Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="5" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="5" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="3" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="3" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="9" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="9" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="11" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="11" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="5" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="3" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="7" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="9" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="11" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BorderLeftIcon(props: LucideProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Border Left</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M1.75 1L1.75 14L0.249999 14L0.25 1L1.75 1Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 10 7)"
|
||||
width="1"
|
||||
x="10"
|
||||
y="7"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 10 13)"
|
||||
width="1"
|
||||
x="10"
|
||||
y="13"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 12 7)"
|
||||
width="1"
|
||||
x="12"
|
||||
y="7"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 12 13)"
|
||||
width="1"
|
||||
x="12"
|
||||
y="13"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 7)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="7"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 7)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="7"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 13)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="13"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 13)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="13"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 5)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="5"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 5)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="5"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 3)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="3"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 3)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="3"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 9)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="9"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 9)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="9"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 11)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="11"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 11)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="11"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 6 7)"
|
||||
width="1"
|
||||
x="6"
|
||||
y="7"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 6 13)"
|
||||
width="1"
|
||||
x="6"
|
||||
y="13"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 4 7)"
|
||||
width="1"
|
||||
x="4"
|
||||
y="7"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 4 13)"
|
||||
width="1"
|
||||
x="4"
|
||||
y="13"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 10 1)"
|
||||
width="1"
|
||||
x="10"
|
||||
y="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 12 1)"
|
||||
width="1"
|
||||
x="12"
|
||||
y="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 8 1)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 14 1)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 6 1)"
|
||||
width="1"
|
||||
x="6"
|
||||
y="1"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(90 4 1)"
|
||||
width="1"
|
||||
x="4"
|
||||
y="1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BorderNoneIcon(props: LucideProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Border None</title>
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="5.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="5.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="3.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="3.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="7.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="13.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="7.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="13.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="7.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="13.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="5" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="7.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="13.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="3" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="7.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="13.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="9" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="7.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="13.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="11" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="9.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="9.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="7" y="11.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="13" y="11.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="5.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="3.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="7.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="13.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="1.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="9.025" />
|
||||
<rect fill="currentColor" height="1" rx=".5" width="1" x="1" y="11.025" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BorderRightIcon(props: LucideProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Border Right</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M13.25 1L13.25 14L14.75 14L14.75 1L13.25 1Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 5 7)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 5 13)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 3 7)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 3 13)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 7)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 7)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 13)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 13)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 5)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 5)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 3)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 3)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 9)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 9)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 11)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 11)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 9 7)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 9 13)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 11 7)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 11 13)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 5 1)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 3 1)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 7 1)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 1 1)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 9 1)" width="1" />
|
||||
<rect fill="currentColor" height="1" rx=".5" transform="matrix(0 1 1 0 11 1)" width="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BorderTopIcon(props: LucideProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Border Top</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M14 1.75L1 1.75L1 0.249999L14 0.25L14 1.75Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 8 10)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="10"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 2 10)"
|
||||
width="1"
|
||||
x="2"
|
||||
y="10"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 8 12)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="12"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 2 12)"
|
||||
width="1"
|
||||
x="2"
|
||||
y="12"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 8 8)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 8 14)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 2 8)"
|
||||
width="1"
|
||||
x="2"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 2 14)"
|
||||
width="1"
|
||||
x="2"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 10 8)"
|
||||
width="1"
|
||||
x="10"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 10 14)"
|
||||
width="1"
|
||||
x="10"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 12 8)"
|
||||
width="1"
|
||||
x="12"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 12 14)"
|
||||
width="1"
|
||||
x="12"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 6 8)"
|
||||
width="1"
|
||||
x="6"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 6 14)"
|
||||
width="1"
|
||||
x="6"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 4 8)"
|
||||
width="1"
|
||||
x="4"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 4 14)"
|
||||
width="1"
|
||||
x="4"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 8 6)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="6"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 2 6)"
|
||||
width="1"
|
||||
x="2"
|
||||
y="6"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 8 4)"
|
||||
width="1"
|
||||
x="8"
|
||||
y="4"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 2 4)"
|
||||
width="1"
|
||||
x="2"
|
||||
y="4"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 14 10)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="10"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 14 12)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="12"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 14 8)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="8"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 14 14)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="14"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 14 6)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="6"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="1"
|
||||
rx=".5"
|
||||
transform="rotate(-180 14 4)"
|
||||
width="1"
|
||||
x="14"
|
||||
y="4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
439
surfsense_web/components/ui/table-node.tsx
Normal file
439
surfsense_web/components/ui/table-node.tsx
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useDraggable, useDropLine } from "@platejs/dnd";
|
||||
import { BlockSelectionPlugin, useBlockSelected } from "@platejs/selection/react";
|
||||
import {
|
||||
TablePlugin,
|
||||
TableProvider,
|
||||
useTableCellElement,
|
||||
useTableCellElementResizable,
|
||||
useTableElement,
|
||||
useTableMergeState,
|
||||
} from "@platejs/table/react";
|
||||
import { PopoverAnchor } from "@radix-ui/react-popover";
|
||||
import { cva } from "class-variance-authority";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
CombineIcon,
|
||||
GripVertical,
|
||||
SquareSplitHorizontalIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type TElement,
|
||||
type TTableCellElement,
|
||||
type TTableElement,
|
||||
type TTableRowElement,
|
||||
KEYS,
|
||||
PathApi,
|
||||
} from "platejs";
|
||||
import {
|
||||
type PlateElementProps,
|
||||
PlateElement,
|
||||
useComposedRef,
|
||||
useEditorPlugin,
|
||||
useEditorRef,
|
||||
useEditorSelector,
|
||||
useElement,
|
||||
useFocusedLast,
|
||||
usePluginOption,
|
||||
useReadOnly,
|
||||
useRemoveNodeButton,
|
||||
useSelected,
|
||||
withHOC,
|
||||
} from "platejs/react";
|
||||
import { useElementSelector } from "platejs/react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { blockSelectionVariants } from "./block-selection";
|
||||
import { ResizeHandle } from "./resize-handle";
|
||||
import { Toolbar, ToolbarButton, ToolbarGroup } from "./toolbar";
|
||||
|
||||
export const TableElement = withHOC(
|
||||
TableProvider,
|
||||
function TableElement({ children, ...props }: PlateElementProps<TTableElement>) {
|
||||
const readOnly = useReadOnly();
|
||||
const isSelectionAreaVisible = usePluginOption(BlockSelectionPlugin, "isSelectionAreaVisible");
|
||||
const hasControls = !readOnly && !isSelectionAreaVisible;
|
||||
const { isSelectingCell, marginLeft, props: tableProps } = useTableElement();
|
||||
|
||||
const isSelectingTable = useBlockSelected(props.element.id as string);
|
||||
|
||||
const content = (
|
||||
<PlateElement
|
||||
{...props}
|
||||
className={cn(
|
||||
"overflow-x-auto py-5",
|
||||
hasControls && "-ml-2 *:data-[slot=block-selection]:left-2"
|
||||
)}
|
||||
style={{ paddingLeft: marginLeft }}
|
||||
>
|
||||
<div className="group/table relative w-fit">
|
||||
<table
|
||||
className={cn(
|
||||
"mr-0 ml-px table h-px w-full table-fixed border-collapse",
|
||||
isSelectingCell && "selection:bg-transparent"
|
||||
)}
|
||||
{...tableProps}
|
||||
>
|
||||
<tbody className="min-w-full">{children}</tbody>
|
||||
</table>
|
||||
|
||||
{isSelectingTable && <div className={blockSelectionVariants()} contentEditable={false} />}
|
||||
</div>
|
||||
</PlateElement>
|
||||
);
|
||||
|
||||
if (readOnly) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <TableFloatingToolbar>{content}</TableFloatingToolbar>;
|
||||
}
|
||||
);
|
||||
|
||||
function TableFloatingToolbar({ children, ...props }: React.ComponentProps<typeof PopoverContent>) {
|
||||
const { tf } = useEditorPlugin(TablePlugin);
|
||||
const selected = useSelected();
|
||||
const element = useElement<TTableElement>();
|
||||
const { props: buttonProps } = useRemoveNodeButton({ element });
|
||||
const collapsedInside = useEditorSelector(
|
||||
(editor) => selected && editor.api.isCollapsed(),
|
||||
[selected]
|
||||
);
|
||||
const isFocusedLast = useFocusedLast();
|
||||
|
||||
const { canMerge, canSplit } = useTableMergeState();
|
||||
|
||||
return (
|
||||
<Popover open={isFocusedLast && (canMerge || canSplit || collapsedInside)} modal={false}>
|
||||
<PopoverAnchor asChild>{children}</PopoverAnchor>
|
||||
<PopoverContent
|
||||
asChild
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
contentEditable={false}
|
||||
{...props}
|
||||
>
|
||||
<Toolbar
|
||||
className="scrollbar-hide flex w-auto max-w-[80vw] flex-row overflow-x-auto rounded-md border bg-popover p-1 shadow-md print:hidden"
|
||||
contentEditable={false}
|
||||
>
|
||||
<ToolbarGroup>
|
||||
{canMerge && (
|
||||
<ToolbarButton
|
||||
onClick={() => tf.table.merge()}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Merge cells"
|
||||
>
|
||||
<CombineIcon />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
{canSplit && (
|
||||
<ToolbarButton
|
||||
onClick={() => tf.table.split()}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Split cell"
|
||||
>
|
||||
<SquareSplitHorizontalIcon />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
|
||||
{collapsedInside && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton tooltip="Delete table" {...buttonProps}>
|
||||
<Trash2Icon />
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
</ToolbarGroup>
|
||||
|
||||
{collapsedInside && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
tf.insert.tableRow({ before: true });
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Insert row before"
|
||||
>
|
||||
<ArrowUp />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
tf.insert.tableRow();
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Insert row after"
|
||||
>
|
||||
<ArrowDown />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
tf.remove.tableRow();
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Delete row"
|
||||
>
|
||||
<XIcon />
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
|
||||
{collapsedInside && (
|
||||
<ToolbarGroup>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
tf.insert.tableColumn({ before: true });
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Insert column before"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
tf.insert.tableColumn();
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Insert column after"
|
||||
>
|
||||
<ArrowRight />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
tf.remove.tableColumn();
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
tooltip="Delete column"
|
||||
>
|
||||
<XIcon />
|
||||
</ToolbarButton>
|
||||
</ToolbarGroup>
|
||||
)}
|
||||
</Toolbar>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRowElement({ children, ...props }: PlateElementProps<TTableRowElement>) {
|
||||
const { element } = props;
|
||||
const readOnly = useReadOnly();
|
||||
const selected = useSelected();
|
||||
const editor = useEditorRef();
|
||||
const isSelectionAreaVisible = usePluginOption(BlockSelectionPlugin, "isSelectionAreaVisible");
|
||||
const hasControls = !readOnly && !isSelectionAreaVisible;
|
||||
|
||||
const { isDragging, nodeRef, previewRef, handleRef } = useDraggable({
|
||||
element,
|
||||
type: element.type,
|
||||
canDropNode: ({ dragEntry, dropEntry }) =>
|
||||
PathApi.equals(PathApi.parent(dragEntry[1]), PathApi.parent(dropEntry[1])),
|
||||
onDropHandler: (_, { dragItem }) => {
|
||||
const dragElement = (dragItem as { element: TElement }).element;
|
||||
|
||||
if (dragElement) {
|
||||
editor.tf.select(dragElement);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<PlateElement
|
||||
{...props}
|
||||
ref={useComposedRef(props.ref, previewRef, nodeRef)}
|
||||
as="tr"
|
||||
className={cn("group/row", isDragging && "opacity-50")}
|
||||
attributes={{
|
||||
...props.attributes,
|
||||
"data-selected": selected ? "true" : undefined,
|
||||
}}
|
||||
>
|
||||
{hasControls && (
|
||||
<td className="w-2 select-none" contentEditable={false}>
|
||||
<RowDragHandle dragRef={handleRef} />
|
||||
<RowDropLine />
|
||||
</td>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
function RowDragHandle({ dragRef }: { dragRef: React.Ref<any> }) {
|
||||
const editor = useEditorRef();
|
||||
const element = useElement();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={dragRef}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"-translate-y-1/2 absolute top-1/2 left-0 z-51 h-6 w-4 p-0 focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
"cursor-grab active:cursor-grabbing",
|
||||
'opacity-0 transition-opacity duration-100 group-hover/row:opacity-100 group-has-data-[resizing="true"]/row:opacity-0'
|
||||
)}
|
||||
onClick={() => {
|
||||
editor.tf.select(element);
|
||||
}}
|
||||
>
|
||||
<GripVertical className="text-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function RowDropLine() {
|
||||
const { dropLine } = useDropLine();
|
||||
|
||||
if (!dropLine) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 left-2 z-50 h-0.5 bg-brand/50",
|
||||
dropLine === "top" ? "-top-px" : "-bottom-px"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCellElement({
|
||||
isHeader,
|
||||
...props
|
||||
}: PlateElementProps<TTableCellElement> & {
|
||||
isHeader?: boolean;
|
||||
}) {
|
||||
const { api } = useEditorPlugin(TablePlugin);
|
||||
const readOnly = useReadOnly();
|
||||
const element = props.element;
|
||||
|
||||
const tableId = useElementSelector(([node]) => node.id as string, [], {
|
||||
key: KEYS.table,
|
||||
});
|
||||
const rowId = useElementSelector(([node]) => node.id as string, [], {
|
||||
key: KEYS.tr,
|
||||
});
|
||||
const isSelectingTable = useBlockSelected(tableId);
|
||||
const isSelectingRow = useBlockSelected(rowId) || isSelectingTable;
|
||||
const isSelectionAreaVisible = usePluginOption(BlockSelectionPlugin, "isSelectionAreaVisible");
|
||||
|
||||
const { borders, colIndex, colSpan, minHeight, rowIndex, selected, width } =
|
||||
useTableCellElement();
|
||||
|
||||
const { bottomProps, hiddenLeft, leftProps, rightProps } = useTableCellElementResizable({
|
||||
colIndex,
|
||||
colSpan,
|
||||
rowIndex,
|
||||
});
|
||||
|
||||
return (
|
||||
<PlateElement
|
||||
{...props}
|
||||
as={isHeader ? "th" : "td"}
|
||||
className={cn(
|
||||
"h-full overflow-visible border-none bg-background p-0",
|
||||
element.background ? "bg-(--cellBackground)" : "bg-background",
|
||||
isHeader && "text-left *:m-0",
|
||||
"before:size-full",
|
||||
selected && "before:z-10 before:bg-brand/5",
|
||||
"before:absolute before:box-border before:select-none before:content-['']",
|
||||
borders.bottom?.size && "before:border-b before:border-b-border",
|
||||
borders.right?.size && "before:border-r before:border-r-border",
|
||||
borders.left?.size && "before:border-l before:border-l-border",
|
||||
borders.top?.size && "before:border-t before:border-t-border"
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--cellBackground": element.background,
|
||||
minWidth: width || 48,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
attributes={{
|
||||
...props.attributes,
|
||||
colSpan: api.table.getColSpan(element),
|
||||
rowSpan: api.table.getRowSpan(element),
|
||||
}}
|
||||
>
|
||||
<div className="relative z-20 box-border h-full px-3 py-2" style={{ minHeight }}>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{!isSelectionAreaVisible && (
|
||||
<div
|
||||
className="group absolute top-0 size-full select-none"
|
||||
contentEditable={false}
|
||||
suppressContentEditableWarning={true}
|
||||
>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<ResizeHandle
|
||||
{...rightProps}
|
||||
className="-top-2 -right-1 h-[calc(100%_+_8px)] w-2"
|
||||
data-col={colIndex}
|
||||
/>
|
||||
<ResizeHandle {...bottomProps} className="-bottom-1 h-2" />
|
||||
{!hiddenLeft && (
|
||||
<ResizeHandle
|
||||
{...leftProps}
|
||||
className="-left-1 top-0 w-2"
|
||||
data-resizer-left={colIndex === 0 ? "true" : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 z-30 hidden h-full w-1 bg-ring",
|
||||
"right-[-1.5px]",
|
||||
columnResizeVariants({ colIndex: colIndex as any })
|
||||
)}
|
||||
/>
|
||||
{colIndex === 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 z-30 h-full w-1 bg-ring",
|
||||
"left-[-1.5px]",
|
||||
'fade-in hidden animate-in group-has-[[data-resizer-left]:hover]/table:block group-has-[[data-resizer-left][data-resizing="true"]]/table:block'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelectingRow && <div className={blockSelectionVariants()} contentEditable={false} />}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCellHeaderElement(props: React.ComponentProps<typeof TableCellElement>) {
|
||||
return <TableCellElement {...props} isHeader />;
|
||||
}
|
||||
|
||||
const columnResizeVariants = cva("fade-in hidden animate-in", {
|
||||
variants: {
|
||||
colIndex: {
|
||||
0: 'group-has-[[data-col="0"]:hover]/table:block group-has-[[data-col="0"][data-resizing="true"]]/table:block',
|
||||
1: 'group-has-[[data-col="1"]:hover]/table:block group-has-[[data-col="1"][data-resizing="true"]]/table:block',
|
||||
2: 'group-has-[[data-col="2"]:hover]/table:block group-has-[[data-col="2"][data-resizing="true"]]/table:block',
|
||||
3: 'group-has-[[data-col="3"]:hover]/table:block group-has-[[data-col="3"][data-resizing="true"]]/table:block',
|
||||
4: 'group-has-[[data-col="4"]:hover]/table:block group-has-[[data-col="4"][data-resizing="true"]]/table:block',
|
||||
5: 'group-has-[[data-col="5"]:hover]/table:block group-has-[[data-col="5"][data-resizing="true"]]/table:block',
|
||||
6: 'group-has-[[data-col="6"]:hover]/table:block group-has-[[data-col="6"][data-resizing="true"]]/table:block',
|
||||
7: 'group-has-[[data-col="7"]:hover]/table:block group-has-[[data-col="7"][data-resizing="true"]]/table:block',
|
||||
8: 'group-has-[[data-col="8"]:hover]/table:block group-has-[[data-col="8"][data-resizing="true"]]/table:block',
|
||||
9: 'group-has-[[data-col="9"]:hover]/table:block group-has-[[data-col="9"][data-resizing="true"]]/table:block',
|
||||
10: 'group-has-[[data-col="10"]:hover]/table:block group-has-[[data-col="10"][data-resizing="true"]]/table:block',
|
||||
},
|
||||
},
|
||||
});
|
||||
33
surfsense_web/components/ui/toggle-node.tsx
Normal file
33
surfsense_web/components/ui/toggle-node.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useToggleButton, useToggleButtonState } from "@platejs/toggle/react";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { type PlateElementProps, PlateElement } from "platejs/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ToggleElement({ children, ...props }: PlateElementProps) {
|
||||
const element = props.element;
|
||||
const state = useToggleButtonState(element.id as string);
|
||||
const { buttonProps, open } = useToggleButton(state);
|
||||
|
||||
return (
|
||||
<PlateElement {...props} className="relative py-1 pl-6">
|
||||
<button
|
||||
className={cn(
|
||||
"absolute top-1.5 left-0 flex size-6 cursor-pointer select-none items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
contentEditable={false}
|
||||
type="button"
|
||||
{...buttonProps}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 transition-transform duration-200", open && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
<div>{children}</div>
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
363
surfsense_web/components/ui/toolbar.tsx
Normal file
363
surfsense_web/components/ui/toolbar.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import * as ToolbarPrimitive from "@radix-ui/react-toolbar";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Toolbar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarPrimitive.Root>) {
|
||||
return (
|
||||
<ToolbarPrimitive.Root
|
||||
className={cn("relative flex select-none items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarToggleGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarPrimitive.ToolbarToggleGroup>) {
|
||||
return (
|
||||
<ToolbarPrimitive.ToolbarToggleGroup
|
||||
className={cn("flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarPrimitive.Link>) {
|
||||
return (
|
||||
<ToolbarPrimitive.Link
|
||||
className={cn("font-medium underline underline-offset-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarPrimitive.Separator>) {
|
||||
return (
|
||||
<ToolbarPrimitive.Separator
|
||||
className={cn("mx-2 my-1 w-px shrink-0 bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// From toggleVariants
|
||||
const toolbarButtonVariants = cva(
|
||||
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-checked:bg-accent aria-checked:text-accent-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
variant: "default",
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
default: "h-9 min-w-9 px-2",
|
||||
lg: "h-10 min-w-10 px-2.5",
|
||||
sm: "h-8 min-w-8 px-1.5",
|
||||
},
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const dropdownArrowVariants = cva(
|
||||
cn(
|
||||
"inline-flex items-center justify-center rounded-r-md font-medium text-foreground text-sm transition-colors disabled:pointer-events-none disabled:opacity-50"
|
||||
),
|
||||
{
|
||||
defaultVariants: {
|
||||
size: "sm",
|
||||
variant: "default",
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
default: "h-9 w-6",
|
||||
lg: "h-10 w-8",
|
||||
sm: "h-8 w-4",
|
||||
},
|
||||
variant: {
|
||||
default:
|
||||
"bg-transparent hover:bg-muted hover:text-muted-foreground aria-checked:bg-accent aria-checked:text-accent-foreground",
|
||||
outline:
|
||||
"border border-input border-l-0 bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
type ToolbarButtonProps = {
|
||||
isDropdown?: boolean;
|
||||
pressed?: boolean;
|
||||
} & Omit<React.ComponentPropsWithoutRef<typeof ToolbarToggleItem>, "asChild" | "value"> &
|
||||
VariantProps<typeof toolbarButtonVariants>;
|
||||
|
||||
export const ToolbarButton = withTooltip(function ToolbarButton({
|
||||
children,
|
||||
className,
|
||||
isDropdown,
|
||||
pressed,
|
||||
size = "sm",
|
||||
variant,
|
||||
...props
|
||||
}: ToolbarButtonProps) {
|
||||
return typeof pressed === "boolean" ? (
|
||||
<ToolbarToggleGroup disabled={props.disabled} value="single" type="single">
|
||||
<ToolbarToggleItem
|
||||
className={cn(
|
||||
toolbarButtonVariants({
|
||||
size,
|
||||
variant,
|
||||
}),
|
||||
isDropdown && "justify-between gap-1 pr-1",
|
||||
className
|
||||
)}
|
||||
value={pressed ? "single" : ""}
|
||||
{...props}
|
||||
>
|
||||
{isDropdown ? (
|
||||
<>
|
||||
<div className="flex flex-1 items-center gap-2 whitespace-nowrap">{children}</div>
|
||||
<div>
|
||||
<ChevronDown className="size-3.5 text-muted-foreground" data-icon />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</ToolbarToggleItem>
|
||||
</ToolbarToggleGroup>
|
||||
) : (
|
||||
<ToolbarPrimitive.Button
|
||||
className={cn(
|
||||
toolbarButtonVariants({
|
||||
size,
|
||||
variant,
|
||||
}),
|
||||
isDropdown && "pr-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToolbarPrimitive.Button>
|
||||
);
|
||||
});
|
||||
|
||||
export function ToolbarSplitButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof ToolbarButton>) {
|
||||
return (
|
||||
<ToolbarButton
|
||||
className={cn("group flex gap-0 px-0 hover:bg-transparent", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ToolbarSplitButtonPrimaryProps = Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ToolbarToggleItem>,
|
||||
"value"
|
||||
> &
|
||||
VariantProps<typeof toolbarButtonVariants>;
|
||||
|
||||
export function ToolbarSplitButtonPrimary({
|
||||
children,
|
||||
className,
|
||||
size = "sm",
|
||||
variant,
|
||||
...props
|
||||
}: ToolbarSplitButtonPrimaryProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
toolbarButtonVariants({
|
||||
size,
|
||||
variant,
|
||||
}),
|
||||
"rounded-r-none",
|
||||
"group-data-[pressed=true]:bg-accent group-data-[pressed=true]:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarSplitButtonSecondary({
|
||||
className,
|
||||
size,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"span"> & VariantProps<typeof dropdownArrowVariants>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
dropdownArrowVariants({
|
||||
size,
|
||||
variant,
|
||||
}),
|
||||
"group-data-[pressed=true]:bg-accent group-data-[pressed=true]:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="button"
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="size-3.5 text-muted-foreground" data-icon />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarToggleItem({
|
||||
className,
|
||||
size = "sm",
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToolbarPrimitive.ToggleItem> &
|
||||
VariantProps<typeof toolbarButtonVariants>) {
|
||||
return (
|
||||
<ToolbarPrimitive.ToggleItem
|
||||
className={cn(toolbarButtonVariants({ size, variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarGroup({ children, className }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("group/toolbar-group", "relative hidden has-[button]:flex", className)}>
|
||||
<div className="flex items-center">{children}</div>
|
||||
|
||||
<div className="group-last/toolbar-group:hidden! mx-1.5 py-0.5">
|
||||
<Separator orientation="vertical" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipProps<T extends React.ElementType> = {
|
||||
tooltip?: React.ReactNode;
|
||||
tooltipContentProps?: Omit<React.ComponentPropsWithoutRef<typeof TooltipContent>, "children">;
|
||||
tooltipProps?: Omit<React.ComponentPropsWithoutRef<typeof Tooltip>, "children">;
|
||||
tooltipTriggerProps?: React.ComponentPropsWithoutRef<typeof TooltipTrigger>;
|
||||
} & React.ComponentProps<T>;
|
||||
|
||||
function withTooltip<T extends React.ElementType>(Component: T) {
|
||||
return function ExtendComponent({
|
||||
tooltip,
|
||||
tooltipContentProps,
|
||||
tooltipProps,
|
||||
tooltipTriggerProps,
|
||||
...props
|
||||
}: TooltipProps<T>) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const component = <Component {...(props as React.ComponentProps<T>)} />;
|
||||
|
||||
if (tooltip && mounted) {
|
||||
return (
|
||||
<Tooltip {...tooltipProps}>
|
||||
<TooltipTrigger asChild {...tooltipTriggerProps}>
|
||||
{component}
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent {...tooltipContentProps}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return component;
|
||||
};
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
children,
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
className={cn(
|
||||
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none",
|
||||
className
|
||||
)}
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarMenuGroup({
|
||||
children,
|
||||
className,
|
||||
label,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuRadioGroup> & { label?: string }) {
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator
|
||||
className={cn(
|
||||
"hidden",
|
||||
"mb-0 mx-2 shrink-0 peer-has-[[role=menuitem]]/menu-group:block peer-has-[[role=menuitemradio]]/menu-group:block peer-has-[[role=option]]/menu-group:block",
|
||||
"dark:bg-neutral-700"
|
||||
)}
|
||||
/>
|
||||
|
||||
<DropdownMenuRadioGroup
|
||||
{...props}
|
||||
className={cn(
|
||||
"hidden",
|
||||
"peer/menu-group group/menu-group my-1.5 has-[[role=menuitem]]:block has-[[role=menuitemradio]]:block has-[[role=option]]:block",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label && (
|
||||
<DropdownMenuLabel className="select-none font-semibold text-muted-foreground text-xs">
|
||||
{label}
|
||||
</DropdownMenuLabel>
|
||||
)}
|
||||
{children}
|
||||
</DropdownMenuRadioGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
|
|||
189
surfsense_web/components/ui/turn-into-toolbar-button.tsx
Normal file
189
surfsense_web/components/ui/turn-into-toolbar-button.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { DropdownMenuProps } from "@radix-ui/react-dropdown-menu";
|
||||
import type { TElement } from "platejs";
|
||||
|
||||
import { DropdownMenuItemIndicator } from "@radix-ui/react-dropdown-menu";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
FileCodeIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
Heading5Icon,
|
||||
Heading6Icon,
|
||||
InfoIcon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
PilcrowIcon,
|
||||
QuoteIcon,
|
||||
SquareIcon,
|
||||
} from "lucide-react";
|
||||
import { KEYS } from "platejs";
|
||||
import { useEditorRef, useSelectionFragmentProp } from "platejs/react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getBlockType, setBlockType } from "@/components/editor/transforms";
|
||||
|
||||
import { ToolbarButton, ToolbarMenuGroup } from "./toolbar";
|
||||
|
||||
export const turnIntoItems = [
|
||||
{
|
||||
icon: <PilcrowIcon />,
|
||||
keywords: ["paragraph"],
|
||||
label: "Text",
|
||||
value: KEYS.p,
|
||||
},
|
||||
{
|
||||
icon: <Heading1Icon />,
|
||||
keywords: ["title", "h1"],
|
||||
label: "Heading 1",
|
||||
value: "h1",
|
||||
},
|
||||
{
|
||||
icon: <Heading2Icon />,
|
||||
keywords: ["subtitle", "h2"],
|
||||
label: "Heading 2",
|
||||
value: "h2",
|
||||
},
|
||||
{
|
||||
icon: <Heading3Icon />,
|
||||
keywords: ["subtitle", "h3"],
|
||||
label: "Heading 3",
|
||||
value: "h3",
|
||||
},
|
||||
{
|
||||
icon: <Heading4Icon />,
|
||||
keywords: ["subtitle", "h4"],
|
||||
label: "Heading 4",
|
||||
value: "h4",
|
||||
},
|
||||
{
|
||||
icon: <Heading5Icon />,
|
||||
keywords: ["subtitle", "h5"],
|
||||
label: "Heading 5",
|
||||
value: "h5",
|
||||
},
|
||||
{
|
||||
icon: <Heading6Icon />,
|
||||
keywords: ["subtitle", "h6"],
|
||||
label: "Heading 6",
|
||||
value: "h6",
|
||||
},
|
||||
{
|
||||
icon: <ListIcon />,
|
||||
keywords: ["unordered", "ul", "-"],
|
||||
label: "Bulleted list",
|
||||
value: KEYS.ul,
|
||||
},
|
||||
{
|
||||
icon: <ListOrderedIcon />,
|
||||
keywords: ["ordered", "ol", "1"],
|
||||
label: "Numbered list",
|
||||
value: KEYS.ol,
|
||||
},
|
||||
{
|
||||
icon: <SquareIcon />,
|
||||
keywords: ["checklist", "task", "checkbox", "[]"],
|
||||
label: "To-do list",
|
||||
value: KEYS.listTodo,
|
||||
},
|
||||
{
|
||||
icon: <FileCodeIcon />,
|
||||
keywords: ["```"],
|
||||
label: "Code",
|
||||
value: KEYS.codeBlock,
|
||||
},
|
||||
{
|
||||
icon: <QuoteIcon />,
|
||||
keywords: ["citation", "blockquote", ">"],
|
||||
label: "Quote",
|
||||
value: KEYS.blockquote,
|
||||
},
|
||||
{
|
||||
icon: <InfoIcon />,
|
||||
keywords: ["callout", "note", "info", "warning", "tip"],
|
||||
label: "Callout",
|
||||
value: KEYS.callout,
|
||||
},
|
||||
{
|
||||
icon: <ChevronRightIcon />,
|
||||
keywords: ["toggle", "collapsible", "expand"],
|
||||
label: "Toggle",
|
||||
value: KEYS.toggle,
|
||||
},
|
||||
];
|
||||
|
||||
export function TurnIntoToolbarButton({
|
||||
tooltip = "Turn into",
|
||||
...props
|
||||
}: DropdownMenuProps & { tooltip?: React.ReactNode }) {
|
||||
const editor = useEditorRef();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const value = useSelectionFragmentProp({
|
||||
defaultValue: KEYS.p,
|
||||
getProp: (node) => getBlockType(node as TElement),
|
||||
});
|
||||
const selectedItem = React.useMemo(
|
||||
() => turnIntoItems.find((item) => item.value === (value ?? KEYS.p)) ?? turnIntoItems[0],
|
||||
[value]
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ToolbarButton
|
||||
className="min-w-[80px] sm:min-w-[125px]"
|
||||
pressed={open}
|
||||
tooltip={tooltip}
|
||||
isDropdown
|
||||
>
|
||||
{selectedItem.label}
|
||||
</ToolbarButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="z-[100] ignore-click-outside/toolbar min-w-0 max-h-[60vh] overflow-y-auto dark:bg-neutral-800 dark:border dark:border-neutral-700"
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
editor.tf.focus();
|
||||
}}
|
||||
align="start"
|
||||
>
|
||||
<ToolbarMenuGroup
|
||||
value={value}
|
||||
onValueChange={(type) => {
|
||||
setBlockType(editor, type);
|
||||
}}
|
||||
label="Turn into"
|
||||
>
|
||||
{turnIntoItems.map(({ icon, label, value: itemValue }) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={itemValue}
|
||||
className="min-w-[180px] pl-2 *:first:[span]:hidden dark:text-white"
|
||||
value={itemValue}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<CheckIcon />
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
{label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</ToolbarMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
18
surfsense_web/hooks/use-debounce.ts
Normal file
18
surfsense_web/hooks/use-debounce.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from "react";
|
||||
|
||||
export const useDebounce = <T>(value: T, delay = 500) => {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState(value);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler: NodeJS.Timeout = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Cancel the timeout if value changes (also on delay change or unmount)
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
11
surfsense_web/hooks/use-mounted.ts
Normal file
11
surfsense_web/hooks/use-mounted.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import * as React from "react";
|
||||
|
||||
export function useMounted() {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return mounted;
|
||||
}
|
||||
57
surfsense_web/hooks/use-platform-shortcut.ts
Normal file
57
surfsense_web/hooks/use-platform-shortcut.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface NavigatorUAData {
|
||||
platform: string;
|
||||
}
|
||||
|
||||
function getIsMac() {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
|
||||
// Modern API (Chromium browsers: Chrome, Edge, Opera)
|
||||
const uaData = (navigator as Navigator & { userAgentData?: NavigatorUAData }).userAgentData;
|
||||
if (uaData?.platform) {
|
||||
return uaData.platform === "macOS";
|
||||
}
|
||||
|
||||
// Fallback for Firefox/Safari
|
||||
return /Mac|iPhone/.test(navigator.platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a helper that formats keyboard shortcut strings with
|
||||
* platform-aware modifier symbols.
|
||||
*
|
||||
* SSR-safe: returns an empty string until mounted so there is no hydration
|
||||
* mismatch.
|
||||
*/
|
||||
export function usePlatformShortcut() {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMac(getIsMac());
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
const shortcut = useCallback(
|
||||
(...keys: string[]) => {
|
||||
if (!ready) return "";
|
||||
|
||||
const mod = isMac ? "⌘" : "Ctrl";
|
||||
const shift = isMac ? "⇧" : "Shift";
|
||||
const alt = isMac ? "⌥" : "Alt";
|
||||
|
||||
const mapped = keys.map((k) => {
|
||||
if (k === "Mod") return mod;
|
||||
if (k === "Shift") return shift;
|
||||
if (k === "Alt") return alt;
|
||||
return k;
|
||||
});
|
||||
|
||||
return `(${mapped.join("+")})`;
|
||||
},
|
||||
[ready, isMac]
|
||||
);
|
||||
|
||||
return { shortcut, isMac, ready };
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { baseApiService } from "./base-api.service";
|
|||
const createNoteRequest = z.object({
|
||||
search_space_id: z.number(),
|
||||
title: z.string().min(1),
|
||||
blocknote_document: z.array(z.any()).optional(),
|
||||
source_markdown: z.string().optional(),
|
||||
});
|
||||
|
||||
const createNoteResponse = z.object({
|
||||
|
|
@ -82,12 +82,12 @@ class NotesApiService {
|
|||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { search_space_id, title, blocknote_document } = parsedRequest.data;
|
||||
const { search_space_id, title, source_markdown } = parsedRequest.data;
|
||||
|
||||
// Send both title and blocknote_document in request body
|
||||
// Send both title and source_markdown in request body
|
||||
const body = {
|
||||
title,
|
||||
...(blocknote_document && { blocknote_document }),
|
||||
...(source_markdown !== undefined && { source_markdown }),
|
||||
};
|
||||
|
||||
return baseApiService.post(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
|||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// Disable StrictMode for BlockNote compatibility with React 19/Next 15
|
||||
reactStrictMode: false,
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
|
|
@ -20,9 +19,6 @@ const nextConfig: NextConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
// Mark BlockNote server packages as external
|
||||
serverExternalPackages: ["@blocknote/server-util"],
|
||||
|
||||
// Turbopack config (used during `next dev --turbopack`)
|
||||
turbopack: {
|
||||
rules: {
|
||||
|
|
@ -33,13 +29,8 @@ const nextConfig: NextConfig = {
|
|||
},
|
||||
},
|
||||
|
||||
// Configure webpack to handle blocknote packages + SVGR
|
||||
webpack: (config, { isServer }) => {
|
||||
if (isServer) {
|
||||
// Don't bundle these packages on the server
|
||||
config.externals = [...(config.externals || []), "@blocknote/server-util"];
|
||||
}
|
||||
|
||||
// Configure webpack (SVGR)
|
||||
webpack: (config) => {
|
||||
// SVGR: import *.svg as React components
|
||||
const fileLoaderRule = config.module.rules.find((rule: any) => rule.test?.test?.(".svg"));
|
||||
config.module.rules.push(
|
||||
|
|
|
|||
21825
surfsense_web/package-lock.json
generated
21825
surfsense_web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -22,19 +22,33 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^1.2.12",
|
||||
"@ariakit/react": "^0.4.21",
|
||||
"@assistant-ui/react": "^0.11.53",
|
||||
"@assistant-ui/react-ai-sdk": "^1.1.20",
|
||||
"@assistant-ui/react-markdown": "^0.11.9",
|
||||
"@blocknote/core": "^0.45.0",
|
||||
"@blocknote/mantine": "^0.45.0",
|
||||
"@blocknote/react": "^0.45.0",
|
||||
"@blocknote/server-util": "^0.45.0",
|
||||
"@electric-sql/client": "^1.4.0",
|
||||
"@electric-sql/pglite": "^0.3.14",
|
||||
"@electric-sql/pglite-sync": "^0.4.0",
|
||||
"@electric-sql/react": "^1.0.26",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@number-flow/react": "^0.5.10",
|
||||
"@platejs/autoformat": "^52.0.11",
|
||||
"@platejs/basic-nodes": "^52.0.11",
|
||||
"@platejs/callout": "^52.0.11",
|
||||
"@platejs/code-block": "^52.0.11",
|
||||
"@platejs/combobox": "^52.0.15",
|
||||
"@platejs/dnd": "^52.0.11",
|
||||
"@platejs/floating": "^52.0.11",
|
||||
"@platejs/indent": "^52.0.11",
|
||||
"@platejs/link": "^52.0.11",
|
||||
"@platejs/list": "^52.0.11",
|
||||
"@platejs/markdown": "^52.1.0",
|
||||
"@platejs/math": "^52.0.11",
|
||||
"@platejs/resizable": "^52.0.11",
|
||||
"@platejs/selection": "^52.0.16",
|
||||
"@platejs/slash-command": "^52.0.15",
|
||||
"@platejs/table": "^52.0.11",
|
||||
"@platejs/toggle": "^52.0.11",
|
||||
"@posthog/react": "^1.7.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
|
|
@ -56,6 +70,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-toolbar": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@streamdown/code": "^1.0.2",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
|
|
@ -66,6 +81,7 @@
|
|||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@udecode/cn": "^52.0.11",
|
||||
"ai": "^4.3.19",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
@ -83,17 +99,22 @@
|
|||
"jotai-tanstack-query": "^0.11.0",
|
||||
"katex": "^0.16.28",
|
||||
"lenis": "^1.3.17",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.477.0",
|
||||
"motion": "^12.23.22",
|
||||
"next": "^16.1.0",
|
||||
"next-intl": "^4.6.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.16.3",
|
||||
"platejs": "^52.0.17",
|
||||
"postgres": "^3.4.7",
|
||||
"posthog-js": "^1.336.1",
|
||||
"posthog-node": "^5.24.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.3",
|
||||
"react-day-picker": "^9.8.1",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.61.1",
|
||||
|
|
@ -109,6 +130,7 @@
|
|||
"sonner": "^2.0.6",
|
||||
"streamdown": "^2.2.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-scrollbar-hide": "^4.0.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
|
|
|
|||
2710
surfsense_web/pnpm-lock.yaml
generated
2710
surfsense_web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue