Compare commits

..

22 Commits

Author SHA1 Message Date
Philipinho 1adbf97701 increase iframe default dimension 2026-03-28 17:22:44 +00:00
Philipinho 4ff67bb962 hide focus on readonly mode 2026-03-28 17:15:51 +00:00
Philipinho 2e392da61a open active 2026-03-28 17:14:31 +00:00
Philipinho 12e9cae65c remove audio menu 2026-03-28 17:06:18 +00:00
Philipinho 477c8ace52 cleanup 2026-03-28 16:58:09 +00:00
Philipinho d8ec0472ef feat: PDF embed 2026-03-28 16:15:52 +00:00
Philipinho c2b41d72bf better sort 2026-03-28 11:45:53 +00:00
Philipinho 963ab5d7cb translations 2026-03-28 11:44:27 +00:00
Philipinho bdde4a7178 keyword 2026-03-28 11:40:10 +00:00
Philipinho 2c35d2b3f4 keywords 2026-03-28 11:39:23 +00:00
Philipinho 7681894953 hide in readonly mode 2026-03-28 11:38:44 +00:00
Philipinho bfaef88429 unify pulse 2026-03-28 11:33:56 +00:00
Philipinho c6bbb57406 Fix import and export 2026-03-28 11:16:26 +00:00
Philipinho c599b6a9c1 fix pulse 2026-03-28 11:14:40 +00:00
Philipinho 4b99d89e55 add audio 2026-03-28 11:06:13 +00:00
Philipinho aabdc7264d hide notice 2026-03-28 11:01:46 +00:00
Philipinho 4ebcbb71da error handling 2026-03-28 10:41:29 +00:00
Philipinho c07f348b38 cleanup 2026-03-28 10:24:13 +00:00
Philipinho a5360ad341 feat: use confluence real file names 2026-03-28 10:23:29 +00:00
Philipinho 795b79c2a2 support audio imports 2026-03-27 22:50:55 +00:00
Philipinho 6b2f8542c4 feat: aduio 2026-03-27 22:50:34 +00:00
Philipinho 9f38c61882 use local resizable 2026-03-27 22:25:35 +00:00
55 changed files with 163 additions and 1434 deletions
@@ -341,7 +341,6 @@
"Insert horizontal rule divider": "Horizontale Trennlinie einfügen", "Insert horizontal rule divider": "Horizontale Trennlinie einfügen",
"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 audio from your device.": "Laden Sie beliebige Audiodateien 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 {{name}}": "Lade {{name}} hoch",
"Uploading file": "Datei wird hochgeladen", "Uploading file": "Datei wird hochgeladen",
@@ -352,12 +351,6 @@
"Divider": "Trennlinie", "Divider": "Trennlinie",
"Quote": "Zitat", "Quote": "Zitat",
"Image": "Bild", "Image": "Bild",
"Audio": "Audio.",
"Embed PDF": "PDF einbetten",
"Upload and embed a PDF file.": "Laden Sie eine PDF-Datei hoch und betten Sie sie ein.",
"Embed as PDF": "Als PDF einbetten",
"Failed to load PDF": "Fehler beim Laden der PDF",
"Convert to attachment": "In Anhang umwandeln",
"File attachment": "Dateianhang", "File attachment": "Dateianhang",
"Toggle block": "Block umschalten", "Toggle block": "Block umschalten",
"Callout": "Hinweisbox", "Callout": "Hinweisbox",
@@ -449,9 +442,6 @@
"Prevent members from sharing pages publicly.": "Verhindern Sie, dass Mitglieder Seiten öffentlich teilen.", "Prevent members from sharing pages publicly.": "Verhindern Sie, dass Mitglieder Seiten öffentlich teilen.",
"Toggle public sharing": "Öffentliches Teilen umschalten", "Toggle public sharing": "Öffentliches Teilen umschalten",
"Toggle space public sharing": "Öffentliches Teilen im Bereich umschalten", "Toggle space public sharing": "Öffentliches Teilen im Bereich umschalten",
"Allow viewers to comment": "Zuschauern erlauben, Kommentare zu hinterlassen",
"Allow viewers to add comments on pages in this space.": "Erlauben Sie Zuschauern, Kommentare auf Seiten in diesem Bereich hinzuzufügen.",
"Toggle viewer comments": "Zuschauerkommentare umschalten",
"Public sharing is disabled at the workspace level": "Öffentliches Teilen ist auf der Arbeitsbereichsebene deaktiviert", "Public sharing is disabled at the workspace level": "Öffentliches Teilen ist auf der Arbeitsbereichsebene deaktiviert",
"Prevent pages in this space from being shared publicly.": "Verhindern Sie, dass Seiten in diesem Bereich öffentlich geteilt werden.", "Prevent pages in this space from being shared publicly.": "Verhindern Sie, dass Seiten in diesem Bereich öffentlich geteilt werden.",
"Page permissions": "Seitenberechtigungen", "Page permissions": "Seitenberechtigungen",
@@ -184,20 +184,20 @@
"Updated successfully": "Updated successfully.", "Updated successfully": "Updated successfully.",
"User": "User.", "User": "User.",
"Workspace": "Workspace.", "Workspace": "Workspace.",
"Workspace Name": "Workspace name.", "Workspace Name": "Workspace Name.",
"Workspace settings": "Workspace settings.", "Workspace settings": "Workspace settings.",
"You can change your password here.": "You can change your password here.", "You can change your password here.": "You can change your password here.",
"Your Email": "Your email.", "Your Email": "Your Email.",
"Your import is complete.": "Your import is complete.", "Your import is complete.": "Your import is complete.",
"Your name": "Your name.", "Your name": "Your name.",
"Your Name": "Your name.", "Your Name": "Your Name.",
"Your password": "Your password.", "Your password": "Your password.",
"Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.", "Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.",
"Sidebar toggle": "Sidebar toggle.", "Sidebar toggle": "Sidebar toggle.",
"Comments": "Comments.", "Comments": "Comments.",
"404 page not found": "404 page not found.", "404 page not found": "404 page not found.",
"Sorry, we can't find the page you are looking for.": "Sorry, we can't find the page you are looking for.", "Sorry, we can't find the page you are looking for.": "Sorry, we can't find the page you are looking for.",
"Take me back to homepage": "Take me back to the homepage.", "Take me back to homepage": "Take me back to homepage.",
"Forgot password": "Forgot password.", "Forgot password": "Forgot password.",
"Forgot your password?": "Forgot your password?", "Forgot your password?": "Forgot your password?",
"A password reset link has been sent to your email. Please check your inbox.": "A password reset link has been sent to your email. Please check your inbox.", "A password reset link has been sent to your email. Please check your inbox.": "A password reset link has been sent to your email. Please check your inbox.",
@@ -223,12 +223,12 @@
"Failed to delete comment": "Failed to delete comment", "Failed to delete comment": "Failed to delete comment",
"Comment resolved successfully": "Comment resolved successfully", "Comment resolved successfully": "Comment resolved successfully",
"Comment re-opened successfully": "Comment re-opened successfully.", "Comment re-opened successfully": "Comment re-opened successfully.",
"Comment unresolved successfully": "Comment marked as unresolved successfully.", "Comment unresolved successfully": "Comment unresolved successfully.",
"Failed to resolve comment": "Failed to resolve comment", "Failed to resolve comment": "Failed to resolve comment",
"Resolve comment": "Resolve comment.", "Resolve comment": "Resolve comment.",
"Unresolve comment": "Mark comment as unresolved.", "Unresolve comment": "Unresolve comment.",
"Resolve Comment Thread": "Resolve comment thread.", "Resolve Comment Thread": "Resolve Comment Thread.",
"Unresolve Comment Thread": "Mark comment thread as unresolved.", "Unresolve Comment Thread": "Unresolve Comment Thread.",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Are you sure you want to resolve this comment thread? This will mark it as completed.", "Are you sure you want to resolve this comment thread? This will mark it as completed.": "Are you sure you want to resolve this comment thread? This will mark it as completed.",
"Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?", "Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
"Resolved": "Resolved.", "Resolved": "Resolved.",
@@ -370,7 +370,7 @@
"Insert mermaid diagram": "Insert mermaid diagram.", "Insert mermaid diagram": "Insert mermaid diagram.",
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams.", "Insert and design Drawio diagrams": "Insert and design Drawio diagrams.",
"Insert current date": "Insert current date.", "Insert current date": "Insert current date.",
"Draw and sketch excalidraw diagrams": "Draw and sketch Excalidraw diagrams.", "Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams.",
"Multiple": "Multiple.", "Multiple": "Multiple.",
"Turn into": "Turn into", "Turn into": "Turn into",
"Text align": "Text align", "Text align": "Text align",
@@ -449,9 +449,6 @@
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.", "Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
"Toggle public sharing": "Toggle public sharing", "Toggle public sharing": "Toggle public sharing",
"Toggle space public sharing": "Toggle space public sharing", "Toggle space public sharing": "Toggle space public sharing",
"Allow viewers to comment": "Allow viewers to comment",
"Allow viewers to add comments on pages in this space.": "Allow viewers to add comments on pages in this space.",
"Toggle viewer comments": "Toggle viewer comments",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level", "Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.", "Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Page permissions": "Page permissions", "Page permissions": "Page permissions",
@@ -733,5 +730,7 @@
"Publish": "Publish.", "Publish": "Publish.",
"Security": "Security.", "Security": "Security.",
"Enforce SSO": "Enforce SSO.", "Enforce SSO": "Enforce SSO.",
"Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to log in with email and password." "Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to log in with email and password.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file"
} }
@@ -341,7 +341,6 @@
"Insert horizontal rule divider": "Insertar regla horizontal", "Insert horizontal rule divider": "Insertar regla horizontal",
"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 audio from your device.": "Sube cualquier audio 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 {{name}}": "Subiendo {{name}}",
"Uploading file": "Subiendo archivo", "Uploading file": "Subiendo archivo",
@@ -352,12 +351,6 @@
"Divider": "Divisor", "Divider": "Divisor",
"Quote": "Cita", "Quote": "Cita",
"Image": "Imagen", "Image": "Imagen",
"Audio": "Audio.",
"Embed PDF": "Adjuntar PDF",
"Upload and embed a PDF file.": "Sube y adjunta un archivo PDF.",
"Embed as PDF": "Adjuntar como PDF",
"Failed to load PDF": "Error al cargar el PDF",
"Convert to attachment": "Convertir en adjunto",
"File attachment": "Adjunto de archivo", "File attachment": "Adjunto de archivo",
"Toggle block": "Alternar bloque", "Toggle block": "Alternar bloque",
"Callout": "Aviso", "Callout": "Aviso",
@@ -449,9 +442,6 @@
"Prevent members from sharing pages publicly.": "Evitar que los miembros compartan páginas públicamente.", "Prevent members from sharing pages publicly.": "Evitar que los miembros compartan páginas públicamente.",
"Toggle public sharing": "Alternar el uso compartido público", "Toggle public sharing": "Alternar el uso compartido público",
"Toggle space public sharing": "Alternar el uso compartido público del espacio", "Toggle space public sharing": "Alternar el uso compartido público del espacio",
"Allow viewers to comment": "Permitir que los espectadores comenten",
"Allow viewers to add comments on pages in this space.": "Permitir que los espectadores agreguen comentarios en las páginas de este espacio.",
"Toggle viewer comments": "Activar/desactivar comentarios de los espectadores",
"Public sharing is disabled at the workspace level": "El uso compartido público está desactivado a nivel de espacio de trabajo", "Public sharing is disabled at the workspace level": "El uso compartido público está desactivado a nivel de espacio de trabajo",
"Prevent pages in this space from being shared publicly.": "Evitar que las páginas en este espacio se compartan públicamente.", "Prevent pages in this space from being shared publicly.": "Evitar que las páginas en este espacio se compartan públicamente.",
"Page permissions": "Permisos de la página},{", "Page permissions": "Permisos de la página},{",
@@ -341,7 +341,6 @@
"Insert horizontal rule divider": "Insérer un séparateur de règle horizontale", "Insert horizontal rule divider": "Insérer un séparateur de règle horizontale",
"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 audio from your device.": "Téléchargez n'importe quel fichier audio 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 {{name}}": "Téléchargement de {{name}}",
"Uploading file": "Téléchargement du fichier", "Uploading file": "Téléchargement du fichier",
@@ -352,12 +351,6 @@
"Divider": "Diviseur", "Divider": "Diviseur",
"Quote": "Citation", "Quote": "Citation",
"Image": "Image", "Image": "Image",
"Audio": "Audio.",
"Embed PDF": "Intégrer un PDF",
"Upload and embed a PDF file.": "Téléchargez et intégrez un fichier PDF.",
"Embed as PDF": "Intégrer comme PDF",
"Failed to load PDF": "Échec du chargement du PDF",
"Convert to attachment": "Convertir en pièce jointe",
"File attachment": "Pièce jointe", "File attachment": "Pièce jointe",
"Toggle block": "Basculer le bloc", "Toggle block": "Basculer le bloc",
"Callout": "Appel", "Callout": "Appel",
@@ -422,7 +415,7 @@
"Move page": "Déplacer la page", "Move page": "Déplacer la page",
"Move page to a different space.": "Déplacer la page vers un autre espace.", "Move page to a different space.": "Déplacer la page vers un autre espace.",
"Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...", "Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...",
"Table of contents": "Table des matières.", "Table of contents": "",
"Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières.", "Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières.",
"Share": "Partager", "Share": "Partager",
"Public sharing": "Partage public", "Public sharing": "Partage public",
@@ -449,9 +442,6 @@
"Prevent members from sharing pages publicly.": "Empêcher les membres de partager des pages publiquement.", "Prevent members from sharing pages publicly.": "Empêcher les membres de partager des pages publiquement.",
"Toggle public sharing": "Basculer le partage public", "Toggle public sharing": "Basculer le partage public",
"Toggle space public sharing": "Basculer le partage public de l'espace", "Toggle space public sharing": "Basculer le partage public de l'espace",
"Allow viewers to comment": "Autoriser les spectateurs à commenter",
"Allow viewers to add comments on pages in this space.": "Autoriser les spectateurs à ajouter des commentaires sur les pages de cet espace.",
"Toggle viewer comments": "Basculer les commentaires des spectateurs",
"Public sharing is disabled at the workspace level": "Le partage public est désactivé au niveau de l'espace de travail", "Public sharing is disabled at the workspace level": "Le partage public est désactivé au niveau de l'espace de travail",
"Prevent pages in this space from being shared publicly.": "Empêcher les pages de cet espace d'être partagées publiquement.", "Prevent pages in this space from being shared publicly.": "Empêcher les pages de cet espace d'être partagées publiquement.",
"Page permissions": "Autorisations de la page", "Page permissions": "Autorisations de la page",
@@ -341,7 +341,6 @@
"Insert horizontal rule divider": "Inserisci divisore di regola orizzontale", "Insert horizontal rule divider": "Inserisci divisore di regola orizzontale",
"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 audio from your device.": "Carica qualsiasi audio 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 {{name}}": "Caricamento di {{name}}",
"Uploading file": "Caricamento file", "Uploading file": "Caricamento file",
@@ -352,12 +351,6 @@
"Divider": "Divisore", "Divider": "Divisore",
"Quote": "Preventivo", "Quote": "Preventivo",
"Image": "Immagine", "Image": "Immagine",
"Audio": "Audio.",
"Embed PDF": "Incorpora PDF",
"Upload and embed a PDF file.": "Carica e incorpora un file PDF.",
"Embed as PDF": "Incorpora come PDF",
"Failed to load PDF": "Caricamento del PDF non riuscito",
"Convert to attachment": "Converti in allegato",
"File attachment": "Allegato file", "File attachment": "Allegato file",
"Toggle block": "Attiva blocco", "Toggle block": "Attiva blocco",
"Callout": "Avviso", "Callout": "Avviso",
@@ -449,9 +442,6 @@
"Prevent members from sharing pages publicly.": "Impedisci ai membri di condividere pubblicamente le pagine.", "Prevent members from sharing pages publicly.": "Impedisci ai membri di condividere pubblicamente le pagine.",
"Toggle public sharing": "Attiva/disattiva la condivisione pubblica", "Toggle public sharing": "Attiva/disattiva la condivisione pubblica",
"Toggle space public sharing": "Attiva/disattiva la condivisione pubblica nello spazio", "Toggle space public sharing": "Attiva/disattiva la condivisione pubblica nello spazio",
"Allow viewers to comment": "Consenti agli utenti di commentare",
"Allow viewers to add comments on pages in this space.": "Consenti agli utenti di aggiungere commenti alle pagine in questo spazio.",
"Toggle viewer comments": "Attiva/disattiva i commenti degli utenti",
"Public sharing is disabled at the workspace level": "La condivisione pubblica è disabilitata a livello di area di lavoro", "Public sharing is disabled at the workspace level": "La condivisione pubblica è disabilitata a livello di area di lavoro",
"Prevent pages in this space from being shared publicly.": "Impedisci che le pagine in questo spazio vengano condivise pubblicamente.", "Prevent pages in this space from being shared publicly.": "Impedisci che le pagine in questo spazio vengano condivise pubblicamente.",
"Page permissions": "Autorizzazioni della pagina.", "Page permissions": "Autorizzazioni della pagina.",
@@ -341,7 +341,6 @@
"Insert horizontal rule divider": "区切り線を挿入します", "Insert horizontal rule divider": "区切り線を挿入します",
"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 audio from your device.": "デバイスから音声ファイルをアップロードします。",
"Upload any file from your device.": "デバイスからファイルをアップロードします", "Upload any file from your device.": "デバイスからファイルをアップロードします",
"Uploading {{name}}": "{{name}} をアップロード中", "Uploading {{name}}": "{{name}} をアップロード中",
"Uploading file": "ファイルをアップロード中", "Uploading file": "ファイルをアップロード中",
@@ -352,12 +351,6 @@
"Divider": "区切り線", "Divider": "区切り線",
"Quote": "引用", "Quote": "引用",
"Image": "画像", "Image": "画像",
"Audio": "音声。",
"Embed PDF": "PDFを埋め込む",
"Upload and embed a PDF file.": "PDFファイルをアップロードして埋め込みます。",
"Embed as PDF": "PDFとして埋め込む",
"Failed to load PDF": "PDFの読み込みに失敗しました",
"Convert to attachment": "添付ファイルに変換",
"File attachment": "ファイル添付", "File attachment": "ファイル添付",
"Toggle block": "ブロックを切り替える", "Toggle block": "ブロックを切り替える",
"Callout": "コールアウト", "Callout": "コールアウト",
@@ -449,9 +442,6 @@
"Prevent members from sharing pages publicly.": "メンバーがページを公開で共有するのを防ぐ。", "Prevent members from sharing pages publicly.": "メンバーがページを公開で共有するのを防ぐ。",
"Toggle public sharing": "公開共有を切り替える", "Toggle public sharing": "公開共有を切り替える",
"Toggle space public sharing": "スペースの公開共有を切り替える", "Toggle space public sharing": "スペースの公開共有を切り替える",
"Allow viewers to comment": "閲覧者によるコメントを許可",
"Allow viewers to add comments on pages in this space.": "このスペース内のページに閲覧者がコメントを追加できるようにします。",
"Toggle viewer comments": "閲覧者コメントの切り替え",
"Public sharing is disabled at the workspace level": "ワークスペースレベルで公開共有が無効になっています", "Public sharing is disabled at the workspace level": "ワークスペースレベルで公開共有が無効になっています",
"Prevent pages in this space from being shared publicly.": "このスペース内のページが公開で共有されるのを防ぐ。", "Prevent pages in this space from being shared publicly.": "このスペース内のページが公開で共有されるのを防ぐ。",
"Page permissions": "ページのアクセス権", "Page permissions": "ページのアクセス権",
@@ -341,7 +341,6 @@
"Insert horizontal rule divider": "가로 구분선 삽입", "Insert horizontal rule divider": "가로 구분선 삽입",
"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 audio from your device.": "기기에서 오디오를 업로드하세요.",
"Upload any file from your device.": "기기에서 파일을 업로드하세요.", "Upload any file from your device.": "기기에서 파일을 업로드하세요.",
"Uploading {{name}}": "{{name}} 업로드 중", "Uploading {{name}}": "{{name}} 업로드 중",
"Uploading file": "파일 업로드 중", "Uploading file": "파일 업로드 중",
@@ -352,12 +351,6 @@
"Divider": "구분선", "Divider": "구분선",
"Quote": "인용", "Quote": "인용",
"Image": "이미지", "Image": "이미지",
"Audio": "오디오.",
"Embed PDF": "PDF 임베드",
"Upload and embed a PDF file.": "PDF 파일을 업로드하고 임베드하세요.",
"Embed as PDF": "PDF로 임베드",
"Failed to load PDF": "PDF 로드 실패",
"Convert to attachment": "첨부 파일로 변환",
"File attachment": "파일 첨부", "File attachment": "파일 첨부",
"Toggle block": "블록 토글", "Toggle block": "블록 토글",
"Callout": "경고 상자", "Callout": "경고 상자",
@@ -449,9 +442,6 @@
"Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.", "Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.",
"Toggle public sharing": "공유 전환", "Toggle public sharing": "공유 전환",
"Toggle space public sharing": "공간 공유 전환", "Toggle space public sharing": "공간 공유 전환",
"Allow viewers to comment": "뷰어가 댓글을 달 수 있도록 허용",
"Allow viewers to add comments on pages in this space.": "이 공간 내 페이지에 뷰어가 댓글을 추가할 수 있도록 허용합니다.",
"Toggle viewer comments": "뷰어 댓글 전환",
"Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.", "Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
"Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.", "Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.",
"Page permissions": "페이지 권한},{", "Page permissions": "페이지 권한},{",
@@ -341,7 +341,6 @@
"Insert horizontal rule divider": "Horizontale lijn invoegen", "Insert horizontal rule divider": "Horizontale lijn invoegen",
"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 audio from your device.": "Upload een audio 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 {{name}}": "Uploaden {{name}}",
"Uploading file": "Bestand uploaden", "Uploading file": "Bestand uploaden",
@@ -352,12 +351,6 @@
"Divider": "Scheidingslijn", "Divider": "Scheidingslijn",
"Quote": "Quote", "Quote": "Quote",
"Image": "Afbeelding", "Image": "Afbeelding",
"Audio": "Audio.",
"Embed PDF": "PDF insluiten",
"Upload and embed a PDF file.": "Upload en sluit een PDF-bestand in.",
"Embed as PDF": "Insluiten als PDF",
"Failed to load PDF": "Laden van PDF mislukt",
"Convert to attachment": "Converteren naar bijlage",
"File attachment": "Bestand bijlage", "File attachment": "Bestand bijlage",
"Toggle block": "Schakel blok in/uit", "Toggle block": "Schakel blok in/uit",
"Callout": "Opmerking", "Callout": "Opmerking",
@@ -449,9 +442,6 @@
"Prevent members from sharing pages publicly.": "Voorkom dat leden pagina's openbaar delen.", "Prevent members from sharing pages publicly.": "Voorkom dat leden pagina's openbaar delen.",
"Toggle public sharing": "Wissel openbaar delen", "Toggle public sharing": "Wissel openbaar delen",
"Toggle space public sharing": "Wissel openbaar delen van ruimte", "Toggle space public sharing": "Wissel openbaar delen van ruimte",
"Allow viewers to comment": "Toestaan dat kijkers reageren",
"Allow viewers to add comments on pages in this space.": "Sta kijkers toe om reacties toe te voegen op pagina\u0019s in deze ruimte.",
"Toggle viewer comments": "Reacties van kijkers in- of uitschakelen",
"Public sharing is disabled at the workspace level": "Openbaar delen is uitgeschakeld op werkruimteniveau", "Public sharing is disabled at the workspace level": "Openbaar delen is uitgeschakeld op werkruimteniveau",
"Prevent pages in this space from being shared publicly.": "Voorkom dat pagina's in deze ruimte openbaar worden gedeeld.", "Prevent pages in this space from being shared publicly.": "Voorkom dat pagina's in deze ruimte openbaar worden gedeeld.",
"Page permissions": "Pagina rechten", "Page permissions": "Pagina rechten",
@@ -341,7 +341,6 @@
"Insert horizontal rule divider": "Insira um divisor horizontal", "Insert horizontal rule divider": "Insira um divisor horizontal",
"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 audio from your device.": "Envie qualquer áudio 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 {{name}}": "Enviando {{name}}",
"Uploading file": "Enviando arquivo", "Uploading file": "Enviando arquivo",
@@ -352,12 +351,6 @@
"Divider": "Divisor", "Divider": "Divisor",
"Quote": "Citação", "Quote": "Citação",
"Image": "Imagem", "Image": "Imagem",
"Audio": "Áudio.",
"Embed PDF": "Incorporar PDF",
"Upload and embed a PDF file.": "Envie e incorpore um arquivo PDF.",
"Embed as PDF": "Incorporar como PDF",
"Failed to load PDF": "Falha ao carregar PDF",
"Convert to attachment": "Converter em anexo",
"File attachment": "Anexo de arquivo", "File attachment": "Anexo de arquivo",
"Toggle block": "Bloco colapsável", "Toggle block": "Bloco colapsável",
"Callout": "Aviso", "Callout": "Aviso",
@@ -449,9 +442,6 @@
"Prevent members from sharing pages publicly.": "Impedir que os membros compartilhem páginas publicamente.", "Prevent members from sharing pages publicly.": "Impedir que os membros compartilhem páginas publicamente.",
"Toggle public sharing": "Alternar compartilhamento público", "Toggle public sharing": "Alternar compartilhamento público",
"Toggle space public sharing": "Alternar compartilhamento público do espaço", "Toggle space public sharing": "Alternar compartilhamento público do espaço",
"Allow viewers to comment": "Permitir que os visualizadores comentem",
"Allow viewers to add comments on pages in this space.": "Permitir que os visualizadores adicionem comentários em páginas deste espaço.",
"Toggle viewer comments": "Ativar/desativar comentários de visualizadores",
"Public sharing is disabled at the workspace level": "O compartilhamento público está desativado no nível do espaço de trabalho", "Public sharing is disabled at the workspace level": "O compartilhamento público está desativado no nível do espaço de trabalho",
"Prevent pages in this space from being shared publicly.": "Impedir que as páginas neste espaço sejam compartilhadas publicamente.", "Prevent pages in this space from being shared publicly.": "Impedir que as páginas neste espaço sejam compartilhadas publicamente.",
"Page permissions": "Permissões da página},{", "Page permissions": "Permissões da página},{",
@@ -341,7 +341,6 @@
"Insert horizontal rule divider": "Вставить горизонтальный разделитель", "Insert horizontal rule divider": "Вставить горизонтальный разделитель",
"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 audio from your device.": "Загрузите любой аудиофайл с вашего устройства.",
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.", "Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
"Uploading {{name}}": "Загрузка {{name}}", "Uploading {{name}}": "Загрузка {{name}}",
"Uploading file": "Загрузка файла", "Uploading file": "Загрузка файла",
@@ -352,12 +351,6 @@
"Divider": "Разделитель", "Divider": "Разделитель",
"Quote": "Цитата", "Quote": "Цитата",
"Image": "Изображение", "Image": "Изображение",
"Audio": "Аудио.",
"Embed PDF": "Встроить PDF",
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
"Embed as PDF": "Встроить как PDF",
"Failed to load PDF": "Не удалось загрузить PDF",
"Convert to attachment": "Преобразовать в вложение",
"File attachment": "Прикрепленный файл", "File attachment": "Прикрепленный файл",
"Toggle block": "Сворачиваемый блок", "Toggle block": "Сворачиваемый блок",
"Callout": "Выноска", "Callout": "Выноска",
@@ -449,9 +442,6 @@
"Prevent members from sharing pages publicly.": "Запретить участникам делиться страницами публично.", "Prevent members from sharing pages publicly.": "Запретить участникам делиться страницами публично.",
"Toggle public sharing": "Переключить общий доступ", "Toggle public sharing": "Переключить общий доступ",
"Toggle space public sharing": "Переключить общий доступ для пространства", "Toggle space public sharing": "Переключить общий доступ для пространства",
"Allow viewers to comment": "Разрешить зрителям комментировать",
"Allow viewers to add comments on pages in this space.": "Разрешить зрителям добавлять комментарии на страницах в этом пространстве.",
"Toggle viewer comments": "Переключить комментарии зрителей",
"Public sharing is disabled at the workspace level": "Общий доступ отключен на уровне рабочего пространства", "Public sharing is disabled at the workspace level": "Общий доступ отключен на уровне рабочего пространства",
"Prevent pages in this space from being shared publicly.": "Запретить делиться страницами в этом пространстве публично.", "Prevent pages in this space from being shared publicly.": "Запретить делиться страницами в этом пространстве публично.",
"Page permissions": "Права доступа к странице},{", "Page permissions": "Права доступа к странице},{",
@@ -341,7 +341,6 @@
"Insert horizontal rule divider": "Вставити горизонтальний роздільник", "Insert horizontal rule divider": "Вставити горизонтальний роздільник",
"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 audio from your device.": "Завантажте будь-який аудіофайл зі свого пристрою.",
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.", "Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
"Uploading {{name}}": "Завантаження {{name}}", "Uploading {{name}}": "Завантаження {{name}}",
"Uploading file": "Завантаження файлу", "Uploading file": "Завантаження файлу",
@@ -352,12 +351,6 @@
"Divider": "Роздільник", "Divider": "Роздільник",
"Quote": "Цитата", "Quote": "Цитата",
"Image": "Зображення", "Image": "Зображення",
"Audio": "Аудіо.",
"Embed PDF": "Вбудувати PDF",
"Upload and embed a PDF file.": "Завантажте та вбудуйте файл PDF.",
"Embed as PDF": "Вбудувати як PDF",
"Failed to load PDF": "Не вдалося завантажити PDF",
"Convert to attachment": "Перетворити на вкладення",
"File attachment": "Прикріплений файл", "File attachment": "Прикріплений файл",
"Toggle block": "Блок, що згортається", "Toggle block": "Блок, що згортається",
"Callout": "Виноска", "Callout": "Виноска",
@@ -449,9 +442,6 @@
"Prevent members from sharing pages publicly.": "Перешкодити учасникам публічно ділитися сторінками.", "Prevent members from sharing pages publicly.": "Перешкодити учасникам публічно ділитися сторінками.",
"Toggle public sharing": "Перемикання публічного доступу", "Toggle public sharing": "Перемикання публічного доступу",
"Toggle space public sharing": "Перемикання публічного доступу до просторів", "Toggle space public sharing": "Перемикання публічного доступу до просторів",
"Allow viewers to comment": "Дозволити глядачам коментувати",
"Allow viewers to add comments on pages in this space.": "Дозволити глядачам додавати коментарі на сторінках у цьому просторі.",
"Toggle viewer comments": "Увімкнути або вимкнути коментарі глядачів",
"Public sharing is disabled at the workspace level": "Публічний доступ вимкнуто на рівні робочого простору", "Public sharing is disabled at the workspace level": "Публічний доступ вимкнуто на рівні робочого простору",
"Prevent pages in this space from being shared publicly.": "Перешкодити публічному поширенню сторінок у цьому просторі.", "Prevent pages in this space from being shared publicly.": "Перешкодити публічному поширенню сторінок у цьому просторі.",
"Page permissions": "Права доступу до сторінки.", "Page permissions": "Права доступу до сторінки.",
@@ -341,7 +341,6 @@
"Insert horizontal rule divider": "插入水平分割线", "Insert horizontal rule divider": "插入水平分割线",
"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 audio from your device.": "从您的设备上传任意音频文件。",
"Upload any file from your device.": "从设备上传任何文件", "Upload any file from your device.": "从设备上传任何文件",
"Uploading {{name}}": "正在上传{{name}}", "Uploading {{name}}": "正在上传{{name}}",
"Uploading file": "正在上传文件", "Uploading file": "正在上传文件",
@@ -352,12 +351,6 @@
"Divider": "分割线", "Divider": "分割线",
"Quote": "引用", "Quote": "引用",
"Image": "图像", "Image": "图像",
"Audio": "音频。",
"Embed PDF": "嵌入 PDF",
"Upload and embed a PDF file.": "上传并嵌入 PDF 文件。",
"Embed as PDF": "作为 PDF 嵌入",
"Failed to load PDF": "加载 PDF 失败",
"Convert to attachment": "转换为附件",
"File attachment": "文件附件", "File attachment": "文件附件",
"Toggle block": "切换块", "Toggle block": "切换块",
"Callout": "标注块", "Callout": "标注块",
@@ -449,9 +442,6 @@
"Prevent members from sharing pages publicly.": "阻止成员公开分享页面。", "Prevent members from sharing pages publicly.": "阻止成员公开分享页面。",
"Toggle public sharing": "切换公开分享", "Toggle public sharing": "切换公开分享",
"Toggle space public sharing": "切换空间公开分享", "Toggle space public sharing": "切换空间公开分享",
"Allow viewers to comment": "允许观众评论",
"Allow viewers to add comments on pages in this space.": "允许观众在此空间的页面上添加评论。",
"Toggle viewer comments": "切换观众评论",
"Public sharing is disabled at the workspace level": "公开分享在工作区级别被禁用", "Public sharing is disabled at the workspace level": "公开分享在工作区级别被禁用",
"Prevent pages in this space from being shared publicly.": "阻止此空间中的页面被公开分享。", "Prevent pages in this space from being shared publicly.": "阻止此空间中的页面被公开分享。",
"Page permissions": "页面权限},{", "Page permissions": "页面权限},{",
-1
View File
@@ -16,5 +16,4 @@ export const Feature = {
AUDIT_LOGS: 'audit:logs', AUDIT_LOGS: 'audit:logs',
RETENTION: 'retention', RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls', SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
} as const; } as const;
@@ -1,61 +0,0 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
type SpaceViewerCommentsToggleProps = {
space: ISpace;
};
export default function SpaceViewerCommentsToggle({
space,
}: SpaceViewerCommentsToggleProps) {
const { t } = useTranslation();
const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS);
const upgradeLabel = useUpgradeLabel();
const isDisabled = !hasViewerComments;
const [checked, setChecked] = useState(
space.settings?.comments?.allowViewerComments === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
allowViewerComments: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Allow viewers to comment")}</Text>
<Text size="sm" c="dimmed">
{t("Allow viewers to add comments on pages in this space.")}
</Text>
</div>
<Tooltip
label={upgradeLabel}
disabled={!isDisabled}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={isDisabled}
aria-label={t("Toggle viewer comments")}
/>
</Tooltip>
</Group>
);
}
@@ -3,15 +3,3 @@ 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,8 +6,6 @@ import {
activeCommentIdAtom, activeCommentIdAtom,
draftCommentIdAtom, draftCommentIdAtom,
showCommentPopupAtom, showCommentPopupAtom,
showReadOnlyCommentPopupAtom,
readOnlyCommentDataAtom,
} from "@/features/comment/atoms/comment-atom"; } from "@/features/comment/atoms/comment-atom";
import CommentEditor from "@/features/comment/components/comment-editor"; import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions"; import CommentActions from "@/features/comment/components/comment-actions";
@@ -21,15 +19,12 @@ import { useTranslation } from "react-i18next";
interface CommentDialogProps { interface CommentDialogProps {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
pageId: string; pageId: string;
readOnly?: boolean;
} }
function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) { function CommentDialog({ editor, pageId }: CommentDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom); const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
@@ -39,17 +34,11 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
handleDialogClose(); handleDialogClose();
}); });
const createCommentMutation = useCreateCommentMutation(); const createCommentMutation = useCreateCommentMutation();
const isPending = createCommentMutation.isPending; const { isPending } = createCommentMutation;
const handleDialogClose = () => { const handleDialogClose = () => {
if (readOnly) { setShowCommentPopup(false);
setShowReadOnlyCommentPopup(false); editor.chain().focus().unsetCommentDecoration().run();
// @ts-ignore
setReadOnlyCommentData(null);
} else {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
}
}; };
const getSelectedText = () => { const getSelectedText = () => {
@@ -58,11 +47,6 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
}; };
const handleAddComment = async () => { const handleAddComment = async () => {
if (readOnly) {
await handleAddReadOnlyComment();
return;
}
try { try {
const selectedText = getSelectedText(); const selectedText = getSelectedText();
const commentData = { const commentData = {
@@ -81,6 +65,7 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
.run(); .run();
setActiveCommentId(createdComment.id); setActiveCommentId(createdComment.id);
//unselect text to close bubble menu
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from }); editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
setAsideState({ tab: "comments", isAsideOpen: true }); setAsideState({ tab: "comments", isAsideOpen: true });
@@ -100,33 +85,6 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
} }
}; };
const handleAddReadOnlyComment = async () => {
if (!readOnlyCommentData) return;
try {
const createdComment = await createCommentMutation.mutateAsync({
pageId,
content: JSON.stringify(comment),
selection: readOnlyCommentData.selectedText,
type: "inline",
yjsSelection: readOnlyCommentData.yjsSelection,
});
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);
} finally {
setShowReadOnlyCommentPopup(false);
// @ts-ignore
setReadOnlyCommentData(null);
}
};
const handleCommentEditorChange = (newContent: any) => { const handleCommentEditorChange = (newContent: any) => {
setComment(newContent); setComment(newContent);
}; };
@@ -44,9 +44,7 @@ function CommentListWithTabs() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canComment = const canComment = page?.permissions?.canEdit ?? false;
(page?.permissions?.canEdit ?? false) ||
(space?.settings?.comments?.allowViewerComments === true);
// Separate active and resolved comments // Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => { const { activeComments, resolvedComments } = useMemo(() => {
@@ -155,7 +153,7 @@ function CommentListWithTabs() {
)} )}
</Paper> </Paper>
), ),
[comments, handleAddReply, isLoading, space?.membership?.role, canComment], [comments, handleAddReply, isLoading, space?.membership?.role],
); );
if (isCommentsLoading) { if (isCommentsLoading) {
@@ -75,7 +75,7 @@ function CommentMenu({
{isResolved ? t("Re-open comment") : t("Resolve comment")} {isResolved ? t("Re-open comment") : t("Resolve comment")}
</Menu.Item> </Menu.Item>
) : ( ) : (
<Tooltip label={upgradeLabel} position="left" withinPortal={false}> <Tooltip label={upgradeLabel} position="left">
<Menu.Item disabled leftSection={<IconCircleCheck size={14} />}> <Menu.Item disabled leftSection={<IconCircleCheck size={14} />}>
{t("Resolve comment")} {t("Resolve comment")}
</Menu.Item> </Menu.Item>
@@ -17,10 +17,6 @@ export interface IComment {
deletedAt?: Date; deletedAt?: Date;
creator: IUser; creator: IUser;
resolvedBy?: IUser; resolvedBy?: IUser;
yjsSelection?: {
anchor: any;
head: any;
};
} }
export interface ICommentData { export interface ICommentData {
@@ -1,159 +0,0 @@
import type { Editor } from "@tiptap/react";
import { TextSelection } from "@tiptap/pm/state";
import { FC, useCallback, useEffect, useRef, useState } from "react";
import { IconMessage } from "@tabler/icons-react";
import classes from "./bubble-menu.module.css";
import { ActionIcon, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import {
showReadOnlyCommentPopupAtom,
readOnlyCommentDataAtom,
} from "@/features/comment/atoms/comment-atom";
import { useTranslation } from "react-i18next";
import { getRelativeSelection, ySyncPluginKey } from "@tiptap/y-tiptap";
type ReadonlyBubbleMenuProps = {
editor: Editor;
};
export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
const { t } = useTranslation();
const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom(
showReadOnlyCommentPopupAtom,
);
const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
const menuRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const isInteractingRef = useRef(false);
const updateMenuPosition = useCallback(() => {
if (isInteractingRef.current) return;
const pmSelection = editor.state.selection;
if (!(pmSelection instanceof TextSelection) || pmSelection.empty) {
setVisible(false);
return;
}
const selection = window.getSelection();
if (
!selection ||
selection.isCollapsed ||
selection.rangeCount === 0 ||
showReadOnlyCommentPopup
) {
setVisible(false);
return;
}
const editorDom = editor.view.dom;
if (
!editorDom.contains(selection.anchorNode) ||
!editorDom.contains(selection.focusNode)
) {
setVisible(false);
return;
}
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.width === 0) {
setVisible(false);
return;
}
const editorRect = editorDom
.closest(".editor-container")
?.getBoundingClientRect();
if (!editorRect) {
setVisible(false);
return;
}
setPosition({
top: rect.top - editorRect.top - 44,
left: rect.left - editorRect.left + rect.width / 2,
});
setVisible(true);
}, [editor, showReadOnlyCommentPopup]);
useEffect(() => {
const handleSelectionChange = () => {
updateMenuPosition();
};
document.addEventListener("selectionchange", handleSelectionChange);
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
};
}, [updateMenuPosition]);
useEffect(() => {
if (showReadOnlyCommentPopup) {
setVisible(false);
}
}, [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);
setVisible(false);
}
};
if (!visible) return null;
return (
<div
ref={menuRef}
style={{
position: "absolute",
top: position.top,
left: position.left,
transform: "translateX(-50%)",
zIndex: 199,
}}
>
<div className={classes.bubbleMenu}>
<Tooltip label={t("Comment")} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
radius="6px"
aria-label={t("Comment")}
style={{ border: "none" }}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
isInteractingRef.current = true;
handleCommentClick();
isInteractingRef.current = false;
}}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
</div>
</div>
);
};
@@ -34,7 +34,7 @@ export const TableMenu = React.memo(
if (isTextSelected(editor)) return false; if (isTextSelected(editor)) return false;
return editor.isActive("table") && !isCellSelection(state.selection); return editor.isActive("table") && !isCellSelection(state.selection);
}, },
[editor], [editor]
); );
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
@@ -121,11 +121,7 @@ export const TableMenu = React.memo(
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div className={classes.toolbar}> <div className={classes.toolbar}>
<Tooltip <Tooltip position="top" label={t("Add left column")}>
position="top"
label={t("Add left column")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={addColumnLeft} onClick={addColumnLeft}
variant="subtle" variant="subtle"
@@ -136,11 +132,7 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip <Tooltip position="top" label={t("Add right column")}>
position="top"
label={t("Add right column")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={addColumnRight} onClick={addColumnRight}
variant="subtle" variant="subtle"
@@ -151,11 +143,7 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip <Tooltip position="top" label={t("Delete column")}>
position="top"
label={t("Delete column")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={deleteColumn} onClick={deleteColumn}
variant="subtle" variant="subtle"
@@ -168,11 +156,7 @@ export const TableMenu = React.memo(
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip <Tooltip position="top" label={t("Add row above")}>
position="top"
label={t("Add row above")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={addRowAbove} onClick={addRowAbove}
variant="subtle" variant="subtle"
@@ -183,11 +167,7 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip <Tooltip position="top" label={t("Add row below")}>
position="top"
label={t("Add row below")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={addRowBelow} onClick={addRowBelow}
variant="subtle" variant="subtle"
@@ -198,7 +178,7 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Delete row")} withinPortal={false}> <Tooltip position="top" label={t("Delete row")}>
<ActionIcon <ActionIcon
onClick={deleteRow} onClick={deleteRow}
variant="subtle" variant="subtle"
@@ -211,11 +191,7 @@ export const TableMenu = React.memo(
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip <Tooltip position="top" label={t("Toggle header row")}>
position="top"
label={t("Toggle header row")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={toggleHeaderRow} onClick={toggleHeaderRow}
variant="subtle" variant="subtle"
@@ -226,11 +202,7 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip <Tooltip position="top" label={t("Toggle header column")}>
position="top"
label={t("Toggle header column")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={toggleHeaderColumn} onClick={toggleHeaderColumn}
variant="subtle" variant="subtle"
@@ -243,11 +215,7 @@ export const TableMenu = React.memo(
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip <Tooltip position="top" label={t("Delete table")}>
position="top"
label={t("Delete table")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={deleteTable} onClick={deleteTable}
variant="subtle" variant="subtle"
@@ -260,7 +228,7 @@ export const TableMenu = React.memo(
</div> </div>
</BubbleMenu> </BubbleMenu>
); );
}, }
); );
export default TableMenu; export default TableMenu;
@@ -1,105 +0,0 @@
// https://github.com/NiclasDev63/tiptap-extension-auto-joiner - MIT
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { canJoin } from "@tiptap/pm/transform";
import { getNodeType } from "@tiptap/react";
import { NodeType } from "@tiptap/pm/model";
import { Transaction } from "@tiptap/pm/state";
// https://discuss.prosemirror.net/t/how-to-autojoin-all-the-time/2957/4
// Adapted from prosemirror-commands wrapDispatchForJoin
function autoJoin(
transactions: readonly Transaction[],
newTr: Transaction,
nodeTypes: NodeType[]
) {
// Collect changed ranges across all transactions, mapping earlier ranges
// forward through later mappings so every position lands in newTr.doc space.
let ranges: number[] = [];
for (const tr of transactions) {
for (let i = 0; i < tr.mapping.maps.length; i++) {
let map = tr.mapping.maps[i];
if (!map) continue;
for (let j = 0; j < ranges.length; j++) ranges[j] = map.map(ranges[j]!);
map.forEach((_s, _e, from, to) => ranges.push(from, to));
}
}
// Figure out which joinable points exist inside those ranges,
// by checking all node boundaries in their parent nodes.
// Resolve against newTr.doc — the same document we will join on.
let joinable: number[] = [];
for (let i = 0; i < ranges.length; i += 2) {
let from = ranges[i]!,
to = ranges[i + 1]!;
let $from = newTr.doc.resolve(from),
depth = $from.sharedDepth(to),
parent = $from.node(depth);
for (
let index = $from.indexAfter(depth), pos = $from.after(depth + 1);
pos <= to;
++index
) {
let after = parent.maybeChild(index);
if (!after) break;
if (index && joinable.indexOf(pos) == -1) {
let before = parent.child(index - 1);
if (before.type == after.type && nodeTypes.includes(before.type))
joinable.push(pos);
}
pos += after.nodeSize;
}
}
// Join the joinable points (reverse order to preserve earlier positions)
let joined = false;
joinable.sort((a, b) => a - b);
for (let i = joinable.length - 1; i >= 0; i--) {
if (canJoin(newTr.doc, joinable[i]!)) {
newTr.join(joinable[i]!);
joined = true;
}
}
return joined;
}
export interface AutoJoinerOptions {
elementsToJoin: string[];
}
const AutoJoiner = Extension.create<AutoJoinerOptions>({
name: "autoJoiner",
addOptions() {
return {
elementsToJoin: [],
};
},
addProseMirrorPlugins() {
const plugin = new PluginKey(this.name);
const joinableNodes = [
this.editor.schema.nodes.bulletList,
this.editor.schema.nodes.orderedList,
];
this.options.elementsToJoin.forEach((element) => {
const nodeTyp = getNodeType(element, this.editor.schema);
joinableNodes.push(nodeTyp);
});
return [
new Plugin({
key: plugin,
appendTransaction(transactions, _, newState) {
let newTr = newState.tr;
if (autoJoin(transactions, newTr, joinableNodes as NodeType[])) {
return newTr;
}
},
}),
];
},
});
export default AutoJoiner;
@@ -49,7 +49,7 @@ import {
SharedStorage, SharedStorage,
Columns, Columns,
Column, Column,
Status, Status
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -97,7 +97,6 @@ import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command"; import EmojiCommand from "./emoji-command";
import { countWords } from "alfaaz"; import { countWords } from "alfaaz";
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext); lowlight.register("mermaid", plaintext);
@@ -354,9 +353,6 @@ export const mainExtensions = [
}).configure(), }).configure(),
Columns, Columns,
Column, Column,
AutoJoiner.configure({
elementsToJoin: [],
}),
] as any; ] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@@ -1,9 +1,9 @@
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT // adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
import { Extension } from "@tiptap/core"; import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { Plugin, PluginKey } from "@tiptap/pm/state";
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model"; import { DOMParser } from "@tiptap/pm/model";
import { find } from "linkifyjs"; import { find } from "linkifyjs";
import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext"; import { markdownToHtml } from "@docmost/editor-ext";
export const MarkdownClipboard = Extension.create({ export const MarkdownClipboard = Extension.create({
name: "markdownClipboard", name: "markdownClipboard",
@@ -19,27 +19,6 @@ export const MarkdownClipboard = Extension.create({
new Plugin({ new Plugin({
key: new PluginKey("markdownClipboard"), key: new PluginKey("markdownClipboard"),
props: { props: {
clipboardTextSerializer: (slice) => {
const listTypes = ["bulletList", "orderedList", "taskList"];
let topLevelCount = 0;
let hasList = false;
slice.content.forEach((node) => {
if (listTypes.includes(node.type.name)) {
hasList = true;
topLevelCount += node.childCount;
} else {
topLevelCount++;
}
});
if (!hasList || topLevelCount < 2) return null;
const div = document.createElement("div");
const serializer = DOMSerializer.fromSchema(this.editor.schema);
const fragment = serializer.serializeFragment(slice.content);
div.appendChild(fragment);
return htmlToMarkdown(div.innerHTML);
},
handlePaste: (view, event, slice) => { handlePaste: (view, event, slice) => {
if (!event.clipboardData) { if (!event.clipboardData) {
return false; return false;
@@ -50,80 +29,49 @@ export const MarkdownClipboard = Extension.create({
} }
const text = event.clipboardData.getData("text/plain"); const text = event.clipboardData.getData("text/plain");
const html = event.clipboardData.getData("text/html");
const vscode = event.clipboardData.getData("vscode-editor-data"); const vscode = event.clipboardData.getData("vscode-editor-data");
const vscodeData = vscode ? JSON.parse(vscode) : undefined; const vscodeData = vscode ? JSON.parse(vscode) : undefined;
const language = vscodeData?.mode; const language = vscodeData?.mode;
const isVscodeMarkdown = language === "markdown"; if (language !== "markdown") {
const isPlainTextOnly = !html && !vscode && !!text;
if (!isVscodeMarkdown && !isPlainTextOnly) {
return false; return false;
} }
if (isPlainTextOnly) {
if ((view as any).input?.shiftKey || !this.options.transformPastedText) {
return false;
}
const link = find(text, {
defaultProtocol: "http",
}).find((item) => item.isLink && item.value === text);
if (link) {
return false;
}
}
const { tr } = view.state; const { tr } = view.state;
const { from, to } = view.state.selection; const { from, to } = view.state.selection;
const parsed = markdownToHtml(text.replace(/\n+$/, "")); const html = markdownToHtml(text);
const contentNodes = DOMParser.fromSchema( const contentNodes = DOMParser.fromSchema(
this.editor.schema, this.editor.schema,
).parseSlice(elementFromString(parsed), { ).parseSlice(elementFromString(html), {
preserveWhitespace: true, preserveWhitespace: true,
}); });
tr.replaceRange(from, to, contentNodes); tr.replaceRange(from, to, contentNodes);
const insertEnd = tr.mapping.map(from, 1);
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
tr.setMeta('paste', true) tr.setMeta('paste', true)
view.dispatch(tr); view.dispatch(tr);
return true; return true;
}, },
// Strip trailing whitespace-only paragraphs from pasted content. clipboardTextParser: (text, context, plainText) => {
// Terminals (GNOME Terminal, etc.) often include trailing const link = find(text, {
// whitespace in their HTML clipboard data, which ProseMirror defaultProtocol: "http",
// parses as an extra paragraph. Inside a list item this creates }).find((item) => item.isLink && item.value === text);
// an orphan empty line that breaks the list structure.
transformPasted: (slice) => {
let { content, openStart, openEnd } = slice;
// Remove trailing paragraphs that contain only whitespace if (plainText || !this.options.transformPastedText || link) {
while (content.childCount > 1) { // don't parse plaintext link to allow link paste handler to work
const lastChild = content.lastChild; // pasting with shift key prevents formatting
if ( return null;
lastChild?.type.name === "paragraph" &&
lastChild.textContent.trim() === ""
) {
const children = [];
for (let i = 0; i < content.childCount - 1; i++) {
children.push(content.child(i));
}
content = Fragment.from(children);
} else {
break;
}
} }
if (content !== slice.content) { const parsed = markdownToHtml(text);
return new Slice(content, openStart, Math.max(openEnd, 1)); return DOMParser.fromSchema(this.editor.schema).parseSlice(
} elementFromString(parsed),
{
return slice; preserveWhitespace: true,
context,
},
);
}, },
}, },
}), }),
@@ -16,7 +16,6 @@ export interface FullEditorProps {
content: string; content: string;
spaceSlug: string; spaceSlug: string;
editable: boolean; editable: boolean;
canComment?: boolean;
} }
export function FullEditor({ export function FullEditor({
@@ -26,7 +25,6 @@ export function FullEditor({
content, content,
spaceSlug, spaceSlug,
editable, editable,
canComment,
}: FullEditorProps) { }: FullEditorProps) {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const fullPageWidth = user.settings?.preferences?.fullPageWidth; const fullPageWidth = user.settings?.preferences?.fullPageWidth;
@@ -48,7 +46,6 @@ export function FullEditor({
pageId={pageId} pageId={pageId}
editable={editable} editable={editable}
content={content} content={content}
canComment={canComment}
/> />
</Container> </Container>
); );
@@ -37,11 +37,9 @@ 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/readonly-bubble-menu";
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";
@@ -76,14 +74,12 @@ interface PageEditorProps {
pageId: string; pageId: string;
editable: boolean; editable: boolean;
content: any; content: any;
canComment?: boolean;
} }
export default function PageEditor({ export default function PageEditor({
pageId, pageId,
editable, editable,
content, content,
canComment,
}: PageEditorProps) { }: PageEditorProps) {
const collaborationURL = useCollaborationUrl(); const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
@@ -98,7 +94,6 @@ 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 [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false); const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false); const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@@ -428,13 +423,7 @@ export default function PageEditor({
<ColumnsMenu editor={editor} /> <ColumnsMenu editor={editor} />
</div> </div>
)} )}
{editor && !editorIsEditable && (editable || canComment) && providersRef.current && (
<ReadonlyBubbleMenu editor={editor} />
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />} {showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly />
)}
</div> </div>
<div <div
onClick={() => editor.commands.focus("end")} onClick={() => editor.commands.focus("end")}
@@ -13,7 +13,7 @@ import {
import { CustomAvatar } from "@/components/ui/custom-avatar"; import { CustomAvatar } from "@/components/ui/custom-avatar";
import { INotification } from "../types/notification.types"; import { INotification } from "../types/notification.types";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useState } from "react"; import { useState } from "react";
import { useMarkReadMutation } from "../queries/notification-query"; import { useMarkReadMutation } from "../queries/notification-query";
import { buildPageUrl } from "@/features/page/page.utils"; import { buildPageUrl } from "@/features/page/page.utils";
@@ -30,6 +30,7 @@ export function NotificationItem({
onNavigate, onNavigate,
}: NotificationItemProps) { }: NotificationItemProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const markRead = useMarkReadMutation(); const markRead = useMarkReadMutation();
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
@@ -54,39 +55,32 @@ export function NotificationItem({
} }
}; };
const pageUrl = const handleClick = () => {
notification.page && notification.space if (notification.page && notification.space) {
? buildPageUrl( if (isUnread) {
markRead.mutate([notification.id]);
}
navigate(
buildPageUrl(
notification.space.slug, notification.space.slug,
notification.page.slugId, notification.page.slugId,
notification.page.title, notification.page.title,
) ),
: undefined; );
onNavigate();
}
};
const markReadIfNeeded = () => { const handleMarkRead = (e: React.MouseEvent) => {
e.stopPropagation();
if (isUnread) { if (isUnread) {
markRead.mutate([notification.id]); markRead.mutate([notification.id]);
} }
}; };
const handleClick = () => {
markReadIfNeeded();
onNavigate();
};
const handleMarkRead = (e: React.MouseEvent) => {
e.stopPropagation();
markReadIfNeeded();
};
return ( return (
<UnstyledButton <UnstyledButton
component={Link}
to={pageUrl ?? ""}
onClick={handleClick} onClick={handleClick}
// auxclick fires for all non-primary buttons; guard to middle-click only (button 1)
// so that right-click (button 2, context menu) does not mark as read
onAuxClick={(e: React.MouseEvent) => e.button === 1 && markReadIfNeeded()}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
w="100%" w="100%"
@@ -1,9 +1,7 @@
.notificationItem { .notificationItem {
display: block;
padding: 8px 12px; padding: 8px 12px;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
user-select: none;
} }
.notificationItem:hover { .notificationItem:hover {
@@ -3,7 +3,6 @@ import SpaceMembersList from "@/features/space/components/space-members.tsx";
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx"; import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
import React from "react"; import React from "react";
import SpaceDetails from "@/features/space/components/space-details.tsx"; import SpaceDetails from "@/features/space/components/space-details.tsx";
import SpaceSecuritySettings from "@/features/space/components/space-security-settings.tsx";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import { import {
@@ -60,14 +59,6 @@ export default function SpaceSettingsModal({
<Tabs.Tab fw={500} value="members"> <Tabs.Tab fw={500} value="members">
{t("Members")} {t("Members")}
</Tabs.Tab> </Tabs.Tab>
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
) && (
<Tabs.Tab fw={500} value="security">
{t("Security")}
</Tabs.Tab>
)}
</Tabs.List> </Tabs.List>
<Tabs.Panel value="general"> <Tabs.Panel value="general">
@@ -100,20 +91,6 @@ export default function SpaceSettingsModal({
)} )}
/> />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="security">
<ScrollArea h={580} scrollbarSize={5} pr={8}>
<div style={{ paddingBottom: "100px" }}>
<SpaceSecuritySettings
space={space}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
</div>
</ScrollArea>
</Tabs.Panel>
</Tabs> </Tabs>
</div> </div>
</Modal.Body> </Modal.Body>
@@ -18,7 +18,7 @@ import {
ResponsiveSettingsControl, ResponsiveSettingsControl,
ResponsiveSettingsRow, ResponsiveSettingsRow,
} from "@/components/ui/responsive-settings-row.tsx"; } from "@/components/ui/responsive-settings-row.tsx";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
interface SpaceDetailsProps { interface SpaceDetailsProps {
spaceId: string; spaceId: string;
@@ -27,6 +27,7 @@ interface SpaceDetailsProps {
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId); const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
const showSharingToggle = !readOnly;
const [exportOpened, { open: openExportModal, close: closeExportModal }] = const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false); useDisclosure(false);
const [isIconUploading, setIsIconUploading] = useState(false); const [isIconUploading, setIsIconUploading] = useState(false);
@@ -88,6 +89,13 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<EditSpaceForm space={space} readOnly={readOnly} /> <EditSpaceForm space={space} readOnly={readOnly} />
{showSharingToggle && (
<>
<Divider my="lg" />
<SpacePublicSharingToggle space={space} />
</>
)}
{!readOnly && ( {!readOnly && (
<> <>
<Divider my="lg" /> <Divider my="lg" />
@@ -1,34 +0,0 @@
import { Text, Divider } from "@mantine/core";
import React from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
import SpaceViewerCommentsToggle from "@/ee/security/components/space-viewer-comments-toggle.tsx";
type SpaceSecuritySettingsProps = {
space: ISpace;
readOnly?: boolean;
};
export default function SpaceSecuritySettings({
space,
readOnly,
}: SpaceSecuritySettingsProps) {
const { t } = useTranslation();
if (readOnly) return null;
return (
<div>
<Text my="md" fw={600}>
{t("Security")}
</Text>
<SpacePublicSharingToggle space={space} />
<Divider my="lg" />
<SpaceViewerCommentsToggle space={space} />
</div>
);
}
@@ -9,13 +9,8 @@ export interface ISpaceSharingSettings {
disabled?: boolean; disabled?: boolean;
} }
export interface ISpaceCommentsSettings {
allowViewerComments?: boolean;
}
export interface ISpaceSettings { export interface ISpaceSettings {
sharing?: ISpaceSharingSettings; sharing?: ISpaceSharingSettings;
comments?: ISpaceCommentsSettings;
} }
export interface ISpace { export interface ISpace {
@@ -34,7 +29,6 @@ export interface ISpace {
settings?: ISpaceSettings; settings?: ISpaceSettings;
// for updates // for updates
disablePublicSharing?: boolean; disablePublicSharing?: boolean;
allowViewerComments?: boolean;
} }
interface IMembership { interface IMembership {
-4
View File
@@ -53,9 +53,6 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false; const canEdit = page?.permissions?.canEdit ?? false;
const canComment =
canEdit ||
(space?.settings?.comments?.allowViewerComments === true);
if (isLoading) { if (isLoading) {
return <></>; return <></>;
@@ -107,7 +104,6 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
slugId={page.slugId} slugId={page.slugId}
spaceSlug={page?.space?.slug} spaceSlug={page?.space?.slug}
editable={canEdit} editable={canEdit}
canComment={canComment}
/> />
<MemoizedHistoryModal pageId={page.id} /> <MemoizedHistoryModal pageId={page.id} />
</div> </div>
+2 -2
View File
@@ -74,7 +74,7 @@
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cookie": "^1.1.1", "cookie": "^1.1.1",
"fs-extra": "^11.3.4", "fs-extra": "^11.3.4",
"happy-dom": "20.8.9", "happy-dom": "20.8.4",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"kysely": "^0.28.14", "kysely": "^0.28.14",
@@ -89,7 +89,7 @@
"nestjs-cls": "^6.2.0", "nestjs-cls": "^6.2.0",
"nestjs-kysely": "^3.1.2", "nestjs-kysely": "^3.1.2",
"nestjs-pino": "^4.6.1", "nestjs-pino": "^4.6.1",
"nodemailer": "^8.0.4", "nodemailer": "^8.0.3",
"openid-client": "^6.8.2", "openid-client": "^6.8.2",
"otpauth": "^9.5.0", "otpauth": "^9.5.0",
"p-limit": "^7.3.0", "p-limit": "^7.3.0",
@@ -5,7 +5,6 @@ import {
prosemirrorNodeToYElement, prosemirrorNodeToYElement,
tiptapExtensions, tiptapExtensions,
} from './collaboration.util'; } from './collaboration.util';
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types'; import { User } from '@docmost/db/types/entity.types';
@@ -28,53 +27,6 @@ export class CollaborationHandler {
// const fragment = doc.getXmlFragment('default'); // const fragment = doc.getXmlFragment('default');
//}); //});
}, },
setCommentMark: async (
documentName: string,
payload: {
yjsSelection: YjsSelection;
commentId: string;
resolved: boolean;
user: User;
},
) => {
const { yjsSelection, commentId, resolved, user } = payload;
await this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
setYjsMark(doc, fragment, yjsSelection, 'comment', {
commentId,
resolved,
});
},
);
},
resolveCommentMark: async (
documentName: string,
payload: {
commentId: string;
resolved: boolean;
user: User;
},
) => {
const { commentId, resolved, user } = payload;
await this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
updateYjsMarkAttribute(
fragment,
'comment',
{ name: 'commentId', value: commentId },
{ resolved },
);
},
);
},
updatePageContent: async ( updatePageContent: async (
documentName: string, documentName: string,
payload: { payload: {
@@ -106,7 +58,8 @@ export class CollaborationHandler {
} else { } else {
const newContent = prosemirrorJson.content || []; const newContent = prosemirrorJson.content || [];
const yElements = newContent.map(prosemirrorNodeToYElement); const yElements = newContent.map(prosemirrorNodeToYElement);
const position = operation === 'prepend' ? 0 : fragment.length; const position =
operation === 'prepend' ? 0 : fragment.length;
fragment.insert(position, yElements); fragment.insert(position, yElements);
} }
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
import { import {
initProseMirrorDoc, initProseMirrorDoc,
relativePositionToAbsolutePosition, relativePositionToAbsolutePosition,
} from '@tiptap/y-tiptap'; } from 'y-prosemirror';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { Document } from '@hocuspocus/server'; import { Document } from '@hocuspocus/server';
import { getSchema } from '@tiptap/core'; import { getSchema } from '@tiptap/core';
-22
View File
@@ -1,22 +0,0 @@
export const Feature = {
SSO_CUSTOM: 'sso:custom',
SSO_GOOGLE: 'sso:google',
MFA: 'mfa',
API_KEYS: 'api:keys',
COMMENT_RESOLUTION: 'comment:resolution',
PAGE_PERMISSIONS: 'page:permissions',
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
DOCX_IMPORT: 'import:docx',
ATTACHMENT_INDEXING: 'attachment:indexing',
SECURITY_SETTINGS: 'security:settings',
MCP: 'mcp',
SCIM: 'scim',
PAGE_VERIFICATION: 'page:verification',
AUDIT_LOGS: 'audit:logs',
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -58,13 +58,13 @@ export class CommentController {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
await this.pageAccessService.validateCanComment(page, user, workspace.id); await this.pageAccessService.validateCanEdit(page, user);
const comment = await this.commentService.create( const comment = await this.commentService.create(
{ {
userId: user.id,
page, page,
workspaceId: workspace.id, workspaceId: workspace.id,
user,
}, },
createCommentDto, createCommentDto,
); );
@@ -120,7 +120,7 @@ export class CommentController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('update') @Post('update')
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) { async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
const comment = await this.commentRepo.findById(dto.commentId, { const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true, includeCreator: true,
includeResolvedBy: true, includeResolvedBy: true,
@@ -134,14 +134,14 @@ export class CommentController {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
await this.pageAccessService.validateCanComment(page, user, workspace.id); await this.pageAccessService.validateCanEdit(page, user);
return this.commentService.update(comment, dto, user); return this.commentService.update(comment, dto, user);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('delete') @Post('delete')
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) { async delete(@Body() input: CommentIdDto, @AuthUser() user: User) {
const comment = await this.commentRepo.findById(input.commentId); const comment = await this.commentRepo.findById(input.commentId);
if (!comment) { if (!comment) {
throw new NotFoundException('Comment not found'); throw new NotFoundException('Comment not found');
@@ -152,7 +152,8 @@ export class CommentController {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
await this.pageAccessService.validateCanComment(page, user, workspace.id); // Check page-level edit permission first
await this.pageAccessService.validateCanEdit(page, user);
// Check if user is the comment owner // Check if user is the comment owner
const isOwner = comment.creatorId === user.id; const isOwner = comment.creatorId === user.id;
@@ -168,7 +169,7 @@ export class CommentController {
// Space admin can delete any comment // Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException( throw new ForbiddenException(
'You can only delete your own comments', 'You can only delete your own comments or must be a space admin',
); );
} }
await this.commentRepo.deleteComment(comment.id); await this.commentRepo.deleteComment(comment.id);
@@ -1,10 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CommentService } from './comment.service'; import { CommentService } from './comment.service';
import { CommentController } from './comment.controller'; import { CommentController } from './comment.controller';
import { CollaborationModule } from '../../collaboration/collaboration.module';
@Module({ @Module({
imports: [CollaborationModule],
controllers: [CommentController], controllers: [CommentController],
providers: [CommentService], providers: [CommentService],
exports: [CommentService], exports: [CommentService],
@@ -7,8 +7,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { CreateCommentDto, yjsSelectionSchema } from './dto/create-comment.dto'; import { CreateCommentDto } from './dto/create-comment.dto';
import { CollaborationGateway } from '../../collaboration/collaboration.gateway';
import { UpdateCommentDto } from './dto/update-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { Comment, Page, User } from '@docmost/db/types/entity.types'; import { Comment, Page, User } from '@docmost/db/types/entity.types';
@@ -28,7 +27,6 @@ export class CommentService {
private commentRepo: CommentRepo, private commentRepo: CommentRepo,
private pageRepo: PageRepo, private pageRepo: PageRepo,
private wsService: WsService, private wsService: WsService,
private collaborationGateway: CollaborationGateway,
@InjectQueue(QueueName.GENERAL_QUEUE) @InjectQueue(QueueName.GENERAL_QUEUE)
private generalQueue: Queue, private generalQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) @InjectQueue(QueueName.NOTIFICATION_QUEUE)
@@ -47,10 +45,10 @@ export class CommentService {
} }
async create( async create(
opts: { page: Page; workspaceId: string; user: User }, opts: { userId: string; page: Page; workspaceId: string },
createCommentDto: CreateCommentDto, createCommentDto: CreateCommentDto,
) { ) {
const { page, workspaceId, user } = opts; const { userId, page, workspaceId } = opts;
const commentContent = JSON.parse(createCommentDto.content); const commentContent = JSON.parse(createCommentDto.content);
if (createCommentDto.parentCommentId) { if (createCommentDto.parentCommentId) {
@@ -73,39 +71,11 @@ export class CommentService {
selection: createCommentDto?.selection?.substring(0, 250) ?? null, selection: createCommentDto?.selection?.substring(0, 250) ?? null,
type: createCommentDto.type ?? 'page', type: createCommentDto.type ?? 'page',
parentCommentId: createCommentDto?.parentCommentId, parentCommentId: createCommentDto?.parentCommentId,
creatorId: user.id, creatorId: userId,
workspaceId: workspaceId, workspaceId: workspaceId,
spaceId: page.spaceId, spaceId: page.spaceId,
}); });
if (createCommentDto.yjsSelection) {
const parsed = yjsSelectionSchema.safeParse(createCommentDto.yjsSelection);
if (!parsed.success) {
this.logger.warn(
`Invalid yjsSelection for comment ${inserted.id}: ${parsed.error.message}`,
);
} else {
const documentName = `page.${page.id}`;
try {
await this.collaborationGateway.handleYjsEvent(
'setCommentMark',
documentName,
{
yjsSelection: parsed.data,
commentId: inserted.id,
resolved: false,
user,
},
);
} catch (error) {
this.logger.warn(
`Failed to apply comment mark for comment ${inserted.id}, comment saved without inline highlight`,
error,
);
}
}
}
const comment = await this.commentRepo.findById(inserted.id, { const comment = await this.commentRepo.findById(inserted.id, {
includeCreator: true, includeCreator: true,
includeResolvedBy: true, includeResolvedBy: true,
@@ -113,7 +83,7 @@ export class CommentService {
this.generalQueue this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, { .add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [user.id], userIds: [userId],
pageId: page.id, pageId: page.id,
spaceId: page.spaceId, spaceId: page.spaceId,
workspaceId, workspaceId,
@@ -131,7 +101,7 @@ export class CommentService {
page.id, page.id,
page.spaceId, page.spaceId,
workspaceId, workspaceId,
user.id, userId,
!isReply, !isReply,
createCommentDto.parentCommentId, createCommentDto.parentCommentId,
); );
@@ -1,22 +1,4 @@
import { IsIn, IsJSON, IsObject, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsIn, IsJSON, IsOptional, IsString, IsUUID } from 'class-validator';
import { z } from 'zod';
const yjsIdSchema = z.object({
client: z.number().int().nonnegative(),
clock: z.number().int().nonnegative(),
});
const yjsRelativePositionSchema = z.object({
type: yjsIdSchema,
tname: z.string().nullable(),
item: yjsIdSchema.nullable(),
assoc: z.number().int(),
});
export const yjsSelectionSchema = z.object({
anchor: yjsRelativePositionSchema,
head: yjsRelativePositionSchema,
});
export class CreateCommentDto { export class CreateCommentDto {
@IsString() @IsString()
@@ -36,11 +18,4 @@ export class CreateCommentDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
parentCommentId: string; parentCommentId: string;
@IsOptional()
@IsObject()
yjsSelection?: {
anchor: any;
head: any;
};
} }
@@ -6,14 +6,12 @@ import {
SpaceCaslAction, SpaceCaslAction,
SpaceCaslSubject, SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type'; } from '../../casl/interfaces/space-ability.type';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
@Injectable() @Injectable()
export class PageAccessService { export class PageAccessService {
constructor( constructor(
private readonly pagePermissionRepo: PagePermissionRepo, private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory, private readonly spaceAbility: SpaceAbilityFactory,
private readonly spaceRepo: SpaceRepo,
) {} ) {}
/** /**
@@ -101,25 +99,4 @@ export class PageAccessService {
return { hasRestriction: hasAnyRestriction }; return { hasRestriction: hasAnyRestriction };
} }
async validateCanComment(
page: Page,
user: User,
workspaceId: string,
): Promise<void> {
try {
await this.validateCanEdit(page, user);
return;
} catch {
// User cannot edit — check if reader commenting is enabled
}
await this.validateCanView(page, user);
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
const settings = space?.settings as Record<string, any> | null;
if (!settings?.comments?.allowViewerComments) {
throw new ForbiddenException();
}
}
} }
@@ -11,8 +11,4 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
disablePublicSharing: boolean; disablePublicSharing: boolean;
@IsOptional()
@IsBoolean()
allowViewerComments: boolean;
} }
@@ -13,7 +13,6 @@ import { Space, User } from '@docmost/db/types/entity.types';
import { UpdateSpaceDto } from '../dto/update-space.dto'; import { UpdateSpaceDto } from '../dto/update-space.dto';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { Feature } from '../../../common/features';
import { SpaceMemberService } from './space-member.service'; import { SpaceMemberService } from './space-member.service';
import { SpaceRole } from '../../../common/helpers/types/permission'; import { SpaceRole } from '../../../common/helpers/types/permission';
import { QueueJob, QueueName } from 'src/integrations/queue/constants'; import { QueueJob, QueueName } from 'src/integrations/queue/constants';
@@ -134,34 +133,17 @@ export class SpaceService {
} }
} }
if ( if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
typeof updateSpaceDto.disablePublicSharing !== 'undefined' ||
typeof updateSpaceDto.allowViewerComments !== 'undefined'
) {
const workspace = await this.workspaceRepo.findById(workspaceId, { const workspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true, withLicenseKey: true,
}); });
if ( if (
typeof updateSpaceDto.disablePublicSharing !== 'undefined' && !this.licenseCheckService.hasFeature(workspace.licenseKey, 'security:settings', workspace.plan)
!this.licenseCheckService.hasFeature(
workspace.licenseKey,
Feature.SECURITY_SETTINGS,
workspace.plan,
)
) { ) {
throw new ForbiddenException('This feature requires a valid license'); throw new ForbiddenException(
} 'This feature requires a valid license',
);
if (
typeof updateSpaceDto.allowViewerComments !== 'undefined' &&
!this.licenseCheckService.hasFeature(
workspace.licenseKey,
Feature.VIEWER_COMMENTS,
workspace.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
} }
} }
@@ -197,22 +179,6 @@ export class SpaceService {
} }
} }
if (typeof updateSpaceDto.allowViewerComments !== 'undefined') {
const prev = settingsBefore?.comments?.allowViewerComments ?? false;
if (prev !== updateSpaceDto.allowViewerComments) {
before.allowViewerComments = prev;
after.allowViewerComments = updateSpaceDto.allowViewerComments;
}
await this.spaceRepo.updateCommentSettings(
updateSpaceDto.spaceId,
workspaceId,
'allowViewerComments',
updateSpaceDto.allowViewerComments,
trx,
);
}
updatedSpace = await this.spaceRepo.updateSpace( updatedSpace = await this.spaceRepo.updateSpace(
{ {
name: updateSpaceDto.name, name: updateSpaceDto.name,
@@ -18,7 +18,6 @@ import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { Feature } from '../../../common/features';
import { User } from '@docmost/db/types/entity.types'; import { User } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { GroupRepo } from '@docmost/db/repos/group/group.repo';
@@ -353,7 +352,7 @@ export class WorkspaceService {
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' || typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
) { ) {
if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SECURITY_SETTINGS, ws.plan)) { if (!this.licenseCheckService.hasFeature(ws.licenseKey, 'security:settings', ws.plan)) {
throw new ForbiddenException( throw new ForbiddenException(
'This feature requires a valid license', 'This feature requires a valid license',
); );
@@ -1,333 +0,0 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createIndex('idx_group_users_user_id')
.ifNotExists()
.on('group_users')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_space_members_user_id')
.ifNotExists()
.on('space_members')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_space_members_group_id')
.ifNotExists()
.on('space_members')
.column('group_id')
.execute();
// Page tree
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_parent_position
ON pages (space_id, parent_page_id, position COLLATE "C")
WHERE deleted_at IS NULL
`.execute(db);
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_parent_page_id
ON pages (parent_page_id)
WHERE deleted_at IS NULL
`.execute(db);
// Recent pages query
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_updated
ON pages (space_id, updated_at DESC)
WHERE deleted_at IS NULL
`.execute(db);
// Trash view
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_deleted
ON pages (space_id, deleted_at DESC)
WHERE deleted_at IS NOT NULL
`.execute(db);
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_hostname_lower
ON workspaces (LOWER(hostname))
`.execute(db);
await db.schema
.createIndex('idx_workspaces_created_at')
.ifNotExists()
.on('workspaces')
.column('created_at')
.execute();
await db.schema
.createIndex('idx_users_workspace_deleted')
.ifNotExists()
.on('users')
.columns(['workspace_id', 'deleted_at'])
.execute();
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_spaces_slug_lower_workspace
ON spaces (LOWER(slug), workspace_id)
`.execute(db);
await db.schema
.createIndex('idx_spaces_workspace_id')
.ifNotExists()
.on('spaces')
.column('workspace_id')
.execute();
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_lower_workspace
ON groups (LOWER(name), workspace_id)
`.execute(db);
await db.schema
.createIndex('idx_groups_workspace_id')
.ifNotExists()
.on('groups')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_shares_page_id')
.ifNotExists()
.on('shares')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_attachments_page_id')
.ifNotExists()
.on('attachments')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_attachments_space_id')
.ifNotExists()
.on('attachments')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_comments_page_id')
.ifNotExists()
.on('comments')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_comments_parent_comment_id')
.ifNotExists()
.on('comments')
.column('parent_comment_id')
.execute();
await sql`
CREATE INDEX IF NOT EXISTS idx_page_history_page_created
ON page_history (page_id, created_at DESC)
`.execute(db);
await db.schema
.createIndex('idx_attachments_workspace_id')
.ifNotExists()
.on('attachments')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_backlinks_target_page_id')
.ifNotExists()
.on('backlinks')
.column('target_page_id')
.execute();
await db.schema
.createIndex('idx_pages_workspace_id')
.ifNotExists()
.on('pages')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_pages_creator_id')
.ifNotExists()
.on('pages')
.column('creator_id')
.execute();
// Notifications: FK cascade from pages, spaces, comments
await db.schema
.createIndex('idx_notifications_page_id')
.ifNotExists()
.on('notifications')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_notifications_space_id')
.ifNotExists()
.on('notifications')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_notifications_comment_id')
.ifNotExists()
.on('notifications')
.column('comment_id')
.execute();
// Watchers: cleanup queries and FK cascade
await db.schema
.createIndex('idx_watchers_user_workspace')
.ifNotExists()
.on('watchers')
.columns(['user_id', 'workspace_id'])
.execute();
await db.schema
.createIndex('idx_watchers_space_id')
.ifNotExists()
.on('watchers')
.column('space_id')
.execute();
// Auth providers: all queries filter by workspaceId
await db.schema
.createIndex('idx_auth_providers_workspace_id')
.ifNotExists()
.on('auth_providers')
.column('workspace_id')
.execute();
// Auth accounts: SSO login lookup by provider user
await db.schema
.createIndex('idx_auth_accounts_provider_user_id')
.ifNotExists()
.on('auth_accounts')
.columns(['provider_user_id', 'auth_provider_id'])
.execute();
// Workspace invitations: listing and SSO lookup
await db.schema
.createIndex('idx_workspace_invitations_workspace_id')
.ifNotExists()
.on('workspace_invitations')
.column('workspace_id')
.execute();
// API keys: query and FK cascade
await db.schema
.createIndex('idx_api_keys_workspace_id')
.ifNotExists()
.on('api_keys')
.column('workspace_id')
.execute();
// User sessions: delete queries and FK cascade on all session states
await db.schema
.createIndex('idx_user_sessions_user_workspace')
.ifNotExists()
.on('user_sessions')
.columns(['user_id', 'workspace_id'])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('idx_group_users_user_id').ifExists().execute();
await db.schema.dropIndex('idx_space_members_user_id').ifExists().execute();
await db.schema.dropIndex('idx_space_members_group_id').ifExists().execute();
await db.schema
.dropIndex('idx_pages_space_parent_position')
.ifExists()
.execute();
await db.schema.dropIndex('idx_pages_parent_page_id').ifExists().execute();
await db.schema.dropIndex('idx_pages_space_updated').ifExists().execute();
await db.schema.dropIndex('idx_pages_space_deleted').ifExists().execute();
await db.schema
.dropIndex('idx_workspaces_hostname_lower')
.ifExists()
.execute();
await db.schema.dropIndex('idx_workspaces_created_at').ifExists().execute();
await db.schema
.dropIndex('idx_users_workspace_deleted')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_spaces_slug_lower_workspace')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_spaces_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_groups_name_lower_workspace')
.ifExists()
.execute();
await db.schema.dropIndex('idx_groups_workspace_id').ifExists().execute();
await db.schema.dropIndex('idx_shares_page_id').ifExists().execute();
await db.schema.dropIndex('idx_attachments_page_id').ifExists().execute();
await db.schema.dropIndex('idx_attachments_space_id').ifExists().execute();
await db.schema.dropIndex('idx_comments_page_id').ifExists().execute();
await db.schema
.dropIndex('idx_comments_parent_comment_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_page_history_page_created')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_attachments_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_backlinks_target_page_id')
.ifExists()
.execute();
await db.schema.dropIndex('idx_pages_workspace_id').ifExists().execute();
await db.schema.dropIndex('idx_pages_creator_id').ifExists().execute();
await db.schema
.dropIndex('idx_notifications_page_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_notifications_space_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_notifications_comment_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_watchers_user_workspace')
.ifExists()
.execute();
await db.schema.dropIndex('idx_watchers_space_id').ifExists().execute();
await db.schema
.dropIndex('idx_auth_providers_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_auth_accounts_provider_user_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_workspace_invitations_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_api_keys_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_user_sessions_user_workspace')
.ifExists()
.execute();
}
@@ -111,28 +111,6 @@ export class SpaceRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
async updateCommentSettings(
spaceId: string,
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('spaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('comments', COALESCE(settings->'comments', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.returningAll()
.executeTakeFirst();
}
async insertSpace( async insertSpace(
insertableSpace: InsertableSpace, insertableSpace: InsertableSpace,
trx?: KyselyTransaction, trx?: KyselyTransaction,
@@ -61,7 +61,7 @@ export class ExportController {
await this.pageAccessService.validateCanView(page, user); await this.pageAccessService.validateCanView(page, user);
const result = await this.exportService.exportPages( const zipFileStream = await this.exportService.exportPages(
dto.pageId, dto.pageId,
dto.format, dto.format,
dto.includeAttachments, dto.includeAttachments,
@@ -83,29 +83,15 @@ export class ExportController {
}, },
}); });
if (result.type === 'file') { const fileName = sanitize(page.title || 'untitled') + '.zip';
const ext = getExportExtension(dto.format);
const fileName = sanitize(page.title || 'untitled') + ext;
const contentType = getMimeType(path.extname(fileName));
res.headers({ res.headers({
'Content-Type': contentType, 'Content-Type': 'application/zip',
'Content-Disposition': 'Content-Disposition':
'attachment; filename="' + encodeURIComponent(fileName) + '"', 'attachment; filename="' + encodeURIComponent(fileName) + '"',
}); });
res.send(result.content); res.send(zipFileStream);
} else {
const fileName = sanitize(page.title || 'untitled') + '.zip';
res.headers({
'Content-Type': 'application/zip',
'Content-Disposition':
'attachment; filename="' + encodeURIComponent(fileName) + '"',
});
res.send(result.stream);
}
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -150,13 +150,6 @@ export class ExportService {
// set to null to make export of pages with parentId work // set to null to make export of pages with parentId work
pages[parentPageIndex].parentPageId = null; pages[parentPageIndex].parentPageId = null;
const isSinglePage = pages.length === 1 && !includeAttachments;
if (isSinglePage) {
const pageContent = await this.exportPage(format, pages[0], true);
return { type: 'file' as const, content: pageContent, page: pages[0] };
}
const tree = buildTree(pages as Page[]); const tree = buildTree(pages as Page[]);
const baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId); const baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId);
@@ -177,7 +170,7 @@ export class ExportService {
compression: 'DEFLATE', compression: 'DEFLATE',
}); });
return { type: 'zip' as const, stream: zipFile, page: pages[0] }; return zipFile;
} }
async exportSpace( async exportSpace(
+1 -3
View File
@@ -124,9 +124,7 @@
"picomatch@>=4.0.0 <4.0.4": "4.0.4", "picomatch@>=4.0.0 <4.0.4": "4.0.4",
"fastify": "5.8.3", "fastify": "5.8.3",
"yaml@>=1.0.0 <1.10.3": "1.10.3", "yaml@>=1.0.0 <1.10.3": "1.10.3",
"yaml@>=2.0.0 <2.8.3": "2.8.3", "yaml@>=2.0.0 <2.8.3": "2.8.3"
"path-to-regexp@^8": "8.4.0",
"brace-expansion@^5": "5.0.5"
}, },
"neverBuiltDependencies": [] "neverBuiltDependencies": []
} }
+1 -3
View File
@@ -9,7 +9,5 @@
"main": "dist/index.js", "main": "dist/index.js",
"module": "./src/index.ts", "module": "./src/index.ts",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"dependencies": { "dependencies": {}
"marked": "17.0.5"
}
} }
@@ -5,23 +5,18 @@ import { mathInlineExtension } from "./math-inline.marked";
marked.use({ marked.use({
renderer: { renderer: {
list({ ordered, start, items }) { // @ts-ignore
let body = ""; list(body: string, isOrdered: boolean, start: number) {
for (const item of items) { if (isOrdered) {
body += this.listitem(item);
}
if (ordered) {
const startAttr = start !== 1 ? ` start="${start}"` : ""; const startAttr = start !== 1 ? ` start="${start}"` : "";
return `<ol${startAttr}>\n${body}</ol>\n`; return `<ol ${startAttr}>\n${body}</ol>\n`;
} }
const isTaskList = items.some((item) => item.task); const dataType = body.includes(`<input`) ? ' data-type="taskList"' : "";
const dataType = isTaskList ? ' data-type="taskList"' : "";
return `<ul${dataType}>\n${body}</ul>\n`; return `<ul${dataType}>\n${body}</ul>\n`;
}, },
listitem({ tokens, task: isTask, checked: isChecked }) { // @ts-ignore
const text = this.parser.parse(tokens); listitem({ text, raw, task: isTask, checked: isChecked }): string {
if (!isTask) { if (!isTask) {
return `<li>${text}</li>\n`; return `<li>${text}</li>\n`;
} }
@@ -21,7 +21,6 @@ export function htmlToMarkdown(html: string): string {
callout, callout,
preserveDetail, preserveDetail,
listParagraph, listParagraph,
orderedListItem,
mathInline, mathInline,
mathBlock, mathBlock,
iframeEmbed, iframeEmbed,
@@ -42,40 +41,6 @@ function listParagraph(turndownService: _TurndownService) {
}); });
} }
function orderedListItem(turndownService: _TurndownService) {
turndownService.addRule('orderedListItem', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'LI' && node.getAttribute('data-type') !== 'taskItem';
},
replacement: (content: string, node: HTMLInputElement, options: any) => {
const parent = node.parentNode as HTMLElement;
if (parent.nodeName !== 'OL' && parent.nodeName !== 'UL') {
return content;
}
content = content
.replace(/^\n+/, '')
.replace(/\n+$/, '\n')
.replace(/\n/gm, '\n ');
let prefix: string;
if (parent.nodeName === 'OL') {
const start = parseInt(parent.getAttribute('start') || '1', 10);
const index = Array.prototype.indexOf.call(parent.children, node);
prefix = `${start + index}. `;
} else {
prefix = `${options.bulletListMarker} `;
}
return (
prefix +
content +
(node.nextSibling && !/\n$/.test(content) ? '\n' : '')
);
},
});
}
function callout(turndownService: _TurndownService) { function callout(turndownService: _TurndownService) {
turndownService.addRule('callout', { turndownService.addRule('callout', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
@@ -98,17 +63,25 @@ function taskList(turndownService: _TurndownService) {
node.parentNode.nodeName === 'UL' node.parentNode.nodeName === 'UL'
); );
}, },
replacement: function (_content: string, node: HTMLInputElement) { replacement: function (content: string, node: HTMLInputElement) {
const isChecked = node.getAttribute('data-checked') === 'true'; const checkbox = node.querySelector(
const div = node.querySelector('div'); 'input[type="checkbox"]',
const text = div ? div.textContent.trim() : node.textContent.trim(); ) as HTMLInputElement;
const isChecked = checkbox.checked;
// Process content like regular list items
content = content
.replace(/^\n+/, '') // remove leading newlines
.replace(/\n+$/, '\n') // replace trailing newlines with just a single one
.replace(/\n/gm, '\n '); // indent nested content with 2 spaces
// Create the checkbox prefix
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `; const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
return ( return (
prefix + prefix +
text + content +
(node.nextSibling && !/\n$/.test(text) ? '\n' : '') (node.nextSibling && !/\n$/.test(content) ? '\n' : '')
); );
}, },
}); });
+24 -30
View File
@@ -32,8 +32,6 @@ overrides:
fastify: 5.8.3 fastify: 5.8.3
yaml@>=1.0.0 <1.10.3: 1.10.3 yaml@>=1.0.0 <1.10.3: 1.10.3
yaml@>=2.0.0 <2.8.3: 2.8.3 yaml@>=2.0.0 <2.8.3: 2.8.3
path-to-regexp@^8: 8.4.0
brace-expansion@^5: 5.0.5
patchedDependencies: patchedDependencies:
react-arborist@3.4.0: react-arborist@3.4.0:
@@ -145,7 +143,7 @@ importers:
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/html': '@tiptap/html':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(happy-dom@20.8.9) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(happy-dom@20.8.4)
'@tiptap/pm': '@tiptap/pm':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4 version: 3.20.4
@@ -584,8 +582,8 @@ importers:
specifier: ^11.3.4 specifier: ^11.3.4
version: 11.3.4 version: 11.3.4
happy-dom: happy-dom:
specifier: 20.8.9 specifier: 20.8.4
version: 20.8.9 version: 20.8.4
ioredis: ioredis:
specifier: ^5.10.1 specifier: ^5.10.1
version: 5.10.1 version: 5.10.1
@@ -629,8 +627,8 @@ importers:
specifier: ^4.6.1 specifier: ^4.6.1
version: 4.6.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2) version: 4.6.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2)
nodemailer: nodemailer:
specifier: ^8.0.4 specifier: ^8.0.3
version: 8.0.4 version: 8.0.3
openid-client: openid-client:
specifier: ^6.8.2 specifier: ^6.8.2
version: 6.8.2 version: 6.8.2
@@ -801,11 +799,7 @@ importers:
specifier: ^8.57.1 specifier: ^8.57.1
version: 8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3) version: 8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3)
packages/editor-ext: packages/editor-ext: {}
dependencies:
marked:
specifier: 17.0.5
version: 17.0.5
packages: packages:
@@ -5872,8 +5866,8 @@ packages:
brace-expansion@2.0.2: brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@5.0.5: brace-expansion@5.0.4:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
braces@3.0.3: braces@3.0.3:
@@ -7274,8 +7268,8 @@ packages:
engines: {node: '>=0.4.7'} engines: {node: '>=0.4.7'}
hasBin: true hasBin: true
happy-dom@20.8.9: happy-dom@20.8.4:
resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==} resolution: {integrity: sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
has-bigints@1.0.2: has-bigints@1.0.2:
@@ -8645,8 +8639,8 @@ packages:
node-releases@2.0.27: node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
nodemailer@8.0.4: nodemailer@8.0.3:
resolution: {integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==} resolution: {integrity: sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
normalize-path@3.0.0: normalize-path@3.0.0:
@@ -8920,8 +8914,8 @@ packages:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
path-to-regexp@8.4.0: path-to-regexp@8.3.0:
resolution: {integrity: sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==} resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
path-type@4.0.0: path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
@@ -13549,7 +13543,7 @@ snapshots:
'@nuxt/opencollective': 0.4.1 '@nuxt/opencollective': 0.4.1
fast-safe-stringify: 2.1.1 fast-safe-stringify: 2.1.1
iterare: 1.2.1 iterare: 1.2.1
path-to-regexp: 8.4.0 path-to-regexp: 8.3.0
reflect-metadata: 0.2.2 reflect-metadata: 0.2.2
rxjs: 7.8.2 rxjs: 7.8.2
tslib: 2.8.1 tslib: 2.8.1
@@ -13593,7 +13587,7 @@ snapshots:
fastify-plugin: 5.1.0 fastify-plugin: 5.1.0
find-my-way: 9.5.0 find-my-way: 9.5.0
light-my-request: 6.6.0 light-my-request: 6.6.0
path-to-regexp: 8.4.0 path-to-regexp: 8.3.0
reusify: 1.1.0 reusify: 1.1.0
tslib: 2.8.1 tslib: 2.8.1
optionalDependencies: optionalDependencies:
@@ -15478,11 +15472,11 @@ snapshots:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/pm': 3.20.4 '@tiptap/pm': 3.20.4
'@tiptap/html@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(happy-dom@20.8.9)': '@tiptap/html@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(happy-dom@20.8.4)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/pm': 3.20.4 '@tiptap/pm': 3.20.4
happy-dom: 20.8.9 happy-dom: 20.8.4
'@tiptap/pm@3.20.4': '@tiptap/pm@3.20.4':
dependencies: dependencies:
@@ -16666,7 +16660,7 @@ snapshots:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
brace-expansion@5.0.5: brace-expansion@5.0.4:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
@@ -18357,7 +18351,7 @@ snapshots:
optionalDependencies: optionalDependencies:
uglify-js: 3.19.3 uglify-js: 3.19.3
happy-dom@20.8.9: happy-dom@20.8.4:
dependencies: dependencies:
'@types/node': 25.5.0 '@types/node': 25.5.0
'@types/whatwg-mimetype': 3.0.2 '@types/whatwg-mimetype': 3.0.2
@@ -19743,7 +19737,7 @@ snapshots:
minimatch@10.2.4: minimatch@10.2.4:
dependencies: dependencies:
brace-expansion: 5.0.5 brace-expansion: 5.0.4
minimatch@3.1.5: minimatch@3.1.5:
dependencies: dependencies:
@@ -19868,7 +19862,7 @@ snapshots:
node-releases@2.0.27: {} node-releases@2.0.27: {}
nodemailer@8.0.4: {} nodemailer@8.0.3: {}
normalize-path@3.0.0: {} normalize-path@3.0.0: {}
@@ -20209,7 +20203,7 @@ snapshots:
lru-cache: 11.2.7 lru-cache: 11.2.7
minipass: 7.1.3 minipass: 7.1.3
path-to-regexp@8.4.0: {} path-to-regexp@8.3.0: {}
path-type@4.0.0: {} path-type@4.0.0: {}
@@ -21094,7 +21088,7 @@ snapshots:
depd: 2.0.0 depd: 2.0.0
is-promise: 4.0.0 is-promise: 4.0.0
parseurl: 1.3.3 parseurl: 1.3.3
path-to-regexp: 8.4.0 path-to-regexp: 8.3.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color