mirror of
https://github.com/docmost/docmost.git
synced 2026-05-08 07:13:06 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a750744f1e | |||
| cb10cf155f | |||
| 9fc5f36bc2 | |||
| 974089c9f6 | |||
| 20aa40a9fd | |||
| a40b90d0d9 | |||
| 8cdfba7281 | |||
| 5739a0e260 | |||
| 9ec0f46eb6 | |||
| 8282403ee8 |
+12
-14
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.25.3",
|
"version": "0.24.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@@ -14,19 +14,18 @@
|
|||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
"@excalidraw/excalidraw": "0.18.0-c158187",
|
||||||
"@mantine/core": "^8.3.14",
|
"@mantine/core": "^8.3.12",
|
||||||
"@mantine/dates": "^8.3.14",
|
"@mantine/dates": "^8.3.12",
|
||||||
"@mantine/form": "^8.3.14",
|
"@mantine/form": "^8.3.12",
|
||||||
"@mantine/hooks": "^8.3.14",
|
"@mantine/hooks": "^8.3.12",
|
||||||
"@mantine/modals": "^8.3.14",
|
"@mantine/modals": "^8.3.12",
|
||||||
"@mantine/notifications": "^8.3.14",
|
"@mantine/notifications": "^8.3.12",
|
||||||
"@mantine/spotlight": "^8.3.14",
|
"@mantine/spotlight": "^8.3.12",
|
||||||
"@tabler/icons-react": "^3.36.1",
|
"@tabler/icons-react": "^3.36.1",
|
||||||
"@tanstack/react-query": "^5.90.17",
|
"@tanstack/react-query": "^5.90.17",
|
||||||
"alfaaz": "^1.1.0",
|
"alfaaz": "^1.1.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.2",
|
||||||
"blueimp-load-image": "^5.16.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",
|
||||||
@@ -42,7 +41,7 @@
|
|||||||
"mantine-form-zod-resolver": "^1.3.0",
|
"mantine-form-zod-resolver": "^1.3.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"posthog-js": "1.345.5",
|
"posthog-js": "^1.255.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
"react-clear-modal": "^2.0.17",
|
"react-clear-modal": "^2.0.17",
|
||||||
@@ -60,7 +59,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^9.16.0",
|
||||||
"@tanstack/eslint-plugin-query": "^5.62.1",
|
"@tanstack/eslint-plugin-query": "^5.62.1",
|
||||||
"@types/blueimp-load-image": "^5.16.0",
|
|
||||||
"@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",
|
||||||
@@ -68,7 +66,7 @@
|
|||||||
"@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": "^5.1.1",
|
||||||
"eslint": "^9.39.2",
|
"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",
|
||||||
"eslint-plugin-react-refresh": "^0.4.16",
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"Date": "Datum",
|
"Date": "Datum",
|
||||||
"Delete": "Löschen",
|
"Delete": "Löschen",
|
||||||
"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? Dabei werden auch alle Unterseiten und der Seitenverlauf gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
"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": "Details",
|
||||||
"e.g ACME": "z.B. ACME",
|
"e.g ACME": "z.B. ACME",
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"Enter your new preferred email": "Geben Sie Ihre neue bevorzugte E-Mail ein",
|
"Enter your new preferred email": "Geben Sie Ihre neue bevorzugte E-Mail ein",
|
||||||
"Enter your password": "Geben Sie Ihr Passwort ein",
|
"Enter your password": "Geben Sie Ihr Passwort ein",
|
||||||
"Error fetching page data.": "Fehler beim Abrufen der Seitendaten.",
|
"Error fetching page data.": "Fehler beim Abrufen der Seitendaten.",
|
||||||
"Error loading page history.": "Fehler beim Laden des Seitenverlaufs.",
|
"Error loading page history.": "Fehler beim Laden der Seitengeschichte.",
|
||||||
"Export": "Exportieren",
|
"Export": "Exportieren",
|
||||||
"Failed to create page": "Erstellung der Seite fehlgeschlagen",
|
"Failed to create page": "Erstellung der Seite fehlgeschlagen",
|
||||||
"Failed to delete page": "Löschen der Seite fehlgeschlagen",
|
"Failed to delete page": "Löschen der Seite fehlgeschlagen",
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
"New page": "Neue Seite",
|
"New page": "Neue Seite",
|
||||||
"New password": "Neues Passwort",
|
"New password": "Neues Passwort",
|
||||||
"No group found": "Keine Gruppe gefunden",
|
"No group found": "Keine Gruppe gefunden",
|
||||||
"No page history saved yet.": "Es wurde noch kein Seitenverlauf gespeichert.",
|
"No page history saved yet.": "Es wurde noch keine Seitengeschichte gespeichert.",
|
||||||
"No pages yet": "Noch keine Seiten",
|
"No pages yet": "Noch keine Seiten",
|
||||||
"No results found...": "Keine Ergebnisse gefunden...",
|
"No results found...": "Keine Ergebnisse gefunden...",
|
||||||
"No user found": "Kein Benutzer gefunden",
|
"No user found": "Kein Benutzer gefunden",
|
||||||
@@ -122,9 +122,7 @@
|
|||||||
"Owner": "Besitzer",
|
"Owner": "Besitzer",
|
||||||
"page": "Seite",
|
"page": "Seite",
|
||||||
"Page deleted successfully": "Seite erfolgreich gelöscht",
|
"Page deleted successfully": "Seite erfolgreich gelöscht",
|
||||||
"Page history": "Seitenverlauf",
|
"Page history": "Seitengeschichte",
|
||||||
"Select version": "Version auswählen",
|
|
||||||
"Highlight changes": "Änderungen hervorheben",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.",
|
"Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.",
|
||||||
"Pages": "Seiten",
|
"Pages": "Seiten",
|
||||||
"pages": "Seiten",
|
"pages": "Seiten",
|
||||||
@@ -355,11 +353,6 @@
|
|||||||
"Insert current date": "Aktuelles Datum einfügen",
|
"Insert current date": "Aktuelles Datum einfügen",
|
||||||
"Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren",
|
"Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren",
|
||||||
"Multiple": "Mehrere",
|
"Multiple": "Mehrere",
|
||||||
"Turn into": "In verwandeln",
|
|
||||||
"Text align": "Text ausrichten",
|
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
|
||||||
"Go to homepage": "Go to homepage",
|
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
|
||||||
"Heading {{level}}": "Überschrift {{level}}",
|
"Heading {{level}}": "Überschrift {{level}}",
|
||||||
"Toggle title": "Titel umschalten",
|
"Toggle title": "Titel umschalten",
|
||||||
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
|
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
|
||||||
@@ -412,21 +405,6 @@
|
|||||||
"Share deleted successfully": "Freigabe erfolgreich gelöscht",
|
"Share deleted successfully": "Freigabe erfolgreich gelöscht",
|
||||||
"Share not found": "Freigabe nicht gefunden",
|
"Share not found": "Freigabe nicht gefunden",
|
||||||
"Failed to share page": "Fehler beim Teilen der Seite",
|
"Failed to share page": "Fehler beim Teilen der Seite",
|
||||||
"Disable public sharing": "Öffentliches Teilen deaktivieren",
|
|
||||||
"Prevent members from sharing pages publicly.": "Verhindern Sie, dass Mitglieder Seiten öffentlich teilen.",
|
|
||||||
"Toggle public sharing": "Öffentliches Teilen umschalten",
|
|
||||||
"Toggle space public sharing": "Öffentliches Teilen im Bereich umschalten",
|
|
||||||
"Public sharing is disabled at the workspace level": "Öffentliches Teilen ist auf der Arbeitsbereichsebene deaktiviert",
|
|
||||||
"Prevent pages in this space from being shared publicly.": "Verhindern Sie, dass Seiten in diesem Bereich öffentlich geteilt werden.",
|
|
||||||
"Requires an enterprise license": "Erfordert eine Unternehmenslizenz",
|
|
||||||
"Enable public sharing": "Öffentliches Teilen aktivieren",
|
|
||||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Sind Sie sicher, dass Sie das öffentliche Teilen aktivieren möchten? Mitglieder können Seiten öffentlich teilen.",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Sind Sie sicher, dass Sie das öffentliche Teilen deaktivieren möchten? Alle bestehenden Freigabelinks in diesem Arbeitsbereich werden gelöscht.",
|
|
||||||
"Are you sure you want to enable public sharing for this space?": "Sind Sie sicher, dass Sie das öffentliche Teilen für diesen Bereich aktivieren möchten?",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Sind Sie sicher, dass Sie das öffentliche Teilen deaktivieren möchten? Alle bestehenden Freigabelinks in diesem Bereich werden gelöscht.",
|
|
||||||
"Public sharing is disabled": "Öffentliches Teilen ist deaktiviert",
|
|
||||||
"Public sharing has been disabled at the workspace level.": "Das öffentliche Teilen wurde auf der Arbeitsbereichsebene deaktiviert.",
|
|
||||||
"Public sharing has been disabled for this space.": "Das öffentliche Teilen wurde für diesen Bereich deaktiviert.",
|
|
||||||
"Copy page": "Seite kopieren",
|
"Copy page": "Seite kopieren",
|
||||||
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
|
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
|
||||||
"Page copied successfully": "Seite erfolgreich kopiert",
|
"Page copied successfully": "Seite erfolgreich kopiert",
|
||||||
@@ -587,33 +565,13 @@
|
|||||||
"Ask AI": "KI fragen",
|
"Ask AI": "KI fragen",
|
||||||
"AI is thinking...": "Die KI überlegt...",
|
"AI is thinking...": "Die KI überlegt...",
|
||||||
"Ask a question...": "Fragen stellen...",
|
"Ask a question...": "Fragen stellen...",
|
||||||
"AI Answers": "KI-Antworten",
|
"AI-powered search (Ask AI)": "KI-gestützte Suche (KI fragen)",
|
||||||
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
|
|
||||||
"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.",
|
"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",
|
"Toggle AI search": "KI-Suche umschalten",
|
||||||
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.",
|
|
||||||
"Toggle generative AI": "Generative KI umschalten",
|
|
||||||
"Sources": "Quellen",
|
"Sources": "Quellen",
|
||||||
"AI Answers not available for attachments": "KI-Antworten sind für Anhänge nicht verfügbar",
|
"Ask AI not available for attachments": "KI fragen nicht für Anhänge verfügbar",
|
||||||
"No answer available": "Keine Antwort verfügbar",
|
"No answer available": "Keine Antwort verfügbar",
|
||||||
"Background color": "Hintergrundfarbe",
|
"Background color": "Hintergrundfarbe",
|
||||||
"Highlight color": "Hervorhebungsfarbe",
|
"Highlight color": "Hervorhebungsfarbe",
|
||||||
"Remove color": "Farbe entfernen",
|
"Remove color": "Farbe entfernen"
|
||||||
"Notifications": "Benachrichtigungen",
|
|
||||||
"No notifications": "Keine Benachrichtigungen",
|
|
||||||
"No unread notifications": "Keine ungelesenen Benachrichtigungen",
|
|
||||||
"All notifications": "Alle Benachrichtigungen",
|
|
||||||
"Unread only": "Nur ungelesen",
|
|
||||||
"Mark all as read": "Alle als gelesen markieren",
|
|
||||||
"Mark as read": "Als gelesen markieren",
|
|
||||||
"More options": "Weitere Optionen",
|
|
||||||
"mentioned you in a comment": "hat Sie in einem Kommentar erwähnt",
|
|
||||||
"commented on a page": "hat auf einer Seite kommentiert",
|
|
||||||
"resolved a comment": "hat einen Kommentar gelöst",
|
|
||||||
"mentioned you on a page": "hat Sie auf einer Seite erwähnt",
|
|
||||||
"Today": "Heute",
|
|
||||||
"Yesterday": "Gestern",
|
|
||||||
"This week": "Diese Woche",
|
|
||||||
"Older": "Älter"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,8 +123,6 @@
|
|||||||
"page": "page",
|
"page": "page",
|
||||||
"Page deleted successfully": "Page deleted successfully",
|
"Page deleted successfully": "Page deleted successfully",
|
||||||
"Page history": "Page history",
|
"Page history": "Page history",
|
||||||
"Select version": "Select version",
|
|
||||||
"Highlight changes": "Highlight changes",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
||||||
"Pages": "Pages",
|
"Pages": "Pages",
|
||||||
"pages": "pages",
|
"pages": "pages",
|
||||||
@@ -274,7 +272,6 @@
|
|||||||
"Add row below": "Add row below",
|
"Add row below": "Add row below",
|
||||||
"Delete table": "Delete table",
|
"Delete table": "Delete table",
|
||||||
"Info": "Info",
|
"Info": "Info",
|
||||||
"Note": "Note",
|
|
||||||
"Success": "Success",
|
"Success": "Success",
|
||||||
"Warning": "Warning",
|
"Warning": "Warning",
|
||||||
"Danger": "Danger",
|
"Danger": "Danger",
|
||||||
@@ -356,23 +353,9 @@
|
|||||||
"Insert current date": "Insert current date",
|
"Insert current date": "Insert current date",
|
||||||
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
|
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
|
||||||
"Multiple": "Multiple",
|
"Multiple": "Multiple",
|
||||||
"Turn into": "Turn into",
|
|
||||||
"Text align": "Text align",
|
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
|
||||||
"Go to homepage": "Go to homepage",
|
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
|
||||||
"Heading {{level}}": "Heading {{level}}",
|
"Heading {{level}}": "Heading {{level}}",
|
||||||
"Toggle title": "Toggle title",
|
"Toggle title": "Toggle title",
|
||||||
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
||||||
"Write...": "Write...",
|
|
||||||
"Column count": "Column count",
|
|
||||||
"{{count}} Columns": "{{count}} Columns",
|
|
||||||
"Equal columns": "Equal columns",
|
|
||||||
"Left sidebar": "Left sidebar",
|
|
||||||
"Right sidebar": "Right sidebar",
|
|
||||||
"Wide center": "Wide center",
|
|
||||||
"Left wide": "Left wide",
|
|
||||||
"Right wide": "Right wide",
|
|
||||||
"Names do not match": "Names do not match",
|
"Names do not match": "Names do not match",
|
||||||
"Today, {{time}}": "Today, {{time}}",
|
"Today, {{time}}": "Today, {{time}}",
|
||||||
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
||||||
@@ -422,21 +405,6 @@
|
|||||||
"Share deleted successfully": "Share deleted successfully",
|
"Share deleted successfully": "Share deleted successfully",
|
||||||
"Share not found": "Share not found",
|
"Share not found": "Share not found",
|
||||||
"Failed to share page": "Failed to share page",
|
"Failed to share page": "Failed to share page",
|
||||||
"Disable public sharing": "Disable public sharing",
|
|
||||||
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
|
|
||||||
"Toggle public sharing": "Toggle public sharing",
|
|
||||||
"Toggle space public sharing": "Toggle space public sharing",
|
|
||||||
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
|
|
||||||
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
|
|
||||||
"Requires an enterprise license": "Requires an enterprise license",
|
|
||||||
"Enable public sharing": "Enable public sharing",
|
|
||||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
|
|
||||||
"Are you sure you want to enable public sharing for this space?": "Are you sure you want to enable public sharing for this space?",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.",
|
|
||||||
"Public sharing is disabled": "Public sharing is disabled",
|
|
||||||
"Public sharing has been disabled at the workspace level.": "Public sharing has been disabled at the workspace level.",
|
|
||||||
"Public sharing has been disabled for this space.": "Public sharing has been disabled for this space.",
|
|
||||||
"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",
|
||||||
@@ -597,33 +565,13 @@
|
|||||||
"Ask AI": "Ask AI",
|
"Ask AI": "Ask AI",
|
||||||
"AI is thinking...": "AI is thinking...",
|
"AI is thinking...": "AI is thinking...",
|
||||||
"Ask a question...": "Ask a question...",
|
"Ask a question...": "Ask a question...",
|
||||||
"AI Answers": "AI Answers",
|
"AI-powered search (Ask AI)": "AI-powered search (Ask AI)",
|
||||||
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
|
|
||||||
"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.",
|
"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",
|
"Toggle AI search": "Toggle AI search",
|
||||||
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
|
|
||||||
"Toggle generative AI": "Toggle generative AI",
|
|
||||||
"Sources": "Sources",
|
"Sources": "Sources",
|
||||||
"AI Answers not available for attachments": "AI Answers not available for attachments",
|
"Ask AI not available for attachments": "Ask AI not available for attachments",
|
||||||
"No answer available": "No answer available",
|
"No answer available": "No answer available",
|
||||||
"Background color": "Background color",
|
"Background color": "Background color",
|
||||||
"Highlight color": "Highlight color",
|
"Highlight color": "Highlight color",
|
||||||
"Remove color": "Remove color",
|
"Remove color": "Remove color"
|
||||||
"Notifications": "Notifications",
|
|
||||||
"No notifications": "No notifications",
|
|
||||||
"No unread notifications": "No unread notifications",
|
|
||||||
"All notifications": "All notifications",
|
|
||||||
"Unread only": "Unread only",
|
|
||||||
"Mark all as read": "Mark all as read",
|
|
||||||
"Mark as read": "Mark as read",
|
|
||||||
"More options": "More options",
|
|
||||||
"mentioned you in a comment": "mentioned you in a comment",
|
|
||||||
"commented on a page": "commented on a page",
|
|
||||||
"resolved a comment": "resolved a comment",
|
|
||||||
"mentioned you on a page": "mentioned you on a page",
|
|
||||||
"Today": "Today",
|
|
||||||
"Yesterday": "Yesterday",
|
|
||||||
"This week": "This week",
|
|
||||||
"Older": "Older"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,8 +123,6 @@
|
|||||||
"page": "página",
|
"page": "página",
|
||||||
"Page deleted successfully": "Página eliminada con éxito",
|
"Page deleted successfully": "Página eliminada con éxito",
|
||||||
"Page history": "Historial de la página",
|
"Page history": "Historial de la página",
|
||||||
"Select version": "Seleccionar versión",
|
|
||||||
"Highlight changes": "Resaltar cambios",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.",
|
"Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.",
|
||||||
"Pages": "Páginas",
|
"Pages": "Páginas",
|
||||||
"pages": "páginas",
|
"pages": "páginas",
|
||||||
@@ -355,11 +353,6 @@
|
|||||||
"Insert current date": "Insertar fecha actual",
|
"Insert current date": "Insertar fecha actual",
|
||||||
"Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw",
|
"Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw",
|
||||||
"Multiple": "Múltiple",
|
"Multiple": "Múltiple",
|
||||||
"Turn into": "Convertir en",
|
|
||||||
"Text align": "Alineación del texto",
|
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
|
||||||
"Go to homepage": "Go to homepage",
|
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
|
||||||
"Heading {{level}}": "Encabezado {{level}}",
|
"Heading {{level}}": "Encabezado {{level}}",
|
||||||
"Toggle title": "Alternar título",
|
"Toggle title": "Alternar título",
|
||||||
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
|
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
|
||||||
@@ -412,21 +405,6 @@
|
|||||||
"Share deleted successfully": "Compartición eliminada con éxito",
|
"Share deleted successfully": "Compartición eliminada con éxito",
|
||||||
"Share not found": "Compartición no encontrada",
|
"Share not found": "Compartición no encontrada",
|
||||||
"Failed to share page": "Error al compartir la página",
|
"Failed to share page": "Error al compartir la página",
|
||||||
"Disable public sharing": "Desactivar el uso compartido público",
|
|
||||||
"Prevent members from sharing pages publicly.": "Evitar que los miembros compartan páginas públicamente.",
|
|
||||||
"Toggle public sharing": "Alternar el uso compartido público",
|
|
||||||
"Toggle space public sharing": "Alternar el uso compartido público del espacio",
|
|
||||||
"Public sharing is disabled at the workspace level": "El uso compartido público está desactivado a nivel de espacio de trabajo",
|
|
||||||
"Prevent pages in this space from being shared publicly.": "Evitar que las páginas en este espacio se compartan públicamente.",
|
|
||||||
"Requires an enterprise license": "Requiere una licencia empresarial",
|
|
||||||
"Enable public sharing": "Activar el uso compartido público",
|
|
||||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "¿Está seguro de que desea activar el uso compartido público? Los miembros podrán compartir páginas públicamente.",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "¿Está seguro de que desea desactivar el uso compartido público? Todos los enlaces compartidos existentes en este espacio de trabajo se eliminarán.",
|
|
||||||
"Are you sure you want to enable public sharing for this space?": "¿Está seguro de que desea activar el uso compartido público para este espacio?",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "¿Está seguro de que desea desactivar el uso compartido público? Todos los enlaces compartidos existentes en este espacio se eliminarán.",
|
|
||||||
"Public sharing is disabled": "El uso compartido público está desactivado",
|
|
||||||
"Public sharing has been disabled at the workspace level.": "El uso compartido público se ha desactivado a nivel de espacio de trabajo.",
|
|
||||||
"Public sharing has been disabled for this space.": "El uso compartido público se ha desactivado para este espacio.",
|
|
||||||
"Copy page": "Copiar página",
|
"Copy page": "Copiar página",
|
||||||
"Copy page to a different space.": "Copiar página en otro espacio",
|
"Copy page to a different space.": "Copiar página en otro espacio",
|
||||||
"Page copied successfully": "Página copiada exitosamente",
|
"Page copied successfully": "Página copiada exitosamente",
|
||||||
@@ -587,33 +565,13 @@
|
|||||||
"Ask AI": "Preguntar a IA",
|
"Ask AI": "Preguntar a IA",
|
||||||
"AI is thinking...": "IA está pensando...",
|
"AI is thinking...": "IA está pensando...",
|
||||||
"Ask a question...": "Haz una pregunta...",
|
"Ask a question...": "Haz una pregunta...",
|
||||||
"AI Answers": "Respuestas de IA",
|
"AI-powered search (Ask AI)": "Búsqueda impulsada por IA (Preguntar a IA)",
|
||||||
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de 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.",
|
"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",
|
"Toggle AI search": "Alternar búsqueda de IA",
|
||||||
"Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.",
|
|
||||||
"Toggle generative AI": "Activar IA generativa",
|
|
||||||
"Sources": "Fuentes",
|
"Sources": "Fuentes",
|
||||||
"AI Answers not available for attachments": "Respuestas de IA no disponibles para archivos adjuntos",
|
"Ask AI not available for attachments": "Preguntar a IA no está disponible para adjuntos",
|
||||||
"No answer available": "No hay respuesta disponible",
|
"No answer available": "No hay respuesta disponible",
|
||||||
"Background color": "Color de fondo",
|
"Background color": "Color de fondo",
|
||||||
"Highlight color": "Color de resaltado",
|
"Highlight color": "Color de resaltado",
|
||||||
"Remove color": "Eliminar color",
|
"Remove color": "Eliminar color"
|
||||||
"Notifications": "Notificaciones",
|
|
||||||
"No notifications": "Sin notificaciones",
|
|
||||||
"No unread notifications": "No hay notificaciones no leídas",
|
|
||||||
"All notifications": "Todas las notificaciones",
|
|
||||||
"Unread only": "Solo no leídas",
|
|
||||||
"Mark all as read": "Marcar todo como leído",
|
|
||||||
"Mark as read": "Marcar como leído",
|
|
||||||
"More options": "Más opciones",
|
|
||||||
"mentioned you in a comment": "te mencionó en un comentario",
|
|
||||||
"commented on a page": "comentó en una página",
|
|
||||||
"resolved a comment": "resolvió un comentario",
|
|
||||||
"mentioned you on a page": "te mencionó en una página",
|
|
||||||
"Today": "Hoy",
|
|
||||||
"Yesterday": "Ayer",
|
|
||||||
"This week": "Esta semana",
|
|
||||||
"Older": "Más antiguo"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,8 +123,6 @@
|
|||||||
"page": "page",
|
"page": "page",
|
||||||
"Page deleted successfully": "Page supprimée avec succès",
|
"Page deleted successfully": "Page supprimée avec succès",
|
||||||
"Page history": "Historique de la page",
|
"Page history": "Historique de la page",
|
||||||
"Select version": "Sélectionner la version",
|
|
||||||
"Highlight changes": "Mettre en évidence les changements",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.",
|
"Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.",
|
||||||
"Pages": "Pages",
|
"Pages": "Pages",
|
||||||
"pages": "pages",
|
"pages": "pages",
|
||||||
@@ -355,11 +353,6 @@
|
|||||||
"Insert current date": "Insérer la date actuelle",
|
"Insert current date": "Insérer la date actuelle",
|
||||||
"Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw",
|
"Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw",
|
||||||
"Multiple": "Multiple",
|
"Multiple": "Multiple",
|
||||||
"Turn into": "Transformer en",
|
|
||||||
"Text align": "Alignement du texte",
|
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
|
||||||
"Go to homepage": "Go to homepage",
|
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
|
||||||
"Heading {{level}}": "Titre {{level}}",
|
"Heading {{level}}": "Titre {{level}}",
|
||||||
"Toggle title": "Basculer le titre",
|
"Toggle title": "Basculer le titre",
|
||||||
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
|
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
|
||||||
@@ -412,21 +405,6 @@
|
|||||||
"Share deleted successfully": "Partage supprimé avec succès",
|
"Share deleted successfully": "Partage supprimé avec succès",
|
||||||
"Share not found": "Partage non trouvé",
|
"Share not found": "Partage non trouvé",
|
||||||
"Failed to share page": "Échec du partage de la page",
|
"Failed to share page": "Échec du partage de la page",
|
||||||
"Disable public sharing": "Désactiver le partage public",
|
|
||||||
"Prevent members from sharing pages publicly.": "Empêcher les membres de partager des pages publiquement.",
|
|
||||||
"Toggle public sharing": "Basculer le partage public",
|
|
||||||
"Toggle space public sharing": "Basculer le partage public de l'espace",
|
|
||||||
"Public sharing is disabled at the workspace level": "Le partage public est désactivé au niveau de l'espace de travail",
|
|
||||||
"Prevent pages in this space from being shared publicly.": "Empêcher les pages de cet espace d'être partagées publiquement.",
|
|
||||||
"Requires an enterprise license": "Nécessite une licence d'entreprise",
|
|
||||||
"Enable public sharing": "Activer le partage public",
|
|
||||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Êtes-vous sûr de vouloir activer le partage public ? Les membres pourront partager des pages publiquement.",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Êtes-vous sûr de vouloir désactiver le partage public ? Tous les liens partagés existants dans cet espace de travail seront supprimés.",
|
|
||||||
"Are you sure you want to enable public sharing for this space?": "Êtes-vous sûr de vouloir activer le partage public pour cet espace ?",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Êtes-vous sûr de vouloir désactiver le partage public ? Tous les liens partagés existants dans cet espace seront supprimés.",
|
|
||||||
"Public sharing is disabled": "Le partage public est désactivé",
|
|
||||||
"Public sharing has been disabled at the workspace level.": "Le partage public a été désactivé au niveau de l'espace de travail.",
|
|
||||||
"Public sharing has been disabled for this space.": "Le partage public a été désactivé pour cet espace.",
|
|
||||||
"Copy page": "Copier la page",
|
"Copy page": "Copier la page",
|
||||||
"Copy page to a different space.": "Copier la page dans un autre espace.",
|
"Copy page to a different space.": "Copier la page dans un autre espace.",
|
||||||
"Page copied successfully": "Page copiée avec succès",
|
"Page copied successfully": "Page copiée avec succès",
|
||||||
@@ -587,33 +565,13 @@
|
|||||||
"Ask AI": "Demander à l'IA",
|
"Ask AI": "Demander à l'IA",
|
||||||
"AI is thinking...": "L'IA réfléchit...",
|
"AI is thinking...": "L'IA réfléchit...",
|
||||||
"Ask a question...": "Posez une question...",
|
"Ask a question...": "Posez une question...",
|
||||||
"AI Answers": "Réponses IA",
|
"AI-powered search (Ask AI)": "Recherche assistée par l'IA (Demander à l'IA)",
|
||||||
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses 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.",
|
"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",
|
"Toggle AI search": "Basculer la recherche IA",
|
||||||
"Generative AI (Ask AI)": "IA générative (Demandez à l'IA)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.",
|
|
||||||
"Toggle generative AI": "Activer/désactiver l'IA générative",
|
|
||||||
"Sources": "Sources",
|
"Sources": "Sources",
|
||||||
"AI Answers not available for attachments": "Réponses IA non disponibles pour les pièces jointes",
|
"Ask AI not available for attachments": "Demande à l'IA non disponible pour les pièces jointes",
|
||||||
"No answer available": "Pas de réponse disponible",
|
"No answer available": "Pas de réponse disponible",
|
||||||
"Background color": "Couleur de fond",
|
"Background color": "Couleur de fond",
|
||||||
"Highlight color": "Couleur de surbrillance",
|
"Highlight color": "Couleur de surbrillance",
|
||||||
"Remove color": "Supprimer la couleur",
|
"Remove color": "Supprimer la couleur"
|
||||||
"Notifications": "Notifications",
|
|
||||||
"No notifications": "Aucune notification",
|
|
||||||
"No unread notifications": "Aucune notification non lue",
|
|
||||||
"All notifications": "Toutes les notifications",
|
|
||||||
"Unread only": "Non lues uniquement",
|
|
||||||
"Mark all as read": "Tout marquer comme lu",
|
|
||||||
"Mark as read": "Marquer comme lu",
|
|
||||||
"More options": "Plus d'options",
|
|
||||||
"mentioned you in a comment": "vous a mentionné dans un commentaire",
|
|
||||||
"commented on a page": "a commenté une page",
|
|
||||||
"resolved a comment": "a résolu un commentaire",
|
|
||||||
"mentioned you on a page": "vous a mentionné sur une page",
|
|
||||||
"Today": "Aujourd'hui",
|
|
||||||
"Yesterday": "Hier",
|
|
||||||
"This week": "Cette semaine",
|
|
||||||
"Older": "Plus ancien"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,8 +123,6 @@
|
|||||||
"page": "pagina",
|
"page": "pagina",
|
||||||
"Page deleted successfully": "Pagina eliminata con successo",
|
"Page deleted successfully": "Pagina eliminata con successo",
|
||||||
"Page history": "Cronologia della pagina",
|
"Page history": "Cronologia della pagina",
|
||||||
"Select version": "Seleziona versione",
|
|
||||||
"Highlight changes": "Evidenzia modifiche",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.",
|
"Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.",
|
||||||
"Pages": "Pagine",
|
"Pages": "Pagine",
|
||||||
"pages": "pagine",
|
"pages": "pagine",
|
||||||
@@ -355,11 +353,6 @@
|
|||||||
"Insert current date": "Inserisci la data corrente",
|
"Insert current date": "Inserisci la data corrente",
|
||||||
"Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw",
|
"Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw",
|
||||||
"Multiple": "Multiplo",
|
"Multiple": "Multiplo",
|
||||||
"Turn into": "Trasforma in",
|
|
||||||
"Text align": "Allinea testo",
|
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
|
||||||
"Go to homepage": "Go to homepage",
|
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
|
||||||
"Heading {{level}}": "Intestazione {{level}}",
|
"Heading {{level}}": "Intestazione {{level}}",
|
||||||
"Toggle title": "Attiva/disattiva titolo",
|
"Toggle title": "Attiva/disattiva titolo",
|
||||||
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
|
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
|
||||||
@@ -412,21 +405,6 @@
|
|||||||
"Share deleted successfully": "Condivisione eliminata con successo",
|
"Share deleted successfully": "Condivisione eliminata con successo",
|
||||||
"Share not found": "Condivisione non trovata",
|
"Share not found": "Condivisione non trovata",
|
||||||
"Failed to share page": "Condivisione della pagina fallita",
|
"Failed to share page": "Condivisione della pagina fallita",
|
||||||
"Disable public sharing": "Disabilita la condivisione pubblica",
|
|
||||||
"Prevent members from sharing pages publicly.": "Impedisci ai membri di condividere pubblicamente le pagine.",
|
|
||||||
"Toggle public sharing": "Attiva/disattiva la condivisione pubblica",
|
|
||||||
"Toggle space public sharing": "Attiva/disattiva la condivisione pubblica nello spazio",
|
|
||||||
"Public sharing is disabled at the workspace level": "La condivisione pubblica è disabilitata a livello di area di lavoro",
|
|
||||||
"Prevent pages in this space from being shared publicly.": "Impedisci che le pagine in questo spazio vengano condivise pubblicamente.",
|
|
||||||
"Requires an enterprise license": "Richiede una licenza enterprise",
|
|
||||||
"Enable public sharing": "Abilita la condivisione pubblica",
|
|
||||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Sei sicuro di voler abilitare la condivisione pubblica? I membri potranno condividere le pagine pubblicamente.",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Sei sicuro di voler disabilitare la condivisione pubblica? Tutti i link condivisi esistenti in questa area di lavoro verranno eliminati.",
|
|
||||||
"Are you sure you want to enable public sharing for this space?": "Sei sicuro di voler abilitare la condivisione pubblica per questo spazio?",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Sei sicuro di voler disabilitare la condivisione pubblica? Tutti i link condivisi esistenti in questo spazio verranno eliminati.",
|
|
||||||
"Public sharing is disabled": "La condivisione pubblica è disabilitata",
|
|
||||||
"Public sharing has been disabled at the workspace level.": "La condivisione pubblica è stata disabilitata a livello di area di lavoro.",
|
|
||||||
"Public sharing has been disabled for this space.": "La condivisione pubblica è stata disabilitata per questo spazio.",
|
|
||||||
"Copy page": "Copia pagina",
|
"Copy page": "Copia pagina",
|
||||||
"Copy page to a different space.": "Copia pagina in un altro spazio.",
|
"Copy page to a different space.": "Copia pagina in un altro spazio.",
|
||||||
"Page copied successfully": "Pagina copiata con successo",
|
"Page copied successfully": "Pagina copiata con successo",
|
||||||
@@ -587,33 +565,13 @@
|
|||||||
"Ask AI": "Chiedi all'AI",
|
"Ask AI": "Chiedi all'AI",
|
||||||
"AI is thinking...": "L'AI sta pensando...",
|
"AI is thinking...": "L'AI sta pensando...",
|
||||||
"Ask a question...": "Fai una domanda...",
|
"Ask a question...": "Fai una domanda...",
|
||||||
"AI Answers": "Risposte AI",
|
"AI-powered search (Ask AI)": "Ricerca potenziata dall'AI (Chiedi all'AI)",
|
||||||
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte 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.",
|
"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",
|
"Toggle AI search": "Attiva/disattiva ricerca AI",
|
||||||
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
|
|
||||||
"Toggle generative AI": "Attiva/Disattiva AI generativa",
|
|
||||||
"Sources": "Fonti",
|
"Sources": "Fonti",
|
||||||
"AI Answers not available for attachments": "Risposte AI non disponibili per gli allegati",
|
"Ask AI not available for attachments": "Chiedere all'AI non è disponibile per gli allegati",
|
||||||
"No answer available": "Nessuna risposta disponibile",
|
"No answer available": "Nessuna risposta disponibile",
|
||||||
"Background color": "Colore di sfondo",
|
"Background color": "Colore di sfondo",
|
||||||
"Highlight color": "Colore evidenziato",
|
"Highlight color": "Colore evidenziato",
|
||||||
"Remove color": "Rimuovi colore",
|
"Remove color": "Rimuovi colore"
|
||||||
"Notifications": "Notifiche",
|
|
||||||
"No notifications": "Nessuna notifica",
|
|
||||||
"No unread notifications": "Nessuna notifica non letta",
|
|
||||||
"All notifications": "Tutte le notifiche",
|
|
||||||
"Unread only": "Solo non lette",
|
|
||||||
"Mark all as read": "Segna tutto come letto",
|
|
||||||
"Mark as read": "Segna come letto",
|
|
||||||
"More options": "Altre opzioni",
|
|
||||||
"mentioned you in a comment": "ti ha menzionato in un commento",
|
|
||||||
"commented on a page": "ha commentato una pagina",
|
|
||||||
"resolved a comment": "ha risolto un commento",
|
|
||||||
"mentioned you on a page": "ti ha menzionato in una pagina",
|
|
||||||
"Today": "Oggi",
|
|
||||||
"Yesterday": "Ieri",
|
|
||||||
"This week": "Questa settimana",
|
|
||||||
"Older": "Più vecchie"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,8 +123,6 @@
|
|||||||
"page": "ページ",
|
"page": "ページ",
|
||||||
"Page deleted successfully": "ページを削除しました",
|
"Page deleted successfully": "ページを削除しました",
|
||||||
"Page history": "ページ履歴",
|
"Page history": "ページ履歴",
|
||||||
"Select version": "バージョンを選択",
|
|
||||||
"Highlight changes": "変更を強調表示",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください",
|
"Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください",
|
||||||
"Pages": "ページ",
|
"Pages": "ページ",
|
||||||
"pages": "ページ",
|
"pages": "ページ",
|
||||||
@@ -355,11 +353,6 @@
|
|||||||
"Insert current date": "現在の日付を挿入します",
|
"Insert current date": "現在の日付を挿入します",
|
||||||
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
|
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
|
||||||
"Multiple": "複数",
|
"Multiple": "複数",
|
||||||
"Turn into": "変換する",
|
|
||||||
"Text align": "テキストの配置",
|
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
|
||||||
"Go to homepage": "Go to homepage",
|
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
|
||||||
"Heading {{level}}": "見出し {{level}}",
|
"Heading {{level}}": "見出し {{level}}",
|
||||||
"Toggle title": "タイトルの表示/非表示を切り替える",
|
"Toggle title": "タイトルの表示/非表示を切り替える",
|
||||||
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
|
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
|
||||||
@@ -412,21 +405,6 @@
|
|||||||
"Share deleted successfully": "共有を削除しました",
|
"Share deleted successfully": "共有を削除しました",
|
||||||
"Share not found": "共有が見つかりません",
|
"Share not found": "共有が見つかりません",
|
||||||
"Failed to share page": "ページの共有に失敗しました",
|
"Failed to share page": "ページの共有に失敗しました",
|
||||||
"Disable public sharing": "公開共有を無効にする",
|
|
||||||
"Prevent members from sharing pages publicly.": "メンバーがページを公開で共有するのを防ぐ。",
|
|
||||||
"Toggle public sharing": "公開共有を切り替える",
|
|
||||||
"Toggle space public sharing": "スペースの公開共有を切り替える",
|
|
||||||
"Public sharing is disabled at the workspace level": "ワークスペースレベルで公開共有が無効になっています",
|
|
||||||
"Prevent pages in this space from being shared publicly.": "このスペース内のページが公開で共有されるのを防ぐ。",
|
|
||||||
"Requires an enterprise license": "エンタープライズライセンスが必要です",
|
|
||||||
"Enable public sharing": "公開共有を有効にする",
|
|
||||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "本当に公開共有を有効にしますか?メンバーはページを公開で共有できるようになります。",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "本当に公開共有を無効にしますか?このワークスペース内のすべての既存の共有リンクが削除されます。",
|
|
||||||
"Are you sure you want to enable public sharing for this space?": "本当にこのスペースの公開共有を有効にしますか?",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "本当に公開共有を無効にしますか?このスペースのすべての既存の共有リンクが削除されます。",
|
|
||||||
"Public sharing is disabled": "公開共有が無効になっています",
|
|
||||||
"Public sharing has been disabled at the workspace level.": "ワークスペースレベルで公開共有が無効になりました。",
|
|
||||||
"Public sharing has been disabled for this space.": "このスペースで公開共有が無効になりました。",
|
|
||||||
"Copy page": "ページをコピー",
|
"Copy page": "ページをコピー",
|
||||||
"Copy page to a different space.": "ページを別のスペースにコピーします",
|
"Copy page to a different space.": "ページを別のスペースにコピーします",
|
||||||
"Page copied successfully": "ページをコピーしました",
|
"Page copied successfully": "ページをコピーしました",
|
||||||
@@ -587,33 +565,13 @@
|
|||||||
"Ask AI": "AIに質問する",
|
"Ask AI": "AIに質問する",
|
||||||
"AI is thinking...": "AIが考え中...",
|
"AI is thinking...": "AIが考え中...",
|
||||||
"Ask a question...": "質問を入力...",
|
"Ask a question...": "質問を入力...",
|
||||||
"AI Answers": "AI回答",
|
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
|
||||||
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
|
|
||||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
|
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
|
||||||
"Toggle AI search": "AI検索を切り替え",
|
"Toggle AI search": "AI検索を切り替え",
|
||||||
"Generative AI (Ask AI)": "生成AI (Ask AI)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
|
|
||||||
"Toggle generative AI": "生成AIを切り替える",
|
|
||||||
"Sources": "ソース",
|
"Sources": "ソース",
|
||||||
"AI Answers not available for attachments": "添付ファイルにはAI回答を利用できません",
|
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
|
||||||
"No answer available": "回答がありません",
|
"No answer available": "回答がありません",
|
||||||
"Background color": "背景色",
|
"Background color": "背景色",
|
||||||
"Highlight color": "ハイライト色",
|
"Highlight color": "ハイライト色",
|
||||||
"Remove color": "色を削除",
|
"Remove color": "色を削除"
|
||||||
"Notifications": "通知",
|
|
||||||
"No notifications": "通知なし",
|
|
||||||
"No unread notifications": "未読の通知はありません",
|
|
||||||
"All notifications": "すべての通知",
|
|
||||||
"Unread only": "未読のみ",
|
|
||||||
"Mark all as read": "すべてを既読にする",
|
|
||||||
"Mark as read": "既読にする",
|
|
||||||
"More options": "その他のオプション",
|
|
||||||
"mentioned you in a comment": "コメントであなたに言及しました",
|
|
||||||
"commented on a page": "ページにコメントしました",
|
|
||||||
"resolved a comment": "コメントを解決しました",
|
|
||||||
"mentioned you on a page": "ページ上であなたに言及しました",
|
|
||||||
"Today": "今日",
|
|
||||||
"Yesterday": "昨日",
|
|
||||||
"This week": "今週",
|
|
||||||
"Older": "以前のもの"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,8 +123,6 @@
|
|||||||
"page": "페이지",
|
"page": "페이지",
|
||||||
"Page deleted successfully": "페이지 삭제 완료",
|
"Page deleted successfully": "페이지 삭제 완료",
|
||||||
"Page history": "페이지 기록",
|
"Page history": "페이지 기록",
|
||||||
"Select version": "버전 선택",
|
|
||||||
"Highlight changes": "변경 사항 강조",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.",
|
"Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.",
|
||||||
"Pages": "페이지",
|
"Pages": "페이지",
|
||||||
"pages": "페이지",
|
"pages": "페이지",
|
||||||
@@ -355,11 +353,6 @@
|
|||||||
"Insert current date": "현재 날짜 삽입",
|
"Insert current date": "현재 날짜 삽입",
|
||||||
"Draw and sketch excalidraw diagrams": "Excalidraw diagram 그리기 및 스케치",
|
"Draw and sketch excalidraw diagrams": "Excalidraw diagram 그리기 및 스케치",
|
||||||
"Multiple": "복제",
|
"Multiple": "복제",
|
||||||
"Turn into": "변경하기",
|
|
||||||
"Text align": "텍스트 정렬",
|
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
|
||||||
"Go to homepage": "Go to homepage",
|
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
|
||||||
"Heading {{level}}": "제목 {{level}}",
|
"Heading {{level}}": "제목 {{level}}",
|
||||||
"Toggle title": "제목 토글",
|
"Toggle title": "제목 토글",
|
||||||
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
|
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
|
||||||
@@ -412,21 +405,6 @@
|
|||||||
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
|
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
|
||||||
"Share not found": "공유를 찾을 수 없습니다",
|
"Share not found": "공유를 찾을 수 없습니다",
|
||||||
"Failed to share page": "페이지 공유에 실패했습니다",
|
"Failed to share page": "페이지 공유에 실패했습니다",
|
||||||
"Disable public sharing": "공유 비활성화",
|
|
||||||
"Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.",
|
|
||||||
"Toggle public sharing": "공유 전환",
|
|
||||||
"Toggle space public sharing": "공간 공유 전환",
|
|
||||||
"Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
|
|
||||||
"Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.",
|
|
||||||
"Requires an enterprise license": "기업 라이센스가 필요합니다.",
|
|
||||||
"Enable public sharing": "공유 활성화",
|
|
||||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공유를 활성화하시겠습니까? 멤버들이 페이지를 공개적으로 공유할 수 있게 됩니다.",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 워크스페이스의 모든 기존 공유 링크가 삭제됩니다.",
|
|
||||||
"Are you sure you want to enable public sharing for this space?": "이 공간의 공유를 활성화하시겠습니까?",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 공간의 모든 기존 공유 링크가 삭제됩니다.",
|
|
||||||
"Public sharing is disabled": "공유가 비활성화되었습니다.",
|
|
||||||
"Public sharing has been disabled at the workspace level.": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
|
|
||||||
"Public sharing has been disabled for this space.": "이 공간의 공유가 비활성화되었습니다.",
|
|
||||||
"Copy page": "페이지 복사하기",
|
"Copy page": "페이지 복사하기",
|
||||||
"Copy page to a different space.": "다른 공간으로 페이지 복사하기.",
|
"Copy page to a different space.": "다른 공간으로 페이지 복사하기.",
|
||||||
"Page copied successfully": "페이지가 성공적으로 복사되었습니다",
|
"Page copied successfully": "페이지가 성공적으로 복사되었습니다",
|
||||||
@@ -587,33 +565,13 @@
|
|||||||
"Ask AI": "AI에게 묻기",
|
"Ask AI": "AI에게 묻기",
|
||||||
"AI is thinking...": "AI가 생각 중입니다...",
|
"AI is thinking...": "AI가 생각 중입니다...",
|
||||||
"Ask a question...": "질문하세요...",
|
"Ask a question...": "질문하세요...",
|
||||||
"AI Answers": "AI 답변",
|
"AI-powered search (Ask AI)": "AI 지원 검색 (AI에게 묻기)",
|
||||||
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
|
|
||||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
|
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
|
||||||
"Toggle AI search": "AI 검색 전환",
|
"Toggle AI search": "AI 검색 전환",
|
||||||
"Generative AI (Ask AI)": "생성 AI (Ask AI)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
|
|
||||||
"Toggle generative AI": "생성 AI 토글",
|
|
||||||
"Sources": "출처",
|
"Sources": "출처",
|
||||||
"AI Answers not available for attachments": "첨부 파일에 대해 AI 답변을 사용할 수 없습니다",
|
"Ask AI not available for attachments": "AI에게 묻기 기능은 첨부 파일에 대해 사용할 수 없습니다",
|
||||||
"No answer available": "답변을 제공할 수 없습니다",
|
"No answer available": "답변을 제공할 수 없습니다",
|
||||||
"Background color": "배경 색",
|
"Background color": "배경 색",
|
||||||
"Highlight color": "강조 색",
|
"Highlight color": "강조 색",
|
||||||
"Remove color": "색 제거",
|
"Remove color": "색 제거"
|
||||||
"Notifications": "알림",
|
|
||||||
"No notifications": "알림 없음",
|
|
||||||
"No unread notifications": "읽지 않은 알림 없음",
|
|
||||||
"All notifications": "모든 알림",
|
|
||||||
"Unread only": "읽지 않음만",
|
|
||||||
"Mark all as read": "모두 읽음으로 표시",
|
|
||||||
"Mark as read": "읽음으로 표시",
|
|
||||||
"More options": "추가 옵션",
|
|
||||||
"mentioned you in a comment": "댓글에서 당신을 언급했습니다",
|
|
||||||
"commented on a page": "페이지에 댓글을 달았습니다",
|
|
||||||
"resolved a comment": "댓글을 해결했습니다",
|
|
||||||
"mentioned you on a page": "페이지에서 당신을 언급했습니다",
|
|
||||||
"Today": "오늘",
|
|
||||||
"Yesterday": "어제",
|
|
||||||
"This week": "이번 주",
|
|
||||||
"Older": "이전"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,8 +123,6 @@
|
|||||||
"page": "pagina",
|
"page": "pagina",
|
||||||
"Page deleted successfully": "Pagina succesvol verwijderd",
|
"Page deleted successfully": "Pagina succesvol verwijderd",
|
||||||
"Page history": "Pagina geschiedenis",
|
"Page history": "Pagina geschiedenis",
|
||||||
"Select version": "Selecteer versie",
|
|
||||||
"Highlight changes": "Wijzigingen markeren",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.",
|
"Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.",
|
||||||
"Pages": "Pagina's",
|
"Pages": "Pagina's",
|
||||||
"pages": "pagina's",
|
"pages": "pagina's",
|
||||||
@@ -355,11 +353,6 @@
|
|||||||
"Insert current date": "Huidige datum invoeren",
|
"Insert current date": "Huidige datum invoeren",
|
||||||
"Draw and sketch excalidraw diagrams": "Teken en schets excalidraw diagrammen",
|
"Draw and sketch excalidraw diagrams": "Teken en schets excalidraw diagrammen",
|
||||||
"Multiple": "Meerdere",
|
"Multiple": "Meerdere",
|
||||||
"Turn into": "Omzetten naar",
|
|
||||||
"Text align": "Tekstuitlijning",
|
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
|
||||||
"Go to homepage": "Go to homepage",
|
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
|
||||||
"Heading {{level}}": "Kop {{level}}",
|
"Heading {{level}}": "Kop {{level}}",
|
||||||
"Toggle title": "Schakel titel in/uit",
|
"Toggle title": "Schakel titel in/uit",
|
||||||
"Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's",
|
"Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's",
|
||||||
@@ -412,21 +405,6 @@
|
|||||||
"Share deleted successfully": "Delen succesvol verwijderd",
|
"Share deleted successfully": "Delen succesvol verwijderd",
|
||||||
"Share not found": "Delen niet gevonden",
|
"Share not found": "Delen niet gevonden",
|
||||||
"Failed to share page": "Pagina delen mislukt",
|
"Failed to share page": "Pagina delen mislukt",
|
||||||
"Disable public sharing": "Openbaar delen uitschakelen",
|
|
||||||
"Prevent members from sharing pages publicly.": "Voorkom dat leden pagina's openbaar delen.",
|
|
||||||
"Toggle public sharing": "Wissel openbaar delen",
|
|
||||||
"Toggle space public sharing": "Wissel openbaar delen van ruimte",
|
|
||||||
"Public sharing is disabled at the workspace level": "Openbaar delen is uitgeschakeld op werkruimteniveau",
|
|
||||||
"Prevent pages in this space from being shared publicly.": "Voorkom dat pagina's in deze ruimte openbaar worden gedeeld.",
|
|
||||||
"Requires an enterprise license": "Vereist een bedrijfslicentie",
|
|
||||||
"Enable public sharing": "Openbaar delen inschakelen",
|
|
||||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Weet je zeker dat je openbaar delen wilt inschakelen? Leden kunnen pagina's openbaar delen.",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Weet je zeker dat je openbaar delen wilt uitschakelen? Alle bestaande gedeelde links in deze werkruimte zullen worden verwijderd.",
|
|
||||||
"Are you sure you want to enable public sharing for this space?": "Weet je zeker dat je openbaar delen voor deze ruimte wilt inschakelen?",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Weet je zeker dat je openbaar delen wilt uitschakelen? Alle bestaande gedeelde links in deze ruimte zullen worden verwijderd.",
|
|
||||||
"Public sharing is disabled": "Openbaar delen is uitgeschakeld",
|
|
||||||
"Public sharing has been disabled at the workspace level.": "Openbaar delen is uitgeschakeld op werkruimteniveau.",
|
|
||||||
"Public sharing has been disabled for this space.": "Openbaar delen is uitgeschakeld voor deze ruimte.",
|
|
||||||
"Copy page": "Pagina kopiëren",
|
"Copy page": "Pagina kopiëren",
|
||||||
"Copy page to a different space.": "Kopieer pagina naar een andere ruimte.",
|
"Copy page to a different space.": "Kopieer pagina naar een andere ruimte.",
|
||||||
"Page copied successfully": "Pagina succesvol gekopieerd",
|
"Page copied successfully": "Pagina succesvol gekopieerd",
|
||||||
@@ -587,33 +565,13 @@
|
|||||||
"Ask AI": "Vraag AI",
|
"Ask AI": "Vraag AI",
|
||||||
"AI is thinking...": "AI is aan het nadenken...",
|
"AI is thinking...": "AI is aan het nadenken...",
|
||||||
"Ask a question...": "Stel een vraag...",
|
"Ask a question...": "Stel een vraag...",
|
||||||
"AI Answers": "AI Antwoorden",
|
"AI-powered search (Ask AI)": "AI-ondersteunde zoekopdracht (Vraag AI)",
|
||||||
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
|
|
||||||
"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.",
|
"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",
|
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
|
||||||
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.",
|
|
||||||
"Toggle generative AI": "Generatieve AI schakelen",
|
|
||||||
"Sources": "Bronnen",
|
"Sources": "Bronnen",
|
||||||
"AI Answers not available for attachments": "AI Antwoorden niet beschikbaar voor bijlagen",
|
"Ask AI not available for attachments": "Vraag AI is niet beschikbaar voor bijlages",
|
||||||
"No answer available": "Geen antwoord beschikbaar",
|
"No answer available": "Geen antwoord beschikbaar",
|
||||||
"Background color": "Achtergrondkleur",
|
"Background color": "Achtergrondkleur",
|
||||||
"Highlight color": "Markeerkleur",
|
"Highlight color": "Markeerkleur",
|
||||||
"Remove color": "Kleur verwijderen",
|
"Remove color": "Kleur verwijderen"
|
||||||
"Notifications": "Meldingen",
|
|
||||||
"No notifications": "Geen meldingen",
|
|
||||||
"No unread notifications": "Geen ongelezen meldingen",
|
|
||||||
"All notifications": "Alle meldingen",
|
|
||||||
"Unread only": "Alleen ongelezen",
|
|
||||||
"Mark all as read": "Markeer alles als gelezen",
|
|
||||||
"Mark as read": "Markeer als gelezen",
|
|
||||||
"More options": "Meer opties",
|
|
||||||
"mentioned you in a comment": "noemde je in een reactie",
|
|
||||||
"commented on a page": "reageerde op een pagina",
|
|
||||||
"resolved a comment": "heeft een opmerking opgelost",
|
|
||||||
"mentioned you on a page": "noemde je op een pagina",
|
|
||||||
"Today": "Vandaag",
|
|
||||||
"Yesterday": "Gisteren",
|
|
||||||
"This week": "Deze week",
|
|
||||||
"Older": "Ouder"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,8 +123,6 @@
|
|||||||
"page": "página",
|
"page": "página",
|
||||||
"Page deleted successfully": "Página excluída com sucesso",
|
"Page deleted successfully": "Página excluída com sucesso",
|
||||||
"Page history": "Histórico da página",
|
"Page history": "Histórico da página",
|
||||||
"Select version": "Selecionar versão",
|
|
||||||
"Highlight changes": "Destacar alterações",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.",
|
"Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.",
|
||||||
"Pages": "Páginas",
|
"Pages": "Páginas",
|
||||||
"pages": "páginas",
|
"pages": "páginas",
|
||||||
@@ -355,11 +353,6 @@
|
|||||||
"Insert current date": "Insira a data atual",
|
"Insert current date": "Insira a data atual",
|
||||||
"Draw and sketch excalidraw diagrams": "Desenhe e esboce diagramas Excalidraw",
|
"Draw and sketch excalidraw diagrams": "Desenhe e esboce diagramas Excalidraw",
|
||||||
"Multiple": "Múltiplo",
|
"Multiple": "Múltiplo",
|
||||||
"Turn into": "Transformar em",
|
|
||||||
"Text align": "Alinhar texto",
|
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
|
||||||
"Go to homepage": "Go to homepage",
|
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
|
||||||
"Heading {{level}}": "Título {{level}}",
|
"Heading {{level}}": "Título {{level}}",
|
||||||
"Toggle title": "Alternar título",
|
"Toggle title": "Alternar título",
|
||||||
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
|
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
|
||||||
@@ -412,21 +405,6 @@
|
|||||||
"Share deleted successfully": "Compartilhamento excluído com sucesso",
|
"Share deleted successfully": "Compartilhamento excluído com sucesso",
|
||||||
"Share not found": "Compartilhamento não encontrado",
|
"Share not found": "Compartilhamento não encontrado",
|
||||||
"Failed to share page": "Falha ao compartilhar página",
|
"Failed to share page": "Falha ao compartilhar página",
|
||||||
"Disable public sharing": "Desativar compartilhamento público",
|
|
||||||
"Prevent members from sharing pages publicly.": "Impedir que os membros compartilhem páginas publicamente.",
|
|
||||||
"Toggle public sharing": "Alternar compartilhamento público",
|
|
||||||
"Toggle space public sharing": "Alternar compartilhamento público do espaço",
|
|
||||||
"Public sharing is disabled at the workspace level": "O compartilhamento público está desativado no nível do espaço de trabalho",
|
|
||||||
"Prevent pages in this space from being shared publicly.": "Impedir que as páginas neste espaço sejam compartilhadas publicamente.",
|
|
||||||
"Requires an enterprise license": "Requer uma licença empresarial",
|
|
||||||
"Enable public sharing": "Ativar compartilhamento público",
|
|
||||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Tem certeza de que deseja ativar o compartilhamento público? Os membros poderão compartilhar páginas publicamente.",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Tem certeza de que deseja desativar o compartilhamento público? Todos os links compartilhados existentes neste espaço de trabalho serão excluídos.",
|
|
||||||
"Are you sure you want to enable public sharing for this space?": "Tem certeza de que deseja ativar o compartilhamento público para este espaço?",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Tem certeza de que deseja desativar o compartilhamento público? Todos os links compartilhados existentes neste espaço serão excluídos.",
|
|
||||||
"Public sharing is disabled": "Compartilhamento público está desativado",
|
|
||||||
"Public sharing has been disabled at the workspace level.": "O compartilhamento público foi desativado no nível do espaço de trabalho.",
|
|
||||||
"Public sharing has been disabled for this space.": "O compartilhamento público foi desativado para este espaço.",
|
|
||||||
"Copy page": "Copiar página",
|
"Copy page": "Copiar página",
|
||||||
"Copy page to a different space.": "Copiar página para um espaço diferente.",
|
"Copy page to a different space.": "Copiar página para um espaço diferente.",
|
||||||
"Page copied successfully": "Página copiada com sucesso",
|
"Page copied successfully": "Página copiada com sucesso",
|
||||||
@@ -587,33 +565,13 @@
|
|||||||
"Ask AI": "Pergunte à IA",
|
"Ask AI": "Pergunte à IA",
|
||||||
"AI is thinking...": "IA está pensando...",
|
"AI is thinking...": "IA está pensando...",
|
||||||
"Ask a question...": "Faça uma pergunta...",
|
"Ask a question...": "Faça uma pergunta...",
|
||||||
"AI Answers": "Respostas de IA",
|
"AI-powered search (Ask AI)": "Pesquisa com IA (Pergunte à IA)",
|
||||||
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de 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.",
|
"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",
|
"Toggle AI search": "Alternar pesquisa de IA",
|
||||||
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
|
|
||||||
"Toggle generative AI": "Alternar IA generativa",
|
|
||||||
"Sources": "Fontes",
|
"Sources": "Fontes",
|
||||||
"AI Answers not available for attachments": "Respostas de IA não disponíveis para anexos",
|
"Ask AI not available for attachments": "Perguntar à IA não está disponível para anexos",
|
||||||
"No answer available": "Nenhuma resposta disponível",
|
"No answer available": "Nenhuma resposta disponível",
|
||||||
"Background color": "Cor de fundo",
|
"Background color": "Cor de fundo",
|
||||||
"Highlight color": "Cor de destaque",
|
"Highlight color": "Cor de destaque",
|
||||||
"Remove color": "Remover cor",
|
"Remove color": "Remover cor"
|
||||||
"Notifications": "Notificações",
|
|
||||||
"No notifications": "Sem notificações",
|
|
||||||
"No unread notifications": "Sem notificações não lidas",
|
|
||||||
"All notifications": "Todas as notificações",
|
|
||||||
"Unread only": "Somente não lidas",
|
|
||||||
"Mark all as read": "Marcar todas como lidas",
|
|
||||||
"Mark as read": "Marcar como lida",
|
|
||||||
"More options": "Mais opções",
|
|
||||||
"mentioned you in a comment": "mencionou você em um comentário",
|
|
||||||
"commented on a page": "comentou em uma página",
|
|
||||||
"resolved a comment": "resolveu um comentário",
|
|
||||||
"mentioned you on a page": "mencionou você em uma página",
|
|
||||||
"Today": "Hoje",
|
|
||||||
"Yesterday": "Ontem",
|
|
||||||
"This week": "Esta semana",
|
|
||||||
"Older": "Mais antigo"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,8 +123,6 @@
|
|||||||
"page": "страница",
|
"page": "страница",
|
||||||
"Page deleted successfully": "Страница успешно удалена",
|
"Page deleted successfully": "Страница успешно удалена",
|
||||||
"Page history": "История страницы",
|
"Page history": "История страницы",
|
||||||
"Select version": "Выбрать версию",
|
|
||||||
"Highlight changes": "Выделить изменения",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
|
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
|
||||||
"Pages": "Страницы",
|
"Pages": "Страницы",
|
||||||
"pages": "страницы",
|
"pages": "страницы",
|
||||||
@@ -355,11 +353,6 @@
|
|||||||
"Insert current date": "Вставить текущую дату",
|
"Insert current date": "Вставить текущую дату",
|
||||||
"Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw",
|
"Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw",
|
||||||
"Multiple": "Несколько",
|
"Multiple": "Несколько",
|
||||||
"Turn into": "Преобразовать в",
|
|
||||||
"Text align": "Выравнивание текста",
|
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
|
||||||
"Go to homepage": "Go to homepage",
|
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
|
||||||
"Heading {{level}}": "Заголовок {{level}}",
|
"Heading {{level}}": "Заголовок {{level}}",
|
||||||
"Toggle title": "Переключить заголовок",
|
"Toggle title": "Переключить заголовок",
|
||||||
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
|
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
|
||||||
@@ -412,21 +405,6 @@
|
|||||||
"Share deleted successfully": "Общий доступ успешно удален",
|
"Share deleted successfully": "Общий доступ успешно удален",
|
||||||
"Share not found": "Общий доступ не найден",
|
"Share not found": "Общий доступ не найден",
|
||||||
"Failed to share page": "Не удалось поделиться страницей",
|
"Failed to share page": "Не удалось поделиться страницей",
|
||||||
"Disable public sharing": "Отключить общий доступ",
|
|
||||||
"Prevent members from sharing pages publicly.": "Запретить участникам делиться страницами публично.",
|
|
||||||
"Toggle public sharing": "Переключить общий доступ",
|
|
||||||
"Toggle space public sharing": "Переключить общий доступ для пространства",
|
|
||||||
"Public sharing is disabled at the workspace level": "Общий доступ отключен на уровне рабочего пространства",
|
|
||||||
"Prevent pages in this space from being shared publicly.": "Запретить делиться страницами в этом пространстве публично.",
|
|
||||||
"Requires an enterprise license": "Требуется корпоративная лицензия",
|
|
||||||
"Enable public sharing": "Включить общий доступ",
|
|
||||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Вы уверены, что хотите включить общий доступ? Участники смогут делиться страницами публично.",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Вы уверены, что хотите отключить общий доступ? Все существующие ссылки в этом рабочем пространстве будут удалены.",
|
|
||||||
"Are you sure you want to enable public sharing for this space?": "Вы уверены, что хотите включить общий доступ для этого пространства?",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Вы уверены, что хотите отключить общий доступ? Все существующие ссылки в этом пространстве будут удалены.",
|
|
||||||
"Public sharing is disabled": "Общий доступ отключен",
|
|
||||||
"Public sharing has been disabled at the workspace level.": "Общий доступ был отключен на уровне рабочего пространства.",
|
|
||||||
"Public sharing has been disabled for this space.": "Общий доступ был отключен для этого пространства.",
|
|
||||||
"Copy page": "Копировать страницу",
|
"Copy page": "Копировать страницу",
|
||||||
"Copy page to a different space.": "Копировать страницу в другое пространство.",
|
"Copy page to a different space.": "Копировать страницу в другое пространство.",
|
||||||
"Page copied successfully": "Страница успешно скопирована",
|
"Page copied successfully": "Страница успешно скопирована",
|
||||||
@@ -587,33 +565,13 @@
|
|||||||
"Ask AI": "Спросить ИИ",
|
"Ask AI": "Спросить ИИ",
|
||||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||||
"Ask a question...": "Задайте вопрос...",
|
"Ask a question...": "Задайте вопрос...",
|
||||||
"AI Answers": "Ответы ИИ",
|
"AI-powered search (Ask AI)": "Поиск на базе ИИ (Спросить ИИ)",
|
||||||
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
|
|
||||||
"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": "Переключить поиск ИИ",
|
||||||
"Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
|
|
||||||
"Toggle generative AI": "Переключить генеративный ИИ",
|
|
||||||
"Sources": "Источники",
|
"Sources": "Источники",
|
||||||
"AI Answers 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": "Удалить цвет"
|
||||||
"Notifications": "Уведомления",
|
|
||||||
"No notifications": "Нет уведомлений",
|
|
||||||
"No unread notifications": "Нет непрочитанных уведомлений",
|
|
||||||
"All notifications": "Все уведомления",
|
|
||||||
"Unread only": "Только непрочитанные",
|
|
||||||
"Mark all as read": "Отметить все как прочитанные",
|
|
||||||
"Mark as read": "Отметить как прочитанное",
|
|
||||||
"More options": "Больше возможностей",
|
|
||||||
"mentioned you in a comment": "упомянул вас в комментарии",
|
|
||||||
"commented on a page": "прокомментировал на странице",
|
|
||||||
"resolved a comment": "разрешил комментарий",
|
|
||||||
"mentioned you on a page": "упомянул вас на странице",
|
|
||||||
"Today": "Сегодня",
|
|
||||||
"Yesterday": "Вчера",
|
|
||||||
"This week": "На этой неделе",
|
|
||||||
"Older": "Старше"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,8 +123,6 @@
|
|||||||
"page": "сторінка",
|
"page": "сторінка",
|
||||||
"Page deleted successfully": "Сторінку успішно видалено",
|
"Page deleted successfully": "Сторінку успішно видалено",
|
||||||
"Page history": "Історія сторінки",
|
"Page history": "Історія сторінки",
|
||||||
"Select version": "Вибрати версію",
|
|
||||||
"Highlight changes": "Підсвітити зміни",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
|
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
|
||||||
"Pages": "Сторінки",
|
"Pages": "Сторінки",
|
||||||
"pages": "сторінки",
|
"pages": "сторінки",
|
||||||
@@ -355,11 +353,6 @@
|
|||||||
"Insert current date": "Вставити поточну дату",
|
"Insert current date": "Вставити поточну дату",
|
||||||
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
|
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
|
||||||
"Multiple": "Декілька",
|
"Multiple": "Декілька",
|
||||||
"Turn into": "Перетворити",
|
|
||||||
"Text align": "Вирівнювання тексту",
|
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
|
||||||
"Go to homepage": "Go to homepage",
|
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
|
||||||
"Heading {{level}}": "Заголовок {{level}}",
|
"Heading {{level}}": "Заголовок {{level}}",
|
||||||
"Toggle title": "Перемкнути заголовок",
|
"Toggle title": "Перемкнути заголовок",
|
||||||
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
|
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
|
||||||
@@ -412,21 +405,6 @@
|
|||||||
"Share deleted successfully": "Спільний доступ успішно видалено",
|
"Share deleted successfully": "Спільний доступ успішно видалено",
|
||||||
"Share not found": "Спільний доступ не знайдено",
|
"Share not found": "Спільний доступ не знайдено",
|
||||||
"Failed to share page": "Не вдалося поділитися сторінкою",
|
"Failed to share page": "Не вдалося поділитися сторінкою",
|
||||||
"Disable public sharing": "Вимкнути публічний доступ",
|
|
||||||
"Prevent members from sharing pages publicly.": "Перешкодити учасникам публічно ділитися сторінками.",
|
|
||||||
"Toggle public sharing": "Перемикання публічного доступу",
|
|
||||||
"Toggle space public sharing": "Перемикання публічного доступу до просторів",
|
|
||||||
"Public sharing is disabled at the workspace level": "Публічний доступ вимкнуто на рівні робочого простору",
|
|
||||||
"Prevent pages in this space from being shared publicly.": "Перешкодити публічному поширенню сторінок у цьому просторі.",
|
|
||||||
"Requires an enterprise license": "Потребує корпоративної ліцензії",
|
|
||||||
"Enable public sharing": "Увімкнути публічний доступ",
|
|
||||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Ви впевнені, що хочете увімкнути публічний доступ? Учасники зможуть публічно ділитися сторінками.",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Ви впевнені, що хочете вимкнути публічний доступ? Усі існуючі посилання для спільного доступу в цьому робочому просторі будуть видалені.",
|
|
||||||
"Are you sure you want to enable public sharing for this space?": "Ви впевнені, що хочете увімкнути публічний доступ для цього простору?",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Ви впевнені, що хочете вимкнути публічний доступ? Усі існуючі посилання для спільного доступу в цьому просторі будуть видалені.",
|
|
||||||
"Public sharing is disabled": "Публічний доступ вимкнуто",
|
|
||||||
"Public sharing has been disabled at the workspace level.": "Публічний доступ було вимкнено на рівні робочого простору.",
|
|
||||||
"Public sharing has been disabled for this space.": "Публічний доступ було вимкнено для цього простору.",
|
|
||||||
"Copy page": "Копіювати сторінки",
|
"Copy page": "Копіювати сторінки",
|
||||||
"Copy page to a different space.": "Скопіювати сторінку в інший простір.",
|
"Copy page to a different space.": "Скопіювати сторінку в інший простір.",
|
||||||
"Page copied successfully": "Сторінку успішно скопійовано",
|
"Page copied successfully": "Сторінку успішно скопійовано",
|
||||||
@@ -587,33 +565,13 @@
|
|||||||
"Ask AI": "Запитати ШІ",
|
"Ask AI": "Запитати ШІ",
|
||||||
"AI is thinking...": "ШІ думає...",
|
"AI is thinking...": "ШІ думає...",
|
||||||
"Ask a question...": "Задайте питання...",
|
"Ask a question...": "Задайте питання...",
|
||||||
"AI Answers": "Відповіді ШІ",
|
"AI-powered search (Ask AI)": "Пошук на базі ШІ (Запитати ШІ)",
|
||||||
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
|
|
||||||
"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": "Переключити пошук з ШІ",
|
||||||
"Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
|
|
||||||
"Toggle generative AI": "Переключити генеративний ШІ",
|
|
||||||
"Sources": "Джерела",
|
"Sources": "Джерела",
|
||||||
"AI Answers 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": "Видалити колір"
|
||||||
"Notifications": "Сповіщення",
|
|
||||||
"No notifications": "Немає сповіщень",
|
|
||||||
"No unread notifications": "Немає непрочитаних сповіщень",
|
|
||||||
"All notifications": "Усі сповіщення",
|
|
||||||
"Unread only": "Тільки непрочитані",
|
|
||||||
"Mark all as read": "Позначити все як прочитане",
|
|
||||||
"Mark as read": "Позначити як прочитане",
|
|
||||||
"More options": "Більше опцій",
|
|
||||||
"mentioned you in a comment": "згадали вас у коментарі",
|
|
||||||
"commented on a page": "прокоментували на сторінці",
|
|
||||||
"resolved a comment": "вирішили коментар",
|
|
||||||
"mentioned you on a page": "згадали вас на сторінці",
|
|
||||||
"Today": "Сьогодні",
|
|
||||||
"Yesterday": "Вчора",
|
|
||||||
"This week": "Цього тижня",
|
|
||||||
"Older": "Старіші"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,8 +123,6 @@
|
|||||||
"page": "个页面",
|
"page": "个页面",
|
||||||
"Page deleted successfully": "页面已成功删除",
|
"Page deleted successfully": "页面已成功删除",
|
||||||
"Page history": "页面历史",
|
"Page history": "页面历史",
|
||||||
"Select version": "选择版本",
|
|
||||||
"Highlight changes": "突出显示更改",
|
|
||||||
"Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。",
|
"Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。",
|
||||||
"Pages": "页面",
|
"Pages": "页面",
|
||||||
"pages": "个页面",
|
"pages": "个页面",
|
||||||
@@ -355,11 +353,6 @@
|
|||||||
"Insert current date": "插入当前日期",
|
"Insert current date": "插入当前日期",
|
||||||
"Draw and sketch excalidraw diagrams": "绘制 Excalidraw 图表",
|
"Draw and sketch excalidraw diagrams": "绘制 Excalidraw 图表",
|
||||||
"Multiple": "多个",
|
"Multiple": "多个",
|
||||||
"Turn into": "变成",
|
|
||||||
"Text align": "文本对齐",
|
|
||||||
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
|
|
||||||
"Go to homepage": "Go to homepage",
|
|
||||||
"Pages you create will show up here.": "Pages you create will show up here.",
|
|
||||||
"Heading {{level}}": "{{level}} 级标题",
|
"Heading {{level}}": "{{level}} 级标题",
|
||||||
"Toggle title": "切换标题",
|
"Toggle title": "切换标题",
|
||||||
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
|
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
|
||||||
@@ -412,21 +405,6 @@
|
|||||||
"Share deleted successfully": "分享已成功删除",
|
"Share deleted successfully": "分享已成功删除",
|
||||||
"Share not found": "未找到分享",
|
"Share not found": "未找到分享",
|
||||||
"Failed to share page": "页面分享失败",
|
"Failed to share page": "页面分享失败",
|
||||||
"Disable public sharing": "禁用公开分享",
|
|
||||||
"Prevent members from sharing pages publicly.": "阻止成员公开分享页面。",
|
|
||||||
"Toggle public sharing": "切换公开分享",
|
|
||||||
"Toggle space public sharing": "切换空间公开分享",
|
|
||||||
"Public sharing is disabled at the workspace level": "公开分享在工作区级别被禁用",
|
|
||||||
"Prevent pages in this space from being shared publicly.": "阻止此空间中的页面被公开分享。",
|
|
||||||
"Requires an enterprise license": "需要企业许可证",
|
|
||||||
"Enable public sharing": "启用公开分享",
|
|
||||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "您确定要启用公开分享吗?成员将能够公开分享页面。",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "您确定要禁用公开分享吗?此工作区中的所有现有共享链接都将被删除。",
|
|
||||||
"Are you sure you want to enable public sharing for this space?": "您确定要为此空间启用公开分享吗?",
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "您确定要禁用公开分享吗?此空间中的所有现有共享链接都将被删除。",
|
|
||||||
"Public sharing is disabled": "公开分享已被禁用",
|
|
||||||
"Public sharing has been disabled at the workspace level.": "公开分享已在工作区级别被禁用。",
|
|
||||||
"Public sharing has been disabled for this space.": "此空间的公开分享已被禁用。",
|
|
||||||
"Copy page": "复制页面",
|
"Copy page": "复制页面",
|
||||||
"Copy page to a different space.": "将页面复制到不同的空间。",
|
"Copy page to a different space.": "将页面复制到不同的空间。",
|
||||||
"Page copied successfully": "页面复制成功",
|
"Page copied successfully": "页面复制成功",
|
||||||
@@ -587,33 +565,13 @@
|
|||||||
"Ask AI": "询问AI",
|
"Ask AI": "询问AI",
|
||||||
"AI is thinking...": "AI正在思考...",
|
"AI is thinking...": "AI正在思考...",
|
||||||
"Ask a question...": "提问...",
|
"Ask a question...": "提问...",
|
||||||
"AI Answers": "AI答案",
|
"AI-powered search (Ask AI)": "AI驱动的搜索(询问AI)",
|
||||||
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
|
|
||||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
|
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
|
||||||
"Toggle AI search": "切换AI搜索",
|
"Toggle AI search": "切换AI搜索",
|
||||||
"Generative AI (Ask AI)": "生成型AI (询问AI)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
|
|
||||||
"Toggle generative AI": "切换生成型AI",
|
|
||||||
"Sources": "来源",
|
"Sources": "来源",
|
||||||
"AI Answers not available for attachments": "AI答案不适用于附件",
|
"Ask AI not available for attachments": "附件不支持询问AI",
|
||||||
"No answer available": "无可用答案",
|
"No answer available": "无可用答案",
|
||||||
"Background color": "背景颜色",
|
"Background color": "背景颜色",
|
||||||
"Highlight color": "突出显示颜色",
|
"Highlight color": "突出显示颜色",
|
||||||
"Remove color": "移除颜色",
|
"Remove color": "移除颜色"
|
||||||
"Notifications": "通知",
|
|
||||||
"No notifications": "没有通知",
|
|
||||||
"No unread notifications": "没有未读通知",
|
|
||||||
"All notifications": "所有通知",
|
|
||||||
"Unread only": "仅未读",
|
|
||||||
"Mark all as read": "标记所有为已读",
|
|
||||||
"Mark as read": "标记为已读",
|
|
||||||
"More options": "更多选项",
|
|
||||||
"mentioned you in a comment": "在评论中提到你",
|
|
||||||
"commented on a page": "在页面上评论",
|
|
||||||
"resolved a comment": "解决了一个评论",
|
|
||||||
"mentioned you on a page": "在页面上提到你",
|
|
||||||
"Today": "今天",
|
|
||||||
"Yesterday": "昨天",
|
|
||||||
"This week": "本周",
|
|
||||||
"Older": "较早"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import AccountPreferences from "@/pages/settings/account/account-preferences.tsx
|
|||||||
import SpaceHome from "@/pages/space/space-home.tsx";
|
import SpaceHome from "@/pages/space/space-home.tsx";
|
||||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||||
import Layout from "@/components/layouts/global/layout.tsx";
|
import Layout from "@/components/layouts/global/layout.tsx";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||||
import PasswordReset from "./pages/auth/password-reset";
|
import PasswordReset from "./pages/auth/password-reset";
|
||||||
@@ -83,7 +84,13 @@ export default function App() {
|
|||||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||||
<Route
|
<Route
|
||||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||||
element={<Page />}
|
element={
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={<>{t("Failed to load page. An error occurred.")}</>}
|
||||||
|
>
|
||||||
|
<Page />
|
||||||
|
</ErrorBoundary>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path={"/settings"}>
|
<Route path={"/settings"}>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT
|
|
||||||
// modified to use the polyfilled clipboard api
|
|
||||||
import React from "react";
|
|
||||||
import { useClipboard } from "@/hooks/use-clipboard";
|
|
||||||
import { useProps } from "@mantine/core";
|
|
||||||
|
|
||||||
interface CopyButtonProps {
|
|
||||||
/** Children callback, provides current status and copy function as an argument */
|
|
||||||
children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode;
|
|
||||||
|
|
||||||
/** Value that is copied to the clipboard when the button is clicked */
|
|
||||||
value: string;
|
|
||||||
|
|
||||||
/** Copied status timeout in ms @default `1000` */
|
|
||||||
timeout?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
timeout: 1000,
|
|
||||||
} satisfies Partial<CopyButtonProps>;
|
|
||||||
|
|
||||||
export function CopyButton(props: CopyButtonProps) {
|
|
||||||
const { children, timeout, value, ...others } = useProps(
|
|
||||||
"CopyButton",
|
|
||||||
defaultProps,
|
|
||||||
props,
|
|
||||||
);
|
|
||||||
const clipboard = useClipboard({ timeout });
|
|
||||||
const copy = () => clipboard.copy(value);
|
|
||||||
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyButton.displayName = "@mantine/core/CopyButton";
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
|
||||||
import { CopyButton } from "@/components/common/copy-button";
|
|
||||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
|||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { formattedDate } from "@/lib/time.ts";
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
|
||||||
import { getSpaceUrl } from "@/lib/config.ts";
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getInitialsColor } from "@/lib/get-initials-color.ts";
|
import { getInitialsColor } from "@/lib/get-initials-color.ts";
|
||||||
@@ -86,10 +85,8 @@ export default function RecentChanges({ spaceId }: Props) {
|
|||||||
</Table>
|
</Table>
|
||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<Text size="md" ta="center">
|
||||||
icon={IconFiles}
|
{t("No pages yet")}
|
||||||
title={t("No pages yet")}
|
</Text>
|
||||||
description={t("Pages you create will show up here.")}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { rem } from "@mantine/core";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
size?: number | string;
|
|
||||||
stroke?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function IconColumns4({ size = 24, stroke = 2 }: Props) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={rem(size)}
|
|
||||||
height={rem(size)}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={stroke}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
|
||||||
<path d="M7.5 3v18" />
|
|
||||||
<path d="M12 3v18" />
|
|
||||||
<path d="M16.5 3v18" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { rem } from "@mantine/core";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
size?: number | string;
|
|
||||||
stroke?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function IconColumns5({ size = 24, stroke = 2 }: Props) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={rem(size)}
|
|
||||||
height={rem(size)}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={stroke}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
|
||||||
<path d="M6.6 3v18" />
|
|
||||||
<path d="M10.2 3v18" />
|
|
||||||
<path d="M13.8 3v18" />
|
|
||||||
<path d="M17.4 3v18" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
searchSpotlight,
|
searchSpotlight,
|
||||||
shareSearchSpotlight,
|
shareSearchSpotlight,
|
||||||
} from "@/features/search/constants.ts";
|
} from "@/features/search/constants.ts";
|
||||||
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
|
||||||
|
|
||||||
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
||||||
|
|
||||||
@@ -98,7 +97,6 @@ export function AppHeader() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Group px={"xl"} wrap="nowrap">
|
<Group px={"xl"} wrap="nowrap">
|
||||||
<NotificationPopover />
|
|
||||||
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="light"
|
variant="light"
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ const groupedData: DataGroup[] = [
|
|||||||
icon: IconSparkles,
|
icon: IconSparkles,
|
||||||
path: "/settings/ai",
|
path: "/settings/ai",
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
|
isSelfhosted: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 60px 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { Stack, Text } from "@mantine/core";
|
|
||||||
import { type TablerIcon } from "@tabler/icons-react";
|
|
||||||
import { ReactNode } from "react";
|
|
||||||
import classes from "./empty-state.module.css";
|
|
||||||
|
|
||||||
type EmptyStateProps = {
|
|
||||||
icon: TablerIcon;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
action?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
|
||||||
return (
|
|
||||||
<div className={classes.root}>
|
|
||||||
<Stack align="center" gap="xs">
|
|
||||||
<Icon size={40} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
|
||||||
<Text size="lg" fw={500}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{description && (
|
|
||||||
<Text size="sm" c="dimmed" maw={350}>
|
|
||||||
{description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{action}
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
.aiMenu {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 600px;
|
|
||||||
min-height: 2.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aiInput {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
& input {
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 22px;
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 40px;
|
|
||||||
border: 1px solid
|
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
font-size: var(--mantine-font-size-sm);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: light-dark(
|
|
||||||
var(--mantine-color-gray-4),
|
|
||||||
var(--mantine-color-dark-3)
|
|
||||||
);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.menuItemSelected {
|
|
||||||
background-color: var(--mantine-color-gray-1);
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-dark-5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultPreview {
|
|
||||||
background-color: light-dark(
|
|
||||||
var(--mantine-color-white),
|
|
||||||
var(--mantine-color-dark-6)
|
|
||||||
);
|
|
||||||
border: 1px solid
|
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultPreviewWrapper {
|
|
||||||
font-size: var(--mantine-font-size-md);
|
|
||||||
line-height: 1.6;
|
|
||||||
padding: var(--mantine-spacing-md);
|
|
||||||
|
|
||||||
*:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
*:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
import { Editor } from "@tiptap/react";
|
|
||||||
import { ActionIcon, TextInput, Tooltip } from "@mantine/core";
|
|
||||||
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { IconArrowUp } from "@tabler/icons-react";
|
|
||||||
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
|
||||||
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
|
|
||||||
import { AiAction } from "@/ee/ai/types/ai.types.ts";
|
|
||||||
import { CommandItem, commandItems, CommandSet } from "./command-items.ts";
|
|
||||||
import { CommandSelector } from "./command-selector.tsx";
|
|
||||||
import { ResultPreview } from "./result-preview.tsx";
|
|
||||||
import classes from "./ai-menu.module.css";
|
|
||||||
import { marked } from "marked";
|
|
||||||
import { DOMSerializer } from "@tiptap/pm/model";
|
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
|
|
||||||
interface EditorAiMenuProps {
|
|
||||||
editor: Editor | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
|
||||||
const aiGenerateStreamMutation = useAiGenerateStreamMutation();
|
|
||||||
const location = useLocation();
|
|
||||||
const isSmBreakpoint = useMediaQuery("(max-width: 48em)");
|
|
||||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
const [prompt, setPrompt] = useState("");
|
|
||||||
const [output, setOutput] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
||||||
const [activeCommandSet, setActiveCommandSet] = useState<CommandSet>("main");
|
|
||||||
const [lastAction, setLastAction] = useState<CommandItem | null>(null);
|
|
||||||
const [menuPlacement, setMenuPlacement] = useState<{
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
width: number;
|
|
||||||
}>({
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: 0,
|
|
||||||
});
|
|
||||||
const currentItems = useMemo(() => {
|
|
||||||
return commandItems[activeCommandSet].filter((item) => {
|
|
||||||
return item.name.toLowerCase().includes(prompt.toLowerCase());
|
|
||||||
});
|
|
||||||
}, [prompt, output, activeCommandSet]);
|
|
||||||
const updateMenuPlacement = useCallback(() => {
|
|
||||||
if (!editor || !showAiMenu) return;
|
|
||||||
|
|
||||||
const { view } = editor;
|
|
||||||
const { to } = editor.state.selection;
|
|
||||||
const editorRect = view.dom.getBoundingClientRect();
|
|
||||||
const cursorCoords = view.coordsAtPos(to);
|
|
||||||
const topOffset = 8;
|
|
||||||
const editorPadding = isSmBreakpoint ? 16 : 48;
|
|
||||||
|
|
||||||
setMenuPlacement({
|
|
||||||
top: cursorCoords.bottom + topOffset + window.scrollY,
|
|
||||||
left: editorRect.left + editorPadding + window.scrollX,
|
|
||||||
width: editorRect.width - editorPadding * 2,
|
|
||||||
});
|
|
||||||
}, [editor, showAiMenu, isSmBreakpoint]);
|
|
||||||
const resetMenu = useCallback(() => {
|
|
||||||
setPrompt("");
|
|
||||||
setOutput("");
|
|
||||||
setActiveCommandSet("main");
|
|
||||||
setLastAction(null);
|
|
||||||
aiGenerateStreamMutation.reset();
|
|
||||||
}, [aiGenerateStreamMutation.reset]);
|
|
||||||
const debouncedUpdateMenuPlacement = useDebouncedCallback(
|
|
||||||
updateMenuPlacement,
|
|
||||||
60,
|
|
||||||
);
|
|
||||||
const handleGenerate = useCallback(
|
|
||||||
(item?: CommandItem) => {
|
|
||||||
if (!editor || isLoading) return;
|
|
||||||
|
|
||||||
let command: CommandItem | null = item || null;
|
|
||||||
|
|
||||||
if (!command) {
|
|
||||||
if (!prompt) return;
|
|
||||||
|
|
||||||
command = {
|
|
||||||
id: "custom",
|
|
||||||
name: "Custom",
|
|
||||||
action: AiAction.CUSTOM,
|
|
||||||
prompt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { from, to } = editor.state.selection;
|
|
||||||
const slice = editor.state.doc.slice(from, to);
|
|
||||||
const serializer = DOMSerializer.fromSchema(editor.schema);
|
|
||||||
const fragment = serializer.serializeFragment(slice.content);
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.appendChild(fragment);
|
|
||||||
const content = htmlToMarkdown(wrapper.innerHTML);
|
|
||||||
|
|
||||||
setOutput("");
|
|
||||||
setIsLoading(true);
|
|
||||||
aiGenerateStreamMutation.mutate({
|
|
||||||
action: command.action,
|
|
||||||
prompt: command.prompt,
|
|
||||||
content,
|
|
||||||
onChunk: (chunk) => {
|
|
||||||
setOutput((output) => output + chunk.content);
|
|
||||||
},
|
|
||||||
onComplete: () => {
|
|
||||||
setIsLoading(false);
|
|
||||||
setActiveCommandSet("result");
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setIsLoading(false);
|
|
||||||
resetMenu();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setLastAction(command);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
editor,
|
|
||||||
prompt,
|
|
||||||
isLoading,
|
|
||||||
aiGenerateStreamMutation.mutateAsync,
|
|
||||||
resetMenu,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const handleCommand = useCallback(
|
|
||||||
(item?: CommandItem) => {
|
|
||||||
setPrompt("");
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
return handleGenerate();
|
|
||||||
}
|
|
||||||
if (item.id === "back") {
|
|
||||||
return setActiveCommandSet("main");
|
|
||||||
}
|
|
||||||
if (item.id === "result-replace") {
|
|
||||||
const chain = editor.chain().focus();
|
|
||||||
|
|
||||||
if (lastAction.action === AiAction.CONTINUE_WRITING) {
|
|
||||||
chain.setTextSelection(editor.state.selection.to);
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = (marked.parse(output) as string).trim();
|
|
||||||
// Strip <p> wrapper for single-paragraph output to preserve inline context
|
|
||||||
const content =
|
|
||||||
html.startsWith("<p>") &&
|
|
||||||
html.endsWith("</p>") &&
|
|
||||||
html.lastIndexOf("<p>") === 0
|
|
||||||
? html.slice(3, -4)
|
|
||||||
: html;
|
|
||||||
|
|
||||||
chain.insertContent(content).run();
|
|
||||||
|
|
||||||
return setShowAiMenu(false);
|
|
||||||
}
|
|
||||||
if (item.id === "result-insert-below") {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.setTextSelection(editor.state.selection.to)
|
|
||||||
.insertContent(marked.parse(output))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
return setShowAiMenu(false);
|
|
||||||
}
|
|
||||||
if (item.id === "result-copy") {
|
|
||||||
navigator.clipboard.writeText(output);
|
|
||||||
|
|
||||||
return setShowAiMenu(false);
|
|
||||||
}
|
|
||||||
if (item.id === "result-discard") {
|
|
||||||
setOutput("");
|
|
||||||
|
|
||||||
return resetMenu();
|
|
||||||
}
|
|
||||||
if (item.id === "result-try-again" && lastAction) {
|
|
||||||
return handleGenerate(lastAction);
|
|
||||||
}
|
|
||||||
if (item.subCommandSet) {
|
|
||||||
return setActiveCommandSet(item.subCommandSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
return handleGenerate(item);
|
|
||||||
},
|
|
||||||
[editor, output, lastAction, handleGenerate, resetMenu],
|
|
||||||
);
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
const totalItems = currentItems.length;
|
|
||||||
const cycleSize = totalItems + 1;
|
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
return setShowAiMenu(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
return setSelectedIndex((selectedIndex) => {
|
|
||||||
const direction = event.key === "ArrowDown" ? 1 : -1;
|
|
||||||
const newIndex = selectedIndex + direction;
|
|
||||||
|
|
||||||
if (newIndex < -1) return cycleSize - 1;
|
|
||||||
if (newIndex >= cycleSize) return 0;
|
|
||||||
|
|
||||||
return newIndex;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
return handleCommand(currentItems[selectedIndex]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentItems, selectedIndex],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
const handleClose = () => setShowAiMenu(false);
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
debouncedUpdateMenuPlacement();
|
|
||||||
});
|
|
||||||
|
|
||||||
updateMenuPlacement();
|
|
||||||
editor.on("focus", handleClose);
|
|
||||||
editor.on("blur", handleClose);
|
|
||||||
window.addEventListener("resize", debouncedUpdateMenuPlacement);
|
|
||||||
window.addEventListener("scroll", debouncedUpdateMenuPlacement, true);
|
|
||||||
observer.observe(editor.view.dom);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
editor.off("focus", handleClose);
|
|
||||||
editor.off("blur", handleClose);
|
|
||||||
window.removeEventListener("resize", debouncedUpdateMenuPlacement);
|
|
||||||
window.removeEventListener("scroll", debouncedUpdateMenuPlacement, true);
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, [editor, updateMenuPlacement, debouncedUpdateMenuPlacement]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setShowAiMenu(false);
|
|
||||||
}, [location]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (showAiMenu) {
|
|
||||||
resetMenu();
|
|
||||||
}
|
|
||||||
}, [showAiMenu, resetMenu]);
|
|
||||||
useEffect(() => {
|
|
||||||
// Focus input when menu opens or command set changes
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
inputRef.current?.focus({ preventScroll: true });
|
|
||||||
});
|
|
||||||
}, [showAiMenu, isLoading, currentItems]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentItems.length) {
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
}
|
|
||||||
setSelectedIndex(prompt || activeCommandSet !== "main" ? 0 : -1);
|
|
||||||
}, [prompt, activeCommandSet, currentItems]);
|
|
||||||
|
|
||||||
if (!showAiMenu) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
zIndex: 200,
|
|
||||||
position: "absolute",
|
|
||||||
top: menuPlacement.top,
|
|
||||||
left: menuPlacement.left,
|
|
||||||
width: menuPlacement.width,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classes.aiMenu}
|
|
||||||
style={{ pointerEvents: "auto" }}
|
|
||||||
tabIndex={0}
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
<ResultPreview output={output} isLoading={isLoading} />
|
|
||||||
<CommandSelector
|
|
||||||
selectedIndex={selectedIndex}
|
|
||||||
isLoading={isLoading}
|
|
||||||
output={output}
|
|
||||||
currentItems={currentItems}
|
|
||||||
handleCommand={handleCommand}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
ref={inputRef}
|
|
||||||
className={classes.aiInput}
|
|
||||||
placeholder="Ask AI..."
|
|
||||||
data-autofocus
|
|
||||||
value={prompt}
|
|
||||||
disabled={isLoading}
|
|
||||||
onChange={(e) => setPrompt(e.currentTarget.value)}
|
|
||||||
rightSection={
|
|
||||||
<ActionIcon
|
|
||||||
disabled={!prompt || isLoading}
|
|
||||||
variant="filled"
|
|
||||||
color="blue"
|
|
||||||
radius="xl"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleGenerate()}
|
|
||||||
>
|
|
||||||
<IconArrowUp size={14} stroke={2.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
/>
|
|
||||||
</CommandSelector>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { EditorAiMenu };
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import { AiAction } from "@/ee/ai/types/ai.types.ts";
|
|
||||||
import {
|
|
||||||
IconSparkles,
|
|
||||||
IconArrowsMaximize,
|
|
||||||
IconArrowsMinimize,
|
|
||||||
IconWriting,
|
|
||||||
IconHelp,
|
|
||||||
IconList,
|
|
||||||
IconMoodSmile,
|
|
||||||
IconLanguage,
|
|
||||||
IconTrash,
|
|
||||||
IconRefresh,
|
|
||||||
IconChevronLeft,
|
|
||||||
IconCheck,
|
|
||||||
IconArrowDownLeft,
|
|
||||||
IconCopy,
|
|
||||||
IconTextPlus,
|
|
||||||
IconAlignJustified,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
interface CommandItem {
|
|
||||||
name: string;
|
|
||||||
id: string;
|
|
||||||
icon?: typeof IconSparkles;
|
|
||||||
action?: AiAction;
|
|
||||||
prompt?: string;
|
|
||||||
subCommandSet?: CommandSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CommandSet = "main" | "tone" | "translate" | "result";
|
|
||||||
|
|
||||||
const mainItems: CommandItem[] = [
|
|
||||||
{
|
|
||||||
id: "improve-writing",
|
|
||||||
name: "Improve writing",
|
|
||||||
icon: IconSparkles,
|
|
||||||
action: AiAction.IMPROVE_WRITING,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fix-spelling-grammar",
|
|
||||||
name: "Fix spelling & grammar",
|
|
||||||
icon: IconCheck,
|
|
||||||
action: AiAction.FIX_SPELLING_GRAMMAR,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "make-longer",
|
|
||||||
name: "Make longer",
|
|
||||||
icon: IconTextPlus,
|
|
||||||
action: AiAction.MAKE_LONGER,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "make-shorter",
|
|
||||||
name: "Make shorter",
|
|
||||||
icon: IconAlignJustified,
|
|
||||||
action: AiAction.MAKE_SHORTER,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "continue-writing",
|
|
||||||
name: "Continue writing",
|
|
||||||
icon: IconWriting,
|
|
||||||
action: AiAction.CONTINUE_WRITING,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "explain",
|
|
||||||
name: "Explain",
|
|
||||||
icon: IconHelp,
|
|
||||||
action: AiAction.EXPLAIN,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "summarize",
|
|
||||||
name: "Summarize",
|
|
||||||
icon: IconList,
|
|
||||||
action: AiAction.SUMMARIZE,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "change-tone",
|
|
||||||
name: "Change tone",
|
|
||||||
icon: IconMoodSmile,
|
|
||||||
subCommandSet: "tone",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate",
|
|
||||||
name: "Translate",
|
|
||||||
icon: IconLanguage,
|
|
||||||
subCommandSet: "translate",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const toneItems: CommandItem[] = [
|
|
||||||
{
|
|
||||||
id: "back",
|
|
||||||
name: "Back",
|
|
||||||
icon: IconChevronLeft,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tone-professional",
|
|
||||||
name: "Professional",
|
|
||||||
icon: IconMoodSmile,
|
|
||||||
action: AiAction.CHANGE_TONE,
|
|
||||||
prompt: "Professional",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tone-casual",
|
|
||||||
name: "Casual",
|
|
||||||
icon: IconMoodSmile,
|
|
||||||
action: AiAction.CHANGE_TONE,
|
|
||||||
prompt: "Casual",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tone-friendly",
|
|
||||||
name: "Friendly",
|
|
||||||
icon: IconMoodSmile,
|
|
||||||
action: AiAction.CHANGE_TONE,
|
|
||||||
prompt: "Friendly",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const translateItems: CommandItem[] = [
|
|
||||||
{
|
|
||||||
id: "back",
|
|
||||||
name: "Back",
|
|
||||||
icon: IconChevronLeft,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-english",
|
|
||||||
name: "English",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "English",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-spanish",
|
|
||||||
name: "Spanish",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Spanish",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-german",
|
|
||||||
name: "German",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "German",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-french",
|
|
||||||
name: "French",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "French",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-dutch",
|
|
||||||
name: "Dutch",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Dutch",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-portuguese",
|
|
||||||
name: "Portuguese",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Portuguese",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-italian",
|
|
||||||
name: "Italian",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Italian",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-japanese",
|
|
||||||
name: "Japanese",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Japanese",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-korean",
|
|
||||||
name: "Korean",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Korean",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-swedish",
|
|
||||||
name: "Swedish",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Swedish",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "translate-chinese",
|
|
||||||
name: "Chinese (Simplified)",
|
|
||||||
icon: IconLanguage,
|
|
||||||
action: AiAction.TRANSLATE,
|
|
||||||
prompt: "Simplified Chinese",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const resultItems: CommandItem[] = [
|
|
||||||
{ id: "result-replace", name: "Replace", icon: IconCheck },
|
|
||||||
{ id: "result-insert-below", name: "Insert below", icon: IconArrowDownLeft },
|
|
||||||
{ id: "result-copy", name: "Copy", icon: IconCopy },
|
|
||||||
{ id: "result-discard", name: "Discard", icon: IconTrash },
|
|
||||||
{
|
|
||||||
id: "result-try-again",
|
|
||||||
name: "Try again",
|
|
||||||
icon: IconRefresh,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const commandItems: Record<CommandSet, CommandItem[]> = {
|
|
||||||
main: mainItems,
|
|
||||||
tone: toneItems,
|
|
||||||
translate: translateItems,
|
|
||||||
result: resultItems,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type { CommandItem, CommandSet };
|
|
||||||
export { commandItems };
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { Loader, Menu, ScrollArea } from "@mantine/core";
|
|
||||||
import { IconChevronRight } from "@tabler/icons-react";
|
|
||||||
import { ReactNode } from "react";
|
|
||||||
import { CommandItem } from "./command-items.ts";
|
|
||||||
import classes from "./ai-menu.module.css";
|
|
||||||
|
|
||||||
interface CommandSelectorProps {
|
|
||||||
selectedIndex: number;
|
|
||||||
|
|
||||||
isLoading: boolean;
|
|
||||||
output: string;
|
|
||||||
currentItems: CommandItem[];
|
|
||||||
children: ReactNode;
|
|
||||||
handleCommand(item: CommandItem): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommandSelector = ({
|
|
||||||
selectedIndex,
|
|
||||||
children,
|
|
||||||
isLoading,
|
|
||||||
output,
|
|
||||||
currentItems,
|
|
||||||
handleCommand,
|
|
||||||
}: CommandSelectorProps) => {
|
|
||||||
return (
|
|
||||||
<Menu
|
|
||||||
opened={!isLoading && currentItems.length > 0}
|
|
||||||
middlewares={{ flip: false }}
|
|
||||||
position="bottom-start"
|
|
||||||
offset={4}
|
|
||||||
width={250}
|
|
||||||
trapFocus={false}
|
|
||||||
shadow="lg"
|
|
||||||
>
|
|
||||||
<Menu.Target>{children}</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<ScrollArea.Autosize type="scroll" scrollbarSize={5} mah={300}>
|
|
||||||
{currentItems.map((item, index) => {
|
|
||||||
const isSelected = selectedIndex === index;
|
|
||||||
const showLoader =
|
|
||||||
isLoading && output === "" && !item.subCommandSet;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Menu.Item
|
|
||||||
key={item.id}
|
|
||||||
className={isSelected ? classes.menuItemSelected : undefined}
|
|
||||||
leftSection={
|
|
||||||
showLoader ? (
|
|
||||||
<Loader size={14} />
|
|
||||||
) : item.icon ? (
|
|
||||||
<item.icon size={16} />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
rightSection={
|
|
||||||
item.subCommandSet ? (
|
|
||||||
<IconChevronRight size={14} />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
onClick={() => handleCommand(item)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Menu.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { CommandSelector };
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Loader, Paper, ScrollArea } from "@mantine/core";
|
|
||||||
import DOMPurify from "dompurify";
|
|
||||||
import { marked } from "marked";
|
|
||||||
import { memo } from "react";
|
|
||||||
import classes from "./ai-menu.module.css";
|
|
||||||
|
|
||||||
interface ResultPreviewProps {
|
|
||||||
output: string;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
const ResultPreview = memo(({ output, isLoading }: ResultPreviewProps) => {
|
|
||||||
if (!output && !isLoading) return;
|
|
||||||
|
|
||||||
const parsedOutput = `${marked.parse(output)}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper mb={4} shadow="lg" radius="md" className={classes.resultPreview}>
|
|
||||||
<ScrollArea.Autosize mah={300} type="scroll" scrollbarSize={5}>
|
|
||||||
<div className={classes.resultPreviewWrapper}>
|
|
||||||
{parsedOutput && (
|
|
||||||
<div
|
|
||||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(parsedOutput) }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isLoading && <Loader size={12} ml="xs" display="inline-block" />}
|
|
||||||
</div>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export { ResultPreview };
|
|
||||||
@@ -15,7 +15,7 @@ export default function EnableAiSearch() {
|
|||||||
<>
|
<>
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
<div>
|
<div>
|
||||||
<Text size="md">{t("AI-powered search (AI Answers)")}</Text>
|
<Text size="md">{t("AI-powered search (Ask AI)")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t(
|
{t(
|
||||||
"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.",
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { Group, Text, Switch } 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 { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
|
|
||||||
|
|
||||||
export default function EnableGenerativeAi() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
|
||||||
const [checked, setChecked] = useState(workspace?.settings?.ai?.generative);
|
|
||||||
const hasAccess = useIsCloudEE();
|
|
||||||
|
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
try {
|
|
||||||
const updatedWorkspace = await updateWorkspace({ generativeAi: value });
|
|
||||||
setChecked(value);
|
|
||||||
setWorkspace(updatedWorkspace);
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err?.response?.data?.message,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="md">{t("Generative AI (Ask AI)")}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
defaultChecked={checked}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={!hasAccess}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, UseMutationResult } from "@tanstack/react-query";
|
import { useMutation, UseMutationResult } from "@tanstack/react-query";
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { aiAnswers, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
|
import { askAi, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
|
||||||
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
|
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -26,7 +26,7 @@ export function useAiSearch(): UseAiSearchResult {
|
|||||||
|
|
||||||
const { contentType, ...apiParams } = params;
|
const { contentType, ...apiParams } = params;
|
||||||
|
|
||||||
return await aiAnswers(apiParams, (chunk) => {
|
return await askAi(apiParams, (chunk) => {
|
||||||
if (chunk.content) {
|
if (chunk.content) {
|
||||||
setStreamingAnswer((prev) => prev + chunk.content);
|
setStreamingAnswer((prev) => prev + chunk.content);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { getAppName } from "@/lib/config.ts";
|
import { getAppName, isCloud } from "@/lib/config.ts";
|
||||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||||
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
|
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
|
||||||
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
|
import { Alert } from "@mantine/core";
|
||||||
import { Alert, Stack } from "@mantine/core";
|
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
|
|
||||||
import { isCloud } from "@/lib/config.ts";
|
|
||||||
|
|
||||||
export default function AiSettings() {
|
export default function AiSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
const hasAccess = useIsCloudEE();
|
const { hasLicenseKey } = useLicense();
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -40,10 +40,7 @@ export default function AiSettings() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack gap="md">
|
<EnableAiSearch />
|
||||||
{!isCloud() && <EnableAiSearch />}
|
|
||||||
<EnableGenerativeAi />
|
|
||||||
</Stack>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ export interface IAiSearchResponse {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function aiAnswers(
|
export async function askAi(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
|
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
|
||||||
): Promise<IAiSearchResponse> {
|
): Promise<IAiSearchResponse> {
|
||||||
const response = await fetch("/api/ai/answers", {
|
const response = await fetch("/api/ai/ask", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -43,16 +43,13 @@ export async function generateAiContentStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const processStream = async () => {
|
const processStream = async () => {
|
||||||
let buffer = "";
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
const lines = buffer.split("\n");
|
const lines = chunk.split("\n");
|
||||||
|
|
||||||
buffer = lines.pop() || "";
|
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith("data: ")) {
|
if (line.startsWith("data: ")) {
|
||||||
@@ -69,7 +66,7 @@ export async function generateAiContentStream(
|
|||||||
onChunk(parsed);
|
onChunk(parsed);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Skip invalid JSON
|
// Ignore parse errors for incomplete chunks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export enum AiAction {
|
|||||||
SIMPLIFY = "simplify",
|
SIMPLIFY = "simplify",
|
||||||
CHANGE_TONE = "change_tone",
|
CHANGE_TONE = "change_tone",
|
||||||
SUMMARIZE = "summarize",
|
SUMMARIZE = "summarize",
|
||||||
EXPLAIN = "explain",
|
|
||||||
CONTINUE_WRITING = "continue_writing",
|
CONTINUE_WRITING = "continue_writing",
|
||||||
TRANSLATE = "translate",
|
TRANSLATE = "translate",
|
||||||
CUSTOM = "custom",
|
CUSTOM = "custom",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types";
|
import { IAuthProvider } from "@/ee/security/types/security.types";
|
||||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
|
import APP_ROUTE from "@/lib/app-route";
|
||||||
import { ldapLogin } from "@/ee/security/services/ldap-auth-service";
|
import { ldapLogin } from "@/ee/security/services/ldap-auth-service";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@@ -59,13 +59,13 @@ export function LdapLoginModal({
|
|||||||
// Handle MFA like the regular login
|
// Handle MFA like the regular login
|
||||||
if (response?.userHasMfa) {
|
if (response?.userHasMfa) {
|
||||||
onClose();
|
onClose();
|
||||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
|
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
||||||
} else if (response?.requiresMfaSetup) {
|
} else if (response?.requiresMfaSetup) {
|
||||||
onClose();
|
onClose();
|
||||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
|
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
||||||
} else {
|
} else {
|
||||||
onClose();
|
onClose();
|
||||||
navigate(getPostLoginRedirect());
|
navigate(APP_ROUTE.HOME);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { isCloud } from "@/lib/config";
|
|
||||||
import useLicense from "@/ee/hooks/use-license";
|
|
||||||
import usePlan from "@/ee/hooks/use-plan";
|
|
||||||
|
|
||||||
const useEnterpriseAccess = () => {
|
|
||||||
const { hasLicenseKey } = useLicense();
|
|
||||||
const { isBusiness } = usePlan();
|
|
||||||
|
|
||||||
return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useEnterpriseAccess;
|
|
||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
List,
|
List,
|
||||||
Code,
|
Code,
|
||||||
|
CopyButton,
|
||||||
Alert,
|
Alert,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { CopyButton } from "@/components/common/copy-button";
|
|
||||||
import {
|
import {
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import classes from "./mfa-challenge.module.css";
|
import classes from "./mfa-challenge.module.css";
|
||||||
import { verifyMfa } from "@/ee/mfa";
|
import { verifyMfa } from "@/ee/mfa";
|
||||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
|
import APP_ROUTE from "@/lib/app-route";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
|
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
|
||||||
@@ -53,7 +53,7 @@ export function MfaChallenge() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await verifyMfa(values.code);
|
await verifyMfa(values.code);
|
||||||
navigate(getPostLoginRedirect());
|
navigate(APP_ROUTE.HOME);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
PinInput,
|
PinInput,
|
||||||
Alert,
|
Alert,
|
||||||
List,
|
List,
|
||||||
|
CopyButton,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -19,7 +20,6 @@ import {
|
|||||||
Collapse,
|
Collapse,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { CopyButton } from "@/components/common/copy-button";
|
|
||||||
import {
|
import {
|
||||||
IconQrcode,
|
IconQrcode,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core";
|
|||||||
import { IconAlertCircle } from "@tabler/icons-react";
|
import { IconAlertCircle } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MfaSetupModal } from "@/ee/mfa";
|
import { MfaSetupModal } from "@/ee/mfa";
|
||||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export default function MfaSetupRequired() {
|
export default function MfaSetupRequired() {
|
||||||
@@ -11,7 +11,7 @@ export default function MfaSetupRequired() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleSetupComplete = () => {
|
const handleSetupComplete = () => {
|
||||||
navigate(getPostLoginRedirect());
|
navigate(APP_ROUTE.HOME);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
|
import APP_ROUTE from "@/lib/app-route";
|
||||||
import { validateMfaAccess } from "@/ee/mfa";
|
import { validateMfaAccess } from "@/ee/mfa";
|
||||||
|
|
||||||
export function useMfaPageProtection() {
|
export function useMfaPageProtection() {
|
||||||
@@ -13,10 +13,8 @@ export function useMfaPageProtection() {
|
|||||||
const checkAccess = async () => {
|
const checkAccess = async () => {
|
||||||
const result = await validateMfaAccess();
|
const result = await validateMfaAccess();
|
||||||
|
|
||||||
const search = location.search;
|
|
||||||
|
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN + search);
|
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,17 +26,17 @@ export function useMfaPageProtection() {
|
|||||||
|
|
||||||
if (result.requiresMfaSetup && !isOnSetupPage) {
|
if (result.requiresMfaSetup && !isOnSetupPage) {
|
||||||
// User needs to set up MFA but is on challenge page
|
// User needs to set up MFA but is on challenge page
|
||||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + search);
|
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
||||||
} else if (
|
} else if (
|
||||||
!result.requiresMfaSetup &&
|
!result.requiresMfaSetup &&
|
||||||
result.userHasMfa &&
|
result.userHasMfa &&
|
||||||
!isOnChallengePage
|
!isOnChallengePage
|
||||||
) {
|
) {
|
||||||
// User has MFA and should be on challenge page
|
// User has MFA and should be on challenge page
|
||||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + search);
|
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
||||||
} else if (!result.isTransferToken) {
|
} else if (!result.isTransferToken) {
|
||||||
// User has a regular auth token, shouldn't be on MFA pages
|
// User has a regular auth token, shouldn't be on MFA pages
|
||||||
navigate(getPostLoginRedirect());
|
navigate(APP_ROUTE.HOME);
|
||||||
} else {
|
} else {
|
||||||
setIsValid(true);
|
setIsValid(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
|
||||||
import { modals } from "@mantine/modals";
|
|
||||||
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 useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
|
|
||||||
|
|
||||||
export default function DisablePublicSharing() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="md">{t("Disable public sharing")}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("Prevent members from sharing pages publicly.")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DisablePublicSharingToggle />
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DisablePublicSharingToggle() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
|
||||||
const [checked, setChecked] = useState(
|
|
||||||
workspace?.settings?.sharing?.disabled === true,
|
|
||||||
);
|
|
||||||
const hasAccess = useEnterpriseAccess();
|
|
||||||
|
|
||||||
const applyChange = async (value: boolean) => {
|
|
||||||
try {
|
|
||||||
const updatedWorkspace = await updateWorkspace({
|
|
||||||
disablePublicSharing: value,
|
|
||||||
});
|
|
||||||
setChecked(value);
|
|
||||||
setWorkspace(updatedWorkspace);
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err?.response?.data?.message,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: value ? t("Disable public sharing") : t("Enable public sharing"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{value
|
|
||||||
? t(
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
centered: true,
|
|
||||||
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
|
||||||
confirmProps: value ? { color: "red" } : {},
|
|
||||||
onConfirm: () => applyChange(value),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
label={t("Requires an enterprise license")}
|
|
||||||
disabled={hasAccess}
|
|
||||||
refProp="rootRef"
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={checked}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={!hasAccess}
|
|
||||||
aria-label={t("Toggle public sharing")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,18 +10,23 @@ export default function EnforceMfa() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<>
|
||||||
<div>
|
<Title order={4} my="sm">
|
||||||
<Text size="md">{t("Enforce two-factor authentication")}</Text>
|
MFA
|
||||||
<Text size="sm" c="dimmed">
|
</Title>
|
||||||
{t(
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
"Once enforced, all members must enable two-factor authentication to access the workspace.",
|
<div>
|
||||||
)}
|
<Text size="md">{t("Enforce two-factor authentication")}</Text>
|
||||||
</Text>
|
<Text size="sm" c="dimmed">
|
||||||
</div>
|
{t(
|
||||||
|
"Once enforced, all members must enable two-factor authentication to access the workspace.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
<EnforceMfaToggle />
|
<EnforceMfaToggle />
|
||||||
</Group>
|
</Group>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
|
||||||
import { modals } from "@mantine/modals";
|
|
||||||
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 { ISpace } from "@/features/space/types/space.types.ts";
|
|
||||||
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
|
||||||
|
|
||||||
type SpacePublicSharingToggleProps = {
|
|
||||||
space: ISpace;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SpacePublicSharingToggle({
|
|
||||||
space,
|
|
||||||
}: SpacePublicSharingToggleProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
|
||||||
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
|
|
||||||
const [checked, setChecked] = useState(
|
|
||||||
space.settings?.sharing?.disabled === true,
|
|
||||||
);
|
|
||||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
|
||||||
|
|
||||||
const applyChange = async (value: boolean) => {
|
|
||||||
try {
|
|
||||||
await updateSpaceMutation.mutateAsync({
|
|
||||||
spaceId: space.id,
|
|
||||||
disablePublicSharing: value,
|
|
||||||
});
|
|
||||||
setChecked(value);
|
|
||||||
} catch {
|
|
||||||
// error handled by mutation
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: value ? t("Disable public sharing") : t("Enable public sharing"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{value
|
|
||||||
? t(
|
|
||||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.",
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"Are you sure you want to enable public sharing for this space?",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
centered: true,
|
|
||||||
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
|
||||||
confirmProps: value ? { color: "red" } : {},
|
|
||||||
onConfirm: () => applyChange(value),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="md">{t("Disable public sharing")}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{workspaceDisabled
|
|
||||||
? t("Public sharing is disabled at the workspace level")
|
|
||||||
: t("Prevent pages in this space from being shared publicly.")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Tooltip
|
|
||||||
label={t("Public sharing is disabled at the workspace level")}
|
|
||||||
disabled={!workspaceDisabled}
|
|
||||||
refProp="rootRef"
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={checked}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={workspaceDisabled}
|
|
||||||
aria-label={t("Toggle space public sharing")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,16 +9,15 @@ import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx"
|
|||||||
import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
|
import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
|
||||||
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
|
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||||
|
import usePlan from "@/ee/hooks/use-plan.tsx";
|
||||||
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
||||||
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
|
|
||||||
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
|
|
||||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
|
|
||||||
|
|
||||||
export default function Security() {
|
export default function Security() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
const hasEnterpriseAccess = useEnterpriseAccess();
|
const { hasLicenseKey } = useLicense();
|
||||||
const isCloudEE = useIsCloudEE();
|
const { isBusiness } = usePlan();
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return null;
|
return null;
|
||||||
@@ -31,41 +30,26 @@ export default function Security() {
|
|||||||
</Helmet>
|
</Helmet>
|
||||||
<SettingsTitle title={t("Security")} />
|
<SettingsTitle title={t("Security")} />
|
||||||
|
|
||||||
<EnforceMfa />
|
<AllowedDomains />
|
||||||
|
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|
||||||
{(!isCloud() || hasEnterpriseAccess) && (
|
<EnforceMfa />
|
||||||
<>
|
|
||||||
<DisablePublicSharing />
|
<Divider my="lg" />
|
||||||
<Divider my="lg" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Title order={4} my="lg">
|
<Title order={4} my="lg">
|
||||||
Single sign-on (SSO)
|
Single sign-on (SSO)
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
{hasEnterpriseAccess && (
|
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
|
||||||
<>
|
<>
|
||||||
<EnforceSso />
|
<EnforceSso />
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCloudEE && (
|
|
||||||
<>
|
|
||||||
<AllowedDomains />
|
|
||||||
<Divider my="lg" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasEnterpriseAccess && (
|
|
||||||
<>
|
|
||||||
<CreateSsoProvider />
|
<CreateSsoProvider />
|
||||||
<Divider size={0} my="lg" />
|
<Divider size={0} my="lg" />
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<SsoProviderList />
|
<SsoProviderList />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,62 +1,20 @@
|
|||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
import loadImage from "blueimp-load-image";
|
|
||||||
import {
|
import {
|
||||||
AvatarIconType,
|
AvatarIconType,
|
||||||
IAttachment,
|
IAttachment,
|
||||||
} from "@/features/attachments/types/attachment.types.ts";
|
} from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
|
||||||
async function compressAndResizeIcon(
|
|
||||||
file: File,
|
|
||||||
type: AvatarIconType,
|
|
||||||
): Promise<File> {
|
|
||||||
const isPng = file.type === "image/png";
|
|
||||||
|
|
||||||
const { image: canvas } = await loadImage(file, {
|
|
||||||
maxWidth: 300,
|
|
||||||
maxHeight: 300,
|
|
||||||
canvas: true,
|
|
||||||
orientation: true,
|
|
||||||
imageSmoothingQuality: "high",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (type === AvatarIconType.AVATAR || !isPng) {
|
|
||||||
const ctx = (canvas as HTMLCanvasElement).getContext("2d")!;
|
|
||||||
ctx.globalCompositeOperation = "destination-over";
|
|
||||||
ctx.fillStyle = "#ffffff";
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
ctx.globalCompositeOperation = "source-over";
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputType = isPng ? "image/png" : "image/jpeg";
|
|
||||||
|
|
||||||
return new Promise<File>((resolve, reject) => {
|
|
||||||
(canvas as HTMLCanvasElement).toBlob(
|
|
||||||
(blob) => {
|
|
||||||
if (!blob) {
|
|
||||||
reject(new Error("Failed to compress image"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(new File([blob], file.name, { type: outputType }));
|
|
||||||
},
|
|
||||||
outputType,
|
|
||||||
isPng ? undefined : 0.85,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadIcon(
|
export async function uploadIcon(
|
||||||
file: File,
|
file: File,
|
||||||
type: AvatarIconType,
|
type: AvatarIconType,
|
||||||
spaceId?: string,
|
spaceId?: string,
|
||||||
): Promise<IAttachment> {
|
): Promise<IAttachment> {
|
||||||
const processed = await compressAndResizeIcon(file, type);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("type", type);
|
formData.append("type", type);
|
||||||
if (spaceId) {
|
if (spaceId) {
|
||||||
formData.append("spaceId", spaceId);
|
formData.append("spaceId", spaceId);
|
||||||
}
|
}
|
||||||
formData.append("image", processed);
|
formData.append("image", file);
|
||||||
|
|
||||||
return await api.post("/attachments/upload-image", formData, {
|
return await api.post("/attachments/upload-image", formData, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
acceptInvitation,
|
acceptInvitation,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
} from "@/features/workspace/services/workspace-service.ts";
|
} from "@/features/workspace/services/workspace-service.ts";
|
||||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import { RESET } from "jotai/utils";
|
import { RESET } from "jotai/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
@@ -44,11 +44,11 @@ export default function useAuth() {
|
|||||||
|
|
||||||
// Check if MFA is required
|
// Check if MFA is required
|
||||||
if (response?.userHasMfa) {
|
if (response?.userHasMfa) {
|
||||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
|
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
||||||
} else if (response?.requiresMfaSetup) {
|
} else if (response?.requiresMfaSetup) {
|
||||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
|
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
||||||
} else {
|
} else {
|
||||||
navigate(getPostLoginRedirect());
|
navigate(APP_ROUTE.HOME);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
|
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
|
||||||
import { getPostLoginRedirect } from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export function useRedirectIfAuthenticated() {
|
export function useRedirectIfAuthenticated() {
|
||||||
@@ -9,7 +9,7 @@ export function useRedirectIfAuthenticated() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && data?.user) {
|
if (data && data?.user) {
|
||||||
navigate(getPostLoginRedirect());
|
navigate(APP_ROUTE.HOME);
|
||||||
}
|
}
|
||||||
}, [isLoading, data]);
|
}, [isLoading, data]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const useClickOutsideRef = useClickOutside(() => {
|
const useClickOutsideRef = useClickOutside(() => {
|
||||||
if (document.querySelector("#mention")) return;
|
|
||||||
handleDialogClose();
|
handleDialogClose();
|
||||||
});
|
});
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
@@ -106,7 +105,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
|||||||
position={{ bottom: 500, right: 50 }}
|
position={{ bottom: 500, right: 50 }}
|
||||||
withCloseButton
|
withCloseButton
|
||||||
withBorder
|
withBorder
|
||||||
data-comment-dialog
|
|
||||||
>
|
>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||||
|
import { Underline } from "@tiptap/extension-underline";
|
||||||
|
import { Link } from "@tiptap/extension-link";
|
||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
||||||
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
|
|
||||||
import MentionView from "@/features/editor/components/mention/mention-view";
|
|
||||||
|
|
||||||
interface CommentEditorProps {
|
interface CommentEditorProps {
|
||||||
defaultContent?: any;
|
defaultContent?: any;
|
||||||
@@ -40,29 +39,13 @@ const CommentEditor = forwardRef(
|
|||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
gapcursor: false,
|
gapcursor: false,
|
||||||
dropcursor: false,
|
dropcursor: false,
|
||||||
link: false,
|
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: placeholder || t("Reply..."),
|
placeholder: placeholder || t("Reply..."),
|
||||||
}),
|
}),
|
||||||
LinkExtension,
|
Underline,
|
||||||
|
Link,
|
||||||
EmojiCommand,
|
EmojiCommand,
|
||||||
Mention.configure({
|
|
||||||
suggestion: {
|
|
||||||
allowSpaces: true,
|
|
||||||
items: () => [],
|
|
||||||
// @ts-ignore
|
|
||||||
render: mentionRenderItems,
|
|
||||||
},
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: "mention",
|
|
||||||
},
|
|
||||||
}).extend({
|
|
||||||
addNodeView() {
|
|
||||||
this.editor.isInitialized = true;
|
|
||||||
return ReactNodeViewRenderer(MentionView);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
editorProps: {
|
editorProps: {
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
@@ -77,8 +60,7 @@ const CommentEditor = forwardRef(
|
|||||||
].includes(event.key)
|
].includes(event.key)
|
||||||
) {
|
) {
|
||||||
const emojiCommand = document.querySelector("#emoji-command");
|
const emojiCommand = document.querySelector("#emoji-command");
|
||||||
const mentionPopup = document.querySelector("#mention");
|
if (emojiCommand) {
|
||||||
if (emojiCommand || mentionPopup) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,14 +84,9 @@ const CommentEditor = forwardRef(
|
|||||||
autofocus: (autofocus && "end") || false,
|
autofocus: (autofocus && "end") || false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync content from props for read-only editors (e.g. when updated via
|
|
||||||
// websocket on another browser). Skip for editable editors to avoid
|
|
||||||
// resetting the cursor position on every keystroke.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editable && commentEditor && defaultContent) {
|
commentEditor.commands.setContent(defaultContent);
|
||||||
commentEditor.commands.setContent(defaultContent);
|
}, [defaultContent]);
|
||||||
}
|
|
||||||
}, [defaultContent, editable, commentEditor]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -126,11 +103,7 @@ const CommentEditor = forwardRef(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={focusRef} className={classes.commentEditor}>
|
||||||
ref={focusRef}
|
|
||||||
className={classes.commentEditor}
|
|
||||||
data-editable={editable || undefined}
|
|
||||||
>
|
|
||||||
<EditorContent
|
<EditorContent
|
||||||
editor={commentEditor}
|
editor={commentEditor}
|
||||||
className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
|
className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Group, Text, Box, Badge } from "@mantine/core";
|
import { Group, Text, Box, Badge } from "@mantine/core";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { timeAgo } from "@/lib/time";
|
import { timeAgo } from "@/lib/time";
|
||||||
@@ -40,7 +40,6 @@ function CommentListItem({
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const editor = useAtomValue(pageEditorAtom);
|
const editor = useAtomValue(pageEditorAtom);
|
||||||
const [content, setContent] = useState<string>(comment.content);
|
const [content, setContent] = useState<string>(comment.content);
|
||||||
const editContentRef = useRef<any>(null);
|
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
@@ -57,13 +56,9 @@ function CommentListItem({
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const commentToUpdate = {
|
const commentToUpdate = {
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
content: JSON.stringify(editContentRef.current ?? content),
|
content: JSON.stringify(content),
|
||||||
};
|
};
|
||||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||||
if (editContentRef.current) {
|
|
||||||
setContent(editContentRef.current);
|
|
||||||
editContentRef.current = null;
|
|
||||||
}
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
||||||
emit({
|
emit({
|
||||||
@@ -133,7 +128,6 @@ function CommentListItem({
|
|||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}
|
}
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
editContentRef.current = null;
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +194,7 @@ function CommentListItem({
|
|||||||
<CommentEditor
|
<CommentEditor
|
||||||
defaultContent={content}
|
defaultContent={content}
|
||||||
editable={true}
|
editable={true}
|
||||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
onUpdate={(newContent: any) => setContent(newContent)}
|
||||||
onSave={handleUpdateComment}
|
onSave={handleUpdateComment}
|
||||||
autofocus={true}
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -32,14 +32,11 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
max-height: 20vh;
|
||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
|
||||||
|
|
||||||
&[data-editable] .ProseMirror :global(.ProseMirror){
|
|
||||||
max-height: 50vh;
|
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,3 @@ export const titleEditorAtom = atom<Editor | null>(null);
|
|||||||
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
export const yjsConnectionStatusAtom = atom<string>("");
|
export const yjsConnectionStatusAtom = atom<string>("");
|
||||||
|
|
||||||
export const showAiMenuAtom = atom(false);
|
|
||||||
|
|||||||
@@ -1,53 +1,11 @@
|
|||||||
.bubbleMenu {
|
.bubbleMenu {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
|
||||||
overflow-x: auto;
|
|
||||||
max-width: 100vw;
|
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
box-shadow: 0 4px 12px light-dark(#cfcfcf, #0f0f0f);
|
border-radius: 2px;
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid
|
border: 1px solid
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
|
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
|
||||||
background-color: light-dark(
|
|
||||||
var(--mantine-color-white),
|
|
||||||
var(--mantine-color-dark-6)
|
|
||||||
);
|
|
||||||
|
|
||||||
> * {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollbar-width: none;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5));
|
color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttonRoot {
|
|
||||||
height: 34px;
|
|
||||||
padding-left: rem(8);
|
|
||||||
padding-right: rem(4);
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonSeparator {
|
|
||||||
border-right: 1px solid
|
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 16px;
|
|
||||||
align-self: center;
|
|
||||||
margin: 0 4px;
|
|
||||||
background-color: light-dark(
|
|
||||||
var(--mantine-color-gray-3),
|
|
||||||
var(--mantine-color-dark-3)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ import {
|
|||||||
IconStrikethrough,
|
IconStrikethrough,
|
||||||
IconUnderline,
|
IconUnderline,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconSparkles,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import classes from "./bubble-menu.module.css";
|
import classes from "./bubble-menu.module.css";
|
||||||
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
|
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||||
import { ColorSelector } from "./color-selector";
|
import { ColorSelector } from "./color-selector";
|
||||||
import { NodeSelector } from "./node-selector";
|
import { NodeSelector } from "./node-selector";
|
||||||
import { TextAlignmentSelector } from "./text-alignment-selector";
|
import { TextAlignmentSelector } from "./text-alignment-selector";
|
||||||
@@ -21,13 +20,11 @@ import {
|
|||||||
draftCommentIdAtom,
|
draftCommentIdAtom,
|
||||||
showCommentPopupAtom,
|
showCommentPopupAtom,
|
||||||
} from "@/features/comment/atoms/comment-atom";
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { v7 as uuid7 } from "uuid";
|
import { v7 as uuid7 } from "uuid";
|
||||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
export interface BubbleMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -42,22 +39,14 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
|||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
|
||||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
|
||||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||||
const showCommentPopupRef = useRef(showCommentPopup);
|
const showCommentPopupRef = useRef(showCommentPopup);
|
||||||
const showAiMenuRef = useRef(showAiMenu);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showCommentPopupRef.current = showCommentPopup;
|
showCommentPopupRef.current = showCommentPopup;
|
||||||
}, [showCommentPopup]);
|
}, [showCommentPopup]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
showAiMenuRef.current = showAiMenu;
|
|
||||||
}, [showAiMenu]);
|
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
selector: (ctx) => {
|
selector: (ctx) => {
|
||||||
@@ -134,7 +123,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
empty ||
|
empty ||
|
||||||
isNodeSelection(selection) ||
|
isNodeSelection(selection) ||
|
||||||
isCellSelection(selection) ||
|
isCellSelection(selection) ||
|
||||||
showAiMenuRef.current ||
|
|
||||||
showCommentPopupRef?.current
|
showCommentPopupRef?.current
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@@ -158,31 +146,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||||
|
|
||||||
// Hide the bubble menu immediately when AI menu is shown
|
|
||||||
if (showAiMenu) return;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu
|
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
|
||||||
{...bubbleMenuProps}
|
|
||||||
style={{ zIndex: 200, position: "relative" }}
|
|
||||||
>
|
|
||||||
<div className={classes.bubbleMenu}>
|
<div className={classes.bubbleMenu}>
|
||||||
{isGenerativeAiEnabled && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
className={clsx(classes.buttonRoot)}
|
|
||||||
radius="0"
|
|
||||||
leftSection={<IconSparkles size={16} />}
|
|
||||||
onClick={() => {
|
|
||||||
setShowAiMenu(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("Ask AI")}
|
|
||||||
</Button>
|
|
||||||
<div className={classes.divider} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<NodeSelector
|
<NodeSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
isOpen={isNodeSelectorOpen}
|
isOpen={isNodeSelectorOpen}
|
||||||
@@ -246,18 +212,16 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
|
<ActionIcon
|
||||||
<ActionIcon
|
variant="default"
|
||||||
variant="default"
|
size="lg"
|
||||||
size="lg"
|
radius="0"
|
||||||
radius="6px"
|
aria-label={t(commentItem.name)}
|
||||||
aria-label={t(commentItem.name)}
|
style={{ border: "none" }}
|
||||||
style={{ border: "none" }}
|
onClick={commentItem.command}
|
||||||
onClick={commentItem.command}
|
>
|
||||||
>
|
<IconMessage size={16} stroke={2} />
|
||||||
<IconMessage size={16} stroke={2} />
|
</ActionIcon>
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { Dispatch, FC, SetStateAction } from "react";
|
import React, { Dispatch, FC, SetStateAction } from "react";
|
||||||
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
|
import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Button,
|
Button,
|
||||||
Popover,
|
Popover,
|
||||||
rem,
|
rem,
|
||||||
@@ -14,8 +15,6 @@ import {
|
|||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
import { useEditorState } from "@tiptap/react";
|
import { useEditorState } from "@tiptap/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import clsx from "clsx";
|
|
||||||
import classes from "./bubble-menu.module.css";
|
|
||||||
|
|
||||||
export interface BubbleColorMenuItem {
|
export interface BubbleColorMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -167,10 +166,14 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
|||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
data-text-color={activeColorItem?.color || ""}
|
data-text-color={activeColorItem?.color || ""}
|
||||||
data-highlight-color={activeHighlightItem?.color || ""}
|
data-highlight-color={activeHighlightItem?.color || ""}
|
||||||
className={clsx(["color-selector-trigger", classes.buttonRoot])}
|
className="color-selector-trigger"
|
||||||
style={{
|
style={{
|
||||||
|
height: "34px",
|
||||||
|
border: "none",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: rem(16),
|
fontSize: rem(16),
|
||||||
|
paddingLeft: rem(8),
|
||||||
|
paddingRight: rem(4),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
A
|
A
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { Dispatch, FC, SetStateAction } from "react";
|
import React, { Dispatch, FC, SetStateAction } from "react";
|
||||||
import {
|
import {
|
||||||
IconBlockquote,
|
IconBlockquote,
|
||||||
IconCaretRightFilled,
|
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconCheckbox,
|
IconCheckbox,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
@@ -9,16 +8,14 @@ import {
|
|||||||
IconH1,
|
IconH1,
|
||||||
IconH2,
|
IconH2,
|
||||||
IconH3,
|
IconH3,
|
||||||
IconInfoCircle,
|
|
||||||
IconList,
|
IconList,
|
||||||
IconListNumbers,
|
IconListNumbers,
|
||||||
IconTypography,
|
IconTypography,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core";
|
import { Popover, Button, ScrollArea } from "@mantine/core";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
import { useEditorState } from "@tiptap/react";
|
import { useEditorState } from "@tiptap/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classes from "./bubble-menu.module.css";
|
|
||||||
|
|
||||||
interface NodeSelectorProps {
|
interface NodeSelectorProps {
|
||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
@@ -57,8 +54,6 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
isTaskItem: ctx.editor.isActive("taskItem"),
|
isTaskItem: ctx.editor.isActive("taskItem"),
|
||||||
isBlockquote: ctx.editor.isActive("blockquote"),
|
isBlockquote: ctx.editor.isActive("blockquote"),
|
||||||
isCodeBlock: ctx.editor.isActive("codeBlock"),
|
isCodeBlock: ctx.editor.isActive("codeBlock"),
|
||||||
isCallout: ctx.editor.isActive("callout"),
|
|
||||||
isDetails: ctx.editor.isActive("details"),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -128,18 +123,6 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
command: () => editor.chain().focus().toggleCodeBlock().run(),
|
command: () => editor.chain().focus().toggleCodeBlock().run(),
|
||||||
isActive: () => editorState?.isCodeBlock,
|
isActive: () => editorState?.isCodeBlock,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Callout",
|
|
||||||
icon: IconInfoCircle,
|
|
||||||
command: () => editor.chain().focus().toggleCallout().run(),
|
|
||||||
isActive: () => editorState?.isCallout,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Toggle block",
|
|
||||||
icon: IconCaretRightFilled,
|
|
||||||
command: () => editor.chain().focus().setDetails().run(),
|
|
||||||
isActive: () => editorState?.isDetails,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||||
@@ -149,18 +132,15 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Popover opened={isOpen} withArrow>
|
<Popover opened={isOpen} withArrow>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t("Turn into")} withArrow withinPortal={false} disabled={isOpen}>
|
<Button
|
||||||
<Button
|
variant="default"
|
||||||
className={classes.buttonRoot}
|
style={{ border: "none", height: "34px" }}
|
||||||
variant="default"
|
radius="0"
|
||||||
style={{ border: "none", height: "34px" }}
|
rightSection={<IconChevronDown size={16} />}
|
||||||
radius="0"
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
rightSection={<IconChevronDown size={16} />}
|
>
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
{t(activeItem?.name)}
|
||||||
>
|
</Button>
|
||||||
{t(activeItem?.name)}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
|
|||||||
+11
-13
@@ -7,7 +7,7 @@ import {
|
|||||||
IconCheck,
|
IconCheck,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Popover, Button, ScrollArea, Tooltip, rem } from "@mantine/core";
|
import { Popover, Button, ScrollArea, rem } from "@mantine/core";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
import { useEditorState } from "@tiptap/react";
|
import { useEditorState } from "@tiptap/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -84,18 +84,16 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Popover opened={isOpen} withArrow>
|
<Popover opened={isOpen} withArrow>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t("Text align")} withArrow withinPortal={false} disabled={isOpen}>
|
<Button
|
||||||
<Button
|
variant="default"
|
||||||
variant="default"
|
style={{ border: "none", height: "34px" }}
|
||||||
style={{ border: "none", height: "34px" }}
|
px="5"
|
||||||
px="5"
|
radius="0"
|
||||||
radius="0"
|
rightSection={<IconChevronDown size={16} />}
|
||||||
rightSection={<IconChevronDown size={16} />}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
>
|
||||||
>
|
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
|
||||||
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
|
|||||||
@@ -7,19 +7,16 @@ import {
|
|||||||
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 } from "@mantine/core";
|
||||||
import clsx from "clsx";
|
|
||||||
import {
|
import {
|
||||||
IconAlertTriangleFilled,
|
IconAlertTriangleFilled,
|
||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
IconCircleXFilled,
|
IconCircleXFilled,
|
||||||
IconInfoCircleFilled,
|
IconInfoCircleFilled,
|
||||||
IconMoodSmile,
|
IconMoodSmile,
|
||||||
IconNotes,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { CalloutType, isTextSelected } from "@docmost/editor-ext";
|
import { CalloutType } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
|
||||||
|
|
||||||
export function CalloutMenu({ editor }: EditorMenuProps) {
|
export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -29,7 +26,6 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
if (!state) {
|
if (!state) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (isTextSelected(editor)) return false;
|
|
||||||
|
|
||||||
return editor.isActive("callout");
|
return editor.isActive("callout");
|
||||||
},
|
},
|
||||||
@@ -46,7 +42,6 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
return {
|
return {
|
||||||
isCallout: ctx.editor.isActive("callout"),
|
isCallout: ctx.editor.isActive("callout"),
|
||||||
isInfo: ctx.editor.isActive("callout", { type: "info" }),
|
isInfo: ctx.editor.isActive("callout", { type: "info" }),
|
||||||
isNote: ctx.editor.isActive("callout", { type: "note" }),
|
|
||||||
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
|
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
|
||||||
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
|
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
|
||||||
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
|
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
|
||||||
@@ -131,31 +126,15 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div className={classes.toolbar}>
|
<ActionIcon.Group className="actionIconGroup">
|
||||||
<Tooltip position="top" label={t("Info")}>
|
<Tooltip position="top" label={t("Info")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => setCalloutType("info")}
|
onClick={() => setCalloutType("info")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Info")}
|
aria-label={t("Info")}
|
||||||
variant="subtle"
|
variant={editorState?.isInfo ? "light" : "default"}
|
||||||
className={clsx({ [classes.active]: editorState?.isInfo })}
|
|
||||||
>
|
>
|
||||||
<IconInfoCircleFilled
|
<IconInfoCircleFilled size={18} />
|
||||||
size={18}
|
|
||||||
color="var(--mantine-color-blue-5)"
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Note")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={() => setCalloutType("note")}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Note")}
|
|
||||||
variant="subtle"
|
|
||||||
className={clsx({ [classes.active]: editorState?.isNote })}
|
|
||||||
>
|
|
||||||
<IconNotes size={18} color="var(--mantine-color-grape-5)" />
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -164,13 +143,9 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={() => setCalloutType("success")}
|
onClick={() => setCalloutType("success")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Success")}
|
aria-label={t("Success")}
|
||||||
variant="subtle"
|
variant={editorState?.isSuccess ? "light" : "default"}
|
||||||
className={clsx({ [classes.active]: editorState?.isSuccess })}
|
|
||||||
>
|
>
|
||||||
<IconCircleCheckFilled
|
<IconCircleCheckFilled size={18} />
|
||||||
size={18}
|
|
||||||
color="var(--mantine-color-green-5)"
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -179,13 +154,9 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={() => setCalloutType("warning")}
|
onClick={() => setCalloutType("warning")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Warning")}
|
aria-label={t("Warning")}
|
||||||
variant="subtle"
|
variant={editorState?.isWarning ? "light" : "default"}
|
||||||
className={clsx({ [classes.active]: editorState?.isWarning })}
|
|
||||||
>
|
>
|
||||||
<IconAlertTriangleFilled
|
<IconAlertTriangleFilled size={18} />
|
||||||
size={18}
|
|
||||||
color="var(--mantine-color-orange-5)"
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -194,10 +165,9 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={() => setCalloutType("danger")}
|
onClick={() => setCalloutType("danger")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Danger")}
|
aria-label={t("Danger")}
|
||||||
variant="subtle"
|
variant={editorState?.isDanger ? "light" : "default"}
|
||||||
className={clsx({ [classes.active]: editorState?.isDanger })}
|
|
||||||
>
|
>
|
||||||
<IconCircleXFilled size={18} color="var(--mantine-color-red-5)" />
|
<IconCircleXFilled size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -208,10 +178,11 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
icon={currentIcon || <IconMoodSmile size={18} />}
|
icon={currentIcon || <IconMoodSmile size={18} />}
|
||||||
actionIconProps={{
|
actionIconProps={{
|
||||||
size: "lg",
|
size: "lg",
|
||||||
variant: "subtle",
|
variant: "default",
|
||||||
|
c: undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ActionIcon.Group>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
IconCircleXFilled,
|
IconCircleXFilled,
|
||||||
IconInfoCircleFilled,
|
IconInfoCircleFilled,
|
||||||
IconNotes,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Alert } from "@mantine/core";
|
import { Alert } from "@mantine/core";
|
||||||
import classes from "./callout.module.css";
|
import classes from "./callout.module.css";
|
||||||
@@ -23,7 +22,6 @@ export default function CalloutView(props: NodeViewProps) {
|
|||||||
icon={getCalloutIcon(type, icon)}
|
icon={getCalloutIcon(type, icon)}
|
||||||
p="xs"
|
p="xs"
|
||||||
classNames={{
|
classNames={{
|
||||||
root: classes.root,
|
|
||||||
message: classes.message,
|
message: classes.message,
|
||||||
icon: classes.icon,
|
icon: classes.icon,
|
||||||
}}
|
}}
|
||||||
@@ -36,14 +34,12 @@ export default function CalloutView(props: NodeViewProps) {
|
|||||||
|
|
||||||
function getCalloutIcon(type: CalloutType, customIcon?: string) {
|
function getCalloutIcon(type: CalloutType, customIcon?: string) {
|
||||||
if (customIcon && customIcon.trim() !== "") {
|
if (customIcon && customIcon.trim() !== "") {
|
||||||
return <span style={{ fontSize: "18px" }}>{customIcon}</span>;
|
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "info":
|
case "info":
|
||||||
return <IconInfoCircleFilled />;
|
return <IconInfoCircleFilled />;
|
||||||
case "note":
|
|
||||||
return <IconNotes />;
|
|
||||||
case "success":
|
case "success":
|
||||||
return <IconCircleCheckFilled />;
|
return <IconCircleCheckFilled />;
|
||||||
case "warning":
|
case "warning":
|
||||||
@@ -59,8 +55,6 @@ function getCalloutColor(type: CalloutType) {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case "info":
|
case "info":
|
||||||
return "blue";
|
return "blue";
|
||||||
case "note":
|
|
||||||
return "grape";
|
|
||||||
case "success":
|
case "success":
|
||||||
return "green";
|
return "green";
|
||||||
case "warning":
|
case "warning":
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
.root {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin-inline-end: var(--mantine-spacing-xs);
|
margin-inline-end: var(--mantine-spacing-md);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -15,8 +11,18 @@
|
|||||||
.message {
|
.message {
|
||||||
font-size: var(--mantine-font-size-md);
|
font-size: var(--mantine-font-size-md);
|
||||||
color: var(--mantine-color-default-color);
|
color: var(--mantine-color-default-color);
|
||||||
overflow: visible;
|
|
||||||
text-overflow: unset;
|
white-space: nowrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
@mixin where-light {
|
||||||
|
color: var(--mantine-color-default-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin where-dark {
|
||||||
|
color: var(--mantine-color-default-color);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { ActionIcon, Group, Select, Tooltip } from "@mantine/core";
|
import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core";
|
||||||
import { CopyButton } from "@/components/common/copy-button";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||||
import classes from "./code-block.module.css";
|
import classes from "./code-block.module.css";
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
|
||||||
import React, { useCallback, useState } from "react";
|
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
|
||||||
import {
|
|
||||||
EditorMenuProps,
|
|
||||||
ShouldShowProps,
|
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
|
||||||
import { ActionIcon, Tooltip, Popover, Button } from "@mantine/core";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {
|
|
||||||
IconChevronDown,
|
|
||||||
IconCheck,
|
|
||||||
IconColumns2,
|
|
||||||
IconColumns3,
|
|
||||||
IconLayoutSidebar,
|
|
||||||
IconLayoutSidebarRight,
|
|
||||||
IconLayoutAlignCenter,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { isTextSelected } from "@docmost/editor-ext";
|
|
||||||
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
|
||||||
|
|
||||||
type LayoutPreset = {
|
|
||||||
layout: ColumnsLayout;
|
|
||||||
label: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
};
|
|
||||||
|
|
||||||
const twoColumnPresets: LayoutPreset[] = [
|
|
||||||
{ layout: "two_equal", label: "Equal columns", icon: IconColumns2 },
|
|
||||||
{
|
|
||||||
layout: "two_left_sidebar",
|
|
||||||
label: "Left sidebar",
|
|
||||||
icon: IconLayoutSidebar,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
layout: "two_right_sidebar",
|
|
||||||
label: "Right sidebar",
|
|
||||||
icon: IconLayoutSidebarRight,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const threeColumnPresets: LayoutPreset[] = [
|
|
||||||
{ layout: "three_equal", label: "Equal columns", icon: IconColumns3 },
|
|
||||||
{
|
|
||||||
layout: "three_with_sidebars",
|
|
||||||
label: "Wide center",
|
|
||||||
icon: IconLayoutAlignCenter,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
layout: "three_left_wide",
|
|
||||||
label: "Left wide",
|
|
||||||
icon: IconLayoutSidebarRight,
|
|
||||||
},
|
|
||||||
{ layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function getPresetsForCount(count: number): LayoutPreset[] {
|
|
||||||
if (count === 2) return twoColumnPresets;
|
|
||||||
if (count === 3) return threeColumnPresets;
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ColumnsMenu({ editor }: EditorMenuProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isCountOpen, setIsCountOpen] = useState(false);
|
|
||||||
|
|
||||||
const nodesWithMenus = [
|
|
||||||
"callout",
|
|
||||||
"image",
|
|
||||||
"video",
|
|
||||||
"drawio",
|
|
||||||
"excalidraw",
|
|
||||||
"table",
|
|
||||||
];
|
|
||||||
|
|
||||||
const shouldShow = useCallback(
|
|
||||||
({ state }: ShouldShowProps) => {
|
|
||||||
if (!state) return false;
|
|
||||||
if (!editor.isActive("columns")) return false;
|
|
||||||
if (isTextSelected(editor)) return false;
|
|
||||||
if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
|
|
||||||
|
|
||||||
const parent = findParentNode(
|
|
||||||
(node: PMNode) => node.type.name === "columns",
|
|
||||||
)(state.selection);
|
|
||||||
if (!parent) return false;
|
|
||||||
|
|
||||||
const dom = editor.view.nodeDOM(parent.pos) as HTMLElement;
|
|
||||||
if (!dom) return false;
|
|
||||||
|
|
||||||
const rect = dom.getBoundingClientRect();
|
|
||||||
return rect.bottom > 0 && rect.top < window.innerHeight;
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const editorState = useEditorState({
|
|
||||||
editor,
|
|
||||||
selector: (ctx) => {
|
|
||||||
if (!ctx.editor) return null;
|
|
||||||
|
|
||||||
const { selection } = ctx.editor.state;
|
|
||||||
const parent = findParentNode(
|
|
||||||
(node: PMNode) => node.type.name === "columns",
|
|
||||||
)(selection);
|
|
||||||
|
|
||||||
return {
|
|
||||||
columnCount: parent?.node.childCount || 2,
|
|
||||||
layout: (parent?.node.attrs.layout as ColumnsLayout) || "two_equal",
|
|
||||||
isNormal: ctx.editor.isActive("columns", { widthMode: "normal" }),
|
|
||||||
isWide: ctx.editor.isActive("columns", { widthMode: "wide" }),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
|
||||||
if (!editor) return;
|
|
||||||
const { selection } = editor.state;
|
|
||||||
const predicate = (node: PMNode) => node.type.name === "columns";
|
|
||||||
const parent = findParentNode(predicate)(selection);
|
|
||||||
|
|
||||||
if (parent) {
|
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
|
||||||
const domRect = dom.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Columns entirely out of viewport — return real rect so menu goes off-screen
|
|
||||||
if (domRect.bottom <= 0 || domRect.top >= window.innerHeight) {
|
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp bottom so menu stays within viewport when columns extend below it
|
|
||||||
// 55px = 15px offset + ~40px menu height
|
|
||||||
const maxBottom = window.innerHeight - 55;
|
|
||||||
if (domRect.bottom > maxBottom) {
|
|
||||||
const clamped = new DOMRect(
|
|
||||||
domRect.x,
|
|
||||||
domRect.y,
|
|
||||||
domRect.width,
|
|
||||||
maxBottom - domRect.y,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => clamped,
|
|
||||||
getClientRects: () => [clamped],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const setColumnCount = useCallback(
|
|
||||||
(count: number) => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus(undefined, { scrollIntoView: false })
|
|
||||||
.setColumnCount(count)
|
|
||||||
.run();
|
|
||||||
setIsCountOpen(false);
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setLayout = useCallback(
|
|
||||||
(layout: ColumnsLayout) => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus(undefined, { scrollIntoView: false })
|
|
||||||
.setColumnsLayout(layout)
|
|
||||||
.run();
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnCount = editorState?.columnCount || 2;
|
|
||||||
const currentLayout = editorState?.layout || "two_equal";
|
|
||||||
const presets = getPresetsForCount(columnCount);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseBubbleMenu
|
|
||||||
editor={editor}
|
|
||||||
pluginKey="columns-menu"
|
|
||||||
updateDelay={0}
|
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
|
||||||
options={{
|
|
||||||
placement: "bottom",
|
|
||||||
offset: {
|
|
||||||
mainAxis: 5,
|
|
||||||
},
|
|
||||||
flip: false,
|
|
||||||
}}
|
|
||||||
shouldShow={shouldShow}
|
|
||||||
>
|
|
||||||
<div className={classes.toolbar}>
|
|
||||||
<Popover opened={isCountOpen} onChange={setIsCountOpen} withArrow>
|
|
||||||
<Popover.Target>
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
color="dark"
|
|
||||||
size="compact-sm"
|
|
||||||
rightSection={<IconChevronDown size={12} />}
|
|
||||||
onClick={() => setIsCountOpen(!isCountOpen)}
|
|
||||||
aria-label={t("Column count")}
|
|
||||||
>
|
|
||||||
{t("{{count}} Columns", { count: columnCount })}
|
|
||||||
</Button>
|
|
||||||
</Popover.Target>
|
|
||||||
<Popover.Dropdown p={4}>
|
|
||||||
<Button.Group orientation="vertical">
|
|
||||||
{[2, 3, 4, 5].map((n) => (
|
|
||||||
<Button
|
|
||||||
key={n}
|
|
||||||
variant={n === columnCount ? "light" : "subtle"}
|
|
||||||
color={n === columnCount ? "blue" : "dark"}
|
|
||||||
justify="space-between"
|
|
||||||
fullWidth
|
|
||||||
rightSection={
|
|
||||||
n === columnCount ? <IconCheck size={14} /> : null
|
|
||||||
}
|
|
||||||
onClick={() => setColumnCount(n)}
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{t("{{count}} Columns", { count: n })}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Button.Group>
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{presets.length > 0 && <div className={classes.divider} />}
|
|
||||||
|
|
||||||
{presets.map((preset) => (
|
|
||||||
<Tooltip key={preset.layout} position="top" label={t(preset.label)}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={() => setLayout(preset.layout)}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t(preset.label)}
|
|
||||||
variant="subtle"
|
|
||||||
className={clsx({
|
|
||||||
[classes.active]: currentLayout === preset.layout,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<preset.icon size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</BaseBubbleMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ColumnsMenu;
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
|
||||||
import classes from "./node-resize.module.css";
|
|
||||||
|
|
||||||
export function createResizeHandle(
|
|
||||||
direction: ResizableNodeViewDirection,
|
|
||||||
): HTMLElement {
|
|
||||||
const handle = document.createElement("div");
|
|
||||||
handle.dataset.resizeHandle = direction;
|
|
||||||
handle.style.position = "absolute";
|
|
||||||
handle.className = classes.handle;
|
|
||||||
|
|
||||||
if (direction === "left") {
|
|
||||||
handle.style.left = "-8px";
|
|
||||||
handle.style.top = "0";
|
|
||||||
handle.style.bottom = "0";
|
|
||||||
} else if (direction === "right") {
|
|
||||||
handle.style.right = "-8px";
|
|
||||||
handle.style.top = "0";
|
|
||||||
handle.style.bottom = "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
const bar = document.createElement("div");
|
|
||||||
bar.className = classes.handleBar;
|
|
||||||
handle.appendChild(bar);
|
|
||||||
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildResizeClasses(nodeClass: string) {
|
|
||||||
return {
|
|
||||||
container: `${classes.container} ${nodeClass}`,
|
|
||||||
wrapper: classes.wrapper,
|
|
||||||
resizing: classes.resizing,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: visible;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper img,
|
|
||||||
.wrapper video {
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizing {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: ew-resize;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle[data-resize-handle="left"] {
|
|
||||||
left: -8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle[data-resize-handle="right"] {
|
|
||||||
right: -8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper:hover .handle {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizing .handle {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handleBar {
|
|
||||||
width: 4px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle:hover .handleBar {
|
|
||||||
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizing .handleBar {
|
|
||||||
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 3px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
|
||||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
|
||||||
box-shadow: 0 2px 12px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.35));
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar :global(.mantine-ActionIcon-root) {
|
|
||||||
--ai-color: light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-4)) !important;
|
|
||||||
--ai-hover: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar .active {
|
|
||||||
--ai-color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)) !important;
|
|
||||||
--ai-hover: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5)) !important;
|
|
||||||
background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5));
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 16px;
|
|
||||||
align-self: center;
|
|
||||||
margin: 0 2px;
|
|
||||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
|
|
||||||
}
|
|
||||||
@@ -1,41 +1,24 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback } from "react";
|
||||||
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 { ActionIcon, Modal, Tooltip, useComputedColorScheme } from "@mantine/core";
|
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {
|
|
||||||
IconLayoutAlignCenter,
|
|
||||||
IconLayoutAlignLeft,
|
|
||||||
IconLayoutAlignRight,
|
|
||||||
IconDownload,
|
|
||||||
IconEdit,
|
|
||||||
IconTrash,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
|
|
||||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
|
||||||
import {
|
|
||||||
DrawIoEmbed,
|
|
||||||
DrawIoEmbedRef,
|
|
||||||
EventExit,
|
|
||||||
EventSave,
|
|
||||||
} from "react-drawio";
|
|
||||||
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
|
|
||||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
|
||||||
|
|
||||||
export function DrawioMenu({ editor }: EditorMenuProps) {
|
export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const shouldShow = useCallback(
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
({ state }: ShouldShowProps) => {
|
||||||
const [initialXML, setInitialXML] = useState<string>("");
|
if (!state) {
|
||||||
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
return false;
|
||||||
const computedColorScheme = useComputedColorScheme();
|
}
|
||||||
|
|
||||||
|
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -47,26 +30,11 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
const drawioAttr = ctx.editor.getAttributes("drawio");
|
const drawioAttr = ctx.editor.getAttributes("drawio");
|
||||||
return {
|
return {
|
||||||
isDrawio: ctx.editor.isActive("drawio"),
|
isDrawio: ctx.editor.isActive("drawio"),
|
||||||
isAlignLeft: ctx.editor.isActive("drawio", { align: "left" }),
|
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
|
||||||
isAlignCenter: ctx.editor.isActive("drawio", { align: "center" }),
|
|
||||||
isAlignRight: ctx.editor.isActive("drawio", { align: "right" }),
|
|
||||||
src: drawioAttr?.src || null,
|
|
||||||
attachmentId: drawioAttr?.attachmentId || null,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldShow = useCallback(
|
|
||||||
({ state }: ShouldShowProps) => {
|
|
||||||
if (!state) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
@@ -89,218 +57,38 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignLeft = useCallback(() => {
|
const onWidthChange = useCallback(
|
||||||
editor
|
(value: number) => {
|
||||||
.chain()
|
editor.commands.updateAttributes("drawio", { width: `${value}%` });
|
||||||
.focus(undefined, { scrollIntoView: false })
|
|
||||||
.setDrawioAlign("left")
|
|
||||||
.run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const alignCenter = useCallback(() => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus(undefined, { scrollIntoView: false })
|
|
||||||
.setDrawioAlign("center")
|
|
||||||
.run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const alignRight = useCallback(() => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus(undefined, { scrollIntoView: false })
|
|
||||||
.setDrawioAlign("right")
|
|
||||||
.run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
|
||||||
if (!editorState?.src) return;
|
|
||||||
const url = getFileUrl(editorState.src);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = "";
|
|
||||||
a.click();
|
|
||||||
}, [editorState?.src]);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
|
||||||
editor.commands.deleteSelection();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const handleOpen = useCallback(async () => {
|
|
||||||
if (!editorState?.src) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = getFileUrl(editorState.src);
|
|
||||||
const request = await fetch(url, {
|
|
||||||
credentials: "include",
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
const blob = await request.blob();
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
reader.onloadend = () => {
|
|
||||||
const base64data = (reader.result || "") as string;
|
|
||||||
setInitialXML(base64data);
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
}, [editorState?.src, open]);
|
|
||||||
|
|
||||||
const handleSave = useCallback(
|
|
||||||
async (data: EventSave) => {
|
|
||||||
const svgString = decodeBase64ToSvgString(data.xml);
|
|
||||||
const fileName = "diagram.drawio.svg";
|
|
||||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const pageId = editor.storage?.pageId;
|
|
||||||
const attachmentId = editorState?.attachmentId;
|
|
||||||
|
|
||||||
let attachment: IAttachment = null;
|
|
||||||
if (attachmentId) {
|
|
||||||
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
|
||||||
} else {
|
|
||||||
attachment = await uploadFile(drawioSVGFile, pageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.commands.updateAttributes("drawio", {
|
|
||||||
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
|
||||||
title: attachment.fileName,
|
|
||||||
size: attachment.fileSize,
|
|
||||||
attachmentId: attachment.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
close();
|
|
||||||
},
|
},
|
||||||
[editor, editorState?.attachmentId, close],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<BaseBubbleMenu
|
||||||
<BaseBubbleMenu
|
editor={editor}
|
||||||
editor={editor}
|
pluginKey={`drawio-menu`}
|
||||||
pluginKey={`drawio-menu`}
|
updateDelay={0}
|
||||||
updateDelay={0}
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
options={{
|
||||||
options={{
|
placement: "top",
|
||||||
placement: "top",
|
offset: 8,
|
||||||
offset: 8,
|
flip: false,
|
||||||
flip: false,
|
}}
|
||||||
|
shouldShow={shouldShow}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
|
||||||
>
|
>
|
||||||
<div className={classes.toolbar}>
|
{editorState?.width && (
|
||||||
<Tooltip position="top" label={t("Align left")}>
|
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||||
<ActionIcon
|
)}
|
||||||
onClick={alignLeft}
|
</div>
|
||||||
size="lg"
|
</BaseBubbleMenu>
|
||||||
aria-label={t("Align left")}
|
|
||||||
variant="subtle"
|
|
||||||
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
|
||||||
>
|
|
||||||
<IconLayoutAlignLeft size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Align center")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={alignCenter}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Align center")}
|
|
||||||
variant="subtle"
|
|
||||||
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
|
||||||
>
|
|
||||||
<IconLayoutAlignCenter size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Align right")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={alignRight}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Align right")}
|
|
||||||
variant="subtle"
|
|
||||||
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
|
||||||
>
|
|
||||||
<IconLayoutAlignRight size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<div className={classes.divider} />
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Edit")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={handleOpen}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Edit")}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Download")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={handleDownload}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Download")}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
<IconDownload size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={handleDelete}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Delete")}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
<IconTrash size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</BaseBubbleMenu>
|
|
||||||
|
|
||||||
<Modal.Root opened={opened} onClose={close} fullScreen>
|
|
||||||
<Modal.Overlay />
|
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
|
||||||
<Modal.Body>
|
|
||||||
<div style={{ height: "100vh" }}>
|
|
||||||
<DrawIoEmbed
|
|
||||||
ref={drawioRef}
|
|
||||||
xml={initialXML}
|
|
||||||
baseUrl={getDrawioUrl()}
|
|
||||||
urlParameters={{
|
|
||||||
ui: computedColorScheme === "light" ? "kennedy" : "dark",
|
|
||||||
spin: true,
|
|
||||||
libraries: true,
|
|
||||||
saveAndExit: true,
|
|
||||||
noSaveBtn: true,
|
|
||||||
}}
|
|
||||||
onSave={(data: EventSave) => {
|
|
||||||
if (data.parentEvent !== "save") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleSave(data);
|
|
||||||
}}
|
|
||||||
onClose={(data: EventExit) => {
|
|
||||||
if (data.parentEvent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Modal.Body>
|
|
||||||
</Modal.Content>
|
|
||||||
</Modal.Root>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Card,
|
Card,
|
||||||
|
Image,
|
||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
useComputedColorScheme,
|
useComputedColorScheme,
|
||||||
@@ -9,7 +10,7 @@ import {
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { getDrawioUrl } from "@/lib/config.ts";
|
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
|
||||||
import {
|
import {
|
||||||
DrawIoEmbed,
|
DrawIoEmbed,
|
||||||
DrawIoEmbedRef,
|
DrawIoEmbedRef,
|
||||||
@@ -25,7 +26,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
export default function DrawioView(props: NodeViewProps) {
|
export default function DrawioView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, updateAttributes, editor, selected } = props;
|
const { node, updateAttributes, editor, selected } = props;
|
||||||
const { attachmentId } = node.attrs;
|
const { src, title, width, attachmentId } = node.attrs;
|
||||||
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||||
const [initialXML, setInitialXML] = useState<string>("");
|
const [initialXML, setInitialXML] = useState<string>("");
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
@@ -35,11 +36,33 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
open();
|
|
||||||
|
try {
|
||||||
|
if (src) {
|
||||||
|
const url = getFileUrl(src);
|
||||||
|
const request = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const blob = await request.blob();
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
reader.onloadend = () => {
|
||||||
|
const base64data = (reader.result || "") as string;
|
||||||
|
setInitialXML(base64data);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
open();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async (data: EventSave) => {
|
const handleSave = async (data: EventSave) => {
|
||||||
const svgString = decodeBase64ToSvgString(data.xml);
|
const svgString = decodeBase64ToSvgString(data.xml);
|
||||||
|
|
||||||
const fileName = "diagram.drawio.svg";
|
const fileName = "diagram.drawio.svg";
|
||||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
@@ -47,6 +70,7 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
|
|
||||||
let attachment: IAttachment = null;
|
let attachment: IAttachment = null;
|
||||||
|
|
||||||
if (attachmentId) {
|
if (attachmentId) {
|
||||||
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||||
} else {
|
} else {
|
||||||
@@ -82,12 +106,14 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
noSaveBtn: true,
|
noSaveBtn: true,
|
||||||
}}
|
}}
|
||||||
onSave={(data: EventSave) => {
|
onSave={(data: EventSave) => {
|
||||||
|
// If the save is triggered by another event, then do nothing
|
||||||
if (data.parentEvent !== "save") {
|
if (data.parentEvent !== "save") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleSave(data);
|
handleSave(data);
|
||||||
}}
|
}}
|
||||||
onClose={(data: EventExit) => {
|
onClose={(data: EventExit) => {
|
||||||
|
// If the exit is triggered by another event, then do nothing
|
||||||
if (data.parentEvent) {
|
if (data.parentEvent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -99,28 +125,62 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
</Modal.Root>
|
</Modal.Root>
|
||||||
|
|
||||||
<Card
|
{src ? (
|
||||||
radius="md"
|
<div style={{ position: "relative" }}>
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
<Image
|
||||||
p="xs"
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
style={{
|
radius="md"
|
||||||
display: "flex",
|
fit="contain"
|
||||||
justifyContent: "center",
|
w={width}
|
||||||
alignItems: "center",
|
src={getFileUrl(src)}
|
||||||
}}
|
alt={title}
|
||||||
withBorder
|
className={clsx(
|
||||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
selected ? "ProseMirror-selectednode" : "",
|
||||||
>
|
"alignCenter",
|
||||||
<div style={{ display: "flex", alignItems: "center" }}>
|
)}
|
||||||
<ActionIcon variant="transparent" color="gray">
|
/>
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
{selected && editor.isEditable && (
|
||||||
{t("Double-click to edit Draw.io diagram")}
|
<ActionIcon
|
||||||
</Text>
|
onClick={handleOpen}
|
||||||
|
variant="default"
|
||||||
|
color="gray"
|
||||||
|
mx="xs"
|
||||||
|
className="print-hide"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
) : (
|
||||||
|
<Card
|
||||||
|
radius="md"
|
||||||
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
withBorder
|
||||||
|
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<ActionIcon variant="transparent" color="gray">
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<Text component="span" size="lg" c="dimmed">
|
||||||
|
{t("Double-click to edit Draw.io diagram")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,26 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { lazy, Suspense, useCallback, useState } from "react";
|
import { useCallback } from "react";
|
||||||
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 {
|
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||||
ActionIcon,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Tooltip,
|
|
||||||
useComputedColorScheme,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {
|
|
||||||
IconLayoutAlignCenter,
|
|
||||||
IconLayoutAlignLeft,
|
|
||||||
IconLayoutAlignRight,
|
|
||||||
IconDownload,
|
|
||||||
IconEdit,
|
|
||||||
IconTrash,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
|
||||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
|
||||||
import { svgStringToFile } from "@/lib";
|
|
||||||
import "@excalidraw/excalidraw/index.css";
|
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
|
||||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
|
||||||
import ReactClearModal from "react-clear-modal";
|
|
||||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
|
||||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
|
||||||
|
|
||||||
const ExcalidrawComponent = lazy(() =>
|
|
||||||
import("@excalidraw/excalidraw").then((module) => ({
|
|
||||||
default: module.Excalidraw,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const shouldShow = useCallback(
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
({ state }: ShouldShowProps) => {
|
||||||
const [excalidrawAPI, setExcalidrawAPI] =
|
if (!state) {
|
||||||
useState<ExcalidrawImperativeAPI>(null);
|
return false;
|
||||||
useHandleLibrary({
|
}
|
||||||
excalidrawAPI,
|
|
||||||
adapter: localStorageLibraryAdapter,
|
return (
|
||||||
});
|
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
|
||||||
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
);
|
||||||
const computedColorScheme = useComputedColorScheme();
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -63,29 +32,11 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
|
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
|
||||||
return {
|
return {
|
||||||
isExcalidraw: ctx.editor.isActive("excalidraw"),
|
isExcalidraw: ctx.editor.isActive("excalidraw"),
|
||||||
isAlignLeft: ctx.editor.isActive("excalidraw", { align: "left" }),
|
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
|
||||||
isAlignCenter: ctx.editor.isActive("excalidraw", { align: "center" }),
|
|
||||||
isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }),
|
|
||||||
src: excalidrawAttr?.src || null,
|
|
||||||
attachmentId: excalidrawAttr?.attachmentId || null,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldShow = useCallback(
|
|
||||||
({ state }: ShouldShowProps) => {
|
|
||||||
if (!state) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
editor.isActive("excalidraw") &&
|
|
||||||
editor.getAttributes("excalidraw")?.src
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
@@ -108,248 +59,38 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignLeft = useCallback(() => {
|
const onWidthChange = useCallback(
|
||||||
editor
|
(value: number) => {
|
||||||
.chain()
|
editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
|
||||||
.focus(undefined, { scrollIntoView: false })
|
},
|
||||||
.setExcalidrawAlign("left")
|
[editor],
|
||||||
.run();
|
);
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const alignCenter = useCallback(() => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus(undefined, { scrollIntoView: false })
|
|
||||||
.setExcalidrawAlign("center")
|
|
||||||
.run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const alignRight = useCallback(() => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus(undefined, { scrollIntoView: false })
|
|
||||||
.setExcalidrawAlign("right")
|
|
||||||
.run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
|
||||||
if (!editorState?.src) return;
|
|
||||||
const url = getFileUrl(editorState.src);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = "";
|
|
||||||
a.click();
|
|
||||||
}, [editorState?.src]);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
|
||||||
editor.commands.deleteSelection();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const handleOpen = useCallback(async () => {
|
|
||||||
if (!editorState?.src) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = getFileUrl(editorState.src);
|
|
||||||
const request = await fetch(url, {
|
|
||||||
credentials: "include",
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { loadFromBlob } = await import("@excalidraw/excalidraw");
|
|
||||||
const data = await loadFromBlob(await request.blob(), null, null);
|
|
||||||
setExcalidrawData(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
}, [editorState?.src, open]);
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
if (!excalidrawAPI) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { exportToSvg } = await import("@excalidraw/excalidraw");
|
|
||||||
|
|
||||||
const svg = await exportToSvg({
|
|
||||||
elements: excalidrawAPI?.getSceneElements(),
|
|
||||||
appState: {
|
|
||||||
exportEmbedScene: true,
|
|
||||||
exportWithDarkMode: false,
|
|
||||||
},
|
|
||||||
files: excalidrawAPI?.getFiles(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const serializer = new XMLSerializer();
|
|
||||||
let svgString = serializer.serializeToString(svg);
|
|
||||||
|
|
||||||
svgString = svgString.replace(
|
|
||||||
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
|
|
||||||
"https://unpkg.com/@excalidraw/excalidraw@latest",
|
|
||||||
);
|
|
||||||
|
|
||||||
const fileName = "diagram.excalidraw.svg";
|
|
||||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const pageId = editor.storage?.pageId;
|
|
||||||
const attachmentId = editorState?.attachmentId;
|
|
||||||
|
|
||||||
let attachment: IAttachment = null;
|
|
||||||
if (attachmentId) {
|
|
||||||
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
|
|
||||||
} else {
|
|
||||||
attachment = await uploadFile(excalidrawSvgFile, pageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.commands.updateAttributes("excalidraw", {
|
|
||||||
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
|
||||||
title: attachment.fileName,
|
|
||||||
size: attachment.fileSize,
|
|
||||||
attachmentId: attachment.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
close();
|
|
||||||
}, [editor, excalidrawAPI, editorState?.attachmentId, close]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<BaseBubbleMenu
|
||||||
<BaseBubbleMenu
|
editor={editor}
|
||||||
editor={editor}
|
pluginKey={`excalidraw-menu`}
|
||||||
pluginKey={`excalidraw-menu`}
|
updateDelay={0}
|
||||||
updateDelay={0}
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
options={{
|
||||||
options={{
|
placement: "top",
|
||||||
placement: "top",
|
offset: 8,
|
||||||
offset: 8,
|
flip: false,
|
||||||
flip: false,
|
}}
|
||||||
}}
|
shouldShow={shouldShow}
|
||||||
shouldShow={shouldShow}
|
>
|
||||||
>
|
<div
|
||||||
<div className={classes.toolbar}>
|
|
||||||
<Tooltip position="top" label={t("Align left")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={alignLeft}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Align left")}
|
|
||||||
variant="subtle"
|
|
||||||
className={clsx({
|
|
||||||
[classes.active]: editorState?.isAlignLeft,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<IconLayoutAlignLeft size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Align center")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={alignCenter}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Align center")}
|
|
||||||
variant="subtle"
|
|
||||||
className={clsx({
|
|
||||||
[classes.active]: editorState?.isAlignCenter,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<IconLayoutAlignCenter size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Align right")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={alignRight}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Align right")}
|
|
||||||
variant="subtle"
|
|
||||||
className={clsx({
|
|
||||||
[classes.active]: editorState?.isAlignRight,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<IconLayoutAlignRight size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<div className={classes.divider} />
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Edit")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={handleOpen}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Edit")}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Download")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={handleDownload}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Download")}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
<IconDownload size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={handleDelete}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Delete")}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
<IconTrash size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</BaseBubbleMenu>
|
|
||||||
|
|
||||||
<ReactClearModal
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
display: "flex",
|
||||||
padding: 0,
|
flexDirection: "column",
|
||||||
zIndex: 200,
|
alignItems: "center",
|
||||||
}}
|
|
||||||
isOpen={opened}
|
|
||||||
onRequestClose={close}
|
|
||||||
disableCloseOnBgClick={true}
|
|
||||||
contentProps={{
|
|
||||||
style: {
|
|
||||||
padding: 0,
|
|
||||||
width: "90vw",
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group
|
{editorState?.width && (
|
||||||
justify="flex-end"
|
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||||
wrap="nowrap"
|
)}
|
||||||
bg="var(--mantine-color-body)"
|
</div>
|
||||||
p="xs"
|
</BaseBubbleMenu>
|
||||||
>
|
|
||||||
<Button onClick={handleSave} size={"compact-sm"}>
|
|
||||||
{t("Save & Exit")}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={close} color="red" size={"compact-sm"}>
|
|
||||||
{t("Exit")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
<div style={{ height: "90vh" }}>
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ExcalidrawComponent
|
|
||||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
|
||||||
initialData={{
|
|
||||||
...excalidrawData,
|
|
||||||
scrollToContent: true,
|
|
||||||
}}
|
|
||||||
theme={computedColorScheme}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</ReactClearModal>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,28 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
Image,
|
||||||
Text,
|
Text,
|
||||||
useComputedColorScheme,
|
useComputedColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { lazy, Suspense, useState } from "react";
|
import { useState } from "react";
|
||||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
import { svgStringToFile } from "@/lib";
|
import { svgStringToFile } from "@/lib";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||||
import ReactClearModal from "react-clear-modal";
|
import ReactClearModal from "react-clear-modal";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
|
import { lazy } from "react";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||||
|
|
||||||
const ExcalidrawComponent = lazy(() =>
|
const Excalidraw = lazy(() =>
|
||||||
import("@excalidraw/excalidraw").then((module) => ({
|
import("@excalidraw/excalidraw").then((module) => ({
|
||||||
default: module.Excalidraw,
|
default: module.Excalidraw,
|
||||||
})),
|
})),
|
||||||
@@ -30,7 +34,7 @@ const ExcalidrawComponent = lazy(() =>
|
|||||||
export default function ExcalidrawView(props: NodeViewProps) {
|
export default function ExcalidrawView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, updateAttributes, editor, selected } = props;
|
const { node, updateAttributes, editor, selected } = props;
|
||||||
const { attachmentId } = node.attrs;
|
const { src, title, width, attachmentId } = node.attrs;
|
||||||
|
|
||||||
const [excalidrawAPI, setExcalidrawAPI] =
|
const [excalidrawAPI, setExcalidrawAPI] =
|
||||||
useState<ExcalidrawImperativeAPI>(null);
|
useState<ExcalidrawImperativeAPI>(null);
|
||||||
@@ -46,7 +50,25 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
open();
|
|
||||||
|
try {
|
||||||
|
if (src) {
|
||||||
|
const url = getFileUrl(src);
|
||||||
|
const request = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { loadFromBlob } = await import("@excalidraw/excalidraw");
|
||||||
|
|
||||||
|
const data = await loadFromBlob(await request.blob(), null, null);
|
||||||
|
setExcalidrawData(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
open();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -129,7 +151,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
<div style={{ height: "90vh" }}>
|
<div style={{ height: "90vh" }}>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ExcalidrawComponent
|
<Excalidraw
|
||||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
initialData={{
|
initialData={{
|
||||||
...excalidrawData,
|
...excalidrawData,
|
||||||
@@ -141,28 +163,62 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</ReactClearModal>
|
</ReactClearModal>
|
||||||
|
|
||||||
<Card
|
{src ? (
|
||||||
radius="md"
|
<div style={{ position: "relative" }}>
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
<Image
|
||||||
p="xs"
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
style={{
|
radius="md"
|
||||||
display: "flex",
|
fit="contain"
|
||||||
justifyContent: "center",
|
w={width}
|
||||||
alignItems: "center",
|
src={getFileUrl(src)}
|
||||||
}}
|
alt={title}
|
||||||
withBorder
|
className={clsx(
|
||||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
selected ? "ProseMirror-selectednode" : "",
|
||||||
>
|
"alignCenter",
|
||||||
<div style={{ display: "flex", alignItems: "center" }}>
|
)}
|
||||||
<ActionIcon variant="transparent" color="gray">
|
/>
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
{selected && editor.isEditable && (
|
||||||
{t("Double-click to edit Excalidraw diagram")}
|
<ActionIcon
|
||||||
</Text>
|
onClick={handleOpen}
|
||||||
|
variant="default"
|
||||||
|
color="gray"
|
||||||
|
mx="xs"
|
||||||
|
className="print-hide"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
) : (
|
||||||
|
<Card
|
||||||
|
radius="md"
|
||||||
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
withBorder
|
||||||
|
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<ActionIcon variant="transparent" color="gray">
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<Text component="span" size="lg" c="dimmed">
|
||||||
|
{t("Double-click to edit Excalidraw diagram")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import React, { useCallback, useRef } from "react";
|
import React, { useCallback } from "react";
|
||||||
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 { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import clsx from "clsx";
|
|
||||||
import {
|
import {
|
||||||
IconLayoutAlignCenter,
|
IconLayoutAlignCenter,
|
||||||
IconLayoutAlignLeft,
|
IconLayoutAlignLeft,
|
||||||
IconLayoutAlignRight,
|
IconLayoutAlignRight,
|
||||||
IconDownload,
|
|
||||||
IconRefresh,
|
|
||||||
IconTrash,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
|
||||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
|
||||||
|
|
||||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -39,7 +32,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
|
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
|
||||||
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
||||||
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
||||||
src: imageAttrs?.src || null,
|
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -101,40 +94,17 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const onWidthChange = useCallback(
|
||||||
if (!editorState?.src) return;
|
(value: number) => {
|
||||||
const url = getFileUrl(editorState.src);
|
editor
|
||||||
const a = document.createElement("a");
|
.chain()
|
||||||
a.href = url;
|
.focus(undefined, { scrollIntoView: false })
|
||||||
a.download = "";
|
.setImageWidth(value)
|
||||||
a.click();
|
.run();
|
||||||
}, [editorState?.src]);
|
|
||||||
|
|
||||||
const handleReplace = useCallback(() => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFileChange = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const pageId = editor.storage?.pageId;
|
|
||||||
if (pageId) {
|
|
||||||
const pos = editor.state.selection.from;
|
|
||||||
uploadImageAction(file, editor, pos, pageId);
|
|
||||||
}
|
|
||||||
// Reset so the same file can be selected again
|
|
||||||
e.target.value = "";
|
|
||||||
},
|
},
|
||||||
[editor],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
|
||||||
editor.commands.deleteSelection();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@@ -148,14 +118,13 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div className={classes.toolbar}>
|
<ActionIcon.Group className="actionIconGroup">
|
||||||
<Tooltip position="top" label={t("Align left")}>
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignImageLeft}
|
onClick={alignImageLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align left")}
|
aria-label={t("Align left")}
|
||||||
variant="subtle"
|
variant={editorState?.isAlignLeft ? "light" : "default"}
|
||||||
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
|
||||||
>
|
>
|
||||||
<IconLayoutAlignLeft size={18} />
|
<IconLayoutAlignLeft size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -166,8 +135,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={alignImageCenter}
|
onClick={alignImageCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align center")}
|
aria-label={t("Align center")}
|
||||||
variant="subtle"
|
variant={editorState?.isAlignCenter ? "light" : "default"}
|
||||||
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
|
||||||
>
|
>
|
||||||
<IconLayoutAlignCenter size={18} />
|
<IconLayoutAlignCenter size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -178,56 +146,16 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={alignImageRight}
|
onClick={alignImageRight}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align right")}
|
aria-label={t("Align right")}
|
||||||
variant="subtle"
|
variant={editorState?.isAlignRight ? "light" : "default"}
|
||||||
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
|
||||||
>
|
>
|
||||||
<IconLayoutAlignRight size={18} />
|
<IconLayoutAlignRight size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</ActionIcon.Group>
|
||||||
|
|
||||||
<div className={classes.divider} />
|
{editorState?.width && (
|
||||||
|
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||||
<Tooltip position="top" label={t("Download")}>
|
)}
|
||||||
<ActionIcon
|
|
||||||
onClick={handleDownload}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Download")}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
<IconDownload size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Replace image")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={handleReplace}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Replace image")}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
<IconRefresh size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={handleDelete}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Delete")}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
<IconTrash size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import {
|
|
||||||
createResizeHandle,
|
|
||||||
buildResizeClasses,
|
|
||||||
} from "../common/node-resize-handles";
|
|
||||||
|
|
||||||
export const createImageHandle = createResizeHandle;
|
|
||||||
export const imageResizeClasses = buildResizeClasses("node-image");
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: visible;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper img {
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizing {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: ew-resize;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle[data-resize-handle="left"] {
|
|
||||||
left: -8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle[data-resize-handle="right"] {
|
|
||||||
right: -8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper:hover .handle {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizing .handle {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handleBar {
|
|
||||||
width: 4px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle:hover .handleBar {
|
|
||||||
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizing .handleBar {
|
|
||||||
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import React, {
|
|||||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
|
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Divider,
|
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -52,7 +51,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
||||||
const createPageMutation = useCreatePageMutation();
|
const createPageMutation = useCreatePageMutation();
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
const isInCommentContext = props.isInCommentContext ?? false;
|
|
||||||
|
|
||||||
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
||||||
query: props.query,
|
query: props.query,
|
||||||
@@ -60,7 +58,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
includePages: true,
|
includePages: true,
|
||||||
spaceId: space.id,
|
spaceId: space.id,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
preload: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const createPageItem = (label: string) : MentionSuggestionItem => {
|
const createPageItem = (label: string) : MentionSuggestionItem => {
|
||||||
@@ -105,9 +102,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isInCommentContext && props.query) {
|
items.push(createPageItem(props.query));
|
||||||
items.push(createPageItem(props.query));
|
|
||||||
}
|
|
||||||
|
|
||||||
setRenderItems(items);
|
setRenderItems(items);
|
||||||
// update editor storage
|
// update editor storage
|
||||||
@@ -255,51 +250,35 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if no results and enter what to do?
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
viewportRef.current
|
viewportRef.current
|
||||||
?.querySelector(`[data-item-index="${selectedIndex}"]`)
|
?.querySelector(`[data-item-index="${selectedIndex}"]`)
|
||||||
?.scrollIntoView({ block: "nearest" });
|
?.scrollIntoView({ block: "nearest" });
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
|
|
||||||
const popupWidth = isInCommentContext ? 280 : 320;
|
|
||||||
|
|
||||||
if (renderItems.length === 0) {
|
if (renderItems.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
|
<Paper shadow="md" p="xs" withBorder>
|
||||||
<Text c="dimmed" size="sm" px="sm">
|
{ t("No results") }
|
||||||
{ t("No results") }
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUsers = renderItems.some((item) => item.entityType === "user");
|
|
||||||
const hasPages = renderItems.some((item) => item.entityType === "page" && item.id !== null);
|
|
||||||
const createPageItemData = renderItems.find((item) => item.entityType === "page" && item.id === null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
|
<Paper id="mention" shadow="md" p="xs" withBorder>
|
||||||
<ScrollArea.Autosize
|
<ScrollArea.Autosize
|
||||||
viewportRef={viewportRef}
|
viewportRef={viewportRef}
|
||||||
mah={350}
|
mah={350}
|
||||||
w={popupWidth}
|
w={320}
|
||||||
scrollbarSize={6}
|
scrollbarSize={8}
|
||||||
>
|
>
|
||||||
{renderItems?.map((item, index) => {
|
{renderItems?.map((item, index) => {
|
||||||
if (item.entityType === "header") {
|
if (item.entityType === "header") {
|
||||||
const isFirst = index === 0;
|
|
||||||
return (
|
return (
|
||||||
<div key={`${item.label}-${index}`}>
|
<div key={`${item.label}-${index}`}>
|
||||||
{!isFirst && <Divider my={6} />}
|
<Text c="dimmed" mb={4} tt="uppercase">
|
||||||
<Text
|
|
||||||
c="dimmed"
|
|
||||||
size="xs"
|
|
||||||
fw={500}
|
|
||||||
px="sm"
|
|
||||||
pt={isFirst ? 2 : 4}
|
|
||||||
pb={4}
|
|
||||||
tt="uppercase"
|
|
||||||
>
|
|
||||||
{item.label}
|
{item.label}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,9 +292,8 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
className={clsx(classes.menuBtn, {
|
className={clsx(classes.menuBtn, {
|
||||||
[classes.selectedItem]: index === selectedIndex,
|
[classes.selectedItem]: index === selectedIndex,
|
||||||
})}
|
})}
|
||||||
px="sm"
|
|
||||||
>
|
>
|
||||||
<Group gap="sm">
|
<Group>
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
size={"sm"}
|
size={"sm"}
|
||||||
avatarUrl={item.avatarUrl}
|
avatarUrl={item.avatarUrl}
|
||||||
@@ -330,7 +308,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
);
|
);
|
||||||
} else if (item.entityType === "page" && item.id !== null) {
|
} else if (item.entityType === "page") {
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
data-item-index={index}
|
data-item-index={index}
|
||||||
@@ -339,24 +317,28 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
className={clsx(classes.menuBtn, {
|
className={clsx(classes.menuBtn, {
|
||||||
[classes.selectedItem]: index === selectedIndex,
|
[classes.selectedItem]: index === selectedIndex,
|
||||||
})}
|
})}
|
||||||
px="sm"
|
|
||||||
>
|
>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="default"
|
||||||
component="div"
|
component="div"
|
||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
color="gray"
|
|
||||||
size="sm"
|
|
||||||
>
|
>
|
||||||
{item.icon || (
|
{item.icon || (
|
||||||
<IconFileDescription size={18} stroke={1.5} />
|
<ActionIcon
|
||||||
|
component="span"
|
||||||
|
variant="transparent"
|
||||||
|
color="gray"
|
||||||
|
size={18}
|
||||||
|
>
|
||||||
|
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
|
||||||
|
</ActionIcon>
|
||||||
)}
|
)}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Text size="sm" fw={500} truncate>
|
<Text size="sm" fw={500}>
|
||||||
{item.label}
|
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -366,37 +348,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{createPageItemData && !isInCommentContext && (
|
|
||||||
<>
|
|
||||||
{(hasUsers || hasPages) && <Divider my={6} />}
|
|
||||||
<UnstyledButton
|
|
||||||
data-item-index={renderItems.indexOf(createPageItemData)}
|
|
||||||
onClick={() => selectItem(renderItems.indexOf(createPageItemData))}
|
|
||||||
className={clsx(classes.menuBtn, {
|
|
||||||
[classes.selectedItem]: renderItems.indexOf(createPageItemData) === selectedIndex,
|
|
||||||
})}
|
|
||||||
px="sm"
|
|
||||||
>
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
component="div"
|
|
||||||
color="gray"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<IconPlus size={16} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Text size="sm" fw={500} truncate>
|
|
||||||
{t("Create page")}: {createPageItemData.label}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ScrollArea.Autosize>
|
</ScrollArea.Autosize>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,13 +17,8 @@ const mentionRenderItems = () => {
|
|||||||
let component: ReactRenderer | null = null;
|
let component: ReactRenderer | null = null;
|
||||||
let activeClientRect: (() => DOMRect) | null = null;
|
let activeClientRect: (() => DOMRect) | null = null;
|
||||||
let updatePositionCleanup: (() => void) | null = null;
|
let updatePositionCleanup: (() => void) | null = null;
|
||||||
let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
|
|
||||||
|
|
||||||
const destroy = () => {
|
const destroy = () => {
|
||||||
if (outsideClickHandler) {
|
|
||||||
document.removeEventListener("pointerdown", outsideClickHandler);
|
|
||||||
outsideClickHandler = null;
|
|
||||||
}
|
|
||||||
updatePositionCleanup?.();
|
updatePositionCleanup?.();
|
||||||
updatePositionCleanup = null;
|
updatePositionCleanup = null;
|
||||||
component?.destroy();
|
component?.destroy();
|
||||||
@@ -50,14 +45,8 @@ const mentionRenderItems = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const editorDom = props.editor?.view?.dom;
|
|
||||||
const asideEl = editorDom?.closest(".mantine-AppShell-aside");
|
|
||||||
const dialogEl = editorDom?.closest("[data-comment-dialog]");
|
|
||||||
const isInCommentContext = !!(asideEl || dialogEl);
|
|
||||||
// const isInCommentContext = !!asideEl;
|
|
||||||
|
|
||||||
component = new ReactRenderer(MentionList, {
|
component = new ReactRenderer(MentionList, {
|
||||||
props: { ...props, isInCommentContext },
|
props,
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,18 +59,6 @@ const mentionRenderItems = () => {
|
|||||||
const { element } = component;
|
const { element } = component;
|
||||||
document.body.appendChild(element);
|
document.body.appendChild(element);
|
||||||
|
|
||||||
outsideClickHandler = (e: MouseEvent) => {
|
|
||||||
const target = e.target as Node;
|
|
||||||
if (element && !element.contains(target)) {
|
|
||||||
destroy();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("pointerdown", outsideClickHandler);
|
|
||||||
|
|
||||||
const shiftMiddleware = asideEl
|
|
||||||
? shift({ boundary: asideEl, crossAxis: true, padding: 8 })
|
|
||||||
: shift();
|
|
||||||
|
|
||||||
updatePositionCleanup = autoUpdate(
|
updatePositionCleanup = autoUpdate(
|
||||||
{
|
{
|
||||||
getBoundingClientRect: () =>
|
getBoundingClientRect: () =>
|
||||||
@@ -99,7 +76,7 @@ const mentionRenderItems = () => {
|
|||||||
element,
|
element,
|
||||||
{
|
{
|
||||||
placement: "bottom-start",
|
placement: "bottom-start",
|
||||||
middleware: [offset(4), flip(), shiftMiddleware],
|
middleware: [offset(0), flip(), shift()],
|
||||||
},
|
},
|
||||||
).then(({ x, y }) => {
|
).then(({ x, y }) => {
|
||||||
Object.assign(element.style, {
|
Object.assign(element.style, {
|
||||||
|
|||||||
@@ -31,14 +31,14 @@
|
|||||||
|
|
||||||
.menuBtn {
|
.menuBtn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 4px;
|
padding: 4px;
|
||||||
margin-bottom: 1px;
|
margin-bottom: 2px;
|
||||||
color: var(--mantine-color-text);
|
color: var(--mantine-color-text);
|
||||||
border-radius: var(--mantine-radius-sm);
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@mixin light {
|
@mixin light {
|
||||||
background: var(--mantine-color-gray-1);
|
background: var(--mantine-color-gray-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin dark {
|
@mixin dark {
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
.selectedItem {
|
.selectedItem {
|
||||||
@mixin light {
|
@mixin light {
|
||||||
background: var(--mantine-color-gray-1);
|
background: var(--mantine-color-gray-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin dark {
|
@mixin dark {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export interface MentionListProps {
|
|||||||
range: Range;
|
range: Range;
|
||||||
text: string;
|
text: string;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
isInCommentContext?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MentionSuggestionItem =
|
export type MentionSuggestionItem =
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ import {
|
|||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconAppWindow,
|
IconAppWindow,
|
||||||
IconSitemap,
|
IconSitemap,
|
||||||
IconColumns3,
|
|
||||||
IconColumns2,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
@@ -33,8 +31,6 @@ import { uploadAttachmentAction } from "@/features/editor/components/attachment/
|
|||||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||||
import IconMermaid from "@/components/icons/icon-mermaid";
|
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||||
import IconDrawio from "@/components/icons/icon-drawio";
|
import IconDrawio from "@/components/icons/icon-drawio";
|
||||||
import { IconColumns4 } from "@/components/icons/icon-columns-4";
|
|
||||||
import { IconColumns5 } from "@/components/icons/icon-columns-5";
|
|
||||||
import {
|
import {
|
||||||
AirtableIcon,
|
AirtableIcon,
|
||||||
FigmaIcon,
|
FigmaIcon,
|
||||||
@@ -174,8 +170,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "image/*";
|
input.accept = "image/*";
|
||||||
input.multiple = true;
|
input.multiple = true;
|
||||||
input.style.display = "none";
|
|
||||||
document.body.appendChild(input);
|
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
for (const file of input.files) {
|
for (const file of input.files) {
|
||||||
@@ -185,7 +179,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input.remove();
|
// Reset the input value to allow uploading the same file again if needed
|
||||||
|
input.value = "";
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
@@ -207,8 +202,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "video/*";
|
input.accept = "video/*";
|
||||||
input.multiple = true;
|
input.multiple = true;
|
||||||
input.style.display = "none";
|
|
||||||
document.body.appendChild(input);
|
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
for (const file of input.files) {
|
for (const file of input.files) {
|
||||||
@@ -218,7 +211,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input.remove();
|
// Reset the input value to allow uploading the same file again if needed
|
||||||
|
input.value = "";
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
@@ -240,8 +234,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "";
|
input.accept = "";
|
||||||
input.multiple = true;
|
input.multiple = true;
|
||||||
input.style.display = "none";
|
|
||||||
document.body.appendChild(input);
|
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
for (const file of input.files) {
|
for (const file of input.files) {
|
||||||
@@ -251,7 +243,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input.remove();
|
// Reset the input value to allow uploading the same file again if needed
|
||||||
|
input.value = "";
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
@@ -394,58 +387,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "2 Columns",
|
|
||||||
description: "Split content into two columns.",
|
|
||||||
searchTerms: ["columns", "layout", "split", "side"],
|
|
||||||
icon: IconColumns2,
|
|
||||||
command: ({ editor, range }: CommandProps) =>
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange(range)
|
|
||||||
.insertColumns({ layout: "two_equal" })
|
|
||||||
.run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "3 Columns",
|
|
||||||
description: "Split content into three columns.",
|
|
||||||
searchTerms: ["columns", "layout", "split", "triple"],
|
|
||||||
icon: IconColumns3,
|
|
||||||
command: ({ editor, range }: CommandProps) =>
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange(range)
|
|
||||||
.insertColumns({ layout: "three_equal" })
|
|
||||||
.run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "4 Columns",
|
|
||||||
description: "Split content into four columns.",
|
|
||||||
searchTerms: ["columns", "layout", "split"],
|
|
||||||
icon: IconColumns4,
|
|
||||||
command: ({ editor, range }: CommandProps) =>
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange(range)
|
|
||||||
.insertColumns({ layout: "four_equal" })
|
|
||||||
.run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "5 Columns",
|
|
||||||
description: "Split content into five columns.",
|
|
||||||
searchTerms: ["columns", "layout", "split"],
|
|
||||||
icon: IconColumns5,
|
|
||||||
command: ({ editor, range }: CommandProps) =>
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange(range)
|
|
||||||
.insertColumns({ layout: "five_equal" })
|
|
||||||
.run(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Iframe embed",
|
title: "Iframe embed",
|
||||||
description: "Embed any Iframe",
|
description: "Embed any Iframe",
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
|||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t("Background color")} withArrow>
|
<Tooltip label={t("Background color")} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Background color")}
|
aria-label={t("Background color")}
|
||||||
onClick={() => setOpened(!opened)}
|
onClick={() => setOpened(!opened)}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { TableBackgroundColor } from "./table-background-color";
|
import { TableBackgroundColor } from "./table-background-color";
|
||||||
import { TableTextAlignment } from "./table-text-alignment";
|
import { TableTextAlignment } from "./table-text-alignment";
|
||||||
import { BubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu } from "@tiptap/react/menus";
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
|
||||||
|
|
||||||
export const TableCellMenu = React.memo(
|
export const TableCellMenu = React.memo(
|
||||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||||
@@ -70,16 +69,14 @@ export const TableCellMenu = React.memo(
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div className={classes.toolbar}>
|
<ActionIcon.Group>
|
||||||
<TableBackgroundColor editor={editor} />
|
<TableBackgroundColor editor={editor} />
|
||||||
<TableTextAlignment editor={editor} />
|
<TableTextAlignment editor={editor} />
|
||||||
|
|
||||||
<div className={classes.divider} />
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Merge cells")}>
|
<Tooltip position="top" label={t("Merge cells")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={mergeCells}
|
onClick={mergeCells}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Merge cells")}
|
aria-label={t("Merge cells")}
|
||||||
>
|
>
|
||||||
@@ -90,7 +87,7 @@ export const TableCellMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Split cell")}>
|
<Tooltip position="top" label={t("Split cell")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={splitCell}
|
onClick={splitCell}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Split cell")}
|
aria-label={t("Split cell")}
|
||||||
>
|
>
|
||||||
@@ -98,12 +95,10 @@ export const TableCellMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div className={classes.divider} />
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete column")}>
|
<Tooltip position="top" label={t("Delete column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteColumn}
|
onClick={deleteColumn}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Delete column")}
|
aria-label={t("Delete column")}
|
||||||
>
|
>
|
||||||
@@ -114,7 +109,7 @@ export const TableCellMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Delete row")}>
|
<Tooltip position="top" label={t("Delete row")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteRow}
|
onClick={deleteRow}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Delete row")}
|
aria-label={t("Delete row")}
|
||||||
>
|
>
|
||||||
@@ -122,19 +117,17 @@ export const TableCellMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div className={classes.divider} />
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header cell")}>
|
<Tooltip position="top" label={t("Toggle header cell")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderCell}
|
onClick={toggleHeaderCell}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Toggle header cell")}
|
aria-label={t("Toggle header cell")}
|
||||||
>
|
>
|
||||||
<IconTableRow size={18} />
|
<IconTableRow size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</ActionIcon.Group>
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ import {
|
|||||||
IconTrashX,
|
IconTrashX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { BubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu } from "@tiptap/react/menus";
|
||||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
import { isCellSelection } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
|
||||||
|
|
||||||
export const TableMenu = React.memo(
|
export const TableMenu = React.memo(
|
||||||
({ editor }: EditorMenuProps): JSX.Element => {
|
({ editor }: EditorMenuProps): JSX.Element => {
|
||||||
@@ -31,7 +30,6 @@ export const TableMenu = React.memo(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTextSelected(editor)) return false;
|
|
||||||
return editor.isActive("table") && !isCellSelection(state.selection);
|
return editor.isActive("table") && !isCellSelection(state.selection);
|
||||||
},
|
},
|
||||||
[editor]
|
[editor]
|
||||||
@@ -120,11 +118,11 @@ export const TableMenu = React.memo(
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div className={classes.toolbar}>
|
<ActionIcon.Group>
|
||||||
<Tooltip position="top" label={t("Add left column")}>
|
<Tooltip position="top" label={t("Add left column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addColumnLeft}
|
onClick={addColumnLeft}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Add left column")}
|
aria-label={t("Add left column")}
|
||||||
>
|
>
|
||||||
@@ -135,7 +133,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Add right column")}>
|
<Tooltip position="top" label={t("Add right column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addColumnRight}
|
onClick={addColumnRight}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Add right column")}
|
aria-label={t("Add right column")}
|
||||||
>
|
>
|
||||||
@@ -146,7 +144,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Delete column")}>
|
<Tooltip position="top" label={t("Delete column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteColumn}
|
onClick={deleteColumn}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Delete column")}
|
aria-label={t("Delete column")}
|
||||||
>
|
>
|
||||||
@@ -154,12 +152,10 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div className={classes.divider} />
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Add row above")}>
|
<Tooltip position="top" label={t("Add row above")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addRowAbove}
|
onClick={addRowAbove}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Add row above")}
|
aria-label={t("Add row above")}
|
||||||
>
|
>
|
||||||
@@ -170,7 +166,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Add row below")}>
|
<Tooltip position="top" label={t("Add row below")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addRowBelow}
|
onClick={addRowBelow}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Add row below")}
|
aria-label={t("Add row below")}
|
||||||
>
|
>
|
||||||
@@ -181,7 +177,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Delete row")}>
|
<Tooltip position="top" label={t("Delete row")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteRow}
|
onClick={deleteRow}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Delete row")}
|
aria-label={t("Delete row")}
|
||||||
>
|
>
|
||||||
@@ -189,12 +185,10 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div className={classes.divider} />
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header row")}>
|
<Tooltip position="top" label={t("Toggle header row")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderRow}
|
onClick={toggleHeaderRow}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Toggle header row")}
|
aria-label={t("Toggle header row")}
|
||||||
>
|
>
|
||||||
@@ -205,7 +199,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Toggle header column")}>
|
<Tooltip position="top" label={t("Toggle header column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderColumn}
|
onClick={toggleHeaderColumn}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Toggle header column")}
|
aria-label={t("Toggle header column")}
|
||||||
>
|
>
|
||||||
@@ -213,19 +207,18 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div className={classes.divider} />
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete table")}>
|
<Tooltip position="top" label={t("Delete table")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteTable}
|
onClick={deleteTable}
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
color="red"
|
||||||
aria-label={t("Delete table")}
|
aria-label={t("Delete table")}
|
||||||
>
|
>
|
||||||
<IconTrashX size={18} />
|
<IconTrashX size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</ActionIcon.Group>
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
|||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t("Text alignment")} withArrow>
|
<Tooltip label={t("Text alignment")} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Text alignment")}
|
aria-label={t("Text alignment")}
|
||||||
onClick={() => setOpened(!opened)}
|
onClick={() => setOpened(!opened)}
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
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 { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import clsx from "clsx";
|
|
||||||
import {
|
import {
|
||||||
IconLayoutAlignCenter,
|
IconLayoutAlignCenter,
|
||||||
IconLayoutAlignLeft,
|
IconLayoutAlignLeft,
|
||||||
IconLayoutAlignRight,
|
IconLayoutAlignRight,
|
||||||
IconDownload,
|
|
||||||
IconTrash,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
|
||||||
|
|
||||||
export function VideoMenu({ editor }: EditorMenuProps) {
|
export function VideoMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -36,7 +32,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
|
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
|
||||||
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
|
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
|
||||||
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
|
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
|
||||||
src: videoAttrs?.src || null,
|
width: videoAttrs?.width ? parseInt(videoAttrs.width) : null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -74,7 +70,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignLeft = useCallback(() => {
|
const alignVideoLeft = useCallback(() => {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus(undefined, { scrollIntoView: false })
|
.focus(undefined, { scrollIntoView: false })
|
||||||
@@ -82,7 +78,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignCenter = useCallback(() => {
|
const alignVideoCenter = useCallback(() => {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus(undefined, { scrollIntoView: false })
|
.focus(undefined, { scrollIntoView: false })
|
||||||
@@ -90,7 +86,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignRight = useCallback(() => {
|
const alignVideoRight = useCallback(() => {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus(undefined, { scrollIntoView: false })
|
.focus(undefined, { scrollIntoView: false })
|
||||||
@@ -98,18 +94,16 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const onWidthChange = useCallback(
|
||||||
if (!editorState?.src) return;
|
(value: number) => {
|
||||||
const url = getFileUrl(editorState.src);
|
editor
|
||||||
const a = document.createElement("a");
|
.chain()
|
||||||
a.href = url;
|
.focus(undefined, { scrollIntoView: false })
|
||||||
a.download = "";
|
.setVideoWidth(value)
|
||||||
a.click();
|
.run();
|
||||||
}, [editorState?.src]);
|
},
|
||||||
|
[editor],
|
||||||
const handleDelete = useCallback(() => {
|
);
|
||||||
editor.commands.deleteSelection();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
@@ -124,14 +118,13 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div className={classes.toolbar}>
|
<ActionIcon.Group className="actionIconGroup">
|
||||||
<Tooltip position="top" label={t("Align left")}>
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignLeft}
|
onClick={alignVideoLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align left")}
|
aria-label={t("Align left")}
|
||||||
variant="subtle"
|
variant={editorState?.isAlignLeft ? "light" : "default"}
|
||||||
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
|
||||||
>
|
>
|
||||||
<IconLayoutAlignLeft size={18} />
|
<IconLayoutAlignLeft size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -139,11 +132,10 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
<Tooltip position="top" label={t("Align center")}>
|
<Tooltip position="top" label={t("Align center")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignCenter}
|
onClick={alignVideoCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align center")}
|
aria-label={t("Align center")}
|
||||||
variant="subtle"
|
variant={editorState?.isAlignCenter ? "light" : "default"}
|
||||||
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
|
||||||
>
|
>
|
||||||
<IconLayoutAlignCenter size={18} />
|
<IconLayoutAlignCenter size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -151,40 +143,19 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
<Tooltip position="top" label={t("Align right")}>
|
<Tooltip position="top" label={t("Align right")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignRight}
|
onClick={alignVideoRight}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align right")}
|
aria-label={t("Align right")}
|
||||||
variant="subtle"
|
variant={editorState?.isAlignRight ? "light" : "default"}
|
||||||
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
|
||||||
>
|
>
|
||||||
<IconLayoutAlignRight size={18} />
|
<IconLayoutAlignRight size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</ActionIcon.Group>
|
||||||
|
|
||||||
<div className={classes.divider} />
|
{editorState?.width && (
|
||||||
|
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||||
<Tooltip position="top" label={t("Download")}>
|
)}
|
||||||
<ActionIcon
|
|
||||||
onClick={handleDownload}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Download")}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
<IconDownload size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={handleDelete}
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Delete")}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
<IconTrash size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ import {
|
|||||||
Highlight,
|
Highlight,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
SharedStorage,
|
SharedStorage,
|
||||||
Columns,
|
|
||||||
Column,
|
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -54,14 +52,6 @@ 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 ImageView from "@/features/editor/components/image/image-view.tsx";
|
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||||
import {
|
|
||||||
createImageHandle,
|
|
||||||
imageResizeClasses,
|
|
||||||
} from "@/features/editor/components/image/image-resize-handles.ts";
|
|
||||||
import {
|
|
||||||
createResizeHandle,
|
|
||||||
buildResizeClasses,
|
|
||||||
} from "@/features/editor/components/common/node-resize-handles.ts";
|
|
||||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||||
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";
|
||||||
@@ -101,7 +91,6 @@ lowlight.register("fortran", fortran);
|
|||||||
lowlight.register("haskell", haskell);
|
lowlight.register("haskell", haskell);
|
||||||
lowlight.register("scala", scala);
|
lowlight.register("scala", scala);
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
export const mainExtensions = [
|
export const mainExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: false,
|
heading: false,
|
||||||
@@ -126,7 +115,7 @@ export const mainExtensions = [
|
|||||||
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: ({ editor, node, pos }) => {
|
placeholder: ({ node }) => {
|
||||||
if (node.type.name === "heading") {
|
if (node.type.name === "heading") {
|
||||||
return i18n.t("Heading {{level}}", { level: node.attrs.level });
|
return i18n.t("Heading {{level}}", { level: node.attrs.level });
|
||||||
}
|
}
|
||||||
@@ -134,17 +123,6 @@ export const mainExtensions = [
|
|||||||
return i18n.t("Toggle title");
|
return i18n.t("Toggle title");
|
||||||
}
|
}
|
||||||
if (node.type.name === "paragraph") {
|
if (node.type.name === "paragraph") {
|
||||||
const $pos = editor.state.doc.resolve(pos);
|
|
||||||
const parentName = $pos.parent.type.name;
|
|
||||||
if (
|
|
||||||
parentName === "column" ||
|
|
||||||
parentName === "tableCell" ||
|
|
||||||
parentName === "tableHeader" ||
|
|
||||||
parentName === "callout" ||
|
|
||||||
parentName === "blockquote"
|
|
||||||
) {
|
|
||||||
return i18n.t("Write...");
|
|
||||||
}
|
|
||||||
return i18n.t('Write anything. Enter "/" for commands');
|
return i18n.t('Write anything. Enter "/" for commands');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -222,29 +200,9 @@ export const mainExtensions = [
|
|||||||
TiptapImage.configure({
|
TiptapImage.configure({
|
||||||
view: ImageView,
|
view: ImageView,
|
||||||
allowBase64: false,
|
allowBase64: false,
|
||||||
resize: {
|
|
||||||
enabled: true,
|
|
||||||
directions: ["left", "right"],
|
|
||||||
minWidth: 80,
|
|
||||||
minHeight: 40,
|
|
||||||
alwaysPreserveAspectRatio: true,
|
|
||||||
//@ts-ignore
|
|
||||||
createCustomHandle: createImageHandle,
|
|
||||||
className: imageResizeClasses,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
TiptapVideo.configure({
|
TiptapVideo.configure({
|
||||||
view: VideoView,
|
view: VideoView,
|
||||||
resize: {
|
|
||||||
enabled: true,
|
|
||||||
directions: ["left", "right"],
|
|
||||||
minWidth: 80,
|
|
||||||
minHeight: 40,
|
|
||||||
alwaysPreserveAspectRatio: true,
|
|
||||||
//@ts-ignore
|
|
||||||
createCustomHandle: createResizeHandle,
|
|
||||||
className: buildResizeClasses("node-video"),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
Callout.configure({
|
Callout.configure({
|
||||||
view: CalloutView,
|
view: CalloutView,
|
||||||
@@ -263,29 +221,9 @@ export const mainExtensions = [
|
|||||||
}),
|
}),
|
||||||
Drawio.configure({
|
Drawio.configure({
|
||||||
view: DrawioView,
|
view: DrawioView,
|
||||||
resize: {
|
|
||||||
enabled: true,
|
|
||||||
directions: ["left", "right"],
|
|
||||||
minWidth: 80,
|
|
||||||
minHeight: 40,
|
|
||||||
alwaysPreserveAspectRatio: true,
|
|
||||||
//@ts-ignore
|
|
||||||
createCustomHandle: createResizeHandle,
|
|
||||||
className: buildResizeClasses("node-drawio"),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
Excalidraw.configure({
|
Excalidraw.configure({
|
||||||
view: ExcalidrawView,
|
view: ExcalidrawView,
|
||||||
resize: {
|
|
||||||
enabled: true,
|
|
||||||
directions: ["left", "right"],
|
|
||||||
minWidth: 80,
|
|
||||||
minHeight: 40,
|
|
||||||
alwaysPreserveAspectRatio: true,
|
|
||||||
//@ts-ignore
|
|
||||||
createCustomHandle: createResizeHandle,
|
|
||||||
className: buildResizeClasses("node-excalidraw"),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
Embed.configure({
|
Embed.configure({
|
||||||
view: EmbedView,
|
view: EmbedView,
|
||||||
@@ -315,8 +253,6 @@ export const mainExtensions = [
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}).configure(),
|
}).configure(),
|
||||||
Columns,
|
|
||||||
Column,
|
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ 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 { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
|
||||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -407,7 +405,6 @@ export default function PageEditor({
|
|||||||
|
|
||||||
{editor && editorIsEditable && (
|
{editor && editorIsEditable && (
|
||||||
<div>
|
<div>
|
||||||
<EditorAiMenu editor={editor} />
|
|
||||||
<EditorBubbleMenu editor={editor} />
|
<EditorBubbleMenu editor={editor} />
|
||||||
<TableMenu editor={editor} />
|
<TableMenu editor={editor} />
|
||||||
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
@@ -417,7 +414,6 @@ export default function PageEditor({
|
|||||||
<SubpagesMenu editor={editor} />
|
<SubpagesMenu editor={editor} />
|
||||||
<ExcalidrawMenu editor={editor} />
|
<ExcalidrawMenu editor={editor} />
|
||||||
<DrawioMenu editor={editor} />
|
<DrawioMenu editor={editor} />
|
||||||
<ColumnsMenu editor={editor} />
|
|
||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef } 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, UniqueID } from "@docmost/editor-ext";
|
import { Heading, generateNodeId, UniqueID } from "@docmost/editor-ext";
|
||||||
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";
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
div[data-type="columns"] {
|
|
||||||
display: flex;
|
|
||||||
margin: 0.75rem 0;
|
|
||||||
padding: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"] > div[data-type="column"] {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
|
|
||||||
border-left: 1px solid transparent;
|
|
||||||
padding-left: 1rem;
|
|
||||||
transition: border 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"]:hover
|
|
||||||
> div[data-type="column"]
|
|
||||||
+ div[data-type="column"] {
|
|
||||||
border-left: 1px solid
|
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-7));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Confluence layout types */
|
|
||||||
div[data-type="columns"][data-layout="two_left_sidebar"]
|
|
||||||
> div[data-type="column"]:first-child {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"][data-layout="two_left_sidebar"]
|
|
||||||
> div[data-type="column"]:last-child {
|
|
||||||
flex: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"][data-layout="two_right_sidebar"]
|
|
||||||
> div[data-type="column"]:first-child {
|
|
||||||
flex: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"][data-layout="two_right_sidebar"]
|
|
||||||
> div[data-type="column"]:last-child {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"][data-layout="three_left_wide"]
|
|
||||||
> div[data-type="column"]:first-child {
|
|
||||||
flex: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"][data-layout="three_right_wide"]
|
|
||||||
> div[data-type="column"]:last-child {
|
|
||||||
flex: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"][data-layout="three_with_sidebars"]
|
|
||||||
> div[data-type="column"]:first-child,
|
|
||||||
div[data-type="columns"][data-layout="three_with_sidebars"]
|
|
||||||
> div[data-type="column"]:last-child {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"][data-layout="three_with_sidebars"]
|
|
||||||
> div[data-type="column"]:nth-child(2) {
|
|
||||||
flex: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stack columns vertically on small viewports */
|
|
||||||
@media (max-width: 820px) {
|
|
||||||
div[data-type="columns"] {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
|
|
||||||
border-left: none;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"]:hover
|
|
||||||
> div[data-type="column"]
|
|
||||||
+ div[data-type="column"] {
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wide width mode — extends columns to full container width */
|
|
||||||
div[data-type="columns"][data-width-mode="wide"] {
|
|
||||||
margin-left: -3rem;
|
|
||||||
margin-right: -3rem;
|
|
||||||
width: calc(100% + 6rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: $mantine-breakpoint-sm) {
|
|
||||||
div[data-type="columns"][data-width-mode="wide"] {
|
|
||||||
margin-left: -1rem;
|
|
||||||
margin-right: -1rem;
|
|
||||||
width: calc(100% + 2rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
div[data-type="columns"] {
|
|
||||||
flex-direction: row !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
|
|
||||||
border-left: none;
|
|
||||||
padding-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[data-type="columns"][data-width-mode="wide"] {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -82,9 +82,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
padding-left: 1rem;
|
padding-left: 25px;
|
||||||
border-left: 3px solid
|
padding-right: 25px;
|
||||||
light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
|
border-left: 2px solid var(--mantine-color-gray-6);
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-gray-0),
|
||||||
|
var(--mantine-color-dark-8)
|
||||||
|
);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,14 +126,13 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
&.node-callout {
|
||||||
|
div[style*="white-space: inherit;"] {
|
||||||
.react-renderer.node-callout div[style*="white-space: inherit;"] > :first-child {
|
> :first-child {
|
||||||
margin-top: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.react-renderer.node-callout + .react-renderer.node-callout {
|
}
|
||||||
margin-top: 0.75em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection {
|
.selection {
|
||||||
|
|||||||
@@ -13,4 +13,3 @@
|
|||||||
@import "./mention.css";
|
@import "./mention.css";
|
||||||
@import "./ordered-list.css";
|
@import "./ordered-list.css";
|
||||||
@import "./highlight.css";
|
@import "./highlight.css";
|
||||||
@import "./columns.css";
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mantine-AppShell-main {
|
.mantine-AppShell-main {
|
||||||
padding: 0 !important;
|
padding-top: 0 !important;
|
||||||
min-height: auto !important;
|
min-height: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,10 +157,8 @@ export function TitleEditor({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// guard against Cannot access view['hasFocus'] error
|
titleEditor?.commands.focus("end");
|
||||||
if (!titleEditor?.isInitialized) return;
|
}, 500);
|
||||||
titleEditor?.commands?.focus("end");
|
|
||||||
}, 300);
|
|
||||||
}, [titleEditor]);
|
}, [titleEditor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Group,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconCheck,
|
|
||||||
IconFileDescription,
|
|
||||||
IconPointFilled,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
|
||||||
import { INotification } from "../types/notification.types";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useMarkReadMutation } from "../queries/notification-query";
|
|
||||||
import { buildPageUrl } from "@/features/page/page.utils";
|
|
||||||
import { formatRelativeTime } from "../notification.utils";
|
|
||||||
import classes from "../notification.module.css";
|
|
||||||
|
|
||||||
type NotificationItemProps = {
|
|
||||||
notification: INotification;
|
|
||||||
onNavigate: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NotificationItem({
|
|
||||||
notification,
|
|
||||||
onNavigate,
|
|
||||||
}: NotificationItemProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const markRead = useMarkReadMutation();
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
|
|
||||||
const isUnread = !notification.readAt;
|
|
||||||
|
|
||||||
const getNotificationMessage = (): string => {
|
|
||||||
switch (notification.type) {
|
|
||||||
case "comment.user_mention":
|
|
||||||
return t("mentioned you in a comment");
|
|
||||||
case "comment.created":
|
|
||||||
return t("commented on a page");
|
|
||||||
case "comment.resolved":
|
|
||||||
return t("resolved a comment");
|
|
||||||
case "page.user_mention":
|
|
||||||
return t("mentioned you on a page");
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (notification.page && notification.space) {
|
|
||||||
if (isUnread) {
|
|
||||||
markRead.mutate([notification.id]);
|
|
||||||
}
|
|
||||||
navigate(
|
|
||||||
buildPageUrl(
|
|
||||||
notification.space.slug,
|
|
||||||
notification.page.slugId,
|
|
||||||
notification.page.title,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
onNavigate();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarkRead = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isUnread) {
|
|
||||||
markRead.mutate([notification.id]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnstyledButton
|
|
||||||
onClick={handleClick}
|
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
w="100%"
|
|
||||||
className={classes.notificationItem}
|
|
||||||
>
|
|
||||||
<Group wrap="nowrap" align="flex-start" gap="sm">
|
|
||||||
<CustomAvatar
|
|
||||||
avatarUrl={notification.actor?.avatarUrl}
|
|
||||||
name={notification.actor?.name || "?"}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Text size="sm" lineClamp={2}>
|
|
||||||
<Text span fw={600}>
|
|
||||||
{notification.actor?.name}
|
|
||||||
</Text>{" "}
|
|
||||||
{getNotificationMessage()}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{notification.page && (
|
|
||||||
<Group gap={4} mt={2} wrap="nowrap">
|
|
||||||
{notification.page.icon ? (
|
|
||||||
<Text size="xs" style={{ flexShrink: 0 }}>
|
|
||||||
{notification.page.icon}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<IconFileDescription
|
|
||||||
size={14}
|
|
||||||
stroke={1.5}
|
|
||||||
style={{ flexShrink: 0, color: "var(--mantine-color-dimmed)" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
|
||||||
{notification.page.title || t("Untitled")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Group gap={4} wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
|
|
||||||
{hovered && isUnread ? (
|
|
||||||
<Tooltip label={t("Mark as read")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleMarkRead}
|
|
||||||
>
|
|
||||||
<IconCheck size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{formatRelativeTime(notification.createdAt)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isUnread && (
|
|
||||||
<IconPointFilled
|
|
||||||
size={12}
|
|
||||||
color="var(--mantine-color-blue-filled)"
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { Center, Divider, Loader, Stack, Text } from "@mantine/core";
|
|
||||||
import { IconBellOff } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { NotificationItem } from "./notification-item";
|
|
||||||
import { INotification, NotificationFilter } from "../types/notification.types";
|
|
||||||
import { groupNotificationsByTime } from "../notification.utils";
|
|
||||||
import { useNotificationsQuery } from "../queries/notification-query";
|
|
||||||
import classes from "../notification.module.css";
|
|
||||||
|
|
||||||
type NotificationListProps = {
|
|
||||||
filter: NotificationFilter;
|
|
||||||
onNavigate: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NotificationList({
|
|
||||||
filter,
|
|
||||||
onNavigate,
|
|
||||||
}: NotificationListProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
hasNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
} = useNotificationsQuery();
|
|
||||||
|
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sentinel = sentinelRef.current;
|
|
||||||
if (!sentinel) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 },
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(sentinel);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Center py="xl">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allNotifications =
|
|
||||||
data?.pages.flatMap((page) => page.items) ?? [];
|
|
||||||
|
|
||||||
const filtered =
|
|
||||||
filter === "unread"
|
|
||||||
? allNotifications.filter((n) => !n.readAt)
|
|
||||||
: allNotifications;
|
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
return (
|
|
||||||
<Center py="xl">
|
|
||||||
<Stack align="center" gap="xs">
|
|
||||||
<IconBellOff size={32} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{filter === "unread"
|
|
||||||
? t("No unread notifications")
|
|
||||||
: t("No notifications")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeGroupLabels = {
|
|
||||||
today: t("Today"),
|
|
||||||
yesterday: t("Yesterday"),
|
|
||||||
this_week: t("This week"),
|
|
||||||
older: t("Older"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const groups = groupNotificationsByTime(filtered, timeGroupLabels);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap={0}>
|
|
||||||
{groups.map((group, groupIndex) => (
|
|
||||||
<div key={group.key}>
|
|
||||||
{groupIndex > 0 && <Divider className={classes.divider} />}
|
|
||||||
<Text size="xs" fw={600} c="dimmed" px="md" pt="sm" pb={4}>
|
|
||||||
{group.label}
|
|
||||||
</Text>
|
|
||||||
{group.notifications.map((notification: INotification) => (
|
|
||||||
<NotificationItem
|
|
||||||
key={notification.id}
|
|
||||||
notification={notification}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div ref={sentinelRef} style={{ height: 1 }} />
|
|
||||||
|
|
||||||
{isFetchingNextPage && (
|
|
||||||
<Center py="xs">
|
|
||||||
<Loader size="xs" />
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Group,
|
|
||||||
Indicator,
|
|
||||||
Menu,
|
|
||||||
Popover,
|
|
||||||
ScrollArea,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconBell,
|
|
||||||
IconCheck,
|
|
||||||
IconChecks,
|
|
||||||
IconDots,
|
|
||||||
IconFilter,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { NotificationList } from "./notification-list";
|
|
||||||
import { NotificationFilter } from "../types/notification.types";
|
|
||||||
import {
|
|
||||||
useMarkAllReadMutation,
|
|
||||||
useUnreadCountQuery,
|
|
||||||
} from "../queries/notification-query";
|
|
||||||
|
|
||||||
export function NotificationPopover() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [opened, setOpened] = useState(false);
|
|
||||||
const [filter, setFilter] = useState<NotificationFilter>("all");
|
|
||||||
|
|
||||||
const { data: unreadData } = useUnreadCountQuery();
|
|
||||||
const markAllRead = useMarkAllReadMutation();
|
|
||||||
|
|
||||||
const unreadCount = unreadData?.count ?? 0;
|
|
||||||
|
|
||||||
const handleMarkAllRead = () => {
|
|
||||||
markAllRead.mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
position="bottom-end"
|
|
||||||
shadow="lg"
|
|
||||||
opened={opened}
|
|
||||||
onChange={setOpened}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<Tooltip label={t("Notifications")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="dark"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setOpened((o) => !o)}
|
|
||||||
>
|
|
||||||
<Indicator
|
|
||||||
offset={5}
|
|
||||||
color="red"
|
|
||||||
withBorder
|
|
||||||
disabled={unreadCount === 0}
|
|
||||||
>
|
|
||||||
<IconBell size={20} />
|
|
||||||
</Indicator>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover.Target>
|
|
||||||
|
|
||||||
<Popover.Dropdown
|
|
||||||
p={0}
|
|
||||||
style={{ width: "min(420px, calc(100vw - 24px))" }}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" px="md" py="sm">
|
|
||||||
<Text fw={600} size="sm">
|
|
||||||
{t("Notifications")}
|
|
||||||
</Text>
|
|
||||||
<Group gap={4}>
|
|
||||||
<Menu position="bottom-end" withArrow withinPortal={false}>
|
|
||||||
<Menu.Target>
|
|
||||||
<Tooltip label={t("Filter")} withArrow>
|
|
||||||
<ActionIcon variant="subtle" color="dark" size="sm">
|
|
||||||
<IconFilter size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Label>{t("Filter")}</Menu.Label>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() => setFilter("all")}
|
|
||||||
rightSection={
|
|
||||||
filter === "all" ? <IconCheck size={14} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("All notifications")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() => setFilter("unread")}
|
|
||||||
rightSection={
|
|
||||||
filter === "unread" ? <IconCheck size={14} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Unread only")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
<Menu position="bottom-end" withArrow withinPortal={false}>
|
|
||||||
<Menu.Target>
|
|
||||||
<Tooltip label={t("More options")} withArrow>
|
|
||||||
<ActionIcon variant="subtle" color="dark" size="sm">
|
|
||||||
<IconDots size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconChecks size={16} />}
|
|
||||||
onClick={handleMarkAllRead}
|
|
||||||
disabled={unreadCount === 0}
|
|
||||||
>
|
|
||||||
{t("Mark all as read")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<ScrollArea.Autosize
|
|
||||||
mah={500}
|
|
||||||
type="auto"
|
|
||||||
offsetScrollbars
|
|
||||||
scrollbarSize={6}
|
|
||||||
>
|
|
||||||
<NotificationList
|
|
||||||
filter={filter}
|
|
||||||
onNavigate={() => setOpened(false)}
|
|
||||||
/>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
|
|
||||||
import { NOTIFICATION_KEY } from "../queries/notification-query";
|
|
||||||
|
|
||||||
export function useNotificationSocket() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [socket] = useAtom(socketAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handler = () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on("notification", handler);
|
|
||||||
return () => {
|
|
||||||
socket.off("notification", handler);
|
|
||||||
};
|
|
||||||
}, [socket, queryClient]);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
.notificationItem {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notificationItem:hover {
|
|
||||||
background-color: var(--mantine-color-default-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { INotification } from "./types/notification.types";
|
|
||||||
|
|
||||||
export function formatRelativeTime(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMin = Math.floor(diffMs / 60_000);
|
|
||||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
|
||||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
||||||
|
|
||||||
if (diffMin < 1) return "now";
|
|
||||||
if (diffMin < 60) return `${diffMin}m`;
|
|
||||||
if (diffHours < 24) return `${diffHours}h`;
|
|
||||||
if (diffDays < 7) return `${diffDays}d`;
|
|
||||||
|
|
||||||
return date.toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
type TimeGroup = "today" | "yesterday" | "this_week" | "older";
|
|
||||||
|
|
||||||
export function getTimeGroup(dateStr: string): TimeGroup {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const startOfToday = new Date(
|
|
||||||
now.getFullYear(),
|
|
||||||
now.getMonth(),
|
|
||||||
now.getDate(),
|
|
||||||
);
|
|
||||||
const startOfYesterday = new Date(startOfToday);
|
|
||||||
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
|
|
||||||
const startOfWeek = new Date(startOfToday);
|
|
||||||
startOfWeek.setDate(startOfWeek.getDate() - 7);
|
|
||||||
|
|
||||||
if (date >= startOfToday) return "today";
|
|
||||||
if (date >= startOfYesterday) return "yesterday";
|
|
||||||
if (date >= startOfWeek) return "this_week";
|
|
||||||
return "older";
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GroupedNotifications = {
|
|
||||||
key: TimeGroup;
|
|
||||||
label: string;
|
|
||||||
notifications: INotification[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function groupNotificationsByTime(
|
|
||||||
notifications: INotification[],
|
|
||||||
labels: Record<TimeGroup, string>,
|
|
||||||
): GroupedNotifications[] {
|
|
||||||
const groups: Record<TimeGroup, INotification[]> = {
|
|
||||||
today: [],
|
|
||||||
yesterday: [],
|
|
||||||
this_week: [],
|
|
||||||
older: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const notification of notifications) {
|
|
||||||
const group = getTimeGroup(notification.createdAt);
|
|
||||||
groups[group].push(notification);
|
|
||||||
}
|
|
||||||
|
|
||||||
const order: TimeGroup[] = ["today", "yesterday", "this_week", "older"];
|
|
||||||
|
|
||||||
return order
|
|
||||||
.filter((key) => groups[key].length > 0)
|
|
||||||
.map((key) => ({
|
|
||||||
key,
|
|
||||||
label: labels[key],
|
|
||||||
notifications: groups[key],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import {
|
|
||||||
keepPreviousData,
|
|
||||||
useInfiniteQuery,
|
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
getNotifications,
|
|
||||||
getUnreadCount,
|
|
||||||
markNotificationsRead,
|
|
||||||
markAllNotificationsRead,
|
|
||||||
} from "../services/notification-service";
|
|
||||||
|
|
||||||
export const NOTIFICATION_KEY = ["notifications"];
|
|
||||||
export const UNREAD_COUNT_KEY = ["notifications", "unread-count"];
|
|
||||||
|
|
||||||
export function useNotificationsQuery() {
|
|
||||||
return useInfiniteQuery({
|
|
||||||
queryKey: NOTIFICATION_KEY,
|
|
||||||
queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam }),
|
|
||||||
initialPageParam: undefined as string | undefined,
|
|
||||||
getNextPageParam: (lastPage) =>
|
|
||||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
|
||||||
staleTime: 0,
|
|
||||||
gcTime: 0,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUnreadCountQuery() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: UNREAD_COUNT_KEY,
|
|
||||||
queryFn: getUnreadCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMarkReadMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (notificationIds: string[]) =>
|
|
||||||
markNotificationsRead(notificationIds),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMarkAllReadMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: markAllNotificationsRead,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import { INotification } from "../types/notification.types";
|
|
||||||
import { IPagination } from "@/lib/types";
|
|
||||||
|
|
||||||
export async function getNotifications(params: {
|
|
||||||
limit?: number;
|
|
||||||
cursor?: string;
|
|
||||||
}): Promise<IPagination<INotification>> {
|
|
||||||
const req = await api.post<IPagination<INotification>>(
|
|
||||||
"/notifications",
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUnreadCount(): Promise<{ count: number }> {
|
|
||||||
const req = await api.post<{ count: number }>(
|
|
||||||
"/notifications/unread-count",
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markNotificationsRead(
|
|
||||||
notificationIds: string[],
|
|
||||||
): Promise<void> {
|
|
||||||
await api.post("/notifications/mark-read", { notificationIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markAllNotificationsRead(): Promise<void> {
|
|
||||||
await api.post("/notifications/mark-all-read");
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user