Compare commits

..

19 Commits

Author SHA1 Message Date
Philipinho 949972cf41 fix 2026-03-30 13:15:15 +01:00
Philipinho 3e5a1f73ea fix media links 2026-03-30 12:38:27 +01:00
Philipinho 990273fb02 min resize dimensions 2026-03-30 12:23:24 +01:00
Philipinho 02334f4106 fix placeholder 2026-03-30 12:16:14 +01:00
Philip Okugbe cbd0dd4a0b feat: indexes (#2071) 2026-03-29 20:29:12 +01:00
Philip Okugbe 2d6d829581 New translations translation.json (English) (#2066) 2026-03-29 16:25:45 +01:00
Philipinho 5cea30cc5c fix markdown paste 2026-03-29 16:11:21 +01:00
Philipinho bca85a49d6 pin marked version 2026-03-29 03:03:35 +01:00
Philipinho c9cdfa0f17 fix 2026-03-29 02:20:56 +01:00
Philip Okugbe 412962204c fix: editor fixes (#2067)
* autojoiner

* fix marked

* return clipboardTextSerializer as markdown

* fix clipboardTextSerializer for single lines

* cleanup two preceeding spaces in ordered lists item

* fix extra paragraph in task list

* don't zip sinple page exports
2026-03-29 02:19:09 +01:00
Olivier Lambert a42ac3d450 fix: strip trailing whitespace-only paragraphs from pasted content (#2050) 2026-03-28 22:26:47 +00:00
Philipinho 642c92f779 fix select 2026-03-28 20:34:44 +00:00
Philipinho ccb35517bb sync 2026-03-28 20:29:31 +00:00
Philip Okugbe cbdb37ed0a New Crowdin updates (#2061) 2026-03-28 20:29:06 +00:00
Julien Fontanet aa27d57624 fix: notification items are now real links (#2039)
Replace UnstyledButton with UnstyledButton component={Link} so each
notification renders as a real anchor element. Regular left-clicks use
SPA navigation and close the popover; Ctrl/Cmd/middle-click open the
page in a new tab. All click types mark the notification as read.
2026-03-28 20:23:21 +00:00
Philip Okugbe 3829b6cbef feat(ee): viewer comments (#2060) 2026-03-28 19:32:52 +00:00
Philipinho 17da762984 overrides 2026-03-28 19:28:22 +00:00
Philipinho 859f16740b tooltip portal 2026-03-28 19:19:00 +00:00
Philip Okugbe 7981ef462e feat(editor): audio and PDF nodes (#2064)
* use local resizable

* feat: aduio

* support audio imports

* feat: use confluence real file names

* cleanup

* error handling

* hide notice

* add audio

* fix pulse

* Fix import and export

* unify pulse

* hide in readonly mode

* keywords

* keyword

* translations

* better sort

* feat: PDF embed

* cleanup

* remove audio menu

* open active

* hide focus on readonly mode

* increase iframe default dimension
2026-03-28 17:33:29 +00:00
61 changed files with 1500 additions and 199 deletions
@@ -341,6 +341,7 @@
"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",
@@ -351,6 +352,12 @@
"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",
@@ -442,6 +449,9 @@
"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 homepage.", "Take me back to homepage": "Take me back to the 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 unresolved successfully.", "Comment unresolved successfully": "Comment marked as 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": "Unresolve comment.", "Unresolve comment": "Mark comment as unresolved.",
"Resolve Comment Thread": "Resolve Comment Thread.", "Resolve Comment Thread": "Resolve comment thread.",
"Unresolve Comment Thread": "Unresolve Comment Thread.", "Unresolve Comment Thread": "Mark comment thread as unresolved.",
"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,6 +449,9 @@
"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",
@@ -730,7 +733,5 @@
"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,6 +341,7 @@
"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",
@@ -351,6 +352,12 @@
"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",
@@ -442,6 +449,9 @@
"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,6 +341,7 @@
"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",
@@ -351,6 +352,12 @@
"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",
@@ -415,7 +422,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 of contents": "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.", "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",
@@ -442,6 +449,9 @@
"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,6 +341,7 @@
"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",
@@ -351,6 +352,12 @@
"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",
@@ -442,6 +449,9 @@
"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,6 +341,7 @@
"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": "ファイルをアップロード中",
@@ -351,6 +352,12 @@
"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": "コールアウト",
@@ -442,6 +449,9 @@
"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,6 +341,7 @@
"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": "파일 업로드 중",
@@ -351,6 +352,12 @@
"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": "경고 상자",
@@ -442,6 +449,9 @@
"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,6 +341,7 @@
"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",
@@ -351,6 +352,12 @@
"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",
@@ -442,6 +449,9 @@
"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,6 +341,7 @@
"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",
@@ -351,6 +352,12 @@
"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",
@@ -442,6 +449,9 @@
"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,6 +341,7 @@
"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": "Загрузка файла",
@@ -351,6 +352,12 @@
"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": "Выноска",
@@ -442,6 +449,9 @@
"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,6 +341,7 @@
"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": "Завантаження файлу",
@@ -351,6 +352,12 @@
"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": "Виноска",
@@ -442,6 +449,9 @@
"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,6 +341,7 @@
"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": "正在上传文件",
@@ -351,6 +352,12 @@
"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": "标注块",
@@ -442,6 +449,9 @@
"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,4 +16,5 @@ 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;
@@ -0,0 +1,61 @@
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,3 +3,15 @@ import { atom } from 'jotai';
export const showCommentPopupAtom = atom<boolean>(false); export const showCommentPopupAtom = atom<boolean>(false);
export const activeCommentIdAtom = atom<string>(''); export const activeCommentIdAtom = atom<string>('');
export const draftCommentIdAtom = atom<string>(''); export const draftCommentIdAtom = atom<string>('');
// Read-only comment state
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);
export type YjsSelection = {
anchor: any;
head: any;
};
export type ReadOnlyCommentData = {
yjsSelection: YjsSelection;
selectedText: string;
};
export const readOnlyCommentDataAtom = atom<ReadOnlyCommentData | null>(null);
@@ -6,6 +6,8 @@ import {
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";
@@ -19,12 +21,15 @@ 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 }: CommentDialogProps) { function CommentDialog({ editor, pageId, readOnly }: 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);
@@ -34,11 +39,17 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
handleDialogClose(); handleDialogClose();
}); });
const createCommentMutation = useCreateCommentMutation(); const createCommentMutation = useCreateCommentMutation();
const { isPending } = createCommentMutation; const isPending = createCommentMutation.isPending;
const handleDialogClose = () => { const handleDialogClose = () => {
setShowCommentPopup(false); if (readOnly) {
editor.chain().focus().unsetCommentDecoration().run(); setShowReadOnlyCommentPopup(false);
// @ts-ignore
setReadOnlyCommentData(null);
} else {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
}
}; };
const getSelectedText = () => { const getSelectedText = () => {
@@ -47,6 +58,11 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
}; };
const handleAddComment = async () => { const handleAddComment = async () => {
if (readOnly) {
await handleAddReadOnlyComment();
return;
}
try { try {
const selectedText = getSelectedText(); const selectedText = getSelectedText();
const commentData = { const commentData = {
@@ -65,7 +81,6 @@ function CommentDialog({ editor, pageId }: 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 });
@@ -85,6 +100,33 @@ function CommentDialog({ editor, pageId }: 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,7 +44,9 @@ 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 = page?.permissions?.canEdit ?? false; const canComment =
(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(() => {
@@ -153,7 +155,7 @@ function CommentListWithTabs() {
)} )}
</Paper> </Paper>
), ),
[comments, handleAddReply, isLoading, space?.membership?.role], [comments, handleAddReply, isLoading, space?.membership?.role, canComment],
); );
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"> <Tooltip label={upgradeLabel} position="left" withinPortal={false}>
<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,6 +17,10 @@ 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 {
@@ -10,7 +10,7 @@ import { useCallback } from "react";
export default function AttachmentView(props: NodeViewProps) { export default function AttachmentView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { editor, node, getPos, selected } = props; const { editor, node, getPos, selected } = props;
const { url, name, size, mime, attachmentId } = node.attrs; const { url, name, size, mime, attachmentId, placeholder } = node.attrs;
const { hovered, ref } = useHover(); const { hovered, ref } = useHover();
const isPdf = mime === "application/pdf" || name?.toLowerCase().endsWith(".pdf"); const isPdf = mime === "application/pdf" || name?.toLowerCase().endsWith(".pdf");
@@ -49,14 +49,14 @@ export default function AttachmentView(props: NodeViewProps) {
h={25} h={25}
> >
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}> <Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
{url ? ( {!url && placeholder ? (
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
) : (
<Loader size={20} style={{ flexShrink: 0 }} /> <Loader size={20} style={{ flexShrink: 0 }} />
) : (
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
)} )}
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}> <Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
{url ? name : t("Uploading {{name}}", { name })} {!url && placeholder ? t("Uploading {{name}}", { name }) : name}
</Text> </Text>
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}> <Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
@@ -29,7 +29,7 @@ export default function AudioView(props: NodeViewProps) {
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<div className={`${classes.audioWrapper} ${!safeSrc ? classes.skeleton : ''}`}> <div className={`${classes.audioWrapper} ${!safeSrc && placeholder ? classes.skeleton : ''}`}>
{safeSrc && ( {safeSrc && (
<audio <audio
className={classes.audio} className={classes.audio}
@@ -49,7 +49,7 @@ export default function AudioView(props: NodeViewProps) {
<Loader size={20} pos="absolute" top={6} right={6} /> <Loader size={20} pos="absolute" top={6} right={6} />
</Group> </Group>
)} )}
{!safeSrc && !previewSrc && ( {!safeSrc && !previewSrc && placeholder && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md" h={54}> <Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md" h={54}>
<Loader size={20} style={{ flexShrink: 0 }} /> <Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end"> <Text component="span" size="sm" truncate="end">
@@ -59,6 +59,9 @@ export default function AudioView(props: NodeViewProps) {
</Text> </Text>
</Group> </Group>
)} )}
{!safeSrc && !previewSrc && !placeholder && (
<audio className={classes.audio} controls />
)}
</div> </div>
</NodeViewWrapper> </NodeViewWrapper>
); );
@@ -0,0 +1,159 @@
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>
);
};
@@ -33,7 +33,7 @@ export default function ImageView(props: NodeViewProps) {
className={clsx( className={clsx(
selected && "ProseMirror-selectednode", selected && "ProseMirror-selectednode",
classes.imageWrapper, classes.imageWrapper,
!src && classes.skeleton, !src && placeholder && classes.skeleton,
alignClass, alignClass,
)} )}
style={{ style={{
@@ -55,7 +55,7 @@ export default function ImageView(props: NodeViewProps) {
<Loader size={20} pos="absolute" bottom={6} right={6} /> <Loader size={20} pos="absolute" bottom={6} right={6} />
</Group> </Group>
)} )}
{!src && !previewSrc && ( {!src && !previewSrc && placeholder && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md"> <Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} /> <Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end"> <Text component="span" size="sm" truncate="end">
@@ -73,15 +73,17 @@ export default function PdfView(props: NodeViewProps) {
if (!src || !safeSrc) { if (!src || !safeSrc) {
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<div className={`${classes.pdfWrapper} ${classes.skeleton}`} style={{ height: 600 }}> <div className={`${classes.pdfWrapper} ${placeholder ? classes.skeleton : ''}`} style={{ height: placeholder ? 600 : undefined }}>
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md"> {placeholder && (
<Loader size={20} style={{ flexShrink: 0 }} /> <Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Text component="span" size="sm" truncate="end"> <Loader size={20} style={{ flexShrink: 0 }} />
{placeholder?.name <Text component="span" size="sm" truncate="end">
? t("Uploading {{name}}", { name: placeholder.name }) {placeholder?.name
: t("Uploading file")} ? t("Uploading {{name}}", { name: placeholder.name })
</Text> : t("Uploading file")}
</Group> </Text>
</Group>
)}
</div> </div>
</NodeViewWrapper> </NodeViewWrapper>
); );
@@ -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,7 +121,11 @@ export const TableMenu = React.memo(
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div className={classes.toolbar}> <div className={classes.toolbar}>
<Tooltip position="top" label={t("Add left column")}> <Tooltip
position="top"
label={t("Add left column")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={addColumnLeft} onClick={addColumnLeft}
variant="subtle" variant="subtle"
@@ -132,7 +136,11 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Add right column")}> <Tooltip
position="top"
label={t("Add right column")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={addColumnRight} onClick={addColumnRight}
variant="subtle" variant="subtle"
@@ -143,7 +151,11 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Delete column")}> <Tooltip
position="top"
label={t("Delete column")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={deleteColumn} onClick={deleteColumn}
variant="subtle" variant="subtle"
@@ -156,7 +168,11 @@ export const TableMenu = React.memo(
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip position="top" label={t("Add row above")}> <Tooltip
position="top"
label={t("Add row above")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={addRowAbove} onClick={addRowAbove}
variant="subtle" variant="subtle"
@@ -167,7 +183,11 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Add row below")}> <Tooltip
position="top"
label={t("Add row below")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={addRowBelow} onClick={addRowBelow}
variant="subtle" variant="subtle"
@@ -178,7 +198,7 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Delete row")}> <Tooltip position="top" label={t("Delete row")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={deleteRow} onClick={deleteRow}
variant="subtle" variant="subtle"
@@ -191,7 +211,11 @@ export const TableMenu = React.memo(
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip position="top" label={t("Toggle header row")}> <Tooltip
position="top"
label={t("Toggle header row")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={toggleHeaderRow} onClick={toggleHeaderRow}
variant="subtle" variant="subtle"
@@ -202,7 +226,11 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Toggle header column")}> <Tooltip
position="top"
label={t("Toggle header column")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={toggleHeaderColumn} onClick={toggleHeaderColumn}
variant="subtle" variant="subtle"
@@ -215,7 +243,11 @@ export const TableMenu = React.memo(
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip position="top" label={t("Delete table")}> <Tooltip
position="top"
label={t("Delete table")}
withinPortal={false}
>
<ActionIcon <ActionIcon
onClick={deleteTable} onClick={deleteTable}
variant="subtle" variant="subtle"
@@ -228,7 +260,7 @@ export const TableMenu = React.memo(
</div> </div>
</BubbleMenu> </BubbleMenu>
); );
} },
); );
export default TableMenu; export default TableMenu;
@@ -33,7 +33,7 @@ export default function VideoView(props: NodeViewProps) {
className={clsx( className={clsx(
selected && "ProseMirror-selectednode", selected && "ProseMirror-selectednode",
classes.videoWrapper, classes.videoWrapper,
!src && classes.skeleton, !src && placeholder && classes.skeleton,
alignClass, alignClass,
)} )}
style={{ style={{
@@ -60,7 +60,7 @@ export default function VideoView(props: NodeViewProps) {
<Loader size={20} pos="absolute" top={6} right={6} /> <Loader size={20} pos="absolute" top={6} right={6} />
</Group> </Group>
)} )}
{!src && !previewSrc && ( {!src && !previewSrc && placeholder && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md"> <Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} /> <Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end"> <Text component="span" size="sm" truncate="end">
@@ -70,6 +70,9 @@ export default function VideoView(props: NodeViewProps) {
</Text> </Text>
</Group> </Group>
)} )}
{!src && !previewSrc && !placeholder && (
<video className={classes.video} controls />
)}
</div> </div>
</NodeViewWrapper> </NodeViewWrapper>
); );
@@ -0,0 +1,105 @@
// 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,6 +97,7 @@ 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);
@@ -252,8 +253,8 @@ export const mainExtensions = [
resize: { resize: {
enabled: true, enabled: true,
directions: ["left", "right"], directions: ["left", "right"],
minWidth: 80, minWidth: 24,
minHeight: 40, minHeight: 16,
alwaysPreserveAspectRatio: true, alwaysPreserveAspectRatio: true,
//@ts-ignore //@ts-ignore
createCustomHandle: createImageHandle, createCustomHandle: createImageHandle,
@@ -265,8 +266,8 @@ export const mainExtensions = [
resize: { resize: {
enabled: true, enabled: true,
directions: ["left", "right"], directions: ["left", "right"],
minWidth: 80, minWidth: 24,
minHeight: 40, minHeight: 16,
alwaysPreserveAspectRatio: true, alwaysPreserveAspectRatio: true,
//@ts-ignore //@ts-ignore
createCustomHandle: createResizeHandle, createCustomHandle: createResizeHandle,
@@ -296,8 +297,8 @@ export const mainExtensions = [
resize: { resize: {
enabled: true, enabled: true,
directions: ["left", "right"], directions: ["left", "right"],
minWidth: 80, minWidth: 24,
minHeight: 40, minHeight: 16,
alwaysPreserveAspectRatio: true, alwaysPreserveAspectRatio: true,
//@ts-ignore //@ts-ignore
createCustomHandle: createResizeHandle, createCustomHandle: createResizeHandle,
@@ -309,8 +310,8 @@ export const mainExtensions = [
resize: { resize: {
enabled: true, enabled: true,
directions: ["left", "right"], directions: ["left", "right"],
minWidth: 80, minWidth: 24,
minHeight: 40, minHeight: 16,
alwaysPreserveAspectRatio: true, alwaysPreserveAspectRatio: true,
//@ts-ignore //@ts-ignore
createCustomHandle: createResizeHandle, createCustomHandle: createResizeHandle,
@@ -353,6 +354,9 @@ 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 } from "@tiptap/pm/state"; import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { DOMParser } from "@tiptap/pm/model"; import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
import { find } from "linkifyjs"; import { find } from "linkifyjs";
import { markdownToHtml } from "@docmost/editor-ext"; import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext";
export const MarkdownClipboard = Extension.create({ export const MarkdownClipboard = Extension.create({
name: "markdownClipboard", name: "markdownClipboard",
@@ -19,6 +19,27 @@ 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;
@@ -29,49 +50,80 @@ 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;
if (language !== "markdown") { const isVscodeMarkdown = 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 html = markdownToHtml(text); const parsed = markdownToHtml(text.replace(/\n+$/, ""));
const contentNodes = DOMParser.fromSchema( const contentNodes = DOMParser.fromSchema(
this.editor.schema, this.editor.schema,
).parseSlice(elementFromString(html), { ).parseSlice(elementFromString(parsed), {
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;
}, },
clipboardTextParser: (text, context, plainText) => { // Strip trailing whitespace-only paragraphs from pasted content.
const link = find(text, { // Terminals (GNOME Terminal, etc.) often include trailing
defaultProtocol: "http", // whitespace in their HTML clipboard data, which ProseMirror
}).find((item) => item.isLink && item.value === text); // parses as an extra paragraph. Inside a list item this creates
// an orphan empty line that breaks the list structure.
transformPasted: (slice) => {
let { content, openStart, openEnd } = slice;
if (plainText || !this.options.transformPastedText || link) { // Remove trailing paragraphs that contain only whitespace
// don't parse plaintext link to allow link paste handler to work while (content.childCount > 1) {
// pasting with shift key prevents formatting const lastChild = content.lastChild;
return null; if (
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;
}
} }
const parsed = markdownToHtml(text); if (content !== slice.content) {
return DOMParser.fromSchema(this.editor.schema).parseSlice( return new Slice(content, openStart, Math.max(openEnd, 1));
elementFromString(parsed), }
{
preserveWhitespace: true, return slice;
context,
},
);
}, },
}, },
}), }),
@@ -16,6 +16,7 @@ export interface FullEditorProps {
content: string; content: string;
spaceSlug: string; spaceSlug: string;
editable: boolean; editable: boolean;
canComment?: boolean;
} }
export function FullEditor({ export function FullEditor({
@@ -25,6 +26,7 @@ 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;
@@ -46,6 +48,7 @@ export function FullEditor({
pageId={pageId} pageId={pageId}
editable={editable} editable={editable}
content={content} content={content}
canComment={canComment}
/> />
</Container> </Container>
); );
@@ -37,9 +37,11 @@ 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";
@@ -74,12 +76,14 @@ 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);
@@ -94,6 +98,7 @@ 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(
@@ -423,7 +428,13 @@ 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 { useNavigate } from "react-router-dom"; import { Link } 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,7 +30,6 @@ 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);
@@ -55,32 +54,39 @@ export function NotificationItem({
} }
}; };
const handleClick = () => { const pageUrl =
if (notification.page && notification.space) { notification.page && notification.space
if (isUnread) { ? buildPageUrl(
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 handleMarkRead = (e: React.MouseEvent) => { const markReadIfNeeded = () => {
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,7 +1,9 @@
.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,6 +3,7 @@ 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 {
@@ -59,6 +60,14 @@ 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">
@@ -91,6 +100,20 @@ 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,7 +27,6 @@ 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);
@@ -89,13 +88,6 @@ 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" />
@@ -0,0 +1,34 @@
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,8 +9,13 @@ 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 {
@@ -29,6 +34,7 @@ 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,6 +53,9 @@ 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 <></>;
@@ -104,6 +107,7 @@ 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.4", "happy-dom": "20.8.9",
"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.3", "nodemailer": "^8.0.4",
"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,6 +5,7 @@ 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';
@@ -27,6 +28,53 @@ 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: {
@@ -58,8 +106,7 @@ 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 = const position = operation === 'prepend' ? 0 : fragment.length;
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 'y-prosemirror'; } from '@tiptap/y-tiptap';
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
@@ -0,0 +1,22 @@
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.validateCanEdit(page, user); await this.pageAccessService.validateCanComment(page, user, workspace.id);
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) { async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
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.validateCanEdit(page, user); await this.pageAccessService.validateCanComment(page, user, workspace.id);
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) { async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
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,8 +152,7 @@ export class CommentController {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
// Check page-level edit permission first await this.pageAccessService.validateCanComment(page, user, workspace.id);
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;
@@ -169,7 +168,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 or must be a space admin', 'You can only delete your own comments',
); );
} }
await this.commentRepo.deleteComment(comment.id); await this.commentRepo.deleteComment(comment.id);
@@ -1,8 +1,10 @@
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,7 +7,8 @@ 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 } from './dto/create-comment.dto'; import { CreateCommentDto, yjsSelectionSchema } 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';
@@ -27,6 +28,7 @@ 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)
@@ -45,10 +47,10 @@ export class CommentService {
} }
async create( async create(
opts: { userId: string; page: Page; workspaceId: string }, opts: { page: Page; workspaceId: string; user: User },
createCommentDto: CreateCommentDto, createCommentDto: CreateCommentDto,
) { ) {
const { userId, page, workspaceId } = opts; const { page, workspaceId, user } = opts;
const commentContent = JSON.parse(createCommentDto.content); const commentContent = JSON.parse(createCommentDto.content);
if (createCommentDto.parentCommentId) { if (createCommentDto.parentCommentId) {
@@ -71,11 +73,39 @@ 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: userId, creatorId: user.id,
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,
@@ -83,7 +113,7 @@ export class CommentService {
this.generalQueue this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, { .add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [userId], userIds: [user.id],
pageId: page.id, pageId: page.id,
spaceId: page.spaceId, spaceId: page.spaceId,
workspaceId, workspaceId,
@@ -101,7 +131,7 @@ export class CommentService {
page.id, page.id,
page.spaceId, page.spaceId,
workspaceId, workspaceId,
userId, user.id,
!isReply, !isReply,
createCommentDto.parentCommentId, createCommentDto.parentCommentId,
); );
@@ -1,4 +1,22 @@
import { IsIn, IsJSON, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsIn, IsJSON, IsObject, 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()
@@ -18,4 +36,11 @@ export class CreateCommentDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
parentCommentId: string; parentCommentId: string;
@IsOptional()
@IsObject()
yjsSelection?: {
anchor: any;
head: any;
};
} }
@@ -6,12 +6,14 @@ 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,
) {} ) {}
/** /**
@@ -99,4 +101,25 @@ 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,4 +11,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
disablePublicSharing: boolean; disablePublicSharing: boolean;
@IsOptional()
@IsBoolean()
allowViewerComments: boolean;
} }
@@ -13,6 +13,7 @@ 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';
@@ -133,17 +134,34 @@ export class SpaceService {
} }
} }
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') { if (
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 (
!this.licenseCheckService.hasFeature(workspace.licenseKey, 'security:settings', workspace.plan) typeof updateSpaceDto.disablePublicSharing !== 'undefined' &&
!this.licenseCheckService.hasFeature(
workspace.licenseKey,
Feature.SECURITY_SETTINGS,
workspace.plan,
)
) { ) {
throw new ForbiddenException( throw new ForbiddenException('This feature requires a valid license');
'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');
} }
} }
@@ -179,6 +197,22 @@ 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,6 +18,7 @@ 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';
@@ -352,7 +353,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, 'security:settings', ws.plan)) { if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SECURITY_SETTINGS, ws.plan)) {
throw new ForbiddenException( throw new ForbiddenException(
'This feature requires a valid license', 'This feature requires a valid license',
); );
@@ -0,0 +1,333 @@
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,6 +111,28 @@ 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 zipFileStream = await this.exportService.exportPages( const result = await this.exportService.exportPages(
dto.pageId, dto.pageId,
dto.format, dto.format,
dto.includeAttachments, dto.includeAttachments,
@@ -83,15 +83,29 @@ export class ExportController {
}, },
}); });
const fileName = sanitize(page.title || 'untitled') + '.zip'; if (result.type === 'file') {
const ext = getExportExtension(dto.format);
const fileName = sanitize(page.title || 'untitled') + ext;
const contentType = getMimeType(path.extname(fileName));
res.headers({ res.headers({
'Content-Type': 'application/zip', 'Content-Type': contentType,
'Content-Disposition': 'Content-Disposition':
'attachment; filename="' + encodeURIComponent(fileName) + '"', 'attachment; filename="' + encodeURIComponent(fileName) + '"',
}); });
res.send(zipFileStream); res.send(result.content);
} 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,6 +150,13 @@ 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);
@@ -170,7 +177,7 @@ export class ExportService {
compression: 'DEFLATE', compression: 'DEFLATE',
}); });
return zipFile; return { type: 'zip' as const, stream: zipFile, page: pages[0] };
} }
async exportSpace( async exportSpace(
@@ -193,6 +193,8 @@ export class ImportAttachmentService {
// Build a map from resolved archive path → real filename from Confluence // Build a map from resolved archive path → real filename from Confluence
// metadata. Confluence Server archives often store files under numeric IDs // metadata. Confluence Server archives often store files under numeric IDs
// (e.g. "attachments/65601/65602") instead of the original filename. // (e.g. "attachments/65601/65602") instead of the original filename.
// Also register aliases so HTML references using the original filename
// (e.g. "attachments/pageId/original.mp3") resolve to the numeric path.
const pageDir = path.dirname(pageRelativePath); const pageDir = path.dirname(pageRelativePath);
const attachmentNameByRelPath = new Map<string, string>(); const attachmentNameByRelPath = new Map<string, string>();
for (const attachment of pageAttachments) { for (const attachment of pageAttachments) {
@@ -203,6 +205,13 @@ export class ImportAttachmentService {
); );
if (relPath && attachment.fileName) { if (relPath && attachment.fileName) {
attachmentNameByRelPath.set(relPath, attachment.fileName); attachmentNameByRelPath.set(relPath, attachment.fileName);
const dir = path.posix.dirname(relPath);
const aliasKey = `${dir}/${attachment.fileName}`;
if (!attachmentCandidates.has(aliasKey)) {
attachmentCandidates.set(aliasKey, attachmentCandidates.get(relPath)!);
attachmentNameByRelPath.set(aliasKey, attachment.fileName);
}
} }
} }
@@ -562,18 +571,31 @@ export class ImportAttachmentService {
continue; continue;
} }
// Check if already processed (was referenced in HTML) // Resolve the metadata href to the actual archive path
if (processed.has(href)) { const resolvedHref = resolveRelativeAttachmentPath(
continue; href,
} pageDir,
attachmentCandidates,
);
if (!resolvedHref) continue;
// Skip if the file doesn't exist // Check if already processed (was referenced in HTML).
if (!attachmentCandidates.has(href)) { // Inline elements may have been processed under an alias key (original
// filename) rather than the numeric archive path, so also check whether
// the underlying absolute file path has already been uploaded.
const absPath = attachmentCandidates.get(resolvedHref);
const alreadyProcessed =
processed.has(resolvedHref) ||
(absPath &&
Array.from(processed.values()).some(
(entry) => entry.abs === absPath,
));
if (alreadyProcessed) {
continue; continue;
} }
// This attachment was in the list but not referenced in HTML - add it // This attachment was in the list but not referenced in HTML - add it
const { attachmentId, apiFilePath, abs } = processFile(href); const { attachmentId, apiFilePath, abs } = processFile(resolvedHref);
const mime = mimeType || getMimeType(abs); const mime = mimeType || getMimeType(abs);
// Add as attachment node at the end // Add as attachment node at the end
+3 -1
View File
@@ -124,7 +124,9 @@
"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": []
} }
+3 -1
View File
@@ -9,5 +9,7 @@
"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,18 +5,23 @@ import { mathInlineExtension } from "./math-inline.marked";
marked.use({ marked.use({
renderer: { renderer: {
// @ts-ignore list({ ordered, start, items }) {
list(body: string, isOrdered: boolean, start: number) { let body = "";
if (isOrdered) { for (const item of items) {
const startAttr = start !== 1 ? ` start="${start}"` : ""; body += this.listitem(item);
return `<ol ${startAttr}>\n${body}</ol>\n`;
} }
const dataType = body.includes(`<input`) ? ' data-type="taskList"' : ""; if (ordered) {
const startAttr = start !== 1 ? ` start="${start}"` : "";
return `<ol${startAttr}>\n${body}</ol>\n`;
}
const isTaskList = items.some((item) => item.task);
const dataType = isTaskList ? ' data-type="taskList"' : "";
return `<ul${dataType}>\n${body}</ul>\n`; return `<ul${dataType}>\n${body}</ul>\n`;
}, },
// @ts-ignore listitem({ tokens, task: isTask, checked: isChecked }) {
listitem({ text, raw, task: isTask, checked: isChecked }): string { const text = this.parser.parse(tokens);
if (!isTask) { if (!isTask) {
return `<li>${text}</li>\n`; return `<li>${text}</li>\n`;
} }
@@ -21,6 +21,7 @@ export function htmlToMarkdown(html: string): string {
callout, callout,
preserveDetail, preserveDetail,
listParagraph, listParagraph,
orderedListItem,
mathInline, mathInline,
mathBlock, mathBlock,
iframeEmbed, iframeEmbed,
@@ -41,6 +42,40 @@ 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) {
@@ -63,25 +98,17 @@ 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 checkbox = node.querySelector( const isChecked = node.getAttribute('data-checked') === 'true';
'input[type="checkbox"]', const div = node.querySelector('div');
) as HTMLInputElement; const text = div ? div.textContent.trim() : node.textContent.trim();
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 +
content + text +
(node.nextSibling && !/\n$/.test(content) ? '\n' : '') (node.nextSibling && !/\n$/.test(text) ? '\n' : '')
); );
}, },
}); });
+30 -24
View File
@@ -32,6 +32,8 @@ 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:
@@ -143,7 +145,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.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)
'@tiptap/pm': '@tiptap/pm':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4 version: 3.20.4
@@ -582,8 +584,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.4 specifier: 20.8.9
version: 20.8.4 version: 20.8.9
ioredis: ioredis:
specifier: ^5.10.1 specifier: ^5.10.1
version: 5.10.1 version: 5.10.1
@@ -627,8 +629,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.3 specifier: ^8.0.4
version: 8.0.3 version: 8.0.4
openid-client: openid-client:
specifier: ^6.8.2 specifier: ^6.8.2
version: 6.8.2 version: 6.8.2
@@ -799,7 +801,11 @@ 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:
@@ -5866,8 +5872,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.4: brace-expansion@5.0.5:
resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
braces@3.0.3: braces@3.0.3:
@@ -7268,8 +7274,8 @@ packages:
engines: {node: '>=0.4.7'} engines: {node: '>=0.4.7'}
hasBin: true hasBin: true
happy-dom@20.8.4: happy-dom@20.8.9:
resolution: {integrity: sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==} resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
has-bigints@1.0.2: has-bigints@1.0.2:
@@ -8639,8 +8645,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.3: nodemailer@8.0.4:
resolution: {integrity: sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==} resolution: {integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
normalize-path@3.0.0: normalize-path@3.0.0:
@@ -8914,8 +8920,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.3.0: path-to-regexp@8.4.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} resolution: {integrity: sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==}
path-type@4.0.0: path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
@@ -13543,7 +13549,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.3.0 path-to-regexp: 8.4.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
@@ -13587,7 +13593,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.3.0 path-to-regexp: 8.4.0
reusify: 1.1.0 reusify: 1.1.0
tslib: 2.8.1 tslib: 2.8.1
optionalDependencies: optionalDependencies:
@@ -15472,11 +15478,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.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)':
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.4 happy-dom: 20.8.9
'@tiptap/pm@3.20.4': '@tiptap/pm@3.20.4':
dependencies: dependencies:
@@ -16660,7 +16666,7 @@ snapshots:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
brace-expansion@5.0.4: brace-expansion@5.0.5:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
@@ -18351,7 +18357,7 @@ snapshots:
optionalDependencies: optionalDependencies:
uglify-js: 3.19.3 uglify-js: 3.19.3
happy-dom@20.8.4: happy-dom@20.8.9:
dependencies: dependencies:
'@types/node': 25.5.0 '@types/node': 25.5.0
'@types/whatwg-mimetype': 3.0.2 '@types/whatwg-mimetype': 3.0.2
@@ -19737,7 +19743,7 @@ snapshots:
minimatch@10.2.4: minimatch@10.2.4:
dependencies: dependencies:
brace-expansion: 5.0.4 brace-expansion: 5.0.5
minimatch@3.1.5: minimatch@3.1.5:
dependencies: dependencies:
@@ -19862,7 +19868,7 @@ snapshots:
node-releases@2.0.27: {} node-releases@2.0.27: {}
nodemailer@8.0.3: {} nodemailer@8.0.4: {}
normalize-path@3.0.0: {} normalize-path@3.0.0: {}
@@ -20203,7 +20209,7 @@ snapshots:
lru-cache: 11.2.7 lru-cache: 11.2.7
minipass: 7.1.3 minipass: 7.1.3
path-to-regexp@8.3.0: {} path-to-regexp@8.4.0: {}
path-type@4.0.0: {} path-type@4.0.0: {}
@@ -21088,7 +21094,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.3.0 path-to-regexp: 8.4.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color