Compare commits

...

17 Commits

Author SHA1 Message Date
Philipinho 2fe2c0e6c1 don't zip sinple page exports 2026-03-29 02:02:53 +01:00
Philipinho 388572f689 fix extra paragraph in task list 2026-03-29 00:34:21 +00:00
Philipinho a8335475fd Merge branch 'main' into editor-972 2026-03-29 00:11:06 +00:00
Philipinho 6a90b318e5 cleanup two preceeding spaces in ordered lists item 2026-03-29 00:05:40 +00:00
Philipinho 8b4cc82e5a fix clipboardTextSerializer for single lines 2026-03-28 23:59:46 +00:00
Philipinho cda7cc9a57 return clipboardTextSerializer as markdown 2026-03-28 22:59:18 +00:00
Olivier Lambert a42ac3d450 fix: strip trailing whitespace-only paragraphs from pasted content (#2050) 2026-03-28 22:26:47 +00:00
Philipinho 59c5f25502 fix marked 2026-03-28 21:52:28 +00:00
Philipinho b9d58081b8 autojoiner 2026-03-28 21:52:04 +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
97 changed files with 3943 additions and 348 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.",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Insert horizontal rule divider", "Insert horizontal rule divider": "Insert horizontal rule divider",
"Upload any image from your device.": "Upload any image from your device.", "Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.", "Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.",
"Upload any file from your device.": "Upload any file from your device.", "Upload any file from your device.": "Upload any file from your device.",
"Uploading {{name}}": "Uploading {{name}}", "Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file", "Uploading file": "Uploading file",
@@ -351,6 +352,12 @@
"Divider": "Divider.", "Divider": "Divider.",
"Quote": "Quote.", "Quote": "Quote.",
"Image": "Image.", "Image": "Image.",
"Audio": "Audio.",
"Embed PDF": "Embed PDF",
"Upload and embed a PDF file.": "Upload and embed a PDF file.",
"Embed as PDF": "Embed as PDF",
"Failed to load PDF": "Failed to load PDF",
"Convert to attachment": "Convert to attachment",
"File attachment": "File attachment.", "File attachment": "File attachment.",
"Toggle block": "Toggle block.", "Toggle block": "Toggle block.",
"Callout": "Callout.", "Callout": "Callout.",
@@ -363,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",
@@ -442,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",
@@ -723,5 +733,7 @@
"Publish": "Publish.", "Publish": "Publish.",
"Security": "Security.", "Security": "Security.",
"Enforce SSO": "Enforce SSO.", "Enforce SSO": "Enforce SSO.",
"Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to log in with email and password." "Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to log in with email and password.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file"
} }
@@ -341,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;
@@ -71,7 +71,10 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
) : null ) : null
} }
variant="default" variant="default"
onClick={open} onClick={() => {
setActiveTab(isPubliclyShared ? "publish" : hasPagePermissions ? "access" : "publish");
open();
}}
> >
{t("Share")} {t("Share")}
</Button> </Button>
@@ -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" withPortal={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 {
@@ -1,17 +1,43 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core"; import { Group, Text, Paper, ActionIcon, Loader, Tooltip } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import { IconDownload, IconPaperclip } from "@tabler/icons-react"; import { IconDownload, IconFileTypePdf, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks"; import { useHover } from "@mantine/hooks";
import { formatBytes } from "@/lib"; import { formatBytes } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useCallback } from "react";
export default function AttachmentView(props: NodeViewProps) { export default function AttachmentView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { node, selected } = props; const { editor, node, getPos, selected } = props;
const { url, name, size } = node.attrs; const { url, name, size, mime, attachmentId } = node.attrs;
const { hovered, ref } = useHover(); const { hovered, ref } = useHover();
const isPdf = mime === "application/pdf" || name?.toLowerCase().endsWith(".pdf");
const handleEmbedAsPdf = useCallback(() => {
const pos = getPos();
if (pos === undefined || !url) return;
const nodeSize = node.nodeSize;
editor
.chain()
.insertContentAt(
{ from: pos, to: pos + nodeSize },
{
type: "pdf",
attrs: {
src: url,
name,
attachmentId,
size,
},
},
)
.run();
}, [editor, getPos, node, url, name, attachmentId]);
return ( return (
<NodeViewWrapper> <NodeViewWrapper>
<Paper withBorder p="4px" ref={ref} data-drag-handle> <Paper withBorder p="4px" ref={ref} data-drag-handle>
@@ -39,11 +65,20 @@ export default function AttachmentView(props: NodeViewProps) {
</Group> </Group>
{url && (selected || hovered) && ( {url && (selected || hovered) && (
<a href={getFileUrl(url)} target="_blank"> <Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
<ActionIcon variant="default" aria-label="download file"> {isPdf && editor.isEditable && (
<IconDownload size={18} /> <Tooltip label={t("Embed as PDF")} position="top" withinPortal={false}>
</ActionIcon> <ActionIcon variant="default" aria-label={t("Embed as PDF")} onClick={handleEmbedAsPdf}>
</a> <IconFileTypePdf size={18} />
</ActionIcon>
</Tooltip>
)}
<a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} />
</ActionIcon>
</a>
</Group>
)} )}
</Group> </Group>
</Paper> </Paper>
@@ -0,0 +1,123 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconDownload,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import classes from "../common/toolbar-menu.module.css";
export function AudioMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const audioAttrs = ctx.editor.getAttributes("audio");
return {
isAudio: ctx.editor.isActive("audio"),
src: audioAttrs?.src || null,
};
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("audio") && editor.getAttributes("audio").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "audio";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
const a = document.createElement("a");
a.href = url;
a.download = "";
a.click();
}, [editorState?.src]);
const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`audio-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon
onClick={handleDownload}
size="lg"
aria-label={t("Download")}
variant="subtle"
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu>
);
}
export default AudioMenu;
@@ -0,0 +1,37 @@
.audioWrapper {
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
.audio {
display: block;
width: 100%;
border-radius: 8px;
}
@@ -0,0 +1,65 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Loader, Text } from "@mantine/core";
import { useMemo } from "react";
import { getFileUrl } from "@/lib/config.ts";
import { isInternalFileUrl } from "@docmost/editor-ext";
import classes from "./audio-view.module.css";
import { useTranslation } from "react-i18next";
export default function AudioView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node } = props;
const { src, placeholder } = node.attrs;
const safeSrc = useMemo(() => {
if (!src || !isInternalFileUrl(src)) return null;
return getFileUrl(src);
}, [src]);
const previewSrc = useMemo(() => {
editor.storage.shared.audioPreviews =
editor.storage.shared.audioPreviews || {};
if (placeholder?.id) {
return editor.storage.shared.audioPreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return (
<NodeViewWrapper data-drag-handle>
<div className={`${classes.audioWrapper} ${!safeSrc ? classes.skeleton : ''}`}>
{safeSrc && (
<audio
className={classes.audio}
preload="metadata"
controls
src={safeSrc}
/>
)}
{!safeSrc && previewSrc && (
<Group pos="relative" w="100%">
<audio
className={classes.audio}
preload="metadata"
controls
src={previewSrc}
/>
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
)}
{!safeSrc && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md" h={54}>
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
</NodeViewWrapper>
);
}
@@ -0,0 +1,36 @@
import { handleAudioUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import i18n from "@/i18n.ts";
export const uploadAudioAction = handleAudioUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
try {
return await uploadFile(file, pageId);
} catch (err) {
notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err;
}
},
validateFn: (file) => {
if (!file.type.includes("audio/")) {
return false;
}
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: i18n.t("File exceeds the {{limit}} attachment limit", {
limit: formatBytes(getFileUploadSizeLimit()),
}),
});
return false;
}
return true;
},
});
@@ -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>
);
};
@@ -1,6 +1,7 @@
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { uploadPdfAction } from "../pdf/upload-pdf-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
@@ -12,6 +13,8 @@ import {
const ATTACHMENT_NODE_TYPES = [ const ATTACHMENT_NODE_TYPES = [
"image", "image",
"video", "video",
"audio",
"pdf",
"attachment", "attachment",
"excalidraw", "excalidraw",
"drawio", "drawio",
@@ -63,6 +66,7 @@ export const handlePaste = (
const pos = editor.state.selection.from; const pos = editor.state.selection.from;
uploadImageAction(file, editor, pos, pageId); uploadImageAction(file, editor, pos, pageId);
uploadVideoAction(file, editor, pos, pageId); uploadVideoAction(file, editor, pos, pageId);
uploadPdfAction(file, editor, pos, pageId);
uploadAttachmentAction(file, editor, pos, pageId); uploadAttachmentAction(file, editor, pos, pageId);
} }
return true; return true;
@@ -229,6 +233,7 @@ export const handleFileDrop = (
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadPdfAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
} }
return true; return true;
@@ -1,5 +1,5 @@
import type { ResizableNodeViewDirection } from "@tiptap/core";
import classes from "./node-resize.module.css"; import classes from "./node-resize.module.css";
import { ResizableNodeViewDirection } from "@docmost/editor-ext";
export function createResizeHandle( export function createResizeHandle(
direction: ResizableNodeViewDirection, direction: ResizableNodeViewDirection,
@@ -20,8 +20,8 @@
.cornerHandle { .cornerHandle {
position: absolute; position: absolute;
width: 36px; width: 24px;
height: 36px; height: 24px;
z-index: 2; z-index: 2;
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
@@ -42,13 +42,13 @@
} }
&::before { &::before {
width: 28px; width: 20px;
height: 3px; height: 3px;
} }
&::after { &::after {
width: 3px; width: 3px;
height: 28px; height: 20px;
} }
&:hover::before, &:hover::before,
@@ -74,6 +74,15 @@ export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight }); const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight });
constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight }; constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight };
useEffect(() => {
if (!dragRef.current && wrapperRef.current) {
widthRef.current = initialWidth;
heightRef.current = initialHeight;
wrapperRef.current.style.width = `${initialWidth}px`;
wrapperRef.current.style.height = `${initialHeight}px`;
}
}, [initialWidth, initialHeight]);
const handleMouseMove = useRef((e: MouseEvent) => { const handleMouseMove = useRef((e: MouseEvent) => {
const drag = dragRef.current; const drag = dragRef.current;
if (!drag || !wrapperRef.current) return; if (!drag || !wrapperRef.current) return;
@@ -86,8 +86,8 @@ export default function EmbedView(props: NodeViewProps) {
{embedUrl ? ( {embedUrl ? (
<div className={classes.embedContainer}> <div className={classes.embedContainer}>
<ResizableWrapper <ResizableWrapper
initialWidth={nodeWidth || 640} initialWidth={nodeWidth || 800}
initialHeight={nodeHeight || 480} initialHeight={nodeHeight || 600}
minWidth={200} minWidth={200}
maxWidth={1200} maxWidth={1200}
minHeight={200} minHeight={200}
@@ -102,8 +102,9 @@ export default function EmbedView(props: NodeViewProps) {
<iframe <iframe
className={classes.embedIframe} className={classes.embedIframe}
src={sanitizeUrl(embedUrl)} src={sanitizeUrl(embedUrl)}
allow="encrypted-media" allow="encrypted-media; clipboard-read; clipboard-write; picture-in-picture;"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups" loading="lazy"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
allowFullScreen allowFullScreen
frameBorder="0" frameBorder="0"
/> />
@@ -5,6 +5,9 @@
max-width: 100%; max-width: 100%;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite; animation: pulse 1.2s ease-in-out infinite;
@mixin light { @mixin light {
@@ -33,6 +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,
alignClass, alignClass,
)} )}
style={{ style={{
@@ -0,0 +1,145 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconPaperclip,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
export function PdfMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const pdfAttrs = ctx.editor.getAttributes("pdf");
return {
isPdf: ctx.editor.isActive("pdf"),
src: pdfAttrs?.src || null,
name: pdfAttrs?.name || null,
attachmentId: pdfAttrs?.attachmentId || null,
};
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state || !editor.isActive("pdf")) {
return false;
}
const { selection } = state;
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
if (!dom) return false;
return !!dom.querySelector("[data-pdf-error]");
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "pdf";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]);
const handleConvertToAttachment = useCallback(() => {
if (!editorState?.src) return;
const { selection } = editor.state;
const { from } = selection;
const node = editor.state.doc.nodeAt(from);
if (!node || node.type.name !== "pdf") return;
editor
.chain()
.insertContentAt(
{ from, to: from + node.nodeSize },
{
type: "attachment",
attrs: {
url: node.attrs.src,
name: node.attrs.name,
attachmentId: node.attrs.attachmentId,
size: node.attrs.size,
mime: "application/pdf",
},
},
)
.run();
}, [editor, editorState]);
const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`pdf-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Convert to attachment")} withinPortal={false}>
<ActionIcon
onClick={handleConvertToAttachment}
size="lg"
aria-label={t("Convert to attachment")}
variant="subtle"
>
<IconPaperclip size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu>
);
}
export default PdfMenu;
@@ -0,0 +1,100 @@
.pdfWrapper {
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
.pdfContainer {
display: flex;
justify-content: center;
}
.pdfResizeWrapper {
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}
.pdfIframe {
width: 100%;
height: 100%;
border: none;
border-radius: 8px;
}
.hoverMenu {
position: absolute;
top: 56px;
right: 8px;
z-index: 2;
display: flex;
gap: 4px;
padding: 4px;
border-radius: 6px;
opacity: 0;
transition: opacity 0.15s ease;
background-color: rgba(0, 0, 0, 0.5);
}
.hoverMenu::before {
content: "";
position: absolute;
inset: -12px;
}
.hoverMenu:hover {
opacity: 1;
}
.pdfResizeWrapper:hover .hoverMenu {
opacity: 1;
}
.pdfError {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 32px;
border-radius: 8px;
cursor: pointer;
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}
@@ -0,0 +1,168 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Group, Loader, Text, Tooltip } from "@mantine/core";
import { useCallback, useMemo, useState } from "react";
import { getFileUrl } from "@/lib/config.ts";
import { ResizableWrapper } from "../common/resizable-wrapper";
import clsx from "clsx";
import classes from "./pdf-view.module.css";
import { useTranslation } from "react-i18next";
import { isInternalFileUrl } from "@docmost/editor-ext";
import {
IconFileTypePdf,
IconPaperclip,
IconTrash,
} from "@tabler/icons-react";
export default function PdfView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node, getPos, selected, updateAttributes } = props;
const { src, placeholder, width: nodeWidth, height: nodeHeight } = node.attrs;
const [hasError, setHasError] = useState(false);
const safeSrc = useMemo(() => {
if (!src || !isInternalFileUrl(src)) return null;
return getFileUrl(src);
}, [src]);
const handleSelect = useCallback(() => {
const pos = getPos();
if (pos !== undefined) {
editor.commands.setNodeSelection(pos);
}
}, [editor, getPos]);
const handleResize = useCallback(
(newWidth: number, newHeight: number) => {
updateAttributes({ width: newWidth, height: newHeight });
},
[updateAttributes],
);
const handleConvertToAttachment = useCallback(() => {
if (!src) return;
const pos = getPos();
if (pos === undefined) return;
const currentNode = editor.state.doc.nodeAt(pos);
if (!currentNode || currentNode.type.name !== "pdf") return;
editor
.chain()
.insertContentAt(
{ from: pos, to: pos + currentNode.nodeSize },
{
type: "attachment",
attrs: {
url: currentNode.attrs.src,
name: currentNode.attrs.name,
attachmentId: currentNode.attrs.attachmentId,
size: currentNode.attrs.size,
mime: "application/pdf",
},
},
)
.run();
}, [editor, src, getPos]);
const handleDelete = useCallback(() => {
const pos = getPos();
if (pos === undefined) return;
editor.commands.setNodeSelection(pos);
editor.commands.deleteSelection();
}, [editor, getPos]);
if (!src || !safeSrc) {
return (
<NodeViewWrapper data-drag-handle>
<div className={`${classes.pdfWrapper} ${classes.skeleton}`} style={{ height: 600 }}>
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
</div>
</NodeViewWrapper>
);
}
if (hasError) {
return (
<NodeViewWrapper data-drag-handle>
<div data-pdf-error className={clsx(classes.pdfError, { "ProseMirror-selectednode": selected })} onClick={handleSelect}>
<IconFileTypePdf size={32} stroke={1.5} />
<Text size="sm" c="dimmed">
{t("Failed to load PDF")}
</Text>
</div>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper data-drag-handle className={classes.pdfNodeView}>
<div className={classes.pdfContainer}>
<ResizableWrapper
initialWidth={nodeWidth || 800}
initialHeight={nodeHeight || 600}
minWidth={200}
maxWidth={1200}
minHeight={200}
maxHeight={1200}
onResize={handleResize}
isEditable={editor.isEditable}
selected={selected}
className={clsx(classes.pdfResizeWrapper, {
"ProseMirror-selectednode": selected,
})}
>
<iframe
className={classes.pdfIframe}
src={safeSrc}
loading="lazy"
frameBorder="0"
onError={() => setHasError(true)}
onLoad={(e) => {
try {
const iframe = e.currentTarget;
const status = iframe.contentDocument?.querySelector("pre")?.textContent;
if (status && status.includes('"statusCode":404')) {
setHasError(true);
}
} catch {
// cross-origin - can't inspect, assume OK
}
}}
/>
{editor.isEditable && (
<div className={classes.hoverMenu}>
<Tooltip position="top" label={t("Convert to attachment")} withinPortal>
<ActionIcon
size="sm"
variant="filled"
color="dark"
onClick={handleConvertToAttachment}
aria-label={t("Convert to attachment")}
>
<IconPaperclip size={14} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal>
<ActionIcon
size="sm"
variant="filled"
color="dark"
onClick={handleDelete}
aria-label={t("Delete")}
>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
</div>
)}
</ResizableWrapper>
</div>
</NodeViewWrapper>
);
}
@@ -0,0 +1,36 @@
import { handlePdfUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import i18n from "@/i18n.ts";
export const uploadPdfAction = handlePdfUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
try {
return await uploadFile(file, pageId);
} catch (err) {
notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err;
}
},
validateFn: (file) => {
if (file.type !== "application/pdf") {
return false;
}
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: i18n.t("File exceeds the {{limit}} attachment limit", {
limit: formatBytes(getFileUploadSizeLimit()),
}),
});
return false;
}
return true;
},
});
@@ -12,7 +12,9 @@ import {
IconMath, IconMath,
IconMathFunction, IconMathFunction,
IconMovie, IconMovie,
IconMusic,
IconPaperclip, IconPaperclip,
IconFileTypePdf,
IconPhoto, IconPhoto,
IconTable, IconTable,
IconTypography, IconTypography,
@@ -30,7 +32,9 @@ import {
} from "@/features/editor/components/slash-menu/types"; } from "@/features/editor/components/slash-menu/types";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx"; import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx";
import { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-action.tsx";
import IconExcalidraw from "@/components/icons/icon-excalidraw"; import IconExcalidraw from "@/components/icons/icon-excalidraw";
import IconMermaid from "@/components/icons/icon-mermaid"; import IconMermaid from "@/components/icons/icon-mermaid";
import IconDrawio from "@/components/icons/icon-drawio"; import IconDrawio from "@/components/icons/icon-drawio";
@@ -161,7 +165,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{ {
title: "Image", title: "Image",
description: "Upload any image from your device.", description: "Upload any image from your device.",
searchTerms: ["photo", "picture", "media"], searchTerms: ["photo", "picture", "media", "file", "attachment"],
icon: IconPhoto, icon: IconPhoto,
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
@@ -194,7 +198,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{ {
title: "Video", title: "Video",
description: "Upload any video from your device.", description: "Upload any video from your device.",
searchTerms: ["video", "mp4", "media"], searchTerms: ["video", "mp4", "media", "file", "attachment"],
icon: IconMovie, icon: IconMovie,
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
@@ -224,10 +228,74 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.click(); input.click();
}, },
}, },
{
title: "Audio",
description: "Upload any audio from your device.",
searchTerms: ["audio", "music", "sound", "mp3", "media", "file", "attachment"],
icon: IconMusic,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
// upload audio
const input = document.createElement("input");
input.type = "file";
input.accept = "audio/*";
input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadAudioAction(file, editor, pos, pageId);
}
}
input.remove();
};
input.click();
},
},
{
title: "Embed PDF",
description: "Upload and embed a PDF file.",
searchTerms: ["pdf", "document", "embed"],
icon: IconFileTypePdf,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
const input = document.createElement("input");
input.type = "file";
input.accept = "application/pdf";
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadPdfAction(file, editor, pos, pageId);
}
}
input.remove();
};
input.click();
},
},
{ {
title: "File attachment", title: "File attachment",
description: "Upload any file from your device.", description: "Upload any file from your device.",
searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"], searchTerms: ["file", "attachment", "upload", "csv", "zip"],
icon: IconPaperclip, icon: IconPaperclip,
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
@@ -359,7 +427,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
editor.chain().focus().deleteRange(range).setDrawio().run(), editor.chain().focus().deleteRange(range).setDrawio().run(),
}, },
{ {
title: "Excalidraw diagram", title: "Excalidraw (Whiteboard)",
description: "Draw and sketch excalidraw diagrams", description: "Draw and sketch excalidraw diagrams",
searchTerms: ["diagrams", "draw", "sketch", "whiteboard"], searchTerms: ["diagrams", "draw", "sketch", "whiteboard"],
icon: IconExcalidraw, icon: IconExcalidraw,
@@ -548,7 +616,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{ {
title: "YouTube", title: "YouTube",
description: "Embed YouTube video", description: "Embed YouTube video",
searchTerms: ["youtube", "yt"], searchTerms: ["youtube", "yt", "media", "video"],
icon: YoutubeIcon, icon: YoutubeIcon,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor editor
@@ -647,7 +715,11 @@ export const getSuggestionItems = ({
}); });
if (filteredItems.length) { if (filteredItems.length) {
filteredGroups[group] = filteredItems; filteredGroups[group] = filteredItems.sort((a, b) => {
const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1;
const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1;
return aTitle - bTitle;
});
} }
} }
@@ -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;
@@ -5,6 +5,9 @@
max-width: 100%; max-width: 100%;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite; animation: pulse 1.2s ease-in-out infinite;
@mixin light { @mixin light {
@@ -26,6 +29,7 @@
} }
} }
} }
.video { .video {
display: block; display: block;
width: 100%; width: 100%;
@@ -33,6 +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,
alignClass, alignClass,
)} )}
style={{ style={{
@@ -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;
@@ -30,6 +30,7 @@ import {
TiptapImage, TiptapImage,
Callout, Callout,
TiptapVideo, TiptapVideo,
TiptapAudio,
LinkExtension, LinkExtension,
Selection, Selection,
Attachment, Attachment,
@@ -37,6 +38,7 @@ import {
Drawio, Drawio,
Excalidraw, Excalidraw,
Embed, Embed,
TiptapPdf,
SearchAndReplace, SearchAndReplace,
Mention, Mention,
TableDndExtension, TableDndExtension,
@@ -47,7 +49,7 @@ import {
SharedStorage, SharedStorage,
Columns, Columns,
Column, Column,
Status Status,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -68,11 +70,13 @@ import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx"; import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import StatusView from "@/features/editor/components/status/status-view.tsx"; import StatusView from "@/features/editor/components/status/status-view.tsx";
import VideoView from "@/features/editor/components/video/video-view.tsx"; import VideoView from "@/features/editor/components/video/video-view.tsx";
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx"; import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx"; import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import DrawioView from "../components/drawio/drawio-view"; import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx"; import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import { common, createLowlight } from "lowlight"; import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext"; import plaintext from "highlight.js/lib/languages/plaintext";
@@ -93,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);
@@ -269,6 +274,9 @@ export const mainExtensions = [
className: buildResizeClasses("node-video"), className: buildResizeClasses("node-video"),
}, },
}), }),
TiptapAudio.configure({
view: AudioView,
}),
Callout.configure({ Callout.configure({
view: CalloutView, view: CalloutView,
}), }),
@@ -313,6 +321,9 @@ export const mainExtensions = [
Embed.configure({ Embed.configure({
view: EmbedView, view: EmbedView,
}), }),
TiptapPdf.configure({
view: PdfView,
}),
Subpages.configure({ Subpages.configure({
view: SubpagesView, view: SubpagesView,
}), }),
@@ -343,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 } 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;
@@ -40,7 +61,7 @@ export const MarkdownClipboard = Extension.create({
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 html = markdownToHtml(text.replace(/\n+$/, ""));
const contentNodes = DOMParser.fromSchema( const contentNodes = DOMParser.fromSchema(
this.editor.schema, this.editor.schema,
@@ -53,6 +74,37 @@ export const MarkdownClipboard = Extension.create({
view.dispatch(tr); view.dispatch(tr);
return true; return true;
}, },
// Strip trailing whitespace-only paragraphs from pasted content.
// Terminals (GNOME Terminal, etc.) often include trailing
// whitespace in their HTML clipboard data, which ProseMirror
// 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;
// Remove trailing paragraphs that contain only whitespace
while (content.childCount > 1) {
const lastChild = content.lastChild;
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;
}
}
if (content !== slice.content) {
return new Slice(content, openStart, Math.max(openEnd, 1));
}
return slice;
},
clipboardTextParser: (text, context, plainText) => { clipboardTextParser: (text, context, plainText) => {
const link = find(text, { const link = find(text, {
defaultProtocol: "http", defaultProtocol: "http",
@@ -64,7 +116,7 @@ export const MarkdownClipboard = Extension.create({
return null; return null;
} }
const parsed = markdownToHtml(text); const parsed = markdownToHtml(text.replace(/\n+$/, ""));
return DOMParser.fromSchema(this.editor.schema).parseSlice( return DOMParser.fromSchema(this.editor.schema).parseSlice(
elementFromString(parsed), elementFromString(parsed),
{ {
@@ -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,14 +37,17 @@ 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";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx"; import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx"; import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx"; import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import { import {
handleFileDrop, handleFileDrop,
@@ -73,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);
@@ -93,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(
@@ -414,6 +420,7 @@ export default function PageEditor({
<TableCellMenu editor={editor} appendTo={menuContainerRef} /> <TableCellMenu editor={editor} appendTo={menuContainerRef} />
<ImageMenu editor={editor} /> <ImageMenu editor={editor} />
<VideoMenu editor={editor} /> <VideoMenu editor={editor} />
<PdfMenu editor={editor} />
<CalloutMenu editor={editor} /> <CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} /> <SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} /> <ExcalidrawMenu editor={editor} />
@@ -421,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")}
@@ -133,10 +133,18 @@
border-top: 1px solid #68cef8; border-top: 1px solid #68cef8;
} }
&[contenteditable="false"] hr.ProseMirror-selectednode {
border-top: none;
}
.ProseMirror-selectednode { .ProseMirror-selectednode {
outline: 2px solid #70cff8; outline: 2px solid #70cff8;
} }
&[contenteditable="false"] .ProseMirror-selectednode {
outline: none;
}
& > .react-renderer { & > .react-renderer {
margin-top: var(--mantine-spacing-sm); margin-top: var(--mantine-spacing-sm);
margin-bottom: var(--mantine-spacing-sm); margin-bottom: var(--mantine-spacing-sm);
@@ -8,7 +8,7 @@
} }
} }
.node-image, .node-video, .node-excalidraw, .node-drawio { .node-image, .node-video, .node-pdf, .node-excalidraw, .node-drawio {
&.ProseMirror-selectednode { &.ProseMirror-selectednode {
outline: none; outline: none;
} }
@@ -37,5 +37,28 @@
font-size: var(--mantine-font-size-md); font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-md); line-height: var(--mantine-line-height-md);
} }
.media-pulse {
animation: media-pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes media-pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
} }
@@ -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 {
+1
View File
@@ -14,6 +14,7 @@ i18n
.init({ .init({
fallbackLng: "en-US", fallbackLng: "en-US",
debug: false, debug: false,
showSupportNotice: false,
load: 'currentOnly', load: 'currentOnly',
interpolation: { interpolation: {
+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);
} }
}, },
@@ -24,6 +24,8 @@ import {
CustomTable, CustomTable,
TiptapImage, TiptapImage,
TiptapVideo, TiptapVideo,
TiptapAudio,
TiptapPdf,
TrailingNode, TrailingNode,
Attachment, Attachment,
Drawio, Drawio,
@@ -86,6 +88,8 @@ export const tiptapExtensions = [
Youtube, Youtube,
TiptapImage, TiptapImage,
TiptapVideo, TiptapVideo,
TiptapAudio,
TiptapPdf,
Callout, Callout,
Attachment, Attachment,
CustomCodeBlock, CustomCodeBlock,
+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];
@@ -102,6 +102,8 @@ export function isAttachmentNode(nodeType: string) {
'attachment', 'attachment',
'image', 'image',
'video', 'video',
'audio',
'pdf',
'excalidraw', 'excalidraw',
'drawio', 'drawio',
]; ];
@@ -15,4 +15,9 @@ export const inlineFileExtensions = [
'.pdf', '.pdf',
'.mp4', '.mp4',
'.mov', '.mov',
'.mp3',
'.wav',
'.ogg',
'.m4a',
'.webm',
]; ];
@@ -457,6 +457,10 @@ export class AttachmentController {
const rangeHeader = req.headers.range; const rangeHeader = req.headers.range;
res.header('Accept-Ranges', 'bytes'); res.header('Accept-Ranges', 'bytes');
res.header(
'Content-Security-Policy',
"base-uri 'none'; object-src 'self'; default-src 'self';",
);
if (!inlineFileExtensions.includes(attachment.fileExt)) { if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header( res.header(
@@ -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',
); );
@@ -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(
@@ -190,13 +190,32 @@ export class ImportAttachmentService {
} }
} }
// Build a map from resolved archive path → real filename from Confluence
// metadata. Confluence Server archives often store files under numeric IDs
// (e.g. "attachments/65601/65602") instead of the original filename.
const pageDir = path.dirname(pageRelativePath);
const attachmentNameByRelPath = new Map<string, string>();
for (const attachment of pageAttachments) {
const relPath = resolveRelativeAttachmentPath(
attachment.href,
pageDir,
attachmentCandidates,
);
if (relPath && attachment.fileName) {
attachmentNameByRelPath.set(relPath, attachment.fileName);
}
}
const uploadOnce = (relPath: string) => { const uploadOnce = (relPath: string) => {
const abs = attachmentCandidates.get(relPath)!; const abs = attachmentCandidates.get(relPath)!;
const attachmentId = v7(); const attachmentId = v7();
const ext = path.extname(abs);
const realName = attachmentNameByRelPath.get(relPath);
const baseName = realName || path.basename(abs);
const ext = path.extname(baseName);
const fileNameWithExt = const fileNameWithExt =
sanitizeFileName(path.basename(abs, ext)) + ext.toLowerCase(); sanitizeFileName(path.basename(baseName, ext)) + ext.toLowerCase();
const storageFilePath = `${getAttachmentFolderPath( const storageFilePath = `${getAttachmentFolderPath(
AttachmentType.File, AttachmentType.File,
@@ -240,7 +259,6 @@ export class ImportAttachmentService {
return fresh; return fresh;
}; };
const pageDir = path.dirname(pageRelativePath);
const $ = load(html); const $ = load(html);
// image // image
@@ -335,6 +353,28 @@ export class ImportAttachmentService {
unwrapFromParagraph($, $vid); unwrapFromParagraph($, $vid);
} }
// audio
for (const audEl of $('audio').toArray()) {
const $aud = $(audEl);
const src = cleanUrlString($aud.attr('src') ?? '')!;
if (!src || src.startsWith('http')) continue;
const relPath = resolveRelativeAttachmentPath(
src,
pageDir,
attachmentCandidates,
);
if (!relPath) continue;
const { attachmentId, apiFilePath } = processFile(relPath);
$aud
.attr('src', apiFilePath)
.attr('data-attachment-id', attachmentId);
unwrapFromParagraph($, $aud);
}
// <div data-type="attachment"> // <div data-type="attachment">
for (const el of $('div[data-type="attachment"]').toArray()) { for (const el of $('div[data-type="attachment"]').toArray()) {
const $oldDiv = $(el); const $oldDiv = $(el);
@@ -401,7 +441,18 @@ export class ImportAttachmentService {
const { attachmentId, apiFilePath, abs } = processFile(relPath); const { attachmentId, apiFilePath, abs } = processFile(relPath);
const ext = path.extname(relPath).toLowerCase(); const ext = path.extname(relPath).toLowerCase();
if (ext === '.mp4') { const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.webm', '.flac', '.aac']);
if (ext === '.pdf') {
const $pdf = $('<div>')
.attr('data-type', 'pdf')
.attr('src', apiFilePath)
.attr('data-attachment-id', attachmentId)
.attr('width', '800')
.attr('height', '600');
$a.replaceWith($pdf);
unwrapFromParagraph($, $pdf);
} else if (ext === '.mp4') {
const $video = $('<video>') const $video = $('<video>')
.attr('src', apiFilePath) .attr('src', apiFilePath)
.attr('data-attachment-id', attachmentId) .attr('data-attachment-id', attachmentId)
@@ -409,6 +460,12 @@ export class ImportAttachmentService {
.attr('data-align', 'center'); .attr('data-align', 'center');
$a.replaceWith($video); $a.replaceWith($video);
unwrapFromParagraph($, $video); unwrapFromParagraph($, $video);
} else if (audioExtensions.has(ext)) {
const $audio = $('<audio>')
.attr('src', apiFilePath)
.attr('data-attachment-id', attachmentId);
$a.replaceWith($audio);
unwrapFromParagraph($, $audio);
} else { } else {
const confAliasName = $a.attr('data-linked-resource-default-alias'); const confAliasName = $a.attr('data-linked-resource-default-alias');
let attachmentName = path.basename(abs); let attachmentName = path.basename(abs);
@@ -555,7 +612,7 @@ export class ImportAttachmentService {
// Post-process DOM elements to add file sizes after uploads complete // Post-process DOM elements to add file sizes after uploads complete
// This avoids blocking file operations during initial DOM processing // This avoids blocking file operations during initial DOM processing
const elementsNeedingSize = $( const elementsNeedingSize = $(
'[data-attachment-id]:not([data-attachment-size])', '[data-attachment-id]:not([data-attachment-size]):not([data-size])',
); );
for (const element of elementsNeedingSize.toArray()) { for (const element of elementsNeedingSize.toArray()) {
const $el = $(element); const $el = $(element);
@@ -570,7 +627,14 @@ export class ImportAttachmentService {
if (processedEntry) { if (processedEntry) {
try { try {
const stat = await fs.stat(processedEntry.abs); const stat = await fs.stat(processedEntry.abs);
$el.attr('data-attachment-size', stat.size.toString()); const sizeStr = stat.size.toString();
const tagName = $el.prop('tagName')?.toLowerCase();
// audio and pdf nodes use data-size, attachment nodes use data-attachment-size
if (tagName === 'audio' || $el.attr('data-type') === 'pdf') {
$el.attr('data-size', sizeStr);
} else {
$el.attr('data-attachment-size', sizeStr);
}
} catch (error) { } catch (error) {
this.logger.debug( this.logger.debug(
`Could not get size for ${processedEntry.abs}:`, `Could not get size for ${processedEntry.abs}:`,
@@ -41,6 +41,15 @@ export function resolveRelativeAttachmentPath(
'ImportUtils', 'ImportUtils',
); );
} }
// Confluence Server uses "/download/attachments/..." in HTML but the ZIP
// stores files under "attachments/...". Strip the "download/" prefix so
// the path can match candidates from the archive.
const confluenceStripped = mainRel.replace(
/^download\/attachments\//,
'attachments/',
);
const fallback = path const fallback = path
.normalize(path.join(pageDir, mainRel)) .normalize(path.join(pageDir, mainRel))
.split(path.sep) .split(path.sep)
@@ -49,9 +58,13 @@ export function resolveRelativeAttachmentPath(
if (attachmentCandidates.has(mainRel)) { if (attachmentCandidates.has(mainRel)) {
return mainRel; return mainRel;
} }
if (confluenceStripped !== mainRel && attachmentCandidates.has(confluenceStripped)) {
return confluenceStripped;
}
if (attachmentCandidates.has(fallback)) { if (attachmentCandidates.has(fallback)) {
return fallback; return fallback;
} }
return null; return null;
} }
@@ -66,25 +66,25 @@ export class LocalDriver implements StorageDriver {
} }
async readStream(filePath: string): Promise<Readable> { async readStream(filePath: string): Promise<Readable> {
try { const fullPath = this._fullPath(filePath);
return createReadStream(this._fullPath(filePath)); if (!(await fs.pathExists(fullPath))) {
} catch (err) { throw new Error(`File not found: ${filePath}`);
throw new Error(`Failed to read file: ${(err as Error).message}`);
} }
return createReadStream(fullPath);
} }
async readRangeStream( async readRangeStream(
filePath: string, filePath: string,
range: { start: number; end: number }, range: { start: number; end: number },
): Promise<Readable> { ): Promise<Readable> {
try { const fullPath = this._fullPath(filePath);
return createReadStream(this._fullPath(filePath), { if (!(await fs.pathExists(fullPath))) {
start: range.start, throw new Error(`File not found: ${filePath}`);
end: range.end,
});
} catch (err) {
throw new Error(`Failed to read file: ${(err as Error).message}`);
} }
return createReadStream(fullPath, {
start: range.start,
end: range.end,
});
} }
async exists(filePath: string): Promise<boolean> { async exists(filePath: string): Promise<boolean> {
+5 -3
View File
@@ -30,6 +30,7 @@
"@joplin/turndown-plugin-gfm": "^1.0.64", "@joplin/turndown-plugin-gfm": "^1.0.64",
"@sindresorhus/slugify": "3.0.0", "@sindresorhus/slugify": "3.0.0",
"@tiptap/core": "3.20.4", "@tiptap/core": "3.20.4",
"@tiptap/extension-audio": "3.20.4",
"@tiptap/extension-code-block": "3.20.4", "@tiptap/extension-code-block": "3.20.4",
"@tiptap/extension-collaboration": "3.20.4", "@tiptap/extension-collaboration": "3.20.4",
"@tiptap/extension-collaboration-caret": "3.20.4", "@tiptap/extension-collaboration-caret": "3.20.4",
@@ -94,8 +95,7 @@
"packageManager": "pnpm@10.4.0", "packageManager": "pnpm@10.4.0",
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
"react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch", "react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch"
"@tiptap/core": "patches/@tiptap__core.patch"
}, },
"overrides": { "overrides": {
"prosemirror-changeset": "2.4.0", "prosemirror-changeset": "2.4.0",
@@ -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": []
} }
+4
View File
@@ -11,6 +11,7 @@ export * from "./lib/media-utils";
export * from "./lib/link"; export * from "./lib/link";
export * from "./lib/selection"; export * from "./lib/selection";
export * from "./lib/attachment"; export * from "./lib/attachment";
export * from "./lib/audio";
export * from "./lib/custom-code-block"; export * from "./lib/custom-code-block";
export * from "./lib/drawio"; export * from "./lib/drawio";
export * from "./lib/excalidraw"; export * from "./lib/excalidraw";
@@ -27,3 +28,6 @@ export * from "./lib/shared-storage";
export * from "./lib/recreate-transform"; export * from "./lib/recreate-transform";
export * from "./lib/columns"; export * from "./lib/columns";
export * from "./lib/status"; export * from "./lib/status";
export * from "./lib/pdf";
export * from "./lib/resizable-nodeview";
@@ -0,0 +1,139 @@
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
import { Command } from "@tiptap/core";
const findAudioNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
doc.descendants((node, pos) => {
if (result) return false;
if (
node.type.name === "audio" &&
node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
}
return true;
});
return result;
};
const handleAudioUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, editor, pos, pageId) => {
const validated = validateFn?.(file);
// @ts-ignore
if (!validated) return;
const objectUrl = URL.createObjectURL(file);
const placeholderId = generateNodeId();
let placeholderInserted = false;
editor.storage.shared.audioPreviews =
editor.storage.shared.audioPreviews || {};
editor.storage.shared.audioPreviews[placeholderId] = objectUrl;
const insertPlaceholder = (): Command => {
return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.audio?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
});
if (!initialPlaceholderNode) return false;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) {
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
};
const replacePlaceholderWithAudio = (attachment: IAttachment): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAudioNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null || !attachment) return;
tr.setNodeMarkup(currentPos, undefined, {
src: `/api/files/${attachment.id}/${attachment.fileName}`,
attachmentId: attachment.id,
size: attachment.fileSize,
});
return true;
};
};
const removePlaceholder = (): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAudioNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null) return false;
tr.delete(currentPos, currentPos + 2);
return true;
};
};
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
const disposePreviewFile = () => {
URL.revokeObjectURL(objectUrl);
if (editor.storage.shared.audioPreviews) {
delete editor.storage.shared.audioPreviews[placeholderId];
}
};
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithAudio(attachment));
disposePreviewFile();
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithAudio(attachment))
.run();
disposePreviewFile();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
disposePreviewFile();
}
};
export { handleAudioUpload };
+134
View File
@@ -0,0 +1,134 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "../media-utils";
import { sanitizeUrl, isInternalFileUrl } from "../utils";
export interface AudioOptions {
view: any;
HTMLAttributes: Record<string, any>;
}
export interface AudioAttributes {
src?: string;
attachmentId?: string;
size?: number;
placeholder?: {
id: string;
name: string;
};
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
audioBlock: {
setAudio: (attributes: AudioAttributes) => ReturnType;
};
}
}
export const TiptapAudio = Node.create<AudioOptions>({
name: "audio",
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
view: null,
HTMLAttributes: {},
};
},
addAttributes() {
return {
src: {
default: "",
parseHTML: (element) => {
const src = element.getAttribute("src");
const sanitized = sanitizeUrl(src);
return isInternalFileUrl(sanitized) ? sanitized : "";
},
renderHTML: (attributes) => ({
src: isInternalFileUrl(attributes.src)
? sanitizeUrl(attributes.src)
: "",
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: AudioAttributes) => ({
"data-attachment-id": attributes.attachmentId,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
renderHTML: (attributes: AudioAttributes) => ({
"data-size": attributes.size,
}),
},
placeholder: {
default: null,
rendered: false,
},
};
},
parseHTML() {
return [
{
tag: "audio",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"audio",
mergeAttributes(
{ controls: "true", preload: "metadata" },
this.options.HTMLAttributes,
HTMLAttributes,
),
["source", { src: HTMLAttributes.src }],
];
},
addCommands() {
return {
setAudio:
(attrs: AudioAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "audio",
attrs: attrs,
});
},
};
},
addNodeView() {
if (this.options.view) {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
return ({ node, HTMLAttributes }) => {
const dom = document.createElement("div");
const audio = document.createElement("audio");
const src = node.attrs.src;
if (src && isInternalFileUrl(src)) {
audio.src = normalizeFileUrl(src);
}
audio.controls = true;
audio.preload = "metadata";
audio.style.width = "100%";
dom.append(audio);
return { dom };
};
},
});
@@ -0,0 +1,2 @@
export { TiptapAudio } from "./audio";
export * from "./audio-upload";
+5 -5
View File
@@ -1,5 +1,6 @@
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; import { Node, mergeAttributes } from "@tiptap/core";
import type { ResizableNodeViewDirection } from "@tiptap/core"; import { ResizableNodeView } from "./resizable-nodeview";
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "./media-utils"; import { normalizeFileUrl } from "./media-utils";
@@ -320,12 +321,11 @@ export const Drawio = Node.create<DrawioOptions>({
// Show skeleton background while image loads from server // Show skeleton background while image loads from server
dom.style.pointerEvents = "none"; dom.style.pointerEvents = "none";
dom.style.background = el.classList.add("media-pulse");
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.onload = () => { el.onload = () => {
dom.style.pointerEvents = ""; dom.style.pointerEvents = "";
dom.style.background = ""; el.classList.remove("media-pulse");
}; };
return nodeView; return nodeView;
+2 -2
View File
@@ -64,14 +64,14 @@ export const Embed = Node.create<EmbedOptions>({
}), }),
}, },
width: { width: {
default: 640, default: 800,
parseHTML: (element) => element.getAttribute("data-width"), parseHTML: (element) => element.getAttribute("data-width"),
renderHTML: (attributes: EmbedAttributes) => ({ renderHTML: (attributes: EmbedAttributes) => ({
"data-width": attributes.width, "data-width": attributes.width,
}), }),
}, },
height: { height: {
default: 480, default: 600,
parseHTML: (element) => element.getAttribute("data-height"), parseHTML: (element) => element.getAttribute("data-height"),
renderHTML: (attributes: EmbedAttributes) => ({ renderHTML: (attributes: EmbedAttributes) => ({
"data-height": attributes.height, "data-height": attributes.height,
+5 -5
View File
@@ -1,5 +1,6 @@
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; import { Node, mergeAttributes } from "@tiptap/core";
import type { ResizableNodeViewDirection } from "@tiptap/core"; import { ResizableNodeView } from "./resizable-nodeview";
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "./media-utils"; import { normalizeFileUrl } from "./media-utils";
@@ -320,12 +321,11 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
// Show skeleton background while image loads from server // Show skeleton background while image loads from server
dom.style.pointerEvents = "none"; dom.style.pointerEvents = "none";
dom.style.background = el.classList.add("media-pulse");
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.onload = () => { el.onload = () => {
dom.style.pointerEvents = ""; dom.style.pointerEvents = "";
dom.style.background = ""; el.classList.remove("media-pulse");
}; };
return nodeView; return nodeView;
+4 -5
View File
@@ -4,10 +4,10 @@ import { ReactNodeViewRenderer } from "@tiptap/react";
import { import {
mergeAttributes, mergeAttributes,
Range, Range,
ResizableNodeView,
} from "@tiptap/core"; } from "@tiptap/core";
import { ResizableNodeView } from "../resizable-nodeview";
import type { ResizableNodeViewDirection } from "../resizable-nodeview";
import { normalizeFileUrl } from "../media-utils"; import { normalizeFileUrl } from "../media-utils";
import type { ResizableNodeViewDirection } from "@tiptap/core";
export type ImageResizeOptions = { export type ImageResizeOptions = {
enabled: boolean; enabled: boolean;
@@ -362,12 +362,11 @@ export const TiptapImage = Image.extend<ImageOptions>({
// Show skeleton background while image loads from server // Show skeleton background while image loads from server
dom.style.pointerEvents = "none"; dom.style.pointerEvents = "none";
dom.style.background = el.classList.add("media-pulse");
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.onload = () => { el.onload = () => {
dom.style.pointerEvents = ""; dom.style.pointerEvents = "";
dom.style.background = ""; el.classList.remove("media-pulse");
}; };
return nodeView; return nodeView;
@@ -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' : '')
); );
}, },
}); });
+2
View File
@@ -0,0 +1,2 @@
export { TiptapPdf } from "./pdf";
export * from "./pdf-upload";
@@ -0,0 +1,123 @@
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
import { Command } from "@tiptap/core";
const findPdfNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
doc.descendants((node, pos) => {
if (result) return false;
if (
node.type.name === "pdf" &&
node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
}
return true;
});
return result;
};
const handlePdfUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, editor, pos, pageId) => {
const validated = validateFn?.(file);
// @ts-ignore
if (!validated) return;
const placeholderId = generateNodeId();
let placeholderInserted = false;
const insertPlaceholder = (): Command => {
return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.pdf?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
});
if (!initialPlaceholderNode) return false;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) {
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
};
const replacePlaceholderWithPdf = (attachment: IAttachment): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findPdfNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null || !attachment) return;
tr.setNodeMarkup(currentPos, undefined, {
src: `/api/files/${attachment.id}/${attachment.fileName}`,
name: attachment.fileName,
attachmentId: attachment.id,
size: attachment.fileSize,
});
return true;
};
};
const removePlaceholder = (): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findPdfNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null) return false;
tr.delete(currentPos, currentPos + 2);
return true;
};
};
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithPdf(attachment));
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithPdf(attachment))
.run();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
}
};
export { handlePdfUpload };
+156
View File
@@ -0,0 +1,156 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { Node, mergeAttributes } from "@tiptap/core";
import { sanitizeUrl, isInternalFileUrl } from "../utils";
export type PdfOptions = {
view: any;
HTMLAttributes: Record<string, any>;
};
export type PdfAttributes = {
src?: string;
name?: string;
attachmentId?: string;
size?: number;
width?: number;
height?: number;
placeholder?: {
id: string;
name: string;
};
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
pdfBlock: {
setPdf: (attributes: PdfAttributes) => ReturnType;
};
}
}
export const TiptapPdf = Node.create<PdfOptions>({
name: "pdf",
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
view: null,
HTMLAttributes: {},
};
},
addAttributes() {
return {
src: {
default: "",
parseHTML: (element) => {
const src = element.getAttribute("src");
const sanitized = sanitizeUrl(src);
return isInternalFileUrl(sanitized) ? sanitized : "";
},
renderHTML: (attributes) => ({
src: isInternalFileUrl(attributes.src) ? sanitizeUrl(attributes.src) : "",
}),
},
name: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-name"),
renderHTML: (attributes: PdfAttributes) => ({
"data-name": attributes.name,
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: PdfAttributes) => ({
"data-attachment-id": attributes.attachmentId,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
renderHTML: (attributes: PdfAttributes) => ({
"data-size": attributes.size,
}),
},
width: {
default: 800,
parseHTML: (element) => {
const raw = element.getAttribute("width");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: PdfAttributes) => ({
width: attributes.width,
}),
},
height: {
default: 600,
parseHTML: (element) => {
const raw = element.getAttribute("height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: PdfAttributes) => ({
height: attributes.height,
}),
},
placeholder: {
default: null,
rendered: false,
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
[
"iframe",
{
src: isInternalFileUrl(HTMLAttributes.src) ? sanitizeUrl(HTMLAttributes.src) : "",
width: HTMLAttributes.width || 800,
height: HTMLAttributes.height || 600,
},
],
];
},
addCommands() {
return {
setPdf:
(attrs: PdfAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "pdf",
attrs,
});
},
};
},
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
});
File diff suppressed because it is too large Load Diff
+6
View File
@@ -382,6 +382,12 @@ export function sanitizeUrl(url: string | undefined): string {
return sanitized === "about:blank" ? "" : sanitized; return sanitized === "about:blank" ? "" : sanitized;
} }
export function isInternalFileUrl(url: string | undefined): boolean {
if (!url) return false;
const normalized = url.trim();
return normalized.startsWith("/api/files/") || normalized.startsWith("/files/");
}
const alphabet = "abcdefghijklmnopqrstuvwxyz"; const alphabet = "abcdefghijklmnopqrstuvwxyz";
export const generateNodeId = customAlphabet(alphabet, 12); export const generateNodeId = customAlphabet(alphabet, 12);
+5 -5
View File
@@ -1,7 +1,8 @@
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { Range, Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; import { Range, Node, mergeAttributes } from "@tiptap/core";
import { ResizableNodeView } from "../resizable-nodeview";
import type { ResizableNodeViewDirection } from "../resizable-nodeview";
import { normalizeFileUrl } from "../media-utils"; import { normalizeFileUrl } from "../media-utils";
import type { ResizableNodeViewDirection } from "@tiptap/core";
export type VideoResizeOptions = { export type VideoResizeOptions = {
enabled: boolean; enabled: boolean;
@@ -328,12 +329,11 @@ export const TiptapVideo = Node.create<VideoOptions>({
// Show skeleton background while video loads from server // Show skeleton background while video loads from server
dom.style.pointerEvents = "none"; dom.style.pointerEvents = "none";
dom.style.background = el.classList.add("media-pulse");
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.onloadedmetadata = () => { el.onloadedmetadata = () => {
dom.style.pointerEvents = ""; dom.style.pointerEvents = "";
dom.style.background = ""; el.classList.remove("media-pulse");
}; };
return nodeView; return nodeView;
+173 -162
View File
@@ -32,11 +32,10 @@ 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:
'@tiptap/core':
hash: efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00
path: patches/@tiptap__core.patch
react-arborist@3.4.0: react-arborist@3.4.0:
hash: 419b3b02e24afe928cc006a006f6e906666aff19aa6fd7daaa788ccc2202678a hash: 419b3b02e24afe928cc006a006f6e906666aff19aa6fd7daaa788ccc2202678a
path: patches/react-arborist@3.4.0.patch path: patches/react-arborist@3.4.0.patch
@@ -65,7 +64,7 @@ importers:
version: 3.4.4(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) version: 3.4.4(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)
'@hocuspocus/transformer': '@hocuspocus/transformer':
specifier: 3.4.4 specifier: 3.4.4
version: 3.4.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30) version: 3.4.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)
'@joplin/turndown': '@joplin/turndown':
specifier: ^4.0.82 specifier: ^4.0.82
version: 4.0.82 version: 4.0.82
@@ -77,85 +76,88 @@ importers:
version: 3.0.0 version: 3.0.0
'@tiptap/core': '@tiptap/core':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) version: 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-audio':
specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-code-block': '@tiptap/extension-code-block':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-collaboration': '@tiptap/extension-collaboration':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)
'@tiptap/extension-collaboration-caret': '@tiptap/extension-collaboration-caret':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))
'@tiptap/extension-color': '@tiptap/extension-color':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/extension-text-style@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))) version: 3.20.4(@tiptap/extension-text-style@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)))
'@tiptap/extension-document': '@tiptap/extension-document':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-heading': '@tiptap/extension-heading':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-highlight': '@tiptap/extension-highlight':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-history': '@tiptap/extension-history':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) version: 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-image': '@tiptap/extension-image':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-link': '@tiptap/extension-link':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-list': '@tiptap/extension-list':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-placeholder': '@tiptap/extension-placeholder':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) version: 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-subscript': '@tiptap/extension-subscript':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-superscript': '@tiptap/extension-superscript':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-table': '@tiptap/extension-table':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-text': '@tiptap/extension-text':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-text-align': '@tiptap/extension-text-align':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-text-style': '@tiptap/extension-text-style':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-typography': '@tiptap/extension-typography':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-unique-id': '@tiptap/extension-unique-id':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-youtube': '@tiptap/extension-youtube':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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
'@tiptap/react': '@tiptap/react':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@floating-ui/dom@1.7.3)(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 3.20.4(@floating-ui/dom@1.7.3)(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tiptap/starter-kit': '@tiptap/starter-kit':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4 version: 3.20.4
'@tiptap/suggestion': '@tiptap/suggestion':
specifier: 3.20.4 specifier: 3.20.4
version: 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/y-tiptap': '@tiptap/y-tiptap':
specifier: 3.0.2 specifier: 3.0.2
version: 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) version: 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)
@@ -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
@@ -4662,6 +4664,11 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/pm': ^3.20.4 '@tiptap/pm': ^3.20.4
'@tiptap/extension-audio@3.20.4':
resolution: {integrity: sha512-zX90pxpEYpV5jSrwtQw8Nmh2uK4WC+xwSG5MXVh4VLG8SnSE/vg/vCCqFiSHjXNfw68dctd6HJ0MJigwnuS0lw==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/extension-blockquote@3.20.4': '@tiptap/extension-blockquote@3.20.4':
resolution: {integrity: sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==} resolution: {integrity: sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==}
peerDependencies: peerDependencies:
@@ -5861,8 +5868,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:
@@ -7263,8 +7270,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:
@@ -8634,8 +8641,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:
@@ -8909,8 +8916,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==}
@@ -12707,9 +12714,9 @@ snapshots:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
'@hocuspocus/transformer@3.4.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)': '@hocuspocus/transformer@3.4.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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/starter-kit': 3.20.4 '@tiptap/starter-kit': 3.20.4
y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)
@@ -13538,7 +13545,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
@@ -13582,7 +13589,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:
@@ -15281,193 +15288,197 @@ snapshots:
'@tanstack/query-core': 5.90.17 '@tanstack/query-core': 5.90.17
react: 18.3.1 react: 18.3.1
'@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)': '@tiptap/core@3.20.4(@tiptap/pm@3.20.4)':
dependencies: dependencies:
'@tiptap/pm': 3.20.4 '@tiptap/pm': 3.20.4
'@tiptap/extension-blockquote@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-audio@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-bold@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-blockquote@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-bubble-menu@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': '@tiptap/extension-bold@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-bubble-menu@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies: dependencies:
'@floating-ui/dom': 1.7.4 '@floating-ui/dom': 1.7.4
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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
optional: true optional: true
'@tiptap/extension-bullet-list@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': '@tiptap/extension-bullet-list@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-code-block@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': '@tiptap/extension-code-block@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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/extension-code@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-code@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-collaboration-caret@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))': '@tiptap/extension-collaboration-caret@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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/y-tiptap': 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) '@tiptap/y-tiptap': 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)
'@tiptap/extension-collaboration@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)': '@tiptap/extension-collaboration@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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/y-tiptap': 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) '@tiptap/y-tiptap': 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)
yjs: 13.6.30 yjs: 13.6.30
'@tiptap/extension-color@3.20.4(@tiptap/extension-text-style@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)))': '@tiptap/extension-color@3.20.4(@tiptap/extension-text-style@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)))':
dependencies: dependencies:
'@tiptap/extension-text-style': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) '@tiptap/extension-text-style': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-document@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-document@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-dropcursor@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': '@tiptap/extension-dropcursor@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-floating-menu@3.20.4(@floating-ui/dom@1.7.3)(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': '@tiptap/extension-floating-menu@3.20.4(@floating-ui/dom@1.7.3)(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies: dependencies:
'@floating-ui/dom': 1.7.3 '@floating-ui/dom': 1.7.3
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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
optional: true optional: true
'@tiptap/extension-gapcursor@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': '@tiptap/extension-gapcursor@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-hard-break@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-hard-break@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-heading@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-heading@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-highlight@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-highlight@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-history@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': '@tiptap/extension-history@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-horizontal-rule@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': '@tiptap/extension-horizontal-rule@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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/extension-image@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-image@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-italic@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-italic@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-link@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': '@tiptap/extension-link@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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
linkifyjs: 4.3.2 linkifyjs: 4.3.2
'@tiptap/extension-list-item@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': '@tiptap/extension-list-item@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-list-keymap@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': '@tiptap/extension-list-keymap@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': '@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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/extension-ordered-list@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': '@tiptap/extension-ordered-list@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-paragraph@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-paragraph@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-placeholder@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))': '@tiptap/extension-placeholder@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-strike@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-strike@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-subscript@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': '@tiptap/extension-subscript@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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/extension-superscript@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': '@tiptap/extension-superscript@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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/extension-table@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': '@tiptap/extension-table@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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/extension-text-align@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-text-align@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-text-style@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-text-style@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-text@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-text@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-typography@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-typography@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-underline@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-underline@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-unique-id@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': '@tiptap/extension-unique-id@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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
uuid: 10.0.0 uuid: 10.0.0
'@tiptap/extension-youtube@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))': '@tiptap/extension-youtube@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': '@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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:
@@ -15490,9 +15501,9 @@ snapshots:
prosemirror-transform: 1.10.4 prosemirror-transform: 1.10.4
prosemirror-view: 1.40.0 prosemirror-view: 1.40.0
'@tiptap/react@3.20.4(@floating-ui/dom@1.7.3)(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': '@tiptap/react@3.20.4(@floating-ui/dom@1.7.3)(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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
'@types/react': 18.3.12 '@types/react': 18.3.12
'@types/react-dom': 18.3.1 '@types/react-dom': 18.3.1
@@ -15502,41 +15513,41 @@ snapshots:
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
use-sync-external-store: 1.6.0(react@18.3.1) use-sync-external-store: 1.6.0(react@18.3.1)
optionalDependencies: optionalDependencies:
'@tiptap/extension-bubble-menu': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extension-bubble-menu': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-floating-menu': 3.20.4(@floating-ui/dom@1.7.3)(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extension-floating-menu': 3.20.4(@floating-ui/dom@1.7.3)(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
transitivePeerDependencies: transitivePeerDependencies:
- '@floating-ui/dom' - '@floating-ui/dom'
'@tiptap/starter-kit@3.20.4': '@tiptap/starter-kit@3.20.4':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4) '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-blockquote': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) '@tiptap/extension-blockquote': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-bold': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) '@tiptap/extension-bold': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-bullet-list': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) '@tiptap/extension-bullet-list': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-code': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) '@tiptap/extension-code': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-code-block': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extension-code-block': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-document': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) '@tiptap/extension-document': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-dropcursor': 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) '@tiptap/extension-dropcursor': 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-gapcursor': 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) '@tiptap/extension-gapcursor': 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-hard-break': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) '@tiptap/extension-hard-break': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-heading': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) '@tiptap/extension-heading': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-horizontal-rule': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extension-horizontal-rule': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-italic': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) '@tiptap/extension-italic': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-link': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extension-link': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/extension-list-item': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) '@tiptap/extension-list-item': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-list-keymap': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) '@tiptap/extension-list-keymap': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-ordered-list': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) '@tiptap/extension-ordered-list': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
'@tiptap/extension-paragraph': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) '@tiptap/extension-paragraph': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-strike': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) '@tiptap/extension-strike': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-text': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) '@tiptap/extension-text': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extension-underline': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4)) '@tiptap/extension-underline': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) '@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/pm': 3.20.4 '@tiptap/pm': 3.20.4
'@tiptap/suggestion@3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)': '@tiptap/suggestion@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies: dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)': '@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)':
@@ -16651,7 +16662,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
@@ -18342,7 +18353,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
@@ -19728,7 +19739,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:
@@ -19853,7 +19864,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: {}
@@ -20194,7 +20205,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: {}
@@ -21079,7 +21090,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