Merge branch 'main' into feat/collab-redis

This commit is contained in:
Philipinho
2026-01-25 00:29:40 +00:00
78 changed files with 1958 additions and 1093 deletions
+6
View File
@@ -47,3 +47,9 @@ DISABLE_TELEMETRY=false
# Enable debug logging in production (default: false) # Enable debug logging in production (default: false)
DEBUG_MODE=false DEBUG_MODE=false
# Log database queries
DEBUG_DB=false
# Log http requests
LOG_HTTP=false
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.", "Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.",
"Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.", "Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.",
"Confirm": "Bestätigen", "Confirm": "Bestätigen",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Link kopieren", "Copy link": "Link kopieren",
"Create": "Erstellen", "Create": "Erstellen",
"Create group": "Gruppe erstellen", "Create group": "Gruppe erstellen",
@@ -234,7 +235,9 @@
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.", "Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
"Invite link": "Einladungslink", "Invite link": "Einladungslink",
"Copy": "Kopieren", "Copy": "Kopieren",
"Copy to space": "In Raum kopieren",
"Copied": "Kopiert", "Copied": "Kopiert",
"Duplicate": "Duplizieren",
"Select a user": "Benutzer auswählen", "Select a user": "Benutzer auswählen",
"Select a group": "Gruppe auswählen", "Select a group": "Gruppe auswählen",
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.", "Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
@@ -251,6 +254,7 @@
"Export failed:": "Export fehlgeschlagen:", "Export failed:": "Export fehlgeschlagen:",
"export error": "Exportfehler", "export error": "Exportfehler",
"Export page": "Seite exportieren", "Export page": "Seite exportieren",
"Export successful": "Export successful",
"Export space": "Bereich exportieren", "Export space": "Bereich exportieren",
"Export {{type}}": "Exportiere {{type}}", "Export {{type}}": "Exportiere {{type}}",
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}", "File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
@@ -326,6 +330,8 @@
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.", "Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.", "Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
"Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.", "Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Tabelle", "Table": "Tabelle",
"Insert a table.": "Tabelle einfügen.", "Insert a table.": "Tabelle einfügen.",
"Insert collapsible block.": "Einklappbaren Block einfügen.", "Insert collapsible block.": "Einklappbaren Block einfügen.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.", "Choose your preferred page width.": "Choose your preferred page width.",
"Confirm": "Confirm", "Confirm": "Confirm",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link", "Copy link": "Copy link",
"Create": "Create", "Create": "Create",
"Create group": "Create group", "Create group": "Create group",
@@ -253,6 +254,7 @@
"Export failed:": "Export failed:", "Export failed:": "Export failed:",
"export error": "export error", "export error": "export error",
"Export page": "Export page", "Export page": "Export page",
"Export successful": "Export successful",
"Export space": "Export space", "Export space": "Export space",
"Export {{type}}": "Export {{type}}", "Export {{type}}": "Export {{type}}",
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit", "File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "Upload any image from your device.", "Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.", "Upload any video from your device.": "Upload any video from your device.",
"Upload any file from your device.": "Upload any file from your device.", "Upload any file from your device.": "Upload any file from your device.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Table", "Table": "Table",
"Insert a table.": "Insert a table.", "Insert a table.": "Insert a table.",
"Insert collapsible block.": "Insert collapsible block.", "Insert collapsible block.": "Insert collapsible block.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.", "Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
"Choose your preferred page width.": "Elige el ancho de página que prefieras.", "Choose your preferred page width.": "Elige el ancho de página que prefieras.",
"Confirm": "Confirmar", "Confirm": "Confirmar",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copiar enlace", "Copy link": "Copiar enlace",
"Create": "Crear", "Create": "Crear",
"Create group": "Crear grupo", "Create group": "Crear grupo",
@@ -253,6 +254,7 @@
"Export failed:": "Exportación fallida:", "Export failed:": "Exportación fallida:",
"export error": "error de exportación", "export error": "error de exportación",
"Export page": "Exportar página", "Export page": "Exportar página",
"Export successful": "Export successful",
"Export space": "Exportar espacio", "Export space": "Exportar espacio",
"Export {{type}}": "Exportar {{type}}", "Export {{type}}": "Exportar {{type}}",
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos", "File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.", "Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.", "Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.", "Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Tabla", "Table": "Tabla",
"Insert a table.": "Insertar una tabla.", "Insert a table.": "Insertar una tabla.",
"Insert collapsible block.": "Insertar bloque desplegable.", "Insert collapsible block.": "Insertar bloque desplegable.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.", "Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.",
"Choose your preferred page width.": "Choisissez votre largeur de page préférée.", "Choose your preferred page width.": "Choisissez votre largeur de page préférée.",
"Confirm": "Confirmer", "Confirm": "Confirmer",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copier le lien", "Copy link": "Copier le lien",
"Create": "Créer", "Create": "Créer",
"Create group": "Créer groupe", "Create group": "Créer groupe",
@@ -253,6 +254,7 @@
"Export failed:": "Échec de l'exportation :", "Export failed:": "Échec de l'exportation :",
"export error": "exporter l'erreur", "export error": "exporter l'erreur",
"Export page": "Exporter la page", "Export page": "Exporter la page",
"Export successful": "Export successful",
"Export space": "Exporter l'espace", "Export space": "Exporter l'espace",
"Export {{type}}": "Exporter {{type}}", "Export {{type}}": "Exporter {{type}}",
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes", "File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.", "Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.", "Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
"Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.", "Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Tableau", "Table": "Tableau",
"Insert a table.": "Insérez un tableau.", "Insert a table.": "Insérez un tableau.",
"Insert collapsible block.": "Insérer un bloc repliable.", "Insert collapsible block.": "Insérer un bloc repliable.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.", "Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.",
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.", "Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
"Confirm": "Conferma", "Confirm": "Conferma",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copia link", "Copy link": "Copia link",
"Create": "Crea", "Create": "Crea",
"Create group": "Crea gruppo", "Create group": "Crea gruppo",
@@ -253,6 +254,7 @@
"Export failed:": "Esportazione fallita:", "Export failed:": "Esportazione fallita:",
"export error": "errore di esportazione", "export error": "errore di esportazione",
"Export page": "Esporta pagina", "Export page": "Esporta pagina",
"Export successful": "Export successful",
"Export space": "Esporta spazio", "Export space": "Esporta spazio",
"Export {{type}}": "Esporta {{type}}", "Export {{type}}": "Esporta {{type}}",
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}", "File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.", "Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.", "Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.", "Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Tabella", "Table": "Tabella",
"Insert a table.": "Inserisci una tabella.", "Insert a table.": "Inserisci una tabella.",
"Insert collapsible block.": "Inserisci blocco comprimibile.", "Insert collapsible block.": "Inserisci blocco comprimibile.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "お好みの言語を選択してください", "Choose your preferred interface language.": "お好みの言語を選択してください",
"Choose your preferred page width.": "お好みのページ幅を選択してください", "Choose your preferred page width.": "お好みのページ幅を選択してください",
"Confirm": "確認", "Confirm": "確認",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "リンクをコピー", "Copy link": "リンクをコピー",
"Create": "新規作成", "Create": "新規作成",
"Create group": "グループを作成", "Create group": "グループを作成",
@@ -253,6 +254,7 @@
"Export failed:": "エクスポートに失敗しました:", "Export failed:": "エクスポートに失敗しました:",
"export error": "エクスポートエラー", "export error": "エクスポートエラー",
"Export page": "エクスポートページ", "Export page": "エクスポートページ",
"Export successful": "Export successful",
"Export space": "エクスポートスペース", "Export space": "エクスポートスペース",
"Export {{type}}": "{{type}}をエクスポート", "Export {{type}}": "{{type}}をエクスポート",
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています", "File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "デバイスから画像をアップロードします", "Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします", "Upload any video from your device.": "デバイスから動画をアップロードします",
"Upload any file from your device.": "デバイスからファイルをアップロードします", "Upload any file from your device.": "デバイスからファイルをアップロードします",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "テーブル", "Table": "テーブル",
"Insert a table.": "テーブルを挿入します", "Insert a table.": "テーブルを挿入します",
"Insert collapsible block.": "折りたたみブロックを挿入します", "Insert collapsible block.": "折りたたみブロックを挿入します",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.", "Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.", "Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
"Confirm": "확인", "Confirm": "확인",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "링크 복사", "Copy link": "링크 복사",
"Create": "생성", "Create": "생성",
"Create group": "팀 생성", "Create group": "팀 생성",
@@ -253,6 +254,7 @@
"Export failed:": "내보내기 실패:", "Export failed:": "내보내기 실패:",
"export error": "내보내기 오류", "export error": "내보내기 오류",
"Export page": "페이지 내보내기", "Export page": "페이지 내보내기",
"Export successful": "Export successful",
"Export space": "Space 내보내기", "Export space": "Space 내보내기",
"Export {{type}}": "{{type}} 내보내기", "Export {{type}}": "{{type}} 내보내기",
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다", "File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.", "Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.", "Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
"Upload any file from your device.": "기기에서 파일을 업로드하세요.", "Upload any file from your device.": "기기에서 파일을 업로드하세요.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "테이블", "Table": "테이블",
"Insert a table.": "테이블 삽입.", "Insert a table.": "테이블 삽입.",
"Insert collapsible block.": "접을 수 있는 블록 삽입.", "Insert collapsible block.": "접을 수 있는 블록 삽입.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.", "Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.", "Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
"Confirm": "Bevestig", "Confirm": "Bevestig",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Link kopiëren", "Copy link": "Link kopiëren",
"Create": "Aanmaken", "Create": "Aanmaken",
"Create group": "Groep aanmaken", "Create group": "Groep aanmaken",
@@ -253,6 +254,7 @@
"Export failed:": "Exporteren mislukt:", "Export failed:": "Exporteren mislukt:",
"export error": "Exporteer fout", "export error": "Exporteer fout",
"Export page": "Exporteer pagina", "Export page": "Exporteer pagina",
"Export successful": "Export successful",
"Export space": "Exporteer ruimte", "Export space": "Exporteer ruimte",
"Export {{type}}": "Exporteer {{type}}", "Export {{type}}": "Exporteer {{type}}",
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}", "File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.", "Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
"Upload any video from your device.": "Upload een video vanaf uw apparaat.", "Upload any video from your device.": "Upload een video vanaf uw apparaat.",
"Upload any file from your device.": "Upload een bestand vanaf uw apparaat.", "Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Tabel", "Table": "Tabel",
"Insert a table.": "Voeg een tabel in.", "Insert a table.": "Voeg een tabel in.",
"Insert collapsible block.": "Inklapbaar blok invoegen.", "Insert collapsible block.": "Inklapbaar blok invoegen.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Escolha o idioma da interface.", "Choose your preferred interface language.": "Escolha o idioma da interface.",
"Choose your preferred page width.": "Escolha a largura preferida da página.", "Choose your preferred page width.": "Escolha a largura preferida da página.",
"Confirm": "Confirmar", "Confirm": "Confirmar",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copiar link", "Copy link": "Copiar link",
"Create": "Criar", "Create": "Criar",
"Create group": "Criar grupo", "Create group": "Criar grupo",
@@ -253,6 +254,7 @@
"Export failed:": "Falha ao exportar:", "Export failed:": "Falha ao exportar:",
"export error": "erro de exportação", "export error": "erro de exportação",
"Export page": "Exportar página", "Export page": "Exportar página",
"Export successful": "Export successful",
"Export space": "Exportar espaço", "Export space": "Exportar espaço",
"Export {{type}}": "Exportar para {{type}}", "Export {{type}}": "Exportar para {{type}}",
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}", "File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.", "Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.", "Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
"Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.", "Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Tabela", "Table": "Tabela",
"Insert a table.": "Insira uma tabela.", "Insert a table.": "Insira uma tabela.",
"Insert collapsible block.": "Insira um bloco colapsável.", "Insert collapsible block.": "Insira um bloco colapsável.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.", "Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.", "Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
"Confirm": "Подтвердить", "Confirm": "Подтвердить",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Копировать ссылку", "Copy link": "Копировать ссылку",
"Create": "Создать", "Create": "Создать",
"Create group": "Создать группу", "Create group": "Создать группу",
@@ -253,6 +254,7 @@
"Export failed:": "Экспортирование не удалось:", "Export failed:": "Экспортирование не удалось:",
"export error": "ошибка экспорта", "export error": "ошибка экспорта",
"Export page": "Экспорт страницы", "Export page": "Экспорт страницы",
"Export successful": "Export successful",
"Export space": "Экспорт пространства", "Export space": "Экспорт пространства",
"Export {{type}}": "Экспорт {{type}}", "Export {{type}}": "Экспорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}", "File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.", "Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.", "Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.", "Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Таблица", "Table": "Таблица",
"Insert a table.": "Вставить таблицу.", "Insert a table.": "Вставить таблицу.",
"Insert collapsible block.": "Вставить сворачиваемый блок.", "Insert collapsible block.": "Вставить сворачиваемый блок.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.", "Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.", "Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
"Confirm": "Підтвердити", "Confirm": "Підтвердити",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Копіювати посилання", "Copy link": "Копіювати посилання",
"Create": "Створити", "Create": "Створити",
"Create group": "Створити групу", "Create group": "Створити групу",
@@ -253,6 +254,7 @@
"Export failed:": "Експортування не вдалося:", "Export failed:": "Експортування не вдалося:",
"export error": "помилка експорту", "export error": "помилка експорту",
"Export page": "Експорт сторінки", "Export page": "Експорт сторінки",
"Export successful": "Export successful",
"Export space": "Експорт простору", "Export space": "Експорт простору",
"Export {{type}}": "Експорт {{type}}", "Export {{type}}": "Експорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}", "File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.", "Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.", "Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.", "Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Таблиця", "Table": "Таблиця",
"Insert a table.": "Вставити таблицю.", "Insert a table.": "Вставити таблицю.",
"Insert collapsible block.": "Вставити блок, що згортається.", "Insert collapsible block.": "Вставити блок, що згортається.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "选择您喜欢的界面语言。", "Choose your preferred interface language.": "选择您喜欢的界面语言。",
"Choose your preferred page width.": "选择您喜欢的页面宽度。", "Choose your preferred page width.": "选择您喜欢的页面宽度。",
"Confirm": "确认", "Confirm": "确认",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "复制链接", "Copy link": "复制链接",
"Create": "创建", "Create": "创建",
"Create group": "创建群组", "Create group": "创建群组",
@@ -253,6 +254,7 @@
"Export failed:": "导出失败:", "Export failed:": "导出失败:",
"export error": "导出出错", "export error": "导出出错",
"Export page": "导出页面", "Export page": "导出页面",
"Export successful": "Export successful",
"Export space": "导出空间", "Export space": "导出空间",
"Export {{type}}": "导出为 {{type}}", "Export {{type}}": "导出为 {{type}}",
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制", "File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "从设备上传任何图像", "Upload any image from your device.": "从设备上传任何图像",
"Upload any video from your device.": "从设备上传任何视频", "Upload any video from your device.": "从设备上传任何视频",
"Upload any file from your device.": "从设备上传任何文件", "Upload any file from your device.": "从设备上传任何文件",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "表格", "Table": "表格",
"Insert a table.": "插入一个表格", "Insert a table.": "插入一个表格",
"Insert collapsible block.": "插入一个折叠块", "Insert collapsible block.": "插入一个折叠块",
@@ -30,9 +30,11 @@ export default function ExportModal({
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown); const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const [includeChildren, setIncludeChildren] = useState<boolean>(false); const [includeChildren, setIncludeChildren] = useState<boolean>(false);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false); const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
const [isExporting, setIsExporting] = useState<boolean>(false);
const { t } = useTranslation(); const { t } = useTranslation();
const handleExport = async () => { const handleExport = async () => {
setIsExporting(true);
try { try {
if (type === "page") { if (type === "page") {
await exportPage({ await exportPage({
@@ -45,6 +47,9 @@ export default function ExportModal({
if (type === "space") { if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments }); await exportSpace({ spaceId: id, format, includeAttachments });
} }
notifications.show({
message: t("Export successful"),
});
onClose(); onClose();
} catch (err) { } catch (err) {
notifications.show({ notifications.show({
@@ -52,6 +57,8 @@ export default function ExportModal({
color: "red", color: "red",
}); });
console.error("export error", err); console.error("export error", err);
} finally {
setIsExporting(false);
} }
}; };
@@ -136,7 +143,7 @@ export default function ExportModal({
<Button onClick={onClose} variant="default"> <Button onClick={onClose} variant="default">
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button onClick={handleExport}>{t("Export")}</Button> <Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
</Group> </Group>
</Modal.Body> </Modal.Body>
</Modal.Content> </Modal.Content>
@@ -5,26 +5,27 @@ import {
Badge, Badge,
Table, Table,
ActionIcon, ActionIcon,
} from '@mantine/core'; } from "@mantine/core";
import {Link} from 'react-router-dom'; import { Link } from "react-router-dom";
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx'; import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from '@/features/page/page.utils.ts'; import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from '@/lib/time.ts'; import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts'; import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription } from '@tabler/icons-react'; import { IconFileDescription } from "@tabler/icons-react";
import { getSpaceUrl } from '@/lib/config.ts'; import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts";
interface Props { interface Props {
spaceId?: string; spaceId?: string;
} }
export default function RecentChanges({spaceId}: Props) { export default function RecentChanges({ spaceId }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId); const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
if (isLoading) { if (isLoading) {
return <PageListSkeleton/>; return <PageListSkeleton />;
} }
if (isError) { if (isError) {
@@ -44,8 +45,8 @@ export default function RecentChanges({spaceId}: Props) {
> >
<Group wrap="nowrap"> <Group wrap="nowrap">
{page.icon || ( {page.icon || (
<ActionIcon variant='transparent' color='gray' size={18}> <ActionIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18}/> <IconFileDescription size={18} />
</ActionIcon> </ActionIcon>
)} )}
@@ -58,18 +59,23 @@ export default function RecentChanges({spaceId}: Props) {
{!spaceId && ( {!spaceId && (
<Table.Td> <Table.Td>
<Badge <Badge
color="blue" color={getInitialsColor(page?.space.name)}
variant="light" variant="light"
component={Link} component={Link}
to={getSpaceUrl(page?.space.slug)} to={getSpaceUrl(page?.space.slug)}
style={{cursor: 'pointer'}} style={{ cursor: "pointer" }}
> >
{page?.space.name} {page?.space.name}
</Badge> </Badge>
</Table.Td> </Table.Td>
)} )}
<Table.Td> <Table.Td>
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}> <Text
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
>
{formattedDate(page.updatedAt)} {formattedDate(page.updatedAt)}
</Text> </Text>
</Table.Td> </Table.Td>
@@ -0,0 +1,49 @@
import { useRef, useState, ReactNode } from "react";
import { Text, TextProps, Tooltip } from "@mantine/core";
type AutoTooltipTextProps = TextProps & {
children: ReactNode;
tooltipLabel?: string;
tooltipProps?: Omit<
React.ComponentProps<typeof Tooltip>,
"children" | "label"
>;
};
export function AutoTooltipText({
children,
tooltipLabel,
tooltipProps,
...textProps
}: AutoTooltipTextProps) {
const textRef = useRef<HTMLParagraphElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const handleMouseEnter = () => {
const element = textRef.current;
if (element) {
setIsTruncated(element.scrollWidth > element.clientWidth);
}
};
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
return (
<Tooltip
label={label}
disabled={!isTruncated || !label}
multiline
withArrow
{...tooltipProps}
>
<Text
ref={textRef}
truncate
onMouseEnter={handleMouseEnter}
{...textProps}
>
{children}
</Text>
</Tooltip>
);
}
@@ -1,11 +1,13 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Text, Paper, ActionIcon } from "@mantine/core"; import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import { IconDownload, IconPaperclip } from "@tabler/icons-react"; import { IconDownload, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks"; import { useHover } from "@mantine/hooks";
import { formatBytes } from "@/lib"; import { formatBytes } from "@/lib";
import { useTranslation } from "react-i18next";
export default function AttachmentView(props: NodeViewProps) { export default function AttachmentView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected } = props; const { node, selected } = props;
const { url, name, size } = node.attrs; const { url, name, size } = node.attrs;
const { hovered, ref } = useHover(); const { hovered, ref } = useHover();
@@ -20,26 +22,28 @@ export default function AttachmentView(props: NodeViewProps) {
wrap="nowrap" wrap="nowrap"
h={25} h={25}
> >
<Group justify="space-between" wrap="nowrap"> <Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
<IconPaperclip size={20} /> {url ? (
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
) : (
<Loader size={20} style={{ flexShrink: 0 }} />
)}
<Text component="span" size="md" truncate="end"> <Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
{name} {url ? name : t("Uploading {{name}}", { name })}
</Text> </Text>
<Text component="span" size="sm" c="dimmed" inline> <Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
{formatBytes(size)} {formatBytes(size)}
</Text> </Text>
</Group> </Group>
{selected || hovered ? ( {url && (selected || hovered) && (
<a href={getFileUrl(url)} target="_blank"> <a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file"> <ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} /> <IconDownload size={18} />
</ActionIcon> </ActionIcon>
</a> </a>
) : (
""
)} )}
</Group> </Group>
</Paper> </Paper>
@@ -1,13 +1,12 @@
import type { EditorView } from "@tiptap/pm/view";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { Slice } from "@tiptap/pm/model";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
export const handlePaste = ( export const handlePaste = (
view: EditorView, editor: Editor,
event: ClipboardEvent, event: ClipboardEvent,
pageId: string, pageId: string,
creatorId?: string, creatorId?: string,
@@ -18,7 +17,7 @@ export const handlePaste = (
// we have to do this validation here to allow the default link extension to takeover if needs be // we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault(); event.preventDefault();
const url = clipboardData.trim(); const url = clipboardData.trim();
const { from: pos, empty } = view.state.selection; const { from: pos, empty } = editor.state.selection;
const match = INTERNAL_LINK_REGEX.exec(url); const match = INTERNAL_LINK_REGEX.exec(url);
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href); const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
@@ -34,19 +33,27 @@ export const handlePaste = (
return false; return false;
} }
const anchorId = match[6] ? match[6].split('#')[0] : undefined; const anchorId = match[6] ? match[6].split("#")[0] : undefined;
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url; const urlWithoutAnchor = anchorId
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId); ? url.substring(0, url.indexOf("#"))
: url;
createMentionAction(
urlWithoutAnchor,
editor.view,
pos,
creatorId,
anchorId,
);
return true; return true;
} }
if (event.clipboardData?.files.length) { if (event.clipboardData?.files.length) {
event.preventDefault(); event.preventDefault();
for (const file of event.clipboardData.files) { for (const file of event.clipboardData.files) {
const pos = view.state.selection.from; const pos = editor.state.selection.from;
uploadImageAction(file, view, pos, pageId); uploadImageAction(file, editor, pos, pageId);
uploadVideoAction(file, view, pos, pageId); uploadVideoAction(file, editor, pos, pageId);
uploadAttachmentAction(file, view, pos, pageId); uploadAttachmentAction(file, editor, pos, pageId);
} }
return true; return true;
} }
@@ -54,7 +61,7 @@ export const handlePaste = (
}; };
export const handleFileDrop = ( export const handleFileDrop = (
view: EditorView, editor: Editor,
event: DragEvent, event: DragEvent,
moved: boolean, moved: boolean,
pageId: string, pageId: string,
@@ -63,14 +70,14 @@ export const handleFileDrop = (
event.preventDefault(); event.preventDefault();
for (const file of event.dataTransfer.files) { for (const file of event.dataTransfer.files) {
const coordinates = view.posAtCoords({ const coordinates = editor.view.posAtCoords({
left: event.clientX, left: event.clientX,
top: event.clientY, top: event.clientY,
}); });
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
} }
return true; return true;
} }
@@ -43,7 +43,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
return false; return false;
} }
return editor.isActive("image"); return editor.isActive("image") && editor.getAttributes("image").src;
}, },
[editor], [editor],
); );
@@ -0,0 +1,27 @@
.imageWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
@@ -1,30 +1,70 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Image, Loader, Text } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { Image } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./image-view.module.css";
import { useTranslation } from "react-i18next";
export default function ImageView(props: NodeViewProps) { export default function ImageView(props: NodeViewProps) {
const { node, selected } = props; const { t } = useTranslation();
const { src, width, align, title } = node.attrs; const { editor, node, selected } = props;
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => { const alignClass = useMemo(() => {
if (align === "left") return "alignLeft"; if (align === "left") return "alignLeft";
if (align === "right") return "alignRight"; if (align === "right") return "alignRight";
if (align === "center") return "alignCenter"; if (align === "center") return "alignCenter";
return "alignCenter"; return "alignCenter";
}, [align]); }, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.imagePreviews =
editor.storage.shared.imagePreviews || {};
if (placeholder?.id) {
return editor.storage.shared.imagePreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<Image <div
radius="md" className={clsx(
fit="contain" selected && "ProseMirror-selectednode",
w={width} classes.imageWrapper,
src={getFileUrl(src)} alignClass,
alt={title} )}
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)} style={{
/> aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
width,
}}
>
{src && (
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<Image
radius="md"
fit="contain"
src={previewSrc}
alt={placeholder?.name}
/>
<Loader size={20} pos="absolute" bottom={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
</NodeViewWrapper> </NodeViewWrapper>
); );
} }
@@ -164,7 +164,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const enterHandler = () => { const enterHandler = () => {
if (!renderItems.length) return; if (!renderItems.length) return;
if (renderItems[selectedIndex].entityType !== "header") { if (renderItems[selectedIndex]?.entityType !== "header") {
selectItem(selectedIndex); selectItem(selectedIndex);
} }
}; };
@@ -77,7 +77,7 @@ const mentionRenderItems = () => {
{ {
placement: "bottom-start", placement: "bottom-start",
middleware: [offset(0), flip(), shift()], middleware: [offset(0), flip(), shift()],
} },
).then(({ x, y }) => { ).then(({ x, y }) => {
Object.assign(element.style, { Object.assign(element.style, {
left: `${x}px`, left: `${x}px`,
@@ -86,7 +86,7 @@ const mentionRenderItems = () => {
zIndex: "9999", zIndex: "9999",
}); });
}); });
} },
); );
}, },
onUpdate: (props: { onUpdate: (props: {
@@ -115,23 +115,30 @@ const mentionRenderItems = () => {
// destroy component if space is greater 3 without a match // destroy component if space is greater 3 without a match
if ( if (
whitespaceCount > 3 && whitespaceCount > 4 &&
//@ts-ignore //@ts-ignore
props.editor.storage.mentionItems.length === 0 props.editor.storage.mentionItems.length === 1
) { ) {
destroy(); destroy();
return; return;
} }
// fallback exit
if (whitespaceCount > 7) {
destroy();
return;
}
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key) if (props.event.key === "Escape") {
if ( destroy();
props.event.key === "Escape" || return true;
(props.event.key === "Enter" && !component) }
) {
destroy(); if (props.event.key === "Enter" && !component) {
return false; destroy();
} return false;
}
return (component?.ref as any)?.onKeyDown(props); return (component?.ref as any)?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
@@ -174,9 +174,13 @@ const CommandGroups: SlashMenuGroupedItemsType = {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { for (const file of input.files) {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadImageAction(file, editor.view, pos, pageId);
uploadImageAction(file, editor, pos, pageId);
} }
} }
// Reset the input value to allow uploading the same file again if needed
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -197,12 +201,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = "video/*"; input.accept = "video/*";
input.multiple = true;
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; for (const file of input.files) {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadVideoAction(file, editor.view, pos, pageId);
uploadVideoAction(file, editor, pos, pageId);
}
} }
// Reset the input value to allow uploading the same file again if needed
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -223,12 +233,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = ""; input.accept = "";
input.multiple = true;
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; for (const file of input.files) {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor.view, pos, pageId, true);
uploadAttachmentAction(file, editor, pos, pageId, true);
}
} }
// Reset the input value to allow uploading the same file again if needed
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -20,7 +20,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
selector: ctx => { selector: (ctx) => {
if (!ctx.editor) { if (!ctx.editor) {
return null; return null;
} }
@@ -43,7 +43,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
return false; return false;
} }
return editor.isActive("video"); return editor.isActive("video") && editor.getAttributes("video").src;
}, },
[editor], [editor],
); );
@@ -0,0 +1,33 @@
.videoWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
.video {
display: block;
width: 100%;
height: 100%;
border-radius: 8px;
}
@@ -1,29 +1,75 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Loader, Text } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./video-view.module.css";
import { useTranslation } from "react-i18next";
export default function VideoView(props: NodeViewProps) { export default function VideoView(props: NodeViewProps) {
const { node, selected } = props; const { t } = useTranslation();
const { src, width, align } = node.attrs; const { editor, node, selected } = props;
const { src, width, align, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => { const alignClass = useMemo(() => {
if (align === "left") return "alignLeft"; if (align === "left") return "alignLeft";
if (align === "right") return "alignRight"; if (align === "right") return "alignRight";
if (align === "center") return "alignCenter"; if (align === "center") return "alignCenter";
return "alignCenter"; return "alignCenter";
}, [align]); }, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.videoPreviews =
editor.storage.shared.videoPreviews || {};
if (placeholder?.id) {
return editor.storage.shared.videoPreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<video <div
preload="metadata" className={clsx(
width={width} selected && "ProseMirror-selectednode",
controls classes.videoWrapper,
src={getFileUrl(src)} alignClass,
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)} )}
style={{ display: "block" }} style={{
/> aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
width,
}}
>
{src && (
<video
className={classes.video}
preload="metadata"
controls
src={getFileUrl(src)}
/>
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<video
className={classes.video}
preload="metadata"
controls
src={previewSrc}
/>
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
</NodeViewWrapper> </NodeViewWrapper>
); );
} }
@@ -42,6 +42,7 @@ import {
Heading, Heading,
Highlight, Highlight,
UniqueID, UniqueID,
SharedStorage,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -107,6 +108,7 @@ export const mainExtensions = [
}, },
}, },
}), }),
SharedStorage,
Heading, Heading,
UniqueID.configure({ UniqueID.configure({
types: ["heading", "paragraph"], types: ["heading", "paragraph"],
@@ -246,7 +248,7 @@ export const mainExtensions = [
Escape: () => { Escape: () => {
const event = new CustomEvent("closeFindDialogFromEditor", {}); const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event); document.dispatchEvent(event);
return true; return false;
}, },
}; };
}, },
+23 -11
View File
@@ -16,6 +16,7 @@ import {
onSyncedParameters, onSyncedParameters,
} from "@hocuspocus/provider"; } from "@hocuspocus/provider";
import { import {
Editor,
EditorContent, EditorContent,
EditorProvider, EditorProvider,
useEditor, useEditor,
@@ -79,7 +80,7 @@ export default function PageEditor({
}: PageEditorProps) { }: PageEditorProps) {
const collaborationURL = useCollaborationUrl(); const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
const editorCreated = useRef(false); const editorRef = useRef<Editor | null>(null);
useEffect(() => { useEffect(() => {
isComponentMounted.current = true; isComponentMounted.current = true;
@@ -93,7 +94,7 @@ export default function PageEditor({
const [isLocalSynced, setIsLocalSynced] = useState(false); const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false); const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom yjsConnectionStatusAtom,
); );
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
@@ -104,8 +105,8 @@ export default function PageEditor({
const userPageEditMode = const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback( const canScroll = useCallback(
() => isComponentMounted.current && editorCreated.current, () => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted, editorCreated] [isComponentMounted],
); );
const { handleScrollTo } = useEditorScroll({ canScroll }); const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId // Providers only created once per pageId
@@ -253,10 +254,21 @@ export default function PageEditor({
} }
}, },
}, },
handlePaste: (view, event, slice) => handlePaste: (_view, event) => {
handlePaste(view, event, pageId, currentUser?.user.id), if (!editorRef.current) return false;
handleDrop: (view, event, _slice, moved) =>
handleFileDrop(view, event, moved, pageId), return handlePaste(
editorRef.current,
event,
pageId,
currentUser?.user.id,
);
},
handleDrop: (_view, event, _slice, moved) => {
if (!editorRef.current) return false;
return handleFileDrop(editorRef.current, event, moved, pageId);
},
}, },
onCreate({ editor }) { onCreate({ editor }) {
if (editor) { if (editor) {
@@ -265,7 +277,7 @@ export default function PageEditor({
// @ts-ignore // @ts-ignore
editor.storage.pageId = pageId; editor.storage.pageId = pageId;
handleScrollTo(editor); handleScrollTo(editor);
editorCreated.current = true; editorRef.current = editor;
} }
}, },
onUpdate({ editor }) { onUpdate({ editor }) {
@@ -275,7 +287,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson); debouncedUpdateContent(editorJson);
}, },
}, },
[pageId, editable, extensions] [pageId, editable, extensions],
); );
const editorIsEditable = useEditorState({ const editorIsEditable = useEditorState({
@@ -320,7 +332,7 @@ export default function PageEditor({
return () => { return () => {
document.removeEventListener( document.removeEventListener(
"ACTIVE_COMMENT_EVENT", "ACTIVE_COMMENT_EVENT",
handleActiveCommentEvent handleActiveCommentEvent,
); );
}; };
}, []); }, []);
@@ -10,6 +10,7 @@ import Paginate from "@/components/common/paginate.tsx";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { getSpaces } from "@/features/space/services/space-service.ts"; import { getSpaces } from "@/features/space/services/space-service.ts";
import { getGroupMembers } from "@/features/group/services/group-service.ts"; import { getGroupMembers } from "@/features/group/services/group-service.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
export default function GroupList() { export default function GroupList() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -51,9 +52,9 @@ export default function GroupList() {
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<IconGroupCircle /> <IconGroupCircle />
<div style={{ minWidth: 0, overflow: "hidden" }}> <div style={{ minWidth: 0, overflow: "hidden" }}>
<Text fz="sm" fw={500} lineClamp={1}> <AutoTooltipText fz="sm" fw={500} lineClamp={1}>
{group.name} {group.name}
</Text> </AutoTooltipText>
<Text fz="xs" c="dimmed" lineClamp={2}> <Text fz="xs" c="dimmed" lineClamp={2}>
{group.description} {group.description}
</Text> </Text>
@@ -1,8 +1,9 @@
import '@/features/editor/styles/index.css'; import "@/features/editor/styles/index.css";
import React, { useEffect } from 'react'; import React, { useEffect } from "react";
import { EditorContent, useEditor } from '@tiptap/react'; import { EditorContent, useEditor } from "@tiptap/react";
import { mainExtensions } from '@/features/editor/extensions/extensions'; import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Title } from '@mantine/core'; import { Title } from "@mantine/core";
import classes from "./history.module.css";
export interface HistoryEditorProps { export interface HistoryEditorProps {
title: string; title: string;
@@ -26,7 +27,9 @@ export function HistoryEditor({ title, content }: HistoryEditorProps) {
<div> <div>
<Title order={1}>{title}</Title> <Title order={1}>{title}</Title>
{editor && <EditorContent editor={editor} />} {editor && (
<EditorContent editor={editor} className={classes.historyEditor} />
)}
</div> </div>
</> </>
); );
@@ -1,37 +1,49 @@
.history { .history {
display: block; display: block;
width: 100%; width: 100%;
padding: var(--mantine-spacing-md); padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover { @mixin hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8)); background-color: light-dark(
} var(--mantine-color-gray-2),
var(--mantine-color-dark-8)
);
}
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
}
} }
.active { .active {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8)); background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-8)
);
} }
.sidebar { .sidebar {
max-height: rem(700px); max-height: rem(700px);
width: rem(250px); width: rem(250px);
padding: var(--mantine-spacing-sm); padding: var(--mantine-spacing-sm);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-right: rem(1px) solid border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
} }
.sidebarFlex { .sidebarFlex {
display: flex; display: flex;
} }
.sidebarMain { .sidebarMain {
flex: 1; flex: 1;
} }
.sidebarRightSection { .sidebarRightSection {
flex: 1; flex: 1;
padding: rem(16px) rem(40px); padding: rem(16px) rem(40px);
} }
@@ -7,22 +7,17 @@ import {
IconHistory, IconHistory,
IconLink, IconLink,
IconList, IconList,
IconMarkdown,
IconMessage, IconMessage,
IconPrinter, IconPrinter,
IconSearch,
IconTrash, IconTrash,
IconWifiOff, IconWifiOff,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import React from "react"; import React, { useEffect, useRef, useState } from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
getHotkeyHandler,
useClipboard,
useDisclosure,
useHotkeys,
} from "@mantine/hooks";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -34,12 +29,12 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal"; import ExportModal from "@/components/common/export-modal";
import { htmlToMarkdown } from "@docmost/editor-ext";
import { import {
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts"; } from "@/features/editor/atoms/editor-atoms.ts";
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts"; import { formattedDate } from "@/lib/time.ts";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx"; import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
@@ -51,7 +46,6 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const toggleAside = useToggleAside(); const toggleAside = useToggleAside();
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
useHotkeys( useHotkeys(
[ [
@@ -68,6 +62,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const event = new CustomEvent("closeFindDialogFromEditor", {}); const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event); document.dispatchEvent(event);
}, },
{ preventDefault: false },
], ],
], ],
[], [],
@@ -75,17 +70,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
return ( return (
<> <>
{yjsConnectionStatus === "disconnected" && ( <ConnectionWarning />
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
<IconWifiOff size={20} stroke={2} />
</ActionIcon>
</Tooltip>
)}
{!readOnly && <PageStateSegmentedControl size="xs" />} {!readOnly && <PageStateSegmentedControl size="xs" />}
@@ -146,6 +131,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
notifications.show({ message: t("Link copied") }); notifications.show({ message: t("Link copied") });
}; };
const handleCopyAsMarkdown = () => {
if (!pageEditor) return;
const html = pageEditor.getHTML();
const markdown = htmlToMarkdown(html);
const title = page?.title ? `# ${page.title}\n\n` : "";
clipboard.copy(`${title}${markdown}`);
notifications.show({ message: t("Copied") });
};
const handlePrint = () => { const handlePrint = () => {
setTimeout(() => { setTimeout(() => {
window.print(); window.print();
@@ -183,6 +177,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
> >
{t("Copy link")} {t("Copy link")}
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<IconMarkdown size={16} />}
onClick={handleCopyAsMarkdown}
>
{t("Copy as Markdown")}
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}> <Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
@@ -290,3 +291,51 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</> </>
); );
} }
function ConnectionWarning() {
const { t } = useTranslation();
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
const [showWarning, setShowWarning] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const isDisconnected = ["disconnected", "connecting"].includes(
yjsConnectionStatus,
);
if (isDisconnected) {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
}
} else {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setShowWarning(false);
}
}, [yjsConnectionStatus]);
// Cleanup only on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
if (!showWarning) return null;
return (
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
<IconWifiOff size={20} stroke={2} />
</ActionIcon>
</Tooltip>
);
}
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
export default function SpaceList() { export default function SpaceList() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -49,9 +50,9 @@ export default function SpaceList() {
name={space.name} name={space.name}
/> />
<div style={{ minWidth: 0, overflow: "hidden" }}> <div style={{ minWidth: 0, overflow: "hidden" }}>
<Text fz="sm" fw={500} lineClamp={1}> <AutoTooltipText fz="sm" fw={500} lineClamp={1}>
{space.name} {space.name}
</Text> </AutoTooltipText>
<Text fz="xs" c="dimmed" lineClamp={2}> <Text fz="xs" c="dimmed" lineClamp={2}>
{space.description} {space.description}
</Text> </Text>
@@ -27,6 +27,7 @@ import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { SearchInput } from "@/components/common/search-input.tsx"; import { SearchInput } from "@/components/common/search-input.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx"; import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
type MemberType = "user" | "group"; type MemberType = "user" | "group";
@@ -138,10 +139,10 @@ export default function SpaceMembersList({
{member.type === "group" && <IconGroupCircle />} {member.type === "group" && <IconGroupCircle />}
<div> <div style={{ minWidth: 0, overflow: "hidden", maxWidth: 260 }}>
<Text fz="sm" fw={500} lineClamp={1}> <AutoTooltipText fz="sm" fw={500}>
{member?.name} {member?.name}
</Text> </AutoTooltipText>
<Text fz="xs" c="dimmed"> <Text fz="xs" c="dimmed">
{member.type == "user" && member?.email} {member.type == "user" && member?.email}
@@ -23,6 +23,7 @@ import SpaceSettingsModal from "@/features/space/components/settings-modal";
import classes from "./all-spaces-list.module.css"; import classes from "./all-spaces-list.module.css";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
interface AllSpacesListProps { interface AllSpacesListProps {
spaces: any[]; spaces: any[];
@@ -96,10 +97,10 @@ export default function AllSpacesList({
variant="filled" variant="filled"
size="md" size="md"
/> />
<div> <div style={{ minWidth: 0, overflow: "hidden", maxWidth: 350 }}>
<Text fz="sm" fw={500} lineClamp={1}> <AutoTooltipText fz="sm" fw={500} lineClamp={1}>
{space.name} {space.name}
</Text> </AutoTooltipText>
{space.description && ( {space.description && (
<Text fz="xs" c="dimmed" lineClamp={2}> <Text fz="xs" c="dimmed" lineClamp={2}>
{space.description} {space.description}
+34
View File
@@ -0,0 +1,34 @@
import { MantineColor } from "@mantine/core";
function hashCode(input: string) {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
const char = input.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return hash;
}
const defaultColors: MantineColor[] = [
"blue",
"cyan",
"grape",
"green",
"indigo",
"lime",
"orange",
"pink",
"red",
"teal",
"violet",
];
export function getInitialsColor(
name: string,
colors: MantineColor[] = defaultColors,
) {
const hash = hashCode(name);
const index = Math.abs(hash) % colors.length;
return colors[index];
}
+5 -2
View File
@@ -74,6 +74,7 @@
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"kysely": "^0.28.2", "kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"kysely-postgres-js": "^3.0.0",
"ldapts": "^7.4.0", "ldapts": "^7.4.0",
"lib0": "^0.2.117", "lib0": "^0.2.117",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
@@ -81,6 +82,7 @@
"msgpackr": "^1.11.8", "msgpackr": "^1.11.8",
"nanoid": "3.3.11", "nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0", "nestjs-kysely": "^1.2.0",
"nestjs-pino": "^4.5.0",
"nodemailer": "^7.0.12", "nodemailer": "^7.0.12",
"openid-client": "^5.7.1", "openid-client": "^5.7.1",
"otpauth": "^9.4.1", "otpauth": "^9.4.1",
@@ -88,9 +90,11 @@
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdfjs-dist": "^5.4.394", "pdfjs-dist": "^5.4.394",
"pg": "^8.16.3",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1", "pgvector": "^0.2.1",
"postgres": "^3.4.8",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"postmark": "^4.0.5", "postmark": "^4.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -119,7 +123,6 @@
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@types/passport-google-oauth20": "^2.0.16", "@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.11.11",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/ws": "^8.5.14", "@types/ws": "^8.5.14",
"@types/yauzl": "^2.10.3", "@types/yauzl": "^2.10.3",
+2
View File
@@ -18,6 +18,7 @@ import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module'; import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis'; import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service'; import { RedisConfigService } from './integrations/redis/redis-config.service';
import { LoggerModule } from './common/logger/logger.module';
const enterpriseModules = []; const enterpriseModules = [];
try { try {
@@ -35,6 +36,7 @@ try {
@Module({ @Module({
imports: [ imports: [
LoggerModule,
CoreModule, CoreModule,
DatabaseModule, DatabaseModule,
EnvironmentModule, EnvironmentModule,
@@ -8,9 +8,11 @@ import { QueueModule } from '../../integrations/queue/queue.module';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from '../../integrations/health/health.module'; import { HealthModule } from '../../integrations/health/health.module';
import { CollaborationController } from './collaboration.controller'; import { CollaborationController } from './collaboration.controller';
import { LoggerModule } from '../../common/logger/logger.module';
@Module({ @Module({
imports: [ imports: [
LoggerModule,
DatabaseModule, DatabaseModule,
EnvironmentModule, EnvironmentModule,
CollaborationModule, CollaborationModule,
@@ -5,8 +5,8 @@ import {
NestFastifyApplication, NestFastifyApplication,
} from '@nestjs/platform-fastify'; } from '@nestjs/platform-fastify';
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor'; import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
import { InternalLogFilter } from '../../common/logger/internal-log-filter';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Logger as PinoLogger } from 'nestjs-pino';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
@@ -17,10 +17,12 @@ async function bootstrap() {
maxParamLength: 500, maxParamLength: 500,
}), }),
{ {
logger: new InternalLogFilter(), bufferLogs: true,
}, },
); );
app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', { exclude: ['/'] }); app.setGlobalPrefix('api', { exclude: ['/'] });
app.enableCors(); app.enableCors();
@@ -0,0 +1,71 @@
// https://github.com/WebReflection/html-escaper
/**
* Copyright (C) 2017-present by Andrea Giammarchi - @WebReflection
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
const { replace } = '';
// escape
const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g;
const ca = /[&<>'"]/g;
const esca = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
};
const pe = (m) => esca[m];
/**
* Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`.
* @param {string} es the input to safely escape
* @returns {string} the escaped input, and it **throws** an error if
* the input type is unexpected, except for boolean and numbers,
* converted as string.
*/
export const htmlEscape = (es) => replace.call(es, ca, pe);
// unescape
const unes = {
'&amp;': '&',
'&#38;': '&',
'&lt;': '<',
'&#60;': '<',
'&gt;': '>',
'&#62;': '>',
'&apos;': "'",
'&#39;': "'",
'&quot;': '"',
'&#34;': '"',
};
const cape = (m) => unes[m];
/**
* Safely unescape previously escaped entities such as `&`, `<`, `>`, `"`,
* and `'`.
* @param {string} un a previously escaped string
* @returns {string} the unescaped input, and it **throws** an error if
* the input type is unexpected, except for boolean and numbers,
* converted as string.
*/
export const htmlUnescape = (un) => replace.call(un, es, cape);
+36
View File
@@ -2,6 +2,7 @@ import * as path from 'path';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { sanitize } from 'sanitize-filename-ts'; import { sanitize } from 'sanitize-filename-ts';
import { FastifyRequest } from 'fastify'; import { FastifyRequest } from 'fastify';
import { Readable, Transform } from 'stream';
export const envPath = path.resolve(process.cwd(), '..', '..', '.env'); export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
@@ -98,3 +99,38 @@ export function hasLicenseOrEE(opts: {
const { licenseKey, plan, isCloud } = opts; const { licenseKey, plan, isCloud } = opts;
return Boolean(licenseKey) || (isCloud && plan === 'business'); return Boolean(licenseKey) || (isCloud && plan === 'business');
} }
/**
* Normalizes a database URL for postgres.js compatibility.
* - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values
* - Removes `schema` parameter (has no effect via connection string)
* Note: If we don't strip them, the connection will fail
*/
export function normalizePostgresUrl(url: string): string {
const parsed = new URL(url);
const newParams = new URLSearchParams();
for (const [key, value] of parsed.searchParams) {
if (key === 'sslmode' && value === 'no-verify') continue;
if (key === 'schema') continue;
newParams.append(key, value);
}
parsed.search = newParams.toString();
return parsed.toString();
}
export function createByteCountingStream(source: Readable) {
let bytesRead = 0;
const stream = new Transform({
transform(chunk, encoding, callback) {
bytesRead += chunk.length;
callback(null, chunk);
},
});
source.pipe(stream);
source.on('error', (err) => stream.emit('error', err));
return { stream, getBytesRead: () => bytesRead };
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { LoggerModule as PinoLoggerModule } from 'nestjs-pino';
import { createPinoConfig } from './pino.config';
@Module({
imports: [PinoLoggerModule.forRoot(createPinoConfig())],
exports: [PinoLoggerModule],
})
export class LoggerModule {}
@@ -0,0 +1,77 @@
import { Params } from 'nestjs-pino';
import { stdTimeFunctions } from 'pino';
const CONTEXTS_TO_IGNORE = [
'InstanceLoader',
'RoutesResolver',
'RouterExplorer',
'WebSocketsController',
];
export function createPinoConfig(): Params {
const isProduction = process.env.NODE_ENV === 'production';
const isDebugMode = process.env.DEBUG_MODE === 'true';
const logHttp = process.env.LOG_HTTP === 'true';
const level = isProduction && !isDebugMode ? 'info' : 'debug';
return {
pinoHttp: {
level,
timestamp: stdTimeFunctions.isoTime,
transport: !isProduction
? {
target: 'pino-pretty',
options: {
colorize: true,
singleLine: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
}
: undefined,
formatters: {
level: (label) => ({ level: label }),
log: (object: Record<string, unknown>) => {
if (isProduction && !isDebugMode) {
const context = object['context'] as string | undefined;
if (context && CONTEXTS_TO_IGNORE.includes(context)) {
return { filtered: true };
}
}
return object;
},
},
serializers: {
req: (req) => {
const forwardedFor = req.headers?.['x-forwarded-for'];
const ip =
req.headers?.['cf-connecting-ip'] ||
(typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() : undefined) ||
req.remoteAddress;
return {
method: req.method,
url: req.url,
ip,
userAgent: req.headers?.['user-agent'],
};
},
res: (res) => ({
statusCode: res.statusCode,
}),
},
customLogLevel: (_req, res, err) => {
if (res.statusCode >= 500 || err) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
autoLogging: logHttp
? {
ignore: (req) =>
req.url === '/api/health' || req.url === '/api/health/live',
}
: false,
},
};
}
@@ -181,7 +181,9 @@ export class AttachmentController {
} }
try { try {
const fileStream = await this.storageService.read(attachment.filePath); const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({ res.headers({
'Content-Type': attachment.mimeType, 'Content-Type': attachment.mimeType,
'Cache-Control': 'private, max-age=3600', 'Cache-Control': 'private, max-age=3600',
@@ -241,7 +243,9 @@ export class AttachmentController {
} }
try { try {
const fileStream = await this.storageService.read(attachment.filePath); const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({ res.headers({
'Content-Type': attachment.mimeType, 'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600', 'Cache-Control': 'public, max-age=3600',
@@ -367,14 +371,14 @@ export class AttachmentController {
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`; const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
try { try {
const fileStream = await this.storageService.read(filePath); const fileStream = await this.storageService.readStream(filePath);
res.headers({ res.headers({
'Content-Type': getMimeType(filePath), 'Content-Type': getMimeType(filePath),
'Cache-Control': 'private, max-age=86400', 'Cache-Control': 'private, max-age=86400',
}); });
return res.send(fileStream); return res.send(fileStream);
} catch (err) { } catch (err) {
// this.logger.error(err); // this.logger.error(err);
throw new NotFoundException('File not found'); throw new NotFoundException('File not found');
} }
} }
@@ -5,15 +5,17 @@ import { sanitizeFileName } from '../../common/helpers';
import * as sharp from 'sharp'; import * as sharp from 'sharp';
export interface PreparedFile { export interface PreparedFile {
buffer: Buffer; buffer?: Buffer;
fileName: string; fileName: string;
fileSize: number; fileSize: number;
fileExtension: string; fileExtension: string;
mimeType: string; mimeType: string;
multiPartFile?: MultipartFile;
} }
export async function prepareFile( export async function prepareFile(
filePromise: Promise<MultipartFile>, filePromise: Promise<MultipartFile>,
options: { skipBuffer?: boolean } = {},
): Promise<PreparedFile> { ): Promise<PreparedFile> {
const file = await filePromise; const file = await filePromise;
@@ -22,10 +24,16 @@ export async function prepareFile(
} }
try { try {
const buffer = await file.toBuffer(); let buffer: Buffer | undefined;
let fileSize = 0;
if (!options.skipBuffer) {
buffer = await file.toBuffer();
fileSize = buffer.length;
}
const sanitizedFilename = sanitizeFileName(file.filename); const sanitizedFilename = sanitizeFileName(file.filename);
const fileName = sanitizedFilename.slice(0, 255); const fileName = sanitizedFilename.slice(0, 255);
const fileSize = buffer.length;
const fileExtension = path.extname(file.filename).toLowerCase(); const fileExtension = path.extname(file.filename).toLowerCase();
return { return {
@@ -34,6 +42,7 @@ export async function prepareFile(
fileSize, fileSize,
fileExtension, fileExtension,
mimeType: file.mimetype, mimeType: file.mimetype,
multiPartFile: file,
}; };
} catch (error) { } catch (error) {
throw error; throw error;
@@ -4,6 +4,7 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Readable } from 'stream';
import { StorageService } from '../../../integrations/storage/storage.service'; import { StorageService } from '../../../integrations/storage/storage.service';
import { MultipartFile } from '@fastify/multipart'; import { MultipartFile } from '@fastify/multipart';
import { import {
@@ -26,6 +27,7 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { createByteCountingStream } from '../../../common/helpers/utils';
@Injectable() @Injectable()
export class AttachmentService { export class AttachmentService {
@@ -49,7 +51,9 @@ export class AttachmentService {
attachmentId?: string; attachmentId?: string;
}) { }) {
const { filePromise, pageId, spaceId, userId, workspaceId } = opts; const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
const preparedFile: PreparedFile = await prepareFile(filePromise); const preparedFile: PreparedFile = await prepareFile(filePromise, {
skipBuffer: true,
});
let isUpdate = false; let isUpdate = false;
let attachmentId = null; let attachmentId = null;
@@ -81,7 +85,14 @@ export class AttachmentService {
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`; const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
await this.uploadToDrive(filePath, preparedFile.buffer); const { stream, getBytesRead } = createByteCountingStream(
preparedFile.multiPartFile.file,
);
await this.uploadToDrive(filePath, stream);
// Update fileSize from the consumed stream
preparedFile.fileSize = getBytesRead();
let attachment: Attachment = null; let attachment: Attachment = null;
try { try {
@@ -142,7 +153,10 @@ export class AttachmentService {
const preparedFile: PreparedFile = await prepareFile(filePromise); const preparedFile: PreparedFile = await prepareFile(filePromise);
validateFileType(preparedFile.fileExtension, validImageExtensions); validateFileType(preparedFile.fileExtension, validImageExtensions);
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type); const processedBuffer = await compressAndResizeIcon(
preparedFile.buffer,
type,
);
preparedFile.buffer = processedBuffer; preparedFile.buffer = processedBuffer;
preparedFile.fileSize = processedBuffer.length; preparedFile.fileSize = processedBuffer.length;
preparedFile.fileName = uuid4() + preparedFile.fileExtension; preparedFile.fileName = uuid4() + preparedFile.fileExtension;
@@ -232,9 +246,9 @@ export class AttachmentService {
} }
} }
async uploadToDrive(filePath: string, fileBuffer: any) { async uploadToDrive(filePath: string, fileContent: Buffer | Readable) {
try { try {
await this.storageService.upload(filePath, fileBuffer); await this.storageService.upload(filePath, fileContent);
} catch (err) { } catch (err) {
this.logger.error('Error uploading file to drive:', err); this.logger.error('Error uploading file to drive:', err);
throw new BadRequestException('Error uploading file to drive'); throw new BadRequestException('Error uploading file to drive');
@@ -7,6 +7,7 @@ import { validate as isValidUUID } from 'uuid';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service'; import { EnvironmentService } from '../../integrations/environment/environment.service';
import { Workspace } from '@docmost/db/types/entity.types'; import { Workspace } from '@docmost/db/types/entity.types';
import { htmlEscape } from '../../common/helpers/html-escaper';
@Controller('share') @Controller('share')
export class ShareSeoController { export class ShareSeoController {
@@ -68,7 +69,7 @@ export class ShareSeoController {
return this.sendIndex(indexFilePath, res); return this.sendIndex(indexFilePath, res);
} }
const rawTitle = share.sharedPage.title ?? 'untitled'; const rawTitle = htmlEscape(share?.sharedPage.title ?? 'untitled');
const metaTitle = const metaTitle =
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}` : rawTitle; rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}` : rawTitle;
+23 -20
View File
@@ -7,8 +7,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectKysely, KyselyModule } from 'nestjs-kysely'; import { InjectKysely, KyselyModule } from 'nestjs-kysely';
import { EnvironmentService } from '../integrations/environment/environment.service'; import { EnvironmentService } from '../integrations/environment/environment.service';
import { CamelCasePlugin, LogEvent, PostgresDialect, sql } from 'kysely'; import { CamelCasePlugin, LogEvent, sql } from 'kysely';
import { Pool, types } from 'pg';
import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
@@ -26,9 +25,9 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PageListener } from '@docmost/db/listeners/page.listener'; import { PageListener } from '@docmost/db/listeners/page.listener';
import { PostgresJSDialect } from 'kysely-postgres-js';
// https://github.com/brianc/node-postgres/issues/811 import * as postgres from 'postgres';
types.setTypeParser(types.builtins.INT8, (val) => Number(val)); import { normalizePostgresUrl } from '../common/helpers';
@Global() @Global()
@Module({ @Module({
@@ -37,26 +36,30 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
imports: [], imports: [],
inject: [EnvironmentService], inject: [EnvironmentService],
useFactory: (environmentService: EnvironmentService) => ({ useFactory: (environmentService: EnvironmentService) => ({
dialect: new PostgresDialect({ dialect: new PostgresJSDialect({
pool: new Pool({ postgres: postgres(
connectionString: environmentService.getDatabaseURL(), normalizePostgresUrl(environmentService.getDatabaseURL()),
max: environmentService.getDatabaseMaxPool(), {
}).on('error', (err) => { max: environmentService.getDatabaseMaxPool(),
console.error('Database error:', err.message); onnotice: () => {},
}), types: {
bigint: {
to: 20,
from: [20, 1700],
serialize: (value: number) => value.toString(),
parse: (value: string) => Number.parseInt(value),
},
},
},
),
}), }),
plugins: [new CamelCasePlugin()], plugins: [new CamelCasePlugin()],
log: (event: LogEvent) => { log: (event: LogEvent) => {
if (environmentService.getNodeEnv() !== 'development') return; if (environmentService.getNodeEnv() !== 'development') return;
const logger = new Logger(DatabaseModule.name); const logger = new Logger(DatabaseModule.name);
if (event.level) { if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
if (process.env.DEBUG_DB?.toLowerCase() === 'true') { logger.debug(event.query.sql);
logger.debug(event.query.sql); logger.debug('query time: ' + event.queryDurationMillis + ' ms');
logger.debug('query time: ' + event.queryDurationMillis + ' ms');
//if (event.query.parameters.length > 0) {
// logger.debug('parameters: ' + event.query.parameters);
//}
}
} }
}, },
}), }),
+6 -12
View File
@@ -1,25 +1,19 @@
import * as path from 'path'; import * as path from 'path';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import pg from 'pg'; import { Kysely, Migrator, FileMigrationProvider } from 'kysely';
import {
Kysely,
Migrator,
PostgresDialect,
FileMigrationProvider,
} from 'kysely';
import { run } from 'kysely-migration-cli'; import { run } from 'kysely-migration-cli';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import { envPath } from '../common/helpers/utils'; import { envPath, normalizePostgresUrl } from '../common/helpers';
import { PostgresJSDialect } from 'kysely-postgres-js';
import postgres from 'postgres';
dotenv.config({ path: envPath }); dotenv.config({ path: envPath });
const migrationFolder = path.join(__dirname, './migrations'); const migrationFolder = path.join(__dirname, './migrations');
const db = new Kysely<any>({ const db = new Kysely<any>({
dialect: new PostgresDialect({ dialect: new PostgresJSDialect({
pool: new pg.Pool({ postgres: postgres(normalizePostgresUrl(process.env.DATABASE_URL)),
connectionString: process.env.DATABASE_URL,
}) as any,
}), }),
}); });
@@ -55,7 +55,7 @@ export class ExportController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
const zipFileBuffer = await this.exportService.exportPages( const zipFileStream = await this.exportService.exportPages(
dto.pageId, dto.pageId,
dto.format, dto.format,
dto.includeAttachments, dto.includeAttachments,
@@ -70,7 +70,7 @@ export class ExportController {
'attachment; filename="' + encodeURIComponent(fileName) + '"', 'attachment; filename="' + encodeURIComponent(fileName) + '"',
}); });
res.send(zipFileBuffer); res.send(zipFileStream);
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -100,6 +100,6 @@ export class ExportController {
'"', '"',
}); });
res.send(exportFile.fileBuffer); res.send(exportFile.fileStream);
} }
} }
@@ -5,7 +5,6 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util'; import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util';
import { turndown } from './turndown-utils';
import { ExportFormat } from './dto/export-dto'; import { ExportFormat } from './dto/export-dto';
import { Page } from '@docmost/db/types/entity.types'; import { Page } from '@docmost/db/types/entity.types';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
@@ -31,6 +30,7 @@ import {
getAttachmentIds, getAttachmentIds,
getProsemirrorContent, getProsemirrorContent,
} from '../../common/helpers/prosemirror/utils'; } from '../../common/helpers/prosemirror/utils';
import { htmlToMarkdown } from '@docmost/editor-ext';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
@@ -83,7 +83,7 @@ export class ExportService {
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim, /<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
'', '',
); );
return turndown(newPageHtml); return htmlToMarkdown(newPageHtml);
} }
return; return;
@@ -177,7 +177,7 @@ export class ExportService {
const fileName = `${space.name}-space-export.zip`; const fileName = `${space.name}-space-export.zip`;
return { return {
fileBuffer: zipFile, fileStream: zipFile,
fileName, fileName,
}; };
} }
@@ -10,7 +10,11 @@ import {
} from '../../../collaboration/collaboration.util'; } from '../../../collaboration/collaboration.util';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateSlugId, sanitizeFileName } from '../../../common/helpers'; import {
generateSlugId,
sanitizeFileName,
createByteCountingStream,
} from '../../../common/helpers';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { TiptapTransformer } from '@hocuspocus/transformer'; import { TiptapTransformer } from '@hocuspocus/transformer';
import * as Y from 'yjs'; import * as Y from 'yjs';
@@ -173,15 +177,24 @@ export class ImportService {
}; };
} }
async getNewPagePosition(spaceId: string): Promise<string> { async getNewPagePosition(
const lastPage = await this.db spaceId: string,
parentPageId?: string,
): Promise<string> {
let query = this.db
.selectFrom('pages') .selectFrom('pages')
.select(['id', 'position']) .select(['id', 'position'])
.where('spaceId', '=', spaceId) .where('spaceId', '=', spaceId)
.orderBy('position', (ob) => ob.collate('C').desc()) .orderBy('position', (ob) => ob.collate('C').desc())
.limit(1) .limit(1);
.where('parentPageId', 'is', null)
.executeTakeFirst(); if (parentPageId) {
query = query.where('parentPageId', '=', parentPageId);
} else {
query = query.where('parentPageId', 'is', null);
}
const lastPage = await query.executeTakeFirst();
if (lastPage) { if (lastPage) {
return generateJitteredKeyBetween(lastPage.position, null); return generateJitteredKeyBetween(lastPage.position, null);
@@ -198,20 +211,21 @@ export class ImportService {
workspaceId: string, workspaceId: string,
) { ) {
const file = await filePromise; const file = await filePromise;
const fileBuffer = await file.toBuffer();
const fileExtension = path.extname(file.filename).toLowerCase(); const fileExtension = path.extname(file.filename).toLowerCase();
const fileName = sanitizeFileName( const fileName = sanitizeFileName(
path.basename(file.filename, fileExtension), path.basename(file.filename, fileExtension),
); );
const fileSize = fileBuffer.length;
const fileNameWithExt = fileName + fileExtension; const fileNameWithExt = fileName + fileExtension;
const fileTaskId = uuid7(); const fileTaskId = uuid7();
const filePath = `${getFileTaskFolderPath(FileTaskType.Import, workspaceId)}/${fileTaskId}/${fileNameWithExt}`; const filePath = `${getFileTaskFolderPath(FileTaskType.Import, workspaceId)}/${fileTaskId}/${fileNameWithExt}`;
// upload file // upload file
await this.storageService.upload(filePath, fileBuffer); const { stream, getBytesRead } = createByteCountingStream(file.file);
await this.storageService.upload(filePath, stream);
const fileSize = getBytesRead();
const fileTask = await this.db const fileTask = await this.db
.insertInto('fileTasks') .insertInto('fileTasks')
@@ -20,9 +20,15 @@ export class LocalDriver implements StorageDriver {
return join(this.config.storagePath, filePath); return join(this.config.storagePath, filePath);
} }
async upload(filePath: string, file: Buffer): Promise<void> { async upload(filePath: string, file: Buffer | Readable): Promise<void> {
try { try {
await fs.outputFile(this._fullPath(filePath), file); const fullPath = this._fullPath(filePath);
if (file instanceof Buffer) {
await fs.outputFile(fullPath, file);
} else {
await fs.mkdir(dirname(fullPath), { recursive: true });
await pipeline(file, createWriteStream(fullPath));
}
} catch (err) { } catch (err) {
throw new Error(`Failed to upload file: ${(err as Error).message}`); throw new Error(`Failed to upload file: ${(err as Error).message}`);
} }
@@ -23,19 +23,21 @@ export class S3Driver implements StorageDriver {
this.s3Client = new S3Client(config as any); this.s3Client = new S3Client(config as any);
} }
async upload(filePath: string, file: Buffer): Promise<void> { async upload(filePath: string, file: Buffer | Readable): Promise<void> {
try { try {
const contentType = getMimeType(filePath); const contentType = getMimeType(filePath);
const command = new PutObjectCommand({ const upload = new Upload({
Bucket: this.config.bucket, client: this.s3Client,
Key: filePath, params: {
Body: file, Bucket: this.config.bucket,
ContentType: contentType, Key: filePath,
// ACL: "public-read", Body: file,
ContentType: contentType,
},
}); });
await this.s3Client.send(command); await upload.done();
} catch (err) { } catch (err) {
throw new Error(`Failed to upload file: ${(err as Error).message}`); throw new Error(`Failed to upload file: ${(err as Error).message}`);
} }
@@ -1,7 +1,7 @@
import { Readable } from 'stream'; import { Readable } from 'stream';
export interface StorageDriver { export interface StorageDriver {
upload(filePath: string, file: Buffer): Promise<void>; upload(filePath: string, file: Buffer | Readable): Promise<void>;
uploadStream(filePath: string, file: Readable, options?: { recreateClient?: boolean }): Promise<void>; uploadStream(filePath: string, file: Readable, options?: { recreateClient?: boolean }): Promise<void>;
@@ -8,9 +8,9 @@ export class StorageService {
private readonly logger = new Logger(StorageService.name); private readonly logger = new Logger(StorageService.name);
constructor( constructor(
@Inject(STORAGE_DRIVER_TOKEN) private storageDriver: StorageDriver, @Inject(STORAGE_DRIVER_TOKEN) private storageDriver: StorageDriver,
) {} ) { }
async upload(filePath: string, fileContent: Buffer | any) { async upload(filePath: string, fileContent: Buffer | Readable) {
await this.storageDriver.upload(filePath, fileContent); await this.storageDriver.upload(filePath, fileContent);
this.logger.debug(`File uploaded successfully. Path: ${filePath}`); this.logger.debug(`File uploaded successfully. Path: ${filePath}`);
} }
+5 -5
View File
@@ -5,9 +5,9 @@ import {
NestFastifyApplication, NestFastifyApplication,
} from '@nestjs/platform-fastify'; } from '@nestjs/platform-fastify';
import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common'; import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common';
import { Logger as PinoLogger } from 'nestjs-pino';
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor'; import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter'; import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
import { InternalLogFilter } from './common/logger/internal-log-filter';
import fastifyMultipart from '@fastify/multipart'; import fastifyMultipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie'; import fastifyCookie from '@fastify/cookie';
@@ -24,10 +24,12 @@ async function bootstrap() {
}), }),
{ {
rawBody: true, rawBody: true,
logger: new InternalLogFilter(), bufferLogs: true,
}, },
); );
app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', { app.setGlobalPrefix('api', {
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug'], exclude: ['robots.txt', 'share/:shareId/p/:pageSlug'],
}); });
@@ -99,9 +101,7 @@ async function bootstrap() {
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
await app.listen(port, '0.0.0.0', () => { await app.listen(port, '0.0.0.0', () => {
logger.log( logger.log(`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`);
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
);
}); });
} }
+31 -29
View File
@@ -16,11 +16,11 @@
"server:start": "nx run server:start:prod", "server:start": "nx run server:start:prod",
"email:dev": "nx run server:email:dev", "email:dev": "nx run server:email:dev",
"dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\"", "dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\"",
"clean": "rm -rf apps/*/dist packages/*/dist apps/*/node_modules/.vite" "clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite"
}, },
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "^7.1.0", "@braintree/sanitize-url": "^7.1.0",
"@casl/ability": "^6.7.5", "@casl/ability": "6.8.0",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3", "@floating-ui/dom": "^1.7.3",
"@hocuspocus/extension-redis": "3.4.3", "@hocuspocus/extension-redis": "3.4.3",
@@ -30,33 +30,33 @@
"@joplin/turndown": "^4.0.74", "@joplin/turndown": "^4.0.74",
"@joplin/turndown-plugin-gfm": "^1.0.56", "@joplin/turndown-plugin-gfm": "^1.0.56",
"@sindresorhus/slugify": "1.1.0", "@sindresorhus/slugify": "1.1.0",
"@tiptap/core": "3.15.3", "@tiptap/core": "3.17.1",
"@tiptap/extension-code-block": "3.15.3", "@tiptap/extension-code-block": "3.17.1",
"@tiptap/extension-collaboration": "3.15.3", "@tiptap/extension-collaboration": "3.17.1",
"@tiptap/extension-collaboration-caret": "3.15.3", "@tiptap/extension-collaboration-caret": "3.17.1",
"@tiptap/extension-color": "3.15.3", "@tiptap/extension-color": "3.17.1",
"@tiptap/extension-document": "3.15.3", "@tiptap/extension-document": "3.17.1",
"@tiptap/extension-heading": "3.15.3", "@tiptap/extension-heading": "3.17.1",
"@tiptap/extension-highlight": "3.15.3", "@tiptap/extension-highlight": "3.17.1",
"@tiptap/extension-history": "3.15.3", "@tiptap/extension-history": "3.17.1",
"@tiptap/extension-image": "3.15.3", "@tiptap/extension-image": "3.17.1",
"@tiptap/extension-link": "3.15.3", "@tiptap/extension-link": "3.17.1",
"@tiptap/extension-list": "3.15.3", "@tiptap/extension-list": "3.17.1",
"@tiptap/extension-placeholder": "3.15.3", "@tiptap/extension-placeholder": "3.17.1",
"@tiptap/extension-subscript": "3.15.3", "@tiptap/extension-subscript": "3.17.1",
"@tiptap/extension-superscript": "3.15.3", "@tiptap/extension-superscript": "3.17.1",
"@tiptap/extension-table": "3.15.3", "@tiptap/extension-table": "3.17.1",
"@tiptap/extension-text": "3.15.3", "@tiptap/extension-text": "3.17.1",
"@tiptap/extension-text-align": "3.15.3", "@tiptap/extension-text-align": "3.17.1",
"@tiptap/extension-text-style": "3.15.3", "@tiptap/extension-text-style": "3.17.1",
"@tiptap/extension-typography": "3.15.3", "@tiptap/extension-typography": "3.17.1",
"@tiptap/extension-unique-id": "^3.15.3", "@tiptap/extension-unique-id": "^3.17.1",
"@tiptap/extension-youtube": "3.15.3", "@tiptap/extension-youtube": "3.17.1",
"@tiptap/html": "3.15.3", "@tiptap/html": "3.17.1",
"@tiptap/pm": "3.15.3", "@tiptap/pm": "3.17.1",
"@tiptap/react": "3.15.3", "@tiptap/react": "3.17.1",
"@tiptap/starter-kit": "3.15.3", "@tiptap/starter-kit": "3.17.1",
"@tiptap/suggestion": "3.15.3", "@tiptap/suggestion": "3.17.1",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"bytes": "^3.1.2", "bytes": "^3.1.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
@@ -64,6 +64,7 @@
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"fractional-indexing-jittered": "^1.0.0", "fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"image-dimensions": "^2.5.0",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"linkifyjs": "^4.3.2", "linkifyjs": "^4.3.2",
@@ -78,6 +79,7 @@
"devDependencies": { "devDependencies": {
"@nx/js": "20.4.5", "@nx/js": "20.4.5",
"@types/bytes": "^3.1.5", "@types/bytes": "^3.1.5",
"@types/turndown": "^5.0.6",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"nx": "20.4.5", "nx": "20.4.5",
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+1
View File
@@ -23,3 +23,4 @@ export * from "./lib/subpages";
export * from "./lib/highlight"; export * from "./lib/highlight";
export * from "./lib/heading/heading"; export * from "./lib/heading/heading";
export * from "./lib/unique-id"; export * from "./lib/unique-id";
export * from "./lib/shared-storage";
@@ -1,126 +1,125 @@
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { Node } from "@tiptap/pm/model";
import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { MediaUploadOptions, UploadFn } from "../media-utils";
import {
insertTrailingNode,
MediaUploadOptions,
UploadFn,
} from "../media-utils";
import { IAttachment } from "../types"; import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Command } from "@tiptap/core";
const uploadKey = new PluginKey("attachment-upload"); const findAttachmentNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
export const AttachmentUploadPlugin = ({ doc.descendants((node, pos) => {
placeholderClass, if (result) return false;
}: { if (
placeholderClass: string; node.type.name === "attachment" &&
}) => node.attrs.placeholder?.id === placeholderId
new Plugin({ ) {
key: uploadKey, result = { node, pos };
state: { return false;
init() { }
return DecorationSet.empty; return true;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
//@-ts-expect-error - not yet sure what the type I need here
const action = tr.getMeta(this);
if (action?.add) {
const { id, pos, fileName } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", placeholderClass);
const uploadingText = document.createElement("span");
uploadingText.setAttribute("class", "uploading-text");
uploadingText.textContent = `Uploading ${fileName}`;
placeholder.appendChild(uploadingText);
const realPos = pos + 1;
const deco = Decoration.widget(realPos, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action?.remove) {
set = set.remove(
set.find(
undefined,
undefined,
(spec) => spec.id == action.remove.id,
),
);
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}); });
function findPlaceholder(state: EditorState, id: {}) { return result;
const decos = uploadKey.getState(state) as DecorationSet; };
const found = decos.find(undefined, undefined, (spec) => spec.id == id); const handleAttachmentUpload =
return found.length ? found[0]?.from : null;
}
export const handleAttachmentUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn => ({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, view, pos, pageId, allowMedia) => { async (file, editor, pos, pageId, allowMedia) => {
const validated = validateFn?.(file, allowMedia); const validated = validateFn?.(file, allowMedia);
// @ts-ignore // @ts-ignore
if (!validated) return; if (!validated) return;
// A fresh object to act as the ID for this upload
const id = {};
// Replace the selection with a placeholder const placeholderId = generateNodeId();
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
tr.setMeta(uploadKey, { let placeholderInserted = false;
add: {
id,
pos,
fileName: file.name,
},
});
insertTrailingNode(tr, pos, view); const insertPlaceholder = (): Command => {
view.dispatch(tr); return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.attachment?.create({
placeholder: {
id: placeholderId,
},
name: file.name,
size: file.size,
});
await onUpload(file, pageId).then( if (!initialPlaceholderNode) return false;
(attachment: IAttachment) => {
const { schema } = view.state;
const pos = findPlaceholder(view.state, id); const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (pos == null) return; if (isEmptyTextBlock) {
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
if (!attachment) return; return true;
};
};
const replacePlaceholderWithAttachment = (
attachment: IAttachment,
): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
const node = schema.nodes.attachment?.create({ // If the placeholder is not found or attachment is missing, abort the process
if (currentPos === null || !attachment) return false;
// Update the placeholder node with the actual attachment data
tr.setNodeMarkup(currentPos, undefined, {
url: `/api/files/${attachment.id}/${attachment.fileName}`, url: `/api/files/${attachment.id}/${attachment.fileName}`,
name: attachment.fileName, name: attachment.fileName,
mime: attachment.mimeType, mime: attachment.mimeType,
size: attachment.fileSize, size: attachment.fileSize,
attachmentId: attachment.id, attachmentId: attachment.id,
}); });
if (!node) return;
const transaction = view.state.tr return true;
.replaceWith(pos, pos, node) };
.setMeta(uploadKey, { remove: { id } }); };
view.dispatch(transaction); const removePlaceholder = (): Command => {
}, return ({ tr }) => {
() => { const { pos: currentPos = null } =
// Deletes the placeholder on error findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
const transaction = view.state.tr
.delete(pos, pos) if (currentPos === null) return false;
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction); tr.delete(currentPos, currentPos + 2);
},
); return true;
};
};
// Only show the placeholder if the upload takes more than 250ms
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(replacePlaceholderWithAttachment(attachment));
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithAttachment(attachment))
.run();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
}
}; };
export { handleAttachmentUpload };
@@ -1,6 +1,5 @@
import { Node, mergeAttributes } from "@tiptap/core"; import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { AttachmentUploadPlugin } from "./attachment-upload";
export interface AttachmentOptions { export interface AttachmentOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
@@ -13,6 +12,7 @@ export interface AttachmentAttributes {
mime?: string; // e.g. application/zip mime?: string; // e.g. application/zip
size?: number; size?: number;
attachmentId?: string; attachmentId?: string;
placeholder?: string;
} }
declare module "@tiptap/core" { declare module "@tiptap/core" {
@@ -75,6 +75,10 @@ export const Attachment = Node.create<AttachmentOptions>({
"data-attachment-id": attributes.attachmentId, "data-attachment-id": attributes.attachmentId,
}), }),
}, },
placeholder: {
default: null,
rendered: false,
},
}; };
}, },
@@ -125,12 +129,4 @@ export const Attachment = Node.create<AttachmentOptions>({
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
addProseMirrorPlugins() {
return [
AttachmentUploadPlugin({
placeholderClass: "attachment-placeholder",
}),
];
},
}); });
@@ -99,7 +99,7 @@ export const embedProviders: IEmbedProvider[] = [
id: "gsheets", id: "gsheets",
name: "Google Sheets", name: "Google Sheets",
regex: regex:
/^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/e\/([a-zA-Z0-9_-]+)\/.*$/, /^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/([a-zA-Z0-9_-]+)\/.*$/,
getEmbedUrl: (match, url: string) => { getEmbedUrl: (match, url: string) => {
return url; return url;
}, },
+123 -105
View File
@@ -1,127 +1,145 @@
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { imageDimensionsFromStream } from "image-dimensions";
import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { MediaUploadOptions, UploadFn } from "../media-utils";
import { insertTrailingNode, MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types"; import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
import { Command } from "@tiptap/core";
const uploadKey = new PluginKey("image-upload"); const findImageNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
export const ImageUploadPlugin = ({ doc.descendants((node, pos) => {
placeholderClass, if (result) return false;
}: { if (
placeholderClass: string; node.type.name === "image" &&
}) => node.attrs.placeholder?.id === placeholderId
new Plugin({ ) {
key: uploadKey, result = { node, pos };
state: { return false;
init() { }
return DecorationSet.empty; return true;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
//@-ts-expect-error - not yet sure what the type I need here
const action = tr.getMeta(this);
if (action?.add) {
const { id, pos, src } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute("class", placeholderClass);
image.src = src;
placeholder.appendChild(image);
const deco = Decoration.widget(pos + 1, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action?.remove) {
set = set.remove(
set.find(
undefined,
undefined,
(spec) => spec.id == action.remove.id,
),
);
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}); });
function findPlaceholder(state: EditorState, id: {}) { return result;
const decos = uploadKey.getState(state) as DecorationSet; };
const found = decos.find(undefined, undefined, (spec) => spec.id == id); const handleImageUpload =
return found.length ? found[0]?.from : null;
}
export const handleImageUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn => ({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, view, pos, pageId) => { async (file, editor, pos, pageId) => {
// check if the file is an image // check if the file is an image
const validated = validateFn?.(file); const validated = validateFn?.(file);
// @ts-ignore // @ts-ignore
if (!validated) return; if (!validated) return;
// A fresh object to act as the ID for this upload
const id = {};
const reader = new FileReader(); const objectUrl = URL.createObjectURL(file);
reader.readAsDataURL(file); const imageDimensions = await imageDimensionsFromStream(file.stream());
reader.onload = () => { const placeholderId = generateNodeId();
const tr = view.state.tr; const aspectRatio = imageDimensions
// Replace the selection with a placeholder ? imageDimensions.width / imageDimensions.height
if (!tr.selection.empty) tr.deleteSelection(); : undefined;
tr.setMeta(uploadKey, { let placeholderInserted = false;
add: {
id,
pos,
src: reader.result,
},
});
insertTrailingNode(tr, pos, view); editor.storage.shared.imagePreviews =
view.dispatch(tr); editor.storage.shared.imagePreviews || {};
editor.storage.shared.imagePreviews[placeholderId] = objectUrl;
const insertPlaceholder = (): Command => {
return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.image?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
aspectRatio,
});
if (!initialPlaceholderNode) return false;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) {
// Replace e.g. empty paragraph with the image
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
}; };
const replacePlaceholderWithImage = (attachment: IAttachment): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
await onUpload(file, pageId).then( // If the placeholder is not found or attachment is missing, abort the process
(attachment: IAttachment) => { if (currentPos === null || !attachment) return false;
const { schema } = view.state;
const pos = findPlaceholder(view.state, id); // Update the placeholder node with the actual image data
tr.setNodeMarkup(currentPos, undefined, {
// If the content around the placeholder has been deleted, drop
// the image
if (pos == null) return;
// Otherwise, insert it at the placeholder's position, and remove
// the placeholder
if (!attachment) return;
const node = schema.nodes.image?.create({
src: `/api/files/${attachment.id}/${attachment.fileName}`, src: `/api/files/${attachment.id}/${attachment.fileName}`,
attachmentId: attachment.id, attachmentId: attachment.id,
title: attachment.fileName,
size: attachment.fileSize, size: attachment.fileSize,
aspectRatio,
}); });
if (!node) return;
const transaction = view.state.tr return true;
.replaceWith(pos, pos, node) };
.setMeta(uploadKey, { remove: { id } }); };
view.dispatch(transaction); const removePlaceholder = (): Command => {
}, return ({ tr }) => {
() => { const { pos: currentPos = null } =
// Deletes the image placeholder on error findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
const transaction = view.state.tr
.delete(pos, pos) if (currentPos === null) return false;
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction); // Remove the placeholder node
}, tr.delete(currentPos, currentPos + 2);
);
return true;
};
};
// Only show the placeholder if the upload takes more than 250ms
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
const disposePreviewFile = () => {
URL.revokeObjectURL(objectUrl);
if (editor.storage.shared.imagePreviews) {
delete editor.storage.shared.imagePreviews[placeholderId];
}
};
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithImage(attachment));
disposePreviewFile();
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithImage(attachment))
.run();
disposePreviewFile();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
disposePreviewFile();
}
}; };
export { handleImageUpload };
+16 -10
View File
@@ -1,7 +1,6 @@
import Image from "@tiptap/extension-image"; import Image from "@tiptap/extension-image";
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image"; import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { ImageUploadPlugin } from "./image-upload";
import { mergeAttributes, Range } from "@tiptap/core"; import { mergeAttributes, Range } from "@tiptap/core";
export interface ImageOptions extends DefaultImageOptions { export interface ImageOptions extends DefaultImageOptions {
@@ -10,11 +9,15 @@ export interface ImageOptions extends DefaultImageOptions {
export interface ImageAttributes { export interface ImageAttributes {
src?: string; src?: string;
alt?: string; alt?: string;
title?: string;
align?: string; align?: string;
attachmentId?: string; attachmentId?: string;
size?: number; size?: number;
width?: number; width?: number;
aspectRatio?: number;
placeholder?: {
id: string;
name: string;
};
} }
declare module "@tiptap/core" { declare module "@tiptap/core" {
@@ -90,6 +93,17 @@ export const TiptapImage = Image.extend<ImageOptions>({
"data-size": attributes.size, "data-size": attributes.size,
}), }),
}, },
aspectRatio: {
default: null,
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
renderHTML: (attributes: ImageAttributes) => ({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
placeholder: {
default: null,
rendered: false,
},
}; };
}, },
@@ -140,12 +154,4 @@ export const TiptapImage = Image.extend<ImageOptions>({
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
addProseMirrorPlugins() {
return [
ImageUploadPlugin({
placeholderClass: "image-upload",
}),
];
},
}); });
@@ -1 +1,2 @@
export * from "./utils/marked.utils"; export * from "./utils/marked.utils";
export * from "./utils/turndown.utils";
@@ -0,0 +1,29 @@
/**
* Flexible `basename` implementation for node and the browser
* @see https://stackoverflow.com/a/59907288/2228771
*/
export function getBasename(path: string) {
// make sure the basename is not empty, if string ends with separator
let end = path.length - 1;
while (path[end] === '/' || path[end] === '\\') {
--end;
}
// support mixing of Win + Unix path separators
const i1 = path.lastIndexOf('/', end);
const i2 = path.lastIndexOf('\\', end);
let start: number;
if (i1 === -1) {
if (i2 === -1) {
// no separator in the whole thing
return path;
}
start = i2;
} else if (i2 === -1) {
start = i1;
} else {
start = Math.max(i1, i2);
}
return path.substring(start + 1, end + 1);
}
@@ -0,0 +1,12 @@
// Map @joplin/turndown types to @types/turndown
declare module "@joplin/turndown" {
import TurndownService from "turndown";
export = TurndownService;
}
declare module "@joplin/turndown-plugin-gfm" {
import TurndownService from "turndown";
export const tables: TurndownService.Plugin;
export const strikethrough: TurndownService.Plugin;
export const highlightedCodeBlock: TurndownService.Plugin;
}
@@ -1,22 +1,22 @@
import * as TurndownService from '@joplin/turndown'; import * as _TurndownService from '@joplin/turndown';
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm'; import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
import * as path from 'path'; import { getBasename } from './basename';
export function turndown(html: string): string { // CJS/ESM interop: .default exists in Vite, not in NestJS
const TurndownService = (_TurndownService as any).default || _TurndownService;
export function htmlToMarkdown(html: string): string {
const turndownService = new TurndownService({ const turndownService = new TurndownService({
headingStyle: 'atx', headingStyle: 'atx',
codeBlockStyle: 'fenced', codeBlockStyle: 'fenced',
hr: '---', hr: '---',
bulletListMarker: '-', bulletListMarker: '-',
}); });
const tables = TurndownPluginGfm.tables;
const strikethrough = TurndownPluginGfm.strikethrough;
const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock;
turndownService.use([ turndownService.use([
tables, TurndownPluginGfm.tables,
strikethrough, TurndownPluginGfm.strikethrough,
highlightedCodeBlock, TurndownPluginGfm.highlightedCodeBlock,
taskList, taskList,
callout, callout,
preserveDetail, preserveDetail,
@@ -29,34 +29,33 @@ export function turndown(html: string): string {
return turndownService.turndown(html).replaceAll('<br>', ' '); return turndownService.turndown(html).replaceAll('<br>', ' ');
} }
function listParagraph(turndownService: TurndownService) { function listParagraph(turndownService: _TurndownService) {
turndownService.addRule('paragraph', { turndownService.addRule('paragraph', {
filter: ['p'], filter: ['p'],
replacement: (content: any, node: HTMLInputElement) => { replacement: (content: string, node: HTMLInputElement) => {
if (node.parentElement?.nodeName === 'LI') { if (node.parentElement?.nodeName === 'LI') {
return content; return content;
} }
return `\n\n${content}\n\n`; return `\n\n${content}\n\n`;
}, },
}); });
} }
function callout(turndownService: TurndownService) { function callout(turndownService: _TurndownService) {
turndownService.addRule('callout', { turndownService.addRule('callout', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
return ( return (
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout' node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
); );
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (content: string, node: HTMLInputElement) {
const calloutType = node.getAttribute('data-callout-type'); const calloutType = node.getAttribute('data-callout-type');
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`; return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
}, },
}); });
} }
function taskList(turndownService: TurndownService) { function taskList(turndownService: _TurndownService) {
turndownService.addRule('taskListItem', { turndownService.addRule('taskListItem', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
return ( return (
@@ -64,7 +63,7 @@ function taskList(turndownService: TurndownService) {
node.parentNode.nodeName === 'UL' node.parentNode.nodeName === 'UL'
); );
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (content: string, node: HTMLInputElement) {
const checkbox = node.querySelector( const checkbox = node.querySelector(
'input[type="checkbox"]', 'input[type="checkbox"]',
) as HTMLInputElement; ) as HTMLInputElement;
@@ -79,17 +78,21 @@ function taskList(turndownService: TurndownService) {
// Create the checkbox prefix // Create the checkbox prefix
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `; const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
return prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : ''); return (
prefix +
content +
(node.nextSibling && !/\n$/.test(content) ? '\n' : '')
);
}, },
}); });
} }
function preserveDetail(turndownService: TurndownService) { function preserveDetail(turndownService: _TurndownService) {
turndownService.addRule('preserveDetail', { turndownService.addRule('preserveDetail', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
return node.nodeName === 'DETAILS'; return node.nodeName === 'DETAILS';
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (_content: string, node: HTMLInputElement) {
const summary = node.querySelector(':scope > summary'); const summary = node.querySelector(':scope > summary');
let detailSummary = ''; let detailSummary = '';
@@ -111,7 +114,7 @@ function preserveDetail(turndownService: TurndownService) {
}); });
} }
function mathInline(turndownService: TurndownService) { function mathInline(turndownService: _TurndownService) {
turndownService.addRule('mathInline', { turndownService.addRule('mathInline', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
return ( return (
@@ -119,13 +122,13 @@ function mathInline(turndownService: TurndownService) {
node.getAttribute('data-type') === 'mathInline' node.getAttribute('data-type') === 'mathInline'
); );
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (content: string) {
return `$${content}$`; return `$${content}$`;
}, },
}); });
} }
function mathBlock(turndownService: TurndownService) { function mathBlock(turndownService: _TurndownService) {
turndownService.addRule('mathBlock', { turndownService.addRule('mathBlock', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
return ( return (
@@ -133,32 +136,32 @@ function mathBlock(turndownService: TurndownService) {
node.getAttribute('data-type') === 'mathBlock' node.getAttribute('data-type') === 'mathBlock'
); );
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (content: string) {
return `\n$$\n${content}\n$$\n`; return `\n$$\n${content}\n$$\n`;
}, },
}); });
} }
function iframeEmbed(turndownService: TurndownService) { function iframeEmbed(turndownService: _TurndownService) {
turndownService.addRule('iframeEmbed', { turndownService.addRule('iframeEmbed', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
return node.nodeName === 'IFRAME'; return node.nodeName === 'IFRAME';
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (_content: string, node: HTMLInputElement) {
const src = node.getAttribute('src'); const src = node.getAttribute('src');
return '[' + src + '](' + src + ')'; return '[' + src + '](' + src + ')';
}, },
}); });
} }
function video(turndownService: TurndownService) { function video(turndownService: _TurndownService) {
turndownService.addRule('video', { turndownService.addRule('video', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
return node.tagName === 'VIDEO'; return node.tagName === 'VIDEO';
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (_content: string, node: HTMLInputElement) {
const src = node.getAttribute('src') || ''; const src = node.getAttribute('src') || '';
const name = path.basename(src); const name = getBasename(src) || src;
return '[' + name + '](' + src + ')'; return '[' + name + '](' + src + ')';
}, },
}); });
+2 -16
View File
@@ -1,9 +1,8 @@
import type { EditorView } from "@tiptap/pm/view"; import { Editor } from "@tiptap/core";
import { Transaction } from "@tiptap/pm/state";
export type UploadFn = ( export type UploadFn = (
file: File, file: File,
view: EditorView, editor: Editor,
pos: number, pos: number,
pageId: string, pageId: string,
// only applicable to file attachments // only applicable to file attachments
@@ -14,16 +13,3 @@ export interface MediaUploadOptions {
validateFn?: (file: File, allowMedia?: boolean) => void; validateFn?: (file: File, allowMedia?: boolean) => void;
onUpload: (file: File, pageId: string) => Promise<any>; onUpload: (file: File, pageId: string) => Promise<any>;
} }
export function insertTrailingNode(
tr: Transaction,
pos: number,
view: EditorView,
) {
// create trailing node after decoration
// if decoration is at the last node
const currentDocSize = view.state.doc.content.size;
if (pos + 1 === currentDocSize) {
tr.insert(currentDocSize, view.state.schema.nodes.paragraph.create());
}
}
@@ -0,0 +1 @@
export { SharedStorage } from "./shared-storage";
@@ -0,0 +1,17 @@
import { Extension } from "@tiptap/core";
declare module "@tiptap/core" {
interface Storage {
shared: Record<string, any>;
}
}
const SharedStorage = Extension.create({
name: "shared",
addStorage() {
return {};
},
});
export { SharedStorage };
+144 -107
View File
@@ -1,132 +1,169 @@
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { MediaUploadOptions, UploadFn } from "../media-utils";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import {
insertTrailingNode,
MediaUploadOptions,
UploadFn,
} from "../media-utils";
import { IAttachment } from "../types"; import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
import { Command } from "@tiptap/core";
const uploadKey = new PluginKey("video-upload"); const findVideoNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
export const VideoUploadPlugin = ({ doc.descendants((node, pos) => {
placeholderClass, if (result) return false;
}: {
placeholderClass: string;
}) =>
new Plugin({
key: uploadKey,
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
//@-ts-expect-error - not yet sure what the type I need here
const action = tr.getMeta(this);
if (action?.add) {
const { id, pos, src } = action.add;
const placeholder = document.createElement("div"); if (
placeholder.setAttribute("class", "video-placeholder"); node.type.name === "video" &&
const video = document.createElement("video"); node.attrs.placeholder?.id === placeholderId
video.setAttribute("class", placeholderClass); ) {
video.src = src; result = { node, pos };
placeholder.appendChild(video); return false;
const deco = Decoration.widget(pos + 1, placeholder, { }
id,
}); return true;
set = set.add(tr.doc, [deco]);
} else if (action?.remove) {
set = set.remove(
set.find(
undefined,
undefined,
(spec) => spec.id == action.remove.id,
),
);
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}); });
function findPlaceholder(state: EditorState, id: {}) { return result;
const decos = uploadKey.getState(state) as DecorationSet; };
const found = decos.find(undefined, undefined, (spec) => spec.id == id); const getVideoDimensions = (
return found.length ? found[0]?.from : null; url: string,
} ): Promise<
{ width: number; height: number; aspectRatio: number } | undefined
> => {
return new Promise<
{ width: number; height: number; aspectRatio: number } | undefined
>((resolve) => {
const video = document.createElement("video");
export const handleVideoUpload = video.preload = "metadata";
video.onloadedmetadata = () => {
const width = video.videoWidth;
const height = video.videoHeight;
const aspectRatio = height > 0 ? width / height : 1;
resolve({ width, height, aspectRatio });
};
video.onerror = () => {
resolve(undefined);
};
video.src = url;
});
};
const handleVideoUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn => ({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, view, pos, pageId) => { async (file, editor, pos, pageId) => {
// check if the file is an image // check if the file is valid
const validated = validateFn?.(file); const validated = validateFn?.(file);
// @ts-ignore // @ts-ignore
if (!validated) return; if (!validated) return;
// A fresh object to act as the ID for this upload
const id = {};
// Replace the selection with a placeholder const objectUrl = URL.createObjectURL(file);
const videoDimensions = await getVideoDimensions(objectUrl);
const placeholderId = generateNodeId();
const aspectRatio = videoDimensions.aspectRatio;
const reader = new FileReader(); let placeholderInserted = false;
reader.readAsDataURL(file);
reader.onload = () => {
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
tr.setMeta(uploadKey, { editor.storage.shared.videoPreviews =
add: { editor.storage.shared.videoPreviews || {};
id, editor.storage.shared.videoPreviews[placeholderId] = objectUrl;
pos,
src: reader.result,
},
});
insertTrailingNode(tr, pos, view); const insertPlaceholder = (): Command => {
view.dispatch(tr); return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.video?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
aspectRatio,
});
if (!initialPlaceholderNode) return false;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) {
// Replace e.g. empty paragraph with the video
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
}; };
const replacePlaceholderWithVideo = (attachment: IAttachment): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {};
await onUpload(file, pageId).then( // If the placeholder is not found or attachment is missing, abort the process
(attachment: IAttachment) => { if (currentPos === null || !attachment) return;
const { schema } = view.state;
const pos = findPlaceholder(view.state, id); // Update the placeholder node with the actual video data
tr.setNodeMarkup(currentPos, undefined, {
// If the content around the placeholder has been deleted, drop
// the image
if (pos == null) return;
// Otherwise, insert it at the placeholder's position, and remove
// the placeholder
if (!attachment) return;
const node = schema.nodes.video?.create({
src: `/api/files/${attachment.id}/${attachment.fileName}`, src: `/api/files/${attachment.id}/${attachment.fileName}`,
attachmentId: attachment.id, attachmentId: attachment.id,
title: attachment.fileName, title: attachment.fileName,
size: attachment.fileSize, size: attachment.fileSize,
aspectRatio,
}); });
if (!node) return;
const transaction = view.state.tr return true;
.replaceWith(pos, pos, node) };
.setMeta(uploadKey, { remove: { id } }); };
view.dispatch(transaction); const removePlaceholder = (): Command => {
}, return ({ tr }) => {
() => { const { pos: currentPos = null } =
// Deletes the image placeholder on error findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {};
const transaction = view.state.tr
.delete(pos, pos) if (currentPos === null) return false;
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction); tr.delete(currentPos, currentPos + 2);
},
); return true;
};
};
// Only show the placeholder if the upload takes more than 250ms
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
const disposePreviewFile = () => {
URL.revokeObjectURL(objectUrl);
if (editor.storage.shared.videoPreviews) {
delete editor.storage.shared.videoPreviews[placeholderId];
}
};
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithVideo(attachment));
disposePreviewFile();
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithVideo(attachment))
.run();
disposePreviewFile();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
disposePreviewFile();
}
}; };
export { handleVideoUpload };
+17 -11
View File
@@ -1,6 +1,5 @@
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { VideoUploadPlugin } from "./video-upload"; import { Range, Node } from "@tiptap/core";
import { mergeAttributes, Range, Node, nodeInputRule } from "@tiptap/core";
export interface VideoOptions { export interface VideoOptions {
view: any; view: any;
@@ -8,11 +7,15 @@ export interface VideoOptions {
} }
export interface VideoAttributes { export interface VideoAttributes {
src?: string; src?: string;
title?: string;
align?: string; align?: string;
attachmentId?: string; attachmentId?: string;
size?: number; size?: number;
width?: number; width?: number;
aspectRatio?: number;
placeholder?: {
id: string;
name: string;
};
} }
declare module "@tiptap/core" { declare module "@tiptap/core" {
@@ -81,6 +84,17 @@ export const TiptapVideo = Node.create<VideoOptions>({
"data-align": attributes.align, "data-align": attributes.align,
}), }),
}, },
aspectRatio: {
default: null,
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
renderHTML: (attributes: VideoAttributes) => ({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
placeholder: {
default: null,
rendered: false,
},
}; };
}, },
@@ -131,12 +145,4 @@ export const TiptapVideo = Node.create<VideoOptions>({
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
addProseMirrorPlugins() {
return [
VideoUploadPlugin({
placeholderClass: "video-upload",
}),
];
},
}); });
+513 -429
View File
File diff suppressed because it is too large Load Diff