Compare commits

...

15 Commits

Author SHA1 Message Date
Philipinho af5bd54fec feat: indexes 2026-03-29 20:25:34 +01:00
Philip Okugbe 2d6d829581 New translations translation.json (English) (#2066) 2026-03-29 16:25:45 +01:00
Philipinho 5cea30cc5c fix markdown paste 2026-03-29 16:11:21 +01:00
Philipinho bca85a49d6 pin marked version 2026-03-29 03:03:35 +01:00
Philipinho c9cdfa0f17 fix 2026-03-29 02:20:56 +01:00
Philip Okugbe 412962204c fix: editor fixes (#2067)
* autojoiner

* fix marked

* return clipboardTextSerializer as markdown

* fix clipboardTextSerializer for single lines

* cleanup two preceeding spaces in ordered lists item

* fix extra paragraph in task list

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

* feat: aduio

* support audio imports

* feat: use confluence real file names

* cleanup

* error handling

* hide notice

* add audio

* fix pulse

* Fix import and export

* unify pulse

* hide in readonly mode

* keywords

* keyword

* translations

* better sort

* feat: PDF embed

* cleanup

* remove audio menu

* open active

* hide focus on readonly mode

* increase iframe default dimension
2026-03-28 17:33:29 +00:00
99 changed files with 4299 additions and 367 deletions
@@ -341,6 +341,7 @@
"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 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.",
"Uploading {{name}}": "Lade {{name}} hoch",
"Uploading file": "Datei wird hochgeladen",
@@ -351,6 +352,12 @@
"Divider": "Trennlinie",
"Quote": "Zitat",
"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",
"Toggle block": "Block umschalten",
"Callout": "Hinweisbox",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Verhindern Sie, dass Mitglieder Seiten öffentlich teilen.",
"Toggle public sharing": "Öffentliches Teilen umschalten",
"Toggle space public sharing": "Öffentliches Teilen im Bereich umschalten",
"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",
"Prevent pages in this space from being shared publicly.": "Verhindern Sie, dass Seiten in diesem Bereich öffentlich geteilt werden.",
"Page permissions": "Seitenberechtigungen",
@@ -184,20 +184,20 @@
"Updated successfully": "Updated successfully.",
"User": "User.",
"Workspace": "Workspace.",
"Workspace Name": "Workspace Name.",
"Workspace Name": "Workspace name.",
"Workspace settings": "Workspace settings.",
"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 name": "Your name.",
"Your Name": "Your Name.",
"Your Name": "Your name.",
"Your password": "Your password.",
"Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.",
"Sidebar toggle": "Sidebar toggle.",
"Comments": "Comments.",
"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.",
"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 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.",
@@ -223,12 +223,12 @@
"Failed to delete comment": "Failed to delete comment",
"Comment resolved successfully": "Comment resolved 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",
"Resolve comment": "Resolve comment.",
"Unresolve comment": "Unresolve comment.",
"Resolve Comment Thread": "Resolve Comment Thread.",
"Unresolve Comment Thread": "Unresolve Comment Thread.",
"Unresolve comment": "Mark comment as unresolved.",
"Resolve Comment Thread": "Resolve 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 unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
"Resolved": "Resolved.",
@@ -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 audio from your device.",
"Upload any file from your device.": "Upload any file from your device.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
@@ -351,6 +352,12 @@
"Divider": "Divider.",
"Quote": "Quote.",
"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.",
"Toggle block": "Toggle block.",
"Callout": "Callout.",
@@ -363,7 +370,7 @@
"Insert mermaid diagram": "Insert mermaid diagram.",
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams.",
"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.",
"Turn into": "Turn into",
"Text align": "Text align",
@@ -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 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",
"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": "Insertar regla horizontal",
"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 audio from your device.": "Sube cualquier audio desde tu dispositivo.",
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
"Uploading {{name}}": "Subiendo {{name}}",
"Uploading file": "Subiendo archivo",
@@ -351,6 +352,12 @@
"Divider": "Divisor",
"Quote": "Cita",
"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",
"Toggle block": "Alternar bloque",
"Callout": "Aviso",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Evitar que los miembros compartan páginas públicamente.",
"Toggle public sharing": "Alternar el uso compartido público",
"Toggle space public sharing": "Alternar el uso compartido público del espacio",
"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",
"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},{",
@@ -341,6 +341,7 @@
"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 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.",
"Uploading {{name}}": "Téléchargement de {{name}}",
"Uploading file": "Téléchargement du fichier",
@@ -351,6 +352,12 @@
"Divider": "Diviseur",
"Quote": "Citation",
"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",
"Toggle block": "Basculer le bloc",
"Callout": "Appel",
@@ -415,7 +422,7 @@
"Move page": "Déplacer la page",
"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...",
"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.",
"Share": "Partager",
"Public sharing": "Partage public",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Empêcher les membres de partager des pages publiquement.",
"Toggle public sharing": "Basculer le partage public",
"Toggle space public sharing": "Basculer le partage public de l'espace",
"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",
"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",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Inserisci divisore di regola orizzontale",
"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 audio from your device.": "Carica qualsiasi audio dal tuo dispositivo.",
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
"Uploading {{name}}": "Caricamento di {{name}}",
"Uploading file": "Caricamento file",
@@ -351,6 +352,12 @@
"Divider": "Divisore",
"Quote": "Preventivo",
"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",
"Toggle block": "Attiva blocco",
"Callout": "Avviso",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Impedisci ai membri di condividere pubblicamente le pagine.",
"Toggle public sharing": "Attiva/disattiva la condivisione pubblica",
"Toggle space public sharing": "Attiva/disattiva la condivisione pubblica nello spazio",
"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",
"Prevent pages in this space from being shared publicly.": "Impedisci che le pagine in questo spazio vengano condivise pubblicamente.",
"Page permissions": "Autorizzazioni della pagina.",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "区切り線を挿入します",
"Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします",
"Upload any audio from your device.": "デバイスから音声ファイルをアップロードします。",
"Upload any file from your device.": "デバイスからファイルをアップロードします",
"Uploading {{name}}": "{{name}} をアップロード中",
"Uploading file": "ファイルをアップロード中",
@@ -351,6 +352,12 @@
"Divider": "区切り線",
"Quote": "引用",
"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": "ファイル添付",
"Toggle block": "ブロックを切り替える",
"Callout": "コールアウト",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "メンバーがページを公開で共有するのを防ぐ。",
"Toggle 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": "ワークスペースレベルで公開共有が無効になっています",
"Prevent pages in this space from being shared publicly.": "このスペース内のページが公開で共有されるのを防ぐ。",
"Page permissions": "ページのアクセス権",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "가로 구분선 삽입",
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
"Upload any audio from your device.": "기기에서 오디오를 업로드하세요.",
"Upload any file from your device.": "기기에서 파일을 업로드하세요.",
"Uploading {{name}}": "{{name}} 업로드 중",
"Uploading file": "파일 업로드 중",
@@ -351,6 +352,12 @@
"Divider": "구분선",
"Quote": "인용",
"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": "파일 첨부",
"Toggle block": "블록 토글",
"Callout": "경고 상자",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.",
"Toggle 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": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
"Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.",
"Page permissions": "페이지 권한},{",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Horizontale lijn invoegen",
"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 audio from your device.": "Upload een audio vanaf uw apparaat.",
"Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
"Uploading {{name}}": "Uploaden {{name}}",
"Uploading file": "Bestand uploaden",
@@ -351,6 +352,12 @@
"Divider": "Scheidingslijn",
"Quote": "Quote",
"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",
"Toggle block": "Schakel blok in/uit",
"Callout": "Opmerking",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Voorkom dat leden pagina's openbaar delen.",
"Toggle public sharing": "Wissel openbaar delen",
"Toggle space public sharing": "Wissel openbaar delen van ruimte",
"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",
"Prevent pages in this space from being shared publicly.": "Voorkom dat pagina's in deze ruimte openbaar worden gedeeld.",
"Page permissions": "Pagina rechten",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Insira um divisor horizontal",
"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 audio from your device.": "Envie qualquer áudio do seu dispositivo.",
"Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
"Uploading {{name}}": "Enviando {{name}}",
"Uploading file": "Enviando arquivo",
@@ -351,6 +352,12 @@
"Divider": "Divisor",
"Quote": "Citação",
"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",
"Toggle block": "Bloco colapsável",
"Callout": "Aviso",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Impedir que os membros compartilhem páginas publicamente.",
"Toggle public sharing": "Alternar compartilhamento público",
"Toggle space public sharing": "Alternar compartilhamento público do espaço",
"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",
"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},{",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Вставить горизонтальный разделитель",
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
"Upload any audio from your device.": "Загрузите любой аудиофайл с вашего устройства.",
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
"Uploading {{name}}": "Загрузка {{name}}",
"Uploading file": "Загрузка файла",
@@ -351,6 +352,12 @@
"Divider": "Разделитель",
"Quote": "Цитата",
"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": "Прикрепленный файл",
"Toggle block": "Сворачиваемый блок",
"Callout": "Выноска",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Запретить участникам делиться страницами публично.",
"Toggle 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": "Общий доступ отключен на уровне рабочего пространства",
"Prevent pages in this space from being shared publicly.": "Запретить делиться страницами в этом пространстве публично.",
"Page permissions": "Права доступа к странице},{",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
"Upload any audio from your device.": "Завантажте будь-який аудіофайл зі свого пристрою.",
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
"Uploading {{name}}": "Завантаження {{name}}",
"Uploading file": "Завантаження файлу",
@@ -351,6 +352,12 @@
"Divider": "Роздільник",
"Quote": "Цитата",
"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": "Прикріплений файл",
"Toggle block": "Блок, що згортається",
"Callout": "Виноска",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Перешкодити учасникам публічно ділитися сторінками.",
"Toggle 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": "Публічний доступ вимкнуто на рівні робочого простору",
"Prevent pages in this space from being shared publicly.": "Перешкодити публічному поширенню сторінок у цьому просторі.",
"Page permissions": "Права доступу до сторінки.",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "插入水平分割线",
"Upload any image from your device.": "从设备上传任何图像",
"Upload any video from your device.": "从设备上传任何视频",
"Upload any audio from your device.": "从您的设备上传任意音频文件。",
"Upload any file from your device.": "从设备上传任何文件",
"Uploading {{name}}": "正在上传{{name}}",
"Uploading file": "正在上传文件",
@@ -351,6 +352,12 @@
"Divider": "分割线",
"Quote": "引用",
"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": "文件附件",
"Toggle block": "切换块",
"Callout": "标注块",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "阻止成员公开分享页面。",
"Toggle 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": "公开分享在工作区级别被禁用",
"Prevent pages in this space from being shared publicly.": "阻止此空间中的页面被公开分享。",
"Page permissions": "页面权限},{",
+1
View File
@@ -16,4 +16,5 @@ export const Feature = {
AUDIT_LOGS: 'audit:logs',
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
} as const;
@@ -71,7 +71,10 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
) : null
}
variant="default"
onClick={open}
onClick={() => {
setActiveTab(isPubliclyShared ? "publish" : hasPagePermissions ? "access" : "publish");
open();
}}
>
{t("Share")}
</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 activeCommentIdAtom = 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,
draftCommentIdAtom,
showCommentPopupAtom,
showReadOnlyCommentPopupAtom,
readOnlyCommentDataAtom,
} from "@/features/comment/atoms/comment-atom";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
@@ -19,12 +21,15 @@ import { useTranslation } from "react-i18next";
interface CommentDialogProps {
editor: ReturnType<typeof useEditor>;
pageId: string;
readOnly?: boolean;
}
function CommentDialog({ editor, pageId }: CommentDialogProps) {
function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
const { t } = useTranslation();
const [comment, setComment] = useState("");
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom);
const [currentUser] = useAtom(currentUserAtom);
@@ -34,11 +39,17 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
handleDialogClose();
});
const createCommentMutation = useCreateCommentMutation();
const { isPending } = createCommentMutation;
const isPending = createCommentMutation.isPending;
const handleDialogClose = () => {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
if (readOnly) {
setShowReadOnlyCommentPopup(false);
// @ts-ignore
setReadOnlyCommentData(null);
} else {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
}
};
const getSelectedText = () => {
@@ -47,6 +58,11 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
};
const handleAddComment = async () => {
if (readOnly) {
await handleAddReadOnlyComment();
return;
}
try {
const selectedText = getSelectedText();
const commentData = {
@@ -65,7 +81,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
.run();
setActiveCommentId(createdComment.id);
//unselect text to close bubble menu
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
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) => {
setComment(newContent);
};
@@ -44,7 +44,9 @@ function CommentListWithTabs() {
const [isLoading, setIsLoading] = useState(false);
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
const { activeComments, resolvedComments } = useMemo(() => {
@@ -153,7 +155,7 @@ function CommentListWithTabs() {
)}
</Paper>
),
[comments, handleAddReply, isLoading, space?.membership?.role],
[comments, handleAddReply, isLoading, space?.membership?.role, canComment],
);
if (isCommentsLoading) {
@@ -75,7 +75,7 @@ function CommentMenu({
{isResolved ? t("Re-open comment") : t("Resolve comment")}
</Menu.Item>
) : (
<Tooltip label={upgradeLabel} position="left">
<Tooltip label={upgradeLabel} position="left" withinPortal={false}>
<Menu.Item disabled leftSection={<IconCircleCheck size={14} />}>
{t("Resolve comment")}
</Menu.Item>
@@ -17,6 +17,10 @@ export interface IComment {
deletedAt?: Date;
creator: IUser;
resolvedBy?: IUser;
yjsSelection?: {
anchor: any;
head: any;
};
}
export interface ICommentData {
@@ -1,17 +1,43 @@
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 { IconDownload, IconPaperclip } from "@tabler/icons-react";
import { IconDownload, IconFileTypePdf, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks";
import { formatBytes } from "@/lib";
import { useTranslation } from "react-i18next";
import { useCallback } from "react";
export default function AttachmentView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected } = props;
const { url, name, size } = node.attrs;
const { editor, node, getPos, selected } = props;
const { url, name, size, mime, attachmentId } = node.attrs;
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 (
<NodeViewWrapper>
<Paper withBorder p="4px" ref={ref} data-drag-handle>
@@ -39,11 +65,20 @@ export default function AttachmentView(props: NodeViewProps) {
</Group>
{url && (selected || hovered) && (
<a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} />
</ActionIcon>
</a>
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
{isPdf && editor.isEditable && (
<Tooltip label={t("Embed as PDF")} position="top" withinPortal={false}>
<ActionIcon variant="default" aria-label={t("Embed as PDF")} onClick={handleEmbedAsPdf}>
<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>
</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 { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
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 { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
@@ -12,6 +13,8 @@ import {
const ATTACHMENT_NODE_TYPES = [
"image",
"video",
"audio",
"pdf",
"attachment",
"excalidraw",
"drawio",
@@ -63,6 +66,7 @@ export const handlePaste = (
const pos = editor.state.selection.from;
uploadImageAction(file, editor, pos, pageId);
uploadVideoAction(file, editor, pos, pageId);
uploadPdfAction(file, editor, pos, pageId);
uploadAttachmentAction(file, editor, pos, pageId);
}
return true;
@@ -229,6 +233,7 @@ export const handleFileDrop = (
uploadImageAction(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);
}
return true;
@@ -1,5 +1,5 @@
import type { ResizableNodeViewDirection } from "@tiptap/core";
import classes from "./node-resize.module.css";
import { ResizableNodeViewDirection } from "@docmost/editor-ext";
export function createResizeHandle(
direction: ResizableNodeViewDirection,
@@ -20,8 +20,8 @@
.cornerHandle {
position: absolute;
width: 36px;
height: 36px;
width: 24px;
height: 24px;
z-index: 2;
opacity: 0;
transition: opacity 0.2s ease;
@@ -42,13 +42,13 @@
}
&::before {
width: 28px;
width: 20px;
height: 3px;
}
&::after {
width: 3px;
height: 28px;
height: 20px;
}
&:hover::before,
@@ -74,6 +74,15 @@ export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
const constraintsRef = useRef({ 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 drag = dragRef.current;
if (!drag || !wrapperRef.current) return;
@@ -86,8 +86,8 @@ export default function EmbedView(props: NodeViewProps) {
{embedUrl ? (
<div className={classes.embedContainer}>
<ResizableWrapper
initialWidth={nodeWidth || 640}
initialHeight={nodeHeight || 480}
initialWidth={nodeWidth || 800}
initialHeight={nodeHeight || 600}
minWidth={200}
maxWidth={1200}
minHeight={200}
@@ -102,8 +102,9 @@ export default function EmbedView(props: NodeViewProps) {
<iframe
className={classes.embedIframe}
src={sanitizeUrl(embedUrl)}
allow="encrypted-media"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allow="encrypted-media; clipboard-read; clipboard-write; picture-in-picture;"
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
allowFullScreen
frameBorder="0"
/>
@@ -5,6 +5,9 @@
max-width: 100%;
border-radius: 8px;
overflow: hidden;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
@@ -33,6 +33,7 @@ export default function ImageView(props: NodeViewProps) {
className={clsx(
selected && "ProseMirror-selectednode",
classes.imageWrapper,
!src && classes.skeleton,
alignClass,
)}
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,
IconMathFunction,
IconMovie,
IconMusic,
IconPaperclip,
IconFileTypePdf,
IconPhoto,
IconTable,
IconTypography,
@@ -30,7 +32,9 @@ import {
} from "@/features/editor/components/slash-menu/types";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-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 { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-action.tsx";
import IconExcalidraw from "@/components/icons/icon-excalidraw";
import IconMermaid from "@/components/icons/icon-mermaid";
import IconDrawio from "@/components/icons/icon-drawio";
@@ -161,7 +165,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "Image",
description: "Upload any image from your device.",
searchTerms: ["photo", "picture", "media"],
searchTerms: ["photo", "picture", "media", "file", "attachment"],
icon: IconPhoto,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
@@ -194,7 +198,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "Video",
description: "Upload any video from your device.",
searchTerms: ["video", "mp4", "media"],
searchTerms: ["video", "mp4", "media", "file", "attachment"],
icon: IconMovie,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
@@ -224,10 +228,74 @@ const CommandGroups: SlashMenuGroupedItemsType = {
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",
description: "Upload any file from your device.",
searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"],
searchTerms: ["file", "attachment", "upload", "csv", "zip"],
icon: IconPaperclip,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
@@ -359,7 +427,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
editor.chain().focus().deleteRange(range).setDrawio().run(),
},
{
title: "Excalidraw diagram",
title: "Excalidraw (Whiteboard)",
description: "Draw and sketch excalidraw diagrams",
searchTerms: ["diagrams", "draw", "sketch", "whiteboard"],
icon: IconExcalidraw,
@@ -548,7 +616,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "YouTube",
description: "Embed YouTube video",
searchTerms: ["youtube", "yt"],
searchTerms: ["youtube", "yt", "media", "video"],
icon: YoutubeIcon,
command: ({ editor, range }: CommandProps) => {
editor
@@ -647,7 +715,11 @@ export const getSuggestionItems = ({
});
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;
return editor.isActive("table") && !isCellSelection(state.selection);
},
[editor]
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
@@ -121,7 +121,11 @@ export const TableMenu = React.memo(
shouldShow={shouldShow}
>
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Add left column")}>
<Tooltip
position="top"
label={t("Add left column")}
withinPortal={false}
>
<ActionIcon
onClick={addColumnLeft}
variant="subtle"
@@ -132,7 +136,11 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Add right column")}>
<Tooltip
position="top"
label={t("Add right column")}
withinPortal={false}
>
<ActionIcon
onClick={addColumnRight}
variant="subtle"
@@ -143,7 +151,11 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete column")}>
<Tooltip
position="top"
label={t("Delete column")}
withinPortal={false}
>
<ActionIcon
onClick={deleteColumn}
variant="subtle"
@@ -156,7 +168,11 @@ export const TableMenu = React.memo(
<div className={classes.divider} />
<Tooltip position="top" label={t("Add row above")}>
<Tooltip
position="top"
label={t("Add row above")}
withinPortal={false}
>
<ActionIcon
onClick={addRowAbove}
variant="subtle"
@@ -167,7 +183,11 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Add row below")}>
<Tooltip
position="top"
label={t("Add row below")}
withinPortal={false}
>
<ActionIcon
onClick={addRowBelow}
variant="subtle"
@@ -178,7 +198,7 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete row")}>
<Tooltip position="top" label={t("Delete row")} withinPortal={false}>
<ActionIcon
onClick={deleteRow}
variant="subtle"
@@ -191,7 +211,11 @@ export const TableMenu = React.memo(
<div className={classes.divider} />
<Tooltip position="top" label={t("Toggle header row")}>
<Tooltip
position="top"
label={t("Toggle header row")}
withinPortal={false}
>
<ActionIcon
onClick={toggleHeaderRow}
variant="subtle"
@@ -202,7 +226,11 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Toggle header column")}>
<Tooltip
position="top"
label={t("Toggle header column")}
withinPortal={false}
>
<ActionIcon
onClick={toggleHeaderColumn}
variant="subtle"
@@ -215,7 +243,11 @@ export const TableMenu = React.memo(
<div className={classes.divider} />
<Tooltip position="top" label={t("Delete table")}>
<Tooltip
position="top"
label={t("Delete table")}
withinPortal={false}
>
<ActionIcon
onClick={deleteTable}
variant="subtle"
@@ -228,7 +260,7 @@ export const TableMenu = React.memo(
</div>
</BubbleMenu>
);
}
},
);
export default TableMenu;
@@ -5,6 +5,9 @@
max-width: 100%;
border-radius: 8px;
overflow: hidden;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
@@ -26,6 +29,7 @@
}
}
}
.video {
display: block;
width: 100%;
@@ -33,6 +33,7 @@ export default function VideoView(props: NodeViewProps) {
className={clsx(
selected && "ProseMirror-selectednode",
classes.videoWrapper,
!src && classes.skeleton,
alignClass,
)}
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,
Callout,
TiptapVideo,
TiptapAudio,
LinkExtension,
Selection,
Attachment,
@@ -37,6 +38,7 @@ import {
Drawio,
Excalidraw,
Embed,
TiptapPdf,
SearchAndReplace,
Mention,
TableDndExtension,
@@ -47,7 +49,7 @@ import {
SharedStorage,
Columns,
Column,
Status
Status,
} from "@docmost/editor-ext";
import {
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 StatusView from "@/features/editor/components/status/status-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 CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-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 { common, createLowlight } from "lowlight";
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 EmojiCommand from "./emoji-command";
import { countWords } from "alfaaz";
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@@ -269,6 +274,9 @@ export const mainExtensions = [
className: buildResizeClasses("node-video"),
},
}),
TiptapAudio.configure({
view: AudioView,
}),
Callout.configure({
view: CalloutView,
}),
@@ -313,6 +321,9 @@ export const mainExtensions = [
Embed.configure({
view: EmbedView,
}),
TiptapPdf.configure({
view: PdfView,
}),
Subpages.configure({
view: SubpagesView,
}),
@@ -343,6 +354,9 @@ export const mainExtensions = [
}).configure(),
Columns,
Column,
AutoJoiner.configure({
elementsToJoin: [],
}),
] as 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
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { DOMParser } from "@tiptap/pm/model";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
import { find } from "linkifyjs";
import { markdownToHtml } from "@docmost/editor-ext";
import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext";
export const MarkdownClipboard = Extension.create({
name: "markdownClipboard",
@@ -19,6 +19,27 @@ export const MarkdownClipboard = Extension.create({
new Plugin({
key: new PluginKey("markdownClipboard"),
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) => {
if (!event.clipboardData) {
return false;
@@ -29,49 +50,80 @@ export const MarkdownClipboard = Extension.create({
}
const text = event.clipboardData.getData("text/plain");
const html = event.clipboardData.getData("text/html");
const vscode = event.clipboardData.getData("vscode-editor-data");
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
const language = vscodeData?.mode;
if (language !== "markdown") {
const isVscodeMarkdown = language === "markdown";
const isPlainTextOnly = !html && !vscode && !!text;
if (!isVscodeMarkdown && !isPlainTextOnly) {
return false;
}
if (isPlainTextOnly) {
if ((view as any).input?.shiftKey || !this.options.transformPastedText) {
return false;
}
const link = find(text, {
defaultProtocol: "http",
}).find((item) => item.isLink && item.value === text);
if (link) {
return false;
}
}
const { tr } = view.state;
const { from, to } = view.state.selection;
const html = markdownToHtml(text);
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
const contentNodes = DOMParser.fromSchema(
this.editor.schema,
).parseSlice(elementFromString(html), {
).parseSlice(elementFromString(parsed), {
preserveWhitespace: true,
});
tr.replaceRange(from, to, contentNodes);
const insertEnd = tr.mapping.map(from, 1);
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
tr.setMeta('paste', true)
view.dispatch(tr);
return true;
},
clipboardTextParser: (text, context, plainText) => {
const link = find(text, {
defaultProtocol: "http",
}).find((item) => item.isLink && item.value === text);
// 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;
if (plainText || !this.options.transformPastedText || link) {
// don't parse plaintext link to allow link paste handler to work
// pasting with shift key prevents formatting
return null;
// 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;
}
}
const parsed = markdownToHtml(text);
return DOMParser.fromSchema(this.editor.schema).parseSlice(
elementFromString(parsed),
{
preserveWhitespace: true,
context,
},
);
if (content !== slice.content) {
return new Slice(content, openStart, Math.max(openEnd, 1));
}
return slice;
},
},
}),
@@ -16,6 +16,7 @@ export interface FullEditorProps {
content: string;
spaceSlug: string;
editable: boolean;
canComment?: boolean;
}
export function FullEditor({
@@ -25,6 +26,7 @@ export function FullEditor({
content,
spaceSlug,
editable,
canComment,
}: FullEditorProps) {
const [user] = useAtom(userAtom);
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
@@ -46,6 +48,7 @@ export function FullEditor({
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</Container>
);
@@ -37,14 +37,17 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
import {
activeCommentIdAtom,
showCommentPopupAtom,
showReadOnlyCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
import CommentDialog from "@/features/comment/components/comment-dialog";
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 TableMenu from "@/features/editor/components/table/table-menu.tsx";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-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 {
handleFileDrop,
@@ -73,12 +76,14 @@ interface PageEditorProps {
pageId: string;
editable: boolean;
content: any;
canComment?: boolean;
}
export default function PageEditor({
pageId,
editable,
content,
canComment,
}: PageEditorProps) {
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
@@ -93,6 +98,7 @@ export default function PageEditor({
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@@ -414,6 +420,7 @@ export default function PageEditor({
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<PdfMenu editor={editor} />
<CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} />
@@ -421,7 +428,13 @@ export default function PageEditor({
<ColumnsMenu editor={editor} />
</div>
)}
{editor && !editorIsEditable && (editable || canComment) && providersRef.current && (
<ReadonlyBubbleMenu editor={editor} />
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly />
)}
</div>
<div
onClick={() => editor.commands.focus("end")}
@@ -133,10 +133,18 @@
border-top: 1px solid #68cef8;
}
&[contenteditable="false"] hr.ProseMirror-selectednode {
border-top: none;
}
.ProseMirror-selectednode {
outline: 2px solid #70cff8;
}
&[contenteditable="false"] .ProseMirror-selectednode {
outline: none;
}
& > .react-renderer {
margin-top: 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 {
outline: none;
}
@@ -37,5 +37,28 @@
font-size: var(--mantine-font-size-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 { INotification } from "../types/notification.types";
import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";
import { useState } from "react";
import { useMarkReadMutation } from "../queries/notification-query";
import { buildPageUrl } from "@/features/page/page.utils";
@@ -30,7 +30,6 @@ export function NotificationItem({
onNavigate,
}: NotificationItemProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const markRead = useMarkReadMutation();
const [hovered, setHovered] = useState(false);
@@ -55,32 +54,39 @@ export function NotificationItem({
}
};
const handleClick = () => {
if (notification.page && notification.space) {
if (isUnread) {
markRead.mutate([notification.id]);
}
navigate(
buildPageUrl(
const pageUrl =
notification.page && notification.space
? buildPageUrl(
notification.space.slug,
notification.page.slugId,
notification.page.title,
),
);
onNavigate();
}
};
)
: undefined;
const handleMarkRead = (e: React.MouseEvent) => {
e.stopPropagation();
const markReadIfNeeded = () => {
if (isUnread) {
markRead.mutate([notification.id]);
}
};
const handleClick = () => {
markReadIfNeeded();
onNavigate();
};
const handleMarkRead = (e: React.MouseEvent) => {
e.stopPropagation();
markReadIfNeeded();
};
return (
<UnstyledButton
component={Link}
to={pageUrl ?? ""}
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)}
onMouseLeave={() => setHovered(false)}
w="100%"
@@ -1,7 +1,9 @@
.notificationItem {
display: block;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
user-select: none;
}
.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 React from "react";
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 { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
@@ -59,6 +60,14 @@ export default function SpaceSettingsModal({
<Tabs.Tab fw={500} value="members">
{t("Members")}
</Tabs.Tab>
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
) && (
<Tabs.Tab fw={500} value="security">
{t("Security")}
</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="general">
@@ -91,6 +100,20 @@ export default function SpaceSettingsModal({
)}
/>
</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>
</div>
</Modal.Body>
@@ -18,7 +18,7 @@ import {
ResponsiveSettingsControl,
ResponsiveSettingsRow,
} from "@/components/ui/responsive-settings-row.tsx";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
interface SpaceDetailsProps {
spaceId: string;
@@ -27,7 +27,6 @@ interface SpaceDetailsProps {
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation();
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
const showSharingToggle = !readOnly;
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [isIconUploading, setIsIconUploading] = useState(false);
@@ -89,13 +88,6 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<EditSpaceForm space={space} readOnly={readOnly} />
{showSharingToggle && (
<>
<Divider my="lg" />
<SpacePublicSharingToggle space={space} />
</>
)}
{!readOnly && (
<>
<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;
}
export interface ISpaceCommentsSettings {
allowViewerComments?: boolean;
}
export interface ISpaceSettings {
sharing?: ISpaceSharingSettings;
comments?: ISpaceCommentsSettings;
}
export interface ISpace {
@@ -29,6 +34,7 @@ export interface ISpace {
settings?: ISpaceSettings;
// for updates
disablePublicSharing?: boolean;
allowViewerComments?: boolean;
}
interface IMembership {
+1
View File
@@ -14,6 +14,7 @@ i18n
.init({
fallbackLng: "en-US",
debug: false,
showSupportNotice: false,
load: 'currentOnly',
interpolation: {
+4
View File
@@ -53,6 +53,9 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false;
const canComment =
canEdit ||
(space?.settings?.comments?.allowViewerComments === true);
if (isLoading) {
return <></>;
@@ -104,6 +107,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
slugId={page.slugId}
spaceSlug={page?.space?.slug}
editable={canEdit}
canComment={canComment}
/>
<MemoizedHistoryModal pageId={page.id} />
</div>
+2 -2
View File
@@ -74,7 +74,7 @@
"class-validator": "^0.15.1",
"cookie": "^1.1.1",
"fs-extra": "^11.3.4",
"happy-dom": "20.8.4",
"happy-dom": "20.8.9",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.3",
"kysely": "^0.28.14",
@@ -89,7 +89,7 @@
"nestjs-cls": "^6.2.0",
"nestjs-kysely": "^3.1.2",
"nestjs-pino": "^4.6.1",
"nodemailer": "^8.0.3",
"nodemailer": "^8.0.4",
"openid-client": "^6.8.2",
"otpauth": "^9.5.0",
"p-limit": "^7.3.0",
@@ -5,6 +5,7 @@ import {
prosemirrorNodeToYElement,
tiptapExtensions,
} from './collaboration.util';
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types';
@@ -27,6 +28,53 @@ export class CollaborationHandler {
// 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 (
documentName: string,
payload: {
@@ -58,8 +106,7 @@ export class CollaborationHandler {
} else {
const newContent = prosemirrorJson.content || [];
const yElements = newContent.map(prosemirrorNodeToYElement);
const position =
operation === 'prepend' ? 0 : fragment.length;
const position = operation === 'prepend' ? 0 : fragment.length;
fragment.insert(position, yElements);
}
},
@@ -24,6 +24,8 @@ import {
CustomTable,
TiptapImage,
TiptapVideo,
TiptapAudio,
TiptapPdf,
TrailingNode,
Attachment,
Drawio,
@@ -86,6 +88,8 @@ export const tiptapExtensions = [
Youtube,
TiptapImage,
TiptapVideo,
TiptapAudio,
TiptapPdf,
Callout,
Attachment,
CustomCodeBlock,
+1 -1
View File
@@ -1,7 +1,7 @@
import {
initProseMirrorDoc,
relativePositionToAbsolutePosition,
} from 'y-prosemirror';
} from '@tiptap/y-tiptap';
import * as Y from 'yjs';
import { Document } from '@hocuspocus/server';
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',
'image',
'video',
'audio',
'pdf',
'excalidraw',
'drawio',
];
@@ -15,4 +15,9 @@ export const inlineFileExtensions = [
'.pdf',
'.mp4',
'.mov',
'.mp3',
'.wav',
'.ogg',
'.m4a',
'.webm',
];
@@ -457,6 +457,10 @@ export class AttachmentController {
const rangeHeader = req.headers.range;
res.header('Accept-Ranges', 'bytes');
res.header(
'Content-Security-Policy',
"base-uri 'none'; object-src 'self'; default-src 'self';",
);
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
@@ -58,13 +58,13 @@ export class CommentController {
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(
{
userId: user.id,
page,
workspaceId: workspace.id,
user,
},
createCommentDto,
);
@@ -120,7 +120,7 @@ export class CommentController {
@HttpCode(HttpStatus.OK)
@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, {
includeCreator: true,
includeResolvedBy: true,
@@ -134,14 +134,14 @@ export class CommentController {
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);
}
@HttpCode(HttpStatus.OK)
@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);
if (!comment) {
throw new NotFoundException('Comment not found');
@@ -152,8 +152,7 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
// Check page-level edit permission first
await this.pageAccessService.validateCanEdit(page, user);
await this.pageAccessService.validateCanComment(page, user, workspace.id);
// Check if user is the comment owner
const isOwner = comment.creatorId === user.id;
@@ -169,7 +168,7 @@ export class CommentController {
// Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
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);
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { CommentService } from './comment.service';
import { CommentController } from './comment.controller';
import { CollaborationModule } from '../../collaboration/collaboration.module';
@Module({
imports: [CollaborationModule],
controllers: [CommentController],
providers: [CommentService],
exports: [CommentService],
@@ -7,7 +7,8 @@ import {
} from '@nestjs/common';
import { InjectQueue } from '@nestjs/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 { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { Comment, Page, User } from '@docmost/db/types/entity.types';
@@ -27,6 +28,7 @@ export class CommentService {
private commentRepo: CommentRepo,
private pageRepo: PageRepo,
private wsService: WsService,
private collaborationGateway: CollaborationGateway,
@InjectQueue(QueueName.GENERAL_QUEUE)
private generalQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
@@ -45,10 +47,10 @@ export class CommentService {
}
async create(
opts: { userId: string; page: Page; workspaceId: string },
opts: { page: Page; workspaceId: string; user: User },
createCommentDto: CreateCommentDto,
) {
const { userId, page, workspaceId } = opts;
const { page, workspaceId, user } = opts;
const commentContent = JSON.parse(createCommentDto.content);
if (createCommentDto.parentCommentId) {
@@ -71,11 +73,39 @@ export class CommentService {
selection: createCommentDto?.selection?.substring(0, 250) ?? null,
type: createCommentDto.type ?? 'page',
parentCommentId: createCommentDto?.parentCommentId,
creatorId: userId,
creatorId: user.id,
workspaceId: workspaceId,
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, {
includeCreator: true,
includeResolvedBy: true,
@@ -83,7 +113,7 @@ export class CommentService {
this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [userId],
userIds: [user.id],
pageId: page.id,
spaceId: page.spaceId,
workspaceId,
@@ -101,7 +131,7 @@ export class CommentService {
page.id,
page.spaceId,
workspaceId,
userId,
user.id,
!isReply,
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 {
@IsString()
@@ -18,4 +36,11 @@ export class CreateCommentDto {
@IsOptional()
@IsUUID()
parentCommentId: string;
@IsOptional()
@IsObject()
yjsSelection?: {
anchor: any;
head: any;
};
}
@@ -6,12 +6,14 @@ import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
@Injectable()
export class PageAccessService {
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly spaceRepo: SpaceRepo,
) {}
/**
@@ -99,4 +101,25 @@ export class PageAccessService {
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()
@IsBoolean()
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 { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
import { Feature } from '../../../common/features';
import { SpaceMemberService } from './space-member.service';
import { SpaceRole } from '../../../common/helpers/types/permission';
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, {
withLicenseKey: true,
});
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(
'This feature requires a valid license',
);
throw new ForbiddenException('This feature requires a valid license');
}
if (
typeof updateSpaceDto.allowViewerComments !== 'undefined' &&
!this.licenseCheckService.hasFeature(
workspace.licenseKey,
Feature.VIEWER_COMMENTS,
workspace.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
@@ -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(
{
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 { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
import { Feature } from '../../../common/features';
import { User } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
@@ -352,7 +353,7 @@ export class WorkspaceService {
typeof updateWorkspaceDto.trashRetentionDays !== '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(
'This feature requires a valid license',
);
@@ -0,0 +1,333 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createIndex('idx_group_users_user_id')
.ifNotExists()
.on('group_users')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_space_members_user_id')
.ifNotExists()
.on('space_members')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_space_members_group_id')
.ifNotExists()
.on('space_members')
.column('group_id')
.execute();
// Page tree
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_parent_position
ON pages (space_id, parent_page_id, position COLLATE "C")
WHERE deleted_at IS NULL
`.execute(db);
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_parent_page_id
ON pages (parent_page_id)
WHERE deleted_at IS NULL
`.execute(db);
// Recent pages query
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_updated
ON pages (space_id, updated_at DESC)
WHERE deleted_at IS NULL
`.execute(db);
// Trash view
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_deleted
ON pages (space_id, deleted_at DESC)
WHERE deleted_at IS NOT NULL
`.execute(db);
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_hostname_lower
ON workspaces (LOWER(hostname))
`.execute(db);
await db.schema
.createIndex('idx_workspaces_created_at')
.ifNotExists()
.on('workspaces')
.column('created_at')
.execute();
await db.schema
.createIndex('idx_users_workspace_deleted')
.ifNotExists()
.on('users')
.columns(['workspace_id', 'deleted_at'])
.execute();
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_spaces_slug_lower_workspace
ON spaces (LOWER(slug), workspace_id)
`.execute(db);
await db.schema
.createIndex('idx_spaces_workspace_id')
.ifNotExists()
.on('spaces')
.column('workspace_id')
.execute();
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_lower_workspace
ON groups (LOWER(name), workspace_id)
`.execute(db);
await db.schema
.createIndex('idx_groups_workspace_id')
.ifNotExists()
.on('groups')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_shares_page_id')
.ifNotExists()
.on('shares')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_attachments_page_id')
.ifNotExists()
.on('attachments')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_attachments_space_id')
.ifNotExists()
.on('attachments')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_comments_page_id')
.ifNotExists()
.on('comments')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_comments_parent_comment_id')
.ifNotExists()
.on('comments')
.column('parent_comment_id')
.execute();
await sql`
CREATE INDEX IF NOT EXISTS idx_page_history_page_created
ON page_history (page_id, created_at DESC)
`.execute(db);
await db.schema
.createIndex('idx_attachments_workspace_id')
.ifNotExists()
.on('attachments')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_backlinks_target_page_id')
.ifNotExists()
.on('backlinks')
.column('target_page_id')
.execute();
await db.schema
.createIndex('idx_pages_workspace_id')
.ifNotExists()
.on('pages')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_pages_creator_id')
.ifNotExists()
.on('pages')
.column('creator_id')
.execute();
// Notifications: FK cascade from pages, spaces, comments
await db.schema
.createIndex('idx_notifications_page_id')
.ifNotExists()
.on('notifications')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_notifications_space_id')
.ifNotExists()
.on('notifications')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_notifications_comment_id')
.ifNotExists()
.on('notifications')
.column('comment_id')
.execute();
// Watchers: cleanup queries and FK cascade
await db.schema
.createIndex('idx_watchers_user_workspace')
.ifNotExists()
.on('watchers')
.columns(['user_id', 'workspace_id'])
.execute();
await db.schema
.createIndex('idx_watchers_space_id')
.ifNotExists()
.on('watchers')
.column('space_id')
.execute();
// Auth providers: all queries filter by workspaceId
await db.schema
.createIndex('idx_auth_providers_workspace_id')
.ifNotExists()
.on('auth_providers')
.column('workspace_id')
.execute();
// Auth accounts: SSO login lookup by provider user
await db.schema
.createIndex('idx_auth_accounts_provider_user_id')
.ifNotExists()
.on('auth_accounts')
.columns(['provider_user_id', 'auth_provider_id'])
.execute();
// Workspace invitations: listing and SSO lookup
await db.schema
.createIndex('idx_workspace_invitations_workspace_id')
.ifNotExists()
.on('workspace_invitations')
.column('workspace_id')
.execute();
// API keys: query and FK cascade
await db.schema
.createIndex('idx_api_keys_workspace_id')
.ifNotExists()
.on('api_keys')
.column('workspace_id')
.execute();
// User sessions: delete queries and FK cascade on all session states
await db.schema
.createIndex('idx_user_sessions_user_workspace')
.ifNotExists()
.on('user_sessions')
.columns(['user_id', 'workspace_id'])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('idx_group_users_user_id').ifExists().execute();
await db.schema.dropIndex('idx_space_members_user_id').ifExists().execute();
await db.schema.dropIndex('idx_space_members_group_id').ifExists().execute();
await db.schema
.dropIndex('idx_pages_space_parent_position')
.ifExists()
.execute();
await db.schema.dropIndex('idx_pages_parent_page_id').ifExists().execute();
await db.schema.dropIndex('idx_pages_space_updated').ifExists().execute();
await db.schema.dropIndex('idx_pages_space_deleted').ifExists().execute();
await db.schema
.dropIndex('idx_workspaces_hostname_lower')
.ifExists()
.execute();
await db.schema.dropIndex('idx_workspaces_created_at').ifExists().execute();
await db.schema
.dropIndex('idx_users_workspace_deleted')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_spaces_slug_lower_workspace')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_spaces_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_groups_name_lower_workspace')
.ifExists()
.execute();
await db.schema.dropIndex('idx_groups_workspace_id').ifExists().execute();
await db.schema.dropIndex('idx_shares_page_id').ifExists().execute();
await db.schema.dropIndex('idx_attachments_page_id').ifExists().execute();
await db.schema.dropIndex('idx_attachments_space_id').ifExists().execute();
await db.schema.dropIndex('idx_comments_page_id').ifExists().execute();
await db.schema
.dropIndex('idx_comments_parent_comment_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_page_history_page_created')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_attachments_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_backlinks_target_page_id')
.ifExists()
.execute();
await db.schema.dropIndex('idx_pages_workspace_id').ifExists().execute();
await db.schema.dropIndex('idx_pages_creator_id').ifExists().execute();
await db.schema
.dropIndex('idx_notifications_page_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_notifications_space_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_notifications_comment_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_watchers_user_workspace')
.ifExists()
.execute();
await db.schema.dropIndex('idx_watchers_space_id').ifExists().execute();
await db.schema
.dropIndex('idx_auth_providers_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_auth_accounts_provider_user_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_workspace_invitations_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_api_keys_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_user_sessions_user_workspace')
.ifExists()
.execute();
}
@@ -111,6 +111,28 @@ export class SpaceRepo {
.executeTakeFirst();
}
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(
insertableSpace: InsertableSpace,
trx?: KyselyTransaction,
@@ -61,7 +61,7 @@ export class ExportController {
await this.pageAccessService.validateCanView(page, user);
const zipFileStream = await this.exportService.exportPages(
const result = await this.exportService.exportPages(
dto.pageId,
dto.format,
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({
'Content-Type': 'application/zip',
'Content-Disposition':
'attachment; filename="' + encodeURIComponent(fileName) + '"',
});
res.headers({
'Content-Type': contentType,
'Content-Disposition':
'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)
@@ -150,6 +150,13 @@ export class ExportService {
// set to null to make export of pages with parentId work
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 baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId);
@@ -170,7 +177,7 @@ export class ExportService {
compression: 'DEFLATE',
});
return zipFile;
return { type: 'zip' as const, stream: zipFile, page: pages[0] };
}
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 abs = attachmentCandidates.get(relPath)!;
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 =
sanitizeFileName(path.basename(abs, ext)) + ext.toLowerCase();
sanitizeFileName(path.basename(baseName, ext)) + ext.toLowerCase();
const storageFilePath = `${getAttachmentFolderPath(
AttachmentType.File,
@@ -240,7 +259,6 @@ export class ImportAttachmentService {
return fresh;
};
const pageDir = path.dirname(pageRelativePath);
const $ = load(html);
// image
@@ -335,6 +353,28 @@ export class ImportAttachmentService {
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">
for (const el of $('div[data-type="attachment"]').toArray()) {
const $oldDiv = $(el);
@@ -401,7 +441,18 @@ export class ImportAttachmentService {
const { attachmentId, apiFilePath, abs } = processFile(relPath);
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>')
.attr('src', apiFilePath)
.attr('data-attachment-id', attachmentId)
@@ -409,6 +460,12 @@ export class ImportAttachmentService {
.attr('data-align', 'center');
$a.replaceWith($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 {
const confAliasName = $a.attr('data-linked-resource-default-alias');
let attachmentName = path.basename(abs);
@@ -555,7 +612,7 @@ export class ImportAttachmentService {
// Post-process DOM elements to add file sizes after uploads complete
// This avoids blocking file operations during initial DOM processing
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()) {
const $el = $(element);
@@ -570,7 +627,14 @@ export class ImportAttachmentService {
if (processedEntry) {
try {
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) {
this.logger.debug(
`Could not get size for ${processedEntry.abs}:`,
@@ -41,6 +41,15 @@ export function resolveRelativeAttachmentPath(
'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
.normalize(path.join(pageDir, mainRel))
.split(path.sep)
@@ -49,9 +58,13 @@ export function resolveRelativeAttachmentPath(
if (attachmentCandidates.has(mainRel)) {
return mainRel;
}
if (confluenceStripped !== mainRel && attachmentCandidates.has(confluenceStripped)) {
return confluenceStripped;
}
if (attachmentCandidates.has(fallback)) {
return fallback;
}
return null;
}
@@ -66,25 +66,25 @@ export class LocalDriver implements StorageDriver {
}
async readStream(filePath: string): Promise<Readable> {
try {
return createReadStream(this._fullPath(filePath));
} catch (err) {
throw new Error(`Failed to read file: ${(err as Error).message}`);
const fullPath = this._fullPath(filePath);
if (!(await fs.pathExists(fullPath))) {
throw new Error(`File not found: ${filePath}`);
}
return createReadStream(fullPath);
}
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
try {
return createReadStream(this._fullPath(filePath), {
start: range.start,
end: range.end,
});
} catch (err) {
throw new Error(`Failed to read file: ${(err as Error).message}`);
const fullPath = this._fullPath(filePath);
if (!(await fs.pathExists(fullPath))) {
throw new Error(`File not found: ${filePath}`);
}
return createReadStream(fullPath, {
start: range.start,
end: range.end,
});
}
async exists(filePath: string): Promise<boolean> {
+5 -3
View File
@@ -30,6 +30,7 @@
"@joplin/turndown-plugin-gfm": "^1.0.64",
"@sindresorhus/slugify": "3.0.0",
"@tiptap/core": "3.20.4",
"@tiptap/extension-audio": "3.20.4",
"@tiptap/extension-code-block": "3.20.4",
"@tiptap/extension-collaboration": "3.20.4",
"@tiptap/extension-collaboration-caret": "3.20.4",
@@ -94,8 +95,7 @@
"packageManager": "pnpm@10.4.0",
"pnpm": {
"patchedDependencies": {
"react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch",
"@tiptap/core": "patches/@tiptap__core.patch"
"react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch"
},
"overrides": {
"prosemirror-changeset": "2.4.0",
@@ -124,7 +124,9 @@
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
"fastify": "5.8.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": []
}
+3 -1
View File
@@ -9,5 +9,7 @@
"main": "dist/index.js",
"module": "./src/index.ts",
"types": "dist/index.d.ts",
"dependencies": {}
"dependencies": {
"marked": "17.0.5"
}
}
+4
View File
@@ -11,6 +11,7 @@ export * from "./lib/media-utils";
export * from "./lib/link";
export * from "./lib/selection";
export * from "./lib/attachment";
export * from "./lib/audio";
export * from "./lib/custom-code-block";
export * from "./lib/drawio";
export * from "./lib/excalidraw";
@@ -27,3 +28,6 @@ export * from "./lib/shared-storage";
export * from "./lib/recreate-transform";
export * from "./lib/columns";
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 type { ResizableNodeViewDirection } from "@tiptap/core";
import { Node, mergeAttributes } from "@tiptap/core";
import { ResizableNodeView } from "./resizable-nodeview";
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "./media-utils";
@@ -320,12 +321,11 @@ export const Drawio = Node.create<DrawioOptions>({
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.classList.add("media-pulse");
el.onload = () => {
dom.style.pointerEvents = "";
dom.style.background = "";
el.classList.remove("media-pulse");
};
return nodeView;
+2 -2
View File
@@ -64,14 +64,14 @@ export const Embed = Node.create<EmbedOptions>({
}),
},
width: {
default: 640,
default: 800,
parseHTML: (element) => element.getAttribute("data-width"),
renderHTML: (attributes: EmbedAttributes) => ({
"data-width": attributes.width,
}),
},
height: {
default: 480,
default: 600,
parseHTML: (element) => element.getAttribute("data-height"),
renderHTML: (attributes: EmbedAttributes) => ({
"data-height": attributes.height,
+5 -5
View File
@@ -1,5 +1,6 @@
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
import type { ResizableNodeViewDirection } from "@tiptap/core";
import { Node, mergeAttributes } from "@tiptap/core";
import { ResizableNodeView } from "./resizable-nodeview";
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "./media-utils";
@@ -320,12 +321,11 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.classList.add("media-pulse");
el.onload = () => {
dom.style.pointerEvents = "";
dom.style.background = "";
el.classList.remove("media-pulse");
};
return nodeView;
+4 -5
View File
@@ -4,10 +4,10 @@ import { ReactNodeViewRenderer } from "@tiptap/react";
import {
mergeAttributes,
Range,
ResizableNodeView,
} from "@tiptap/core";
import { ResizableNodeView } from "../resizable-nodeview";
import type { ResizableNodeViewDirection } from "../resizable-nodeview";
import { normalizeFileUrl } from "../media-utils";
import type { ResizableNodeViewDirection } from "@tiptap/core";
export type ImageResizeOptions = {
enabled: boolean;
@@ -362,12 +362,11 @@ export const TiptapImage = Image.extend<ImageOptions>({
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.classList.add("media-pulse");
el.onload = () => {
dom.style.pointerEvents = "";
dom.style.background = "";
el.classList.remove("media-pulse");
};
return nodeView;
@@ -5,18 +5,23 @@ import { mathInlineExtension } from "./math-inline.marked";
marked.use({
renderer: {
// @ts-ignore
list(body: string, isOrdered: boolean, start: number) {
if (isOrdered) {
const startAttr = start !== 1 ? ` start="${start}"` : "";
return `<ol ${startAttr}>\n${body}</ol>\n`;
list({ ordered, start, items }) {
let body = "";
for (const item of items) {
body += this.listitem(item);
}
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`;
},
// @ts-ignore
listitem({ text, raw, task: isTask, checked: isChecked }): string {
listitem({ tokens, task: isTask, checked: isChecked }) {
const text = this.parser.parse(tokens);
if (!isTask) {
return `<li>${text}</li>\n`;
}
@@ -21,6 +21,7 @@ export function htmlToMarkdown(html: string): string {
callout,
preserveDetail,
listParagraph,
orderedListItem,
mathInline,
mathBlock,
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) {
turndownService.addRule('callout', {
filter: function (node: HTMLInputElement) {
@@ -63,25 +98,17 @@ function taskList(turndownService: _TurndownService) {
node.parentNode.nodeName === 'UL'
);
},
replacement: function (content: string, node: HTMLInputElement) {
const checkbox = node.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
const isChecked = checkbox.checked;
replacement: function (_content: string, node: HTMLInputElement) {
const isChecked = node.getAttribute('data-checked') === 'true';
const div = node.querySelector('div');
const text = div ? div.textContent.trim() : node.textContent.trim();
// 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]' : '[ ]'} `;
return (
prefix +
content +
(node.nextSibling && !/\n$/.test(content) ? '\n' : '')
text +
(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;
}
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";
export const generateNodeId = customAlphabet(alphabet, 12);
+5 -5
View File
@@ -1,7 +1,8 @@
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 type { ResizableNodeViewDirection } from "@tiptap/core";
export type VideoResizeOptions = {
enabled: boolean;
@@ -328,12 +329,11 @@ export const TiptapVideo = Node.create<VideoOptions>({
// Show skeleton background while video loads from server
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.classList.add("media-pulse");
el.onloadedmetadata = () => {
dom.style.pointerEvents = "";
dom.style.background = "";
el.classList.remove("media-pulse");
};
return nodeView;
+178 -163
View File
@@ -32,11 +32,10 @@ overrides:
fastify: 5.8.3
yaml@>=1.0.0 <1.10.3: 1.10.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:
'@tiptap/core':
hash: efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00
path: patches/@tiptap__core.patch
react-arborist@3.4.0:
hash: 419b3b02e24afe928cc006a006f6e906666aff19aa6fd7daaa788ccc2202678a
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)
'@hocuspocus/transformer':
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':
specifier: ^4.0.82
version: 4.0.82
@@ -77,85 +76,88 @@ importers:
version: 3.0.0
'@tiptap/core':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
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':
specifier: 3.20.4
version: 3.20.4
'@tiptap/react':
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':
specifier: 3.20.4
version: 3.20.4
'@tiptap/suggestion':
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':
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)
@@ -582,8 +584,8 @@ importers:
specifier: ^11.3.4
version: 11.3.4
happy-dom:
specifier: 20.8.4
version: 20.8.4
specifier: 20.8.9
version: 20.8.9
ioredis:
specifier: ^5.10.1
version: 5.10.1
@@ -627,8 +629,8 @@ importers:
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)
nodemailer:
specifier: ^8.0.3
version: 8.0.3
specifier: ^8.0.4
version: 8.0.4
openid-client:
specifier: ^6.8.2
version: 6.8.2
@@ -799,7 +801,11 @@ importers:
specifier: ^8.57.1
version: 8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3)
packages/editor-ext: {}
packages/editor-ext:
dependencies:
marked:
specifier: 17.0.5
version: 17.0.5
packages:
@@ -4662,6 +4668,11 @@ packages:
peerDependencies:
'@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':
resolution: {integrity: sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==}
peerDependencies:
@@ -5861,8 +5872,8 @@ packages:
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@5.0.4:
resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
brace-expansion@5.0.5:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
@@ -7263,8 +7274,8 @@ packages:
engines: {node: '>=0.4.7'}
hasBin: true
happy-dom@20.8.4:
resolution: {integrity: sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==}
happy-dom@20.8.9:
resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==}
engines: {node: '>=20.0.0'}
has-bigints@1.0.2:
@@ -8634,8 +8645,8 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
nodemailer@8.0.3:
resolution: {integrity: sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==}
nodemailer@8.0.4:
resolution: {integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==}
engines: {node: '>=6.0.0'}
normalize-path@3.0.0:
@@ -8909,8 +8920,8 @@ packages:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
path-to-regexp@8.4.0:
resolution: {integrity: sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
@@ -12707,9 +12718,9 @@ snapshots:
- bufferutil
- 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:
'@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/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)
@@ -13538,7 +13549,7 @@ snapshots:
'@nuxt/opencollective': 0.4.1
fast-safe-stringify: 2.1.1
iterare: 1.2.1
path-to-regexp: 8.3.0
path-to-regexp: 8.4.0
reflect-metadata: 0.2.2
rxjs: 7.8.2
tslib: 2.8.1
@@ -13582,7 +13593,7 @@ snapshots:
fastify-plugin: 5.1.0
find-my-way: 9.5.0
light-my-request: 6.6.0
path-to-regexp: 8.3.0
path-to-regexp: 8.4.0
reusify: 1.1.0
tslib: 2.8.1
optionalDependencies:
@@ -15281,193 +15292,197 @@ snapshots:
'@tanstack/query-core': 5.90.17
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:
'@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:
'@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:
'@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:
'@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
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:
'@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:
'@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/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:
'@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:
'@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/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:
'@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/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-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:
'@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:
'@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:
'@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:
'@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
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:
'@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:
'@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:
'@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:
'@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:
'@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:
'@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/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:
'@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:
'@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:
'@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
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:
'@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:
'@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:
'@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/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:
'@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:
'@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:
'@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:
'@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:
'@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/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:
'@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/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:
'@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/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:
'@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:
'@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:
'@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:
'@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:
'@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:
'@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
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:
'@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:
'@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/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:
'@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
happy-dom: 20.8.4
happy-dom: 20.8.9
'@tiptap/pm@3.20.4':
dependencies:
@@ -15490,9 +15505,9 @@ snapshots:
prosemirror-transform: 1.10.4
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:
'@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
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
@@ -15502,41 +15517,41 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
use-sync-external-store: 1.6.0(react@18.3.1)
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-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-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(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
transitivePeerDependencies:
- '@floating-ui/dom'
'@tiptap/starter-kit@3.20.4':
dependencies:
'@tiptap/core': 3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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-bold': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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-code': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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-document': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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-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-hard-break': 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(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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-italic': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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-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(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(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(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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-strike': 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(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@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/extensions': 3.20.4(@tiptap/core@3.20.4(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.20.4))(@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(@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(@tiptap/pm@3.20.4))(@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(@tiptap/pm@3.20.4))(@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(@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(@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(@tiptap/pm@3.20.4))(@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(@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(@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(@tiptap/pm@3.20.4))(@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(@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(@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/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:
'@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/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 +16666,7 @@ snapshots:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.4:
brace-expansion@5.0.5:
dependencies:
balanced-match: 4.0.4
@@ -18342,7 +18357,7 @@ snapshots:
optionalDependencies:
uglify-js: 3.19.3
happy-dom@20.8.4:
happy-dom@20.8.9:
dependencies:
'@types/node': 25.5.0
'@types/whatwg-mimetype': 3.0.2
@@ -19728,7 +19743,7 @@ snapshots:
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
brace-expansion: 5.0.5
minimatch@3.1.5:
dependencies:
@@ -19853,7 +19868,7 @@ snapshots:
node-releases@2.0.27: {}
nodemailer@8.0.3: {}
nodemailer@8.0.4: {}
normalize-path@3.0.0: {}
@@ -20194,7 +20209,7 @@ snapshots:
lru-cache: 11.2.7
minipass: 7.1.3
path-to-regexp@8.3.0: {}
path-to-regexp@8.4.0: {}
path-type@4.0.0: {}
@@ -21079,7 +21094,7 @@ snapshots:
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
path-to-regexp: 8.3.0
path-to-regexp: 8.4.0
transitivePeerDependencies:
- supports-color