Compare commits

...

48 Commits

Author SHA1 Message Date
Philipinho c0df96d4bb update packages 2026-02-25 23:04:18 +00:00
Philipinho 22f33bab7c cleanups 2026-02-25 22:41:54 +00:00
Philipinho e0a8521566 enhance columns 2026-02-25 22:31:01 +00:00
Philip Okugbe b5803f42da xwiki html import cleanup (#1969) 2026-02-24 15:53:38 +00:00
Olivier Lambert 5de1c8e3ed fix: inline code input rule deletes character before opening backtick (#1923)
The upstream TipTap Code extension input rule regex /(^|[^`])`([^`]+)`(?!`)$/
uses a capture group (^|[^`]) that includes the character preceding the
opening backtick in the full match. When markInputRule processes this,
it deletes everything from the match start to the code content, which
removes that preceding character along with the backtick delimiters.

For example, typing foo(`bar` would result in foo`bar` (formatted)
instead of the expected foo(`bar` (formatted) — the ( is lost.

Fix: disable the built-in Code extension from StarterKit and register it
separately with a corrected regex that uses a lookbehind assertion
(?:^|(?<=[^`])) instead of a capture group. The lookbehind asserts the
preceding character without including it in the match, so markInputRule
only deletes the backtick delimiters.

Functionally tested on Firefox and Chrome.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:51:24 +00:00
Philip Okugbe ef87210b3d feat: editor UI refresh and enhancements (#1968)
* feat: new image menu
* switch to resizable side handles
* use pixels

* refactor excalidraw and drawio menu

* support image resize undo

* video resize

* callout menu refresh

* refresh table menus

* fix color scheme

* fix: patch @tiptap/core ResizableNodeView to prevent resize sticking after mouseup

* feat: columns

* notes callout

* focus on first column

* capture tab key in column

* fix print

* hide columns menu when some nodes are focused

* fix print

* fix columns

* selective placeholder

* fix blockquote

* quote

* fix callout in columns
2026-02-24 15:22:37 +00:00
Philipinho c172d3bd5e fix 2026-02-21 00:43:49 +00:00
Philip Okugbe 53132acb0a fix: redirect to original page after re-authentication (#1959)
* fix: redirect to original page after re-authentication

When a session expires, the current URL is now preserved as a query
parameter on the login page. After successful login (including MFA
flows), the user is redirected back to their original page instead of
always landing on /home.

* secure

---------

Co-authored-by: Julien Fontanet <julien.fontanet@isonoe.net>
2026-02-21 00:02:23 +00:00
b4sh2 d6472f0876 Merge commit from fork
Co-authored-by: b4sh2 <b4sh2@users.noreply.github.com>
2026-02-20 16:59:44 +00:00
Philipinho 873c963043 fix db types duplication 2026-02-19 22:34:07 +00:00
Julien Fontanet 03a70d768a fix: allow deleting last character in headings (#1954)
The copy-link decoration widget (contentEditable="false") injected
inside headings prevented browsers from deleting the last remaining
character via Backspace or Delete keys. Only show the widget when the
heading has more than one character of content.
2026-02-18 13:48:15 +00:00
Philip Okugbe 0aeaa43112 feat: replace sharp with client-side icon resize (#1951) 2026-02-16 19:48:19 +00:00
Philip Okugbe 92d5d0b237 New Crowdin updates (#1950)
* New translations
2026-02-16 04:22:40 +00:00
Philipinho 0ce74d34de env validation 2026-02-16 04:11:19 +00:00
Philipinho 00b5328676 fix page error boundary 2026-02-16 04:06:41 +00:00
Philipinho 2ebdc2baea empty states 2026-02-16 00:33:16 +00:00
Philip Okugbe 621ef4f0cf New Crowdin updates (#1948)
* New translations
2026-02-15 23:10:32 +00:00
Philipinho 26b9338da5 sync 2026-02-15 23:04:18 +00:00
Philipinho 618f56577d turn into callout option 2026-02-15 22:51:23 +00:00
Philipinho 0a05ce6133 enhance editor bubble menu 2026-02-15 22:39:42 +00:00
Philipinho cb9d6be3b9 sync 2026-02-15 17:07:27 +00:00
Arek Nawo b76f5adaad feat(ee): AI menu (#1912)
* feat(ee): AI menu

* - Add insert below and copy option

* prebuild @editor-ext

* sanitize output

* clear existing output

* switch to menu component

* refactor directory

* separator

* refactor directory

* support more languages

* pass markdown to model

* fix: close AI menu on page change

* enhance text input and preview styling

* fix: Use absolute positioning for the AI menu

* make preview scrollable

* activation controls

* enhance bubble menu

* sync

* set width

* fix line break

* switch terminologies

* cloud

* buffer

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-02-14 20:58:08 -08:00
Philipinho 41fa77b29d sync 2026-02-14 20:03:35 -08:00
Philip Okugbe 05b3c65b0f feat: notifications (#1947)
* feat: notifications
* feat: watchers

* improvements

* handle page move for watchers

* make watchers non-blocking

* more
2026-02-14 20:00:38 -08:00
Philipinho e0ab9d9b5e override package 2026-02-14 10:37:11 -08:00
Philipinho 55280db672 dark color theme tweaks 2026-02-14 10:35:03 -08:00
Philipinho 32bbc6911f override qs 2026-02-12 11:46:28 -08:00
Philipinho 5814542128 update lock file 2026-02-12 11:41:24 -08:00
Philip Okugbe 18b5781522 feat(API): page content update and retrieval (#1937)
* feat: page content update and retrieval output

* import module

* refactor naming
* support prepend

* rename contentOperation -> operation

* dry

* add yjs utils
2026-02-12 11:13:47 -08:00
Philipinho 49ab9875ba fix tiptap version conflicts 2026-02-11 22:47:25 -08:00
Philipinho 25f4b8c2b4 fix 2026-02-11 17:47:30 -08:00
Philipinho 4d43f86c51 update deps 2026-02-11 17:43:13 -08:00
Philip Okugbe f170ede8da fix(deps): override packages (#1936)
* override packages
2026-02-11 16:48:26 -08:00
Philipinho 7861b5b186 fix: add RedisModule to CollabAppModule 2026-02-09 18:50:31 -08:00
Philipinho 3a9bdfbb06 fix(deps): update vite and nx 2026-02-09 18:32:09 -08:00
Philipinho ab7999a946 v0.25.3 2026-02-09 18:27:55 -08:00
Philip Okugbe 0f02261ee6 feat: page version history improvements (#1925)
* Refactor: use queue for page history

* feat: save multiple version contributors

* display contributor avatars in history list

* fix interval
2026-02-09 18:25:35 -08:00
Philip Okugbe aff8dba2cb fix: diagrams SVG content length (#1928) 2026-02-09 18:20:09 -08:00
Olivier Lambert f6a8247c48 fix: cursor jumps to end of text when editing a comment (#1924)
* fix: cursor jumps to end of text when editing a comment

When editing a comment mid-text, the cursor would jump to the end after
every keystroke, making it impossible to insert text at any position
other than the end.

Root cause: on each keystroke, the comment editor's onUpdate callback
updated parent state (setContent), which changed the defaultContent prop
passed back to CommentEditor. A useEffect watching defaultContent then
called commentEditor.commands.setContent(), which reset the entire
editor content and moved the cursor to the end.

Fix:
- Store in-progress edits in a ref instead of state to avoid triggering
  React re-renders and the prop->effect->setContent cascade
- Read from the ref when saving the comment
- Sync the ref back into state after a successful save so the read-only
  view updates immediately
- Guard the setContent useEffect to only run for read-only editors, so
  websocket-driven updates from other browsers still work

Fixes #1791

Functionally tested on Firefox and Chrome: mid-text editing, saving,
cross-browser live updates via websocket.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix stale content on edit cancel

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-02-09 15:16:40 -08:00
Philip Okugbe 7879e1f600 fix: add execCommand fallback for clipboard (#1927)
* fix: add execCommand fallback for clipboard
2026-02-09 14:44:27 -08:00
Philip Okugbe 3cb70f0696 New translations translation.json (German) (#1915) 2026-02-06 11:37:33 -08:00
Philipinho fbb44df548 v0.25.2 2026-02-06 11:32:00 -08:00
Philip Okugbe bc3ce893c4 New Crowdin updates (#1914)
* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)
2026-02-06 11:31:12 -08:00
Philipinho ae96352189 sync 2026-02-06 10:37:51 -08:00
Philip Okugbe 1ad53c2581 feat(ee): public sharing controls (#1910)
* feat(ee): public sharing controls
* lint
2026-02-06 10:35:36 -08:00
Philip Okugbe 2f97a3debc feat: DOCX import (#1913) 2026-02-06 10:34:51 -08:00
Philipinho 40b5346f9e cleanup redundant param 2026-02-06 10:28:52 -08:00
Philipinho d6b4573b79 update compose services versions 2026-02-06 10:27:34 -08:00
229 changed files with 15117 additions and 6847 deletions
+15 -13
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.25.1",
"version": "0.25.3",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -14,18 +14,19 @@
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-c158187",
"@mantine/core": "^8.3.12",
"@mantine/dates": "^8.3.12",
"@mantine/form": "^8.3.12",
"@mantine/hooks": "^8.3.12",
"@mantine/modals": "^8.3.12",
"@mantine/notifications": "^8.3.12",
"@mantine/spotlight": "^8.3.12",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "^8.3.14",
"@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.14",
"@mantine/modals": "^8.3.14",
"@mantine/notifications": "^8.3.14",
"@mantine/spotlight": "^8.3.14",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.17",
"alfaaz": "^1.1.0",
"axios": "^1.13.2",
"axios": "^1.13.5",
"blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
@@ -41,7 +42,7 @@
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.12.2",
"mitt": "^3.0.1",
"posthog-js": "^1.255.1",
"posthog-js": "1.345.5",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.17",
@@ -58,7 +59,8 @@
},
"devDependencies": {
"@eslint/js": "^9.16.0",
"@tanstack/eslint-plugin-query": "^5.62.1",
"@tanstack/eslint-plugin-query": "^5.91.4",
"@types/blueimp-load-image": "^5.16.0",
"@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7",
@@ -66,7 +68,7 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.15.0",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
@@ -41,7 +41,7 @@
"Date": "Datum",
"Delete": "Löschen",
"Delete group": "Gruppe löschen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dabei werden auch alle Unterseiten und der Seitenverlauf gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"Description": "Beschreibung",
"Details": "Details",
"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 password": "Geben Sie Ihr Passwort ein",
"Error fetching page data.": "Fehler beim Abrufen der Seitendaten.",
"Error loading page history.": "Fehler beim Laden der Seitengeschichte.",
"Error loading page history.": "Fehler beim Laden des Seitenverlaufs.",
"Export": "Exportieren",
"Failed to create page": "Erstellung der Seite fehlgeschlagen",
"Failed to delete page": "Löschen der Seite fehlgeschlagen",
@@ -114,7 +114,7 @@
"New page": "Neue Seite",
"New password": "Neues Passwort",
"No group found": "Keine Gruppe gefunden",
"No page history saved yet.": "Es wurde noch keine Seitengeschichte gespeichert.",
"No page history saved yet.": "Es wurde noch kein Seitenverlauf gespeichert.",
"No pages yet": "Noch keine Seiten",
"No results found...": "Keine Ergebnisse gefunden...",
"No user found": "Kein Benutzer gefunden",
@@ -122,7 +122,7 @@
"Owner": "Besitzer",
"page": "Seite",
"Page deleted successfully": "Seite erfolgreich gelöscht",
"Page history": "Seitengeschichte",
"Page history": "Seitenverlauf",
"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.",
@@ -355,6 +355,11 @@
"Insert current date": "Aktuelles Datum einfügen",
"Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren",
"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}}",
"Toggle title": "Titel umschalten",
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
@@ -407,6 +412,21 @@
"Share deleted successfully": "Freigabe erfolgreich gelöscht",
"Share not found": "Freigabe nicht gefunden",
"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 to a different space.": "Seite in einen anderen Bereich kopieren.",
"Page copied successfully": "Seite erfolgreich kopiert",
@@ -567,13 +587,33 @@
"Ask AI": "KI fragen",
"AI is thinking...": "Die KI überlegt...",
"Ask a question...": "Fragen stellen...",
"AI-powered search (Ask AI)": "KI-gestützte Suche (KI fragen)",
"AI Answers": "KI-Antworten",
"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.",
"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",
"Ask AI not available for attachments": "KI fragen nicht für Anhänge verfügbar",
"AI Answers not available for attachments": "KI-Antworten sind für Anhänge nicht verfügbar",
"No answer available": "Keine Antwort verfügbar",
"Background color": "Hintergrundfarbe",
"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"
}
@@ -274,6 +274,7 @@
"Add row below": "Add row below",
"Delete table": "Delete table",
"Info": "Info",
"Note": "Note",
"Success": "Success",
"Warning": "Warning",
"Danger": "Danger",
@@ -355,9 +356,23 @@
"Insert current date": "Insert current date",
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
"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}}",
"Toggle title": "Toggle title",
"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",
"Today, {{time}}": "Today, {{time}}",
"Yesterday, {{time}}": "Yesterday, {{time}}",
@@ -407,6 +422,21 @@
"Share deleted successfully": "Share deleted successfully",
"Share not found": "Share not found",
"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 to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully",
@@ -567,13 +597,33 @@
"Ask AI": "Ask AI",
"AI is thinking...": "AI is thinking...",
"Ask a question...": "Ask a question...",
"AI-powered search (Ask AI)": "AI-powered search (Ask AI)",
"AI Answers": "AI Answers",
"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.",
"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",
"Ask AI not available for attachments": "Ask AI not available for attachments",
"AI Answers not available for attachments": "AI Answers not available for attachments",
"No answer available": "No answer available",
"Background color": "Background 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"
}
@@ -355,6 +355,11 @@
"Insert current date": "Insertar fecha actual",
"Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw",
"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}}",
"Toggle title": "Alternar título",
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
@@ -407,6 +412,21 @@
"Share deleted successfully": "Compartición eliminada con éxito",
"Share not found": "Compartición no encontrada",
"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 to a different space.": "Copiar página en otro espacio",
"Page copied successfully": "Página copiada exitosamente",
@@ -567,13 +587,33 @@
"Ask AI": "Preguntar a IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Haz una pregunta...",
"AI-powered search (Ask AI)": "Búsqueda impulsada por IA (Preguntar a IA)",
"AI Answers": "Respuestas de 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.",
"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",
"Ask AI not available for attachments": "Preguntar a IA no está disponible para adjuntos",
"AI Answers not available for attachments": "Respuestas de IA no disponibles para archivos adjuntos",
"No answer available": "No hay respuesta disponible",
"Background color": "Color de fondo",
"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"
}
@@ -355,6 +355,11 @@
"Insert current date": "Insérer la date actuelle",
"Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw",
"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}}",
"Toggle title": "Basculer le titre",
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
@@ -407,6 +412,21 @@
"Share deleted successfully": "Partage supprimé avec succès",
"Share not found": "Partage non trouvé",
"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 to a different space.": "Copier la page dans un autre espace.",
"Page copied successfully": "Page copiée avec succès",
@@ -567,13 +587,33 @@
"Ask AI": "Demander à l'IA",
"AI is thinking...": "L'IA réfléchit...",
"Ask a question...": "Posez une question...",
"AI-powered search (Ask AI)": "Recherche assistée par l'IA (Demander à l'IA)",
"AI Answers": "Réponses 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.",
"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",
"Ask AI not available for attachments": "Demande à l'IA non disponible pour les pièces jointes",
"AI Answers not available for attachments": "Réponses IA non disponibles pour les pièces jointes",
"No answer available": "Pas de réponse disponible",
"Background color": "Couleur de fond",
"Highlight color": "Couleur de surbrillance",
"Remove color": "Supprimer la couleur"
"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"
}
@@ -355,6 +355,11 @@
"Insert current date": "Inserisci la data corrente",
"Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw",
"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}}",
"Toggle title": "Attiva/disattiva titolo",
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
@@ -407,6 +412,21 @@
"Share deleted successfully": "Condivisione eliminata con successo",
"Share not found": "Condivisione non trovata",
"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 to a different space.": "Copia pagina in un altro spazio.",
"Page copied successfully": "Pagina copiata con successo",
@@ -567,13 +587,33 @@
"Ask AI": "Chiedi all'AI",
"AI is thinking...": "L'AI sta pensando...",
"Ask a question...": "Fai una domanda...",
"AI-powered search (Ask AI)": "Ricerca potenziata dall'AI (Chiedi all'AI)",
"AI Answers": "Risposte 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.",
"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",
"Ask AI not available for attachments": "Chiedere all'AI non è disponibile per gli allegati",
"AI Answers not available for attachments": "Risposte AI non disponibili per gli allegati",
"No answer available": "Nessuna risposta disponibile",
"Background color": "Colore di sfondo",
"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"
}
@@ -355,6 +355,11 @@
"Insert current date": "現在の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
"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}}",
"Toggle title": "タイトルの表示/非表示を切り替える",
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
@@ -407,6 +412,21 @@
"Share deleted successfully": "共有を削除しました",
"Share not found": "共有が見つかりません",
"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 to a different space.": "ページを別のスペースにコピーします",
"Page copied successfully": "ページをコピーしました",
@@ -567,13 +587,33 @@
"Ask AI": "AIに質問する",
"AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...",
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
"AI Answers": "AI回答",
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "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": "ソース",
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
"AI Answers not available for attachments": "添付ファイルにはAI回答を利用できません",
"No answer available": "回答がありません",
"Background 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": "以前のもの"
}
@@ -355,6 +355,11 @@
"Insert current date": "현재 날짜 삽입",
"Draw and sketch excalidraw diagrams": "Excalidraw diagram 그리기 및 스케치",
"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}}",
"Toggle title": "제목 토글",
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
@@ -407,6 +412,21 @@
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
"Share not found": "공유를 찾을 수 없습니다",
"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 to a different space.": "다른 공간으로 페이지 복사하기.",
"Page copied successfully": "페이지가 성공적으로 복사되었습니다",
@@ -567,13 +587,33 @@
"Ask AI": "AI에게 묻기",
"AI is thinking...": "AI가 생각 중입니다...",
"Ask a question...": "질문하세요...",
"AI-powered search (Ask AI)": "AI 지원 검색 (AI에게 묻기)",
"AI Answers": "AI 답변",
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "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": "출처",
"Ask AI not available for attachments": "AI에게 묻기 기능은 첨부 파일에 대해 사용할 수 없습니다",
"AI Answers not available for attachments": "첨부 파일에 대해 AI 답변을 사용할 수 없습니다",
"No answer available": "답변을 제공할 수 없습니다",
"Background 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": "이전"
}
@@ -355,6 +355,11 @@
"Insert current date": "Huidige datum invoeren",
"Draw and sketch excalidraw diagrams": "Teken en schets excalidraw diagrammen",
"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}}",
"Toggle title": "Schakel titel in/uit",
"Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's",
@@ -407,6 +412,21 @@
"Share deleted successfully": "Delen succesvol verwijderd",
"Share not found": "Delen niet gevonden",
"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 to a different space.": "Kopieer pagina naar een andere ruimte.",
"Page copied successfully": "Pagina succesvol gekopieerd",
@@ -567,13 +587,33 @@
"Ask AI": "Vraag AI",
"AI is thinking...": "AI is aan het nadenken...",
"Ask a question...": "Stel een vraag...",
"AI-powered search (Ask AI)": "AI-ondersteunde zoekopdracht (Vraag AI)",
"AI Answers": "AI Antwoorden",
"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.",
"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",
"Ask AI not available for attachments": "Vraag AI is niet beschikbaar voor bijlages",
"AI Answers not available for attachments": "AI Antwoorden niet beschikbaar voor bijlagen",
"No answer available": "Geen antwoord beschikbaar",
"Background color": "Achtergrondkleur",
"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"
}
@@ -355,6 +355,11 @@
"Insert current date": "Insira a data atual",
"Draw and sketch excalidraw diagrams": "Desenhe e esboce diagramas Excalidraw",
"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}}",
"Toggle title": "Alternar título",
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
@@ -407,6 +412,21 @@
"Share deleted successfully": "Compartilhamento excluído com sucesso",
"Share not found": "Compartilhamento não encontrado",
"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 to a different space.": "Copiar página para um espaço diferente.",
"Page copied successfully": "Página copiada com sucesso",
@@ -567,13 +587,33 @@
"Ask AI": "Pergunte à IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Faça uma pergunta...",
"AI-powered search (Ask AI)": "Pesquisa com IA (Pergunte à IA)",
"AI Answers": "Respostas de 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.",
"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",
"Ask AI not available for attachments": "Perguntar à IA não está disponível para anexos",
"AI Answers not available for attachments": "Respostas de IA não disponíveis para anexos",
"No answer available": "Nenhuma resposta disponível",
"Background color": "Cor de fundo",
"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"
}
@@ -355,6 +355,11 @@
"Insert current date": "Вставить текущую дату",
"Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw",
"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}}",
"Toggle title": "Переключить заголовок",
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
@@ -407,6 +412,21 @@
"Share deleted successfully": "Общий доступ успешно удален",
"Share not found": "Общий доступ не найден",
"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 to a different space.": "Копировать страницу в другое пространство.",
"Page copied successfully": "Страница успешно скопирована",
@@ -567,13 +587,33 @@
"Ask AI": "Спросить ИИ",
"AI is thinking...": "ИИ обрабатывает запрос...",
"Ask a question...": "Задайте вопрос...",
"AI-powered search (Ask AI)": "Поиск на базе ИИ (Спросить ИИ)",
"AI Answers": "Ответы ИИ",
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"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": "Источники",
"Ask AI not available for attachments": "Функция \"Спросить ИИ\" недоступна для вложений",
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
"No answer available": "Ответ недоступен",
"Background 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": "Старше"
}
@@ -355,6 +355,11 @@
"Insert current date": "Вставити поточну дату",
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
"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}}",
"Toggle title": "Перемкнути заголовок",
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
@@ -407,6 +412,21 @@
"Share deleted successfully": "Спільний доступ успішно видалено",
"Share not found": "Спільний доступ не знайдено",
"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 to a different space.": "Скопіювати сторінку в інший простір.",
"Page copied successfully": "Сторінку успішно скопійовано",
@@ -567,13 +587,33 @@
"Ask AI": "Запитати ШІ",
"AI is thinking...": "ШІ думає...",
"Ask a question...": "Задайте питання...",
"AI-powered search (Ask AI)": "Пошук на базі ШІ (Запитати ШІ)",
"AI Answers": "Відповіді ШІ",
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"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": "Джерела",
"Ask AI not available for attachments": "Запитати ШІ недоступно для вкладень",
"AI Answers not available for attachments": "Відповіді ШІ недоступні для вкладень",
"No answer available": "Відповідь недоступна",
"Background 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": "Старіші"
}
@@ -355,6 +355,11 @@
"Insert current date": "插入当前日期",
"Draw and sketch excalidraw diagrams": "绘制 Excalidraw 图表",
"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}} 级标题",
"Toggle title": "切换标题",
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
@@ -407,6 +412,21 @@
"Share deleted successfully": "分享已成功删除",
"Share not found": "未找到分享",
"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 to a different space.": "将页面复制到不同的空间。",
"Page copied successfully": "页面复制成功",
@@ -567,13 +587,33 @@
"Ask AI": "询问AI",
"AI is thinking...": "AI正在思考...",
"Ask a question...": "提问...",
"AI-powered search (Ask AI)": "AI驱动的搜索(询问AI",
"AI Answers": "AI答案",
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "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": "来源",
"Ask AI not available for attachments": "附件不支持询问AI",
"AI Answers not available for attachments": "AI答案不适用于附件",
"No answer available": "无可用答案",
"Background 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": "较早"
}
+1 -8
View File
@@ -14,7 +14,6 @@ import AccountPreferences from "@/pages/settings/account/account-preferences.tsx
import SpaceHome from "@/pages/space/space-home.tsx";
import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx";
import { ErrorBoundary } from "react-error-boundary";
import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset";
@@ -84,13 +83,7 @@ export default function App() {
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
<Route
path={"/s/:spaceSlug/p/:pageSlug"}
element={
<ErrorBoundary
fallback={<>{t("Failed to load page. An error occurred.")}</>}
>
<Page />
</ErrorBoundary>
}
element={<Page />}
/>
<Route path={"/settings"}>
@@ -0,0 +1,33 @@
// 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";
+2 -1
View File
@@ -1,4 +1,5 @@
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
import { ActionIcon, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import React from "react";
import { useTranslation } from "react-i18next";
@@ -11,7 +11,8 @@ import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription } from "@tabler/icons-react";
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts";
@@ -85,8 +86,10 @@ export default function RecentChanges({ spaceId }: Props) {
</Table>
</Table.ScrollContainer>
) : (
<Text size="md" ta="center">
{t("No pages yet")}
</Text>
<EmptyState
icon={IconFiles}
title={t("No pages yet")}
description={t("Pages you create will show up here.")}
/>
);
}
@@ -0,0 +1,27 @@
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>
);
}
@@ -0,0 +1,28 @@
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,6 +22,7 @@ import {
searchSpotlight,
shareSearchSpotlight,
} from "@/features/search/constants.ts";
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
@@ -97,6 +98,7 @@ export function AppHeader() {
</div>
<Group px={"xl"} wrap="nowrap">
<NotificationPopover />
{isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge
variant="light"
@@ -115,7 +115,6 @@ const groupedData: DataGroup[] = [
icon: IconSparkles,
path: "/settings/ai",
isAdmin: true,
isSelfhosted: true,
},
],
},
@@ -0,0 +1,8 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
@@ -0,0 +1,30 @@
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>
);
}
@@ -0,0 +1,61 @@
.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;
}
}
@@ -0,0 +1,325 @@
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 };
@@ -0,0 +1,219 @@
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 };
@@ -0,0 +1,72 @@
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 };
@@ -0,0 +1,32 @@
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">
<div>
<Text size="md">{t("AI-powered search (Ask AI)")}</Text>
<Text size="md">{t("AI-powered search (AI Answers)")}</Text>
<Text size="sm" c="dimmed">
{t(
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
@@ -0,0 +1,48 @@
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>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useMutation, UseMutationResult } from "@tanstack/react-query";
import { useState, useCallback } from "react";
import { askAi, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
import { aiAnswers, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
// @ts-ignore
@@ -26,7 +26,7 @@ export function useAiSearch(): UseAiSearchResult {
const { contentType, ...apiParams } = params;
return await askAi(apiParams, (chunk) => {
return await aiAnswers(apiParams, (chunk) => {
if (chunk.content) {
setStreamingAnswer((prev) => prev + chunk.content);
}
+10 -7
View File
@@ -1,25 +1,25 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import { Alert } from "@mantine/core";
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import { Alert, Stack } from "@mantine/core";
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() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { hasLicenseKey } = useLicense();
const hasAccess = useIsCloudEE();
if (!isAdmin) {
return null;
}
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
return (
<>
<Helmet>
@@ -40,7 +40,10 @@ export default function AiSettings() {
</Alert>
)}
<EnableAiSearch />
<Stack gap="md">
{!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi />
</Stack>
</>
);
}
@@ -15,11 +15,11 @@ export interface IAiSearchResponse {
}>;
}
export async function askAi(
export async function aiAnswers(
params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
): Promise<IAiSearchResponse> {
const response = await fetch("/api/ai/ask", {
const response = await fetch("/api/ai/answers", {
method: "POST",
headers: {
"Content-Type": "application/json",
+6 -3
View File
@@ -43,13 +43,16 @@ export async function generateAiContentStream(
}
const processStream = async () => {
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
@@ -66,7 +69,7 @@ export async function generateAiContentStream(
onChunk(parsed);
}
} catch (e) {
// Ignore parse errors for incomplete chunks
// Skip invalid JSON
}
}
}
+1
View File
@@ -6,6 +6,7 @@ export enum AiAction {
SIMPLIFY = "simplify",
CHANGE_TONE = "change_tone",
SUMMARIZE = "summarize",
EXPLAIN = "explain",
CONTINUE_WRITING = "continue_writing",
TRANSLATE = "translate",
CUSTOM = "custom",
@@ -7,7 +7,7 @@ import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { IAuthProvider } from "@/ee/security/types/security.types";
import APP_ROUTE from "@/lib/app-route";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { ldapLogin } from "@/ee/security/services/ldap-auth-service";
const formSchema = z.object({
@@ -59,13 +59,13 @@ export function LdapLoginModal({
// Handle MFA like the regular login
if (response?.userHasMfa) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
} else if (response?.requiresMfaSetup) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
} else {
onClose();
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
}
} catch (err: any) {
setIsLoading(false);
@@ -0,0 +1,12 @@
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,
List,
Code,
CopyButton,
Alert,
PasswordInput,
} from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import {
IconRefresh,
IconCopy,
@@ -18,7 +18,7 @@ import { useNavigate } from "react-router-dom";
import { notifications } from "@mantine/notifications";
import classes from "./mfa-challenge.module.css";
import { verifyMfa } from "@/ee/mfa";
import APP_ROUTE from "@/lib/app-route";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { useTranslation } from "react-i18next";
import * as z from "zod";
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
@@ -53,7 +53,7 @@ export function MfaChallenge() {
setIsLoading(true);
try {
await verifyMfa(values.code);
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
} catch (error: any) {
setIsLoading(false);
notifications.show({
@@ -11,7 +11,6 @@ import {
PinInput,
Alert,
List,
CopyButton,
ActionIcon,
Tooltip,
Paper,
@@ -20,6 +19,7 @@ import {
Collapse,
UnstyledButton,
} from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import {
IconQrcode,
IconShieldCheck,
@@ -3,7 +3,7 @@ import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { MfaSetupModal } from "@/ee/mfa";
import APP_ROUTE from "@/lib/app-route.ts";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom";
export default function MfaSetupRequired() {
@@ -11,7 +11,7 @@ export default function MfaSetupRequired() {
const navigate = useNavigate();
const handleSetupComplete = () => {
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
};
return (
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { validateMfaAccess } from "@/ee/mfa";
export function useMfaPageProtection() {
@@ -13,8 +13,10 @@ export function useMfaPageProtection() {
const checkAccess = async () => {
const result = await validateMfaAccess();
const search = location.search;
if (!result.valid) {
navigate(APP_ROUTE.AUTH.LOGIN);
navigate(APP_ROUTE.AUTH.LOGIN + search);
return;
}
@@ -26,17 +28,17 @@ export function useMfaPageProtection() {
if (result.requiresMfaSetup && !isOnSetupPage) {
// User needs to set up MFA but is on challenge page
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + search);
} else if (
!result.requiresMfaSetup &&
result.userHasMfa &&
!isOnChallengePage
) {
// User has MFA and should be on challenge page
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + search);
} else if (!result.isTransferToken) {
// User has a regular auth token, shouldn't be on MFA pages
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
} else {
setIsValid(true);
}
@@ -0,0 +1,88 @@
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,23 +10,18 @@ export default function EnforceMfa() {
const { t } = useTranslation();
return (
<>
<Title order={4} my="sm">
MFA
</Title>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Enforce two-factor authentication")}</Text>
<Text size="sm" c="dimmed">
{t(
"Once enforced, all members must enable two-factor authentication to access the workspace.",
)}
</Text>
</div>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Enforce two-factor authentication")}</Text>
<Text size="sm" c="dimmed">
{t(
"Once enforced, all members must enable two-factor authentication to access the workspace.",
)}
</Text>
</div>
<EnforceMfaToggle />
</Group>
</>
<EnforceMfaToggle />
</Group>
);
}
@@ -0,0 +1,84 @@
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>
);
}
+26 -10
View File
@@ -9,15 +9,16 @@ import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx"
import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
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 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() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
const hasEnterpriseAccess = useEnterpriseAccess();
const isCloudEE = useIsCloudEE();
if (!isAdmin) {
return null;
@@ -30,26 +31,41 @@ export default function Security() {
</Helmet>
<SettingsTitle title={t("Security")} />
<AllowedDomains />
<Divider my="lg" />
<EnforceMfa />
<Divider my="lg" />
{(!isCloud() || hasEnterpriseAccess) && (
<>
<DisablePublicSharing />
<Divider my="lg" />
</>
)}
<Title order={4} my="lg">
Single sign-on (SSO)
</Title>
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
{hasEnterpriseAccess && (
<>
<EnforceSso />
<Divider my="lg" />
</>
)}
{isCloudEE && (
<>
<AllowedDomains />
<Divider my="lg" />
</>
)}
{hasEnterpriseAccess && (
<>
<CreateSsoProvider />
<Divider size={0} my="lg" />
</>
) : null}
)}
<SsoProviderList />
</>
@@ -1,20 +1,62 @@
import api from "@/lib/api-client";
import loadImage from "blueimp-load-image";
import {
AvatarIconType,
IAttachment,
} 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(
file: File,
type: AvatarIconType,
spaceId?: string,
): Promise<IAttachment> {
const processed = await compressAndResizeIcon(file, type);
const formData = new FormData();
formData.append("type", type);
if (spaceId) {
formData.append("spaceId", spaceId);
}
formData.append("image", file);
formData.append("image", processed);
return await api.post("/attachments/upload-image", formData, {
headers: {
@@ -23,7 +23,7 @@ import {
acceptInvitation,
createWorkspace,
} from "@/features/workspace/services/workspace-service.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
@@ -44,11 +44,11 @@ export default function useAuth() {
// Check if MFA is required
if (response?.userHasMfa) {
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
} else if (response?.requiresMfaSetup) {
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
} else {
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
}
} catch (err) {
setIsLoading(false);
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import { getPostLoginRedirect } from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom";
export function useRedirectIfAuthenticated() {
@@ -9,7 +9,7 @@ export function useRedirectIfAuthenticated() {
useEffect(() => {
if (data && data?.user) {
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
}
}, [isLoading, data]);
}
@@ -31,6 +31,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
const [currentUser] = useAtom(currentUserAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const useClickOutsideRef = useClickOutside(() => {
if (document.querySelector("#mention")) return;
handleDialogClose();
});
const createCommentMutation = useCreateCommentMutation();
@@ -105,6 +106,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
position={{ bottom: 500, right: 50 }}
withCloseButton
withBorder
data-comment-dialog
>
<Stack gap={2}>
<Group>
@@ -1,14 +1,15 @@
import { EditorContent, useEditor } from "@tiptap/react";
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
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 { Mention, LinkExtension } from "@docmost/editor-ext";
import classes from "./comment.module.css";
import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx";
import { forwardRef, useEffect, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
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 {
defaultContent?: any;
@@ -39,13 +40,29 @@ const CommentEditor = forwardRef(
StarterKit.configure({
gapcursor: false,
dropcursor: false,
link: false,
}),
Placeholder.configure({
placeholder: placeholder || t("Reply..."),
}),
Underline,
Link,
LinkExtension,
EmojiCommand,
Mention.configure({
suggestion: {
allowSpaces: true,
items: () => [],
// @ts-ignore
render: mentionRenderItems,
},
HTMLAttributes: {
class: "mention",
},
}).extend({
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView);
},
}),
],
editorProps: {
handleDOMEvents: {
@@ -60,7 +77,8 @@ const CommentEditor = forwardRef(
].includes(event.key)
) {
const emojiCommand = document.querySelector("#emoji-command");
if (emojiCommand) {
const mentionPopup = document.querySelector("#mention");
if (emojiCommand || mentionPopup) {
return true;
}
}
@@ -84,9 +102,14 @@ const CommentEditor = forwardRef(
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(() => {
commentEditor.commands.setContent(defaultContent);
}, [defaultContent]);
if (!editable && commentEditor && defaultContent) {
commentEditor.commands.setContent(defaultContent);
}
}, [defaultContent, editable, commentEditor]);
useEffect(() => {
setTimeout(() => {
@@ -103,7 +126,11 @@ const CommentEditor = forwardRef(
}));
return (
<div ref={focusRef} className={classes.commentEditor}>
<div
ref={focusRef}
className={classes.commentEditor}
data-editable={editable || undefined}
>
<EditorContent
editor={commentEditor}
className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
@@ -1,5 +1,5 @@
import { Group, Text, Box, Badge } from "@mantine/core";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time";
@@ -40,6 +40,7 @@ function CommentListItem({
const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState<string>(comment.content);
const editContentRef = useRef<any>(null);
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
@@ -56,9 +57,13 @@ function CommentListItem({
setIsLoading(true);
const commentToUpdate = {
commentId: comment.id,
content: JSON.stringify(content),
content: JSON.stringify(editContentRef.current ?? content),
};
await updateCommentMutation.mutateAsync(commentToUpdate);
if (editContentRef.current) {
setContent(editContentRef.current);
editContentRef.current = null;
}
setIsEditing(false);
emit({
@@ -128,6 +133,7 @@ function CommentListItem({
setIsEditing(true);
}
function cancelEdit() {
editContentRef.current = null;
setIsEditing(false);
}
@@ -194,7 +200,7 @@ function CommentListItem({
<CommentEditor
defaultContent={content}
editable={true}
onUpdate={(newContent: any) => setContent(newContent)}
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
onSave={handleUpdateComment}
autofocus={true}
/>
@@ -32,11 +32,14 @@
max-width: 100%;
white-space: pre-wrap;
word-break: break-word;
max-height: 20vh;
padding-left: 6px;
padding-right: 6px;
margin-top: 10px;
margin-bottom: 2px;
}
&[data-editable] .ProseMirror :global(.ProseMirror){
max-height: 50vh;
overflow: hidden auto;
}
@@ -8,3 +8,5 @@ export const titleEditorAtom = atom<Editor | null>(null);
export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
@@ -1,11 +1,53 @@
.bubbleMenu {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
max-width: 100vw;
width: fit-content;
border-radius: 2px;
box-shadow: 0 4px 12px light-dark(#cfcfcf, #0f0f0f);
border-radius: 6px;
border: 1px solid
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 {
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,10 +9,11 @@ import {
IconStrikethrough,
IconUnderline,
IconMessage,
IconSparkles,
} from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
import { ActionIcon, rem, Tooltip } from "@mantine/core";
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
import { ColorSelector } from "./color-selector";
import { NodeSelector } from "./node-selector";
import { TextAlignmentSelector } from "./text-alignment-selector";
@@ -20,11 +21,13 @@ import {
draftCommentIdAtom,
showCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
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 {
name: string;
@@ -39,14 +42,22 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { t } = useTranslation();
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
useEffect(() => {
showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]);
useEffect(() => {
showAiMenuRef.current = showAiMenu;
}, [showAiMenu]);
const editorState = useEditorState({
editor: props.editor,
selector: (ctx) => {
@@ -123,6 +134,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
empty ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
showAiMenuRef.current ||
showCommentPopupRef?.current
) {
return false;
@@ -146,9 +158,31 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
// Hide the bubble menu immediately when AI menu is shown
if (showAiMenu) return;
return (
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
<BubbleMenu
{...bubbleMenuProps}
style={{ zIndex: 200, position: "relative" }}
>
<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
editor={props.editor}
isOpen={isNodeSelectorOpen}
@@ -212,16 +246,18 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
}}
/>
<ActionIcon
variant="default"
size="lg"
radius="0"
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={commentItem.command}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
radius="6px"
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={commentItem.command}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
</div>
</BubbleMenu>
);
@@ -1,7 +1,6 @@
import React, { Dispatch, FC, SetStateAction } from "react";
import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react";
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import {
ActionIcon,
Button,
Popover,
rem,
@@ -15,6 +14,8 @@ import {
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
export interface BubbleColorMenuItem {
name: string;
@@ -166,14 +167,10 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
onClick={() => setIsOpen(!isOpen)}
data-text-color={activeColorItem?.color || ""}
data-highlight-color={activeHighlightItem?.color || ""}
className="color-selector-trigger"
className={clsx(["color-selector-trigger", classes.buttonRoot])}
style={{
height: "34px",
border: "none",
fontWeight: 500,
fontSize: rem(16),
paddingLeft: rem(8),
paddingRight: rem(4),
}}
>
A
@@ -1,6 +1,7 @@
import React, { Dispatch, FC, SetStateAction } from "react";
import {
IconBlockquote,
IconCaretRightFilled,
IconCheck,
IconCheckbox,
IconChevronDown,
@@ -8,14 +9,16 @@ import {
IconH1,
IconH2,
IconH3,
IconInfoCircle,
IconList,
IconListNumbers,
IconTypography,
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea } from "@mantine/core";
import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import classes from "./bubble-menu.module.css";
interface NodeSelectorProps {
editor: Editor | null;
@@ -54,6 +57,8 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
isTaskItem: ctx.editor.isActive("taskItem"),
isBlockquote: ctx.editor.isActive("blockquote"),
isCodeBlock: ctx.editor.isActive("codeBlock"),
isCallout: ctx.editor.isActive("callout"),
isDetails: ctx.editor.isActive("details"),
};
},
});
@@ -123,6 +128,18 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
command: () => editor.chain().focus().toggleCodeBlock().run(),
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() ?? {
@@ -132,15 +149,18 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
return (
<Popover opened={isOpen} withArrow>
<Popover.Target>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
{t(activeItem?.name)}
</Button>
<Tooltip label={t("Turn into")} withArrow withinPortal={false} disabled={isOpen}>
<Button
className={classes.buttonRoot}
variant="default"
style={{ border: "none", height: "34px" }}
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
{t(activeItem?.name)}
</Button>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
@@ -7,7 +7,7 @@ import {
IconCheck,
IconChevronDown,
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea, rem } from "@mantine/core";
import { Popover, Button, ScrollArea, Tooltip, rem } from "@mantine/core";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
@@ -84,16 +84,18 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
return (
<Popover opened={isOpen} withArrow>
<Popover.Target>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
px="5"
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button>
<Tooltip label={t("Text align")} withArrow withinPortal={false} disabled={isOpen}>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
px="5"
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
@@ -1,22 +1,25 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import clsx from "clsx";
import {
IconAlertTriangleFilled,
IconCircleCheckFilled,
IconCircleXFilled,
IconInfoCircleFilled,
IconMoodSmile,
IconNotes,
} from "@tabler/icons-react";
import { CalloutType } from "@docmost/editor-ext";
import { CalloutType, isTextSelected } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import classes from "../common/toolbar-menu.module.css";
export function CalloutMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
@@ -26,6 +29,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
if (!state) {
return false;
}
if (isTextSelected(editor)) return false;
return editor.isActive("callout");
},
@@ -42,6 +46,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
return {
isCallout: ctx.editor.isActive("callout"),
isInfo: ctx.editor.isActive("callout", { type: "info" }),
isNote: ctx.editor.isActive("callout", { type: "note" }),
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
@@ -126,15 +131,31 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
}}
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Info")}>
<ActionIcon
onClick={() => setCalloutType("info")}
size="lg"
aria-label={t("Info")}
variant={editorState?.isInfo ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isInfo })}
>
<IconInfoCircleFilled size={18} />
<IconInfoCircleFilled
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>
</Tooltip>
@@ -143,9 +164,13 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("success")}
size="lg"
aria-label={t("Success")}
variant={editorState?.isSuccess ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isSuccess })}
>
<IconCircleCheckFilled size={18} />
<IconCircleCheckFilled
size={18}
color="var(--mantine-color-green-5)"
/>
</ActionIcon>
</Tooltip>
@@ -154,9 +179,13 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("warning")}
size="lg"
aria-label={t("Warning")}
variant={editorState?.isWarning ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isWarning })}
>
<IconAlertTriangleFilled size={18} />
<IconAlertTriangleFilled
size={18}
color="var(--mantine-color-orange-5)"
/>
</ActionIcon>
</Tooltip>
@@ -165,9 +194,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("danger")}
size="lg"
aria-label={t("Danger")}
variant={editorState?.isDanger ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isDanger })}
>
<IconCircleXFilled size={18} />
<IconCircleXFilled size={18} color="var(--mantine-color-red-5)" />
</ActionIcon>
</Tooltip>
@@ -178,11 +208,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
icon={currentIcon || <IconMoodSmile size={18} />}
actionIconProps={{
size: "lg",
variant: "default",
c: undefined,
variant: "subtle",
}}
/>
</ActionIcon.Group>
</div>
</BaseBubbleMenu>
);
}
@@ -4,6 +4,7 @@ import {
IconCircleCheckFilled,
IconCircleXFilled,
IconInfoCircleFilled,
IconNotes,
} from "@tabler/icons-react";
import { Alert } from "@mantine/core";
import classes from "./callout.module.css";
@@ -22,6 +23,7 @@ export default function CalloutView(props: NodeViewProps) {
icon={getCalloutIcon(type, icon)}
p="xs"
classNames={{
root: classes.root,
message: classes.message,
icon: classes.icon,
}}
@@ -34,12 +36,14 @@ export default function CalloutView(props: NodeViewProps) {
function getCalloutIcon(type: CalloutType, customIcon?: string) {
if (customIcon && customIcon.trim() !== "") {
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
return <span style={{ fontSize: "18px" }}>{customIcon}</span>;
}
switch (type) {
case "info":
return <IconInfoCircleFilled />;
case "note":
return <IconNotes />;
case "success":
return <IconCircleCheckFilled />;
case "warning":
@@ -55,6 +59,8 @@ function getCalloutColor(type: CalloutType) {
switch (type) {
case "info":
return "blue";
case "note":
return "grape";
case "success":
return "green";
case "warning":
@@ -1,9 +1,13 @@
.root {
overflow: visible;
}
.icon {
font-size: 24px;
line-height: 1;
width: 20px;
height: 20px;
margin-inline-end: var(--mantine-spacing-md);
margin-inline-end: var(--mantine-spacing-xs);
margin-top: 4px;
cursor: pointer;
}
@@ -11,18 +15,8 @@
.message {
font-size: var(--mantine-font-size-md);
color: var(--mantine-color-default-color);
white-space: nowrap;
overflow: visible;
text-overflow: unset;
word-break: 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,5 +1,6 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core";
import { ActionIcon, Group, Select, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import classes from "./code-block.module.css";
@@ -0,0 +1,361 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef, useState } from "react";
import { DOMSerializer, Node as PMNode } from "@tiptap/pm/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,
IconCopy,
IconTrash,
} 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 [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
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 handleCopy = useCallback(() => {
const { state } = editor;
const parent = findParentNode(
(node: PMNode) => node.type.name === "columns",
)(state.selection);
if (!parent) return;
const serializer = DOMSerializer.fromSchema(state.schema);
const dom = serializer.serializeNode(parent.node);
const wrapper = document.createElement("div");
wrapper.appendChild(dom);
const onSuccess = () => {
clearTimeout(copyTimerRef.current);
setCopied(true);
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
};
if (navigator.clipboard?.write) {
navigator.clipboard
.write([
new ClipboardItem({
"text/html": new Blob([wrapper.innerHTML], { type: "text/html" }),
"text/plain": new Blob([parent.node.textContent], {
type: "text/plain",
}),
}),
])
.then(onSuccess)
.catch(execCommandFallback);
} else {
execCommandFallback();
}
function execCommandFallback() {
wrapper.style.position = "fixed";
wrapper.style.left = "-9999px";
document.body.appendChild(wrapper);
const range = document.createRange();
range.selectNodeContents(wrapper);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand("copy");
sel?.removeAllRanges();
document.body.removeChild(wrapper);
editor.view.focus();
onSuccess();
}
}, [editor]);
const handleDelete = useCallback(() => {
const parent = findParentNode(
(node: PMNode) => node.type.name === "columns",
)(editor.state.selection);
if (!parent) return;
editor.chain().focus().setNodeSelection(parent.pos).deleteSelection().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 className={classes.divider} />
<Tooltip
position="top"
label={copied ? t("Copied") : t("Copy")}
withinPortal={false}
>
<ActionIcon
onClick={handleCopy}
size="lg"
aria-label={t("Copy")}
variant="subtle"
>
{copied ? (
<IconCheck size={18} color="var(--mantine-color-green-6)" />
) : (
<IconCopy size={18} />
)}
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu>
);
}
export default ColumnsMenu;
@@ -0,0 +1,35 @@
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,
};
}
@@ -0,0 +1,65 @@
.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));
}
@@ -0,0 +1,29 @@
.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,24 +1,41 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import { useCallback, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
import { ActionIcon, Modal, 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 { 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) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
},
[editor],
);
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null);
const computedColorScheme = useComputedColorScheme();
const editorState = useEditorState({
editor,
@@ -30,11 +47,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const drawioAttr = ctx.editor.getAttributes("drawio");
return {
isDrawio: ctx.editor.isActive("drawio"),
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
isAlignLeft: ctx.editor.isActive("drawio", { align: "left" }),
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(() => {
if (!editor) return;
const { selection } = editor.state;
@@ -57,38 +89,218 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
};
}, [editor]);
const onWidthChange = useCallback(
(value: number) => {
editor.commands.updateAttributes("drawio", { width: `${value}%` });
const alignLeft = useCallback(() => {
editor
.chain()
.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],
[editor, editorState?.attachmentId, close],
);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`drawio-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
<>
<BaseBubbleMenu
editor={editor}
pluginKey={`drawio-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</div>
</BaseBubbleMenu>
<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>
<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,7 +2,6 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import {
ActionIcon,
Card,
Image,
Modal,
Text,
useComputedColorScheme,
@@ -10,7 +9,7 @@ import {
import { useRef, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { useDisclosure } from "@mantine/hooks";
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
import { getDrawioUrl } from "@/lib/config.ts";
import {
DrawIoEmbed,
DrawIoEmbedRef,
@@ -26,7 +25,7 @@ import { useTranslation } from "react-i18next";
export default function DrawioView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, editor, selected } = props;
const { src, title, width, attachmentId } = node.attrs;
const { attachmentId } = node.attrs;
const drawioRef = useRef<DrawIoEmbedRef>(null);
const [initialXML, setInitialXML] = useState<string>("");
const [opened, { open, close }] = useDisclosure(false);
@@ -36,33 +35,11 @@ export default function DrawioView(props: NodeViewProps) {
if (!editor.isEditable) {
return;
}
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();
}
open();
};
const handleSave = async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
@@ -70,7 +47,6 @@ export default function DrawioView(props: NodeViewProps) {
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
@@ -106,14 +82,12 @@ export default function DrawioView(props: NodeViewProps) {
noSaveBtn: true,
}}
onSave={(data: EventSave) => {
// If the save is triggered by another event, then do nothing
if (data.parentEvent !== "save") {
return;
}
handleSave(data);
}}
onClose={(data: EventExit) => {
// If the exit is triggered by another event, then do nothing
if (data.parentEvent) {
return;
}
@@ -125,62 +99,28 @@ export default function DrawioView(props: NodeViewProps) {
</Modal.Content>
</Modal.Root>
{src ? (
<div style={{ position: "relative" }}>
<Image
onClick={(e) => e.detail === 2 && handleOpen()}
radius="md"
fit="contain"
w={width}
src={getFileUrl(src)}
alt={title}
className={clsx(
selected ? "ProseMirror-selectednode" : "",
"alignCenter",
)}
/>
<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>
{selected && editor.isEditable && (
<ActionIcon
onClick={handleOpen}
variant="default"
color="gray"
mx="xs"
className="print-hide"
style={{
position: "absolute",
top: 8,
right: 8,
}}
>
<IconEdit size={18} />
</ActionIcon>
)}
<Text component="span" size="lg" c="dimmed">
{t("Double-click to edit Draw.io diagram")}
</Text>
</div>
) : (
<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>
)}
</Card>
</NodeViewWrapper>
);
}
@@ -1,26 +1,57 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import { lazy, Suspense, useCallback, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
import {
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) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return (
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
);
},
[editor],
);
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI>(null);
useHandleLibrary({
excalidrawAPI,
adapter: localStorageLibraryAdapter,
});
const [excalidrawData, setExcalidrawData] = useState<any>(null);
const computedColorScheme = useComputedColorScheme();
const editorState = useEditorState({
editor,
@@ -32,11 +63,29 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
return {
isExcalidraw: ctx.editor.isActive("excalidraw"),
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
isAlignLeft: ctx.editor.isActive("excalidraw", { align: "left" }),
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(() => {
if (!editor) return;
const { selection } = editor.state;
@@ -59,38 +108,248 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
};
}, [editor]);
const onWidthChange = useCallback(
(value: number) => {
editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
},
[editor],
);
const alignLeft = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setExcalidrawAlign("left")
.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 (
<BaseBubbleMenu
editor={editor}
pluginKey={`excalidraw-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
<div
<>
<BaseBubbleMenu
editor={editor}
pluginKey={`excalidraw-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
<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={{
display: "flex",
flexDirection: "column",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
padding: 0,
zIndex: 200,
}}
isOpen={opened}
onRequestClose={close}
disableCloseOnBgClick={true}
contentProps={{
style: {
padding: 0,
width: "90vw",
},
}}
>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</div>
</BaseBubbleMenu>
<Group
justify="flex-end"
wrap="nowrap"
bg="var(--mantine-color-body)"
p="xs"
>
<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,28 +4,24 @@ import {
Button,
Card,
Group,
Image,
Text,
useComputedColorScheme,
} from "@mantine/core";
import { useState } from "react";
import { lazy, Suspense, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts";
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 clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
import { lazy } from "react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
const Excalidraw = lazy(() =>
const ExcalidrawComponent = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({
default: module.Excalidraw,
})),
@@ -34,7 +30,7 @@ const Excalidraw = lazy(() =>
export default function ExcalidrawView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, updateAttributes, editor, selected } = props;
const { src, title, width, attachmentId } = node.attrs;
const { attachmentId } = node.attrs;
const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI>(null);
@@ -50,25 +46,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
if (!editor.isEditable) {
return;
}
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();
}
open();
};
const handleSave = async () => {
@@ -151,7 +129,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
</Group>
<div style={{ height: "90vh" }}>
<Suspense fallback={null}>
<Excalidraw
<ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)}
initialData={{
...excalidrawData,
@@ -163,62 +141,28 @@ export default function ExcalidrawView(props: NodeViewProps) {
</div>
</ReactClearModal>
{src ? (
<div style={{ position: "relative" }}>
<Image
onClick={(e) => e.detail === 2 && handleOpen()}
radius="md"
fit="contain"
w={width}
src={getFileUrl(src)}
alt={title}
className={clsx(
selected ? "ProseMirror-selectednode" : "",
"alignCenter",
)}
/>
<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>
{selected && editor.isEditable && (
<ActionIcon
onClick={handleOpen}
variant="default"
color="gray"
mx="xs"
className="print-hide"
style={{
position: "absolute",
top: 8,
right: 8,
}}
>
<IconEdit size={18} />
</ActionIcon>
)}
<Text component="span" size="lg" c="dimmed">
{t("Double-click to edit Excalidraw diagram")}
</Text>
</div>
) : (
<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>
)}
</Card>
</NodeViewWrapper>
);
}
@@ -1,22 +1,29 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import React, { useCallback, useRef } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import clsx from "clsx";
import {
IconLayoutAlignCenter,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconDownload,
IconRefresh,
IconTrash,
} from "@tabler/icons-react";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
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) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const editorState = useEditorState({
editor,
@@ -32,7 +39,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
src: imageAttrs?.src || null,
};
},
});
@@ -94,17 +101,40 @@ export function ImageMenu({ editor }: EditorMenuProps) {
.run();
}, [editor]);
const onWidthChange = useCallback(
(value: number) => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setImageWidth(value)
.run();
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 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],
);
const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
@@ -118,13 +148,14 @@ export function ImageMenu({ editor }: EditorMenuProps) {
}}
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")}>
<ActionIcon
onClick={alignImageLeft}
size="lg"
aria-label={t("Align left")}
variant={editorState?.isAlignLeft ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
@@ -135,7 +166,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageCenter}
size="lg"
aria-label={t("Align center")}
variant={editorState?.isAlignCenter ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
@@ -146,16 +178,56 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageRight}
size="lg"
aria-label={t("Align right")}
variant={editorState?.isAlignRight ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignRight })}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
<div className={classes.divider} />
<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>
);
}
@@ -0,0 +1,7 @@
import {
createResizeHandle,
buildResizeClasses,
} from "../common/node-resize-handles";
export const createImageHandle = createResizeHandle;
export const imageResizeClasses = buildResizeClasses("node-image");
@@ -0,0 +1,64 @@
.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,6 +10,7 @@ import React, {
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
import {
ActionIcon,
Divider,
Group,
Paper,
ScrollArea,
@@ -51,6 +52,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation();
const emit = useQueryEmit();
const isInCommentContext = props.isInCommentContext ?? false;
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: props.query,
@@ -58,6 +60,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
includePages: true,
spaceId: space.id,
limit: 10,
preload: true,
});
const createPageItem = (label: string) : MentionSuggestionItem => {
@@ -102,7 +105,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
})),
);
}
items.push(createPageItem(props.query));
if (!isInCommentContext && props.query) {
items.push(createPageItem(props.query));
}
setRenderItems(items);
// update editor storage
@@ -250,35 +255,51 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
}
}
// if no results and enter what to do?
useEffect(() => {
viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
const popupWidth = isInCommentContext ? 280 : 320;
if (renderItems.length === 0) {
return (
<Paper shadow="md" p="xs" withBorder>
{ t("No results") }
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
<Text c="dimmed" size="sm" px="sm">
{ t("No results") }
</Text>
</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 (
<Paper id="mention" shadow="md" p="xs" withBorder>
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={350}
w={320}
scrollbarSize={8}
w={popupWidth}
scrollbarSize={6}
>
{renderItems?.map((item, index) => {
if (item.entityType === "header") {
const isFirst = index === 0;
return (
<div key={`${item.label}-${index}`}>
<Text c="dimmed" mb={4} tt="uppercase">
{!isFirst && <Divider my={6} />}
<Text
c="dimmed"
size="xs"
fw={500}
px="sm"
pt={isFirst ? 2 : 4}
pb={4}
tt="uppercase"
>
{item.label}
</Text>
</div>
@@ -292,8 +313,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
})}
px="sm"
>
<Group>
<Group gap="sm">
<CustomAvatar
size={"sm"}
avatarUrl={item.avatarUrl}
@@ -308,7 +330,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
</Group>
</UnstyledButton>
);
} else if (item.entityType === "page") {
} else if (item.entityType === "page" && item.id !== null) {
return (
<UnstyledButton
data-item-index={index}
@@ -317,28 +339,24 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
})}
px="sm"
>
<Group>
<Group gap="sm" wrap="nowrap">
<ActionIcon
variant="default"
variant="subtle"
component="div"
aria-label={item.label}
color="gray"
size="sm"
>
{item.icon || (
<ActionIcon
component="span"
variant="transparent"
color="gray"
size={18}
>
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
</ActionIcon>
<IconFileDescription size={18} stroke={1.5} />
)}
</ActionIcon>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{item.label}
</Text>
</div>
</Group>
@@ -348,6 +366,37 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
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>
</Paper>
);
@@ -17,8 +17,13 @@ const mentionRenderItems = () => {
let component: ReactRenderer | null = null;
let activeClientRect: (() => DOMRect) | null = null;
let updatePositionCleanup: (() => void) | null = null;
let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
const destroy = () => {
if (outsideClickHandler) {
document.removeEventListener("pointerdown", outsideClickHandler);
outsideClickHandler = null;
}
updatePositionCleanup?.();
updatePositionCleanup = null;
component?.destroy();
@@ -45,8 +50,14 @@ const mentionRenderItems = () => {
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, {
props,
props: { ...props, isInCommentContext },
editor: props.editor,
});
@@ -59,6 +70,18 @@ const mentionRenderItems = () => {
const { element } = component;
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(
{
getBoundingClientRect: () =>
@@ -76,7 +99,7 @@ const mentionRenderItems = () => {
element,
{
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
middleware: [offset(4), flip(), shiftMiddleware],
},
).then(({ x, y }) => {
Object.assign(element.style, {
@@ -31,14 +31,14 @@
.menuBtn {
width: 100%;
padding: 4px;
margin-bottom: 2px;
padding: 6px 4px;
margin-bottom: 1px;
color: var(--mantine-color-text);
border-radius: var(--mantine-radius-sm);
&:hover {
@mixin light {
background: var(--mantine-color-gray-2);
background: var(--mantine-color-gray-1);
}
@mixin dark {
@@ -49,7 +49,7 @@
.selectedItem {
@mixin light {
background: var(--mantine-color-gray-2);
background: var(--mantine-color-gray-1);
}
@mixin dark {
@@ -7,6 +7,7 @@ export interface MentionListProps {
range: Range;
text: string;
editor: Editor;
isInCommentContext?: boolean;
}
export type MentionSuggestionItem =
@@ -20,6 +20,8 @@ import {
IconCalendar,
IconAppWindow,
IconSitemap,
IconColumns3,
IconColumns2,
} from "@tabler/icons-react";
import {
CommandProps,
@@ -31,6 +33,8 @@ import { uploadAttachmentAction } from "@/features/editor/components/attachment/
import IconExcalidraw from "@/components/icons/icon-excalidraw";
import IconMermaid from "@/components/icons/icon-mermaid";
import IconDrawio from "@/components/icons/icon-drawio";
import { IconColumns4 } from "@/components/icons/icon-columns-4";
import { IconColumns5 } from "@/components/icons/icon-columns-5";
import {
AirtableIcon,
FigmaIcon,
@@ -390,6 +394,58 @@ const CommandGroups: SlashMenuGroupedItemsType = {
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",
description: "Embed any Iframe",
@@ -95,7 +95,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
<Popover.Target>
<Tooltip label={t("Background color")} withArrow>
<ActionIcon
variant="default"
variant="subtle"
size="lg"
aria-label={t("Background color")}
onClick={() => setOpened(!opened)}
@@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment";
import { BubbleMenu } from "@tiptap/react/menus";
import classes from "../common/toolbar-menu.module.css";
export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@@ -69,14 +70,16 @@ export const TableCellMenu = React.memo(
}}
shouldShow={shouldShow}
>
<ActionIcon.Group>
<div className={classes.toolbar}>
<TableBackgroundColor editor={editor} />
<TableTextAlignment editor={editor} />
<div className={classes.divider} />
<Tooltip position="top" label={t("Merge cells")}>
<ActionIcon
onClick={mergeCells}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Merge cells")}
>
@@ -87,7 +90,7 @@ export const TableCellMenu = React.memo(
<Tooltip position="top" label={t("Split cell")}>
<ActionIcon
onClick={splitCell}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Split cell")}
>
@@ -95,10 +98,12 @@ export const TableCellMenu = React.memo(
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Delete column")}>
<ActionIcon
onClick={deleteColumn}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Delete column")}
>
@@ -109,7 +114,7 @@ export const TableCellMenu = React.memo(
<Tooltip position="top" label={t("Delete row")}>
<ActionIcon
onClick={deleteRow}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Delete row")}
>
@@ -117,17 +122,19 @@ export const TableCellMenu = React.memo(
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Toggle header cell")}>
<ActionIcon
onClick={toggleHeaderCell}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Toggle header cell")}
>
<IconTableRow size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</div>
</BubbleMenu>
);
}
@@ -18,8 +18,9 @@ import {
IconTrashX,
} from "@tabler/icons-react";
import { BubbleMenu } from "@tiptap/react/menus";
import { isCellSelection } from "@docmost/editor-ext";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
export const TableMenu = React.memo(
({ editor }: EditorMenuProps): JSX.Element => {
@@ -30,6 +31,7 @@ export const TableMenu = React.memo(
return false;
}
if (isTextSelected(editor)) return false;
return editor.isActive("table") && !isCellSelection(state.selection);
},
[editor]
@@ -118,11 +120,11 @@ export const TableMenu = React.memo(
}}
shouldShow={shouldShow}
>
<ActionIcon.Group>
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Add left column")}>
<ActionIcon
onClick={addColumnLeft}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Add left column")}
>
@@ -133,7 +135,7 @@ export const TableMenu = React.memo(
<Tooltip position="top" label={t("Add right column")}>
<ActionIcon
onClick={addColumnRight}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Add right column")}
>
@@ -144,7 +146,7 @@ export const TableMenu = React.memo(
<Tooltip position="top" label={t("Delete column")}>
<ActionIcon
onClick={deleteColumn}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Delete column")}
>
@@ -152,10 +154,12 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Add row above")}>
<ActionIcon
onClick={addRowAbove}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Add row above")}
>
@@ -166,7 +170,7 @@ export const TableMenu = React.memo(
<Tooltip position="top" label={t("Add row below")}>
<ActionIcon
onClick={addRowBelow}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Add row below")}
>
@@ -177,7 +181,7 @@ export const TableMenu = React.memo(
<Tooltip position="top" label={t("Delete row")}>
<ActionIcon
onClick={deleteRow}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Delete row")}
>
@@ -185,10 +189,12 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Toggle header row")}>
<ActionIcon
onClick={toggleHeaderRow}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Toggle header row")}
>
@@ -199,7 +205,7 @@ export const TableMenu = React.memo(
<Tooltip position="top" label={t("Toggle header column")}>
<ActionIcon
onClick={toggleHeaderColumn}
variant="default"
variant="subtle"
size="lg"
aria-label={t("Toggle header column")}
>
@@ -207,18 +213,19 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Delete table")}>
<ActionIcon
onClick={deleteTable}
variant="default"
variant="subtle"
size="lg"
color="red"
aria-label={t("Delete table")}
>
<IconTrashX size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</div>
</BubbleMenu>
);
}
@@ -88,7 +88,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
<Popover.Target>
<Tooltip label={t("Text alignment")} withArrow>
<ActionIcon
variant="default"
variant="subtle"
size="lg"
aria-label={t("Text alignment")}
onClick={() => setOpened(!opened)}
@@ -1,19 +1,23 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import clsx from "clsx";
import {
IconLayoutAlignCenter,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconDownload,
IconTrash,
} from "@tabler/icons-react";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import classes from "../common/toolbar-menu.module.css";
export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
@@ -32,7 +36,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
width: videoAttrs?.width ? parseInt(videoAttrs.width) : null,
src: videoAttrs?.src || null,
};
},
});
@@ -70,7 +74,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
};
}, [editor]);
const alignVideoLeft = useCallback(() => {
const alignLeft = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
@@ -78,7 +82,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
.run();
}, [editor]);
const alignVideoCenter = useCallback(() => {
const alignCenter = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
@@ -86,7 +90,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
.run();
}, [editor]);
const alignVideoRight = useCallback(() => {
const alignRight = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
@@ -94,16 +98,18 @@ export function VideoMenu({ editor }: EditorMenuProps) {
.run();
}, [editor]);
const onWidthChange = useCallback(
(value: number) => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setVideoWidth(value)
.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]);
return (
<BaseBubbleMenu
@@ -118,13 +124,14 @@ export function VideoMenu({ editor }: EditorMenuProps) {
}}
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")}>
<ActionIcon
onClick={alignVideoLeft}
onClick={alignLeft}
size="lg"
aria-label={t("Align left")}
variant={editorState?.isAlignLeft ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
@@ -132,10 +139,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<Tooltip position="top" label={t("Align center")}>
<ActionIcon
onClick={alignVideoCenter}
onClick={alignCenter}
size="lg"
aria-label={t("Align center")}
variant={editorState?.isAlignCenter ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
@@ -143,19 +151,40 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<Tooltip position="top" label={t("Align right")}>
<ActionIcon
onClick={alignVideoRight}
onClick={alignRight}
size="lg"
aria-label={t("Align right")}
variant={editorState?.isAlignRight ? "light" : "default"}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignRight })}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
<div className={classes.divider} />
<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>
);
}
@@ -1,4 +1,6 @@
import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
import { Placeholder, CharacterCount } from "@tiptap/extensions";
@@ -43,6 +45,8 @@ import {
Highlight,
UniqueID,
SharedStorage,
Columns,
Column,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -52,6 +56,14 @@ import { IUser } from "@/features/user/types/user.types.ts";
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
import MathBlockView from "@/features/editor/components/math/math-block.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 VideoView from "@/features/editor/components/video/video-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
@@ -91,6 +103,7 @@ lowlight.register("fortran", fortran);
lowlight.register("haskell", haskell);
lowlight.register("scala", scala);
// @ts-ignore
export const mainExtensions = [
StarterKit.configure({
heading: false,
@@ -102,10 +115,24 @@ export const mainExtensions = [
color: "#70CFF8",
},
codeBlock: false,
code: {
HTMLAttributes: {
spellcheck: false,
},
code: false,
}),
// Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule
// to delete it. Using a lookbehind avoids including it in the match.
Code.configure({
HTMLAttributes: {
spellcheck: false,
},
}).extend({
addInputRules() {
return [
markInputRule({
find: /(?:^|(?<=[^`]))`([^`]+)`(?!`)$/,
type: this.type,
}),
];
},
}),
SharedStorage,
@@ -115,7 +142,7 @@ export const mainExtensions = [
filterTransaction: (transaction) => !isChangeOrigin(transaction),
}),
Placeholder.configure({
placeholder: ({ node }) => {
placeholder: ({ editor, node, pos }) => {
if (node.type.name === "heading") {
return i18n.t("Heading {{level}}", { level: node.attrs.level });
}
@@ -123,6 +150,17 @@ export const mainExtensions = [
return i18n.t("Toggle title");
}
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');
}
},
@@ -200,9 +238,29 @@ export const mainExtensions = [
TiptapImage.configure({
view: ImageView,
allowBase64: false,
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createImageHandle,
className: imageResizeClasses,
},
}),
TiptapVideo.configure({
view: VideoView,
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
className: buildResizeClasses("node-video"),
},
}),
Callout.configure({
view: CalloutView,
@@ -221,9 +279,29 @@ export const mainExtensions = [
}),
Drawio.configure({
view: DrawioView,
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
className: buildResizeClasses("node-drawio"),
},
}),
Excalidraw.configure({
view: ExcalidrawView,
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
className: buildResizeClasses("node-excalidraw"),
},
}),
Embed.configure({
view: EmbedView,
@@ -253,6 +331,8 @@ export const mainExtensions = [
};
},
}).configure(),
Columns,
Column,
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@@ -66,6 +66,8 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
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 {
pageId: string;
@@ -405,6 +407,7 @@ export default function PageEditor({
{editor && editorIsEditable && (
<div>
<EditorAiMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
@@ -414,6 +417,7 @@ export default function PageEditor({
<SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div>
)}
@@ -0,0 +1,124 @@
div[data-type="columns"] {
display: flex;
margin: 0.75rem 0;
padding: 0.5em 0;
}
div[data-type="columns"] > div[data-type="column"] {
flex: 1;
min-width: 0;
padding-right: 1rem;
}
div[data-type="columns"] > div[data-type="column"]:last-child {
padding-right: 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"],
div[data-type="columns"].has-focus
> 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: 680px) {
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%;
}
}
+11 -14
View File
@@ -82,13 +82,9 @@
}
blockquote {
padding-left: 25px;
padding-right: 25px;
border-left: 2px solid var(--mantine-color-gray-6);
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-8)
);
padding-left: 1rem;
border-left: 3px solid
light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
margin: 0;
}
@@ -126,13 +122,14 @@
margin-bottom: 0;
}
&.node-callout {
div[style*="white-space: inherit;"] {
> :first-child {
margin: 0;
}
}
}
}
.react-renderer.node-callout div[style*="white-space: inherit;"] > :first-child {
margin-top: 0;
}
.react-renderer.node-callout + .react-renderer.node-callout {
margin-top: 0.75em;
}
.selection {
@@ -13,3 +13,4 @@
@import "./mention.css";
@import "./ordered-list.css";
@import "./highlight.css";
@import "./columns.css";
@@ -160,7 +160,7 @@ export function TitleEditor({
// guard against Cannot access view['hasFocus'] error
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end");
}, 500);
}, 300);
}, [titleEditor]);
useEffect(() => {
@@ -0,0 +1,148 @@
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>
);
}
@@ -0,0 +1,115 @@
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>
);
}
@@ -0,0 +1,142 @@
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>
);
}
@@ -0,0 +1,23 @@
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]);
}
@@ -0,0 +1,13 @@
.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));
}
@@ -0,0 +1,75 @@
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],
}));
}
@@ -0,0 +1,59 @@
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 });
},
});
}
@@ -0,0 +1,31 @@
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");
}
@@ -0,0 +1,39 @@
export type NotificationType =
| "comment.user_mention"
| "comment.created"
| "comment.resolved"
| "page.user_mention";
export type INotification = {
id: string;
userId: string;
workspaceId: string;
type: NotificationType;
actorId: string | null;
pageId: string | null;
spaceId: string | null;
commentId: string | null;
data: Record<string, unknown> | null;
readAt: string | null;
emailedAt: string | null;
archivedAt: string | null;
createdAt: string;
actor: {
id: string;
name: string;
avatarUrl: string | null;
} | null;
page: {
id: string;
title: string;
slugId: string;
icon: string | null;
} | null;
space: {
id: string;
name: string;
slug: string;
} | null;
};
export type NotificationFilter = "all" | "unread";
@@ -1,4 +1,4 @@
import { Text, Group, UnstyledButton } from "@mantine/core";
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css";
@@ -6,6 +6,8 @@ import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react";
const MAX_VISIBLE_AVATARS = 5;
interface HistoryItemProps {
historyItem: IPageHistory;
index: number;
@@ -31,6 +33,9 @@ const HistoryItem = memo(function HistoryItem({
onHover?.(historyItem.id, index);
}, [onHover, historyItem.id, index]);
const contributors = historyItem.contributors;
const hasContributors = contributors && contributors.length > 0;
return (
<UnstyledButton
p="xs"
@@ -39,25 +44,54 @@ const HistoryItem = memo(function HistoryItem({
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
>
<Group wrap="nowrap">
<div>
<Text size="sm">
{formattedDate(new Date(historyItem.createdAt))}
</Text>
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
<div style={{ flex: 1 }}>
<Group gap={4} wrap="nowrap">
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
<>
<Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}>
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
<Tooltip key={contributor.id} label={contributor.name} withArrow>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))}
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
<div key={c.id}>{c.name}</div>
))}
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
{contributors[0].name}
</Text>
</Group>
</div>
</div>
)}
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group>
</UnstyledButton>
);

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