Compare commits

..

9 Commits

Author SHA1 Message Date
Philipinho a7f90d6474 Merge branch 'main' into anchor-link 2025-09-21 01:58:39 +01:00
Philipinho 29d3a8cee2 Merge branch 'main' into anchor-link 2025-09-17 02:44:38 +01:00
Philipinho 66099f4657 use prosemirror decorations 2025-08-11 22:10:30 -07:00
Philipinho cefabc8683 Merge branch 'main' into anchor-link 2025-08-11 13:49:57 -07:00
Philipinho 703bfad424 Support anchor links in page mentions 2025-07-08 21:06:33 -07:00
fuscodev b82171c24c fix: uid in shared pages 2025-06-28 13:07:54 +02:00
fuscodev 1baff07e4e add nanoid by Vito0912 2025-06-27 20:27:54 +02:00
fuscodev f689291a99 Merge branch 'main' into anchor-link 2025-06-18 23:41:03 +02:00
fuscodev 0e4af65935 anchor link init 2025-06-04 20:43:47 +02:00
163 changed files with 2026 additions and 7508 deletions
+2 -4
View File
@@ -1,4 +1,4 @@
FROM node:22-slim AS base FROM node:22-alpine AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost" LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
FROM base AS builder FROM base AS builder
@@ -13,9 +13,7 @@ RUN pnpm build
FROM base AS installer FROM base AS installer
RUN apt-get update \ RUN apk add --no-cache curl bash
&& apt-get install -y --no-install-recommends curl bash \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+6 -7
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.24.1", "version": "0.23.2",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -17,7 +17,6 @@
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-864353b", "@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.1.3", "@mantine/core": "^8.1.3",
"@mantine/dates": "^8.3.2",
"@mantine/form": "^8.1.3", "@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3", "@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3", "@mantine/modals": "^8.1.3",
@@ -27,7 +26,7 @@
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3", "@tiptap/extension-character-count": "^2.10.3",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.13.2", "axios": "^1.9.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
@@ -57,7 +56,7 @@
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18", "tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76" "zod": "^3.25.56"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.16.0", "@eslint/js": "^9.16.0",
@@ -65,10 +64,10 @@
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
"@types/node": "22.19.1", "@types/node": "22.10.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
@@ -81,6 +80,6 @@
"prettier": "^3.4.1", "prettier": "^3.4.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"typescript-eslint": "^8.17.0", "typescript-eslint": "^8.17.0",
"vite": "^7.2.4" "vite": "^6.3.5"
} }
} }
@@ -42,7 +42,7 @@
"Delete group": "Gruppe löschen", "Delete group": "Gruppe löschen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.",
"Description": "Beschreibung", "Description": "Beschreibung",
"Details": "Details", "Details": "Einzelheiten",
"e.g ACME": "z.B. ACME", "e.g ACME": "z.B. ACME",
"e.g ACME Inc": "z.B. ACME Inc.", "e.g ACME Inc": "z.B. ACME Inc.",
"e.g Developers": "z.B. Entwickler", "e.g Developers": "z.B. Entwickler",
@@ -234,7 +234,9 @@
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.", "Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
"Invite link": "Einladungslink", "Invite link": "Einladungslink",
"Copy": "Kopieren", "Copy": "Kopieren",
"Copy to space": "In Raum kopieren",
"Copied": "Kopiert", "Copied": "Kopiert",
"Duplicate": "Duplizieren",
"Select a user": "Benutzer auswählen", "Select a user": "Benutzer auswählen",
"Select a group": "Gruppe auswählen", "Select a group": "Gruppe auswählen",
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.", "Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
@@ -525,47 +527,5 @@
"Delete SSO provider": "SSO-Anbieter löschen", "Delete SSO provider": "SSO-Anbieter löschen",
"Are you sure you want to delete this SSO provider?": "Sind Sie sicher, dass Sie diesen SSO-Anbieter löschen möchten?", "Are you sure you want to delete this SSO provider?": "Sind Sie sicher, dass Sie diesen SSO-Anbieter löschen möchten?",
"Action": "Aktion", "Action": "Aktion",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}-Konfiguration", "{{ssoProviderType}} configuration": "{{ssoProviderType}}-Konfiguration"
"Icon": "Icon",
"Upload image": "Bild hochladen",
"Remove image": "Bild entfernen",
"Failed to remove image": "Fehler beim Entfernen des Bildes",
"Image exceeds 10MB limit.": "Bild überschreitet das Limit von 10 MB.",
"Image removed successfully": "Bild erfolgreich entfernt",
"API key": "API-Schlüssel",
"API key created successfully": "API-Schlüssel erfolgreich erstellt",
"API keys": "API-Schlüssel",
"API management": "API-Verwaltung",
"Are you sure you want to revoke this API key": "Sind Sie sicher, dass Sie diesen API-Schlüssel widerrufen möchten?",
"Create API Key": "API-Schlüssel erstellen",
"Custom expiration date": "Benutzerdefiniertes Ablaufdatum",
"Enter a descriptive token name": "Geben Sie einen beschreibenden Token-Namen ein",
"Expiration": "Ablauf",
"Expired": "Abgelaufen",
"Expires": "Läuft ab",
"I've saved my API key": "Ich habe meinen API-Schlüssel gespeichert",
"Last use": "Zuletzt verwendet",
"No API keys found": "Keine API-Schlüssel gefunden",
"No expiration": "Kein Ablauf",
"Revoke API key": "API-Schlüssel widerrufen",
"Revoked successfully": "Erfolgreich widerrufen",
"Select expiration date": "Ablaufdatum wählen",
"This action cannot be undone. Any applications using this API key will stop working.": "Diese Aktion kann nicht rückgängig gemacht werden. Alle Anwendungen, die diesen API-Schlüssel verwenden, werden nicht mehr funktionieren.",
"Update API key": "API-Schlüssel aktualisieren",
"Manage API keys for all users in the workspace": "Verwalten Sie API-Schlüssel für alle Benutzer im Arbeitsbereich",
"AI settings": "KI-Einstellungen",
"AI search": "KI-Suche",
"AI Answer": "KI-Antwort",
"Ask AI": "KI fragen",
"AI is thinking...": "Die KI überlegt...",
"Ask a question...": "Fragen stellen...",
"AI-powered search (Ask AI)": "KI-gestützte Suche (KI fragen)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
"Toggle AI search": "KI-Suche umschalten",
"Sources": "Quellen",
"Ask AI not available for attachments": "KI fragen nicht für Anhänge verfügbar",
"No answer available": "Keine Antwort verfügbar",
"Background color": "Hintergrundfarbe",
"Highlight color": "Hervorhebungsfarbe",
"Remove color": "Farbe entfernen"
} }
@@ -234,6 +234,7 @@
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.", "Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
"Invite link": "Invite link", "Invite link": "Invite link",
"Copy": "Copy", "Copy": "Copy",
"Copy anchor link": "Copy anchor link",
"Copy to space": "Copy to space", "Copy to space": "Copy to space",
"Copied": "Copied", "Copied": "Copied",
"Duplicate": "Duplicate", "Duplicate": "Duplicate",
@@ -404,6 +405,7 @@
"Copy page": "Copy page", "Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.", "Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully", "Page copied successfully": "Page copied successfully",
"Anchor link copied": "Anchor link copied",
"Page duplicated successfully": "Page duplicated successfully", "Page duplicated successfully": "Page duplicated successfully",
"Find": "Find", "Find": "Find",
"Not found": "Not found", "Not found": "Not found",
@@ -533,41 +535,5 @@
"Remove image": "Remove image", "Remove image": "Remove image",
"Failed to remove image": "Failed to remove image", "Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.", "Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully", "Image removed successfully": "Image removed successfully"
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"API management": "API management",
"Are you sure you want to revoke this API key": "Are you sure you want to revoke this API key",
"Create API Key": "Create API Key",
"Custom expiration date": "Custom expiration date",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"Select expiration date": "Select expiration date",
"This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
"Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace",
"AI settings": "AI settings",
"AI search": "AI search",
"AI Answer": "AI Answer",
"Ask AI": "Ask AI",
"AI is thinking...": "AI is thinking...",
"Ask a question...": "Ask a question...",
"AI-powered search (Ask AI)": "AI-powered search (Ask AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
"Toggle AI search": "Toggle AI search",
"Sources": "Sources",
"Ask AI not available for attachments": "Ask AI not available for attachments",
"No answer available": "No answer available",
"Background color": "Background color",
"Highlight color": "Highlight color",
"Remove color": "Remove color"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "Eliminar proveedor de SSO", "Delete SSO provider": "Eliminar proveedor de SSO",
"Are you sure you want to delete this SSO provider?": "¿Está seguro de que desea eliminar este proveedor de SSO?", "Are you sure you want to delete this SSO provider?": "¿Está seguro de que desea eliminar este proveedor de SSO?",
"Action": "Acción", "Action": "Acción",
"{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}"
"Icon": "Icono",
"Upload image": "Subir imagen",
"Remove image": "Eliminar imagen",
"Failed to remove image": "No se ha podido eliminar la imagen",
"Image exceeds 10MB limit.": "La imagen excede del límite de 10 MB",
"Image removed successfully": "Imagen eliminada correctamente",
"API key": "Clave API",
"API key created successfully": "Clave API creada correctamente",
"API keys": "Claves API",
"API management": "Gestión de API",
"Are you sure you want to revoke this API key": "¿Está seguro de que desea revocar esta clave API? ",
"Create API Key": "Crear clave API",
"Custom expiration date": "Fecha de vencimiento personalizada",
"Enter a descriptive token name": "Introduce un nombre descriptivo del token",
"Expiration": "Vencimiento",
"Expired": "Vencido",
"Expires": "Vence",
"I've saved my API key": "He guardado mi clave API",
"Last use": "Último uso",
"No API keys found": "No se han encontrado claves API",
"No expiration": "Sin vencimiento",
"Revoke API key": "Revocar clave API",
"Revoked successfully": "Revocada correctamente",
"Select expiration date": "Seleccionar fecha de vencimiento",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta acción no se puede deshacer. Las aplicaciones que utilicen esta clave API dejarán de funcionar.",
"Update API key": "Actualizar clave API",
"Manage API keys for all users in the workspace": "Gestionar claves API para todos los usuarios en el espacio de trabajo",
"AI settings": "Configuración de IA",
"AI search": "Búsqueda de IA",
"AI Answer": "Respuesta de IA",
"Ask AI": "Preguntar a IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Haz una pregunta...",
"AI-powered search (Ask AI)": "Búsqueda impulsada por IA (Preguntar a IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
"Toggle AI search": "Alternar búsqueda de IA",
"Sources": "Fuentes",
"Ask AI not available for attachments": "Preguntar a IA no está disponible para adjuntos",
"No answer available": "No hay respuesta disponible",
"Background color": "Color de fondo",
"Highlight color": "Color de resaltado",
"Remove color": "Eliminar color"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "Supprimer le fournisseur SSO", "Delete SSO provider": "Supprimer le fournisseur SSO",
"Are you sure you want to delete this SSO provider?": "Êtes-vous sûr de vouloir supprimer ce fournisseur SSO ?", "Are you sure you want to delete this SSO provider?": "Êtes-vous sûr de vouloir supprimer ce fournisseur SSO ?",
"Action": "Action", "Action": "Action",
"{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}"
"Icon": "Icône",
"Upload image": "Téléverser une image",
"Remove image": "Supprimer l'image",
"Failed to remove image": "Échec de la suppression de l'image",
"Image exceeds 10MB limit.": "L'image dépasse la limite de 10 Mo.",
"Image removed successfully": "Image supprimée avec succès",
"API key": "Clé API",
"API key created successfully": "Clé API créée avec succès",
"API keys": "Clés API",
"API management": "Gestion des API",
"Are you sure you want to revoke this API key": "Êtes-vous sûr de vouloir révoquer cette clé API",
"Create API Key": "Créer une clé API",
"Custom expiration date": "Date d'expiration personnalisée",
"Enter a descriptive token name": "Entrez un nom descriptif pour le jeton",
"Expiration": "Expiration",
"Expired": "Expiré(e)",
"Expires": "Expire",
"I've saved my API key": "J'ai enregistré ma clé API",
"Last use": "Dernière utilisation",
"No API keys found": "Aucune clé API trouvée",
"No expiration": "Pas d'expiration",
"Revoke API key": "Révoquer la clé API",
"Revoked successfully": "Révoqué(e) avec succès",
"Select expiration date": "Sélectionnez la date d'expiration",
"This action cannot be undone. Any applications using this API key will stop working.": "Cette action ne peut pas être annulée. Toutes les applications utilisant cette clé API cesseront de fonctionner.",
"Update API key": "Mettre à jour la clé API",
"Manage API keys for all users in the workspace": "Gérer les clés API pour tous les utilisateurs dans l'espace de travail",
"AI settings": "Paramètres de l'IA",
"AI search": "Recherche IA",
"AI Answer": "Réponse IA",
"Ask AI": "Demander à l'IA",
"AI is thinking...": "L'IA réfléchit...",
"Ask a question...": "Posez une question...",
"AI-powered search (Ask AI)": "Recherche assistée par l'IA (Demander à l'IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
"Toggle AI search": "Basculer la recherche IA",
"Sources": "Sources",
"Ask AI not available for attachments": "Demande à l'IA non disponible pour les pièces jointes",
"No answer available": "Pas de réponse disponible",
"Background color": "Couleur de fond",
"Highlight color": "Couleur de surbrillance",
"Remove color": "Supprimer la couleur"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "Elimina provider SSO", "Delete SSO provider": "Elimina provider SSO",
"Are you sure you want to delete this SSO provider?": "Sei sicuro di voler eliminare questo provider SSO?", "Are you sure you want to delete this SSO provider?": "Sei sicuro di voler eliminare questo provider SSO?",
"Action": "Azione", "Action": "Azione",
"{{ssoProviderType}} configuration": "Configurazione {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Configurazione {{ssoProviderType}}"
"Icon": "Icona",
"Upload image": "Carica immagine",
"Remove image": "Rimuovi immagine",
"Failed to remove image": "Rimozione immagine fallita",
"Image exceeds 10MB limit.": "L'immagine supera il limite di 10MB.",
"Image removed successfully": "Immagine rimossa con successo",
"API key": "Chiave API",
"API key created successfully": "Chiave API creata con successo",
"API keys": "Chiavi API",
"API management": "Gestione API",
"Are you sure you want to revoke this API key": "Sei sicuro di voler revocare questa chiave API",
"Create API Key": "Crea Chiave API",
"Custom expiration date": "Data di scadenza personalizzata",
"Enter a descriptive token name": "Inserisci un nome descrittivo del token",
"Expiration": "Scadenza",
"Expired": "Scaduto",
"Expires": "Scade",
"I've saved my API key": "Ho salvato la mia chiave API",
"Last use": "Ultimo utilizzo",
"No API keys found": "Nessuna chiave API trovata",
"No expiration": "Nessuna scadenza",
"Revoke API key": "Revoca chiave API",
"Revoked successfully": "Revocata con successo",
"Select expiration date": "Seleziona la data di scadenza",
"This action cannot be undone. Any applications using this API key will stop working.": "Questa azione non può essere annullata. Qualsiasi applicazione che utilizza questa chiave API smetterà di funzionare.",
"Update API key": "Aggiorna chiave API",
"Manage API keys for all users in the workspace": "Gestisci le chiavi API per tutti gli utenti nell'area di lavoro",
"AI settings": "Impostazioni AI",
"AI search": "Ricerca AI",
"AI Answer": "Risposta AI",
"Ask AI": "Chiedi all'AI",
"AI is thinking...": "L'AI sta pensando...",
"Ask a question...": "Fai una domanda...",
"AI-powered search (Ask AI)": "Ricerca potenziata dall'AI (Chiedi all'AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
"Toggle AI search": "Attiva/disattiva ricerca AI",
"Sources": "Fonti",
"Ask AI not available for attachments": "Chiedere all'AI non è disponibile per gli allegati",
"No answer available": "Nessuna risposta disponibile",
"Background color": "Colore di sfondo",
"Highlight color": "Colore evidenziato",
"Remove color": "Rimuovi colore"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "SSOプロバイダーを削除する", "Delete SSO provider": "SSOプロバイダーを削除する",
"Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか?", "Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか?",
"Action": "アクション", "Action": "アクション",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成", "{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成"
"Icon": "アイコン",
"Upload image": "画像をアップロード",
"Remove image": "画像を削除",
"Failed to remove image": "画像の削除に失敗しました",
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています。",
"Image removed successfully": "画像が正常に削除されました",
"API key": "APIキー",
"API key created successfully": "APIキーが正常に作成されました",
"API keys": "APIキー",
"API management": "API管理",
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
"Create API Key": "APIキーを作成",
"Custom expiration date": "カスタム有効期限",
"Enter a descriptive token name": "説明的なトークン名を入力してください",
"Expiration": "有効期限",
"Expired": "期限切れ",
"Expires": "期限が切れます",
"I've saved my API key": "APIキーを保存しました",
"Last use": "最終使用",
"No API keys found": "APIキーが見つかりません",
"No expiration": "期限なし",
"Revoke API key": "APIキーを無効にする",
"Revoked successfully": "正常に無効化されました",
"Select expiration date": "有効期限を選択してください",
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は元に戻せません。このAPIキーを使用しているアプリケーションは動作を停止します。",
"Update API key": "APIキーを更新",
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
"AI settings": "AI設定",
"AI search": "AI検索",
"AI Answer": "AI回答",
"Ask AI": "AIに質問する",
"AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...",
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用して、ワークスペースコンテンツ全体にわたって意味検索機能を提供します。",
"Toggle AI search": "AI検索を切り替え",
"Sources": "ソース",
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
"No answer available": "回答がありません",
"Background color": "背景色",
"Highlight color": "ハイライト色",
"Remove color": "色を削除"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "SSO 제공자 삭제", "Delete SSO provider": "SSO 제공자 삭제",
"Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?", "Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?",
"Action": "작업", "Action": "작업",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성", "{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성"
"Icon": "아이콘",
"Upload image": "이미지 업로드",
"Remove image": "이미지 제거",
"Failed to remove image": "이미지 제거 실패",
"Image exceeds 10MB limit.": "이미지가 10MB 용량 제한을 초과합니다.",
"Image removed successfully": "이미지가 성공적으로 제거되었습니다",
"API key": "API 키",
"API key created successfully": "API 키 생성 완료",
"API keys": "API 키",
"API management": "API 관리",
"Are you sure you want to revoke this API key": "이 API 키를 취소하시겠습니까?",
"Create API Key": "API 키 생성",
"Custom expiration date": "사용자 정의 만료일",
"Enter a descriptive token name": "토큰 이름을 입력하세요",
"Expiration": "만료",
"Expired": "만료됨",
"Expires": "만료일",
"I've saved my API key": "API 키를 저장했습니다",
"Last use": "최근 사용",
"No API keys found": "API 키를 찾을 수 없습니다",
"No expiration": "유효기간 없음",
"Revoke API key": "API 키 취소",
"Revoked successfully": "성공적으로 취소되었습니다",
"Select expiration date": "만료일 선택",
"This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.",
"Update API key": "API 키 갱신",
"Manage API keys for all users in the workspace": "워크스페이스 내 모든 사용자의 API 키 관리",
"AI settings": "AI 설정",
"AI search": "AI 검색",
"AI Answer": "AI 답변",
"Ask AI": "AI에게 묻기",
"AI is thinking...": "AI가 생각 중입니다...",
"Ask a question...": "질문하세요...",
"AI-powered search (Ask AI)": "AI 지원 검색 (AI에게 묻기)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "AI 검색 전환",
"Sources": "출처",
"Ask AI not available for attachments": "AI에게 묻기 기능은 첨부 파일에 대해 사용할 수 없습니다",
"No answer available": "답변을 제공할 수 없습니다",
"Background color": "배경 색",
"Highlight color": "강조 색",
"Remove color": "색 제거"
} }
@@ -34,7 +34,7 @@
"Create group": "Groep aanmaken", "Create group": "Groep aanmaken",
"Create page": "Pagina aanmaken", "Create page": "Pagina aanmaken",
"Create space": "Ruimte aanmaken", "Create space": "Ruimte aanmaken",
"Create workspace": "Werkruimte aanmaken", "Create workspace": "Wwerkruimte aanmaken",
"Current password": "Huidig wachtwoord", "Current password": "Huidig wachtwoord",
"Dark": "Donker", "Dark": "Donker",
"Date": "Datum", "Date": "Datum",
@@ -91,7 +91,7 @@
"Invite by email": "Uitnodigen via e-mail", "Invite by email": "Uitnodigen via e-mail",
"Invite members": "Leden uitnodigen", "Invite members": "Leden uitnodigen",
"Invite new members": "Nieuwe leden uitnodigen", "Invite new members": "Nieuwe leden uitnodigen",
"Invited members who are yet to accept their invitation will appear here.": "Uitgenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.", "Invited members who are yet to accept their invitation will appear here.": "Uigenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.",
"Invited members will be granted access to spaces the groups can access": "Uitgenodigde leden wordt toegang gegeven tot ruimtes de groepen toegang toe heeft", "Invited members will be granted access to spaces the groups can access": "Uitgenodigde leden wordt toegang gegeven tot ruimtes de groepen toegang toe heeft",
"Join the workspace": "Word lid van de werkruimte", "Join the workspace": "Word lid van de werkruimte",
"Language": "Taal", "Language": "Taal",
@@ -527,47 +527,5 @@
"Delete SSO provider": "Verwijder SSO-provider", "Delete SSO provider": "Verwijder SSO-provider",
"Are you sure you want to delete this SSO provider?": "Weet u zeker dat u deze SSO-provider wilt verwijderen?", "Are you sure you want to delete this SSO provider?": "Weet u zeker dat u deze SSO-provider wilt verwijderen?",
"Action": "Actie", "Action": "Actie",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie", "{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie"
"Icon": "Icoon",
"Upload image": "Afbeelding uploaden",
"Remove image": "Afbeelding verwijderen",
"Failed to remove image": "Afbeelding verwijderen mislukt",
"Image exceeds 10MB limit.": "Afbeelding overschrijdt de limiet van 10MB.",
"Image removed successfully": "Afbeelding succesvol verwijderd",
"API key": "API-sleutel",
"API key created successfully": "API-sleutel succesvol aangemaakt",
"API keys": "API-sleutels",
"API management": "API-beheer",
"Are you sure you want to revoke this API key": "Weet u zeker dat u deze API-sleutel wilt intrekken",
"Create API Key": "API-sleutel aanmaken",
"Custom expiration date": "Aangepaste vervaldatum",
"Enter a descriptive token name": "Voer een beschrijvende tokennaam in",
"Expiration": "Vervaldatum",
"Expired": "Verlopen",
"Expires": "Verloopt",
"I've saved my API key": "Ik heb mijn API-sleutel opgeslagen",
"Last use": "Laatst gebruikt",
"No API keys found": "Geen API-sleutels gevonden",
"No expiration": "Geen vervaldatum",
"Revoke API key": "API-sleutel intrekken",
"Revoked successfully": "Succesvol ingetrokken",
"Select expiration date": "Selecteer vervaldatum",
"This action cannot be undone. Any applications using this API key will stop working.": "Deze actie kan niet ongedaan worden gemaakt. Alle toepassingen die deze API-sleutel gebruiken, zullen niet meer werken.",
"Update API key": "API-sleutel bijwerken",
"Manage API keys for all users in the workspace": "Beheer API-sleutels voor alle gebruikers in de werkruimte",
"AI settings": "AI-instellingen",
"AI search": "AI-zoekopdracht",
"AI Answer": "AI Antwoord",
"Ask AI": "Vraag AI",
"AI is thinking...": "AI is aan het nadenken...",
"Ask a question...": "Stel een vraag...",
"AI-powered search (Ask AI)": "AI-ondersteunde zoekopdracht (Vraag AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
"Sources": "Bronnen",
"Ask AI not available for attachments": "Vraag AI is niet beschikbaar voor bijlages",
"No answer available": "Geen antwoord beschikbaar",
"Background color": "Achtergrondkleur",
"Highlight color": "Markeerkleur",
"Remove color": "Kleur verwijderen"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "Excluir provedor de SSO", "Delete SSO provider": "Excluir provedor de SSO",
"Are you sure you want to delete this SSO provider?": "Tem certeza de que deseja excluir este provedor de SSO?", "Are you sure you want to delete this SSO provider?": "Tem certeza de que deseja excluir este provedor de SSO?",
"Action": "Ação", "Action": "Ação",
"{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}"
"Icon": "Ícone",
"Upload image": "Fazer upload da imagem",
"Remove image": "Remover imagem",
"Failed to remove image": "Falha ao remover imagem",
"Image exceeds 10MB limit.": "A imagem excede o limite de 10MB.",
"Image removed successfully": "Imagem removida com sucesso",
"API key": "Chave API",
"API key created successfully": "Chave API criada com sucesso",
"API keys": "Chaves API",
"API management": "Gestão de API",
"Are you sure you want to revoke this API key": "Tem certeza de que deseja revogar esta chave API",
"Create API Key": "Criar Chave API",
"Custom expiration date": "Data de expiração personalizada",
"Enter a descriptive token name": "Insira um nome descritivo para o token",
"Expiration": "Expiração",
"Expired": "Expirado",
"Expires": "Expira",
"I've saved my API key": "Salvei minha chave API",
"Last use": "Último uso",
"No API keys found": "Nenhuma chave API encontrada",
"No expiration": "Sem expiração",
"Revoke API key": "Revogar chave API",
"Revoked successfully": "Revogada com sucesso",
"Select expiration date": "Selecionar data de expiração",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta ação não pode ser desfeita. Qualquer aplicação usando esta chave API deixará de funcionar.",
"Update API key": "Atualizar chave API",
"Manage API keys for all users in the workspace": "Gerenciar chaves API para todos os usuários no espaço de trabalho",
"AI settings": "Configurações de IA",
"AI search": "Pesquisa IA",
"AI Answer": "Resposta de IA",
"Ask AI": "Pergunte à IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Faça uma pergunta...",
"AI-powered search (Ask AI)": "Pesquisa com IA (Pergunte à IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
"Toggle AI search": "Alternar pesquisa de IA",
"Sources": "Fontes",
"Ask AI not available for attachments": "Perguntar à IA não está disponível para anexos",
"No answer available": "Nenhuma resposta disponível",
"Background color": "Cor de fundo",
"Highlight color": "Cor de destaque",
"Remove color": "Remover cor"
} }
@@ -498,10 +498,10 @@
"Deleted at": "Удалено в", "Deleted at": "Удалено в",
"Preview": "Предпросмотр", "Preview": "Предпросмотр",
"Subpages": "Подстраницы", "Subpages": "Подстраницы",
"Failed to load subpages": "Не удалось загрузить под страницы", "Failed to load subpages": "Не удалось загрузить подстраницы",
"No subpages": "Нет подстраниц", "No subpages": "Нет подстраниц",
"Subpages (Child pages)": "Подстраницы (вложенные страницы)", "Subpages (Child pages)": "Подстраницы (вложенные страницы)",
"List all subpages of the current page": "Показать все под страницы", "List all subpages of the current page": "Показать все подстраницы текущей страницы",
"Attachments": "Вложения", "Attachments": "Вложения",
"All spaces": "Все пространства", "All spaces": "Все пространства",
"Unknown": "Неизвестно", "Unknown": "Неизвестно",
@@ -527,47 +527,5 @@
"Delete SSO provider": "Удалить поставщика SSO", "Delete SSO provider": "Удалить поставщика SSO",
"Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?", "Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?",
"Action": "Действие", "Action": "Действие",
"{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}"
"Icon": "Иконка",
"Upload image": "Загрузить изображение",
"Remove image": "Удалить изображение",
"Failed to remove image": "Не удалось удалить изображение",
"Image exceeds 10MB limit.": "Изображение превышает предел 10MB.",
"Image removed successfully": "Изображение успешно удалено",
"API key": "API ключ",
"API key created successfully": "API ключ успешно создан",
"API keys": "API ключи",
"API management": "Управление API",
"Are you sure you want to revoke this API key": "Вы уверены, что хотите отозвать этот API ключ",
"Create API Key": "Создать API ключ",
"Custom expiration date": "Пользовательская дата срока действия",
"Enter a descriptive token name": "Введите понятное имя токена",
"Expiration": "Срок действия",
"Expired": "Истек",
"Expires": "Истекает",
"I've saved my API key": "Я сохранил мой API ключ",
"Last use": "Последнее использование",
"No API keys found": "API ключи не найдены",
"No expiration": "Не истекает",
"Revoke API key": "Отозвать API ключ",
"Revoked successfully": "Отозван успешно",
"Select expiration date": "Выберете срок действия",
"This action cannot be undone. Any applications using this API key will stop working.": "Это действие необратимо. Любые приложения, использующие этот API ключ, перестанут работать.",
"Update API key": "Обновить API ключ",
"Manage API keys for all users in the workspace": "Управлять API ключами для всех пользователей в рабочей области",
"AI settings": "Настройки ИИ",
"AI search": "Поиск ИИ",
"AI Answer": "Ответ ИИ",
"Ask AI": "Спросить ИИ",
"AI is thinking...": "ИИ обрабатывает запрос...",
"Ask a question...": "Задайте вопрос...",
"AI-powered search (Ask AI)": "Поиск на базе ИИ (Спросить ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"Toggle AI search": "Переключить поиск ИИ",
"Sources": "Источники",
"Ask AI not available for attachments": "Функция \"Спросить ИИ\" недоступна для вложений",
"No answer available": "Ответ недоступен",
"Background color": "Цвет фона",
"Highlight color": "Цвет выделения",
"Remove color": "Удалить цвет"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "Видалити постачальника SSO", "Delete SSO provider": "Видалити постачальника SSO",
"Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?", "Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?",
"Action": "Дія", "Action": "Дія",
"{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}"
"Icon": "Іконка",
"Upload image": "Завантажити зображення",
"Remove image": "Видалити зображення",
"Failed to remove image": "Не вдалося видалити зображення",
"Image exceeds 10MB limit.": "Зображення має займати менше, ніж 10 МБ.",
"Image removed successfully": "Зображення видалено",
"API key": "Ключ API",
"API key created successfully": "Ключ API успішно створено",
"API keys": "Ключі API",
"API management": "Управління API",
"Are you sure you want to revoke this API key": "Ви впевнені, що хочете відкликати цей ключ API",
"Create API Key": "Створити ключ API",
"Custom expiration date": "Користувацька дата закінчення",
"Enter a descriptive token name": "Введіть описову назву токена",
"Expiration": "Термін дії",
"Expired": "Закінчився",
"Expires": "Закінчується",
"I've saved my API key": "Я зберіг свій ключ API",
"Last use": "Останнє використання",
"No API keys found": "Ключі API не знайдено",
"No expiration": "Без терміну дії",
"Revoke API key": "Відкликати ключ API",
"Revoked successfully": "Успішно відкликано",
"Select expiration date": "Виберіть дату закінчення",
"This action cannot be undone. Any applications using this API key will stop working.": "Цю дію не можна скасувати. Будь-які додатки, що використовують цей ключ API, перестануть працювати.",
"Update API key": "Оновити ключ API",
"Manage API keys for all users in the workspace": "Керувати ключами API для всіх користувачів у робочій області",
"AI settings": "Налаштування ШІ",
"AI search": "Пошук з ШІ",
"AI Answer": "Відповідь ШІ",
"Ask AI": "Запитати ШІ",
"AI is thinking...": "ШІ думає...",
"Ask a question...": "Задайте питання...",
"AI-powered search (Ask AI)": "Пошук на базі ШІ (Запитати ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"Toggle AI search": "Переключити пошук з ШІ",
"Sources": "Джерела",
"Ask AI not available for attachments": "Запитати ШІ недоступно для вкладень",
"No answer available": "Відповідь недоступна",
"Background color": "Колір фону",
"Highlight color": "Колір підсвічування",
"Remove color": "Видалити колір"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "删除SSO提供商", "Delete SSO provider": "删除SSO提供商",
"Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗?", "Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗?",
"Action": "操作", "Action": "操作",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置", "{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置"
"Icon": "图标",
"Upload image": "上传图片",
"Remove image": "删除图片",
"Failed to remove image": "无法删除图片",
"Image exceeds 10MB limit.": "图片超过10MB限制。",
"Image removed successfully": "图片删除成功",
"API key": "API密钥",
"API key created successfully": "API密钥创建成功",
"API keys": "API密钥",
"API management": "API管理",
"Are you sure you want to revoke this API key": "确定要撤销此API密钥吗",
"Create API Key": "创建API密钥",
"Custom expiration date": "自定义到期日期",
"Enter a descriptive token name": "输入描述性令牌名称",
"Expiration": "到期",
"Expired": "已过期",
"Expires": "到期",
"I've saved my API key": "我已保存我的API密钥",
"Last use": "上次使用",
"No API keys found": "找不到API密钥",
"No expiration": "无到期",
"Revoke API key": "撤销API密钥",
"Revoked successfully": "撤销成功",
"Select expiration date": "选择到期日期",
"This action cannot be undone. Any applications using this API key will stop working.": "此操作无法撤销。使用此API密钥的任何应用程序将停止工作。",
"Update API key": "更新API密钥",
"Manage API keys for all users in the workspace": "管理工作空间中所有用户的API密钥",
"AI settings": "AI设置",
"AI search": "AI搜索",
"AI Answer": "AI回答",
"Ask AI": "询问AI",
"AI is thinking...": "AI正在思考...",
"Ask a question...": "提问...",
"AI-powered search (Ask AI)": "AI驱动的搜索(询问AI",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换AI搜索",
"Sources": "来源",
"Ask AI not available for attachments": "附件不支持询问AI",
"No answer available": "无可用答案",
"Background color": "背景颜色",
"Highlight color": "突出显示颜色",
"Remove color": "移除颜色"
} }
-6
View File
@@ -35,9 +35,6 @@ import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/space-trash.tsx"; import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -99,16 +96,13 @@ export default function App() {
path={"account/preferences"} path={"account/preferences"}
element={<AccountPreferences />} element={<AccountPreferences />}
/> />
<Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route path={"workspace"} element={<WorkspaceSettings />} /> <Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} /> <Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
<Route path={"groups"} element={<Groups />} /> <Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} /> <Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} /> <Route path={"spaces"} element={<Spaces />} />
<Route path={"sharing"} element={<Shares />} /> <Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} /> <Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} />
{!isCloud() && <Route path={"license"} element={<License />} />} {!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />} {isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route> </Route>
@@ -4,15 +4,14 @@ import { useTranslation } from "react-i18next";
interface NoTableResultsProps { interface NoTableResultsProps {
colSpan: number; colSpan: number;
text?: string;
} }
export default function NoTableResults({ colSpan, text }: NoTableResultsProps) { export default function NoTableResults({ colSpan }: NoTableResultsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={colSpan}> <Table.Td colSpan={colSpan}>
<Text fw={500} c="dimmed" ta="center"> <Text fw={500} c="dimmed" ta="center">
{text || t("No results found...")} {t("No results found...")}
</Text> </Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -10,7 +10,6 @@ import { getWorkspaceMembers } from "@/features/workspace/services/workspace-ser
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts"; import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts"; import { getSsoProviders } from "@/ee/security/services/security-service.ts";
import { getShares } from "@/features/share/services/share-service.ts"; import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams; const params = { limit: 100, page: 1, query: "" } as QueryParams;
@@ -66,17 +65,3 @@ export const prefetchShares = () => {
queryFn: () => getShares({ page: 1, limit: 100 }), queryFn: () => getShares({ page: 1, limit: 100 }),
}); });
}; };
export const prefetchApiKeys = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1 }),
});
};
export const prefetchApiKeyManagement = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1, adminView: true }),
});
};
@@ -12,18 +12,15 @@ import {
IconLock, IconLock,
IconKey, IconKey,
IconWorld, IconWorld,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css"; import classes from "./settings.module.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { import {
prefetchApiKeyManagement,
prefetchApiKeys,
prefetchBilling, prefetchBilling,
prefetchGroups, prefetchGroups,
prefetchLicense, prefetchLicense,
@@ -63,14 +60,6 @@ const groupedData: DataGroup[] = [
icon: IconBrush, icon: IconBrush,
path: "/settings/account/preferences", path: "/settings/account/preferences",
}, },
{
label: "API keys",
icon: IconKey,
path: "/settings/account/api-keys",
isCloud: true,
isEnterprise: true,
showDisabledInNonEE: true,
},
], ],
}, },
{ {
@@ -101,22 +90,6 @@ const groupedData: DataGroup[] = [
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, { label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
{
label: "API management",
icon: IconKey,
path: "/settings/api-keys",
isCloud: true,
isEnterprise: true,
isAdmin: true,
showDisabledInNonEE: true,
},
{
label: "AI settings",
icon: IconSparkles,
path: "/settings/ai",
isAdmin: true,
isSelfhosted: true,
},
], ],
}, },
{ {
@@ -222,12 +195,6 @@ export default function SettingsSidebar() {
case "Public sharing": case "Public sharing":
prefetchHandler = prefetchShares; prefetchHandler = prefetchShares;
break; break;
case "API keys":
prefetchHandler = prefetchApiKeys;
break;
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
default: default:
break; break;
} }
@@ -1,113 +0,0 @@
import React, { useMemo } from "react";
import { Paper, Text, Group, Stack, Loader, Box } from "@mantine/core";
import { IconSparkles, IconFileText } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { IAiSearchResponse } from "../services/ai-search-service.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { markdownToHtml } from "@docmost/editor-ext";
import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next";
interface AiSearchResultProps {
result?: IAiSearchResponse;
isLoading?: boolean;
streamingAnswer?: string;
streamingSources?: any[];
}
export function AiSearchResult({
result,
isLoading,
streamingAnswer = "",
streamingSources = [],
}: AiSearchResultProps) {
const { t } = useTranslation();
// Use streaming data if available, otherwise fall back to result
const answer = streamingAnswer || result?.answer || "";
const sources =
streamingSources.length > 0 ? streamingSources : result?.sources || [];
// Deduplicate sources by pageId, keeping the one with highest similarity
const deduplicatedSources = useMemo(() => {
if (!sources || sources.length === 0) return [];
const pageMap = new Map();
sources.forEach((source) => {
const existing = pageMap.get(source.pageId);
if (!existing || source.similarity > existing.similarity) {
pageMap.set(source.pageId, source);
}
});
return Array.from(pageMap.values());
}, [sources]);
if (isLoading && !answer) {
return (
<Paper p="md" radius="md" withBorder>
<Group>
<Loader size="sm" />
<Text size="sm">{t("AI is thinking...")}</Text>
</Group>
</Paper>
);
}
if (!answer && !isLoading) {
return null;
}
return (
<Stack gap="md" p="md">
<Paper p="md" radius="md" withBorder>
<Group gap="xs" mb="sm">
<IconSparkles size={20} color="var(--mantine-color-blue-6)" />
<Text fw={600} size="sm">
{t("AI Answer")}
</Text>
{isLoading && <Loader size="xs" />}
</Group>
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(markdownToHtml(answer) as string),
}}
/>
</Paper>
{deduplicatedSources.length > 0 && (
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
{t("Sources")}
</Text>
{deduplicatedSources.map((source) => (
<Box
key={source.pageId}
component={Link}
to={buildPageUrl(source.spaceSlug, source.slugId, source.title)}
style={{
textDecoration: "none",
color: "inherit",
display: "block",
}}
>
<Paper
p="xs"
radius="sm"
withBorder
style={{ cursor: "pointer" }}
>
<Group gap="xs">
<IconFileText size={16} />
<Text size="sm" truncate>
{source.title}
</Text>
</Group>
</Paper>
</Box>
))}
</Stack>
)}
</Stack>
);
}
@@ -1,69 +0,0 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { isCloud } from "@/lib/config.ts";
import useLicense from "@/ee/hooks/use-license.tsx";
export default function EnableAiSearch() {
const { t } = useTranslation();
return (
<>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("AI-powered search (Ask AI)")}</Text>
<Text size="sm" c="dimmed">
{t(
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
)}
</Text>
</div>
<AiSearchToggle />
</Group>
</>
);
}
interface AiSearchToggleProps {
size?: MantineSize;
label?: string;
}
export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
const { hasLicenseKey } = useLicense();
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ aiSearch: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI search")}
/>
);
}
@@ -1,46 +0,0 @@
import { useMutation, UseMutationResult } from "@tanstack/react-query";
import { useState, useCallback } from "react";
import { askAi, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
// @ts-ignore
interface UseAiSearchResult extends UseMutationResult<IAiSearchResponse, Error, IPageSearchParams> {
streamingAnswer: string;
streamingSources: any[];
clearStreaming: () => void;
}
export function useAiSearch(): UseAiSearchResult {
const [streamingAnswer, setStreamingAnswer] = useState("");
const [streamingSources, setStreamingSources] = useState<any[]>([]);
const clearStreaming = useCallback(() => {
setStreamingAnswer("");
setStreamingSources([]);
}, []);
const mutation = useMutation({
mutationFn: async (params: IPageSearchParams & { contentType?: string }) => {
setStreamingAnswer("");
setStreamingSources([]);
const { contentType, ...apiParams } = params;
return await askAi(apiParams, (chunk) => {
if (chunk.content) {
setStreamingAnswer((prev) => prev + chunk.content);
}
if (chunk.sources) {
setStreamingSources(chunk.sources);
}
});
},
});
return {
...mutation,
streamingAnswer,
streamingSources,
clearStreaming,
};
}
-61
View File
@@ -1,61 +0,0 @@
import { useState, useCallback, useRef } from "react";
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
import { AiGenerateDto } from "@/ee/ai/types/ai.types.ts";
export function useAiStream() {
const [content, setContent] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const mutation = useAiGenerateStreamMutation();
const startStream = useCallback(
async (data: AiGenerateDto) => {
setContent("");
setIsStreaming(true);
try {
const controller = await mutation.mutateAsync({
...data,
onChunk: (chunk) => {
setContent((prev) => prev + chunk.content);
},
onError: (error) => {
console.error("AI stream error:", error);
setIsStreaming(false);
},
onComplete: () => {
setIsStreaming(false);
},
});
abortControllerRef.current = controller;
} catch (error) {
console.error("Failed to start stream:", error);
setIsStreaming(false);
}
},
[mutation]
);
const stopStream = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsStreaming(false);
}
}, []);
const resetContent = useCallback(() => {
setContent("");
}, []);
return {
content,
isStreaming,
startStream,
stopStream,
resetContent,
isLoading: mutation.isPending,
error: mutation.error,
};
}
@@ -1,46 +0,0 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import { Alert } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
export default function AiSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { hasLicenseKey } = useLicense();
if (!isAdmin) {
return null;
}
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
return (
<>
<Helmet>
<title>AI - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("AI settings")} />
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
mb="lg"
>
{t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
<EnableAiSearch />
</>
);
}
-44
View File
@@ -1,44 +0,0 @@
import {
useMutation,
UseMutationResult,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import {
generateAiContent,
generateAiContentStream,
} from "@/ee/ai/services/ai-service.ts";
import {
AiConfigResponse,
AiContentResponse,
AiGenerateDto,
AiStreamChunk,
AiStreamError,
} from "@/ee/ai/types/ai.types.ts";
export function useAiGenerateMutation(): UseMutationResult<
AiContentResponse,
Error,
AiGenerateDto
> {
return useMutation({
mutationFn: (data: AiGenerateDto) => generateAiContent(data),
});
}
interface StreamCallbacks {
onChunk: (chunk: AiStreamChunk) => void;
onError?: (error: AiStreamError) => void;
onComplete?: () => void;
}
export function useAiGenerateStreamMutation(): UseMutationResult<
AbortController,
Error,
AiGenerateDto & StreamCallbacks
> {
return useMutation({
mutationFn: ({ onChunk, onError, onComplete, ...data }) =>
generateAiContentStream(data, onChunk, onError, onComplete),
});
}
@@ -1,83 +0,0 @@
import api from "@/lib/api-client.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
export interface IAiSearchResponse {
answer: string;
sources?: Array<{
pageId: string;
title: string;
slugId: string;
spaceSlug: string;
similarity: number;
distance: number;
chunkIndex: number;
excerpt: string;
}>;
}
export async function askAi(
params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
): Promise<IAiSearchResponse> {
const response = await fetch("/api/ai/ask", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let answer = "";
let sources: any[] = [];
let buffer = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
// Keep the last incomplete line in the buffer
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
if (parsed.error) {
throw new Error(parsed.error);
}
if (parsed.content) {
answer += parsed.content;
onChunk?.({ content: parsed.content });
}
if (parsed.sources) {
sources = parsed.sources;
onChunk?.({ sources: parsed.sources });
}
} catch (e) {
if (e instanceof Error) {
throw e;
}
// Skip invalid JSON
}
}
}
}
}
return { answer, sources };
}
@@ -1,89 +0,0 @@
import api from "@/lib/api-client.ts";
import {
AiGenerateDto,
AiContentResponse,
AiStreamChunk,
AiStreamError,
} from "@/ee/ai/types/ai.types.ts";
export async function generateAiContent(
data: AiGenerateDto,
): Promise<AiContentResponse> {
const req = await api.post<AiContentResponse>("/ai/generate", data);
return req.data;
}
export async function generateAiContentStream(
data: AiGenerateDto,
onChunk: (chunk: AiStreamChunk) => void,
onError?: (error: AiStreamError) => void,
onComplete?: () => void,
): Promise<AbortController> {
const abortController = new AbortController();
try {
const response = await fetch("/api/ai/generate/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
signal: abortController.signal,
credentials: "include", // This ensures cookies are sent, matching axios withCredentials
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error("Response body is not readable");
}
const processStream = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") {
onComplete?.();
return;
}
try {
const parsed = JSON.parse(data);
if (parsed.error) {
onError?.(parsed);
} else {
onChunk(parsed);
}
} catch (e) {
// Ignore parse errors for incomplete chunks
}
}
}
}
} catch (error) {
if (error.name !== "AbortError") {
onError?.({ error: error.message });
}
} finally {
reader.releaseLock();
}
};
processStream();
} catch (error) {
onError?.({ error: error.message });
}
return abortController;
}
-40
View File
@@ -1,40 +0,0 @@
export enum AiAction {
IMPROVE_WRITING = "improve_writing",
FIX_SPELLING_GRAMMAR = "fix_spelling_grammar",
MAKE_SHORTER = "make_shorter",
MAKE_LONGER = "make_longer",
SIMPLIFY = "simplify",
CHANGE_TONE = "change_tone",
SUMMARIZE = "summarize",
CONTINUE_WRITING = "continue_writing",
TRANSLATE = "translate",
CUSTOM = "custom",
}
export interface AiGenerateDto {
action?: AiAction;
content: string;
prompt?: string;
}
export interface AiContentResponse {
content: string;
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
export interface AiConfigResponse {
configured: boolean;
availableActions: AiAction[];
}
export interface AiStreamChunk {
content: string;
}
export interface AiStreamError {
error: string;
}
@@ -1,72 +0,0 @@
import {
Modal,
Text,
Stack,
Alert,
Group,
Button,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IApiKey } from "@/ee/api-key";
import CopyTextButton from "@/components/common/copy.tsx";
interface ApiKeyCreatedModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey;
}
export function ApiKeyCreatedModal({
opened,
onClose,
apiKey,
}: ApiKeyCreatedModalProps) {
const { t } = useTranslation();
if (!apiKey) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("API key created")}
size="lg"
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("Important")}
color="red"
>
{t(
"Make sure to copy your API key now. You won't be able to see it again!",
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("API key")}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{
flex: 1,
}}
value={apiKey.token}
readOnly
/>
<CopyTextButton text={apiKey.token} />
</Group>
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my API key")}
</Button>
</Stack>
</Modal>
);
}
@@ -1,143 +0,0 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import { IApiKey } from "@/ee/api-key";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
interface ApiKeyTableProps {
apiKeys: IApiKey[];
isLoading?: boolean;
showUserColumn?: boolean;
onUpdate?: (apiKey: IApiKey) => void;
onRevoke?: (apiKey: IApiKey) => void;
}
export function ApiKeyTable({
apiKeys,
isLoading,
showUserColumn = false,
onUpdate,
onRevoke,
}: ApiKeyTableProps) {
const { t } = useTranslation();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy");
};
const isExpired = (expiresAt: string | null) => {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
};
return (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
{showUserColumn && <Table.Th>{t("User")}</Table.Th>}
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{apiKeys && apiKeys.length > 0 ? (
apiKeys.map((apiKey: IApiKey, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Text fz="sm" fw={500}>
{apiKey.name}
</Text>
</Table.Td>
{showUserColumn && apiKey.creator && (
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={apiKey.creator?.avatarUrl}
name={apiKey.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{apiKey.creator.name}
</Text>
</Group>
</Table.Td>
)}
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
{apiKey.expiresAt ? (
isExpired(apiKey.expiresAt) ? (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{t("Expired")}
</Text>
) : (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.expiresAt)}
</Text>
)
) : (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{t("Never")}
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onUpdate && (
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={() => onUpdate(apiKey)}
>
{t("Rename")}
</Menu.Item>
)}
{onRevoke && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => onRevoke(apiKey)}
>
{t("Revoke")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={showUserColumn ? 6 : 5} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -1,153 +0,0 @@
import { lazy, Suspense, useState } from "react";
import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IconCalendar } from "@tabler/icons-react";
import { IApiKey } from "@/ee/api-key";
const DateInput = lazy(() =>
import("@mantine/dates").then((module) => ({
default: module.DateInput,
})),
);
interface CreateApiKeyModalProps {
opened: boolean;
onClose: () => void;
onSuccess: (response: IApiKey) => void;
}
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
expiresAt: z.string().optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateApiKeyModal({
opened,
onClose,
onSuccess,
}: CreateApiKeyModalProps) {
const { t } = useTranslation();
const [expirationOption, setExpirationOption] = useState<string>("30");
const createApiKeyMutation = useCreateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: "",
expiresAt: "",
},
});
const getExpirationDate = (): string | undefined => {
if (expirationOption === "never") {
return undefined;
}
if (expirationOption === "custom") {
return form.values.expiresAt;
}
const days = parseInt(expirationOption);
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString();
};
const getExpirationLabel = (days: number) => {
const date = new Date();
date.setDate(date.getDate() + days);
const formatted = date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
});
return `${days} days (${formatted})`;
};
const expirationOptions = [
{ value: "30", label: getExpirationLabel(30) },
{ value: "60", label: getExpirationLabel(60) },
{ value: "90", label: getExpirationLabel(90) },
{ value: "365", label: getExpirationLabel(365) },
{ value: "custom", label: t("Custom") },
{ value: "never", label: t("No expiration") },
];
const handleSubmit = async (data: {
name?: string;
expiresAt?: string | Date;
}) => {
const groupData = {
name: data.name,
expiresAt: getExpirationDate(),
};
try {
const createdKey = await createApiKeyMutation.mutateAsync(groupData);
onSuccess(createdKey);
form.reset();
onClose();
} catch (err) {
//
}
};
const handleClose = () => {
form.reset();
setExpirationOption("30");
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create API Key")}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
data-autofocus
required
{...form.getInputProps("name")}
/>
<Select
label={t("Expiration")}
data={expirationOptions}
value={expirationOption}
onChange={(value) => setExpirationOption(value || "30")}
leftSection={<IconCalendar size={16} />}
allowDeselect={false}
/>
{expirationOption === "custom" && (
<Suspense fallback={null}>
<DateInput
label={t("Custom expiration date")}
placeholder={t("Select expiration date")}
minDate={new Date()}
{...form.getInputProps("expiresAt")}
/>
</Suspense>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createApiKeyMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -1,62 +0,0 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRevokeApiKeyMutation } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
interface RevokeApiKeyModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey | null;
}
export function RevokeApiKeyModal({
opened,
onClose,
apiKey,
}: RevokeApiKeyModalProps) {
const { t } = useTranslation();
const revokeApiKeyMutation = useRevokeApiKeyMutation();
const handleRevoke = async () => {
if (!apiKey) return;
await revokeApiKeyMutation.mutateAsync({
apiKeyId: apiKey.id,
});
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke API key")}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this API key")}{" "}
<strong>{apiKey?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
{t(
"This action cannot be undone. Any applications using this API key will stop working.",
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRevoke}
loading={revokeApiKeyMutation.isPending}
>
{t("Revoke")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -1,80 +0,0 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IApiKey } from "@/ee/api-key";
import { useEffect } from "react";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface UpdateApiKeyModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey | null;
}
export function UpdateApiKeyModal({
opened,
onClose,
apiKey,
}: UpdateApiKeyModalProps) {
const { t } = useTranslation();
const updateApiKeyMutation = useUpdateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: "",
},
});
useEffect(() => {
if (opened && apiKey) {
form.setValues({ name: apiKey.name });
}
}, [opened, apiKey]);
const handleSubmit = async (data: { name?: string }) => {
const apiKeyData = {
apiKeyId: apiKey.id,
name: data.name,
};
await updateApiKeyMutation.mutateAsync(apiKeyData);
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Update API key")}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive token name")}
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateApiKeyMutation.isPending}>
{t("Update")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
-11
View File
@@ -1,11 +0,0 @@
export { ApiKeyTable } from "./components/api-key-table";
export { CreateApiKeyModal } from "./components/create-api-key-modal";
export { ApiKeyCreatedModal } from "./components/api-key-created-modal";
export { UpdateApiKeyModal } from "./components/update-api-key-modal";
export { RevokeApiKeyModal } from "./components/revoke-api-key-modal";
// Services
export * from "./services/api-key-service";
// Types
export * from "./types/api-key.types";
@@ -1,106 +0,0 @@
import React, { useState } from "react";
import { Button, Group, Space } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
export default function UserApiKeys() {
const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page });
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
};
const handleUpdate = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setUpdateModalOpened(true);
};
const handleRevoke = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setRevokeModalOpened(true);
};
return (
<>
<Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API keys")} />
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<ApiKeyTable
apiKeys={data?.items || []}
isLoading={isLoading}
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
<CreateApiKeyModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onSuccess={handleCreateSuccess}
/>
<ApiKeyCreatedModal
opened={!!createdApiKey}
onClose={() => setCreatedApiKey(null)}
apiKey={createdApiKey}
/>
<UpdateApiKeyModal
opened={updateModalOpened}
onClose={() => {
setUpdateModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
<RevokeApiKeyModal
opened={revokeModalOpened}
onClose={() => {
setRevokeModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
</>
);
}
@@ -1,117 +0,0 @@
import React, { useState } from "react";
import { Button, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx';
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
const { isAdmin } = useUserRole();
if (!isAdmin) {
return null;
}
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
};
const handleUpdate = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setUpdateModalOpened(true);
};
const handleRevoke = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setRevokeModalOpened(true);
};
return (
<>
<Helmet>
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API management")} />
<Text size="md" c="dimmed" mb="md">
{t("Manage API keys for all users in the workspace")}
</Text>
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<ApiKeyTable
apiKeys={data?.items}
isLoading={isLoading}
showUserColumn
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
<CreateApiKeyModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onSuccess={handleCreateSuccess}
/>
<ApiKeyCreatedModal
opened={!!createdApiKey}
onClose={() => setCreatedApiKey(null)}
apiKey={createdApiKey}
/>
<UpdateApiKeyModal
opened={updateModalOpened}
onClose={() => {
setUpdateModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
<RevokeApiKeyModal
opened={revokeModalOpened}
onClose={() => {
setRevokeModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
</>
);
}
@@ -1,97 +0,0 @@
import { IPagination, QueryParams } from "@/lib/types.ts";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
createApiKey,
getApiKeys,
IApiKey,
ICreateApiKeyRequest,
IUpdateApiKeyRequest,
revokeApiKey,
updateApiKey,
} from "@/ee/api-key";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetApiKeysQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IApiKey>, Error> {
return useQuery({
queryKey: ["api-key-list", params],
queryFn: () => getApiKeys(params),
staleTime: 0,
gcTime: 0,
placeholderData: keepPreviousData,
});
}
export function useRevokeApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<
void,
Error,
{
apiKeyId: string;
}
>({
mutationFn: (data) => revokeApiKey(data),
onSuccess: (data, variables) => {
notifications.show({ message: t("Revoked successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useCreateApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
mutationFn: (data) => createApiKey(data),
onSuccess: () => {
notifications.show({ message: t("API key created successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IApiKey, Error, IUpdateApiKeyRequest>({
mutationFn: (data) => updateApiKey(data),
onSuccess: (data, variables) => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -1,32 +0,0 @@
import api from "@/lib/api-client";
import {
ICreateApiKeyRequest,
IApiKey,
IUpdateApiKeyRequest,
} from "@/ee/api-key/types/api-key.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getApiKeys(
params?: QueryParams,
): Promise<IPagination<IApiKey>> {
const req = await api.post("/api-keys", { ...params });
return req.data;
}
export async function createApiKey(
data: ICreateApiKeyRequest,
): Promise<IApiKey> {
const req = await api.post<IApiKey>("/api-keys/create", data);
return req.data;
}
export async function updateApiKey(
data: IUpdateApiKeyRequest,
): Promise<IApiKey> {
const req = await api.post<IApiKey>("/api-keys/update", data);
return req.data;
}
export async function revokeApiKey(data: { apiKeyId: string }): Promise<void> {
await api.post("/api-keys/revoke", data);
}
@@ -1,23 +0,0 @@
import { IUser } from "@/features/user/types/user.types.ts";
export interface IApiKey {
id: string;
name: string;
token?: string;
creatorId: string;
workspaceId: string;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
creator: Partial<IUser>;
}
export interface ICreateApiKeyRequest {
name: string;
expiresAt?: string;
}
export interface IUpdateApiKeyRequest {
apiKeyId: string;
name: string;
}
@@ -11,7 +11,7 @@ export default function OssDetails() {
withTableBorder withTableBorder
> >
<Table.Caption> <Table.Caption>
To unlock enterprise features like AI, SSO, MFA, Resolve comments, contact sales@docmost.com. To unlock enterprise features like SSO, MFA, Resolve comments, contact sales@docmost.com.
</Table.Caption> </Table.Caption>
<Table.Tbody> <Table.Tbody>
<Table.Tr> <Table.Tr>
@@ -3,7 +3,6 @@ import {
BubbleMenuProps, BubbleMenuProps,
isNodeSelection, isNodeSelection,
useEditor, useEditor,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react"; import { FC, useEffect, useRef, useState } from "react";
import { import {
@@ -51,52 +50,34 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showCommentPopupRef.current = showCommentPopup; showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]); }, [showCommentPopup]);
const editorState = useEditorState({
editor: props.editor,
selector: (ctx) => {
if (!props.editor) {
return null;
}
return {
isBold: ctx.editor.isActive("bold"),
isItalic: ctx.editor.isActive("italic"),
isUnderline: ctx.editor.isActive("underline"),
isStrike: ctx.editor.isActive("strike"),
isCode: ctx.editor.isActive("code"),
isComment: ctx.editor.isActive("comment"),
};
},
});
const items: BubbleMenuItem[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Bold", name: "Bold",
isActive: () => editorState?.isBold, isActive: () => props.editor.isActive("bold"),
command: () => props.editor.chain().focus().toggleBold().run(), command: () => props.editor.chain().focus().toggleBold().run(),
icon: IconBold, icon: IconBold,
}, },
{ {
name: "Italic", name: "Italic",
isActive: () => editorState?.isItalic, isActive: () => props.editor.isActive("italic"),
command: () => props.editor.chain().focus().toggleItalic().run(), command: () => props.editor.chain().focus().toggleItalic().run(),
icon: IconItalic, icon: IconItalic,
}, },
{ {
name: "Underline", name: "Underline",
isActive: () => editorState?.isUnderline, isActive: () => props.editor.isActive("underline"),
command: () => props.editor.chain().focus().toggleUnderline().run(), command: () => props.editor.chain().focus().toggleUnderline().run(),
icon: IconUnderline, icon: IconUnderline,
}, },
{ {
name: "Strike", name: "Strike",
isActive: () => editorState?.isStrike, isActive: () => props.editor.isActive("strike"),
command: () => props.editor.chain().focus().toggleStrike().run(), command: () => props.editor.chain().focus().toggleStrike().run(),
icon: IconStrikethrough, icon: IconStrikethrough,
}, },
{ {
name: "Code", name: "Code",
isActive: () => editorState?.isCode, isActive: () => props.editor.isActive("code"),
command: () => props.editor.chain().focus().toggleCode().run(), command: () => props.editor.chain().focus().toggleCode().run(),
icon: IconCode, icon: IconCode,
}, },
@@ -104,7 +85,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const commentItem: BubbleMenuItem = { const commentItem: BubbleMenuItem = {
name: "Comment", name: "Comment",
isActive: () => editorState?.isComment, isActive: () => props.editor.isActive("comment"),
command: () => { command: () => {
const commentId = uuid7(); const commentId = uuid7();
@@ -144,16 +125,16 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
onHide: () => { onHide: () => {
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
}, },
}, },
}; };
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false); const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
return ( return (
<BubbleMenu {...bubbleMenuProps}> <BubbleMenu {...bubbleMenuProps}>
@@ -164,8 +145,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => { setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
}} }}
/> />
@@ -175,8 +156,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => { setIsOpen={() => {
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen); setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
}} }}
/> />
@@ -1,5 +1,5 @@
import React, { Dispatch, FC, SetStateAction } from "react"; import { Dispatch, FC, SetStateAction } from "react";
import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react"; import { IconCheck, IconPalette } from "@tabler/icons-react";
import { import {
ActionIcon, ActionIcon,
Button, Button,
@@ -8,12 +8,8 @@ import {
ScrollArea, ScrollArea,
Text, Text,
Tooltip, Tooltip,
SimpleGrid,
Box,
Stack,
} from "@mantine/core"; } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface BubbleColorMenuItem { export interface BubbleColorMenuItem {
@@ -22,7 +18,7 @@ export interface BubbleColorMenuItem {
} }
interface ColorSelectorProps { interface ColorSelectorProps {
editor: Editor | null; editor: ReturnType<typeof useEditor>;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@@ -64,12 +60,9 @@ const TEXT_COLORS: BubbleColorMenuItem[] = [
name: "Gray", name: "Gray",
color: "#A8A29E", color: "#A8A29E",
}, },
{
name: "Brown",
color: "#92400E",
},
]; ];
// TODO: handle dark mode
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
{ {
name: "Default", name: "Default",
@@ -77,39 +70,35 @@ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
}, },
{ {
name: "Blue", name: "Blue",
color: "#98d8f2", color: "#c1ecf9",
}, },
{ {
name: "Green", name: "Green",
color: "#7edb6c", color: "#acf79f",
}, },
{ {
name: "Purple", name: "Purple",
color: "#e0d6ed", color: "#f6f3f8",
}, },
{ {
name: "Red", name: "Red",
color: "#ffc6c2", color: "#fdebeb",
}, },
{ {
name: "Yellow", name: "Yellow",
color: "#faf594", color: "#fbf4a2",
}, },
{ {
name: "Orange", name: "Orange",
color: "#f5c8a9", color: "#faebdd",
}, },
{ {
name: "Pink", name: "Pink",
color: "#f5cfe0", color: "#faf1f5",
}, },
{ {
name: "Gray", name: "Gray",
color: "#dfdfd7", color: "#f1f1ef",
},
{
name: "Brown",
color: "#d7c4b7",
}, },
]; ];
@@ -119,180 +108,67 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
setIsOpen, setIsOpen,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const activeColorItem = TEXT_COLORS.find(({ color }) =>
const editorState = useEditorState({ editor.isActive("textStyle", { color }),
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const activeColors: Record<string, boolean> = {};
TEXT_COLORS.forEach(({ color }) => {
activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", {
color,
});
});
HIGHLIGHT_COLORS.forEach(({ color }) => {
activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", {
color,
});
});
return activeColors;
},
});
if (!editor || !editorState) {
return null;
}
const activeColorItem = TEXT_COLORS.find(
({ color }) => editorState[`text_${color}`],
); );
const activeHighlightItem = HIGHLIGHT_COLORS.find( const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
({ color }) => editorState[`highlight_${color}`], editor.isActive("highlight", { color }),
); );
return ( return (
<Popover width={220} opened={isOpen} withArrow> <Popover width={200} opened={isOpen} withArrow>
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text color")} withArrow> <Tooltip label={t("Text color")} withArrow>
<Button <ActionIcon
variant="default" variant="default"
size="lg"
radius="0" radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
data-text-color={activeColorItem?.color || ""}
data-highlight-color={activeHighlightItem?.color || ""}
className="color-selector-trigger"
style={{ style={{
height: "34px",
border: "none", border: "none",
fontWeight: 500, color: activeColorItem?.color,
fontSize: rem(16),
paddingLeft: rem(8),
paddingRight: rem(4),
}} }}
onClick={() => setIsOpen(!isOpen)}
> >
A <IconPalette size={16} stroke={2} />
</Button> </ActionIcon>
</Tooltip> </Tooltip>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
{/* make mah responsive */}
<ScrollArea.Autosize type="scroll" mah="400"> <ScrollArea.Autosize type="scroll" mah="400">
<Stack gap="md"> <Text span c="dimmed" tt="uppercase" inherit>
<Box> {t("Color")}
<Text size="sm" fw={600} mb="xs"> </Text>
{t("Text color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => (
<Tooltip key={index} label={t(name)} withArrow>
<Box
onClick={() => {
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
setIsOpen(false);
}}
style={{
width: rem(28),
height: rem(28),
borderRadius: rem(6),
border: editorState[`text_${color}`]
? "2px solid var(--mantine-color-gray-8)"
: "1px solid var(--mantine-color-gray-4)",
cursor: "pointer",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: rem(16),
fontWeight: 600,
color: color || "var(--mantine-color-gray-8)",
}}
>
A
</Box>
</Tooltip>
))}
</SimpleGrid>
</Box>
<Box> <Button.Group orientation="vertical">
<Text size="sm" fw={600} mb="xs"> {TEXT_COLORS.map(({ name, color }, index) => (
{t("Highlight color")} <Button
</Text> key={index}
<SimpleGrid cols={5} spacing="xs"> variant="default"
{HIGHLIGHT_COLORS.map(({ name, color }, index) => ( leftSection={<span style={{ color }}>A</span>}
<Tooltip key={index} label={t(name)} withArrow> justify="left"
<Box fullWidth
onClick={() => { rightSection={
if (name === "Default") { editor.isActive("textStyle", { color }) && (
editor.commands.unsetHighlight(); <IconCheck style={{ width: rem(16) }} />
} else { )
editor }
.chain() onClick={() => {
.focus() if (name === "Default") {
.toggleMark("highlight", { editor.commands.unsetColor();
color: color || "", } else {
colorName: name.toLowerCase() || "", editor.chain().focus().setColor(color || "").run();
}) }
.run(); setIsOpen(false);
} }}
setIsOpen(false); style={{ border: "none" }}
}} >
style={{ {t(name)}
width: rem(28), </Button>
height: rem(28), ))}
borderRadius: rem(4), </Button.Group>
backgroundColor: color || "var(--mantine-color-gray-2)",
border: "1px solid var(--mantine-color-gray-4)",
cursor: "pointer",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: rem(16),
fontWeight: 600,
color: "var(--mantine-color-gray-8)",
}}
>
{editorState[`highlight_${color}`] ? (
<IconCheck
size={16}
color="var(--mantine-color-green-7)"
/>
) : (
"A"
)}
</Box>
</Tooltip>
))}
</SimpleGrid>
</Box>
<Button
variant="default"
fullWidth
onClick={() => {
editor.commands.unsetColor();
editor.commands.unsetHighlight();
setIsOpen(false);
}}
>
{t("Remove color")}
</Button>
</Stack>
</ScrollArea.Autosize> </ScrollArea.Autosize>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -13,12 +13,11 @@ import {
IconTypography, IconTypography,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea } from "@mantine/core"; import { Popover, Button, ScrollArea } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface NodeSelectorProps { interface NodeSelectorProps {
editor: Editor | null; editor: ReturnType<typeof useEditor>;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@@ -37,27 +36,6 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!editor) {
return null;
}
return {
isParagraph: ctx.editor.isActive("paragraph"),
isBulletList: ctx.editor.isActive("bulletList"),
isOrderedList: ctx.editor.isActive("orderedList"),
isHeading1: ctx.editor.isActive("heading", { level: 1 }),
isHeading2: ctx.editor.isActive("heading", { level: 2 }),
isHeading3: ctx.editor.isActive("heading", { level: 3 }),
isTaskItem: ctx.editor.isActive("taskItem"),
isBlockquote: ctx.editor.isActive("blockquote"),
isCodeBlock: ctx.editor.isActive("codeBlock"),
};
},
});
const items: BubbleMenuItem[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Text", name: "Text",
@@ -65,45 +43,45 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
command: () => command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").run(), editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () => isActive: () =>
editorState?.isParagraph && editor.isActive("paragraph") &&
!editorState?.isBulletList && !editor.isActive("bulletList") &&
!editorState?.isOrderedList, !editor.isActive("orderedList"),
}, },
{ {
name: "Heading 1", name: "Heading 1",
icon: IconH1, icon: IconH1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => editorState?.isHeading1, isActive: () => editor.isActive("heading", { level: 1 }),
}, },
{ {
name: "Heading 2", name: "Heading 2",
icon: IconH2, icon: IconH2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => editorState?.isHeading2, isActive: () => editor.isActive("heading", { level: 2 }),
}, },
{ {
name: "Heading 3", name: "Heading 3",
icon: IconH3, icon: IconH3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => editorState?.isHeading3, isActive: () => editor.isActive("heading", { level: 3 }),
}, },
{ {
name: "To-do List", name: "To-do List",
icon: IconCheckbox, icon: IconCheckbox,
command: () => editor.chain().focus().toggleTaskList().run(), command: () => editor.chain().focus().toggleTaskList().run(),
isActive: () => editorState?.isTaskItem, isActive: () => editor.isActive("taskItem"),
}, },
{ {
name: "Bullet List", name: "Bullet List",
icon: IconList, icon: IconList,
command: () => editor.chain().focus().toggleBulletList().run(), command: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editorState?.isBulletList, isActive: () => editor.isActive("bulletList"),
}, },
{ {
name: "Numbered List", name: "Numbered List",
icon: IconListNumbers, icon: IconListNumbers,
command: () => editor.chain().focus().toggleOrderedList().run(), command: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editorState?.isOrderedList, isActive: () => editor.isActive("orderedList"),
}, },
{ {
name: "Blockquote", name: "Blockquote",
@@ -115,13 +93,13 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
.toggleNode("paragraph", "paragraph") .toggleNode("paragraph", "paragraph")
.toggleBlockquote() .toggleBlockquote()
.run(), .run(),
isActive: () => editorState?.isBlockquote, isActive: () => editor.isActive("blockquote"),
}, },
{ {
name: "Code", name: "Code",
icon: IconCode, icon: IconCode,
command: () => editor.chain().focus().toggleCodeBlock().run(), command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editorState?.isCodeBlock, isActive: () => editor.isActive("codeBlock"),
}, },
]; ];
@@ -8,12 +8,11 @@ import {
IconChevronDown, IconChevronDown,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea, rem } from "@mantine/core"; import { Popover, Button, ScrollArea, rem } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface TextAlignmentProps { interface TextAlignmentProps {
editor: Editor | null; editor: ReturnType<typeof useEditor>;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@@ -32,54 +31,36 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
isAlignJustify: ctx.editor.isActive({ textAlign: "justify" }),
};
},
});
if (!editor || !editorState) {
return null;
}
const items: BubbleMenuItem[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Align left", name: "Align left",
isActive: () => editorState?.isAlignLeft, isActive: () => editor.isActive({ textAlign: "left" }),
command: () => editor.chain().focus().setTextAlign("left").run(), command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft, icon: IconAlignLeft,
}, },
{ {
name: "Align center", name: "Align center",
isActive: () => editorState?.isAlignCenter, isActive: () => editor.isActive({ textAlign: "center" }),
command: () => editor.chain().focus().setTextAlign("center").run(), command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter, icon: IconAlignCenter,
}, },
{ {
name: "Align right", name: "Align right",
isActive: () => editorState?.isAlignRight, isActive: () => editor.isActive({ textAlign: "right" }),
command: () => editor.chain().focus().setTextAlign("right").run(), command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight, icon: IconAlignRight,
}, },
{ {
name: "Justify", name: "Justify",
isActive: () => editorState?.isAlignJustify, isActive: () => editor.isActive({ textAlign: "justify" }),
command: () => editor.chain().focus().setTextAlign("justify").run(), command: () => editor.chain().focus().setTextAlign("justify").run(),
icon: IconAlignJustified, icon: IconAlignJustified,
}, },
]; ];
const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0]; const activeItem = items.filter((item) => item.isActive()).pop() ?? {
name: "Multiple",
};
return ( return (
<Popover opened={isOpen} withArrow> <Popover opened={isOpen} withArrow>
@@ -92,7 +73,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
rightSection={<IconChevronDown size={16} />} rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
> >
<activeItem.icon style={{ width: rem(16) }} stroke={2} /> <IconAlignLeft style={{ width: rem(16) }} stroke={2} />
</Button> </Button>
</Popover.Target> </Popover.Target>
@@ -2,7 +2,6 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
@@ -10,7 +9,7 @@ import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip, Divider } from "@mantine/core";
import { import {
IconAlertTriangleFilled, IconAlertTriangleFilled,
IconCircleCheckFilled, IconCircleCheckFilled,
@@ -36,23 +35,6 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
[editor], [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isCallout: ctx.editor.isActive("callout"),
isInfo: ctx.editor.isActive("callout", { type: "info" }),
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout"; const predicate = (node: PMNode) => node.type.name === "callout";
@@ -110,7 +92,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`callout-menu`} pluginKey={`callout-menu}`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@@ -129,7 +111,9 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("info")} onClick={() => setCalloutType("info")}
size="lg" size="lg"
aria-label={t("Info")} aria-label={t("Info")}
variant={editorState?.isInfo ? "light" : "default"} variant={
editor.isActive("callout", { type: "info" }) ? "light" : "default"
}
> >
<IconInfoCircleFilled size={18} /> <IconInfoCircleFilled size={18} />
</ActionIcon> </ActionIcon>
@@ -140,7 +124,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("success")} onClick={() => setCalloutType("success")}
size="lg" size="lg"
aria-label={t("Success")} aria-label={t("Success")}
variant={editorState?.isSuccess ? "light" : "default"} variant={
editor.isActive("callout", { type: "success" })
? "light"
: "default"
}
> >
<IconCircleCheckFilled size={18} /> <IconCircleCheckFilled size={18} />
</ActionIcon> </ActionIcon>
@@ -151,7 +139,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("warning")} onClick={() => setCalloutType("warning")}
size="lg" size="lg"
aria-label={t("Warning")} aria-label={t("Warning")}
variant={editorState?.isWarning ? "light" : "default"} variant={
editor.isActive("callout", { type: "warning" })
? "light"
: "default"
}
> >
<IconAlertTriangleFilled size={18} /> <IconAlertTriangleFilled size={18} />
</ActionIcon> </ActionIcon>
@@ -162,7 +154,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("danger")} onClick={() => setCalloutType("danger")}
size="lg" size="lg"
aria-label={t("Danger")} aria-label={t("Danger")}
variant={editorState?.isDanger ? "light" : "default"} variant={
editor.isActive("callout", { type: "danger" })
? "light"
: "default"
}
> >
<IconCircleXFilled size={18} /> <IconCircleXFilled size={18} />
</ActionIcon> </ActionIcon>
@@ -5,7 +5,6 @@ import { v4 as uuidv4 } from "uuid";
import classes from "./code-block.module.css"; import classes from "./code-block.module.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useComputedColorScheme } from "@mantine/core"; import { useComputedColorScheme } from "@mantine/core";
import DOMPurify from "dompurify";
interface MermaidViewProps { interface MermaidViewProps {
props: NodeViewProps; props: NodeViewProps;
@@ -38,7 +37,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
.catch((err) => { .catch((err) => {
if (props.editor.isEditable) { if (props.editor.isEditable) {
setPreview( setPreview(
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${DOMPurify.sanitize(err)}</div>`, `<div class="${classes.error}">${t("Mermaid diagram error:")} ${err}</div>`,
); );
} else { } else {
setPreview( setPreview(
@@ -3,7 +3,6 @@ import { uploadImageAction } from "@/features/editor/components/image/upload-ima
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { Slice } from "@tiptap/pm/model";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
export const handlePaste = ( export const handlePaste = (
@@ -34,9 +33,9 @@ export const handlePaste = (
return false; return false;
} }
const anchorId = match[6] ? match[6].split('#')[0] : undefined; const anchor = match[6]; // Extract anchor from the regex match
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url; const urlWithoutAnchor = anchor ? url.substring(0, url.indexOf("#")) : url;
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId); createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchor);
return true; return true;
} }
@@ -2,16 +2,15 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState, } from '@tiptap/react';
} from "@tiptap/react"; import { useCallback } from 'react';
import { useCallback } from "react"; import { sticky } from 'tippy.js';
import { sticky } from "tippy.js"; import { Node as PMNode } from 'prosemirror-model';
import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from '@/features/editor/components/table/types/types.ts';
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
@@ -20,29 +19,14 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
return false; return false;
} }
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src; return editor.isActive('drawio') && editor.getAttributes('drawio')?.src;
}, },
[editor], [editor]
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const drawioAttr = ctx.editor.getAttributes("drawio");
return {
isDrawio: ctx.editor.isActive("drawio"),
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio"; const predicate = (node: PMNode) => node.type.name === 'drawio';
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
@@ -55,37 +39,40 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback( const onWidthChange = useCallback(
(value: number) => { (value: number) => {
editor.commands.updateAttributes("drawio", { width: `${value}%` }); editor.commands.updateAttributes('drawio', { width: `${value}%` });
}, },
[editor], [editor]
); );
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`drawio-menu`} pluginKey={`drawio-menu}`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
offset: [0, 8], offset: [0, 8],
zIndex: 99, zIndex: 99,
popperOptions: { popperOptions: {
modifiers: [{ name: "flip", enabled: false }], modifiers: [{ name: 'flip', enabled: false }],
}, },
plugins: [sticky], plugins: [sticky],
sticky: "popper", sticky: 'popper',
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div <div
style={{ style={{
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
alignItems: "center", alignItems: 'center',
}} }}
> >
{editorState?.width && ( {editor.getAttributes('drawio')?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} /> <NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes('drawio').width)}
/>
)} )}
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>
@@ -87,7 +87,7 @@ export default function DrawioView(props: NodeViewProps) {
}; };
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<Modal.Root opened={opened} onClose={close} fullScreen> <Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
@@ -85,7 +85,7 @@ export default function EmbedView(props: NodeViewProps) {
} }
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
{embedUrl ? ( {embedUrl ? (
<ResizableWrapper <ResizableWrapper
initialHeight={nodeHeight || 480} initialHeight={nodeHeight || 480}
@@ -2,16 +2,15 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState, } from '@tiptap/react';
} from "@tiptap/react"; import { useCallback } from 'react';
import { useCallback } from "react"; import { sticky } from 'tippy.js';
import { sticky } from "tippy.js"; import { Node as PMNode } from 'prosemirror-model';
import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from '@/features/editor/components/table/types/types.ts';
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
export function ExcalidrawMenu({ editor }: EditorMenuProps) { export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
@@ -20,31 +19,14 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return false; return false;
} }
return ( return editor.isActive('excalidraw') && editor.getAttributes('excalidraw')?.src;
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
);
}, },
[editor], [editor]
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
return {
isExcalidraw: ctx.editor.isActive("excalidraw"),
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw"; const predicate = (node: PMNode) => node.type.name === 'excalidraw';
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
@@ -57,9 +39,9 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback( const onWidthChange = useCallback(
(value: number) => { (value: number) => {
editor.commands.updateAttributes("excalidraw", { width: `${value}%` }); editor.commands.updateAttributes('excalidraw', { width: `${value}%` });
}, },
[editor], [editor]
); );
return ( return (
@@ -72,22 +54,25 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
offset: [0, 8], offset: [0, 8],
zIndex: 99, zIndex: 99,
popperOptions: { popperOptions: {
modifiers: [{ name: "flip", enabled: false }], modifiers: [{ name: 'flip', enabled: false }],
}, },
plugins: [sticky], plugins: [sticky],
sticky: "popper", sticky: 'popper',
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div <div
style={{ style={{
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
alignItems: "center", alignItems: 'center',
}} }}
> >
{editorState?.width && ( {editor.getAttributes('excalidraw')?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} /> <NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes('excalidraw').width)}
/>
)} )}
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>
@@ -118,7 +118,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
}; };
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<ReactClearModal <ReactClearModal
style={{ style={{
backgroundColor: "rgba(0, 0, 0, 0.5)", backgroundColor: "rgba(0, 0, 0, 0.5)",
@@ -0,0 +1,3 @@
.anchorScrollMargin {
scroll-margin-top: 95px;
}
@@ -0,0 +1,56 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
export function useAnchorScroll(offset = 95, maxRetries = 10, retryDelay = 500) {
const location = useLocation();
const lastHash = useRef("");
useEffect(() => {
let retries = maxRetries;
const tryScroll = () => {
let el = document.getElementById(lastHash.current);
if (!el) {
const hash = lastHash.current;
if (hash.includes('-')) {
const parts = hash.split('-');
const possibleUid = parts[parts.length - 1];
const elements = document.querySelectorAll('[id]');
for (const element of elements) {
if (element.id.endsWith(`-${possibleUid}`)) {
el = element as HTMLElement;
break;
}
}
}
if (!el) {
const elements = document.querySelectorAll('[id]');
for (const element of elements) {
if (element.id.endsWith(`-${hash}`) || element.id === hash) {
el = element as HTMLElement;
break;
}
}
}
}
if (el) {
const y = el.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top: y, behavior: "smooth" });
window.history.replaceState(null, "", `#${el.id}`);
} else if (retries > 0) {
retries--;
setTimeout(tryScroll, retryDelay);
}
};
if (location.hash) {
lastHash.current = location.hash.slice(1);
tryScroll();
}
}, [location, offset, maxRetries, retryDelay]);
}
@@ -2,7 +2,6 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js"; import { sticky } from "tippy.js";
@@ -33,25 +32,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
[editor], [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const imageAttrs = ctx.editor.getAttributes("image");
return {
isImage: ctx.editor.isActive("image"),
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image"; const predicate = (node: PMNode) => node.type.name === "image";
@@ -103,7 +83,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`image-menu`} pluginKey={`image-menu}`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@@ -123,7 +103,9 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageLeft} onClick={alignImageLeft}
size="lg" size="lg"
aria-label={t("Align left")} aria-label={t("Align left")}
variant={editorState?.isAlignLeft ? "light" : "default"} variant={
editor.isActive("image", { align: "left" }) ? "light" : "default"
}
> >
<IconLayoutAlignLeft size={18} /> <IconLayoutAlignLeft size={18} />
</ActionIcon> </ActionIcon>
@@ -134,7 +116,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageCenter} onClick={alignImageCenter}
size="lg" size="lg"
aria-label={t("Align center")} aria-label={t("Align center")}
variant={editorState?.isAlignCenter ? "light" : "default"} variant={
editor.isActive("image", { align: "center" })
? "light"
: "default"
}
> >
<IconLayoutAlignCenter size={18} /> <IconLayoutAlignCenter size={18} />
</ActionIcon> </ActionIcon>
@@ -145,15 +131,20 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageRight} onClick={alignImageRight}
size="lg" size="lg"
aria-label={t("Align right")} aria-label={t("Align right")}
variant={editorState?.isAlignRight ? "light" : "default"} variant={
editor.isActive("image", { align: "right" }) ? "light" : "default"
}
> >
<IconLayoutAlignRight size={18} /> <IconLayoutAlignRight size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
{editorState?.width && ( {editor.getAttributes("image")?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} /> <NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes("image").width)}
/>
)} )}
</BaseBubbleMenu> </BaseBubbleMenu>
); );
@@ -16,7 +16,7 @@ export default function ImageView(props: NodeViewProps) {
}, [align]); }, [align]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<Image <Image
radius="md" radius="md"
fit="contain" fit="contain"
@@ -9,7 +9,7 @@ export type LinkFn = (
view: EditorView, view: EditorView,
pos: number, pos: number,
creatorId: string, creatorId: string,
anchorId?: string, anchor?: string,
) => void; ) => void;
export interface InternalLinkOptions { export interface InternalLinkOptions {
@@ -19,7 +19,7 @@ export interface InternalLinkOptions {
export const handleInternalLink = export const handleInternalLink =
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn => ({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
async (url: string, view, pos, creatorId, anchorId) => { async (url: string, view, pos, creatorId, anchor) => {
const validated = validateFn(url, view); const validated = validateFn(url, view);
if (!validated) return; if (!validated) return;
@@ -36,7 +36,7 @@ export const handleInternalLink =
entityId: page.id, entityId: page.id,
slugId: page.slugId, slugId: page.slugId,
creatorId: creatorId, creatorId: creatorId,
anchorId: anchorId, anchor: anchor,
}); });
if (!node) return; if (!node) return;
@@ -1,4 +1,4 @@
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
@@ -12,18 +12,7 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return editor.isActive("link"); return editor.isActive("link");
}, [editor]); }, [editor]);
const editorState = useEditorState({ const { href: link } = editor.getAttributes("link");
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const link = ctx.editor.getAttributes("link");
return {
href: link.href,
};
},
});
const handleEdit = useCallback(() => { const handleEdit = useCallback(() => {
setShowEdit(true); setShowEdit(true);
@@ -81,14 +70,11 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
padding="xs" padding="xs"
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
> >
<LinkEditorPanel <LinkEditorPanel initialUrl={link} onSetLink={onSetLink} />
initialUrl={editorState?.href}
onSetLink={onSetLink}
/>
</Card> </Card>
) : ( ) : (
<LinkPreviewPanel <LinkPreviewPanel
url={editorState?.href} url={link}
onClear={onUnsetLink} onClear={onUnsetLink}
onEdit={handleEdit} onEdit={handleEdit}
/> />
@@ -11,7 +11,7 @@ import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) { export default function MentionView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs; const { label, entityType, entityId, slugId, anchor } = node.attrs;
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const { const {
@@ -27,11 +27,11 @@ export default function MentionView(props: NodeViewProps) {
shareId, shareId,
pageSlugId: slugId, pageSlugId: slugId,
pageTitle: label, pageTitle: label,
anchorId, anchor,
}); });
return ( return (
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle> <NodeViewWrapper style={{ display: "inline" }}>
{entityType === "user" && ( {entityType === "user" && (
<Text className={classes.userMention} component="span"> <Text className={classes.userMention} component="span">
@{label} @{label}
@@ -43,7 +43,7 @@ export default function MentionView(props: NodeViewProps) {
component={Link} component={Link}
fw={500} fw={500}
to={ to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId) isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchor)
} }
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
@@ -20,7 +20,6 @@ import {
IconCalendar, IconCalendar,
IconAppWindow, IconAppWindow,
IconSitemap, IconSitemap,
IconLayoutColumns,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@@ -244,51 +243,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.insertTable({ rows: 3, cols: 3, withHeaderRow: true }) .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run(), .run(),
}, },
{
title: "Columns",
description: "Insert 2 columns layout.",
searchTerms: ["columns", "layout", "grid", "side by side"],
icon: IconLayoutColumns,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "column_container",
content: [
{
type: "column",
attrs: { colWidth: 200 },
content: [
{
type: "paragraph",
},
],
},
{
type: "column",
attrs: { colWidth: 200 },
content: [
{
type: "paragraph",
},
],
},
{
type: "column",
attrs: { colWidth: 200 },
content: [
{
type: "paragraph",
},
],
},
],
})
.run();
},
},
{ {
title: "Toggle block", title: "Toggle block",
description: "Insert collapsible block.", description: "Insert collapsible block.",
@@ -52,7 +52,7 @@ export default function SubpagesView(props: NodeViewProps) {
if (error && !shareId) { if (error && !shareId) {
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<Text c="dimmed" size="md" py="md"> <Text c="dimmed" size="md" py="md">
{t("Failed to load subpages")} {t("Failed to load subpages")}
</Text> </Text>
@@ -62,7 +62,7 @@ export default function SubpagesView(props: NodeViewProps) {
if (subpages.length === 0) { if (subpages.length === 0) {
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<div className={classes.container}> <div className={classes.container}>
<Text c="dimmed" size="md" py="md"> <Text c="dimmed" size="md" py="md">
{t("No subpages")} {t("No subpages")}
@@ -73,7 +73,7 @@ export default function SubpagesView(props: NodeViewProps) {
} }
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<div className={classes.container}> <div className={classes.container}>
<Stack gap={5}> <Stack gap={5}>
{subpages.map((page) => ( {subpages.map((page) => (
@@ -9,8 +9,7 @@ import {
Tooltip, Tooltip,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface TableColorItem { export interface TableColorItem {
@@ -19,7 +18,7 @@ export interface TableColorItem {
} }
interface TableBackgroundColorProps { interface TableBackgroundColorProps {
editor: Editor | null; editor: ReturnType<typeof useEditor>;
} }
const TABLE_COLORS: TableColorItem[] = [ const TABLE_COLORS: TableColorItem[] = [
@@ -39,50 +38,37 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, setOpened] = React.useState(false); const [opened, setOpened] = React.useState(false);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
let currentColor = "";
if (ctx.editor.isActive("tableCell")) {
const attrs = ctx.editor.getAttributes("tableCell");
currentColor = attrs.backgroundColor || "";
} else if (ctx.editor.isActive("tableHeader")) {
const attrs = ctx.editor.getAttributes("tableHeader");
currentColor = attrs.backgroundColor || "";
}
return {
currentColor,
isTableCell: ctx.editor.isActive("tableCell"),
isTableHeader: ctx.editor.isActive("tableHeader"),
};
},
});
if (!editor || !editorState) {
return null;
}
const setTableCellBackground = (color: string, colorName: string) => { const setTableCellBackground = (color: string, colorName: string) => {
editor editor
.chain() .chain()
.focus() .focus()
.updateAttributes("tableCell", { .updateAttributes("tableCell", {
backgroundColor: color || null, backgroundColor: color || null,
backgroundColorName: color ? colorName : null, backgroundColorName: color ? colorName : null
}) })
.updateAttributes("tableHeader", { .updateAttributes("tableHeader", {
backgroundColor: color || null, backgroundColor: color || null,
backgroundColorName: color ? colorName : null, backgroundColorName: color ? colorName : null
}) })
.run(); .run();
setOpened(false); setOpened(false);
}; };
// Get current cell's background color
const getCurrentColor = () => {
if (editor.isActive("tableCell")) {
const attrs = editor.getAttributes("tableCell");
return attrs.backgroundColor || "";
}
if (editor.isActive("tableHeader")) {
const attrs = editor.getAttributes("tableHeader");
return attrs.backgroundColor || "";
}
return "";
};
const currentColor = getCurrentColor();
return ( return (
<Popover <Popover
width={200} width={200}
@@ -137,7 +123,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
cursor: "pointer", cursor: "pointer",
}} }}
> >
{editorState.currentColor === item.color && ( {currentColor === item.color && (
<IconCheck <IconCheck
size={18} size={18}
style={{ style={{
@@ -9,15 +9,15 @@ import {
ActionIcon, ActionIcon,
Button, Button,
Popover, Popover,
rem,
ScrollArea, ScrollArea,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface TableTextAlignmentProps { interface TableTextAlignmentProps {
editor: Editor | null; editor: ReturnType<typeof useEditor>;
} }
interface AlignmentItem { interface AlignmentItem {
@@ -32,44 +32,25 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, setOpened] = React.useState(false); const [opened, setOpened] = React.useState(false);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
};
},
});
if (!editor || !editorState) {
return null;
}
const items: AlignmentItem[] = [ const items: AlignmentItem[] = [
{ {
name: "Align left", name: "Align left",
value: "left", value: "left",
isActive: () => editorState?.isAlignLeft, isActive: () => editor.isActive({ textAlign: "left" }),
command: () => editor.chain().focus().setTextAlign("left").run(), command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft, icon: IconAlignLeft,
}, },
{ {
name: "Align center", name: "Align center",
value: "center", value: "center",
isActive: () => editorState?.isAlignCenter, isActive: () => editor.isActive({ textAlign: "center" }),
command: () => editor.chain().focus().setTextAlign("center").run(), command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter, icon: IconAlignCenter,
}, },
{ {
name: "Align right", name: "Align right",
value: "right", value: "right",
isActive: () => editorState?.isAlignRight, isActive: () => editor.isActive({ textAlign: "right" }),
command: () => editor.chain().focus().setTextAlign("right").run(), command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight, icon: IconAlignRight,
}, },
@@ -83,7 +64,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
onChange={setOpened} onChange={setOpened}
position="bottom" position="bottom"
withArrow withArrow
transitionProps={{ transition: "pop" }} transitionProps={{ transition: 'pop' }}
> >
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text alignment")} withArrow> <Tooltip label={t("Text alignment")} withArrow>
@@ -106,7 +87,9 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
key={index} key={index}
variant="default" variant="default"
leftSection={<item.icon size={16} />} leftSection={<item.icon size={16} />}
rightSection={item.isActive() && <IconCheck size={16} />} rightSection={
item.isActive() && <IconCheck size={16} />
}
justify="left" justify="left"
fullWidth fullWidth
onClick={() => { onClick={() => {
@@ -123,4 +106,4 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
); );
}; };
@@ -2,7 +2,6 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js"; import { sticky } from "tippy.js";
@@ -33,25 +32,6 @@ export function VideoMenu({ editor }: EditorMenuProps) {
[editor], [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const videoAttrs = ctx.editor.getAttributes("video");
return {
isVideo: ctx.editor.isActive("video"),
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
width: videoAttrs?.width ? parseInt(videoAttrs.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video"; const predicate = (node: PMNode) => node.type.name === "video";
@@ -103,7 +83,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`video-menu`} pluginKey={`video-menu}`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@@ -123,7 +103,9 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoLeft} onClick={alignVideoLeft}
size="lg" size="lg"
aria-label={t("Align left")} aria-label={t("Align left")}
variant={editorState?.isAlignLeft ? "light" : "default"} variant={
editor.isActive("video", { align: "left" }) ? "light" : "default"
}
> >
<IconLayoutAlignLeft size={18} /> <IconLayoutAlignLeft size={18} />
</ActionIcon> </ActionIcon>
@@ -134,7 +116,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoCenter} onClick={alignVideoCenter}
size="lg" size="lg"
aria-label={t("Align center")} aria-label={t("Align center")}
variant={editorState?.isAlignCenter ? "light" : "default"} variant={
editor.isActive("video", { align: "center" })
? "light"
: "default"
}
> >
<IconLayoutAlignCenter size={18} /> <IconLayoutAlignCenter size={18} />
</ActionIcon> </ActionIcon>
@@ -145,15 +131,20 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoRight} onClick={alignVideoRight}
size="lg" size="lg"
aria-label={t("Align right")} aria-label={t("Align right")}
variant={editorState?.isAlignRight ? "light" : "default"} variant={
editor.isActive("video", { align: "right" }) ? "light" : "default"
}
> >
<IconLayoutAlignRight size={18} /> <IconLayoutAlignRight size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
{editorState?.width && ( {editor.getAttributes("video")?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} /> <NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes("video").width)}
/>
)} )}
</BaseBubbleMenu> </BaseBubbleMenu>
); );
@@ -15,7 +15,7 @@ export default function VideoView(props: NodeViewProps) {
}, [align]); }, [align]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<video <video
preload="metadata" preload="metadata"
width={width} width={width}
@@ -1,18 +1,15 @@
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { CharacterCount } from "@tiptap/extension-character-count";
import { TaskList } from "@tiptap/extension-task-list"; import { TaskList } from "@tiptap/extension-task-list";
import { ListKeymap } from "@tiptap/extension-list-keymap";
import { TaskItem } from "@tiptap/extension-task-item"; import { TaskItem } from "@tiptap/extension-task-item";
import { Underline } from "@tiptap/extension-underline"; import { Underline } from "@tiptap/extension-underline";
import { Superscript } from "@tiptap/extension-superscript"; import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript"; import SubScript from "@tiptap/extension-subscript";
import { Highlight } from "@tiptap/extension-highlight";
import { Typography } from "@tiptap/extension-typography"; import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style"; import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color"; import { Color } from "@tiptap/extension-color";
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand from "@/features/editor/extensions/slash-command"; import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration"; import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor"; import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
@@ -43,10 +40,7 @@ import {
Mention, Mention,
Subpages, Subpages,
TableDndExtension, TableDndExtension,
Heading, HeadingAnchors,
Highlight,
UniqueID,
ColumnsExtension,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -55,8 +49,11 @@ import {
import { IUser } from "@/features/user/types/user.types.ts"; import { IUser } from "@/features/user/types/user.types.ts";
import MathInlineView from "@/features/editor/components/math/math-inline.tsx"; import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
import MathBlockView from "@/features/editor/components/math/math-block.tsx"; import MathBlockView from "@/features/editor/components/math/math-block.tsx";
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube";
import ImageView from "@/features/editor/components/image/image-view.tsx"; import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx"; import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import { common, createLowlight } from "lowlight";
import VideoView from "@/features/editor/components/video/video-view.tsx"; import VideoView from "@/features/editor/components/video/video-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx"; import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx"; import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
@@ -64,7 +61,6 @@ import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx"; import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext"; import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell"; import powershell from "highlight.js/lib/languages/powershell";
import abap from "highlightjs-sap-abap"; import abap from "highlightjs-sap-abap";
@@ -81,7 +77,10 @@ import MentionView from "@/features/editor/components/mention/mention-view.tsx";
import i18n from "@/i18n.ts"; import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command"; import EmojiCommand from "./emoji-command";
import { CharacterCount } from "@tiptap/extension-character-count";
import { countWords } from "alfaaz"; import { countWords } from "alfaaz";
import UniqueID from "@tiptap/extension-unique-id";
import { generateEditorNodeId } from "../utils/nanoid";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext); lowlight.register("mermaid", plaintext);
@@ -97,8 +96,8 @@ lowlight.register("scala", scala);
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
heading: false,
history: false, history: false,
heading: false,
dropcursor: { dropcursor: {
width: 3, width: 3,
color: "#70CFF8", color: "#70CFF8",
@@ -110,11 +109,7 @@ export const mainExtensions = [
}, },
}, },
}), }),
Heading, HeadingAnchors,
UniqueID.configure({
types: ["heading", "paragraph"],
filterTransaction: (transaction) => !isChangeOrigin(transaction),
}),
Placeholder.configure({ Placeholder.configure({
placeholder: ({ node }) => { placeholder: ({ node }) => {
if (node.type.name === "heading") { if (node.type.name === "heading") {
@@ -135,7 +130,6 @@ export const mainExtensions = [
TaskItem.configure({ TaskItem.configure({
nested: true, nested: true,
}), }),
ListKeymap,
Underline, Underline,
LinkExtension.configure({ LinkExtension.configure({
openOnClick: false, openOnClick: false,
@@ -230,7 +224,6 @@ export const mainExtensions = [
Subpages.configure({ Subpages.configure({
view: SubpagesView, view: SubpagesView,
}), }),
ColumnsExtension,
MarkdownClipboard.configure({ MarkdownClipboard.configure({
transformPastedText: true, transformPastedText: true,
}), }),
@@ -240,19 +233,25 @@ export const mainExtensions = [
SearchAndReplace.extend({ SearchAndReplace.extend({
addKeyboardShortcuts() { addKeyboardShortcuts() {
return { return {
"Mod-f": () => { 'Mod-f': () => {
const event = new CustomEvent("openFindDialogFromEditor", {}); const event = new CustomEvent("openFindDialogFromEditor", {});
document.dispatchEvent(event); document.dispatchEvent(event);
return true; return true;
}, },
Escape: () => { 'Escape': () => {
const event = new CustomEvent("closeFindDialogFromEditor", {}); const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event); document.dispatchEvent(event);
return true; return true;
}, },
}; }
}, },
}).configure(), }).configure(),
UniqueID.configure({
types: ["heading"],
attributeName: "nodeId",
generateID: () => generateEditorNodeId(),
filterTransaction: (transaction) => !isChangeOrigin(transaction),
}),
] as any; ] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@@ -1,58 +0,0 @@
import { Editor } from "@tiptap/react";
import { useCallback, useEffect, useState } from "react";
function waitForState(checkFn: () => boolean): Promise<void> {
return new Promise((resolve) => {
const interval = setInterval(() => {
if (checkFn()) {
clearInterval(interval);
resolve();
}
}, 800);
});
}
export const useEditorScroll = ({
canScroll,
initialScrollTo,
}: {
canScroll: () => boolean;
initialScrollTo?: string;
}) => {
const [scrollTo, setScrollTo] = useState<string>(initialScrollTo || "");
useEffect(() => {
if (!initialScrollTo) {
setScrollTo(window.location.hash ? window.location.hash.slice(1) : "");
}
}, [initialScrollTo]);
const handleScrollTo = useCallback(async (editor: Editor, _scrollTo: string | null = null, tryCount: number = 0) => {
await waitForState(() => canScroll());
return new Promise((resolve) => {
const MAX_TRY_COUNT = 10;
if (tryCount >= MAX_TRY_COUNT) {
resolve(false);
return;
}
const targetId = _scrollTo || scrollTo;
if (!targetId) {
resolve(false);
return;
}
const dom = editor.view.dom.querySelector(`[id="${targetId}"]`);
if (dom) {
dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
resolve(true);
} else {
setTimeout(async () => {
resolve(await handleScrollTo(editor, targetId, tryCount + 1));
}, 200);
}
});
}, [scrollTo, canScroll]);
return { scrollTo, handleScrollTo };
};
+13 -37
View File
@@ -1,5 +1,5 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb"; import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs"; import * as Y from "yjs";
import { import {
@@ -7,12 +7,7 @@ import {
onAuthenticationFailedParameters, onAuthenticationFailedParameters,
WebSocketStatus, WebSocketStatus,
} from "@hocuspocus/provider"; } from "@hocuspocus/provider";
import { import { EditorContent, EditorProvider, useEditor } from "@tiptap/react";
EditorContent,
EditorProvider,
useEditor,
useEditorState,
} from "@tiptap/react";
import { import {
collabExtensions, collabExtensions,
mainExtensions, mainExtensions,
@@ -55,8 +50,8 @@ import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts"; import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts"; import { searchSpotlight } from '@/features/search/constants.ts';
import { useEditorScroll } from "./hooks/use-editor-scroll"; import { useAnchorScroll } from "./components/heading/use-anchor-scroll";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@@ -69,16 +64,7 @@ export default function PageEditor({
editable, editable,
content, content,
}: PageEditorProps) { }: PageEditorProps) {
const collaborationURL = useCollaborationUrl(); const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
useEffect(() => {
isComponentMounted.current = true;
}, []);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom); const [, setEditor] = useAtom(pageEditorAtom);
const [, setAsideState] = useAtom(asideStateAtom); const [, setAsideState] = useAtom(asideStateAtom);
@@ -92,7 +78,7 @@ export default function PageEditor({
const [isLocalSynced, setLocalSynced] = useState(false); const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom, yjsConnectionStatusAtom
); );
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`; const documentName = `page.${pageId}`;
@@ -102,11 +88,10 @@ export default function PageEditor({
const [isCollabReady, setIsCollabReady] = useState(false); const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
// useAnchorScroll();
const userPageEditMode = const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId // Providers only created once per pageId
const providersRef = useRef<{ const providersRef = useRef<{
local: IndexeddbPersistence; local: IndexeddbPersistence;
@@ -230,17 +215,17 @@ export default function PageEditor({
extensions, extensions,
editable, editable,
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: false, shouldRerenderOnTransaction: true,
editorProps: { editorProps: {
scrollThreshold: 80, scrollThreshold: 80,
scrollMargin: 80, scrollMargin: 80,
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") { if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
event.preventDefault(); event.preventDefault();
return true; return true;
} }
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") { if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
searchSpotlight.open(); searchSpotlight.open();
return true; return true;
} }
@@ -276,8 +261,6 @@ export default function PageEditor({
// @ts-ignore // @ts-ignore
setEditor(editor); setEditor(editor);
editor.storage.pageId = pageId; editor.storage.pageId = pageId;
handleScrollTo(editor);
editorCreated.current = true;
} }
}, },
onUpdate({ editor }) { onUpdate({ editor }) {
@@ -287,16 +270,9 @@ export default function PageEditor({
debouncedUpdateContent(editorJson); debouncedUpdateContent(editorJson);
}, },
}, },
[pageId, editable, remoteProvider], [pageId, editable, remoteProvider]
); );
const editorIsEditable = useEditorState({
editor,
selector: (ctx) => {
return ctx.editor?.isEditable ?? false;
},
});
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => { const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]); const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
@@ -332,7 +308,7 @@ export default function PageEditor({
return () => { return () => {
document.removeEventListener( document.removeEventListener(
"ACTIVE_COMMENT_EVENT", "ACTIVE_COMMENT_EVENT",
handleActiveCommentEvent, handleActiveCommentEvent
); );
}; };
}, []); }, []);
@@ -415,7 +391,7 @@ export default function PageEditor({
<SearchAndReplaceDialog editor={editor} editable={editable} /> <SearchAndReplaceDialog editor={editor} editable={editable} />
)} )}
{editor && editorIsEditable && ( {editor && editor.isEditable && (
<div> <div>
<EditorBubbleMenu editor={editor} /> <EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} /> <TableMenu editor={editor} />
@@ -1,14 +1,13 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useCallback, useEffect, useMemo, useRef } from "react"; import React, { useMemo } from "react";
import { EditorProvider } from "@tiptap/react"; import { EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions"; import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document"; import { Document } from "@tiptap/extension-document";
import { Heading, generateNodeId, UniqueID } from "@docmost/editor-ext"; import { Heading } from "@tiptap/extension-heading";
import { Text } from "@tiptap/extension-text"; import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
interface PageEditorProps { interface PageEditorProps {
title: string; title: string;
@@ -22,34 +21,9 @@ export default function ReadonlyPageEditor({
pageId, pageId,
}: PageEditorProps) { }: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
const canScroll = useCallback(
() => isComponentMounted.current && editorCreated.current,
[isComponentMounted, editorCreated],
);
const initialScrollTo = window.location.hash
? window.location.hash.slice(1)
: "";
const { handleScrollTo } = useEditorScroll({ canScroll, initialScrollTo });
useEffect(() => {
isComponentMounted.current = true;
}, []);
const extensions = useMemo(() => { const extensions = useMemo(() => {
const filteredExtensions = mainExtensions.filter( return [...mainExtensions];
(ext) => ext.name !== "uniqueID",
);
return [
...filteredExtensions,
UniqueID.configure({
types: ["heading", "paragraph"],
updateDocument: false,
}),
];
}, []); }, []);
const titleExtensions = [ const titleExtensions = [
@@ -85,9 +59,6 @@ export default function ReadonlyPageEditor({
} }
// @ts-ignore // @ts-ignore
setReadOnlyEditor(editor); setReadOnlyEditor(editor);
handleScrollTo(editor);
editorCreated.current = true;
} }
}} }}
></EditorProvider> ></EditorProvider>
@@ -1,123 +0,0 @@
.resize-cursor {
cursor: col-resize;
}
.prosemirror-column-container {
display: flex;
flex-direction: row;
width: calc(100% - 8px);
gap: 12px;
margin: 16px 0;
}
.prosemirror-column-container.has-focus .prosemirror-column,
.prosemirror-column-container:hover .prosemirror-column {
background-color: rgba(100, 106, 115, 0.05);
}
.prosemirror-column-container .prosemirror-column {
position: relative;
border-radius: 8px;
min-width: 50px;
padding: 12px;
background-color: transparent;
transition: background-color 0.2s ease;
}
.prosemirror-column-container
.prosemirror-column
> :not(div.grid-resize-handle):nth-child(1),
.prosemirror-column-container
.prosemirror-column
> div.grid-resize-handle
+ :nth-child(2) {
margin-top: 0;
}
.prosemirror-column-container .prosemirror-column > :nth-last-child(1) {
margin-bottom: 0;
}
.prosemirror-column-container .prosemirror-column .grid-resize-handle {
position: absolute;
right: -7px;
top: 0;
bottom: 0;
width: 2px;
z-index: 20;
background-color: #336df4;
pointer-events: none;
}
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button {
top: -8px;
left: -9px;
width: 12px;
height: 12px;
background-color: #007bff;
border: 4px solid white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
pointer-events: auto;
cursor: pointer;
transition: transform 0.1s ease-in-out;
}
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button:hover {
transform: scale(1.35);
}
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button
.plus {
position: relative;
width: 8px;
height: 8px;
}
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button
.plus::before,
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button
.plus::after {
content: '';
position: absolute;
background-color: white;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button
.plus::before {
width: 8px;
height: 2px;
}
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button
.plus::after {
width: 24px;
height: 8px;
}
@@ -5,7 +5,7 @@
); );
color: light-dark( color: light-dark(
var(--mantine-color-default-color), var(--mantine-color-default-color),
var(--mantine-color-white) var(--mantine-color-dark-0)
); );
font-size: var(--mantine-font-size-md); font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-xl); line-height: var(--mantine-line-height-xl);
@@ -115,7 +115,7 @@
} }
& > .react-renderer { & > .react-renderer {
margin-top: var(--mantine-spacing-sm); margin-top: var(--mantine-spacing-sm);
margin-bottom: var(--mantine-spacing-sm); margin-bottom: var(--mantine-spacing-sm);
&:first-child { &:first-child {
@@ -141,7 +141,7 @@
.selection, .selection,
*::selection { *::selection {
background-color: light-dark(Highlight, var(--mantine-color-gray-7)); background-color: Highlight;
} }
.comment-mark { .comment-mark {
@@ -186,39 +186,6 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
}
.ProseMirror > h1,
.ProseMirror > h2,
.ProseMirror > h3,
.ProseMirror > h4,
.ProseMirror > h5,
.ProseMirror > h6 {
> .link-btn {
cursor: pointer;
position: relative;
}
> .link-btn > .link-btn-content {
opacity: 0;
position: absolute;
left: 5px;
top: 0;
height: 100%;
transition: opacity 0.15s ease;
display: inline-flex;
justify-content: center;
flex-direction: column;
}
&:hover > .link-btn > .link-btn-content {
opacity: 1;
}
scroll-margin-top: 80px; /* match your header height */
} }
.ProseMirror-icon { .ProseMirror-icon {
@@ -242,3 +209,4 @@
.actionIconGroup { .actionIconGroup {
background: var(--mantine-color-body); background: var(--mantine-color-body);
} }
@@ -0,0 +1,79 @@
.heading-block {
position: relative;
scroll-margin-top: 80px;
}
.has-anchor {
position: relative;
}
.heading-anchor-wrapper {
display: inline-block;
margin-left: 8px;
vertical-align: middle;
}
.heading-anchor-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
color: var(--mantine-color-gray-5);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease, color 0.2s ease;
outline: none;
}
.has-anchor:hover .heading-anchor-button {
opacity: 1;
}
.heading-anchor-button:hover {
color: var(--mantine-color-blue-6);
}
.heading-anchor-button.copied {
color: var(--mantine-color-green-6);
opacity: 1;
}
.heading-anchor-button svg {
width: 16px;
height: 16px;
}
@media (max-width: 768px) {
.heading-anchor-button {
opacity: 0.3;
}
.has-anchor:hover .heading-anchor-button {
opacity: 0.7;
}
}
@media print {
.heading-anchor-wrapper {
display: none !important;
}
}
.ProseMirror .heading-anchor-button {
pointer-events: all;
}
/* Hide button when cursor is in the same heading */
.ProseMirror-focused .has-anchor.ProseMirror-selectednode .heading-anchor-button {
opacity: 0;
}
/* Always show on hover, regardless of focus state */
.has-anchor:hover .heading-anchor-button {
opacity: 1;
pointer-events: all;
}
@@ -1,177 +0,0 @@
/* Highlight colors with dark mode support */
.ProseMirror {
/* Blue */
mark[data-color="#98d8f2"] {
background-color: light-dark(
rgb(224 242 254),
rgba(37, 99, 235, 0.35)
) !important;
}
/* Green */
mark[data-color="#7edb6c"] {
background-color: light-dark(
rgb(220 252 231),
rgba(0, 138, 0, 0.35)
) !important;
}
/* Purple */
mark[data-color="#e0d6ed"] {
background-color: light-dark(
rgb(243 232 255),
rgba(147, 51, 234, 0.35)
) !important;
}
/* Red */
mark[data-color="#ffc6c2"] {
background-color: light-dark(
rgb(255 228 230),
rgba(224, 0, 0, 0.35)
) !important;
}
/* Yellow */
mark[data-color="#faf594"] {
background-color: light-dark(
rgb(254 249 195),
rgba(234, 179, 8, 0.35)
) !important;
}
/* Orange */
mark[data-color="#f5c8a9"] {
background-color: light-dark(
rgb(251, 236, 221),
rgba(255, 165, 0, 0.45)
) !important;
}
/* Pink */
mark[data-color="#f5cfe0"] {
background-color: light-dark(
rgb(252, 241, 246),
rgba(186, 64, 129, 0.35)
) !important;
}
/* Gray */
mark[data-color="#dfdfd7"] {
background-color: light-dark(
rgb(238 238 235),
rgba(168, 162, 158, 0.35)
) !important;
}
/* Brown */
mark[data-color="#d7c4b7"] {
background-color: light-dark(
rgb(215 196 183),
rgba(146, 64, 14, 0.35)
) !important;
}
}
/* Color selector trigger button styles */
.color-selector-trigger[data-text-color="#2563EB"] {
color: #2563EB !important;
}
.color-selector-trigger[data-text-color="#008A00"] {
color: #008A00 !important;
}
.color-selector-trigger[data-text-color="#9333EA"] {
color: #9333EA !important;
}
.color-selector-trigger[data-text-color="#E00000"] {
color: #E00000 !important;
}
.color-selector-trigger[data-text-color="#EAB308"] {
color: #EAB308 !important;
}
.color-selector-trigger[data-text-color="#FFA500"] {
color: #FFA500 !important;
}
.color-selector-trigger[data-text-color="#BA4081"] {
color: #BA4081 !important;
}
.color-selector-trigger[data-text-color="#A8A29E"] {
color: #A8A29E !important;
}
.color-selector-trigger[data-text-color="#92400E"] {
color: #92400E !important;
}
/* Highlight background colors with light-dark support - solid colors for trigger button */
.color-selector-trigger[data-highlight-color="#98d8f2"] {
background-color: light-dark(
rgb(224 242 254),
rgb(30 64 175)
) !important;
}
.color-selector-trigger[data-highlight-color="#7edb6c"] {
background-color: light-dark(
rgb(220 252 231),
rgb(21 128 61)
) !important;
}
.color-selector-trigger[data-highlight-color="#e0d6ed"] {
background-color: light-dark(
rgb(243 232 255),
rgb(107 33 168)
) !important;
}
.color-selector-trigger[data-highlight-color="#ffc6c2"] {
background-color: light-dark(
rgb(255 228 230),
rgb(185 28 28)
) !important;
}
.color-selector-trigger[data-highlight-color="#faf594"] {
background-color: light-dark(
rgb(254 249 195),
rgb(161 98 7)
) !important;
}
.color-selector-trigger[data-highlight-color="#f5c8a9"] {
background-color: light-dark(
rgb(251 236 221),
rgb(194 65 12)
) !important;
}
.color-selector-trigger[data-highlight-color="#f5cfe0"] {
background-color: light-dark(
rgb(252 241 246),
rgb(157 23 77)
) !important;
}
.color-selector-trigger[data-highlight-color="#dfdfd7"] {
background-color: light-dark(
rgb(238 238 235),
rgb(115 115 115)
) !important;
}
.color-selector-trigger[data-highlight-color="#d7c4b7"] {
background-color: light-dark(
rgb(215 196 183),
rgb(120 53 15)
) !important;
}
@@ -12,5 +12,4 @@
@import "./find.css"; @import "./find.css";
@import "./mention.css"; @import "./mention.css";
@import "./ordered-list.css"; @import "./ordered-list.css";
@import "./highlight.css"; @import "./heading-anchors.css";
@import "./column.css";
@@ -104,10 +104,7 @@ export function TitleEditor({
}); });
useEffect(() => { useEffect(() => {
const anchorId = window.location.hash const pageSlug = buildPageUrl(spaceSlug, slugId, title);
? window.location.hash.substring(1)
: undefined;
const pageSlug = buildPageUrl(spaceSlug, slugId, title, anchorId);
navigate(pageSlug, { replace: true }); navigate(pageSlug, { replace: true });
}, [title]); }, [title]);
@@ -195,43 +192,10 @@ export function TitleEditor({
const { key } = event; const { key } = event;
const { $head } = titleEditor.state.selection; const { $head } = titleEditor.state.selection;
if (key === "Enter") {
event.preventDefault();
const { $from } = titleEditor.state.selection;
const titleText = titleEditor.getText();
// Get the text offset within the heading node (not document position)
const textOffset = $from.parentOffset;
const textAfterCursor = titleText.slice(textOffset);
// Delete text after cursor from title (this will be in undo history)
const endPos = titleEditor.state.doc.content.size;
if (textAfterCursor) {
titleEditor.commands.deleteRange({ from: $from.pos, to: endPos });
}
// Don't add to history so undo in page editor won't remove this split
pageEditor
.chain()
.command(({ tr }) => {
tr.setMeta("addToHistory", false);
return true;
})
.insertContentAt(0, {
type: "paragraph",
content: textAfterCursor
? [{ type: "text", text: textAfterCursor }]
: undefined,
})
.focus("start")
.run();
return;
}
const shouldFocusEditor = const shouldFocusEditor =
key === "ArrowDown" || (key === "ArrowRight" && !$head.nodeAfter); key === "Enter" ||
key === "ArrowDown" ||
(key === "ArrowRight" && !$head.nodeAfter);
if (shouldFocusEditor) { if (shouldFocusEditor) {
pageEditor.commands.focus("start"); pageEditor.commands.focus("start");
@@ -0,0 +1,5 @@
import { customAlphabet } from "nanoid";
const slugIdAlphabet =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
export const generateEditorNodeId = customAlphabet(slugIdAlphabet, 12);
@@ -1,12 +1,11 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core"; import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useState } from "react"; import React, { useState } from "react";
import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts"; import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts";
import { useForm } from "@mantine/form"; import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod"; import * as z from "zod";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx"; import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { zodResolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({ const formSchema = z.object({
name: z.string().trim().min(2).max(50), name: z.string().trim().min(2).max(50),
@@ -4,11 +4,10 @@ import {
useGroupQuery, useGroupQuery,
useUpdateGroupMutation, useUpdateGroupMutation,
} from "@/features/group/queries/group-query.ts"; } from "@/features/group/queries/group-query.ts";
import { useForm } from "@mantine/form"; import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod"; import * as z from "zod";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { zodResolver } from "mantine-form-zod-resolver";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(50),
@@ -22,7 +22,6 @@ import { IUser } from "@/features/user/types/user.types.ts";
import { useEffect } from "react"; import { useEffect } from "react";
import { validate as isValidUuid } from "uuid"; import { validate as isValidUuid } from "uuid";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { useTranslation } from 'react-i18next';
export function useGetGroupsQuery( export function useGetGroupsQuery(
params?: QueryParams, params?: QueryParams,
@@ -74,12 +73,11 @@ export function useCreateGroupMutation() {
export function useUpdateGroupMutation() { export function useUpdateGroupMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IGroup, Error, Partial<IGroup>>({ return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => updateGroup(data), mutationFn: (data) => updateGroup(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: t("Group updated successfully") }); notifications.show({ message: "Group updated successfully" });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["group", variables.groupId], queryKey: ["group", variables.groupId],
}); });
@@ -93,12 +91,11 @@ export function useUpdateGroupMutation() {
export function useDeleteGroupMutation() { export function useDeleteGroupMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({ return useMutation({
mutationFn: (groupId: string) => deleteGroup({ groupId }), mutationFn: (groupId: string) => deleteGroup({ groupId }),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: t("Group deleted successfully") }); notifications.show({ message: "Group deleted successfully" });
queryClient.refetchQueries({ queryKey: ["groups"] }); queryClient.refetchQueries({ queryKey: ["groups"] });
}, },
onError: (error) => { onError: (error) => {
@@ -122,12 +119,11 @@ export function useGroupMembersQuery(
export function useAddGroupMemberMutation() { export function useAddGroupMemberMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, { groupId: string; userIds: string[] }>({ return useMutation<void, Error, { groupId: string; userIds: string[] }>({
mutationFn: (data) => addGroupMember(data), mutationFn: (data) => addGroupMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: t("Added successfully") }); notifications.show({ message: "Added successfully" });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId], queryKey: ["groupMembers", variables.groupId],
}); });
@@ -143,7 +139,6 @@ export function useAddGroupMemberMutation() {
export function useRemoveGroupMemberMutation() { export function useRemoveGroupMemberMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation< return useMutation<
void, void,
@@ -155,7 +150,7 @@ export function useRemoveGroupMemberMutation() {
>({ >({
mutationFn: (data) => removeGroupMember(data), mutationFn: (data) => removeGroupMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: t("Removed successfully") }); notifications.show({ message: "Removed successfully" });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId], queryKey: ["groupMembers", variables.groupId],
}); });
+5 -5
View File
@@ -15,7 +15,7 @@ export const buildPageUrl = (
spaceName: string, spaceName: string,
pageSlugId: string, pageSlugId: string,
pageTitle?: string, pageTitle?: string,
anchorId?: string, anchor?: string,
): string => { ): string => {
let url: string; let url: string;
if (spaceName === undefined) { if (spaceName === undefined) {
@@ -23,21 +23,21 @@ export const buildPageUrl = (
} else { } else {
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`; url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} }
return anchorId ? `${url}#${anchorId}` : url; return anchor ? `${url}#${anchor}` : url;
}; };
export const buildSharedPageUrl = (opts: { export const buildSharedPageUrl = (opts: {
shareId: string; shareId: string;
pageSlugId: string; pageSlugId: string;
pageTitle?: string; pageTitle?: string;
anchorId?: string; anchor?: string;
}): string => { }): string => {
const { shareId, pageSlugId, pageTitle, anchorId } = opts; const { shareId, pageSlugId, pageTitle, anchor } = opts;
let url: string; let url: string;
if (!shareId) { if (!shareId) {
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`; url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else { } else {
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`; url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} }
return anchorId ? `${url}#${anchorId}` : url; return anchor ? `${url}#${anchor}` : url;
}; };
@@ -9,7 +9,6 @@ import {
ScrollArea, ScrollArea,
Avatar, Avatar,
Group, Group,
Switch,
getDefaultZIndex, getDefaultZIndex,
} from "@mantine/core"; } from "@mantine/core";
import { import {
@@ -18,7 +17,6 @@ import {
IconFileDescription, IconFileDescription,
IconSearch, IconSearch,
IconCheck, IconCheck,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
@@ -26,21 +24,15 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useLicense } from "@/ee/hooks/use-license"; import { useLicense } from "@/ee/hooks/use-license";
import classes from "./search-spotlight-filters.module.css"; import classes from "./search-spotlight-filters.module.css";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
interface SearchSpotlightFiltersProps { interface SearchSpotlightFiltersProps {
onFiltersChange?: (filters: any) => void; onFiltersChange?: (filters: any) => void;
onAskClick?: () => void;
spaceId?: string; spaceId?: string;
isAiMode?: boolean;
} }
export function SearchSpotlightFilters({ export function SearchSpotlightFilters({
onFiltersChange, onFiltersChange,
onAskClick,
spaceId, spaceId,
isAiMode = false,
}: SearchSpotlightFiltersProps) { }: SearchSpotlightFiltersProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { hasLicenseKey } = useLicense(); const { hasLicenseKey } = useLicense();
@@ -50,7 +42,6 @@ export function SearchSpotlightFilters({
const [spaceSearchQuery, setSpaceSearchQuery] = useState(""); const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300); const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
const [contentType, setContentType] = useState<string | null>("page"); const [contentType, setContentType] = useState<string | null>("page");
const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({ const { data: spacesData } = useGetSpacesQuery({
page: 1, page: 1,
@@ -129,31 +120,6 @@ export function SearchSpotlightFilters({
return ( return (
<div className={classes.filtersContainer}> <div className={classes.filtersContainer}>
{workspace?.settings?.ai?.search === true && (
<div
style={{
display: "flex",
alignItems: "center",
height: "32px",
paddingLeft: "8px",
paddingRight: "8px",
}}
>
<Switch
checked={isAiMode}
onChange={(event) => onAskClick()}
label={t("Ask AI")}
size="sm"
color="blue"
labelPosition="left"
styles={{
root: { display: "flex", alignItems: "center" },
label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 },
}}
/>
</div>
)}
<Menu <Menu
shadow="md" shadow="md"
width={250} width={250}
@@ -265,7 +231,7 @@ export function SearchSpotlightFilters({
contentType !== option.value && contentType !== option.value &&
handleFilterChange("contentType", option.value) handleFilterChange("contentType", option.value)
} }
disabled={option.disabled || (isAiMode && option.value === "attachment")} disabled={option.disabled}
> >
<Group flex="1" gap="xs"> <Group flex="1" gap="xs">
<div> <div>
@@ -275,11 +241,6 @@ export function SearchSpotlightFilters({
{t("Enterprise")} {t("Enterprise")}
</Badge> </Badge>
)} )}
{!option.disabled && isAiMode && option.value === "attachment" && (
<Text size="xs" mt={4}>
{t("Ask AI not available for attachments")}
</Text>
)}
</div> </div>
{contentType === option.value && <IconCheck size={20} />} {contentType === option.value && <IconCheck size={20} />}
</Group> </Group>
@@ -1,18 +1,13 @@
import { Spotlight } from "@mantine/spotlight"; import { Spotlight } from "@mantine/spotlight";
import { IconSearch, IconSparkles } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { Group, Button } from "@mantine/core"; import React, { useState, useMemo } from "react";
import React, { useState, useMemo, useEffect } from "react";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { searchSpotlightStore } from "../constants.ts"; import { searchSpotlightStore } from "../constants.ts";
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx"; import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
import { useUnifiedSearch } from "../hooks/use-unified-search.ts"; import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
import { useAiSearch } from "../../../ee/ai/hooks/use-ai-search.ts";
import { SearchResultItem } from "./search-result-item.tsx"; import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
import { useLicense } from "@/ee/hooks/use-license.tsx"; import { useLicense } from "@/ee/hooks/use-license.tsx";
import { isCloud } from "@/lib/config.ts";
interface SearchSpotlightProps { interface SearchSpotlightProps {
spaceId?: string; spaceId?: string;
@@ -28,7 +23,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
}>({ }>({
contentType: "page", contentType: "page",
}); });
const [isAiMode, setIsAiMode] = useState(false);
// Build unified search params // Build unified search params
const searchParams = useMemo(() => { const searchParams = useMemo(() => {
@@ -45,46 +39,11 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
return params; return params;
}, [debouncedSearchQuery, filters]); }, [debouncedSearchQuery, filters]);
const { data: searchResults, isLoading } = useUnifiedSearch( const { data: searchResults, isLoading } = useUnifiedSearch(searchParams);
searchParams,
!isAiMode // Disable regular search when in AI mode
);
const {
//@ts-ignore
data: aiSearchResult,
//@ts-ignore
isPending: isAiLoading,
//@ts-ignore
mutate: triggerAiSearchMutation,
//@ts-ignore
reset: resetAiMutation,
//@ts-ignore
error: aiSearchError,
streamingAnswer,
streamingSources,
clearStreaming,
} = useAiSearch();
// Clear streaming state and mutation data when query changes (user is typing a new query)
useEffect(() => {
clearStreaming();
resetAiMutation();
}, [query, clearStreaming, resetAiMutation]);
// Show error notification when AI search fails
useEffect(() => {
if (aiSearchError) {
notifications.show({
message: aiSearchError.message || t("AI search failed. Please try again."),
color: "red",
position: "top-center"
});
}
}, [aiSearchError, t]);
// Determine result type for rendering // Determine result type for rendering
const isAttachmentSearch = const isAttachmentSearch =
filters.contentType === "attachment" && (hasLicenseKey || isCloud()); filters.contentType === "attachment" && hasLicenseKey;
const resultItems = (searchResults || []).map((result) => ( const resultItems = (searchResults || []).map((result) => (
<SearchResultItem <SearchResultItem
@@ -99,16 +58,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
setFilters(newFilters); setFilters(newFilters);
}; };
const handleAskClick = () => {
setIsAiMode(!isAiMode);
};
const handleAiSearchTrigger = () => {
if (query.trim() && isAiMode) {
triggerAiSearchMutation(searchParams);
}
};
return ( return (
<> <>
<Spotlight.Root <Spotlight.Root
@@ -122,30 +71,10 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
backgroundOpacity: 0.55, backgroundOpacity: 0.55,
}} }}
> >
<Group gap="xs" px="sm" pt="sm" pb="xs"> <Spotlight.Search
<Spotlight.Search placeholder={t("Search...")}
placeholder={isAiMode ? t("Ask a question...") : t("Search...")} leftSection={<IconSearch size={20} stroke={1.5} />}
leftSection={<IconSearch size={20} stroke={1.5} />} />
style={{ flex: 1 }}
onKeyDown={(e) => {
if (e.key === "Enter" && isAiMode && query.trim() && !isAiLoading) {
e.preventDefault();
handleAiSearchTrigger();
}
}}
/>
{isAiMode && hasLicenseKey && (
<Button
size="xs"
leftSection={<IconSparkles size={16} />}
onClick={handleAiSearchTrigger}
disabled={!query.trim()}
loading={isAiLoading}
>
Ask
</Button>
)}
</Group>
<div <div
style={{ style={{
@@ -154,43 +83,20 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
> >
<SearchSpotlightFilters <SearchSpotlightFilters
onFiltersChange={handleFiltersChange} onFiltersChange={handleFiltersChange}
onAskClick={handleAskClick}
spaceId={spaceId} spaceId={spaceId}
isAiMode={isAiMode}
/> />
</div> </div>
<Spotlight.ActionsList> <Spotlight.ActionsList>
{isAiMode ? ( {query.length === 0 && resultItems.length === 0 && (
<> <Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
{query.length === 0 && (
<Spotlight.Empty>{t("Ask a question...")}</Spotlight.Empty>
)}
{query.length > 0 && (isAiLoading || aiSearchResult || streamingAnswer) && (
<AiSearchResult
result={aiSearchResult}
isLoading={isAiLoading}
streamingAnswer={streamingAnswer}
streamingSources={streamingSources}
/>
)}
{query.length > 0 && !isAiLoading && !aiSearchResult && (
<Spotlight.Empty>{t("No answer available")}</Spotlight.Empty>
)}
</>
) : (
<>
{query.length === 0 && resultItems.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && !isLoading && resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{resultItems.length > 0 && <>{resultItems}</>}
</>
)} )}
{query.length > 0 && !isLoading && resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{resultItems.length > 0 && <>{resultItems}</>}
</Spotlight.ActionsList> </Spotlight.ActionsList>
</Spotlight.Root> </Spotlight.Root>
</> </>
@@ -19,7 +19,6 @@ export interface UseUnifiedSearchParams extends IPageSearchParams {
export function useUnifiedSearch( export function useUnifiedSearch(
params: UseUnifiedSearchParams, params: UseUnifiedSearchParams,
enabled: boolean = true,
): UseQueryResult<UnifiedSearchResult[], Error> { ): UseQueryResult<UnifiedSearchResult[], Error> {
const { hasLicenseKey } = useLicense(); const { hasLicenseKey } = useLicense();
@@ -39,6 +38,6 @@ export function useUnifiedSearch(
return await searchPage(backendParams); return await searchPage(backendParams);
} }
}, },
enabled: !!params.query && enabled, enabled: !!params.query,
}); });
} }
@@ -62,17 +62,14 @@ export default function SpaceSettingsModal({
</Tabs.List> </Tabs.List>
<Tabs.Panel value="general"> <Tabs.Panel value="general">
<ScrollArea h={580} scrollbarSize={5} pr={8}> <ScrollArea h={550} scrollbarSize={4} pr={8}>
<div style={{ paddingBottom: "100px"}}> <SpaceDetails
<SpaceDetails spaceId={space?.id}
spaceId={space?.id} readOnly={spaceAbility.cannot(
readOnly={spaceAbility.cannot( SpaceCaslAction.Manage,
SpaceCaslAction.Manage, SpaceCaslSubject.Settings,
SpaceCaslSubject.Settings, )}
)} />
/>
</div>
</ScrollArea> </ScrollArea>
</Tabs.Panel> </Tabs.Panel>
@@ -113,7 +113,7 @@ export default function SpaceMembersList({
return ( return (
<> <>
<SearchInput onSearch={handleSearch} /> <SearchInput onSearch={handleSearch} />
<ScrollArea h={450}> <ScrollArea h={400}>
<Table.ScrollContainer minWidth={500}> <Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing={8}> <Table highlightOnHover verticalSpacing={8}>
<Table.Thead> <Table.Thead>
@@ -42,7 +42,7 @@ export async function deleteWorkspaceMember(data: {
await api.post("/workspace/members/delete", data); await api.post("/workspace/members/delete", data);
} }
export async function updateWorkspace(data: Partial<IWorkspace> & { aiSearch?: boolean }) { export async function updateWorkspace(data: Partial<IWorkspace>) {
const req = await api.post<IWorkspace>("/workspace/update", data); const req = await api.post<IWorkspace>("/workspace/update", data);
return req.data; return req.data;
} }
@@ -9,7 +9,7 @@ export interface IWorkspace {
defaultSpaceId: string; defaultSpaceId: string;
customDomain: string; customDomain: string;
enableInvite: boolean; enableInvite: boolean;
settings: IWorkspaceSettings; settings: any;
status: string; status: string;
enforceSso: boolean; enforceSso: boolean;
stripeCustomerId: string; stripeCustomerId: string;
@@ -24,14 +24,6 @@ export interface IWorkspace {
enforceMfa?: boolean; enforceMfa?: boolean;
} }
export interface IWorkspaceSettings {
ai?: IWorkspaceAiSettings;
}
export interface IWorkspaceAiSettings {
search?: boolean;
}
export interface ICreateInvite { export interface ICreateInvite {
role: string; role: string;
emails: string[]; emails: string[];
+2
View File
@@ -2,3 +2,5 @@ export const INTERNAL_LINK_REGEX =
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?(?:#(.*))?$/; /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?(?:#(.*))?$/;
export const FIVE_MINUTES = 5 * 60 * 1000; export const FIVE_MINUTES = 5 * 60 * 1000;
//export const INTERNAL_LINK_REGEX =
// /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
-1
View File
@@ -2,7 +2,6 @@ export interface QueryParams {
query?: string; query?: string;
page?: number; page?: number;
limit?: number; limit?: number;
adminView?: boolean;
} }
export enum UserRole { export enum UserRole {
+1 -3
View File
@@ -1,8 +1,6 @@
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "@mantine/spotlight/styles.css"; import "@mantine/spotlight/styles.css";
import "@mantine/notifications/styles.css"; import "@mantine/notifications/styles.css";
import '@mantine/dates/styles.css';
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
import { mantineCssResolver, theme } from "@/theme"; import { mantineCssResolver, theme } from "@/theme";
@@ -51,7 +49,7 @@ root.render(
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}> <MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
<ModalsProvider> <ModalsProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Notifications position="bottom-center" limit={3} zIndex={10000} /> <Notifications position="bottom-center" limit={3} />
<HelmetProvider> <HelmetProvider>
<PostHogProvider client={posthog}> <PostHogProvider client={posthog}>
<App /> <App />
-1
View File
@@ -21,7 +21,6 @@ const MemoizedHistoryModal = React.memo(HistoryModal);
export default function Page() { export default function Page() {
const { t } = useTranslation(); const { t } = useTranslation();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const { const {
data: page, data: page,
isLoading, isLoading,
@@ -8,12 +8,14 @@ import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { Error404 } from "@/components/ui/error-404.tsx"; import { Error404 } from "@/components/ui/error-404.tsx";
import ShareBranding from "@/features/share/components/share-branding.tsx"; import ShareBranding from "@/features/share/components/share-branding.tsx";
import { useAnchorScroll } from "@/features/editor/components/heading/use-anchor-scroll";
export default function SharedPage() { export default function SharedPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
useAnchorScroll();
const { data, isLoading, isError, error } = useSharePageQuery({ const { data, isLoading, isError, error } = useSharePageQuery({
pageId: extractPageSlugId(pageSlug), pageId: extractPageSlugId(pageSlug),
+18 -28
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.24.1", "version": "0.23.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -30,9 +30,6 @@
"test:e2e": "jest --config test/jest-e2e.json" "test:e2e": "jest --config test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/azure": "^2.0.47",
"@ai-sdk/google": "^2.0.18",
"@ai-sdk/openai": "^2.0.46",
"@aws-sdk/client-s3": "3.701.0", "@aws-sdk/client-s3": "3.701.0",
"@aws-sdk/lib-storage": "3.701.0", "@aws-sdk/lib-storage": "3.701.0",
"@aws-sdk/s3-request-presigner": "3.701.0", "@aws-sdk/s3-request-presigner": "3.701.0",
@@ -40,55 +37,49 @@
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.0.3", "@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.2.0", "@fastify/static": "^8.2.0",
"@langchain/textsplitters": "^0.1.0", "@nestjs/bullmq": "^11.0.2",
"@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs/common": "^11.1.3",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.9",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.9", "@nestjs/core": "^11.1.3",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.9", "@nestjs/platform-fastify": "^11.1.3",
"@nestjs/platform-socket.io": "^11.1.9", "@nestjs/platform-socket.io": "^11.1.3",
"@nestjs/schedule": "^6.0.1", "@nestjs/schedule": "^6.0.0",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.9", "@nestjs/websockets": "^11.1.3",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28", "@react-email/components": "0.0.28",
"@react-email/render": "1.0.2", "@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"ai": "^5.0.65", "bcrypt": "^5.1.1",
"ai-sdk-ollama": "^0.12.0", "bullmq": "^5.53.2",
"bcrypt": "^6.0.0",
"bullmq": "^5.65.0",
"cache-manager": "^6.4.3", "cache-manager": "^6.4.3",
"cheerio": "^1.1.0", "cheerio": "^1.1.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.1",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"happy-dom": "20.0.10", "happy-dom": "^15.11.6",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kysely": "^0.28.2", "kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"ldapts": "^7.4.0", "ldapts": "^7.4.0",
"mammoth": "^1.11.0", "mammoth": "^1.10.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"nanoid": "3.3.11", "nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0", "nestjs-kysely": "^1.2.0",
"nodemailer": "^7.0.11", "nodemailer": "^7.0.3",
"openid-client": "^5.7.1", "openid-client": "^5.7.1",
"otpauth": "^9.4.0", "otpauth": "^9.4.0",
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdfjs-dist": "^5.4.394", "pdfjs-dist": "^5.4.54",
"pg": "^8.16.3", "pg": "^8.16.0",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1",
"postmark": "^4.0.5", "postmark": "^4.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -98,8 +89,7 @@
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"stripe": "^17.5.0", "stripe": "^17.5.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"typesense": "^2.1.0", "ws": "^8.18.2",
"ws": "^8.18.3",
"yauzl": "^3.2.0" "yauzl": "^3.2.0"
}, },
"devDependencies": { "devDependencies": {
-5
View File
@@ -16,8 +16,6 @@ import { ExportModule } from './integrations/export/export.module';
import { ImportModule } from './integrations/import/import.module'; import { ImportModule } from './integrations/import/import.module';
import { SecurityModule } from './integrations/security/security.module'; import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module'; import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service';
const enterpriseModules = []; const enterpriseModules = [];
try { try {
@@ -38,9 +36,6 @@ try {
CoreModule, CoreModule,
DatabaseModule, DatabaseModule,
EnvironmentModule, EnvironmentModule,
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
CollaborationModule, CollaborationModule,
WsModule, WsModule,
QueueModule, QueueModule,
@@ -5,12 +5,13 @@ import { TaskItem } from '@tiptap/extension-task-item';
import { Underline } from '@tiptap/extension-underline'; import { Underline } from '@tiptap/extension-underline';
import { Superscript } from '@tiptap/extension-superscript'; import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript'; import SubScript from '@tiptap/extension-subscript';
import { Highlight } from '@tiptap/extension-highlight';
import { Typography } from '@tiptap/extension-typography'; import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style'; import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color'; import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube'; import { Youtube } from '@tiptap/extension-youtube';
import UniqueID from '@tiptap/extension-unique-id';
import { import {
Heading,
Callout, Callout,
Comment, Comment,
CustomCodeBlock, CustomCodeBlock,
@@ -33,16 +34,14 @@ import {
Embed, Embed,
Mention, Mention,
Subpages, Subpages,
Highlight, HeadingAnchors
UniqueID,
ColumnsExtension,
addUniqueIdsToDoc,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; import { generateHTML } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML // @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352 // see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
import { generateJSON } from '@tiptap/html';
import { Node } from '@tiptap/pm/model'; import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [ export const tiptapExtensions = [
@@ -50,10 +49,7 @@ export const tiptapExtensions = [
codeBlock: false, codeBlock: false,
heading: false, heading: false,
}), }),
Heading, HeadingAnchors,
UniqueID.configure({
types: ['heading', 'paragraph'],
}),
Comment, Comment,
TextAlign.configure({ types: ['heading', 'paragraph'] }), TextAlign.configure({ types: ['heading', 'paragraph'] }),
TaskList, TaskList,
@@ -89,7 +85,10 @@ export const tiptapExtensions = [
Embed, Embed,
Mention, Mention,
Subpages, Subpages,
ColumnsExtension UniqueID.configure({
types: ['heading'],
attributeName: 'nodeId',
}),
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {
@@ -97,14 +96,7 @@ export function jsonToHtml(tiptapJson: any) {
} }
export function htmlToJson(html: string) { export function htmlToJson(html: string) {
const pmJson = generateJSON(html, tiptapExtensions); return generateJSON(html, tiptapExtensions);
try {
return addUniqueIdsToDoc(pmJson, tiptapExtensions);
} catch (error) {
console.warn('failed to add unique ids to doc', error);
return pmJson;
}
} }
export function jsonToText(tiptapJson: JSONContent) { export function jsonToText(tiptapJson: JSONContent) {
@@ -35,7 +35,6 @@ export class PersistenceExtension implements Extension {
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue, @InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {} ) {}
async onLoadDocument(data: onLoadDocumentPayload) { async onLoadDocument(data: onLoadDocumentPayload) {
@@ -169,11 +168,6 @@ export class PersistenceExtension implements Extension {
workspaceId: page.workspaceId, workspaceId: page.workspaceId,
mentions: pageMentions, mentions: pageMentions,
} as IPageBacklinkJob); } as IPageBacklinkJob);
await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {
pageIds: [pageId],
workspaceId: page.workspaceId,
});
} }
} }
@@ -1,18 +1,3 @@
export enum EventName { export enum EventName {
COLLAB_PAGE_UPDATED = 'collab.page.updated', COLLAB_PAGE_UPDATED = 'collab.page.updated',
PAGE_CREATED = 'page.created', }
PAGE_UPDATED = 'page.updated',
PAGE_CONTENT_UPDATED = 'page-content-updated',
PAGE_MOVED_TO_SPACE = 'page-moved-to-space',
PAGE_DELETED = 'page.deleted',
PAGE_SOFT_DELETED = 'page.soft_deleted',
PAGE_RESTORED = 'page.restored',
SPACE_CREATED = 'space.created',
SPACE_UPDATED = 'space.updated',
SPACE_DELETED = 'space.deleted',
WORKSPACE_CREATED = 'workspace.created',
WORKSPACE_UPDATED = 'workspace.updated',
WORKSPACE_DELETED = 'workspace.deleted',
}
@@ -1,29 +1,21 @@
import { type Extensions, type JSONContent, getSchema } from '@tiptap/core'; import { Extensions, getSchema, JSONContent } from '@tiptap/core';
import { Node } from '@tiptap/pm/model'; import { DOMSerializer, Node } from '@tiptap/pm/model';
import { getHTMLFromFragment } from './getHTMLFromFragment'; import { Window } from 'happy-dom';
/**
* This function generates HTML from a ProseMirror JSON content object.
*
* @remarks **Important**: This function requires `happy-dom` to be installed in your project.
* @param doc - The ProseMirror JSON content object.
* @param extensions - The Tiptap extensions used to build the schema.
* @returns The generated HTML string.
* @example
* ```js
* const html = generateHTML(doc, extensions)
* console.log(html)
* ```
*/
export function generateHTML(doc: JSONContent, extensions: Extensions): string { export function generateHTML(doc: JSONContent, extensions: Extensions): string {
if (typeof window !== 'undefined') {
throw new Error(
'generateHTML can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.',
);
}
const schema = getSchema(extensions); const schema = getSchema(extensions);
const contentNode = Node.fromJSON(schema, doc); const contentNode = Node.fromJSON(schema, doc);
return getHTMLFromFragment(contentNode, schema); const window = new Window();
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(
contentNode.content,
{
document: window.document as unknown as Document,
},
);
const serializer = new window.XMLSerializer();
// @ts-ignore
return serializer.serializeToString(fragment as unknown as Node);
} }
@@ -1,55 +1,21 @@
import type { Extensions } from '@tiptap/core'; import { Extensions, getSchema } from '@tiptap/core';
import { getSchema } from '@tiptap/core'; import { DOMParser, ParseOptions } from '@tiptap/pm/model';
import { type ParseOptions, DOMParser as PMDOMParser } from '@tiptap/pm/model';
import { Window } from 'happy-dom'; import { Window } from 'happy-dom';
/** // this function does not work as intended
* Generates a JSON object from the given HTML string and converts it into a Prosemirror node with content. // it has issues with closing tags
* @remarks **Important**: This function requires `happy-dom` to be installed in your project.
* @param {string} html - The HTML string to be converted into a Prosemirror node.
* @param {Extensions} extensions - The extensions to be used for generating the schema.
* @param {ParseOptions} options - The options to be supplied to the parser.
* @returns {Promise<Record<string, any>>} - A promise with the generated JSON object.
* @example
* const html = '<p>Hello, world!</p>'
* const extensions = [...]
* const json = generateJSON(html, extensions)
* console.log(json) // { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello, world!' }] }] }
*/
export function generateJSON( export function generateJSON(
html: string, html: string,
extensions: Extensions, extensions: Extensions,
options?: ParseOptions, options?: ParseOptions,
): Record<string, any> { ): Record<string, any> {
if (typeof window !== 'undefined') { const schema = getSchema(extensions);
throw new Error(
'generateJSON can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.',
);
}
const localWindow = new Window(); const window = new Window();
const localDOMParser = new localWindow.DOMParser(); const document = window.document;
let result: Record<string, any>; document.body.innerHTML = html;
try { return DOMParser.fromSchema(schema)
const schema = getSchema(extensions); .parse(document as never, options)
let doc: ReturnType<typeof localDOMParser.parseFromString> | null = null; .toJSON();
const htmlString = `<!DOCTYPE html><html><body>${html}</body></html>`;
doc = localDOMParser.parseFromString(htmlString, 'text/html');
if (!doc) {
throw new Error('Failed to parse HTML string');
}
result = PMDOMParser.fromSchema(schema)
.parse(doc.body as unknown as Node, options)
.toJSON();
} finally {
// clean up happy-dom to avoid memory leaks
localWindow.happyDOM.abort();
localWindow.happyDOM.close();
}
return result;
} }
@@ -1,54 +0,0 @@
import type { Node, Schema } from '@tiptap/pm/model';
import { DOMSerializer } from '@tiptap/pm/model';
import { Window } from 'happy-dom';
/**
* Returns the HTML string representation of a given document node.
*
* @remarks **Important**: This function requires `happy-dom` to be installed in your project.
* @param doc - The document node to serialize.
* @param schema - The Prosemirror schema to use for serialization.
* @returns A promise containing the HTML string representation of the document fragment.
*
* @example
* ```typescript
* const html = getHTMLFromFragment(doc, schema)
* ```
*/
export function getHTMLFromFragment(
doc: Node,
schema: Schema,
options?: { document?: Document },
): string {
if (options?.document) {
const wrap = options.document.createElement('div');
DOMSerializer.fromSchema(schema).serializeFragment(
doc.content,
{ document: options.document },
wrap,
);
return wrap.innerHTML;
}
const localWindow = new Window();
let result: string;
try {
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(
doc.content,
{
document: localWindow.document as unknown as Document,
},
);
const serializer = new localWindow.XMLSerializer();
result = serializer.serializeToString(fragment as any);
} finally {
// clean up happy-dom to avoid memory leaks
localWindow.happyDOM.abort();
localWindow.happyDOM.close();
}
return result;
}
@@ -1,34 +0,0 @@
// MIT - https://github.com/typestack/class-validator/pull/2626
import isISO6391Validator from 'validator/lib/isISO6391';
import { buildMessage, ValidateBy, ValidationOptions } from 'class-validator';
export const IS_ISO6391 = 'isISO6391';
/**
* Check if the string is a valid [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) officially assigned language code.
*/
export function isISO6391(value: unknown): boolean {
return typeof value === 'string' && isISO6391Validator(value);
}
/**
* Check if the string is a valid [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) officially assigned language code.
*/
export function IsISO6391(
validationOptions?: ValidationOptions,
): PropertyDecorator {
return ValidateBy(
{
name: IS_ISO6391,
validator: {
validate: (value, args): boolean => isISO6391(value),
defaultMessage: buildMessage(
(eachPrefix) =>
eachPrefix + '$property must be a valid ISO 639-1 language code',
validationOptions,
),
},
},
validationOptions,
);
}

Some files were not shown because too many files have changed in this diff Show More