mirror of
https://github.com/docmost/docmost.git
synced 2026-05-18 23:44:24 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db404815b0 | |||
| d9ebeb2b85 | |||
| c3a9a52b7f |
+1
-7
@@ -46,10 +46,4 @@ DRAWIO_URL=
|
|||||||
DISABLE_TELEMETRY=false
|
DISABLE_TELEMETRY=false
|
||||||
|
|
||||||
# Enable debug logging in production (default: false)
|
# Enable debug logging in production (default: false)
|
||||||
DEBUG_MODE=false
|
DEBUG_MODE=false
|
||||||
|
|
||||||
# Log database queries
|
|
||||||
DEBUG_DB=false
|
|
||||||
|
|
||||||
# Log http requests
|
|
||||||
LOG_HTTP=false
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.25.0-beta.1",
|
"version": "0.24.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"@mantine/spotlight": "^8.3.12",
|
"@mantine/spotlight": "^8.3.12",
|
||||||
"@tabler/icons-react": "^3.36.1",
|
"@tabler/icons-react": "^3.36.1",
|
||||||
"@tanstack/react-query": "^5.90.17",
|
"@tanstack/react-query": "^5.90.17",
|
||||||
|
"@tiptap/extension-character-count": "^2.27.1",
|
||||||
"alfaaz": "^1.1.0",
|
"alfaaz": "^1.1.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.",
|
"Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.",
|
||||||
"Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.",
|
"Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.",
|
||||||
"Confirm": "Bestätigen",
|
"Confirm": "Bestätigen",
|
||||||
"Copy as Markdown": "Als Markdown kopieren",
|
|
||||||
"Copy link": "Link kopieren",
|
"Copy link": "Link kopieren",
|
||||||
"Create": "Erstellen",
|
"Create": "Erstellen",
|
||||||
"Create group": "Gruppe erstellen",
|
"Create group": "Gruppe erstellen",
|
||||||
@@ -235,9 +234,7 @@
|
|||||||
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
|
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
|
||||||
"Invite link": "Einladungslink",
|
"Invite link": "Einladungslink",
|
||||||
"Copy": "Kopieren",
|
"Copy": "Kopieren",
|
||||||
"Copy to space": "In Raum kopieren",
|
|
||||||
"Copied": "Kopiert",
|
"Copied": "Kopiert",
|
||||||
"Duplicate": "Duplizieren",
|
|
||||||
"Select a user": "Benutzer auswählen",
|
"Select a user": "Benutzer auswählen",
|
||||||
"Select a group": "Gruppe auswählen",
|
"Select a group": "Gruppe auswählen",
|
||||||
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
|
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
|
||||||
@@ -254,7 +251,6 @@
|
|||||||
"Export failed:": "Export fehlgeschlagen:",
|
"Export failed:": "Export fehlgeschlagen:",
|
||||||
"export error": "Exportfehler",
|
"export error": "Exportfehler",
|
||||||
"Export page": "Seite exportieren",
|
"Export page": "Seite exportieren",
|
||||||
"Export successful": "Export erfolgreich",
|
|
||||||
"Export space": "Bereich exportieren",
|
"Export space": "Bereich exportieren",
|
||||||
"Export {{type}}": "Exportiere {{type}}",
|
"Export {{type}}": "Exportiere {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
|
||||||
@@ -330,8 +326,6 @@
|
|||||||
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
|
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
|
||||||
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
|
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
|
||||||
"Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.",
|
"Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.",
|
||||||
"Uploading {{name}}": "Lade {{name}} hoch",
|
|
||||||
"Uploading file": "Datei wird hochgeladen",
|
|
||||||
"Table": "Tabelle",
|
"Table": "Tabelle",
|
||||||
"Insert a table.": "Tabelle einfügen.",
|
"Insert a table.": "Tabelle einfügen.",
|
||||||
"Insert collapsible block.": "Einklappbaren Block einfügen.",
|
"Insert collapsible block.": "Einklappbaren Block einfügen.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
||||||
"Choose your preferred page width.": "Choose your preferred page width.",
|
"Choose your preferred page width.": "Choose your preferred page width.",
|
||||||
"Confirm": "Confirm",
|
"Confirm": "Confirm",
|
||||||
"Copy as Markdown": "Copy as Markdown",
|
|
||||||
"Copy link": "Copy link",
|
"Copy link": "Copy link",
|
||||||
"Create": "Create",
|
"Create": "Create",
|
||||||
"Create group": "Create group",
|
"Create group": "Create group",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Export failed:",
|
"Export failed:": "Export failed:",
|
||||||
"export error": "export error",
|
"export error": "export error",
|
||||||
"Export page": "Export page",
|
"Export page": "Export page",
|
||||||
"Export successful": "Export successful",
|
|
||||||
"Export space": "Export space",
|
"Export space": "Export space",
|
||||||
"Export {{type}}": "Export {{type}}",
|
"Export {{type}}": "Export {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Upload any image from your device.",
|
"Upload any image from your device.": "Upload any image from your device.",
|
||||||
"Upload any video from your device.": "Upload any video from your device.",
|
"Upload any video from your device.": "Upload any video from your device.",
|
||||||
"Upload any file from your device.": "Upload any file from your device.",
|
"Upload any file from your device.": "Upload any file from your device.",
|
||||||
"Uploading {{name}}": "Uploading {{name}}",
|
|
||||||
"Uploading file": "Uploading file",
|
|
||||||
"Table": "Table",
|
"Table": "Table",
|
||||||
"Insert a table.": "Insert a table.",
|
"Insert a table.": "Insert a table.",
|
||||||
"Insert collapsible block.": "Insert collapsible block.",
|
"Insert collapsible block.": "Insert collapsible block.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
|
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
|
||||||
"Choose your preferred page width.": "Elige el ancho de página que prefieras.",
|
"Choose your preferred page width.": "Elige el ancho de página que prefieras.",
|
||||||
"Confirm": "Confirmar",
|
"Confirm": "Confirmar",
|
||||||
"Copy as Markdown": "Copiar como Markdown",
|
|
||||||
"Copy link": "Copiar enlace",
|
"Copy link": "Copiar enlace",
|
||||||
"Create": "Crear",
|
"Create": "Crear",
|
||||||
"Create group": "Crear grupo",
|
"Create group": "Crear grupo",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Exportación fallida:",
|
"Export failed:": "Exportación fallida:",
|
||||||
"export error": "error de exportación",
|
"export error": "error de exportación",
|
||||||
"Export page": "Exportar página",
|
"Export page": "Exportar página",
|
||||||
"Export successful": "Exportación exitosa",
|
|
||||||
"Export space": "Exportar espacio",
|
"Export space": "Exportar espacio",
|
||||||
"Export {{type}}": "Exportar {{type}}",
|
"Export {{type}}": "Exportar {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
|
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
|
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
|
||||||
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
|
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
|
||||||
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
|
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
|
||||||
"Uploading {{name}}": "Subiendo {{name}}",
|
|
||||||
"Uploading file": "Subiendo archivo",
|
|
||||||
"Table": "Tabla",
|
"Table": "Tabla",
|
||||||
"Insert a table.": "Insertar una tabla.",
|
"Insert a table.": "Insertar una tabla.",
|
||||||
"Insert collapsible block.": "Insertar bloque desplegable.",
|
"Insert collapsible block.": "Insertar bloque desplegable.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.",
|
"Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.",
|
||||||
"Choose your preferred page width.": "Choisissez votre largeur de page préférée.",
|
"Choose your preferred page width.": "Choisissez votre largeur de page préférée.",
|
||||||
"Confirm": "Confirmer",
|
"Confirm": "Confirmer",
|
||||||
"Copy as Markdown": "Copier comme Markdown",
|
|
||||||
"Copy link": "Copier le lien",
|
"Copy link": "Copier le lien",
|
||||||
"Create": "Créer",
|
"Create": "Créer",
|
||||||
"Create group": "Créer groupe",
|
"Create group": "Créer groupe",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Échec de l'exportation :",
|
"Export failed:": "Échec de l'exportation :",
|
||||||
"export error": "exporter l'erreur",
|
"export error": "exporter l'erreur",
|
||||||
"Export page": "Exporter la page",
|
"Export page": "Exporter la page",
|
||||||
"Export successful": "Exportation réussie",
|
|
||||||
"Export space": "Exporter l'espace",
|
"Export space": "Exporter l'espace",
|
||||||
"Export {{type}}": "Exporter {{type}}",
|
"Export {{type}}": "Exporter {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
|
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
|
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
|
||||||
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
|
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
|
||||||
"Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.",
|
"Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.",
|
||||||
"Uploading {{name}}": "Téléchargement de {{name}}",
|
|
||||||
"Uploading file": "Téléchargement du fichier",
|
|
||||||
"Table": "Tableau",
|
"Table": "Tableau",
|
||||||
"Insert a table.": "Insérez un tableau.",
|
"Insert a table.": "Insérez un tableau.",
|
||||||
"Insert collapsible block.": "Insérer un bloc repliable.",
|
"Insert collapsible block.": "Insérer un bloc repliable.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.",
|
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.",
|
||||||
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
|
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
|
||||||
"Confirm": "Conferma",
|
"Confirm": "Conferma",
|
||||||
"Copy as Markdown": "Copia come Markdown",
|
|
||||||
"Copy link": "Copia link",
|
"Copy link": "Copia link",
|
||||||
"Create": "Crea",
|
"Create": "Crea",
|
||||||
"Create group": "Crea gruppo",
|
"Create group": "Crea gruppo",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Esportazione fallita:",
|
"Export failed:": "Esportazione fallita:",
|
||||||
"export error": "errore di esportazione",
|
"export error": "errore di esportazione",
|
||||||
"Export page": "Esporta pagina",
|
"Export page": "Esporta pagina",
|
||||||
"Export successful": "Esportazione riuscita",
|
|
||||||
"Export space": "Esporta spazio",
|
"Export space": "Esporta spazio",
|
||||||
"Export {{type}}": "Esporta {{type}}",
|
"Export {{type}}": "Esporta {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
|
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
|
||||||
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
|
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
|
||||||
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
|
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
|
||||||
"Uploading {{name}}": "Caricamento di {{name}}",
|
|
||||||
"Uploading file": "Caricamento file",
|
|
||||||
"Table": "Tabella",
|
"Table": "Tabella",
|
||||||
"Insert a table.": "Inserisci una tabella.",
|
"Insert a table.": "Inserisci una tabella.",
|
||||||
"Insert collapsible block.": "Inserisci blocco comprimibile.",
|
"Insert collapsible block.": "Inserisci blocco comprimibile.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "お好みの言語を選択してください",
|
"Choose your preferred interface language.": "お好みの言語を選択してください",
|
||||||
"Choose your preferred page width.": "お好みのページ幅を選択してください",
|
"Choose your preferred page width.": "お好みのページ幅を選択してください",
|
||||||
"Confirm": "確認",
|
"Confirm": "確認",
|
||||||
"Copy as Markdown": "Markdownとしてコピー",
|
|
||||||
"Copy link": "リンクをコピー",
|
"Copy link": "リンクをコピー",
|
||||||
"Create": "新規作成",
|
"Create": "新規作成",
|
||||||
"Create group": "グループを作成",
|
"Create group": "グループを作成",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "エクスポートに失敗しました:",
|
"Export failed:": "エクスポートに失敗しました:",
|
||||||
"export error": "エクスポートエラー",
|
"export error": "エクスポートエラー",
|
||||||
"Export page": "エクスポートページ",
|
"Export page": "エクスポートページ",
|
||||||
"Export successful": "エクスポート成功",
|
|
||||||
"Export space": "エクスポートスペース",
|
"Export space": "エクスポートスペース",
|
||||||
"Export {{type}}": "{{type}}をエクスポート",
|
"Export {{type}}": "{{type}}をエクスポート",
|
||||||
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
|
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "デバイスから画像をアップロードします",
|
"Upload any image from your device.": "デバイスから画像をアップロードします",
|
||||||
"Upload any video from your device.": "デバイスから動画をアップロードします",
|
"Upload any video from your device.": "デバイスから動画をアップロードします",
|
||||||
"Upload any file from your device.": "デバイスからファイルをアップロードします",
|
"Upload any file from your device.": "デバイスからファイルをアップロードします",
|
||||||
"Uploading {{name}}": "{{name}} をアップロード中",
|
|
||||||
"Uploading file": "ファイルをアップロード中",
|
|
||||||
"Table": "テーブル",
|
"Table": "テーブル",
|
||||||
"Insert a table.": "テーブルを挿入します",
|
"Insert a table.": "テーブルを挿入します",
|
||||||
"Insert collapsible block.": "折りたたみブロックを挿入します",
|
"Insert collapsible block.": "折りたたみブロックを挿入します",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
|
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
|
||||||
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
|
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
|
||||||
"Confirm": "확인",
|
"Confirm": "확인",
|
||||||
"Copy as Markdown": "Markdown으로 복사",
|
|
||||||
"Copy link": "링크 복사",
|
"Copy link": "링크 복사",
|
||||||
"Create": "생성",
|
"Create": "생성",
|
||||||
"Create group": "팀 생성",
|
"Create group": "팀 생성",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "내보내기 실패:",
|
"Export failed:": "내보내기 실패:",
|
||||||
"export error": "내보내기 오류",
|
"export error": "내보내기 오류",
|
||||||
"Export page": "페이지 내보내기",
|
"Export page": "페이지 내보내기",
|
||||||
"Export successful": "내보내기 성공",
|
|
||||||
"Export space": "Space 내보내기",
|
"Export space": "Space 내보내기",
|
||||||
"Export {{type}}": "{{type}} 내보내기",
|
"Export {{type}}": "{{type}} 내보내기",
|
||||||
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
|
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
|
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
|
||||||
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
|
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
|
||||||
"Upload any file from your device.": "기기에서 파일을 업로드하세요.",
|
"Upload any file from your device.": "기기에서 파일을 업로드하세요.",
|
||||||
"Uploading {{name}}": "{{name}} 업로드 중",
|
|
||||||
"Uploading file": "파일 업로드 중",
|
|
||||||
"Table": "테이블",
|
"Table": "테이블",
|
||||||
"Insert a table.": "테이블 삽입.",
|
"Insert a table.": "테이블 삽입.",
|
||||||
"Insert collapsible block.": "접을 수 있는 블록 삽입.",
|
"Insert collapsible block.": "접을 수 있는 블록 삽입.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
|
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
|
||||||
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
|
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
|
||||||
"Confirm": "Bevestig",
|
"Confirm": "Bevestig",
|
||||||
"Copy as Markdown": "Kopiëren als Markdown",
|
|
||||||
"Copy link": "Link kopiëren",
|
"Copy link": "Link kopiëren",
|
||||||
"Create": "Aanmaken",
|
"Create": "Aanmaken",
|
||||||
"Create group": "Groep aanmaken",
|
"Create group": "Groep aanmaken",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Exporteren mislukt:",
|
"Export failed:": "Exporteren mislukt:",
|
||||||
"export error": "Exporteer fout",
|
"export error": "Exporteer fout",
|
||||||
"Export page": "Exporteer pagina",
|
"Export page": "Exporteer pagina",
|
||||||
"Export successful": "Export succesvol",
|
|
||||||
"Export space": "Exporteer ruimte",
|
"Export space": "Exporteer ruimte",
|
||||||
"Export {{type}}": "Exporteer {{type}}",
|
"Export {{type}}": "Exporteer {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
|
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
|
||||||
"Upload any video from your device.": "Upload een video vanaf uw apparaat.",
|
"Upload any video from your device.": "Upload een video vanaf uw apparaat.",
|
||||||
"Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
|
"Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
|
||||||
"Uploading {{name}}": "Uploaden {{name}}",
|
|
||||||
"Uploading file": "Bestand uploaden",
|
|
||||||
"Table": "Tabel",
|
"Table": "Tabel",
|
||||||
"Insert a table.": "Voeg een tabel in.",
|
"Insert a table.": "Voeg een tabel in.",
|
||||||
"Insert collapsible block.": "Inklapbaar blok invoegen.",
|
"Insert collapsible block.": "Inklapbaar blok invoegen.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Escolha o idioma da interface.",
|
"Choose your preferred interface language.": "Escolha o idioma da interface.",
|
||||||
"Choose your preferred page width.": "Escolha a largura preferida da página.",
|
"Choose your preferred page width.": "Escolha a largura preferida da página.",
|
||||||
"Confirm": "Confirmar",
|
"Confirm": "Confirmar",
|
||||||
"Copy as Markdown": "Copiar como Markdown",
|
|
||||||
"Copy link": "Copiar link",
|
"Copy link": "Copiar link",
|
||||||
"Create": "Criar",
|
"Create": "Criar",
|
||||||
"Create group": "Criar grupo",
|
"Create group": "Criar grupo",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Falha ao exportar:",
|
"Export failed:": "Falha ao exportar:",
|
||||||
"export error": "erro de exportação",
|
"export error": "erro de exportação",
|
||||||
"Export page": "Exportar página",
|
"Export page": "Exportar página",
|
||||||
"Export successful": "Exportação bem-sucedida",
|
|
||||||
"Export space": "Exportar espaço",
|
"Export space": "Exportar espaço",
|
||||||
"Export {{type}}": "Exportar para {{type}}",
|
"Export {{type}}": "Exportar para {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
|
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
|
||||||
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
|
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
|
||||||
"Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
|
"Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
|
||||||
"Uploading {{name}}": "Enviando {{name}}",
|
|
||||||
"Uploading file": "Enviando arquivo",
|
|
||||||
"Table": "Tabela",
|
"Table": "Tabela",
|
||||||
"Insert a table.": "Insira uma tabela.",
|
"Insert a table.": "Insira uma tabela.",
|
||||||
"Insert collapsible block.": "Insira um bloco colapsável.",
|
"Insert collapsible block.": "Insira um bloco colapsável.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
|
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
|
||||||
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
|
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
|
||||||
"Confirm": "Подтвердить",
|
"Confirm": "Подтвердить",
|
||||||
"Copy as Markdown": "Копировать как Markdown",
|
|
||||||
"Copy link": "Копировать ссылку",
|
"Copy link": "Копировать ссылку",
|
||||||
"Create": "Создать",
|
"Create": "Создать",
|
||||||
"Create group": "Создать группу",
|
"Create group": "Создать группу",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Экспортирование не удалось:",
|
"Export failed:": "Экспортирование не удалось:",
|
||||||
"export error": "ошибка экспорта",
|
"export error": "ошибка экспорта",
|
||||||
"Export page": "Экспорт страницы",
|
"Export page": "Экспорт страницы",
|
||||||
"Export successful": "Экспорт выполнен успешно",
|
|
||||||
"Export space": "Экспорт пространства",
|
"Export space": "Экспорт пространства",
|
||||||
"Export {{type}}": "Экспорт {{type}}",
|
"Export {{type}}": "Экспорт {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
|
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
|
||||||
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
|
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
|
||||||
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
|
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
|
||||||
"Uploading {{name}}": "Загрузка {{name}}",
|
|
||||||
"Uploading file": "Загрузка файла",
|
|
||||||
"Table": "Таблица",
|
"Table": "Таблица",
|
||||||
"Insert a table.": "Вставить таблицу.",
|
"Insert a table.": "Вставить таблицу.",
|
||||||
"Insert collapsible block.": "Вставить сворачиваемый блок.",
|
"Insert collapsible block.": "Вставить сворачиваемый блок.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
|
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
|
||||||
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
|
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
|
||||||
"Confirm": "Підтвердити",
|
"Confirm": "Підтвердити",
|
||||||
"Copy as Markdown": "Скопіювати як Markdown",
|
|
||||||
"Copy link": "Копіювати посилання",
|
"Copy link": "Копіювати посилання",
|
||||||
"Create": "Створити",
|
"Create": "Створити",
|
||||||
"Create group": "Створити групу",
|
"Create group": "Створити групу",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Експортування не вдалося:",
|
"Export failed:": "Експортування не вдалося:",
|
||||||
"export error": "помилка експорту",
|
"export error": "помилка експорту",
|
||||||
"Export page": "Експорт сторінки",
|
"Export page": "Експорт сторінки",
|
||||||
"Export successful": "Експорт виконано успішно",
|
|
||||||
"Export space": "Експорт простору",
|
"Export space": "Експорт простору",
|
||||||
"Export {{type}}": "Експорт {{type}}",
|
"Export {{type}}": "Експорт {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
|
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
|
||||||
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
|
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
|
||||||
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
|
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
|
||||||
"Uploading {{name}}": "Завантаження {{name}}",
|
|
||||||
"Uploading file": "Завантаження файлу",
|
|
||||||
"Table": "Таблиця",
|
"Table": "Таблиця",
|
||||||
"Insert a table.": "Вставити таблицю.",
|
"Insert a table.": "Вставити таблицю.",
|
||||||
"Insert collapsible block.": "Вставити блок, що згортається.",
|
"Insert collapsible block.": "Вставити блок, що згортається.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "选择您喜欢的界面语言。",
|
"Choose your preferred interface language.": "选择您喜欢的界面语言。",
|
||||||
"Choose your preferred page width.": "选择您喜欢的页面宽度。",
|
"Choose your preferred page width.": "选择您喜欢的页面宽度。",
|
||||||
"Confirm": "确认",
|
"Confirm": "确认",
|
||||||
"Copy as Markdown": "复制为Markdown",
|
|
||||||
"Copy link": "复制链接",
|
"Copy link": "复制链接",
|
||||||
"Create": "创建",
|
"Create": "创建",
|
||||||
"Create group": "创建群组",
|
"Create group": "创建群组",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "导出失败:",
|
"Export failed:": "导出失败:",
|
||||||
"export error": "导出出错",
|
"export error": "导出出错",
|
||||||
"Export page": "导出页面",
|
"Export page": "导出页面",
|
||||||
"Export successful": "导出成功",
|
|
||||||
"Export space": "导出空间",
|
"Export space": "导出空间",
|
||||||
"Export {{type}}": "导出为 {{type}}",
|
"Export {{type}}": "导出为 {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
|
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "从设备上传任何图像",
|
"Upload any image from your device.": "从设备上传任何图像",
|
||||||
"Upload any video from your device.": "从设备上传任何视频",
|
"Upload any video from your device.": "从设备上传任何视频",
|
||||||
"Upload any file from your device.": "从设备上传任何文件",
|
"Upload any file from your device.": "从设备上传任何文件",
|
||||||
"Uploading {{name}}": "正在上传{{name}}",
|
|
||||||
"Uploading file": "正在上传文件",
|
|
||||||
"Table": "表格",
|
"Table": "表格",
|
||||||
"Insert a table.": "插入一个表格",
|
"Insert a table.": "插入一个表格",
|
||||||
"Insert collapsible block.": "插入一个折叠块",
|
"Insert collapsible block.": "插入一个折叠块",
|
||||||
|
|||||||
@@ -30,11 +30,9 @@ export default function ExportModal({
|
|||||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||||
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
setIsExporting(true);
|
|
||||||
try {
|
try {
|
||||||
if (type === "page") {
|
if (type === "page") {
|
||||||
await exportPage({
|
await exportPage({
|
||||||
@@ -47,9 +45,6 @@ export default function ExportModal({
|
|||||||
if (type === "space") {
|
if (type === "space") {
|
||||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||||
}
|
}
|
||||||
notifications.show({
|
|
||||||
message: t("Export successful"),
|
|
||||||
});
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -57,8 +52,6 @@ export default function ExportModal({
|
|||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
console.error("export error", err);
|
console.error("export error", err);
|
||||||
} finally {
|
|
||||||
setIsExporting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,7 +136,7 @@ export default function ExportModal({
|
|||||||
<Button onClick={onClose} variant="default">
|
<Button onClick={onClose} variant="default">
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
<Button onClick={handleExport}>{t("Export")}</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ import { Button, Group } from "@mantine/core";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface PagePaginationProps {
|
export interface PagePaginationProps {
|
||||||
|
currentPage: number;
|
||||||
hasPrevPage: boolean;
|
hasPrevPage: boolean;
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
onPrev: () => void;
|
onPageChange: (newPage: number) => void;
|
||||||
onNext: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Paginate({
|
export default function Paginate({
|
||||||
|
currentPage,
|
||||||
hasPrevPage,
|
hasPrevPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
onPrev,
|
onPageChange,
|
||||||
onNext,
|
|
||||||
}: PagePaginationProps) {
|
}: PagePaginationProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export default function Paginate({
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={onPrev}
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
disabled={!hasPrevPage}
|
disabled={!hasPrevPage}
|
||||||
>
|
>
|
||||||
{t("Prev")}
|
{t("Prev")}
|
||||||
@@ -34,7 +34,7 @@ export default function Paginate({
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={onNext}
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
disabled={!hasNextPage}
|
disabled={!hasNextPage}
|
||||||
>
|
>
|
||||||
{t("Next")}
|
{t("Next")}
|
||||||
|
|||||||
@@ -5,27 +5,26 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Table,
|
Table,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
} from "@mantine/core";
|
} from '@mantine/core';
|
||||||
import { Link } from "react-router-dom";
|
import {Link} from 'react-router-dom';
|
||||||
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from '@/features/page/page.utils.ts';
|
||||||
import { formattedDate } from "@/lib/time.ts";
|
import { formattedDate } from '@/lib/time.ts';
|
||||||
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
|
||||||
import { IconFileDescription } from "@tabler/icons-react";
|
import { IconFileDescription } from '@tabler/icons-react';
|
||||||
import { getSpaceUrl } from "@/lib/config.ts";
|
import { getSpaceUrl } from '@/lib/config.ts';
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getInitialsColor } from "@/lib/get-initials-color.ts";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecentChanges({ spaceId }: Props) {
|
export default function RecentChanges({spaceId}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
|
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <PageListSkeleton />;
|
return <PageListSkeleton/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
@@ -45,8 +44,8 @@ export default function RecentChanges({ spaceId }: Props) {
|
|||||||
>
|
>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{page.icon || (
|
{page.icon || (
|
||||||
<ActionIcon variant="transparent" color="gray" size={18}>
|
<ActionIcon variant='transparent' color='gray' size={18}>
|
||||||
<IconFileDescription size={18} />
|
<IconFileDescription size={18}/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -59,23 +58,18 @@ export default function RecentChanges({ spaceId }: Props) {
|
|||||||
{!spaceId && (
|
{!spaceId && (
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<Badge
|
||||||
color={getInitialsColor(page?.space.name)}
|
color="blue"
|
||||||
variant="light"
|
variant="light"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={getSpaceUrl(page?.space.slug)}
|
to={getSpaceUrl(page?.space.slug)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{cursor: 'pointer'}}
|
||||||
>
|
>
|
||||||
{page?.space.name}
|
{page?.space.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
)}
|
)}
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text
|
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
|
||||||
c="dimmed"
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
size="xs"
|
|
||||||
fw={500}
|
|
||||||
>
|
|
||||||
{formattedDate(page.updatedAt)}
|
{formattedDate(page.updatedAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
|
|||||||
import { getApiKeys } from "@/ee/api-key";
|
import { getApiKeys } from "@/ee/api-key";
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params: QueryParams = { limit: 100, query: "" };
|
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["workspaceMembers", params],
|
queryKey: ["workspaceMembers", params],
|
||||||
queryFn: () => getWorkspaceMembers(params),
|
queryFn: () => getWorkspaceMembers(params),
|
||||||
@@ -22,15 +22,15 @@ export const prefetchWorkspaceMembers = () => {
|
|||||||
|
|
||||||
export const prefetchSpaces = () => {
|
export const prefetchSpaces = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["spaces", {}],
|
queryKey: ["spaces", { page: 1 }],
|
||||||
queryFn: () => getSpaces({}),
|
queryFn: () => getSpaces({ page: 1 }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchGroups = () => {
|
export const prefetchGroups = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["groups", {}],
|
queryKey: ["groups", { page: 1 }],
|
||||||
queryFn: () => getGroups({}),
|
queryFn: () => getGroups({ page: 1 }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,21 +62,21 @@ export const prefetchSsoProviders = () => {
|
|||||||
|
|
||||||
export const prefetchShares = () => {
|
export const prefetchShares = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["share-list", {}],
|
queryKey: ["share-list", { page: 1 }],
|
||||||
queryFn: () => getShares({}),
|
queryFn: () => getShares({ page: 1, limit: 100 }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchApiKeys = () => {
|
export const prefetchApiKeys = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["api-key-list", {}],
|
queryKey: ["api-key-list", { page: 1 }],
|
||||||
queryFn: () => getApiKeys({}),
|
queryFn: () => getApiKeys({ page: 1 }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchApiKeyManagement = () => {
|
export const prefetchApiKeyManagement = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["api-key-list", { adminView: true }],
|
queryKey: ["api-key-list", { page: 1 }],
|
||||||
queryFn: () => getApiKeys({ adminView: true }),
|
queryFn: () => getApiKeys({ page: 1, adminView: true }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { useRef, useState, ReactNode } from "react";
|
|
||||||
import { Text, TextProps, Tooltip } from "@mantine/core";
|
|
||||||
|
|
||||||
type AutoTooltipTextProps = TextProps & {
|
|
||||||
children: ReactNode;
|
|
||||||
tooltipLabel?: string;
|
|
||||||
tooltipProps?: Omit<
|
|
||||||
React.ComponentProps<typeof Tooltip>,
|
|
||||||
"children" | "label"
|
|
||||||
>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AutoTooltipText({
|
|
||||||
children,
|
|
||||||
tooltipLabel,
|
|
||||||
tooltipProps,
|
|
||||||
...textProps
|
|
||||||
}: AutoTooltipTextProps) {
|
|
||||||
const textRef = useRef<HTMLParagraphElement>(null);
|
|
||||||
const [isTruncated, setIsTruncated] = useState(false);
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
const element = textRef.current;
|
|
||||||
if (element) {
|
|
||||||
setIsTruncated(element.scrollWidth > element.clientWidth);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
label={label}
|
|
||||||
disabled={!isTruncated || !label}
|
|
||||||
multiline
|
|
||||||
withArrow
|
|
||||||
{...tooltipProps}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
ref={textRef}
|
|
||||||
truncate
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
{...textProps}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,19 +10,19 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
|
|||||||
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
||||||
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
||||||
import Paginate from "@/components/common/paginate";
|
import Paginate from "@/components/common/paginate";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
||||||
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
||||||
import { IApiKey } from "@/ee/api-key";
|
import { IApiKey } from "@/ee/api-key";
|
||||||
|
|
||||||
export default function UserApiKeys() {
|
export default function UserApiKeys() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const { page, setPage } = usePaginateAndSearch();
|
||||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
const [createModalOpened, setCreateModalOpened] = useState(false);
|
||||||
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
||||||
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
||||||
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
||||||
const { data, isLoading } = useGetApiKeysQuery({ cursor });
|
const { data, isLoading } = useGetApiKeysQuery({ page });
|
||||||
|
|
||||||
const handleCreateSuccess = (response: IApiKey) => {
|
const handleCreateSuccess = (response: IApiKey) => {
|
||||||
setCreatedApiKey(response);
|
setCreatedApiKey(response);
|
||||||
@@ -65,10 +65,10 @@ export default function UserApiKeys() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -10,20 +10,20 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
|
|||||||
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
||||||
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
||||||
import Paginate from "@/components/common/paginate";
|
import Paginate from "@/components/common/paginate";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
||||||
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
||||||
import { IApiKey } from "@/ee/api-key";
|
import { IApiKey } from "@/ee/api-key";
|
||||||
import useUserRole from '@/hooks/use-user-role.tsx';
|
import useUserRole from '@/hooks/use-user-role.tsx';
|
||||||
|
|
||||||
export default function WorkspaceApiKeys() {
|
export default function WorkspaceApiKeys() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const { page, setPage } = usePaginateAndSearch();
|
||||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
const [createModalOpened, setCreateModalOpened] = useState(false);
|
||||||
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
||||||
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
||||||
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
||||||
const { data, isLoading } = useGetApiKeysQuery({ cursor, adminView: true });
|
const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
@@ -76,10 +76,10 @@ export default function WorkspaceApiKeys() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function SsoProviderList() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.items.length === 0) {
|
if (data?.length === 0) {
|
||||||
return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
|
return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export default function SsoProviderList() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data?.items
|
{data
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
|
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
|
||||||
if (enabledDiff !== 0) return enabledDiff;
|
if (enabledDiff !== 0) return enabledDiff;
|
||||||
@@ -104,11 +104,7 @@ export default function SsoProviderList() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}>
|
||||||
color={"gray"}
|
|
||||||
variant="light"
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
{provider.type.toUpperCase()}
|
{provider.type.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -138,41 +134,41 @@ export default function SsoProviderList() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={() => handleEdit(provider)}
|
onClick={() => handleEdit(provider)}
|
||||||
>
|
>
|
||||||
<IconPencil size={16} />
|
<IconPencil size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<Menu
|
<Menu
|
||||||
transitionProps={{ transition: "pop" }}
|
transitionProps={{ transition: "pop" }}
|
||||||
withArrow
|
withArrow
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
withinPortal
|
withinPortal
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="subtle" color="gray">
|
<ActionIcon variant="subtle" color="gray">
|
||||||
<IconDots size={16} />
|
<IconDots size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={() => handleEdit(provider)}
|
onClick={() => handleEdit(provider)}
|
||||||
leftSection={<IconPencil size={16} />}
|
leftSection={<IconPencil size={16} />}
|
||||||
>
|
>
|
||||||
{t("Edit")}
|
{t("Edit")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={() => openDeleteModal(provider.id)}
|
onClick={() => openDeleteModal(provider.id)}
|
||||||
leftSection={<IconTrash size={16} />}
|
leftSection={<IconTrash size={16} />}
|
||||||
color="red"
|
color="red"
|
||||||
disabled={provider.type === SSO_PROVIDER.GOOGLE}
|
disabled={provider.type === SSO_PROVIDER.GOOGLE}
|
||||||
>
|
>
|
||||||
{t("Delete")}
|
{t("Delete")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ import {
|
|||||||
} from "@/ee/security/services/security-service.ts";
|
} from "@/ee/security/services/security-service.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
|
|
||||||
export function useGetSsoProviders(): UseQueryResult<IPagination<IAuthProvider>, Error> {
|
export function useGetSsoProviders(): UseQueryResult<IAuthProvider[], Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["sso-providers"],
|
queryKey: ["sso-providers"],
|
||||||
queryFn: () => getSsoProviders(),
|
queryFn: () => getSsoProviders(),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import api from "@/lib/api-client.ts";
|
import api from "@/lib/api-client.ts";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
|
|
||||||
export async function getSsoProviderById(data: {
|
export async function getSsoProviderById(data: {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
@@ -9,8 +8,8 @@ export async function getSsoProviderById(data: {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSsoProviders(): Promise<IPagination<IAuthProvider>> {
|
export async function getSsoProviders(): Promise<IAuthProvider[]> {
|
||||||
const req = await api.post<IPagination<IAuthProvider>>("/sso/providers");
|
const req = await api.post<IAuthProvider[]>("/sso/providers");
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,15 @@ import { atom } from 'jotai';
|
|||||||
export const showCommentPopupAtom = atom<boolean>(false);
|
export const showCommentPopupAtom = atom<boolean>(false);
|
||||||
export const activeCommentIdAtom = atom<string>('');
|
export const activeCommentIdAtom = atom<string>('');
|
||||||
export const draftCommentIdAtom = atom<string>('');
|
export const draftCommentIdAtom = atom<string>('');
|
||||||
|
|
||||||
|
// Read-only comment state
|
||||||
|
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);
|
||||||
|
export type YjsSelection = {
|
||||||
|
anchor: any;
|
||||||
|
head: any;
|
||||||
|
};
|
||||||
|
export type ReadOnlyCommentData = {
|
||||||
|
yjsSelection: YjsSelection;
|
||||||
|
selectedText: string;
|
||||||
|
};
|
||||||
|
export const readOnlyCommentDataAtom = atom<ReadOnlyCommentData | null>(null);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createComment,
|
createComment,
|
||||||
|
createReadOnlyComment,
|
||||||
|
CreateReadOnlyCommentData,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
updateComment,
|
updateComment,
|
||||||
@@ -106,4 +108,23 @@ export function useDeleteCommentMutation(pageId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCreateReadOnlyCommentMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<IComment, Error, CreateReadOnlyCommentData>({
|
||||||
|
mutationFn: (data) => createReadOnlyComment(data),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) });
|
||||||
|
notifications.show({ message: t("Comment created successfully") });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
notifications.show({
|
||||||
|
message: t("Error creating comment"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
||||||
|
|||||||
@@ -40,3 +40,20 @@ export async function getPageComments(
|
|||||||
export async function deleteComment(commentId: string): Promise<void> {
|
export async function deleteComment(commentId: string): Promise<void> {
|
||||||
await api.post("/comments/delete", { commentId });
|
await api.post("/comments/delete", { commentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CreateReadOnlyCommentData = {
|
||||||
|
pageId: string;
|
||||||
|
content: string;
|
||||||
|
selection?: string;
|
||||||
|
yjsSelection: {
|
||||||
|
anchor: any;
|
||||||
|
head: any;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createReadOnlyComment(
|
||||||
|
data: CreateReadOnlyCommentData,
|
||||||
|
): Promise<IComment> {
|
||||||
|
const req = await api.post<IComment>("/comments/create-readonly", data);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
|
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
||||||
import { useHover } from "@mantine/hooks";
|
import { useHover } from "@mantine/hooks";
|
||||||
import { formatBytes } from "@/lib";
|
import { formatBytes } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export default function AttachmentView(props: NodeViewProps) {
|
export default function AttachmentView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { node, selected } = props;
|
const { node, selected } = props;
|
||||||
const { url, name, size } = node.attrs;
|
const { url, name, size } = node.attrs;
|
||||||
const { hovered, ref } = useHover();
|
const { hovered, ref } = useHover();
|
||||||
@@ -22,28 +20,26 @@ export default function AttachmentView(props: NodeViewProps) {
|
|||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
h={25}
|
h={25}
|
||||||
>
|
>
|
||||||
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
|
<Group justify="space-between" wrap="nowrap">
|
||||||
{url ? (
|
<IconPaperclip size={20} />
|
||||||
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
|
|
||||||
) : (
|
|
||||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
|
<Text component="span" size="md" truncate="end">
|
||||||
{url ? name : t("Uploading {{name}}", { name })}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
|
<Text component="span" size="sm" c="dimmed" inline>
|
||||||
{formatBytes(size)}
|
{formatBytes(size)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{url && (selected || hovered) && (
|
{selected || hovered ? (
|
||||||
<a href={getFileUrl(url)} target="_blank">
|
<a href={getFileUrl(url)} target="_blank">
|
||||||
<ActionIcon variant="default" aria-label="download file">
|
<ActionIcon variant="default" aria-label="download file">
|
||||||
<IconDownload size={18} />
|
<IconDownload size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</a>
|
</a>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
|
import {
|
||||||
import { isNodeSelection, useEditorState } from "@tiptap/react";
|
BubbleMenu,
|
||||||
import type { Editor } from "@tiptap/react";
|
BubbleMenuProps,
|
||||||
|
isNodeSelection,
|
||||||
|
useEditor,
|
||||||
|
useEditorState,
|
||||||
|
} from "@tiptap/react";
|
||||||
import { FC, useEffect, useRef, useState } from "react";
|
import { FC, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
IconBold,
|
IconBold,
|
||||||
@@ -34,7 +38,7 @@ export interface BubbleMenuItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||||
editor: Editor | null;
|
editor: ReturnType<typeof useEditor>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||||
@@ -129,9 +133,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
}
|
}
|
||||||
return isTextSelected(editor);
|
return isTextSelected(editor);
|
||||||
},
|
},
|
||||||
options: {
|
tippyOptions: {
|
||||||
placement: "top",
|
moveTransition: "transform 0.15s ease-out",
|
||||||
offset: 8,
|
onCreate: (instance) => {
|
||||||
|
instance.popper.firstChild?.addEventListener("blur", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
});
|
||||||
|
},
|
||||||
onHide: () => {
|
onHide: () => {
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
@@ -147,7 +156,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
|
<BubbleMenu {...bubbleMenuProps}>
|
||||||
<div className={classes.bubbleMenu}>
|
<div className={classes.bubbleMenu}>
|
||||||
<NodeSelector
|
<NodeSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
BubbleMenu,
|
||||||
|
isNodeSelection,
|
||||||
|
isTextSelection,
|
||||||
|
useEditor,
|
||||||
|
} from "@tiptap/react";
|
||||||
|
import { FC, useEffect, useRef } from "react";
|
||||||
|
import { IconMessage } from "@tabler/icons-react";
|
||||||
|
import classes from "./bubble-menu.module.css";
|
||||||
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
readOnlyCommentDataAtom,
|
||||||
|
showReadOnlyCommentPopupAtom,
|
||||||
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { isCellSelection } from "@docmost/editor-ext";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ySyncPluginKey } from "y-prosemirror";
|
||||||
|
import { getRelativeSelection } from "y-prosemirror";
|
||||||
|
|
||||||
|
type ReadOnlyBubbleMenuProps = {
|
||||||
|
editor: ReturnType<typeof useEditor>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReadOnlyBubbleMenu: FC<ReadOnlyBubbleMenuProps> = ({ editor }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom(
|
||||||
|
showReadOnlyCommentPopupAtom,
|
||||||
|
);
|
||||||
|
const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
|
||||||
|
const showPopupRef = useRef(showReadOnlyCommentPopup);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showPopupRef.current = showReadOnlyCommentPopup;
|
||||||
|
}, [showReadOnlyCommentPopup]);
|
||||||
|
|
||||||
|
const handleCommentClick = () => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const view = editor.view;
|
||||||
|
const ystate = ySyncPluginKey.getState(view.state);
|
||||||
|
|
||||||
|
if (ystate?.binding) {
|
||||||
|
const selection = getRelativeSelection(ystate.binding, view.state);
|
||||||
|
const { from, to } = editor.state.selection;
|
||||||
|
const selectedText = editor.state.doc.textBetween(from, to);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
setReadOnlyCommentData({
|
||||||
|
yjsSelection: {
|
||||||
|
anchor: selection.anchor,
|
||||||
|
head: selection.head,
|
||||||
|
},
|
||||||
|
selectedText,
|
||||||
|
});
|
||||||
|
setShowReadOnlyCommentPopup(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't render if editor is not available or is editable
|
||||||
|
if (!editor || editor.isEditable) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
pluginKey="readonly"
|
||||||
|
shouldShow={({ state, editor }) => {
|
||||||
|
// Safety check - don't show if editor became editable
|
||||||
|
if (!editor || editor.isEditable || editor.isDestroyed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { selection } = state;
|
||||||
|
const { empty, from, to } = selection;
|
||||||
|
|
||||||
|
if (
|
||||||
|
editor.isActive("image") ||
|
||||||
|
empty ||
|
||||||
|
isNodeSelection(selection) ||
|
||||||
|
isCellSelection(selection) ||
|
||||||
|
showPopupRef?.current
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if actual text is selected (not just empty block)
|
||||||
|
const hasText = state.doc.textBetween(from, to).length > 0;
|
||||||
|
return isTextSelection(selection) && hasText;
|
||||||
|
}}
|
||||||
|
tippyOptions={{
|
||||||
|
moveTransition: "transform 0.15s ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={classes.bubbleMenu}>
|
||||||
|
<Tooltip label={t("Comment")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="0"
|
||||||
|
aria-label={t("Comment")}
|
||||||
|
style={{ border: "none" }}
|
||||||
|
onClick={handleCommentClick}
|
||||||
|
>
|
||||||
|
<IconMessage size={16} stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</BubbleMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Dialog, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { useClickOutside } from "@mantine/hooks";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
activeCommentIdAtom,
|
||||||
|
readOnlyCommentDataAtom,
|
||||||
|
showReadOnlyCommentPopupAtom,
|
||||||
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
|
import { useCreateReadOnlyCommentMutation } from "@/features/comment/queries/comment-query";
|
||||||
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
|
import { useEditor } from "@tiptap/react";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
|
|
||||||
|
type ReadOnlyCommentDialogProps = {
|
||||||
|
editor: ReturnType<typeof useEditor>;
|
||||||
|
pageId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ReadOnlyCommentDialog({ editor, pageId }: ReadOnlyCommentDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||||
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
|
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(
|
||||||
|
readOnlyCommentDataAtom,
|
||||||
|
);
|
||||||
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
|
const useClickOutsideRef = useClickOutside(() => {
|
||||||
|
handleDialogClose();
|
||||||
|
});
|
||||||
|
const createCommentMutation = useCreateReadOnlyCommentMutation();
|
||||||
|
const { isPending } = createCommentMutation;
|
||||||
|
|
||||||
|
const emit = useQueryEmit();
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setShowReadOnlyCommentPopup(false);
|
||||||
|
setReadOnlyCommentData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddComment = async () => {
|
||||||
|
if (!readOnlyCommentData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const commentData = {
|
||||||
|
pageId: pageId,
|
||||||
|
content: JSON.stringify(comment),
|
||||||
|
selection: readOnlyCommentData.selectedText,
|
||||||
|
yjsSelection: readOnlyCommentData.yjsSelection,
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdComment =
|
||||||
|
await createCommentMutation.mutateAsync(commentData);
|
||||||
|
|
||||||
|
setActiveCommentId(createdComment.id);
|
||||||
|
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||||
|
const commentElement = document.querySelector(selector);
|
||||||
|
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
emit({
|
||||||
|
operation: "invalidateComment",
|
||||||
|
pageId: pageId,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setShowReadOnlyCommentPopup(false);
|
||||||
|
setReadOnlyCommentData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommentEditorChange = (newContent: any) => {
|
||||||
|
setComment(newContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
opened={true}
|
||||||
|
onClose={handleDialogClose}
|
||||||
|
ref={useClickOutsideRef}
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
w={300}
|
||||||
|
position={{ bottom: 500, right: 50 }}
|
||||||
|
withCloseButton
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Group>
|
||||||
|
<CustomAvatar
|
||||||
|
size="sm"
|
||||||
|
avatarUrl={currentUser.user.avatarUrl}
|
||||||
|
name={currentUser.user.name}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Text size="sm" fw={500} lineClamp={1}>
|
||||||
|
{currentUser.user.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<CommentEditor
|
||||||
|
onUpdate={handleCommentEditorChange}
|
||||||
|
onSave={handleAddComment}
|
||||||
|
placeholder={t("Write a comment")}
|
||||||
|
editable={true}
|
||||||
|
autofocus={true}
|
||||||
|
/>
|
||||||
|
<CommentActions onSave={handleAddComment} isLoading={isPending} />
|
||||||
|
</Stack>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReadOnlyCommentDialog;
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import {
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
findParentNode,
|
||||||
|
posToDOMRect,
|
||||||
|
useEditorState,
|
||||||
|
} from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
@@ -49,26 +53,17 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferenceClientRect = useCallback(() => {
|
||||||
if (!editor) return;
|
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "callout";
|
const predicate = (node: PMNode) => node.type.name === "callout";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
const domRect = dom.getBoundingClientRect();
|
return dom.getBoundingClientRect();
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const setCalloutType = useCallback(
|
const setCalloutType = useCallback(
|
||||||
@@ -117,12 +112,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`callout-menu`}
|
pluginKey={`callout-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
tippyOptions={{
|
||||||
options={{
|
getReferenceClientRect,
|
||||||
|
offset: [0, 10],
|
||||||
placement: "bottom",
|
placement: "bottom",
|
||||||
// offset: 233, // // offset: [0, 10],
|
zIndex: 99,
|
||||||
// zIndex: 99,
|
popperOptions: {
|
||||||
flip: false,
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
node.textContent.length > 0
|
node.textContent.length > 0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* @ts-ignore */}
|
|
||||||
<NodeViewContent as="code" className={`language-${language}`} />
|
<NodeViewContent as="code" className={`language-${language}`} />
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
import type { EditorView } from "@tiptap/pm/view";
|
||||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||||
|
import { Slice } from "@tiptap/pm/model";
|
||||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
|
|
||||||
export const handlePaste = (
|
export const handlePaste = (
|
||||||
editor: Editor,
|
view: EditorView,
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
creatorId?: string,
|
creatorId?: string,
|
||||||
@@ -17,7 +18,7 @@ export const handlePaste = (
|
|||||||
// we have to do this validation here to allow the default link extension to takeover if needs be
|
// we have to do this validation here to allow the default link extension to takeover if needs be
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const url = clipboardData.trim();
|
const url = clipboardData.trim();
|
||||||
const { from: pos, empty } = editor.state.selection;
|
const { from: pos, empty } = view.state.selection;
|
||||||
const match = INTERNAL_LINK_REGEX.exec(url);
|
const match = INTERNAL_LINK_REGEX.exec(url);
|
||||||
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
||||||
|
|
||||||
@@ -33,27 +34,19 @@ export const handlePaste = (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchorId = match[6] ? match[6].split("#")[0] : undefined;
|
const anchorId = match[6] ? match[6].split('#')[0] : undefined;
|
||||||
const urlWithoutAnchor = anchorId
|
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
|
||||||
? url.substring(0, url.indexOf("#"))
|
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
|
||||||
: url;
|
|
||||||
createMentionAction(
|
|
||||||
urlWithoutAnchor,
|
|
||||||
editor.view,
|
|
||||||
pos,
|
|
||||||
creatorId,
|
|
||||||
anchorId,
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.clipboardData?.files.length) {
|
if (event.clipboardData?.files.length) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
for (const file of event.clipboardData.files) {
|
for (const file of event.clipboardData.files) {
|
||||||
const pos = editor.state.selection.from;
|
const pos = view.state.selection.from;
|
||||||
uploadImageAction(file, editor, pos, pageId);
|
uploadImageAction(file, view, pos, pageId);
|
||||||
uploadVideoAction(file, editor, pos, pageId);
|
uploadVideoAction(file, view, pos, pageId);
|
||||||
uploadAttachmentAction(file, editor, pos, pageId);
|
uploadAttachmentAction(file, view, pos, pageId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -61,7 +54,7 @@ export const handlePaste = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handleFileDrop = (
|
export const handleFileDrop = (
|
||||||
editor: Editor,
|
view: EditorView,
|
||||||
event: DragEvent,
|
event: DragEvent,
|
||||||
moved: boolean,
|
moved: boolean,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
@@ -70,14 +63,14 @@ export const handleFileDrop = (
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
for (const file of event.dataTransfer.files) {
|
for (const file of event.dataTransfer.files) {
|
||||||
const coordinates = editor.view.posAtCoords({
|
const coordinates = view.posAtCoords({
|
||||||
left: event.clientX,
|
left: event.clientX,
|
||||||
top: event.clientY,
|
top: event.clientY,
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import {
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
findParentNode,
|
||||||
|
posToDOMRect,
|
||||||
|
useEditorState,
|
||||||
|
} from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { sticky } from "tippy.js";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
@@ -35,26 +40,17 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferenceClientRect = useCallback(() => {
|
||||||
if (!editor) return;
|
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "drawio";
|
const predicate = (node: PMNode) => node.type.name === "drawio";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
const domRect = dom.getBoundingClientRect();
|
return dom.getBoundingClientRect();
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const onWidthChange = useCallback(
|
||||||
@@ -69,11 +65,15 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`drawio-menu`}
|
pluginKey={`drawio-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
tippyOptions={{
|
||||||
options={{
|
getReferenceClientRect,
|
||||||
placement: "top",
|
offset: [0, 8],
|
||||||
offset: 8,
|
zIndex: 99,
|
||||||
flip: false,
|
popperOptions: {
|
||||||
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
|
plugins: [sticky],
|
||||||
|
sticky: "popper",
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
const fileName = "diagram.drawio.svg";
|
const fileName = "diagram.drawio.svg";
|
||||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
//@ts-ignore
|
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
|
|
||||||
let attachment: IAttachment = null;
|
let attachment: IAttachment = null;
|
||||||
|
|||||||
@@ -1,41 +1,16 @@
|
|||||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||||
import EmojiList from "./emoji-list";
|
import EmojiList from "./emoji-list";
|
||||||
|
import tippy from "tippy.js";
|
||||||
import { init } from "emoji-mart";
|
import { init } from "emoji-mart";
|
||||||
import {
|
|
||||||
autoUpdate,
|
|
||||||
computePosition,
|
|
||||||
flip,
|
|
||||||
offset,
|
|
||||||
shift,
|
|
||||||
} from "@floating-ui/dom";
|
|
||||||
|
|
||||||
const renderEmojiItems = () => {
|
const renderEmojiItems = () => {
|
||||||
let component: ReactRenderer | null = null;
|
let component: ReactRenderer | null = null;
|
||||||
let popup: HTMLDivElement | null = null;
|
let popup: any | null = null;
|
||||||
let cleanup: (() => void) | null = null;
|
|
||||||
let getReferenceClientRect: (() => DOMRect) | null = null;
|
|
||||||
|
|
||||||
const destroy = () => {
|
|
||||||
if (cleanup) {
|
|
||||||
cleanup();
|
|
||||||
cleanup = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (popup) {
|
|
||||||
popup.remove();
|
|
||||||
popup = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (component) {
|
|
||||||
component.destroy();
|
|
||||||
component = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onBeforeStart: (props: {
|
onBeforeStart: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: () => DOMRect;
|
clientRect: DOMRect;
|
||||||
}) => {
|
}) => {
|
||||||
init({
|
init({
|
||||||
data: async () => (await import("@emoji-mart/data")).default,
|
data: async () => (await import("@emoji-mart/data")).default,
|
||||||
@@ -50,61 +25,51 @@ const renderEmojiItems = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
getReferenceClientRect = props.clientRect;
|
// @ts-ignore
|
||||||
popup = document.createElement("div");
|
popup = tippy("body", {
|
||||||
popup.style.zIndex = "9999";
|
getReferenceClientRect: props.clientRect,
|
||||||
popup.style.position = "absolute";
|
appendTo: () => document.body,
|
||||||
popup.style.top = "0";
|
content: component.element,
|
||||||
popup.style.left = "0";
|
showOnCreate: true,
|
||||||
popup.appendChild(component.element);
|
interactive: true,
|
||||||
document.body.appendChild(popup);
|
trigger: "manual",
|
||||||
|
placement: "bottom",
|
||||||
const virtualElement = {
|
|
||||||
getBoundingClientRect: () => {
|
|
||||||
return getReferenceClientRect
|
|
||||||
? getReferenceClientRect()
|
|
||||||
: new DOMRect(0, 0, 0, 0);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
cleanup = autoUpdate(virtualElement, popup, () => {
|
|
||||||
if (!popup) return;
|
|
||||||
|
|
||||||
computePosition(virtualElement, popup, {
|
|
||||||
placement: "bottom-start",
|
|
||||||
middleware: [offset(10), flip(), shift()],
|
|
||||||
}).then(({ x, y }) => {
|
|
||||||
if (!popup) return;
|
|
||||||
|
|
||||||
Object.assign(popup.style, {
|
|
||||||
transform: `translate(${x}px, ${y}px)`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onStart: (props: {
|
onStart: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: () => DOMRect;
|
clientRect: DOMRect;
|
||||||
}) => {
|
}) => {
|
||||||
component?.updateProps({ ...props, isLoading: false });
|
component?.updateProps({...props, isLoading: false});
|
||||||
|
|
||||||
if (props.clientRect) {
|
if (!props.clientRect) {
|
||||||
getReferenceClientRect = props.clientRect;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
popup &&
|
||||||
|
popup[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onUpdate: (props: {
|
onUpdate: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: () => DOMRect;
|
clientRect: DOMRect;
|
||||||
}) => {
|
}) => {
|
||||||
component?.updateProps(props);
|
component?.updateProps(props);
|
||||||
|
|
||||||
if (props.clientRect) {
|
if (!props.clientRect) {
|
||||||
getReferenceClientRect = props.clientRect;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
popup &&
|
||||||
|
popup[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
if (props.event.key === "Escape") {
|
if (props.event.key === "Escape") {
|
||||||
destroy();
|
popup?.[0].hide();
|
||||||
|
component?.destroy()
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -113,7 +78,13 @@ const renderEmojiItems = () => {
|
|||||||
return component?.ref?.onKeyDown(props);
|
return component?.ref?.onKeyDown(props);
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
destroy();
|
if (popup && !popup[0]?.state.isDestroyed) {
|
||||||
|
popup[0]?.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component) {
|
||||||
|
component?.destroy();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import {
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
findParentNode,
|
||||||
|
posToDOMRect,
|
||||||
|
useEditorState,
|
||||||
|
} from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { sticky } from "tippy.js";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
@@ -37,26 +42,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferenceClientRect = useCallback(() => {
|
||||||
if (!editor) return;
|
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "excalidraw";
|
const predicate = (node: PMNode) => node.type.name === "excalidraw";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
const domRect = dom.getBoundingClientRect();
|
return dom.getBoundingClientRect();
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const onWidthChange = useCallback(
|
||||||
@@ -69,13 +65,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`excalidraw-menu`}
|
pluginKey={`excalidraw-menu}`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
tippyOptions={{
|
||||||
options={{
|
getReferenceClientRect,
|
||||||
placement: "top",
|
offset: [0, 8],
|
||||||
offset: 8,
|
zIndex: 99,
|
||||||
flip: false,
|
popperOptions: {
|
||||||
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
|
plugins: [sticky],
|
||||||
|
sticky: "popper",
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
const fileName = "diagram.excalidraw.svg";
|
const fileName = "diagram.excalidraw.svg";
|
||||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
|
|
||||||
let attachment: IAttachment = null;
|
let attachment: IAttachment = null;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import {
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
findParentNode,
|
||||||
|
posToDOMRect,
|
||||||
|
useEditorState,
|
||||||
|
} from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
|
import { sticky } from "tippy.js";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.isActive("image");
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -37,37 +52,17 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldShow = useCallback(
|
const getReferenceClientRect = useCallback(() => {
|
||||||
({ state }: ShouldShowProps) => {
|
|
||||||
if (!state) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return editor.isActive("image") && editor.getAttributes("image").src;
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
|
||||||
if (!editor) return;
|
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "image";
|
const predicate = (node: PMNode) => node.type.name === "image";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
const domRect = dom.getBoundingClientRect();
|
return dom.getBoundingClientRect();
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignImageLeft = useCallback(() => {
|
const alignImageLeft = useCallback(() => {
|
||||||
@@ -110,11 +105,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`image-menu`}
|
pluginKey={`image-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
tippyOptions={{
|
||||||
options={{
|
getReferenceClientRect,
|
||||||
placement: "top",
|
offset: [0, 8],
|
||||||
offset: 8,
|
zIndex: 99,
|
||||||
flip: false,
|
popperOptions: {
|
||||||
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
|
plugins: [sticky],
|
||||||
|
sticky: "popper",
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
.imageWrapper {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: pulse 1.2s ease-in-out infinite;
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: -135% 0%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +1,30 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { Group, Image, Loader, Text } from "@mantine/core";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { Image } from "@mantine/core";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import classes from "./image-view.module.css";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export default function ImageView(props: NodeViewProps) {
|
export default function ImageView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { node, selected } = props;
|
||||||
const { editor, node, selected } = props;
|
const { src, width, align, title } = node.attrs;
|
||||||
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
|
|
||||||
const alignClass = useMemo(() => {
|
const alignClass = useMemo(() => {
|
||||||
if (align === "left") return "alignLeft";
|
if (align === "left") return "alignLeft";
|
||||||
if (align === "right") return "alignRight";
|
if (align === "right") return "alignRight";
|
||||||
if (align === "center") return "alignCenter";
|
if (align === "center") return "alignCenter";
|
||||||
return "alignCenter";
|
return "alignCenter";
|
||||||
}, [align]);
|
}, [align]);
|
||||||
const previewSrc = useMemo(() => {
|
|
||||||
editor.storage.shared.imagePreviews =
|
|
||||||
editor.storage.shared.imagePreviews || {};
|
|
||||||
|
|
||||||
if (placeholder?.id) {
|
|
||||||
return editor.storage.shared.imagePreviews[placeholder.id];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [placeholder, editor]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle>
|
<NodeViewWrapper data-drag-handle>
|
||||||
<div
|
<Image
|
||||||
className={clsx(
|
radius="md"
|
||||||
selected && "ProseMirror-selectednode",
|
fit="contain"
|
||||||
classes.imageWrapper,
|
w={width}
|
||||||
alignClass,
|
src={getFileUrl(src)}
|
||||||
)}
|
alt={title}
|
||||||
style={{
|
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
||||||
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
|
/>
|
||||||
width,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{src && (
|
|
||||||
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
|
|
||||||
)}
|
|
||||||
{!src && previewSrc && (
|
|
||||||
<Group pos="relative" h="100%" w="100%">
|
|
||||||
<Image
|
|
||||||
radius="md"
|
|
||||||
fit="contain"
|
|
||||||
src={previewSrc}
|
|
||||||
alt={placeholder?.name}
|
|
||||||
/>
|
|
||||||
<Loader size={20} pos="absolute" bottom={6} right={6} />
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{!src && !previewSrc && (
|
|
||||||
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
|
||||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
|
||||||
<Text component="span" size="sm" truncate="end">
|
|
||||||
{placeholder?.name
|
|
||||||
? t("Uploading {{name}}", { name: placeholder.name })
|
|
||||||
: t("Uploading file")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||||
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
|
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
|
||||||
import { Card } from "@mantine/core";
|
import { Card } from "@mantine/core";
|
||||||
import { useEditorState } from "@tiptap/react";
|
|
||||||
|
|
||||||
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
||||||
const [showEdit, setShowEdit] = useState(false);
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
@@ -60,15 +59,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
|||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`link-menu`}
|
pluginKey={`link-menu}`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
options={{
|
tippyOptions={{
|
||||||
onHide: () => {
|
appendTo: () => {
|
||||||
|
return appendTo?.current;
|
||||||
|
},
|
||||||
|
onHidden: () => {
|
||||||
setShowEdit(false);
|
setShowEdit(false);
|
||||||
},
|
},
|
||||||
placement: "bottom",
|
placement: "bottom",
|
||||||
offset: 5,
|
offset: [0, 5],
|
||||||
// zIndex: 101,
|
zIndex: 101,
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
|
|
||||||
setRenderItems(items);
|
setRenderItems(items);
|
||||||
// update editor storage
|
// update editor storage
|
||||||
//@ts-ignore
|
|
||||||
props.editor.storage.mentionItems = items;
|
props.editor.storage.mentionItems = items;
|
||||||
}
|
}
|
||||||
}, [suggestion, isLoading]);
|
}, [suggestion, isLoading]);
|
||||||
@@ -164,7 +163,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
|
|
||||||
const enterHandler = () => {
|
const enterHandler = () => {
|
||||||
if (!renderItems.length) return;
|
if (!renderItems.length) return;
|
||||||
if (renderItems[selectedIndex]?.entityType !== "header") {
|
if (renderItems[selectedIndex].entityType !== "header") {
|
||||||
selectItem(selectedIndex);
|
selectItem(selectedIndex);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -204,7 +203,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
parentPageId: page.id || null,
|
parentPageId: page.id || null,
|
||||||
title: title
|
title: title
|
||||||
};
|
};
|
||||||
|
|
||||||
let createdPage: IPage;
|
let createdPage: IPage;
|
||||||
try {
|
try {
|
||||||
createdPage = await createPageMutation.mutateAsync(payload);
|
createdPage = await createPageMutation.mutateAsync(payload);
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||||
import {
|
import tippy from "tippy.js";
|
||||||
autoUpdate,
|
|
||||||
computePosition,
|
|
||||||
flip,
|
|
||||||
offset,
|
|
||||||
shift,
|
|
||||||
} from "@floating-ui/dom";
|
|
||||||
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
|
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
|
||||||
|
|
||||||
function getWhitespaceCount(query: string) {
|
function getWhitespaceCount(query: string) {
|
||||||
@@ -15,27 +9,16 @@ function getWhitespaceCount(query: string) {
|
|||||||
|
|
||||||
const mentionRenderItems = () => {
|
const mentionRenderItems = () => {
|
||||||
let component: ReactRenderer | null = null;
|
let component: ReactRenderer | null = null;
|
||||||
let activeClientRect: (() => DOMRect) | null = null;
|
let popup: any | null = null;
|
||||||
let updatePositionCleanup: (() => void) | null = null;
|
|
||||||
|
|
||||||
const destroy = () => {
|
|
||||||
updatePositionCleanup?.();
|
|
||||||
updatePositionCleanup = null;
|
|
||||||
component?.destroy();
|
|
||||||
if (component?.element?.parentNode) {
|
|
||||||
component.element.parentNode.removeChild(component.element);
|
|
||||||
}
|
|
||||||
component = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart: (props: {
|
onStart: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: () => DOMRect;
|
clientRect: DOMRect;
|
||||||
query: string;
|
query: string;
|
||||||
}) => {
|
}) => {
|
||||||
// query must not start with a whitespace
|
// query must not start with a whitespace
|
||||||
if (props.query.charAt(0) === " ") {
|
if (props.query.charAt(0) === ' '){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,95 +37,75 @@ const mentionRenderItems = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeClientRect = props.clientRect;
|
// @ts-ignore
|
||||||
|
popup = tippy("body", {
|
||||||
const { element } = component;
|
getReferenceClientRect: props.clientRect,
|
||||||
document.body.appendChild(element);
|
appendTo: () => document.body,
|
||||||
|
content: component.element,
|
||||||
updatePositionCleanup = autoUpdate(
|
showOnCreate: true,
|
||||||
{
|
interactive: true,
|
||||||
getBoundingClientRect: () =>
|
trigger: "manual",
|
||||||
activeClientRect ? activeClientRect() : new DOMRect(),
|
placement: "bottom-start",
|
||||||
},
|
});
|
||||||
element,
|
|
||||||
() => {
|
|
||||||
if (!component?.element) return;
|
|
||||||
computePosition(
|
|
||||||
{
|
|
||||||
getBoundingClientRect: () => {
|
|
||||||
return activeClientRect ? activeClientRect() : new DOMRect();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
element,
|
|
||||||
{
|
|
||||||
placement: "bottom-start",
|
|
||||||
middleware: [offset(0), flip(), shift()],
|
|
||||||
},
|
|
||||||
).then(({ x, y }) => {
|
|
||||||
Object.assign(element.style, {
|
|
||||||
left: `${x}px`,
|
|
||||||
top: `${y}px`,
|
|
||||||
position: "absolute",
|
|
||||||
zIndex: "9999",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onUpdate: (props: {
|
onUpdate: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: () => DOMRect;
|
clientRect: DOMRect;
|
||||||
query: string;
|
query: string;
|
||||||
}) => {
|
}) => {
|
||||||
// query must not start with a whitespace
|
// query must not start with a whitespace
|
||||||
if (props.query.charAt(0) === " ") {
|
if (props.query.charAt(0) === ' '){
|
||||||
destroy();
|
component?.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// only update component if popup is not destroyed
|
// only update component if popup is not destroyed
|
||||||
if (component) {
|
if (!popup?.[0].state.isDestroyed) {
|
||||||
component.updateProps(props);
|
component?.updateProps(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props || !props.clientRect) {
|
if (!props || !props.clientRect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeClientRect = props.clientRect;
|
|
||||||
|
|
||||||
const whitespaceCount = getWhitespaceCount(props.query);
|
const whitespaceCount = getWhitespaceCount(props.query);
|
||||||
|
|
||||||
// destroy component if space is greater 3 without a match
|
// destroy component if space is greater 3 without a match
|
||||||
if (
|
if (
|
||||||
whitespaceCount > 4 &&
|
whitespaceCount > 3 &&
|
||||||
//@ts-ignore
|
props.editor.storage.mentionItems.length === 0
|
||||||
props.editor.storage.mentionItems.length === 1
|
|
||||||
) {
|
) {
|
||||||
destroy();
|
popup?.[0]?.destroy();
|
||||||
return;
|
component?.destroy();
|
||||||
}
|
|
||||||
// fallback exit
|
|
||||||
if (whitespaceCount > 7) {
|
|
||||||
destroy();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
popup &&
|
||||||
|
!popup?.[0].state.isDestroyed &&
|
||||||
|
popup?.[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
if (props.event.key === "Escape") {
|
if (props.event.key)
|
||||||
destroy();
|
if (
|
||||||
return true;
|
props.event.key === "Escape" ||
|
||||||
}
|
(props.event.key === "Enter" && !popup?.[0].state.isShown)
|
||||||
|
) {
|
||||||
if (props.event.key === "Enter" && !component) {
|
popup?.[0].destroy();
|
||||||
destroy();
|
component?.destroy();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (component?.ref as any)?.onKeyDown(props);
|
return (component?.ref as any)?.onKeyDown(props);
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
destroy();
|
if (popup && !popup?.[0].state.isDestroyed) {
|
||||||
|
popup[0].destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component) {
|
||||||
|
component.destroy();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
-2
@@ -73,8 +73,6 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
|||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
|
|
||||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||||
//TODO: check type error
|
|
||||||
//@ts-ignore
|
|
||||||
const position: Range = results[resultIndex];
|
const position: Range = results[resultIndex];
|
||||||
|
|
||||||
if (!position) return;
|
if (!position) return;
|
||||||
|
|||||||
@@ -161,7 +161,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
if (!pageId) return;
|
if (!pageId) return;
|
||||||
|
|
||||||
@@ -174,13 +173,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
for (const file of input.files) {
|
for (const file of input.files) {
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = editor.view.state.selection.from;
|
||||||
|
uploadImageAction(file, editor.view, pos, pageId);
|
||||||
uploadImageAction(file, editor, pos, pageId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the input value to allow uploading the same file again if needed
|
|
||||||
input.value = "";
|
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
@@ -193,7 +188,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
if (!pageId) return;
|
if (!pageId) return;
|
||||||
|
|
||||||
@@ -201,18 +195,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "video/*";
|
input.accept = "video/*";
|
||||||
input.multiple = true;
|
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
for (const file of input.files) {
|
const file = input.files[0];
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = editor.view.state.selection.from;
|
||||||
|
uploadVideoAction(file, editor.view, pos, pageId);
|
||||||
uploadVideoAction(file, editor, pos, pageId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the input value to allow uploading the same file again if needed
|
|
||||||
input.value = "";
|
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
@@ -225,7 +213,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
if (!pageId) return;
|
if (!pageId) return;
|
||||||
|
|
||||||
@@ -233,18 +220,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "";
|
input.accept = "";
|
||||||
input.multiple = true;
|
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
for (const file of input.files) {
|
const file = input.files[0];
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = editor.view.state.selection.from;
|
||||||
|
uploadAttachmentAction(file, editor.view, pos, pageId, true);
|
||||||
uploadAttachmentAction(file, editor, pos, pageId, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the input value to allow uploading the same file again if needed
|
|
||||||
input.value = "";
|
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,35 +1,10 @@
|
|||||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||||
import CommandList from "@/features/editor/components/slash-menu/command-list";
|
import CommandList from "@/features/editor/components/slash-menu/command-list";
|
||||||
import {
|
import tippy from "tippy.js";
|
||||||
autoUpdate,
|
|
||||||
computePosition,
|
|
||||||
flip,
|
|
||||||
offset,
|
|
||||||
shift,
|
|
||||||
} from "@floating-ui/dom";
|
|
||||||
|
|
||||||
const renderItems = () => {
|
const renderItems = () => {
|
||||||
let component: ReactRenderer | null = null;
|
let component: ReactRenderer | null = null;
|
||||||
let popup: HTMLElement | null = null;
|
let popup: any | null = null;
|
||||||
let cleanup: (() => void) | null = null;
|
|
||||||
let getReferenceClientRect: (() => DOMRect) | null = null;
|
|
||||||
|
|
||||||
const updatePosition = () => {
|
|
||||||
if (!popup || !getReferenceClientRect) return;
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const rect = getReferenceClientRect();
|
|
||||||
|
|
||||||
computePosition({ getBoundingClientRect: () => rect }, popup, {
|
|
||||||
placement: "bottom-start",
|
|
||||||
middleware: [offset(0), flip(), shift()],
|
|
||||||
}).then(({ x, y }) => {
|
|
||||||
if (popup) {
|
|
||||||
popup.style.left = `${x}px`;
|
|
||||||
popup.style.top = `${y}px`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart: (props: {
|
onStart: (props: {
|
||||||
@@ -46,29 +21,15 @@ const renderItems = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
getReferenceClientRect = props.clientRect;
|
popup = tippy("body", {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
popup = document.createElement("div");
|
appendTo: () => document.body,
|
||||||
popup.style.zIndex = "9999";
|
content: component.element,
|
||||||
popup.style.position = "absolute";
|
showOnCreate: true,
|
||||||
popup.style.top = "0";
|
interactive: true,
|
||||||
popup.style.left = "0";
|
trigger: "manual",
|
||||||
|
placement: "bottom-start",
|
||||||
document.body.appendChild(popup);
|
});
|
||||||
popup.appendChild(component.element);
|
|
||||||
|
|
||||||
cleanup = autoUpdate(
|
|
||||||
// @ts-ignore
|
|
||||||
{
|
|
||||||
getBoundingClientRect: () => {
|
|
||||||
return getReferenceClientRect
|
|
||||||
? getReferenceClientRect()
|
|
||||||
: new DOMRect();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
popup,
|
|
||||||
updatePosition
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onUpdate: (props: {
|
onUpdate: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
@@ -80,15 +41,14 @@ const renderItems = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
popup &&
|
||||||
getReferenceClientRect = props.clientRect;
|
popup[0].setProps({
|
||||||
updatePosition();
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
if (props.event.key === "Escape") {
|
if (props.event.key === "Escape") {
|
||||||
if (popup) {
|
popup?.[0].hide();
|
||||||
popup.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -97,19 +57,12 @@ const renderItems = () => {
|
|||||||
return component?.ref?.onKeyDown(props);
|
return component?.ref?.onKeyDown(props);
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
if (cleanup) {
|
if (popup && !popup[0].state.isDestroyed) {
|
||||||
cleanup();
|
popup[0].destroy();
|
||||||
cleanup = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (popup) {
|
|
||||||
popup.remove();
|
|
||||||
popup = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (component) {
|
if (component) {
|
||||||
component.destroy();
|
component.destroy();
|
||||||
component = null;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import {
|
||||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
posToDOMRect,
|
||||||
|
findParentNode,
|
||||||
|
} from "@tiptap/react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { sticky } from "tippy.js";
|
||||||
|
|
||||||
interface SubpagesMenuProps {
|
interface SubpagesMenuProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -29,7 +33,7 @@ export const SubpagesMenu = React.memo(
|
|||||||
|
|
||||||
return editor.isActive("subpages");
|
return editor.isActive("subpages");
|
||||||
},
|
},
|
||||||
[editor]
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getReferenceClientRect = useCallback(() => {
|
const getReferenceClientRect = useCallback(() => {
|
||||||
@@ -58,8 +62,18 @@ export const SubpagesMenu = React.memo(
|
|||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`subpages-menu`}
|
pluginKey={`subpages-menu}`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
|
tippyOptions={{
|
||||||
|
getReferenceClientRect,
|
||||||
|
offset: [0, 8],
|
||||||
|
zIndex: 99,
|
||||||
|
popperOptions: {
|
||||||
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
|
plugins: [sticky],
|
||||||
|
sticky: "popper",
|
||||||
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<Tooltip position="top" label={t("Delete")}>
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
@@ -75,7 +89,7 @@ export const SubpagesMenu = React.memo(
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default SubpagesMenu;
|
export default SubpagesMenu;
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export default function SubpagesView(props: NodeViewProps) {
|
|||||||
const { spaceSlug, shareId } = useParams();
|
const { spaceSlug, shareId } = useParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
//@ts-ignore
|
|
||||||
const currentPageId = editor.storage.pageId;
|
const currentPageId = editor.storage.pageId;
|
||||||
|
|
||||||
// Get subpages from shared tree if we're in a shared context
|
// Get subpages from shared tree if we're in a shared context
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -15,7 +17,6 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TableBackgroundColor } from "./table-background-color";
|
import { TableBackgroundColor } from "./table-background-color";
|
||||||
import { TableTextAlignment } from "./table-text-alignment";
|
import { TableTextAlignment } from "./table-text-alignment";
|
||||||
import { BubbleMenu } from "@tiptap/react/menus";
|
|
||||||
|
|
||||||
export const TableCellMenu = React.memo(
|
export const TableCellMenu = React.memo(
|
||||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||||
@@ -28,7 +29,7 @@ export const TableCellMenu = React.memo(
|
|||||||
|
|
||||||
return isCellSelection(state.selection);
|
return isCellSelection(state.selection);
|
||||||
},
|
},
|
||||||
[editor]
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const mergeCells = useCallback(() => {
|
const mergeCells = useCallback(() => {
|
||||||
@@ -52,27 +53,23 @@ export const TableCellMenu = React.memo(
|
|||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey="table-cell-menu"
|
pluginKey="table-cell-menu"
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
appendTo={() => {
|
tippyOptions={{
|
||||||
return appendTo?.current;
|
appendTo: () => {
|
||||||
}}
|
return appendTo?.current;
|
||||||
ref={(element) => {
|
|
||||||
element.style.zIndex = "99";
|
|
||||||
}}
|
|
||||||
options={{
|
|
||||||
offset: {
|
|
||||||
mainAxis: 15,
|
|
||||||
},
|
},
|
||||||
|
offset: [0, 15],
|
||||||
|
zIndex: 99,
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<ActionIcon.Group>
|
||||||
<TableBackgroundColor editor={editor} />
|
<TableBackgroundColor editor={editor} />
|
||||||
<TableTextAlignment editor={editor} />
|
<TableTextAlignment editor={editor} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Merge cells")}>
|
<Tooltip position="top" label={t("Merge cells")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={mergeCells}
|
onClick={mergeCells}
|
||||||
@@ -128,9 +125,9 @@ export const TableCellMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
</BubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default TableCellMenu;
|
export default TableCellMenu;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
import {
|
||||||
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
posToDOMRect,
|
||||||
|
findParentNode,
|
||||||
|
} from "@tiptap/react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -12,12 +17,9 @@ import {
|
|||||||
IconColumnRemove,
|
IconColumnRemove,
|
||||||
IconRowInsertBottom,
|
IconRowInsertBottom,
|
||||||
IconRowInsertTop,
|
IconRowInsertTop,
|
||||||
IconRowRemove,
|
IconRowRemove, IconTableColumn, IconTableRow,
|
||||||
IconTableColumn,
|
|
||||||
IconTableRow,
|
|
||||||
IconTrashX,
|
IconTrashX,
|
||||||
} from "@tabler/icons-react";
|
} from '@tabler/icons-react';
|
||||||
import { BubbleMenu } from "@tiptap/react/menus";
|
|
||||||
import { isCellSelection } from "@docmost/editor-ext";
|
import { isCellSelection } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -32,28 +34,20 @@ export const TableMenu = React.memo(
|
|||||||
|
|
||||||
return editor.isActive("table") && !isCellSelection(state.selection);
|
return editor.isActive("table") && !isCellSelection(state.selection);
|
||||||
},
|
},
|
||||||
[editor]
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferenceClientRect = useCallback(() => {
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "table";
|
const predicate = (node: PMNode) => node.type.name === "table";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
const rect = dom.getBoundingClientRect();
|
return dom.getBoundingClientRect();
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => rect,
|
|
||||||
getClientRects: () => [rect],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => rect,
|
|
||||||
getClientRects: () => [rect],
|
|
||||||
};
|
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const toggleHeaderColumn = useCallback(() => {
|
const toggleHeaderColumn = useCallback(() => {
|
||||||
@@ -93,33 +87,42 @@ export const TableMenu = React.memo(
|
|||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey="table-menu"
|
pluginKey="table-menu"
|
||||||
resizeDelay={0}
|
updateDelay={0}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
tippyOptions={{
|
||||||
ref={(element) => {
|
getReferenceClientRect: getReferenceClientRect,
|
||||||
element.style.zIndex = "99";
|
offset: [0, 15],
|
||||||
}}
|
zIndex: 99,
|
||||||
options={{
|
popperOptions: {
|
||||||
placement: "top",
|
modifiers: [
|
||||||
offset: {
|
{
|
||||||
mainAxis: 15,
|
name: "preventOverflow",
|
||||||
},
|
enabled: true,
|
||||||
flip: {
|
options: {
|
||||||
fallbackPlacements: ["top", "bottom"],
|
altAxis: true,
|
||||||
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
|
boundary: "clippingParents",
|
||||||
boundary: editor.options.element as HTMLElement,
|
padding: 8,
|
||||||
},
|
},
|
||||||
shift: {
|
},
|
||||||
padding: 8 + 15,
|
{
|
||||||
crossAxis: true,
|
name: "flip",
|
||||||
|
enabled: true,
|
||||||
|
options: {
|
||||||
|
boundary: editor.options.element,
|
||||||
|
fallbackPlacements: ["top", "bottom"],
|
||||||
|
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<ActionIcon.Group>
|
||||||
<Tooltip position="top" label={t("Add left column")}>
|
<Tooltip position="top" label={t("Add left column")}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addColumnLeft}
|
onClick={addColumnLeft}
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -185,7 +188,8 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header row")}>
|
<Tooltip position="top" label={t("Toggle header row")}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderRow}
|
onClick={toggleHeaderRow}
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -196,7 +200,8 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header column")}>
|
<Tooltip position="top" label={t("Toggle header column")}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderColumn}
|
onClick={toggleHeaderColumn}
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -219,9 +224,9 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
</BubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default TableMenu;
|
export default TableMenu;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import {
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
BubbleMenu as BaseBubbleMenu,
|
||||||
|
findParentNode,
|
||||||
|
posToDOMRect,
|
||||||
|
useEditorState,
|
||||||
|
} from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
|
import { sticky } from "tippy.js";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
export function VideoMenu({ editor }: EditorMenuProps) {
|
export function VideoMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.isActive("video");
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -37,37 +52,17 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldShow = useCallback(
|
const getReferenceClientRect = useCallback(() => {
|
||||||
({ state }: ShouldShowProps) => {
|
|
||||||
if (!state) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return editor.isActive("video") && editor.getAttributes("video").src;
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
|
||||||
if (!editor) return;
|
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "video";
|
const predicate = (node: PMNode) => node.type.name === "video";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
const domRect = dom.getBoundingClientRect();
|
return dom.getBoundingClientRect();
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
return {
|
|
||||||
getBoundingClientRect: () => domRect,
|
|
||||||
getClientRects: () => [domRect],
|
|
||||||
};
|
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignVideoLeft = useCallback(() => {
|
const alignVideoLeft = useCallback(() => {
|
||||||
@@ -110,11 +105,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`video-menu`}
|
pluginKey={`video-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
tippyOptions={{
|
||||||
options={{
|
getReferenceClientRect,
|
||||||
placement: "top",
|
offset: [0, 8],
|
||||||
offset: 8,
|
zIndex: 99,
|
||||||
flip: false,
|
popperOptions: {
|
||||||
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
|
plugins: [sticky],
|
||||||
|
sticky: "popper",
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
.videoWrapper {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: pulse 1.2s ease-in-out infinite;
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin dark {
|
|
||||||
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: -135% 0%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.video {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
@@ -1,75 +1,29 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { Group, Loader, Text } from "@mantine/core";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import classes from "./video-view.module.css";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export default function VideoView(props: NodeViewProps) {
|
export default function VideoView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { node, selected } = props;
|
||||||
const { editor, node, selected } = props;
|
const { src, width, align } = node.attrs;
|
||||||
const { src, width, align, aspectRatio, placeholder } = node.attrs;
|
|
||||||
const alignClass = useMemo(() => {
|
const alignClass = useMemo(() => {
|
||||||
if (align === "left") return "alignLeft";
|
if (align === "left") return "alignLeft";
|
||||||
if (align === "right") return "alignRight";
|
if (align === "right") return "alignRight";
|
||||||
if (align === "center") return "alignCenter";
|
if (align === "center") return "alignCenter";
|
||||||
return "alignCenter";
|
return "alignCenter";
|
||||||
}, [align]);
|
}, [align]);
|
||||||
const previewSrc = useMemo(() => {
|
|
||||||
editor.storage.shared.videoPreviews =
|
|
||||||
editor.storage.shared.videoPreviews || {};
|
|
||||||
|
|
||||||
if (placeholder?.id) {
|
|
||||||
return editor.storage.shared.videoPreviews[placeholder.id];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [placeholder, editor]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle>
|
<NodeViewWrapper data-drag-handle>
|
||||||
<div
|
<video
|
||||||
className={clsx(
|
preload="metadata"
|
||||||
selected && "ProseMirror-selectednode",
|
width={width}
|
||||||
classes.videoWrapper,
|
controls
|
||||||
alignClass,
|
src={getFileUrl(src)}
|
||||||
)}
|
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
||||||
style={{
|
style={{ display: "block" }}
|
||||||
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
|
/>
|
||||||
width,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{src && (
|
|
||||||
<video
|
|
||||||
className={classes.video}
|
|
||||||
preload="metadata"
|
|
||||||
controls
|
|
||||||
src={getFileUrl(src)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!src && previewSrc && (
|
|
||||||
<Group pos="relative" h="100%" w="100%">
|
|
||||||
<video
|
|
||||||
className={classes.video}
|
|
||||||
preload="metadata"
|
|
||||||
controls
|
|
||||||
src={previewSrc}
|
|
||||||
/>
|
|
||||||
<Loader size={20} pos="absolute" top={6} right={6} />
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{!src && !previewSrc && (
|
|
||||||
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
|
||||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
|
||||||
<Text component="span" size="sm" truncate="end">
|
|
||||||
{placeholder?.name
|
|
||||||
? t("Uploading {{name}}", { name: placeholder.name })
|
|
||||||
: t("Uploading file")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||||
import { TextAlign } from "@tiptap/extension-text-align";
|
import { TextAlign } from "@tiptap/extension-text-align";
|
||||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||||
import { Placeholder, CharacterCount } from "@tiptap/extensions";
|
import { TaskList } from "@tiptap/extension-task-list";
|
||||||
|
import { ListKeymap } from "@tiptap/extension-list-keymap";
|
||||||
|
import { TaskItem } from "@tiptap/extension-task-item";
|
||||||
|
import { Underline } from "@tiptap/extension-underline";
|
||||||
import { Superscript } from "@tiptap/extension-superscript";
|
import { Superscript } from "@tiptap/extension-superscript";
|
||||||
import SubScript from "@tiptap/extension-subscript";
|
import SubScript from "@tiptap/extension-subscript";
|
||||||
import { Typography } from "@tiptap/extension-typography";
|
import { Typography } from "@tiptap/extension-typography";
|
||||||
@@ -11,7 +15,7 @@ import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
|||||||
import { Youtube } from "@tiptap/extension-youtube";
|
import { Youtube } from "@tiptap/extension-youtube";
|
||||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||||
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
|
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||||
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
|
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import {
|
import {
|
||||||
Comment,
|
Comment,
|
||||||
@@ -37,12 +41,11 @@ import {
|
|||||||
Embed,
|
Embed,
|
||||||
SearchAndReplace,
|
SearchAndReplace,
|
||||||
Mention,
|
Mention,
|
||||||
TableDndExtension,
|
|
||||||
Subpages,
|
Subpages,
|
||||||
|
TableDndExtension,
|
||||||
Heading,
|
Heading,
|
||||||
Highlight,
|
Highlight,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
SharedStorage,
|
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -94,9 +97,7 @@ lowlight.register("scala", scala);
|
|||||||
export const mainExtensions = [
|
export const mainExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: false,
|
heading: false,
|
||||||
undoRedo: false,
|
history: false,
|
||||||
link: false,
|
|
||||||
trailingNode: false,
|
|
||||||
dropcursor: {
|
dropcursor: {
|
||||||
width: 3,
|
width: 3,
|
||||||
color: "#70CFF8",
|
color: "#70CFF8",
|
||||||
@@ -108,7 +109,6 @@ export const mainExtensions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
SharedStorage,
|
|
||||||
Heading,
|
Heading,
|
||||||
UniqueID.configure({
|
UniqueID.configure({
|
||||||
types: ["heading", "paragraph"],
|
types: ["heading", "paragraph"],
|
||||||
@@ -134,6 +134,8 @@ export const mainExtensions = [
|
|||||||
TaskItem.configure({
|
TaskItem.configure({
|
||||||
nested: true,
|
nested: true,
|
||||||
}),
|
}),
|
||||||
|
ListKeymap,
|
||||||
|
Underline,
|
||||||
LinkExtension.configure({
|
LinkExtension.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
}),
|
}),
|
||||||
@@ -168,9 +170,6 @@ export const mainExtensions = [
|
|||||||
},
|
},
|
||||||
}).extend({
|
}).extend({
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
|
||||||
this.editor.isInitialized = true;
|
|
||||||
|
|
||||||
return ReactNodeViewRenderer(MentionView);
|
return ReactNodeViewRenderer(MentionView);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -209,7 +208,6 @@ export const mainExtensions = [
|
|||||||
}),
|
}),
|
||||||
CustomCodeBlock.configure({
|
CustomCodeBlock.configure({
|
||||||
view: CodeBlockView,
|
view: CodeBlockView,
|
||||||
//@ts-ignore
|
|
||||||
lowlight,
|
lowlight,
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
spellcheck: false,
|
spellcheck: false,
|
||||||
@@ -248,7 +246,7 @@ export const mainExtensions = [
|
|||||||
Escape: () => {
|
Escape: () => {
|
||||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
return false;
|
return true;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -260,9 +258,8 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
|||||||
export const collabExtensions: CollabExtensions = (provider, user) => [
|
export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||||
Collaboration.configure({
|
Collaboration.configure({
|
||||||
document: provider.document,
|
document: provider.document,
|
||||||
provider,
|
|
||||||
}),
|
}),
|
||||||
CollaborationCaret.configure({
|
CollaborationCursor.configure({
|
||||||
provider,
|
provider,
|
||||||
user: {
|
user: {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, {
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import {
|
import {
|
||||||
HocuspocusProvider,
|
HocuspocusProvider,
|
||||||
onStatusParameters,
|
onAuthenticationFailedParameters,
|
||||||
WebSocketStatus,
|
WebSocketStatus,
|
||||||
HocuspocusProviderWebsocket,
|
|
||||||
onSyncedParameters,
|
|
||||||
} from "@hocuspocus/provider";
|
} from "@hocuspocus/provider";
|
||||||
import {
|
import {
|
||||||
Editor,
|
|
||||||
EditorContent,
|
EditorContent,
|
||||||
EditorProvider,
|
EditorProvider,
|
||||||
useEditor,
|
useEditor,
|
||||||
@@ -37,9 +28,12 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
|||||||
import {
|
import {
|
||||||
activeCommentIdAtom,
|
activeCommentIdAtom,
|
||||||
showCommentPopupAtom,
|
showCommentPopupAtom,
|
||||||
|
showReadOnlyCommentPopupAtom,
|
||||||
} from "@/features/comment/atoms/comment-atom";
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
import CommentDialog from "@/features/comment/components/comment-dialog";
|
import CommentDialog from "@/features/comment/components/comment-dialog";
|
||||||
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
||||||
|
import { ReadOnlyBubbleMenu } from "@/features/editor/components/bubble-menu/read-only-bubble-menu";
|
||||||
|
import ReadOnlyCommentDialog from "@/features/editor/components/bubble-menu/read-only-comment-dialog";
|
||||||
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
||||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||||
@@ -78,9 +72,11 @@ export default function PageEditor({
|
|||||||
editable,
|
editable,
|
||||||
content,
|
content,
|
||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
|
|
||||||
|
|
||||||
const collaborationURL = useCollaborationUrl();
|
const collaborationURL = useCollaborationUrl();
|
||||||
const isComponentMounted = useRef(false);
|
const isComponentMounted = useRef(false);
|
||||||
const editorRef = useRef<Editor | null>(null);
|
const editorCreated = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isComponentMounted.current = true;
|
isComponentMounted.current = true;
|
||||||
@@ -91,127 +87,147 @@ export default function PageEditor({
|
|||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
const ydocRef = useRef<Y.Doc | null>(null);
|
||||||
|
if (!ydocRef.current) {
|
||||||
|
ydocRef.current = new Y.Doc();
|
||||||
|
}
|
||||||
|
const ydoc = ydocRef.current;
|
||||||
|
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||||
|
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
|
const documentName = `page.${pageId}`;
|
||||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||||
const documentState = useDocumentVisibility();
|
const documentState = useDocumentVisibility();
|
||||||
|
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
const userPageEditMode =
|
const userPageEditMode =
|
||||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||||
const canScroll = useCallback(
|
|
||||||
() => Boolean(isComponentMounted.current && editorRef.current),
|
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
|
||||||
[isComponentMounted],
|
|
||||||
);
|
|
||||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||||
// Providers only created once per pageId
|
// Providers only created once per pageId
|
||||||
const providersRef = useRef<{
|
const providersRef = useRef<{
|
||||||
local: IndexeddbPersistence;
|
local: IndexeddbPersistence;
|
||||||
remote: HocuspocusProvider;
|
remote: HocuspocusProvider;
|
||||||
socket: HocuspocusProviderWebsocket;
|
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [providersReady, setProvidersReady] = useState(false);
|
const [providersReady, setProvidersReady] = useState(false);
|
||||||
|
|
||||||
|
const localProvider = providersRef.current?.local;
|
||||||
|
const remoteProvider = providersRef.current?.remote;
|
||||||
|
|
||||||
|
// Track when collaborative provider is ready and synced
|
||||||
|
const [collabReady, setCollabReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
remoteProvider?.status === WebSocketStatus.Connected &&
|
||||||
|
isLocalSynced &&
|
||||||
|
isRemoteSynced
|
||||||
|
) {
|
||||||
|
setCollabReady(true);
|
||||||
|
}
|
||||||
|
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!providersRef.current) {
|
if (!providersRef.current) {
|
||||||
const documentName = `page.${pageId}`;
|
|
||||||
const ydoc = new Y.Doc();
|
|
||||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||||
const socket = new HocuspocusProviderWebsocket({
|
local.on("synced", () => setLocalSynced(true));
|
||||||
url: collaborationURL,
|
|
||||||
});
|
|
||||||
const onLocalSyncedHandler = () => {
|
|
||||||
setIsLocalSynced(true);
|
|
||||||
};
|
|
||||||
const onStatusHandler = (event: onStatusParameters) => {
|
|
||||||
setYjsConnectionStatus(event.status);
|
|
||||||
};
|
|
||||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
|
||||||
setIsRemoteSynced(event.state);
|
|
||||||
};
|
|
||||||
const onAuthenticationFailedHandler = () => {
|
|
||||||
const payload = jwtDecode(collabQuery?.token);
|
|
||||||
const now = Date.now().valueOf() / 1000;
|
|
||||||
const isTokenExpired = now >= payload.exp;
|
|
||||||
if (isTokenExpired) {
|
|
||||||
refetchCollabToken().then((result) => {
|
|
||||||
if (result.data?.token) {
|
|
||||||
socket.disconnect();
|
|
||||||
setTimeout(() => {
|
|
||||||
remote.configuration.token = result.data.token;
|
|
||||||
socket.connect();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const remote = new HocuspocusProvider({
|
const remote = new HocuspocusProvider({
|
||||||
websocketProvider: socket,
|
|
||||||
name: documentName,
|
name: documentName,
|
||||||
|
url: collaborationURL,
|
||||||
document: ydoc,
|
document: ydoc,
|
||||||
token: collabQuery?.token,
|
token: collabQuery?.token,
|
||||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
connect: true,
|
||||||
onStatus: onStatusHandler,
|
preserveConnection: false,
|
||||||
onSynced: onSyncedHandler,
|
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
||||||
|
const payload = jwtDecode(collabQuery?.token);
|
||||||
|
const now = Date.now().valueOf() / 1000;
|
||||||
|
const isTokenExpired = now >= payload.exp;
|
||||||
|
if (isTokenExpired) {
|
||||||
|
refetchCollabToken().then((result) => {
|
||||||
|
if (result.data?.token) {
|
||||||
|
remote.disconnect();
|
||||||
|
setTimeout(() => {
|
||||||
|
remote.configuration.token = result.data.token;
|
||||||
|
remote.connect();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStatus: (status) => {
|
||||||
|
if (status.status === "connected") {
|
||||||
|
setYjsConnectionStatus(status.status);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
remote.on("synced", () => setRemoteSynced(true));
|
||||||
local.on("synced", onLocalSyncedHandler);
|
remote.on("disconnect", () => {
|
||||||
providersRef.current = { socket, local, remote };
|
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||||
|
});
|
||||||
|
providersRef.current = { local, remote };
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
} else {
|
} else {
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
}
|
}
|
||||||
// Only destroy on final unmount
|
// Only destroy on final unmount
|
||||||
return () => {
|
return () => {
|
||||||
providersRef.current?.socket.destroy();
|
|
||||||
providersRef.current?.remote.destroy();
|
providersRef.current?.remote.destroy();
|
||||||
providersRef.current?.local.destroy();
|
providersRef.current?.local.destroy();
|
||||||
providersRef.current = null;
|
providersRef.current = null;
|
||||||
};
|
};
|
||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
useEffect(() => {
|
||||||
|
// Handle token updates by reconnecting with new token
|
||||||
|
if (providersRef.current?.remote && collabQuery?.token) {
|
||||||
|
const currentToken = providersRef.current.remote.configuration.token;
|
||||||
|
if (currentToken !== collabQuery.token) {
|
||||||
|
// Token has changed, need to reconnect with new token
|
||||||
|
providersRef.current.remote.disconnect();
|
||||||
|
providersRef.current.remote.configuration.token = collabQuery.token;
|
||||||
|
providersRef.current.remote.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [collabQuery?.token]);
|
||||||
|
*/
|
||||||
|
|
||||||
// Only connect/disconnect on tab/idle, not destroy
|
// Only connect/disconnect on tab/idle, not destroy
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!providersReady || !providersRef.current) return;
|
if (!providersReady || !providersRef.current) return;
|
||||||
const socket = providersRef.current.socket;
|
const remoteProvider = providersRef.current.remote;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isIdle &&
|
isIdle &&
|
||||||
documentState === "hidden" &&
|
documentState === "hidden" &&
|
||||||
yjsConnectionStatus === WebSocketStatus.Connected
|
remoteProvider.status === WebSocketStatus.Connected
|
||||||
) {
|
) {
|
||||||
socket.disconnect();
|
remoteProvider.disconnect();
|
||||||
|
setIsCollabReady(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
documentState === "visible" &&
|
documentState === "visible" &&
|
||||||
yjsConnectionStatus === WebSocketStatus.Disconnected
|
remoteProvider.status === WebSocketStatus.Disconnected
|
||||||
) {
|
) {
|
||||||
resetIdle();
|
resetIdle();
|
||||||
socket.connect();
|
remoteProvider.connect();
|
||||||
|
setTimeout(() => setIsCollabReady(true), 500);
|
||||||
}
|
}
|
||||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||||
|
|
||||||
// Attach here, to make sure the connection gets properly established
|
|
||||||
providersRef.current?.remote.attach();
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
if (!remoteProvider || !currentUser?.user) return mainExtensions;
|
||||||
return mainExtensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteProvider = providersRef.current.remote;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...mainExtensions,
|
...mainExtensions,
|
||||||
...collabExtensions(remoteProvider, currentUser?.user),
|
...collabExtensions(remoteProvider, currentUser?.user),
|
||||||
];
|
];
|
||||||
}, [providersReady, currentUser?.user]);
|
}, [remoteProvider, currentUser?.user]);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
@@ -254,30 +270,18 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handlePaste: (_view, event) => {
|
handlePaste: (view, event, slice) =>
|
||||||
if (!editorRef.current) return false;
|
handlePaste(view, event, pageId, currentUser?.user.id),
|
||||||
|
handleDrop: (view, event, _slice, moved) =>
|
||||||
return handlePaste(
|
handleFileDrop(view, event, moved, pageId),
|
||||||
editorRef.current,
|
|
||||||
event,
|
|
||||||
pageId,
|
|
||||||
currentUser?.user.id,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
handleDrop: (_view, event, _slice, moved) => {
|
|
||||||
if (!editorRef.current) return false;
|
|
||||||
|
|
||||||
return handleFileDrop(editorRef.current, event, moved, pageId);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
onCreate({ editor }) {
|
onCreate({ editor }) {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setEditor(editor);
|
setEditor(editor);
|
||||||
// @ts-ignore
|
|
||||||
editor.storage.pageId = pageId;
|
editor.storage.pageId = pageId;
|
||||||
handleScrollTo(editor);
|
handleScrollTo(editor);
|
||||||
editorRef.current = editor;
|
editorCreated.current = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
@@ -287,7 +291,7 @@ export default function PageEditor({
|
|||||||
debouncedUpdateContent(editorJson);
|
debouncedUpdateContent(editorJson);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[pageId, editable, extensions],
|
[pageId, editable, remoteProvider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const editorIsEditable = useEditorState({
|
const editorIsEditable = useEditorState({
|
||||||
@@ -343,17 +347,30 @@ export default function PageEditor({
|
|||||||
setAsideState({ tab: "", isAsideOpen: false });
|
setAsideState({ tab: "", isAsideOpen: false });
|
||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (remoteProvider?.status === WebSocketStatus.Connecting) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}, [remoteProvider?.status]);
|
||||||
|
|
||||||
const isSynced = isLocalSynced && isRemoteSynced;
|
const isSynced = isLocalSynced && isRemoteSynced;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => {
|
const collabReadyTimeout = setTimeout(() => {
|
||||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
if (
|
||||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
!isCollabReady &&
|
||||||
|
isSynced &&
|
||||||
|
remoteProvider?.status === WebSocketStatus.Connected
|
||||||
|
) {
|
||||||
|
setIsCollabReady(true);
|
||||||
}
|
}
|
||||||
}, 7500);
|
}, 500);
|
||||||
|
return () => clearTimeout(collabReadyTimeout);
|
||||||
|
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||||
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [yjsConnectionStatus, isSynced]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only honor user default page edit mode preference and permissions
|
// Only honor user default page edit mode preference and permissions
|
||||||
if (editor) {
|
if (editor) {
|
||||||
@@ -375,13 +392,12 @@ export default function PageEditor({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!hasConnectedOnceRef.current &&
|
!hasConnectedOnceRef.current &&
|
||||||
yjsConnectionStatus === WebSocketStatus.Connected &&
|
remoteProvider?.status === WebSocketStatus.Connected
|
||||||
isSynced
|
|
||||||
) {
|
) {
|
||||||
hasConnectedOnceRef.current = true;
|
hasConnectedOnceRef.current = true;
|
||||||
setShowStatic(false);
|
setShowStatic(false);
|
||||||
}
|
}
|
||||||
}, [yjsConnectionStatus, isSynced]);
|
}, [remoteProvider?.status]);
|
||||||
|
|
||||||
if (showStatic) {
|
if (showStatic) {
|
||||||
return (
|
return (
|
||||||
@@ -417,7 +433,13 @@ export default function PageEditor({
|
|||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{editor && !editorIsEditable && (
|
||||||
|
<ReadOnlyBubbleMenu key="readonly-bubble" editor={editor} />
|
||||||
|
)}
|
||||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||||
|
{showReadOnlyCommentPopup && (
|
||||||
|
<ReadOnlyCommentDialog editor={editor} pageId={pageId} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => editor.commands.focus("end")}
|
onClick={() => editor.commands.focus("end")}
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ export default function ReadonlyPageEditor({
|
|||||||
onCreate={({ editor }) => {
|
onCreate={({ editor }) => {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
if (pageId) {
|
if (pageId) {
|
||||||
// @ts-ignore
|
|
||||||
editor.storage.pageId = pageId;
|
editor.storage.pageId = pageId;
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Give a remote user a caret */
|
/* Give a remote user a caret */
|
||||||
.collaboration-carets__caret {
|
.collaboration-cursor__caret {
|
||||||
border-left: 1px solid #0d0d0d;
|
border-left: 1px solid #0d0d0d;
|
||||||
border-right: 1px solid #0d0d0d;
|
border-right: 1px solid #0d0d0d;
|
||||||
margin-left: -1px;
|
margin-left: -1px;
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Render the username above the caret */
|
/* Render the username above the caret */
|
||||||
.collaboration-carets__label {
|
.collaboration-cursor__label {
|
||||||
border-radius: 3px 3px 3px 0;
|
border-radius: 3px 3px 3px 0;
|
||||||
color: #0d0d0d;
|
color: #0d0d0d;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||||
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
||||||
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { getSpaces } from "@/features/space/services/space-service.ts";
|
||||||
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
||||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
|
||||||
|
|
||||||
export default function GroupList() {
|
export default function GroupList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const [page, setPage] = useState(1);
|
||||||
const { data, isLoading } = useGetGroupsQuery({ cursor });
|
const { data, isLoading } = useGetGroupsQuery({ page });
|
||||||
|
|
||||||
const prefetchGroupMembers = (groupId: string) => {
|
const prefetchGroupMembers = (groupId: string) => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["groupMembers", groupId, {}],
|
queryKey: ["groupMembers", groupId, { page: 1 }],
|
||||||
queryFn: () => getGroupMembers(groupId, {}),
|
queryFn: () => getGroupMembers(groupId, { page: 1 }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,9 +51,9 @@ export default function GroupList() {
|
|||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<IconGroupCircle />
|
<IconGroupCircle />
|
||||||
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
||||||
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
{group.name}
|
{group.name}
|
||||||
</AutoTooltipText>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{group.description}
|
{group.description}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -84,10 +84,10 @@ export default function GroupList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
useRemoveGroupMemberMutation,
|
useRemoveGroupMemberMutation,
|
||||||
} from "@/features/group/queries/group-query";
|
} from "@/features/group/queries/group-query";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { IconDots } from "@tabler/icons-react";
|
import { IconDots } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
@@ -12,13 +12,12 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
|
|
||||||
export default function GroupMembersList() {
|
export default function GroupMembersList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const [page, setPage] = useState(1);
|
||||||
const { data, isLoading } = useGroupMembersQuery(groupId, { cursor });
|
const { data, isLoading } = useGroupMembersQuery(groupId, { page });
|
||||||
const removeGroupMember = useRemoveGroupMemberMutation();
|
const removeGroupMember = useRemoveGroupMemberMutation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
@@ -108,10 +107,10 @@ export default function GroupMembersList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import '@/features/editor/styles/index.css';
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from 'react';
|
||||||
import { EditorContent, useEditor } from "@tiptap/react";
|
import { EditorContent, useEditor } from '@tiptap/react';
|
||||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
import { mainExtensions } from '@/features/editor/extensions/extensions';
|
||||||
import { Title } from "@mantine/core";
|
import { Title } from '@mantine/core';
|
||||||
import classes from "./history.module.css";
|
|
||||||
|
|
||||||
export interface HistoryEditorProps {
|
export interface HistoryEditorProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -27,9 +26,7 @@ export function HistoryEditor({ title, content }: HistoryEditorProps) {
|
|||||||
<div>
|
<div>
|
||||||
<Title order={1}>{title}</Title>
|
<Title order={1}>{title}</Title>
|
||||||
|
|
||||||
{editor && (
|
{editor && <EditorContent editor={editor} />}
|
||||||
<EditorContent editor={editor} className={classes.historyEditor} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function HistoryList({ pageId }: Props) {
|
|||||||
mainEditorTitle
|
mainEditorTitle
|
||||||
.chain()
|
.chain()
|
||||||
.clearContent()
|
.clearContent()
|
||||||
.setContent(activeHistoryData.title, { emitUpdate: true })
|
.setContent(activeHistoryData.title, true)
|
||||||
.run();
|
.run();
|
||||||
mainEditor
|
mainEditor
|
||||||
.chain()
|
.chain()
|
||||||
|
|||||||
@@ -1,49 +1,37 @@
|
|||||||
.history {
|
.history {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--mantine-spacing-md);
|
padding: var(--mantine-spacing-md);
|
||||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||||
|
|
||||||
@mixin hover {
|
@mixin hover {
|
||||||
background-color: light-dark(
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
|
||||||
var(--mantine-color-gray-2),
|
}
|
||||||
var(--mantine-color-dark-8)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.historyEditor {
|
|
||||||
:global(.ProseMirror) {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
background-color: light-dark(
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
|
||||||
var(--mantine-color-gray-2),
|
|
||||||
var(--mantine-color-dark-8)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
max-height: rem(700px);
|
max-height: rem(700px);
|
||||||
width: rem(250px);
|
width: rem(250px);
|
||||||
padding: var(--mantine-spacing-sm);
|
padding: var(--mantine-spacing-sm);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: rem(1px) solid
|
border-right: rem(1px) solid
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebarFlex {
|
.sidebarFlex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebarMain {
|
.sidebarMain {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebarRightSection {
|
.sidebarRightSection {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: rem(16px) rem(40px);
|
padding: rem(16px) rem(40px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,22 @@ import {
|
|||||||
IconHistory,
|
IconHistory,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconList,
|
IconList,
|
||||||
IconMarkdown,
|
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
|
IconSearch,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React from "react";
|
||||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
|
import {
|
||||||
|
getHotkeyHandler,
|
||||||
|
useClipboard,
|
||||||
|
useDisclosure,
|
||||||
|
useHotkeys,
|
||||||
|
} from "@mantine/hooks";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@@ -29,12 +34,12 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.
|
|||||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { formattedDate } from "@/lib/time.ts";
|
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
||||||
|
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||||
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
@@ -46,6 +51,7 @@ interface PageHeaderMenuProps {
|
|||||||
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toggleAside = useToggleAside();
|
const toggleAside = useToggleAside();
|
||||||
|
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
[
|
[
|
||||||
@@ -62,7 +68,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
},
|
},
|
||||||
{ preventDefault: false },
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
@@ -70,7 +75,17 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConnectionWarning />
|
{yjsConnectionStatus === "disconnected" && (
|
||||||
|
<Tooltip
|
||||||
|
label={t("Real-time editor connection lost. Retrying...")}
|
||||||
|
openDelay={250}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
|
||||||
|
<IconWifiOff size={20} stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||||
|
|
||||||
@@ -131,15 +146,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
notifications.show({ message: t("Link copied") });
|
notifications.show({ message: t("Link copied") });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyAsMarkdown = () => {
|
|
||||||
if (!pageEditor) return;
|
|
||||||
const html = pageEditor.getHTML();
|
|
||||||
const markdown = htmlToMarkdown(html);
|
|
||||||
const title = page?.title ? `# ${page.title}\n\n` : "";
|
|
||||||
clipboard.copy(`${title}${markdown}`);
|
|
||||||
notifications.show({ message: t("Copied") });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrint = () => {
|
const handlePrint = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.print();
|
window.print();
|
||||||
@@ -177,13 +183,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
>
|
>
|
||||||
{t("Copy link")}
|
{t("Copy link")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconMarkdown size={16} />}
|
|
||||||
onClick={handleCopyAsMarkdown}
|
|
||||||
>
|
|
||||||
{t("Copy as Markdown")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||||
@@ -291,51 +290,3 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionWarning() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
|
||||||
const [showWarning, setShowWarning] = useState(false);
|
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
|
||||||
yjsConnectionStatus,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isDisconnected) {
|
|
||||||
if (!timeoutRef.current) {
|
|
||||||
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
setShowWarning(false);
|
|
||||||
}
|
|
||||||
}, [yjsConnectionStatus]);
|
|
||||||
|
|
||||||
// Cleanup only on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!showWarning) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
label={t("Real-time editor connection lost. Retrying...")}
|
|
||||||
openDelay={250}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
|
|
||||||
<IconWifiOff size={20} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -172,10 +172,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
queryKey: ["root-sidebar-pages", fileTask.spaceId],
|
queryKey: ["root-sidebar-pages", fileTask.spaceId],
|
||||||
});
|
});
|
||||||
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ["recent-changes", fileTask.spaceId],
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "refetchRootTreeNodeEvent",
|
operation: "refetchRootTreeNodeEvent",
|
||||||
|
|||||||
@@ -163,6 +163,9 @@ export function useDeletePageMutation() {
|
|||||||
export function useMovePageMutation() {
|
export function useMovePageMutation() {
|
||||||
return useMutation<void, Error, IMovePage>({
|
return useMutation<void, Error, IMovePage>({
|
||||||
mutationFn: (data) => movePage(data),
|
mutationFn: (data) => movePage(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateOnMovePage();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,10 +253,12 @@ export function useGetSidebarPagesQuery(
|
|||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: ["sidebar-pages", data],
|
||||||
enabled: !!data?.pageId || !!data?.spaceId,
|
enabled: !!data?.pageId || !!data?.spaceId,
|
||||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam }),
|
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
||||||
initialPageParam: undefined,
|
initialPageParam: 1,
|
||||||
|
getPreviousPageParam: (firstPage) =>
|
||||||
|
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta?.nextCursor ?? undefined,
|
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,11 +266,13 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
|||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam });
|
return getSidebarPages({ spaceId: data.spaceId, page: pageParam });
|
||||||
},
|
},
|
||||||
initialPageParam: undefined,
|
initialPageParam: 1,
|
||||||
|
getPreviousPageParam: (firstPage) =>
|
||||||
|
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta?.nextCursor ?? undefined,
|
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,127 +458,17 @@ export function invalidateOnUpdatePage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateCacheOnMovePage(
|
export function invalidateOnMovePage() {
|
||||||
spaceId: string,
|
//for move invalidate all sidebars for now (how to do???)
|
||||||
pageId: string,
|
//invalidate all root sidebar pages
|
||||||
oldParentId: string | null,
|
queryClient.invalidateQueries({
|
||||||
newParentId: string | null,
|
queryKey: ["root-sidebar-pages"],
|
||||||
pageData: Partial<IPage>,
|
});
|
||||||
) {
|
//invalidate all sub sidebar pages
|
||||||
// Remove page from old parent's cache
|
queryClient.invalidateQueries({
|
||||||
const oldQueryKey =
|
queryKey: ["sidebar-pages"],
|
||||||
oldParentId === null
|
});
|
||||||
? ["root-sidebar-pages", spaceId]
|
// ---
|
||||||
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
|
|
||||||
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
|
||||||
oldQueryKey,
|
|
||||||
(old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.filter((item) => item.id !== pageId),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update old parent's hasChildren flag if it has no more children
|
|
||||||
if (oldParentId !== null) {
|
|
||||||
const oldParentCache = queryClient.getQueryData<
|
|
||||||
InfiniteData<IPagination<IPage>>
|
|
||||||
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
|
|
||||||
|
|
||||||
const remainingChildren =
|
|
||||||
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
|
||||||
|
|
||||||
if (remainingChildren === 0) {
|
|
||||||
// Update hasChildren in all caches where old parent appears
|
|
||||||
const allSideBarMatches = queryClient.getQueriesData({
|
|
||||||
predicate: (query) =>
|
|
||||||
query.queryKey[0] === "root-sidebar-pages" ||
|
|
||||||
query.queryKey[0] === "sidebar-pages",
|
|
||||||
});
|
|
||||||
|
|
||||||
allSideBarMatches.forEach(([key]) => {
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
|
||||||
key,
|
|
||||||
(old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.map((item) =>
|
|
||||||
item.id === oldParentId
|
|
||||||
? { ...item, hasChildren: false }
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add page to new parent's cache
|
|
||||||
const newQueryKey =
|
|
||||||
newParentId === null
|
|
||||||
? ["root-sidebar-pages", spaceId]
|
|
||||||
: ["sidebar-pages", { pageId: newParentId, spaceId }];
|
|
||||||
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
|
||||||
newQueryKey,
|
|
||||||
(old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
|
|
||||||
// Check if page already exists in new location
|
|
||||||
const exists = old.pages.some((page) =>
|
|
||||||
page.items.some((item) => item.id === pageId),
|
|
||||||
);
|
|
||||||
if (exists) return old;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page, index) => {
|
|
||||||
if (index === old.pages.length - 1) {
|
|
||||||
return {
|
|
||||||
...page,
|
|
||||||
items: [...page.items, pageData],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return page;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update new parent's hasChildren flag
|
|
||||||
if (newParentId !== null) {
|
|
||||||
const allSideBarMatches = queryClient.getQueriesData({
|
|
||||||
predicate: (query) =>
|
|
||||||
query.queryKey[0] === "root-sidebar-pages" ||
|
|
||||||
query.queryKey[0] === "sidebar-pages",
|
|
||||||
});
|
|
||||||
|
|
||||||
allSideBarMatches.forEach(([key]) => {
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.map((item) =>
|
|
||||||
item.id === newParentId ? { ...item, hasChildren: true } : item,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateOnDeletePage(pageId: string) {
|
export function invalidateOnDeletePage(pageId: string) {
|
||||||
|
|||||||
@@ -72,19 +72,22 @@ export async function getSidebarPages(
|
|||||||
export async function getAllSidebarPages(
|
export async function getAllSidebarPages(
|
||||||
params: SidebarPagesParams,
|
params: SidebarPagesParams,
|
||||||
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
||||||
let cursor: string | undefined = undefined;
|
let page = 1;
|
||||||
|
let hasNextPage = false;
|
||||||
const pages: IPagination<IPage>[] = [];
|
const pages: IPagination<IPage>[] = [];
|
||||||
const pageParams: (string | undefined)[] = [];
|
const pageParams: number[] = [];
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const req = await api.post("/pages/sidebar-pages", { ...params, cursor });
|
const req = await api.post("/pages/sidebar-pages", { ...params, page: page });
|
||||||
|
|
||||||
const data: IPagination<IPage> = req.data;
|
const data: IPagination<IPage> = req.data;
|
||||||
pages.push(data);
|
pages.push(data);
|
||||||
pageParams.push(cursor);
|
pageParams.push(page);
|
||||||
|
|
||||||
cursor = data.meta.nextCursor ?? undefined;
|
hasNextPage = data.meta.hasNextPage;
|
||||||
} while (cursor);
|
|
||||||
|
page += 1;
|
||||||
|
} while (hasNextPage);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pageParams,
|
pageParams,
|
||||||
@@ -115,14 +118,7 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
|
|||||||
.split("filename=")[1]
|
.split("filename=")[1]
|
||||||
.replace(/"/g, "");
|
.replace(/"/g, "");
|
||||||
|
|
||||||
let decodedFileName = fileName;
|
saveAs(req.data, decodeURIComponent(fileName));
|
||||||
try {
|
|
||||||
decodedFileName = decodeURIComponent(fileName);
|
|
||||||
} catch (err) {
|
|
||||||
// fallback to raw filename
|
|
||||||
}
|
|
||||||
|
|
||||||
saveAs(req.data, decodedFileName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importPage(file: File, spaceId: string) {
|
export async function importPage(file: File, spaceId: string) {
|
||||||
|
|||||||
@@ -30,15 +30,15 @@ import { useState } from "react";
|
|||||||
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
|
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
|
||||||
import { UserInfo } from "@/components/common/user-info.tsx";
|
import { UserInfo } from "@/components/common/user-info.tsx";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
||||||
|
|
||||||
export default function Trash() {
|
export default function Trash() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const { page, setPage } = usePaginateAndSearch();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
|
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
|
||||||
cursor, limit: 50
|
page, limit: 50
|
||||||
});
|
});
|
||||||
const restorePageMutation = useRestorePageMutation();
|
const restorePageMutation = useRestorePageMutation();
|
||||||
const deletePageMutation = useDeletePageMutation();
|
const deletePageMutation = useDeletePageMutation();
|
||||||
@@ -206,10 +206,10 @@ export default function Trash() {
|
|||||||
|
|
||||||
{deletedPages && deletedPages.items.length > 0 && (
|
{deletedPages && deletedPages.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={deletedPages.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={deletedPages.meta?.hasNextPage}
|
hasPrevPage={deletedPages.meta.hasPrevPage}
|
||||||
onNext={() => goNext(deletedPages.meta?.nextCursor)}
|
hasNextPage={deletedPages.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
useRemovePageMutation,
|
useRemovePageMutation,
|
||||||
useMovePageMutation,
|
useMovePageMutation,
|
||||||
useUpdatePageMutation,
|
useUpdatePageMutation,
|
||||||
updateCacheOnMovePage,
|
|
||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
@@ -176,25 +175,9 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
parentPageId: args.parentId,
|
parentPageId: args.parentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const draggedNode = args.dragNodes[0];
|
|
||||||
const nodeData = draggedNode.data as SpaceTreeNode;
|
|
||||||
const oldParentId = nodeData.parentPageId ?? null;
|
|
||||||
const pageData = {
|
|
||||||
id: nodeData.id,
|
|
||||||
slugId: nodeData.slugId,
|
|
||||||
title: nodeData.name,
|
|
||||||
icon: nodeData.icon,
|
|
||||||
position: newPosition,
|
|
||||||
spaceId: nodeData.spaceId,
|
|
||||||
parentPageId: args.parentId,
|
|
||||||
hasChildren: nodeData.hasChildren,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await movePageMutation.mutateAsync(payload);
|
await movePageMutation.mutateAsync(payload);
|
||||||
|
|
||||||
updateCacheOnMovePage(spaceId, draggedNodeId, oldParentId, args.parentId, pageData);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "moveTreeNode",
|
operation: "moveTreeNode",
|
||||||
@@ -202,10 +185,8 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
payload: {
|
payload: {
|
||||||
id: draggedNodeId,
|
id: draggedNodeId,
|
||||||
parentId: args.parentId,
|
parentId: args.parentId,
|
||||||
oldParentId,
|
|
||||||
index: args.index,
|
index: args.index,
|
||||||
position: newPosition,
|
position: newPosition,
|
||||||
pageData,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export interface ICopyPageToSpace {
|
|||||||
export interface SidebarPagesParams {
|
export interface SidebarPagesParams {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
cursor?: string;
|
page?: number; // pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPageInput {
|
export interface IPageInput {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
|
IconSparkles,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
@@ -25,7 +26,7 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
|||||||
import { useLicense } from "@/ee/hooks/use-license";
|
import { useLicense } from "@/ee/hooks/use-license";
|
||||||
import classes from "./search-spotlight-filters.module.css";
|
import classes from "./search-spotlight-filters.module.css";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai/index";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
|
||||||
interface SearchSpotlightFiltersProps {
|
interface SearchSpotlightFiltersProps {
|
||||||
@@ -52,6 +53,7 @@ export function SearchSpotlightFilters({
|
|||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
|
||||||
const { data: spacesData } = useGetSpacesQuery({
|
const { data: spacesData } = useGetSpacesQuery({
|
||||||
|
page: 1,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
query: debouncedSpaceQuery,
|
query: debouncedSpaceQuery,
|
||||||
});
|
});
|
||||||
@@ -263,9 +265,7 @@ export function SearchSpotlightFilters({
|
|||||||
contentType !== option.value &&
|
contentType !== option.value &&
|
||||||
handleFilterChange("contentType", option.value)
|
handleFilterChange("contentType", option.value)
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={option.disabled || (isAiMode && option.value === "attachment")}
|
||||||
option.disabled || (isAiMode && option.value === "attachment")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Group flex="1" gap="xs">
|
<Group flex="1" gap="xs">
|
||||||
<div>
|
<div>
|
||||||
@@ -275,13 +275,11 @@ export function SearchSpotlightFilters({
|
|||||||
{t("Enterprise")}
|
{t("Enterprise")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!option.disabled &&
|
{!option.disabled && isAiMode && option.value === "attachment" && (
|
||||||
isAiMode &&
|
<Text size="xs" mt={4}>
|
||||||
option.value === "attachment" && (
|
{t("Ask AI not available for attachments")}
|
||||||
<Text size="xs" mt={4}>
|
</Text>
|
||||||
{t("Ask AI not available for attachments")}
|
)}
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{contentType === option.value && <IconCheck size={20} />}
|
{contentType === option.value && <IconCheck size={20} />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
export async function searchPage(
|
export async function searchPage(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
): Promise<IPageSearch[]> {
|
): Promise<IPageSearch[]> {
|
||||||
const req = await api.post<{ items: IPageSearch[] }>("/search", params);
|
const req = await api.post<IPageSearch[]>("/search", params);
|
||||||
return req.data.items;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchSuggestions(
|
export async function searchSuggestions(
|
||||||
@@ -24,13 +24,13 @@ export async function searchSuggestions(
|
|||||||
export async function searchShare(
|
export async function searchShare(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
): Promise<IPageSearch[]> {
|
): Promise<IPageSearch[]> {
|
||||||
const req = await api.post<{ items: IPageSearch[] }>("/search/share-search", params);
|
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
||||||
return req.data.items;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchAttachments(
|
export async function searchAttachments(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
): Promise<IAttachmentSearch[]> {
|
): Promise<IAttachmentSearch[]> {
|
||||||
const req = await api.post<{ items: IAttachmentSearch[] }>("/search-attachments", params);
|
const req = await api.post<IAttachmentSearch[]>("/search-attachments", params);
|
||||||
return req.data.items;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
||||||
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
@@ -15,8 +14,8 @@ import classes from "./share.module.css";
|
|||||||
|
|
||||||
export default function ShareList() {
|
export default function ShareList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const [page, setPage] = useState(1);
|
||||||
const { data, isLoading } = useGetSharesQuery({ cursor });
|
const { data, isLoading } = useGetSharesQuery({ page });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -87,10 +86,10 @@ export default function ShareList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function useGetSharesQuery(
|
|||||||
params?: QueryParams,
|
params?: QueryParams,
|
||||||
): UseQueryResult<IPagination<ISharedItem>, Error> {
|
): UseQueryResult<IPagination<ISharedItem>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["share-list", params],
|
queryKey: ["share-list"],
|
||||||
queryFn: () => getShares(params),
|
queryFn: () => getShares(params),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { ISpace } from "../types/space.types";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import APP_ROUTE from "@/lib/app-route";
|
import APP_ROUTE from "@/lib/app-route";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface DeleteSpaceModalProps {
|
interface DeleteSpaceModalProps {
|
||||||
space: ISpace;
|
space: ISpace;
|
||||||
@@ -15,7 +14,6 @@ interface DeleteSpaceModalProps {
|
|||||||
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
const deleteSpaceMutation = useDeleteSpaceMutation();
|
const deleteSpaceMutation = useDeleteSpaceMutation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -37,15 +35,12 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsDeleting(true);
|
|
||||||
try {
|
try {
|
||||||
// pass slug too so we can clear the local cache
|
// pass slug too so we can clear the local cache
|
||||||
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
|
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
|
||||||
navigate(APP_ROUTE.HOME);
|
navigate(APP_ROUTE.HOME);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete space", error);
|
console.error("Failed to delete space", error);
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,7 +79,7 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
|||||||
<Button onClick={close} variant="default">
|
<Button onClick={close} variant="default">
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleDelete} color="red" loading={isDeleting}>
|
<Button onClick={handleDelete} color="red">
|
||||||
{t("Confirm")}
|
{t("Confirm")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { SpaceSelect } from "./space-select";
|
import { SpaceSelect } from "./space-select";
|
||||||
import { getSpaceUrl } from "@/lib/config";
|
import { getSpaceUrl } from "@/lib/config";
|
||||||
import { Button, Popover, Text } from "@mantine/core";
|
import { Button, Popover, Text } from "@mantine/core";
|
||||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
import { IconChevronDown } from "@tabler/icons-react";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
@@ -21,7 +21,7 @@ export function SwitchSpace({
|
|||||||
spaceIcon,
|
spaceIcon,
|
||||||
}: SwitchSpaceProps) {
|
}: SwitchSpaceProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [opened, { close, toggle }] = useDisclosure(false);
|
const [opened, { close, open, toggle }] = useDisclosure(false);
|
||||||
|
|
||||||
const handleSelect = (value: string) => {
|
const handleSelect = (value: string) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -44,9 +44,9 @@ export function SwitchSpace({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
fullWidth
|
fullWidth
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
rightSection={opened ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
|
rightSection={<IconChevronDown size={18} />}
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={toggle}
|
onClick={open}
|
||||||
>
|
>
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
name={spaceName}
|
name={spaceName}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts
|
|||||||
|
|
||||||
export default function SpaceGrid() {
|
export default function SpaceGrid() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useGetSpacesQuery({ limit: 10 });
|
const { data, isLoading } = useGetSpacesQuery({ page: 1, limit: 10 });
|
||||||
|
|
||||||
const cards = data?.items.slice(0, 9).map((space, index) => (
|
const cards = data?.items.slice(0, 9).map((space, index) => (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Group, Table, Text } from "@mantine/core";
|
import { Group, Table, Text } from "@mantine/core";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
@@ -9,12 +8,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
|
||||||
|
|
||||||
export default function SpaceList() {
|
export default function SpaceList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const [page, setPage] = useState(1);
|
||||||
const { data, isLoading } = useGetSpacesQuery({ cursor });
|
const { data, isLoading } = useGetSpacesQuery({ page });
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
||||||
|
|
||||||
@@ -51,9 +49,9 @@ export default function SpaceList() {
|
|||||||
name={space.name}
|
name={space.name}
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
||||||
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
{space.name}
|
{space.name}
|
||||||
</AutoTooltipText>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{space.description}
|
{space.description}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -73,10 +71,10 @@ export default function SpaceList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
|
||||||
|
|
||||||
type MemberType = "user" | "group";
|
type MemberType = "user" | "group";
|
||||||
|
|
||||||
@@ -41,9 +40,9 @@ export default function SpaceMembersList({
|
|||||||
readOnly,
|
readOnly,
|
||||||
}: SpaceMembersProps) {
|
}: SpaceMembersProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
|
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
||||||
const { data, isLoading } = useSpaceMembersQuery(spaceId, {
|
const { data, isLoading } = useSpaceMembersQuery(spaceId, {
|
||||||
cursor,
|
page,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
query: search,
|
query: search,
|
||||||
});
|
});
|
||||||
@@ -139,10 +138,10 @@ export default function SpaceMembersList({
|
|||||||
|
|
||||||
{member.type === "group" && <IconGroupCircle />}
|
{member.type === "group" && <IconGroupCircle />}
|
||||||
|
|
||||||
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 260 }}>
|
<div>
|
||||||
<AutoTooltipText fz="sm" fw={500}>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
{member?.name}
|
{member?.name}
|
||||||
</AutoTooltipText>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed">
|
<Text fz="xs" c="dimmed">
|
||||||
{member.type == "user" && member?.email}
|
{member.type == "user" && member?.email}
|
||||||
|
|
||||||
@@ -206,10 +205,10 @@ export default function SpaceMembersList({
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -23,24 +23,23 @@ import SpaceSettingsModal from "@/features/space/components/settings-modal";
|
|||||||
import classes from "./all-spaces-list.module.css";
|
import classes from "./all-spaces-list.module.css";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
|
||||||
|
|
||||||
interface AllSpacesListProps {
|
interface AllSpacesListProps {
|
||||||
spaces: any[];
|
spaces: any[];
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
|
page: number;
|
||||||
hasPrevPage?: boolean;
|
hasPrevPage?: boolean;
|
||||||
hasNextPage?: boolean;
|
hasNextPage?: boolean;
|
||||||
onNext: () => void;
|
onPageChange: (page: number) => void;
|
||||||
onPrev: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AllSpacesList({
|
export default function AllSpacesList({
|
||||||
spaces,
|
spaces,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
page,
|
||||||
hasPrevPage,
|
hasPrevPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
onNext,
|
onPageChange,
|
||||||
onPrev,
|
|
||||||
}: AllSpacesListProps) {
|
}: AllSpacesListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [settingsOpened, { open: openSettings, close: closeSettings }] =
|
const [settingsOpened, { open: openSettings, close: closeSettings }] =
|
||||||
@@ -97,10 +96,10 @@ export default function AllSpacesList({
|
|||||||
variant="filled"
|
variant="filled"
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 350 }}>
|
<div>
|
||||||
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
<Text fz="sm" fw={500} lineClamp={1}>
|
||||||
{space.name}
|
{space.name}
|
||||||
</AutoTooltipText>
|
</Text>
|
||||||
{space.description && (
|
{space.description && (
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{space.description}
|
{space.description}
|
||||||
@@ -145,10 +144,10 @@ export default function AllSpacesList({
|
|||||||
|
|
||||||
{spaces.length > 0 && (
|
{spaces.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
|
currentPage={page}
|
||||||
hasPrevPage={hasPrevPage}
|
hasPrevPage={hasPrevPage}
|
||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
onNext={onNext}
|
onPageChange={onPageChange}
|
||||||
onPrev={onPrev}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -69,12 +69,5 @@ export async function exportSpace(data: IExportSpaceParams): Promise<void> {
|
|||||||
.split("filename=")[1]
|
.split("filename=")[1]
|
||||||
.replace(/"/g, "");
|
.replace(/"/g, "");
|
||||||
|
|
||||||
let decodedFileName = fileName;
|
saveAs(req.data, decodeURIComponent(fileName));
|
||||||
try {
|
|
||||||
decodedFileName = decodeURIComponent(fileName);
|
|
||||||
} catch (err) {
|
|
||||||
// fallback to raw filename
|
|
||||||
}
|
|
||||||
|
|
||||||
saveAs(req.data, decodedFileName);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,8 @@ export type MoveTreeNodeEvent = {
|
|||||||
payload: {
|
payload: {
|
||||||
id: string;
|
id: string;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
oldParentId: string | null;
|
|
||||||
index: number;
|
index: number;
|
||||||
position: string;
|
position: string;
|
||||||
pageData: Partial<IPage>;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { IPagination } from "@/lib/types";
|
|||||||
import {
|
import {
|
||||||
invalidateOnCreatePage,
|
invalidateOnCreatePage,
|
||||||
invalidateOnDeletePage,
|
invalidateOnDeletePage,
|
||||||
updateCacheOnMovePage,
|
invalidateOnMovePage,
|
||||||
invalidateOnUpdatePage,
|
invalidateOnUpdatePage,
|
||||||
} from "../page/queries/page-query";
|
} from "../page/queries/page-query";
|
||||||
import { RQ_KEY } from "../comment/queries/comment-query";
|
import { RQ_KEY } from "../comment/queries/comment-query";
|
||||||
@@ -41,13 +41,7 @@ export const useQuerySubscription = () => {
|
|||||||
invalidateOnCreatePage(data.payload.data);
|
invalidateOnCreatePage(data.payload.data);
|
||||||
break;
|
break;
|
||||||
case "moveTreeNode":
|
case "moveTreeNode":
|
||||||
updateCacheOnMovePage(
|
invalidateOnMovePage();
|
||||||
data.spaceId,
|
|
||||||
data.payload.id,
|
|
||||||
data.payload.oldParentId,
|
|
||||||
data.payload.parentId,
|
|
||||||
data.payload.pageData,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "deleteTreeNode":
|
case "deleteTreeNode":
|
||||||
invalidateOnDeletePage(data.payload.node.id);
|
invalidateOnDeletePage(data.payload.node.id);
|
||||||
|
|||||||
+7
-8
@@ -1,6 +1,6 @@
|
|||||||
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
|
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
|
||||||
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
||||||
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
@@ -8,13 +8,12 @@ import { timeAgo } from "@/lib/time.ts";
|
|||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
|
|
||||||
export default function WorkspaceInvitesTable() {
|
export default function WorkspaceInvitesTable() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const [page, setPage] = useState(1);
|
||||||
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
||||||
cursor,
|
page,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
@@ -66,10 +65,10 @@ export default function WorkspaceInvitesTable() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+6
-6
@@ -21,9 +21,9 @@ import MemberActionMenu from "@/features/workspace/components/members/components
|
|||||||
|
|
||||||
export default function WorkspaceMembersTable() {
|
export default function WorkspaceMembersTable() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
|
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
||||||
const { data, isLoading } = useWorkspaceMembersQuery({
|
const { data, isLoading } = useWorkspaceMembersQuery({
|
||||||
cursor,
|
page,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
query: search,
|
query: search,
|
||||||
});
|
});
|
||||||
@@ -111,10 +111,10 @@ export default function WorkspaceMembersTable() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useState, useCallback } from "react";
|
|
||||||
|
|
||||||
export function useCursorPaginate() {
|
|
||||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
|
||||||
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([]);
|
|
||||||
|
|
||||||
const goNext = useCallback((nextCursor: string | null | undefined) => {
|
|
||||||
if (nextCursor) {
|
|
||||||
setCursorStack((prev) => [...prev, cursor]);
|
|
||||||
setCursor(nextCursor);
|
|
||||||
}
|
|
||||||
}, [cursor]);
|
|
||||||
|
|
||||||
const goPrev = useCallback(() => {
|
|
||||||
setCursorStack((prev) => {
|
|
||||||
const next = prev.slice(0, -1);
|
|
||||||
setCursor(prev[prev.length - 1]);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetCursor = useCallback(() => {
|
|
||||||
setCursor(undefined);
|
|
||||||
setCursorStack([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { cursor, goNext, goPrev, resetCursor };
|
|
||||||
}
|
|
||||||
@@ -2,33 +2,16 @@ import { useState, useRef, useCallback } from "react";
|
|||||||
|
|
||||||
export function usePaginateAndSearch(initialQuery: string = "") {
|
export function usePaginateAndSearch(initialQuery: string = "") {
|
||||||
const [search, setSearch] = useState(initialQuery);
|
const [search, setSearch] = useState(initialQuery);
|
||||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
const [page, setPage] = useState(1);
|
||||||
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([]);
|
|
||||||
const prevSearchRef = useRef(search);
|
const prevSearchRef = useRef(search);
|
||||||
|
|
||||||
const handleSearch = useCallback((newQuery: string) => {
|
const handleSearch = useCallback((newQuery: string) => {
|
||||||
if (prevSearchRef.current !== newQuery) {
|
if (prevSearchRef.current !== newQuery) {
|
||||||
prevSearchRef.current = newQuery;
|
prevSearchRef.current = newQuery;
|
||||||
setSearch(newQuery);
|
setSearch(newQuery);
|
||||||
setCursor(undefined);
|
setPage(1);
|
||||||
setCursorStack([]);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const goNext = useCallback((nextCursor: string | null | undefined) => {
|
return { search, page, setPage, handleSearch };
|
||||||
if (nextCursor) {
|
|
||||||
setCursorStack((prev) => [...prev, cursor]);
|
|
||||||
setCursor(nextCursor);
|
|
||||||
}
|
|
||||||
}, [cursor]);
|
|
||||||
|
|
||||||
const goPrev = useCallback(() => {
|
|
||||||
setCursorStack((prev) => {
|
|
||||||
const next = prev.slice(0, -1);
|
|
||||||
setCursor(prev[prev.length - 1]);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { search, cursor, goNext, goPrev, handleSearch };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { MantineColor } from "@mantine/core";
|
|
||||||
|
|
||||||
function hashCode(input: string) {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < input.length; i += 1) {
|
|
||||||
const char = input.charCodeAt(i);
|
|
||||||
hash = (hash << 5) - hash + char;
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultColors: MantineColor[] = [
|
|
||||||
"blue",
|
|
||||||
"cyan",
|
|
||||||
"grape",
|
|
||||||
"green",
|
|
||||||
"indigo",
|
|
||||||
"lime",
|
|
||||||
"orange",
|
|
||||||
"pink",
|
|
||||||
"red",
|
|
||||||
"teal",
|
|
||||||
"violet",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getInitialsColor(
|
|
||||||
name: string,
|
|
||||||
colors: MantineColor[] = defaultColors,
|
|
||||||
) {
|
|
||||||
const hash = hashCode(name);
|
|
||||||
const index = Math.abs(hash) % colors.length;
|
|
||||||
return colors[index];
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
export interface QueryParams {
|
export interface QueryParams {
|
||||||
query?: string;
|
query?: string;
|
||||||
cursor?: string;
|
page?: number;
|
||||||
beforeCursor?: string;
|
|
||||||
limit?: number;
|
limit?: number;
|
||||||
adminView?: boolean;
|
adminView?: boolean;
|
||||||
}
|
}
|
||||||
@@ -30,10 +29,9 @@ export interface ApiResponse<T> {
|
|||||||
|
|
||||||
export type IPaginationMeta = {
|
export type IPaginationMeta = {
|
||||||
limit: number;
|
limit: number;
|
||||||
|
page: number;
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
hasPrevPage: boolean;
|
hasPrevPage: boolean;
|
||||||
nextCursor: string | null;
|
|
||||||
prevCursor: string | null;
|
|
||||||
};
|
};
|
||||||
export type IPagination<T> = {
|
export type IPagination<T> = {
|
||||||
items: T[];
|
items: T[];
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import useUserRole from "@/hooks/use-user-role";
|
|||||||
export default function Spaces() {
|
export default function Spaces() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
|
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
||||||
|
|
||||||
const { data, isLoading } = useGetSpacesQuery({
|
const { data, isLoading } = useGetSpacesQuery({
|
||||||
cursor,
|
page,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
query: search,
|
query: search,
|
||||||
});
|
});
|
||||||
@@ -41,10 +41,10 @@ export default function Spaces() {
|
|||||||
<AllSpacesList
|
<AllSpacesList
|
||||||
spaces={data?.items || []}
|
spaces={data?.items || []}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
|
page={page}
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
onPageChange={setPage}
|
||||||
onPrev={goPrev}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.25.0-beta.1",
|
"version": "0.24.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -74,15 +74,11 @@
|
|||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"kysely": "^0.28.2",
|
"kysely": "^0.28.2",
|
||||||
"kysely-migration-cli": "^0.4.2",
|
"kysely-migration-cli": "^0.4.2",
|
||||||
"kysely-postgres-js": "^3.0.0",
|
|
||||||
"ldapts": "^7.4.0",
|
"ldapts": "^7.4.0",
|
||||||
"lib0": "^0.2.117",
|
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"msgpackr": "^1.11.8",
|
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"nestjs-kysely": "^1.2.0",
|
"nestjs-kysely": "^1.2.0",
|
||||||
"nestjs-pino": "^4.5.0",
|
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
"openid-client": "^5.7.1",
|
"openid-client": "^5.7.1",
|
||||||
"otpauth": "^9.4.1",
|
"otpauth": "^9.4.1",
|
||||||
@@ -90,11 +86,9 @@
|
|||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdfjs-dist": "^5.4.394",
|
"pdfjs-dist": "^5.4.394",
|
||||||
|
"pg": "^8.16.3",
|
||||||
"pg-tsquery": "^8.4.2",
|
"pg-tsquery": "^8.4.2",
|
||||||
"pgvector": "^0.2.1",
|
"pgvector": "^0.2.1",
|
||||||
"postgres": "^3.4.8",
|
|
||||||
"pino-http": "^11.0.0",
|
|
||||||
"pino-pretty": "^13.1.3",
|
|
||||||
"postmark": "^4.0.5",
|
"postmark": "^4.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -104,7 +98,6 @@
|
|||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"stripe": "^17.5.0",
|
"stripe": "^17.5.0",
|
||||||
"tmp-promise": "^3.0.3",
|
"tmp-promise": "^3.0.3",
|
||||||
"tseep": "^1.3.1",
|
|
||||||
"typesense": "^2.1.0",
|
"typesense": "^2.1.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"yauzl": "^3.2.0"
|
"yauzl": "^3.2.0"
|
||||||
@@ -123,6 +116,7 @@
|
|||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/passport-google-oauth20": "^2.0.16",
|
"@types/passport-google-oauth20": "^2.0.16",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/pg": "^8.11.11",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"@types/yauzl": "^2.10.3",
|
"@types/yauzl": "^2.10.3",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { SecurityModule } from './integrations/security/security.module';
|
|||||||
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
|
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
|
||||||
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
||||||
import { RedisConfigService } from './integrations/redis/redis-config.service';
|
import { RedisConfigService } from './integrations/redis/redis-config.service';
|
||||||
import { LoggerModule } from './common/logger/logger.module';
|
|
||||||
|
|
||||||
const enterpriseModules = [];
|
const enterpriseModules = [];
|
||||||
try {
|
try {
|
||||||
@@ -36,7 +35,6 @@ try {
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
LoggerModule,
|
|
||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
|||||||
@@ -30,20 +30,12 @@ export class CollabWsAdapter {
|
|||||||
return this.wss;
|
return this.wss;
|
||||||
}
|
}
|
||||||
|
|
||||||
public close() {
|
|
||||||
try {
|
|
||||||
this.wss.close();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
try {
|
try {
|
||||||
this.wss.close();
|
|
||||||
this.wss.clients.forEach((client) => {
|
this.wss.clients.forEach((client) => {
|
||||||
client.terminate();
|
client.terminate();
|
||||||
});
|
});
|
||||||
|
this.wss.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Hocuspocus } from '@hocuspocus/server';
|
import { Hocuspocus, Server as HocuspocusServer } from '@hocuspocus/server';
|
||||||
import { IncomingMessage } from 'http';
|
import { IncomingMessage } from 'http';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Redis } from '@hocuspocus/extension-redis';
|
||||||
import { EnvironmentService } from '../integrations/environment/environment.service';
|
import { EnvironmentService } from '../integrations/environment/environment.service';
|
||||||
import {
|
import {
|
||||||
createRetryStrategy,
|
createRetryStrategy,
|
||||||
@@ -11,41 +12,21 @@ import {
|
|||||||
RedisConfig,
|
RedisConfig,
|
||||||
} from '../common/helpers';
|
} from '../common/helpers';
|
||||||
import { LoggerExtension } from './extensions/logger.extension';
|
import { LoggerExtension } from './extensions/logger.extension';
|
||||||
import {
|
|
||||||
RedisSyncExtension,
|
|
||||||
SerializedHTTPRequest,
|
|
||||||
} from './extensions/redis-sync';
|
|
||||||
import { WsSocketWrapper } from './extensions/redis-sync/ws-socket-wrapper';
|
|
||||||
import RedisClient from 'ioredis';
|
|
||||||
import { pack, unpack } from 'msgpackr';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import * as os from 'node:os';
|
|
||||||
import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
|
||||||
import {
|
|
||||||
CollaborationHandler,
|
|
||||||
CollabEventHandlers,
|
|
||||||
} from './collaboration.handler';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CollaborationGateway {
|
export class CollaborationGateway {
|
||||||
private readonly hocuspocus: Hocuspocus;
|
private hocuspocus: Hocuspocus;
|
||||||
private redisConfig: RedisConfig;
|
private redisConfig: RedisConfig;
|
||||||
// @ts-ignore
|
|
||||||
private readonly redisSync: RedisSyncExtension<CollabEventHandlers> | null =
|
|
||||||
null;
|
|
||||||
private readonly withRedis: boolean;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private authenticationExtension: AuthenticationExtension,
|
private authenticationExtension: AuthenticationExtension,
|
||||||
private persistenceExtension: PersistenceExtension,
|
private persistenceExtension: PersistenceExtension,
|
||||||
private loggerExtension: LoggerExtension,
|
private loggerExtension: LoggerExtension,
|
||||||
private environmentService: EnvironmentService,
|
private environmentService: EnvironmentService,
|
||||||
private collabEventsService: CollaborationHandler,
|
|
||||||
) {
|
) {
|
||||||
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
||||||
this.withRedis = !this.environmentService.isCollabDisableRedis();
|
|
||||||
|
|
||||||
this.hocuspocus = new Hocuspocus({
|
this.hocuspocus = HocuspocusServer.configure({
|
||||||
debounce: 10000,
|
debounce: 10000,
|
||||||
maxDebounce: 45000,
|
maxDebounce: 45000,
|
||||||
unloadImmediately: false,
|
unloadImmediately: false,
|
||||||
@@ -53,80 +34,26 @@ export class CollaborationGateway {
|
|||||||
this.authenticationExtension,
|
this.authenticationExtension,
|
||||||
this.persistenceExtension,
|
this.persistenceExtension,
|
||||||
this.loggerExtension,
|
this.loggerExtension,
|
||||||
|
...(this.environmentService.isCollabDisableRedis()
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
new Redis({
|
||||||
|
host: this.redisConfig.host,
|
||||||
|
port: this.redisConfig.port,
|
||||||
|
options: {
|
||||||
|
password: this.redisConfig.password,
|
||||||
|
db: this.redisConfig.db,
|
||||||
|
family: this.redisConfig.family,
|
||||||
|
retryStrategy: createRetryStrategy(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.withRedis) {
|
|
||||||
// @ts-ignore
|
|
||||||
this.redisSync = new RedisSyncExtension({
|
|
||||||
redis: new RedisClient({
|
|
||||||
host: this.redisConfig.host,
|
|
||||||
port: this.redisConfig.port,
|
|
||||||
password: this.redisConfig.password,
|
|
||||||
db: this.redisConfig.db,
|
|
||||||
family: this.redisConfig.family,
|
|
||||||
retryStrategy: createRetryStrategy(),
|
|
||||||
}),
|
|
||||||
serverId: `collab-${os?.hostname()}-${nanoid(10)}`,
|
|
||||||
prefix: 'collab',
|
|
||||||
pack,
|
|
||||||
unpack,
|
|
||||||
// @ts-ignore
|
|
||||||
customEvents: this.collabEventsService.getHandlers(this.hocuspocus),
|
|
||||||
});
|
|
||||||
this.hocuspocus.configuration.extensions.push(this.redisSync);
|
|
||||||
// @ts-ignore
|
|
||||||
this.redisSync.onConfigure({ instance: this.hocuspocus });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private serializeRequest(request: IncomingMessage): SerializedHTTPRequest {
|
|
||||||
return {
|
|
||||||
method: request.method ?? 'GET',
|
|
||||||
url: request.url ?? '/',
|
|
||||||
headers: {
|
|
||||||
'sec-websocket-key': request.headers['sec-websocket-key'] ?? '',
|
|
||||||
'sec-websocket-protocol':
|
|
||||||
request.headers['sec-websocket-protocol'] ?? '',
|
|
||||||
},
|
|
||||||
socket: { remoteAddress: request.socket?.remoteAddress ?? '' },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConnection(client: WebSocket, request: IncomingMessage): any {
|
handleConnection(client: WebSocket, request: IncomingMessage): any {
|
||||||
if (this.redisSync) {
|
this.hocuspocus.handleConnection(client, request);
|
||||||
const serializedHTTPRequest = this.serializeRequest(request);
|
|
||||||
const socketId = serializedHTTPRequest.headers['sec-websocket-key'];
|
|
||||||
|
|
||||||
// Create wrapper socket that only receives events via emit()
|
|
||||||
// This prevents double-handling since Hocuspocus won't listen to raw WebSocket events
|
|
||||||
const wrappedSocket = new WsSocketWrapper(client);
|
|
||||||
|
|
||||||
// Route through RedisSync extension (this calls handleConnection internally)
|
|
||||||
this.redisSync.onSocketOpen(wrappedSocket as any, serializedHTTPRequest);
|
|
||||||
|
|
||||||
// Forward raw WebSocket messages to the extension
|
|
||||||
client.on('message', (data: ArrayBuffer) => {
|
|
||||||
this.redisSync!.onSocketMessage(
|
|
||||||
wrappedSocket as any,
|
|
||||||
serializedHTTPRequest,
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Forward close events
|
|
||||||
client.on('close', (code: number, reason: Buffer) => {
|
|
||||||
this.redisSync!.onSocketClose(socketId, code, reason);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Forward pong events for keepalive
|
|
||||||
client.on('pong', (data: Buffer) => {
|
|
||||||
wrappedSocket.emit('pong', data);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback to direct Hocuspocus connection
|
|
||||||
this.hocuspocus.handleConnection(client, request);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getConnectionCount() {
|
getConnectionCount() {
|
||||||
@@ -137,52 +64,11 @@ export class CollaborationGateway {
|
|||||||
return this.hocuspocus.getDocumentsCount();
|
return this.hocuspocus.getDocumentsCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleYjsEvent<TName extends keyof CollabEventHandlers>(
|
async destroy(): Promise<void> {
|
||||||
eventName: TName,
|
await this.hocuspocus.destroy();
|
||||||
documentName: string,
|
|
||||||
payload: Parameters<CollabEventHandlers[TName]>[1],
|
|
||||||
) {
|
|
||||||
return this.redisSync?.handleEvent(eventName, documentName, payload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openDirectConnection(documentName: string, context?: any) {
|
async openDirectConnection(documentName: string) {
|
||||||
return this.hocuspocus.openDirectConnection(documentName, context);
|
return this.hocuspocus.openDirectConnection(documentName);
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
*Can be used before calling openDirectConnection directly
|
|
||||||
*/
|
|
||||||
async lockDocument(documentName: string) {
|
|
||||||
return this.redisSync.lockDocument(documentName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
*Releases a document lock and stops the interval that maintains it.
|
|
||||||
*/
|
|
||||||
async releaseLock(documentName: string) {
|
|
||||||
return this.redisSync.releaseLock(documentName);
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroy(collabWsAdapter: CollabWsAdapter): Promise<void> {
|
|
||||||
// eslint-disable-next-line no-async-promise-executor
|
|
||||||
await new Promise(async (resolve) => {
|
|
||||||
try {
|
|
||||||
// Wait for all documents to unload
|
|
||||||
this.hocuspocus.configuration.extensions.push({
|
|
||||||
async afterUnloadDocument({ instance }) {
|
|
||||||
if (instance.getDocumentsCount() === 0) resolve('');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
collabWsAdapter?.close();
|
|
||||||
|
|
||||||
if (this.hocuspocus.getDocumentsCount() === 0) resolve('');
|
|
||||||
this.hocuspocus.closeConnections();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.hocuspocus.hooks('onDestroy', { instance: this.hocuspocus });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Hocuspocus, Document } from '@hocuspocus/server';
|
|
||||||
|
|
||||||
export type CollabEventHandlers = ReturnType<
|
|
||||||
CollaborationHandler['getHandlers']
|
|
||||||
>;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CollaborationHandler {
|
|
||||||
private readonly logger = new Logger(CollaborationHandler.name);
|
|
||||||
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
getHandlers(hocuspocus: Hocuspocus) {
|
|
||||||
return {
|
|
||||||
alterState: async (documentName: string, payload: { pageId: string }) => {
|
|
||||||
// dummy
|
|
||||||
// this.logger.log('Processing', documentName, payload);
|
|
||||||
// await this.withYdocConnection(hocuspocus, documentName, {}, (doc) => {
|
|
||||||
// const fragment = doc.getXmlFragment('default');
|
|
||||||
//});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async withYdocConnection(
|
|
||||||
hocuspocus: Hocuspocus,
|
|
||||||
documentName: string,
|
|
||||||
context: any = {},
|
|
||||||
fn: (doc: Document) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const connection = await hocuspocus.openDirectConnection(
|
|
||||||
documentName,
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await connection.transact(fn);
|
|
||||||
} finally {
|
|
||||||
await connection.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import { WebSocket } from 'ws';
|
|||||||
import { TokenModule } from '../core/auth/token.module';
|
import { TokenModule } from '../core/auth/token.module';
|
||||||
import { HistoryListener } from './listeners/history.listener';
|
import { HistoryListener } from './listeners/history.listener';
|
||||||
import { LoggerExtension } from './extensions/logger.extension';
|
import { LoggerExtension } from './extensions/logger.extension';
|
||||||
import { CollaborationHandler } from './collaboration.handler';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -18,7 +17,6 @@ import { CollaborationHandler } from './collaboration.handler';
|
|||||||
PersistenceExtension,
|
PersistenceExtension,
|
||||||
LoggerExtension,
|
LoggerExtension,
|
||||||
HistoryListener,
|
HistoryListener,
|
||||||
CollaborationHandler,
|
|
||||||
],
|
],
|
||||||
exports: [CollaborationGateway],
|
exports: [CollaborationGateway],
|
||||||
imports: [TokenModule],
|
imports: [TokenModule],
|
||||||
@@ -48,12 +46,16 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
wss.on('error', (error) =>
|
wss.on('error', (error) =>
|
||||||
this.logger.error('WebSocket server error:', error),
|
this.logger.log('WebSocket server error:', error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
await this.collaborationGateway?.destroy(this.collabWsAdapter);
|
if (this.collaborationGateway) {
|
||||||
this.collabWsAdapter?.destroy();
|
await this.collaborationGateway.destroy();
|
||||||
|
}
|
||||||
|
if (this.collabWsAdapter) {
|
||||||
|
this.collabWsAdapter.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { StarterKit } from '@tiptap/starter-kit';
|
import { StarterKit } from '@tiptap/starter-kit';
|
||||||
|
import {
|
||||||
|
initProseMirrorDoc,
|
||||||
|
relativePositionToAbsolutePosition,
|
||||||
|
} from 'y-prosemirror';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { Document } from '@hocuspocus/server';
|
||||||
import { TextAlign } from '@tiptap/extension-text-align';
|
import { TextAlign } from '@tiptap/extension-text-align';
|
||||||
|
import { TaskList } from '@tiptap/extension-task-list';
|
||||||
|
import { TaskItem } from '@tiptap/extension-task-item';
|
||||||
|
import { Underline } from '@tiptap/extension-underline';
|
||||||
import { Superscript } from '@tiptap/extension-superscript';
|
import { Superscript } from '@tiptap/extension-superscript';
|
||||||
import SubScript from '@tiptap/extension-subscript';
|
import SubScript from '@tiptap/extension-subscript';
|
||||||
import { Typography } from '@tiptap/extension-typography';
|
import { Typography } from '@tiptap/extension-typography';
|
||||||
import { TextStyle } from '@tiptap/extension-text-style';
|
import { TextStyle } from '@tiptap/extension-text-style';
|
||||||
import { Color } from '@tiptap/extension-color';
|
import { Color } from '@tiptap/extension-color';
|
||||||
import { Youtube } from '@tiptap/extension-youtube';
|
import { Youtube } from '@tiptap/extension-youtube';
|
||||||
import { TaskList, TaskItem } from '@tiptap/extension-list';
|
|
||||||
import {
|
import {
|
||||||
Heading,
|
Heading,
|
||||||
Callout,
|
Callout,
|
||||||
@@ -40,15 +48,11 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
|||||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||||
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||||
//import { generateJSON } from '@tiptap/html';
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { Node, Schema } from '@tiptap/pm/model';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
|
|
||||||
export const tiptapExtensions = [
|
export const tiptapExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
link: false,
|
|
||||||
trailingNode: false,
|
|
||||||
heading: false,
|
heading: false,
|
||||||
}),
|
}),
|
||||||
Heading,
|
Heading,
|
||||||
@@ -61,6 +65,7 @@ export const tiptapExtensions = [
|
|||||||
TaskItem.configure({
|
TaskItem.configure({
|
||||||
nested: true,
|
nested: true,
|
||||||
}),
|
}),
|
||||||
|
Underline,
|
||||||
LinkExtension,
|
LinkExtension,
|
||||||
Superscript,
|
Superscript,
|
||||||
SubScript,
|
SubScript,
|
||||||
@@ -111,53 +116,175 @@ export function jsonToText(tiptapJson: JSONContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function jsonToNode(tiptapJson: JSONContent) {
|
export function jsonToNode(tiptapJson: JSONContent) {
|
||||||
const schema = getSchema(tiptapExtensions);
|
return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson);
|
||||||
try {
|
|
||||||
return Node.fromJSON(schema, tiptapJson);
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof RangeError &&
|
|
||||||
error.message.includes('Unknown node type')
|
|
||||||
) {
|
|
||||||
Logger.warn('Stripping unknown node types from document:', error.message);
|
|
||||||
const cleanedJson = stripUnknownNodes(tiptapJson, schema);
|
|
||||||
return Node.fromJSON(schema, cleanedJson);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPageId(documentName: string) {
|
export function getPageId(documentName: string) {
|
||||||
return documentName.split('.')[1];
|
return documentName.split('.')[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripUnknownNodes(
|
export type YjsSelection = {
|
||||||
json: JSONContent,
|
anchor: any;
|
||||||
schema: Schema,
|
head: any;
|
||||||
): JSONContent | null {
|
};
|
||||||
if (!json || typeof json !== 'object') return json;
|
|
||||||
|
|
||||||
// Recursively clean children first, flattening any unwrapped content
|
export function setYjsMark(
|
||||||
if (json.content && Array.isArray(json.content)) {
|
doc: Document,
|
||||||
const newContent: JSONContent[] = [];
|
fragment: Y.XmlFragment,
|
||||||
for (const child of json.content) {
|
yjsSelection: YjsSelection,
|
||||||
const cleaned = stripUnknownNodes(child, schema);
|
markName: string,
|
||||||
if (Array.isArray(cleaned)) {
|
markAttributes: Record<string, any>,
|
||||||
newContent.push(...cleaned);
|
) {
|
||||||
} else if (cleaned) {
|
const schema = getSchema(tiptapExtensions);
|
||||||
newContent.push(cleaned);
|
const { mapping } = initProseMirrorDoc(fragment, schema);
|
||||||
|
|
||||||
|
// Convert JSON positions to Y.js RelativePosition objects
|
||||||
|
const anchorRelPos = Y.createRelativePositionFromJSON(yjsSelection.anchor);
|
||||||
|
const headRelPos = Y.createRelativePositionFromJSON(yjsSelection.head);
|
||||||
|
|
||||||
|
const anchor = relativePositionToAbsolutePosition(
|
||||||
|
doc,
|
||||||
|
fragment,
|
||||||
|
anchorRelPos,
|
||||||
|
mapping,
|
||||||
|
);
|
||||||
|
const head = relativePositionToAbsolutePosition(
|
||||||
|
doc,
|
||||||
|
fragment,
|
||||||
|
headRelPos,
|
||||||
|
mapping,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (anchor === null || head === null) {
|
||||||
|
throw new Error(
|
||||||
|
'Could not resolve Y.js relative positions to absolute positions',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = Math.min(anchor, head);
|
||||||
|
const to = Math.max(anchor, head);
|
||||||
|
|
||||||
|
// Apply mark directly to Y.js XmlText nodes
|
||||||
|
// This bypasses updateYFragment which has compatibility issues
|
||||||
|
applyMarkToYFragment(fragment, from, to, markName, markAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMarkToYFragment(
|
||||||
|
fragment: Y.XmlFragment,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
markName: string,
|
||||||
|
markAttributes: Record<string, any>,
|
||||||
|
) {
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
const processItem = (item: any): boolean => {
|
||||||
|
if (pos >= to) return false;
|
||||||
|
|
||||||
|
if (item instanceof Y.XmlText) {
|
||||||
|
const textLength = item.length;
|
||||||
|
const itemEnd = pos + textLength;
|
||||||
|
|
||||||
|
if (itemEnd > from && pos < to) {
|
||||||
|
const formatFrom = Math.max(0, from - pos);
|
||||||
|
const formatTo = Math.min(textLength, to - pos);
|
||||||
|
const formatLength = formatTo - formatFrom;
|
||||||
|
|
||||||
|
if (formatLength > 0) {
|
||||||
|
item.format(formatFrom, formatLength, { [markName]: markAttributes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = itemEnd;
|
||||||
|
} else if (item instanceof Y.XmlElement) {
|
||||||
|
pos++; // Opening tag
|
||||||
|
for (let i = 0; i < item.length; i++) {
|
||||||
|
if (!processItem(item.get(i))) return false;
|
||||||
|
}
|
||||||
|
pos++; // Closing tag
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < fragment.length; i++) {
|
||||||
|
if (!processItem(fragment.get(i))) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a mark from all text in the fragment that has the specified attribute value.
|
||||||
|
* Useful for deleting comments by commentId.
|
||||||
|
*/
|
||||||
|
export function removeYjsMarkByAttribute(
|
||||||
|
fragment: Y.XmlFragment,
|
||||||
|
markName: string,
|
||||||
|
attributeName: string,
|
||||||
|
attributeValue: string,
|
||||||
|
) {
|
||||||
|
const processItem = (item: any) => {
|
||||||
|
if (item instanceof Y.XmlText) {
|
||||||
|
// Get all formatting deltas to find ranges with this mark
|
||||||
|
const deltas = item.toDelta();
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const delta of deltas) {
|
||||||
|
const length = delta.insert?.length ?? 0;
|
||||||
|
const attributes = delta.attributes ?? {};
|
||||||
|
const markAttr = attributes[markName];
|
||||||
|
|
||||||
|
if (markAttr && markAttr[attributeName] === attributeValue) {
|
||||||
|
// Remove the mark by setting it to null
|
||||||
|
item.format(offset, length, { [markName]: null });
|
||||||
|
}
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
} else if (item instanceof Y.XmlElement) {
|
||||||
|
for (let i = 0; i < item.length; i++) {
|
||||||
|
processItem(item.get(i));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
json.content = newContent;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this node is unknown AFTER processing children
|
for (let i = 0; i < fragment.length; i++) {
|
||||||
if (json.type && !schema.nodes[json.type]) {
|
processItem(fragment.get(i));
|
||||||
// Unwrap: return cleaned children directly instead of wrapping
|
}
|
||||||
return (
|
}
|
||||||
json.content && json.content.length > 0 ? json.content : null
|
|
||||||
) as any;
|
/**
|
||||||
|
* Updates a mark's attributes for all text that has the specified attribute value.
|
||||||
|
* Useful for resolving/unresolving comments by commentId.
|
||||||
|
*/
|
||||||
|
export function updateYjsMarkAttribute(
|
||||||
|
fragment: Y.XmlFragment,
|
||||||
|
markName: string,
|
||||||
|
findByAttribute: { name: string; value: string },
|
||||||
|
newAttributes: Record<string, any>,
|
||||||
|
) {
|
||||||
|
const processItem = (item: any) => {
|
||||||
|
if (item instanceof Y.XmlText) {
|
||||||
|
const deltas = item.toDelta();
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const delta of deltas) {
|
||||||
|
const length = delta.insert?.length ?? 0;
|
||||||
|
const attributes = delta.attributes ?? {};
|
||||||
|
const markAttr = attributes[markName];
|
||||||
|
|
||||||
|
if (markAttr && markAttr[findByAttribute.name] === findByAttribute.value) {
|
||||||
|
// Update the mark with new attributes (merge with existing)
|
||||||
|
item.format(offset, length, {
|
||||||
|
[markName]: { ...markAttr, ...newAttributes },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
} else if (item instanceof Y.XmlElement) {
|
||||||
|
for (let i = 0; i < item.length; i++) {
|
||||||
|
processItem(item.get(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < fragment.length; i++) {
|
||||||
|
processItem(fragment.get(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
return json;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export class AuthenticationExtension implements Extension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (userSpaceRole === SpaceRole.READER) {
|
if (userSpaceRole === SpaceRole.READER) {
|
||||||
data.connectionConfig.readOnly = true;
|
data.connection.readOnly = true;
|
||||||
this.logger.debug(`User granted readonly access to page: ${pageId}`);
|
this.logger.debug(`User granted readonly access to page: ${pageId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
export class LoggerExtension implements Extension {
|
export class LoggerExtension implements Extension {
|
||||||
private readonly logger = new Logger('Collab' + LoggerExtension.name);
|
private readonly logger = new Logger('Collab' + LoggerExtension.name);
|
||||||
|
|
||||||
|
async onDisconnect(data: onDisconnectPayload) {
|
||||||
|
this.logger.debug(`User disconnected from "${data.documentName}".`);
|
||||||
|
}
|
||||||
|
|
||||||
async afterUnloadDocument(data: onLoadDocumentPayload) {
|
async afterUnloadDocument(data: onLoadDocumentPayload) {
|
||||||
this.logger.debug('Unloaded ' + data.documentName + ' from memory');
|
this.logger.debug('Unloaded ' + data.documentName + ' from memory');
|
||||||
}
|
}
|
||||||
|
|
||||||
async onDisconnect(data: onDisconnectPayload) {
|
|
||||||
this.logger.debug('User disconnected from ' + data.documentName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user