Compare commits

..

7 Commits

Author SHA1 Message Date
Philipinho 8a64c43c71 perm share 2026-01-02 01:39:11 +00:00
Philipinho 8eb698648e WIP 5 2025-12-31 10:16:54 +00:00
Philipinho 0c3901abf5 WIP 4 2025-12-29 22:13:58 +00:00
Philipinho c2e722ee5c WIP 3 2025-12-24 00:27:25 +00:00
Philipinho f65726ae26 Fix permission - WIP 2025-12-23 23:05:04 +00:00
Philipinho 68a838606a WIP 2025-12-23 22:41:29 +00:00
Philipinho b0ceae39ba Add page_hierarchy table 2025-12-23 16:05:48 +00:00
187 changed files with 6005 additions and 5843 deletions
+2 -3
View File
@@ -1,6 +1,5 @@
node_modules node_modules
.git .git
.gitignore
dist dist
/data data
.env*
.nx
+1 -7
View File
@@ -46,10 +46,4 @@ DRAWIO_URL=
DISABLE_TELEMETRY=false 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
+3 -3
View File
@@ -1,14 +1,13 @@
FROM node:22-slim AS base FROM node:22-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost" LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@10.4.0
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN npm install -g pnpm@10.4.0
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
RUN pnpm build RUN pnpm build
@@ -32,11 +31,12 @@ COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-e
# Copy root package files # Copy root package files
COPY --from=builder /app/package.json /app/package.json COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/ COPY --from=builder /app/pnpm*.yaml /app/
COPY --from=builder /app/.npmrc /app/.npmrc
# Copy patches # Copy patches
COPY --from=builder /app/patches /app/patches COPY --from=builder /app/patches /app/patches
RUN npm install -g pnpm@10.4.0
RUN chown -R node:node /app RUN chown -R node:node /app
USER node USER node
+23 -20
View File
@@ -10,49 +10,52 @@
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"" "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.2",
"@casl/react": "^4.0.0", "@casl/react": "^4.0.0",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-c158187", "@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.3.12", "@mantine/core": "^8.1.3",
"@mantine/dates": "^8.3.12", "@mantine/dates": "^8.3.2",
"@mantine/form": "^8.3.12", "@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.3.12", "@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.3.12", "@mantine/modals": "^8.1.3",
"@mantine/notifications": "^8.3.12", "@mantine/notifications": "^8.1.3",
"@mantine/spotlight": "^8.3.12", "@mantine/spotlight": "^8.1.3",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.90.17", "@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0", "highlightjs-sap-abap": "^0.3.0",
"i18next": "^23.16.8", "i18next": "^23.14.0",
"i18next-http-backend": "^2.7.3", "i18next-http-backend": "^2.6.1",
"jotai": "^2.16.2", "jotai": "^2.12.5",
"jotai-optics": "^0.4.0", "jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"katex": "0.16.27", "katex": "0.16.22",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.12.2", "mermaid": "^11.11.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"posthog-js": "^1.255.1", "posthog-js": "^1.255.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.17", "react-clear-modal": "^2.0.15",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-drawio": "^1.0.7", "react-drawio": "^1.0.1",
"react-error-boundary": "^4.1.2", "react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1", "react-i18next": "^15.0.1",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.0.1",
"semver": "^7.7.3", "semver": "^7.7.2",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18", "tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
@@ -29,7 +29,6 @@
"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": "Als Markdown kopieren",
"Copy link": "Link kopieren", "Copy link": "Link kopieren",
"Create": "Erstellen", "Create": "Erstellen",
"Create group": "Gruppe erstellen", "Create group": "Gruppe erstellen",
@@ -235,9 +234,7 @@
"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.",
@@ -254,7 +251,6 @@
"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 erfolgreich",
"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}}",
@@ -330,8 +326,6 @@
"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}}": "Lade {{name}} hoch",
"Uploading file": "Datei wird hochgeladen",
"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,7 +29,6 @@
"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",
@@ -254,7 +253,6 @@
"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",
@@ -330,8 +328,6 @@
"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,7 +29,6 @@
"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": "Copiar como Markdown",
"Copy link": "Copiar enlace", "Copy link": "Copiar enlace",
"Create": "Crear", "Create": "Crear",
"Create group": "Crear grupo", "Create group": "Crear grupo",
@@ -254,7 +253,6 @@
"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": "Exportación exitosa",
"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",
@@ -330,8 +328,6 @@
"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}}": "Subiendo {{name}}",
"Uploading file": "Subiendo archivo",
"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,7 +29,6 @@
"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": "Copier comme 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",
@@ -254,7 +253,6 @@
"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": "Exportation réussie",
"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",
@@ -330,8 +328,6 @@
"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}}": "Téléchargement de {{name}}",
"Uploading file": "Téléchargement du fichier",
"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,7 +29,6 @@
"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": "Copia come Markdown",
"Copy link": "Copia link", "Copy link": "Copia link",
"Create": "Crea", "Create": "Crea",
"Create group": "Crea gruppo", "Create group": "Crea gruppo",
@@ -254,7 +253,6 @@
"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": "Esportazione riuscita",
"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}}",
@@ -330,8 +328,6 @@
"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}}": "Caricamento di {{name}}",
"Uploading file": "Caricamento 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.",
+144 -148
View File
@@ -13,23 +13,22 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "このユーザをグループから削除してもよろしいですか? ユーザはこのグループがアクセス権を持つリソースにアクセスできなくなります。", "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "このユーザをグループから削除してもよろしいですか? ユーザはこのグループがアクセス権を持つリソースにアクセスできなくなります。",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "このユーザをスペースから削除してもよろしいですか? ユーザはこのスペースへのアクセス権をすべて失います。", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "このユーザをスペースから削除してもよろしいですか? ユーザはこのスペースへのアクセス権をすべて失います。",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。",
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになます", "Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになることができます",
"Can create and edit pages in space.": "スペース内のページを作成編集できます", "Can create and edit pages in space.": "スペース内のページを作成および編集できます",
"Can edit": "編集可能", "Can edit": "編集可能",
"Can manage workspace": "ワークスペースを管理できます", "Can manage workspace": "ワークスペースを管理できます",
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません", "Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません",
"Can view": "閲覧可能", "Can view": "閲覧可能",
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません", "Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません",
"Cancel": "キャンセル", "Cancel": "キャンセル",
"Change email": "メールアドレスの変更", "Change email": "メールアドレスの変更",
"Change password": "パスワードの変更", "Change password": "パスワードの変更",
"Change photo": "画像の変更", "Change photo": "画像の変更",
"Choose a role": "ロールを選んでください", "Choose a role": "ロールを選んでください",
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください", "Choose your preferred color scheme.": "お好みのカラースキームを選択してください",
"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": "Markdownとしてコピー",
"Copy link": "リンクをコピー", "Copy link": "リンクをコピー",
"Create": "新規作成", "Create": "新規作成",
"Create group": "グループを作成", "Create group": "グループを作成",
@@ -41,24 +40,24 @@
"Date": "日付", "Date": "日付",
"Delete": "削除", "Delete": "削除",
"Delete group": "グループを削除", "Delete group": "グループを削除",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?子ページページ履歴削除されます。この操作は取り消せません。", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?この操作により、子ページおよびページ履歴削除されます。この操作は元に戻せません。",
"Description": "説明", "Description": "説明",
"Details": "詳細", "Details": "詳細",
"e.g ACME": "例: 山田太郎", "e.g ACME": "例: 山田太郎",
"e.g ACME Inc": "例: 株式会社サンプル", "e.g ACME Inc": "例: 株式会社サンプル",
"e.g Developers": "例: エンジニア", "e.g Developers": "例: エンジニア",
"e.g Group for developers": "例: 開発チーム", "e.g Group for developers": "例: エンジニアグループ",
"e.g product": "例: product", "e.g product": "例: product",
"e.g Product Team": "例: プロダクトチーム", "e.g Product Team": "例: 製品チーム",
"e.g Sales": "例: 営業", "e.g Sales": "例: 営業",
"e.g Space for product team": "例: プロダクトチームスペース", "e.g Space for product team": "例: 製品チームスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム用スペース", "e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース",
"Edit": "編集", "Edit": "編集",
"Read": "閲覧", "Read": "読む",
"Edit group": "グループを編集", "Edit group": "グループを編集",
"Email": "メールアドレス", "Email": "メールアドレス",
"Enter a strong password": "強力なパスワードを入力してください", "Enter a strong password": "強力なパスワードを入力してください",
"Enter valid email addresses separated by comma or space max_50": "メールアドレスをカンマまたはスペース区切りで入力(最大50", "Enter valid email addresses separated by comma or space max_50": "有効なメールアドレスをカンマまたはスペース区切って入力してください(最大 50",
"enter valid emails addresses": "有効なメールアドレスを入力してください", "enter valid emails addresses": "有効なメールアドレスを入力してください",
"Enter your current password": "現在のパスワードを入力してください", "Enter your current password": "現在のパスワードを入力してください",
"enter your full name": "氏名を入力してください", "enter your full name": "氏名を入力してください",
@@ -82,18 +81,18 @@
"Group description": "グループ説明", "Group description": "グループ説明",
"Group name": "グループ名", "Group name": "グループ名",
"Groups": "グループ", "Groups": "グループ",
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます", "Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます",
"Home": "ホーム", "Home": "ホーム",
"Import pages": "ページをインポート", "Import pages": "ページをインポート",
"Import pages & space settings": "ページとスペース設定をインポート", "Import pages & space settings": "ページとスペース設定をインポート",
"Importing pages": "ページをインポートしています", "Importing pages": "ページをインポートしています",
"invalid invitation link": "無効な招待リンクす", "invalid invitation link": "招待リンクが間違っています",
"Invitation signup": "招待登録", "Invitation signup": "招待登録",
"Invite by email": "メールアドレスで招待する", "Invite by email": "メールアドレスで招待する",
"Invite members": "メンバーを招待する", "Invite members": "メンバーを招待する",
"Invite new members": "新しいメンバーを招待する", "Invite new members": "新しいメンバーを招待する",
"Invited members who are yet to accept their invitation will appear here.": "招待を承諾していないメンバーここに表示されます", "Invited members who are yet to accept their invitation will appear here.": "招待をまだ承諾していないメンバーここに表示されます",
"Invited members will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセスできます", "Invited members will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセス権が付与されます",
"Join the workspace": "ワークスペースに参加", "Join the workspace": "ワークスペースに参加",
"Language": "言語", "Language": "言語",
"Light": "ライト", "Light": "ライト",
@@ -114,20 +113,20 @@
"New page": "新規ページ", "New page": "新規ページ",
"New password": "新しいパスワード", "New password": "新しいパスワード",
"No group found": "グループが見つかりません", "No group found": "グループが見つかりません",
"No page history saved yet.": "ページ履歴がありません", "No page history saved yet.": "まだページ履歴が保存されていません",
"No pages yet": "ページがありません", "No pages yet": "ページがありません",
"No results found...": "結果が見つかりません", "No results found...": "結果が見つかりませんでした...",
"No user found": "ユーザーが見つかりません", "No user found": "ユーザがいません",
"Overview": "概要", "Overview": "概要",
"Owner": "所有者", "Owner": "所有者",
"page": "ページ", "page": "ページ",
"Page deleted successfully": "ページを削除しました", "Page deleted successfully": "ページが正常に削除されました",
"Page history": "ページ履歴", "Page history": "ページ履歴",
"Page import is in progress. Please do not close this tab.": "ページインポート中です。このタブを閉じないでください", "Page import is in progress. Please do not close this tab.": "ページインポートが進行中です。このタブを閉じないでください",
"Pages": "ページ", "Pages": "ページ",
"pages": "ページ", "pages": "ページ",
"Password": "パスワード", "Password": "パスワード",
"Password changed successfully": "パスワードを変更しました", "Password changed successfully": "パスワードが正常に変更されました",
"Pending": "保留中", "Pending": "保留中",
"Please confirm your action": "アクションを確認してください", "Please confirm your action": "アクションを確認してください",
"Preferences": "設定", "Preferences": "設定",
@@ -144,95 +143,95 @@
"Search for groups": "グループを検索", "Search for groups": "グループを検索",
"Search for users": "ユーザーを検索", "Search for users": "ユーザーを検索",
"Search for users and groups": "ユーザーとグループを検索", "Search for users and groups": "ユーザーとグループを検索",
"Search...": "検索", "Search...": "検索する語句を入力",
"Select language": "言語を選択", "Select language": "言語を選択",
"Select role": "ロールを選択", "Select role": "ロールを選択",
"Select role to assign to all invited members": "招待するメンバーに割り当てるロールを選択", "Select role to assign to all invited members": "招待されたすべてのメンバーに割り当てるロールを選択してください",
"Select theme": "テーマを選択", "Select theme": "テーマを選択",
"Send invitation": "招待を送る", "Send invitation": "招待を送る",
"Invitation sent": "招待送信ました", "Invitation sent": "招待送信されました",
"Settings": "設定", "Settings": "設定",
"Setup workspace": "ワークスペースを設定する", "Setup workspace": "ワークスペースを設定する",
"Sign In": "サインイン", "Sign In": "サインイン",
"Sign Up": "新規登録", "Sign Up": "アカウント登録",
"Slug": "スラッグ(URL識別子)", "Slug": "Slug (URL用文字列)",
"Space": "スペース", "Space": "スペース",
"Space description": "スペース説明", "Space description": "スペース説明",
"Space menu": "スペースメニュー", "Space menu": "スペースメニュー",
"Space name": "スペース名", "Space name": "スペース名",
"Space settings": "スペース設定", "Space settings": "スペース設定",
"Space slug": "スペースのスラッグ(URL識別子)", "Space slug": "スペースのSlug (URL用文字列)",
"Spaces": "スペース", "Spaces": "スペース",
"Spaces you belong to": "所属しているスペース", "Spaces you belong to": "所属しているスペース",
"No space found": "スペースが見つかりません", "No space found": "スペースが見つかりません",
"Search for spaces": "スペースを検索", "Search for spaces": "スペースを検索",
"Start typing to search...": "入力して検索", "Start typing to search...": "検索を開始するには入力してください...",
"Status": "ステータス", "Status": "ステータス",
"Successfully imported": "インポートしました", "Successfully imported": "インポートに成功しました",
"Successfully restored": "復元しました", "Successfully restored": "正常に復元されました",
"System settings": "システム設定", "System settings": "システム設定",
"Theme": "テーマ", "Theme": "テーマ",
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力してください", "To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力する必要があります。",
"Toggle full page width": "ページ幅を切り替え", "Toggle full page width": "ページ幅を切り替え",
"Unable to import pages. Please try again.": "ページをインポートできませんでした。もう一度お試しください", "Unable to import pages. Please try again.": "ページをインポートできません。もう一度お試しください",
"untitled": "無題", "untitled": "無題",
"Untitled": "無題", "Untitled": "無題",
"Updated successfully": "更新しました", "Updated successfully": "正常に更新されました",
"User": "ユーザー", "User": "ユーザー",
"Workspace": "ワークスペース", "Workspace": "ワークスペース",
"Workspace Name": "ワークスペース名", "Workspace Name": "ワークスペース名",
"Workspace settings": "ワークスペース設定", "Workspace settings": "ワークスペース設定",
"You can change your password here.": "パスワードを変更できます", "You can change your password here.": "パスワードを変更できます",
"Your Email": "メールアドレス", "Your Email": "メールアドレス",
"Your import is complete.": "インポートが完了しました", "Your import is complete.": "インポートが完了しました",
"Your name": "名前", "Your name": "名前",
"Your Name": "名前", "Your Name": "名前",
"Your password": "パスワード", "Your password": "パスワード",
"Your password must be a minimum of 8 characters.": "パスワードは8文字以上にしてください", "Your password must be a minimum of 8 characters.": "パスワードは最低 8 文字必要です。",
"Sidebar toggle": "サイドバー切り替え", "Sidebar toggle": "サイドバー切り替え",
"Comments": "コメント", "Comments": "コメント",
"404 page not found": "404 ページが見つかりません", "404 page not found": "404 ページが見つかりません",
"Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません", "Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません",
"Take me back to homepage": "ホームに戻る", "Take me back to homepage": "ホームに戻る",
"Forgot password": "パスワードを忘れた", "Forgot password": "パスワードを忘れた",
"Forgot your password?": "パスワードを忘れましたか?", "Forgot your password?": "パスワードを忘れましたか?",
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセット用のリンクをメールに送信ました。受信トレイを確認してください", "A password reset link has been sent to your email. Please check your inbox.": "パスワードリセットリンクがあなたのメールアドレスに送信されました。受信を確認してください",
"Send reset link": "リセットリンクを送", "Send reset link": "リセットリンクを送",
"Password reset": "パスワードリセット", "Password reset": "パスワードリセット",
"Your new password": "新しいパスワード", "Your new password": "新しいパスワード",
"Set password": "パスワードを設定", "Set password": "パスワードを設定",
"Write a comment": "コメントを書く", "Write a comment": "コメントを書く",
"Reply...": "返信...", "Reply...": "返信...",
"Error loading comments.": "コメントの読み込みに失敗しました", "Error loading comments.": "コメントの読み込み中にエラーが発生しました",
"No comments yet.": "コメントがありません", "No comments yet.": "コメントがありません",
"Edit comment": "コメントを編集する", "Edit comment": "コメントを編集する",
"Delete comment": "コメントを削除する", "Delete comment": "コメントを削除する",
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?", "Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
"Comment created successfully": "コメント作成ました", "Comment created successfully": "コメント作成されました",
"Error creating comment": "コメントの作成に失敗しました", "Error creating comment": "コメントの作成中にエラーが発生しました",
"Comment updated successfully": "コメント更新ました", "Comment updated successfully": "コメント更新されました",
"Failed to update comment": "コメントの更新に失敗しました", "Failed to update comment": "コメントの更新に失敗しました",
"Comment deleted successfully": "コメント削除ました", "Comment deleted successfully": "コメント削除されました",
"Failed to delete comment": "コメントの削除に失敗しました", "Failed to delete comment": "コメントの削除に失敗しました",
"Comment resolved successfully": "コメント解決ました", "Comment resolved successfully": "コメント解決されました",
"Comment re-opened successfully": "コメント再開ました", "Comment re-opened successfully": "コメント再開されました",
"Comment unresolved successfully": "コメントを未解決に戻しました", "Comment unresolved successfully": "コメントが再解決されました",
"Failed to resolve comment": "コメントの解決に失敗しました", "Failed to resolve comment": "コメントの解決に失敗しました",
"Resolve comment": "コメントを解決", "Resolve comment": "コメントを解決",
"Unresolve comment": "コメントを解決に戻す", "Unresolve comment": "コメントを解決",
"Resolve Comment Thread": "コメントスレッドを解決", "Resolve Comment Thread": "コメントスレッドを解決",
"Unresolve Comment Thread": "コメントスレッドを解決に戻す", "Unresolve Comment Thread": "コメントスレッドを解決",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか完了としてマークされます", "Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか? これにより完了としてマークされます",
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決に戻しますか?", "Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決しますか?",
"Resolved": "解決済", "Resolved": "解決済",
"No active comments.": "アクティブなコメントはありません", "No active comments.": "アクティブなコメントはありません",
"No resolved comments.": "解決済みのコメントはありません", "No resolved comments.": "解決されたコメントはありません",
"Revoke invitation": "招待を取り消す", "Revoke invitation": "招待を取り消す",
"Revoke": "取り消す", "Revoke": "取り消す",
"Don't": "取り消さない", "Don't": "取り消さない",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですかユーザはワークスペースに参加できなくなります", "Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですか? ユーザはワークスペースに参加できなくなります",
"Resend invitation": "招待を再度送る", "Resend invitation": "招待を再度送る",
"Anyone with this link can join this workspace.": "このリンクをっている人は誰でもワークスペースに参加できます", "Anyone with this link can join this workspace.": "このリンクをっている人は誰でもこのワークスペースに参加できます",
"Invite link": "招待リンク", "Invite link": "招待リンク",
"Copy": "コピー", "Copy": "コピー",
"Copy to space": "スペースにコピー", "Copy to space": "スペースにコピー",
@@ -240,13 +239,13 @@
"Duplicate": "複製", "Duplicate": "複製",
"Select a user": "ユーザを選択", "Select a user": "ユーザを選択",
"Select a group": "グループを選択", "Select a group": "グループを選択",
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします", "Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします",
"Delete space": "スペースを削除", "Delete space": "スペースを削除",
"Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?", "Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?",
"Delete this space with all its pages and data.": "このスペースすべてのページデータを削除します", "Delete this space with all its pages and data.": "このスペースおよびスペース内のすべてのページデータを削除します",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "スペース内のすべてのページ、コメント、添付ファイル、権限完全に削除されます", "All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "このスペース内のすべてのページ、コメント、添付ファイル、および権限完全に削除されます",
"Confirm space name": "スペース名を確認する", "Confirm space name": "スペース名を確認する",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "確認のためスペース名 <b>{{spaceName}}</b> を入力してください", "Type the space name <b>{{spaceName}}</b> to confirm your action.": "アクションを確認するためスペース名 <b>{{spaceName}}</b> を入力してください",
"Format": "フォーマット", "Format": "フォーマット",
"Include subpages": "サブページを含める", "Include subpages": "サブページを含める",
"Include attachments": "添付ファイルを含める", "Include attachments": "添付ファイルを含める",
@@ -254,7 +253,6 @@
"Export failed:": "エクスポートに失敗しました:", "Export failed:": "エクスポートに失敗しました:",
"export error": "エクスポートエラー", "export error": "エクスポートエラー",
"Export page": "エクスポートページ", "Export page": "エクスポートページ",
"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}}の添付制限を超えています",
@@ -275,12 +273,12 @@
"Success": "成功", "Success": "成功",
"Warning": "警告", "Warning": "警告",
"Danger": "危険", "Danger": "危険",
"Mermaid diagram error:": "Mermaid ダイアグラムエラー:", "Mermaid diagram error:": "Mermaid コードエラー",
"Invalid Mermaid diagram": "無効な Mermaid ダイアグラムです", "Invalid Mermaid diagram": "無効な Mermaid コードです",
"Double-click to edit Draw.io diagram": "ダブルクリックして Draw.io 図を編集", "Double-click to edit Draw.io diagram": "ダブルクリックしてDraw.io図を編集",
"Exit": "終了", "Exit": "終了",
"Save & Exit": "保存して終了", "Save & Exit": "保存して終了",
"Double-click to edit Excalidraw diagram": "ダブルクリックして Excalidraw 図を編集", "Double-click to edit Excalidraw diagram": "ダブルクリックしてExcalidraw図を編集",
"Paste link": "リンクを貼り付け", "Paste link": "リンクを貼り付け",
"Edit link": "リンクを編集", "Edit link": "リンクを編集",
"Remove link": "リンクを削除", "Remove link": "リンクを削除",
@@ -317,24 +315,22 @@
"Bullet List": "箇条書きリスト", "Bullet List": "箇条書きリスト",
"Numbered List": "番号付きリスト", "Numbered List": "番号付きリスト",
"Blockquote": "引用", "Blockquote": "引用",
"Just start typing with plain text.": "プレーンテキストを入力します", "Just start typing with plain text.": "すぐに文章を書き始められます",
"Track tasks with a to-do list.": "Todo リストでタスクを管理します", "Track tasks with a to-do list.": "Todoリストでタスクを追跡します",
"Big section heading.": "大見出し", "Big section heading.": "大きいフォントのセクション見出しです。",
"Medium section heading.": "中見出し", "Medium section heading.": "中くらいのフォントのセクション見出しです。",
"Small section heading.": "小見出し", "Small section heading.": "小さいフォントのセクション見出しです。",
"Create a simple bullet list.": "箇条書きリストを作成します", "Create a simple bullet list.": "シンプルな箇条書きリストを作成します",
"Create a list with numbering.": "番号付きリストを作成します", "Create a list with numbering.": "番号付きリストを作成します",
"Create block quote.": "引用ブロックを作成します", "Create block quote.": "引用を作成します",
"Insert code snippet.": "コードスニペットを入します", "Insert code snippet.": "コードスニペットを入します",
"Insert horizontal rule divider": "区切り線を挿入します", "Insert horizontal rule divider": "水平線を挿入します",
"Upload any image from your device.": "デバイスから画像をアップロードします", "Upload any image from your device.": "画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします", "Upload any video from your device.": "動画をアップロードします",
"Upload any file from your device.": "デバイスからファイルをアップロードします", "Upload any file from your device.": "ファイルをアップロードします",
"Uploading {{name}}": "{{name}} をアップロード中",
"Uploading file": "ファイルをアップロード中",
"Table": "テーブル", "Table": "テーブル",
"Insert a table.": "テーブルを挿入します", "Insert a table.": "を挿入します",
"Insert collapsible block.": "折りたたみブロックを挿入します", "Insert collapsible block.": "折りたたみ可能なブロックを挿入します",
"Video": "動画", "Video": "動画",
"Divider": "区切り線", "Divider": "区切り線",
"Quote": "引用", "Quote": "引用",
@@ -342,16 +338,16 @@
"File attachment": "ファイル添付", "File attachment": "ファイル添付",
"Toggle block": "ブロックを切り替える", "Toggle block": "ブロックを切り替える",
"Callout": "コールアウト", "Callout": "コールアウト",
"Insert callout notice.": "コールアウトを挿入します", "Insert callout notice.": "コールアウトブロックを挿入します",
"Math inline": "インライン数式", "Math inline": "インライン数式",
"Insert inline math equation.": "インライン数式を挿入します", "Insert inline math equation.": "インライン数式を挿入します",
"Math block": "数式ブロック", "Math block": "数式ブロック",
"Insert math equation": "数式を挿入します", "Insert math equation": "数式を挿入します",
"Mermaid diagram": "Mermaid ダイアグラム", "Mermaid diagram": "Mermaidコード",
"Insert mermaid diagram": "Mermaid ダイアグラムを挿入します", "Insert mermaid diagram": "Mermaidコードを記述して図を挿入します",
"Insert and design Drawio diagrams": "Draw.io 図を挿入・編集します", "Insert and design Drawio diagrams": "Drawio図を挿入してデザインします",
"Insert current date": "現在の日付を挿入します", "Insert current date": "今日の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します", "Draw and sketch excalidraw diagrams": "Excalidraw図を埋め込みます",
"Multiple": "複数", "Multiple": "複数",
"Heading {{level}}": "見出し {{level}}", "Heading {{level}}": "見出し {{level}}",
"Toggle title": "タイトルの表示/非表示を切り替える", "Toggle title": "タイトルの表示/非表示を切り替える",
@@ -361,29 +357,29 @@
"Yesterday, {{time}}": "昨日、{{time}}", "Yesterday, {{time}}": "昨日、{{time}}",
"Space created successfully": "スペースを作成しました", "Space created successfully": "スペースを作成しました",
"Space updated successfully": "スペースを更新しました", "Space updated successfully": "スペースを更新しました",
"Space deleted successfully": "スペース削除ました", "Space deleted successfully": "スペース削除されました",
"Members added successfully": "メンバーを追加しました", "Members added successfully": "メンバーを追加しました",
"Member removed successfully": "メンバー削除ました", "Member removed successfully": "メンバー削除されました",
"Member role updated successfully": "メンバーのロールを更新しました", "Member role updated successfully": "メンバーのロールを更新しました",
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>", "Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
"Created at: {{time}}": "作成日: {{time}}", "Created at: {{time}}": "作成しました:{{time}}",
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}", "Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
"Word count: {{wordCount}}": "単語数: {{wordCount}}", "Word count: {{wordCount}}": "ワード数: {{wordCount}}",
"Character count: {{characterCount}}": "文字数: {{characterCount}}", "Character count: {{characterCount}}": "文字数: {{characterCount}}",
"New update": "新規更新", "New update": "新規更新",
"{{latestVersion}} is available": "{{latestVersion}}利用可能です", "{{latestVersion}} is available": "{{latestVersion}}利用可能です",
"Default page edit mode": "デフォルトのページ編集モード", "Default page edit mode": "デフォルトのページ編集モード",
"Choose your preferred page edit mode. Avoid accidental edits.": "お好みのページ編集モードを選択してください(誤編集を防止します", "Choose your preferred page edit mode. Avoid accidental edits.": "希望のページ編集モードを選択してください。誤って編集を防ます",
"Reading": "読み取り", "Reading": "読み取り",
"Delete member": "メンバーを削除する", "Delete member": "メンバーを削除する",
"Member deleted successfully": "メンバー削除ました", "Member deleted successfully": "メンバー削除されました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "このメンバーを削除してもよろしいですか?この操作は取り消せません", "Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません",
"Move": "移動", "Move": "移動",
"Move page": "ページを移動", "Move page": "ページを移動",
"Move page to a different space.": "ページを別のスペースに移動します", "Move page to a different space.": "ページを別のスペースに移動します",
"Real-time editor connection lost. Retrying...": "リアルタイム編集の接続が切断されました。再接続中...", "Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
"Table of contents": "目次", "Table of contents": "目次",
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加すると目次生成されます", "Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加して目次生成ます",
"Share": "共有", "Share": "共有",
"Public sharing": "公開共有", "Public sharing": "公開共有",
"Shared by": "共有者", "Shared by": "共有者",
@@ -402,13 +398,13 @@
"Delete share": "共有を削除", "Delete share": "共有を削除",
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?", "Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます", "Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
"Share deleted successfully": "共有を削除しました", "Share deleted successfully": "共有が正常に削除されました",
"Share not found": "共有が見つかりません", "Share not found": "共有が見つかりません",
"Failed to share page": "ページの共有に失敗しました", "Failed to share page": "ページの共有に失敗しました",
"Copy page": "ページをコピー", "Copy page": "ページをコピー",
"Copy page to a different space.": "ページを別のスペースにコピーします", "Copy page to a different space.": "ページを別のスペースにコピーします",
"Page copied successfully": "ページコピーしました", "Page copied successfully": "ページコピーに成功しました",
"Page duplicated successfully": "ページを複製しました", "Page duplicated successfully": "ページが正常に複製されました",
"Find": "検索", "Find": "検索",
"Not found": "見つかりません", "Not found": "見つかりません",
"Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)", "Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)",
@@ -423,26 +419,26 @@
"Error": "エラー", "Error": "エラー",
"Failed to disable MFA": "MFAの無効化に失敗しました", "Failed to disable MFA": "MFAの無効化に失敗しました",
"Disable two-factor authentication": "二要素認証を無効化", "Disable two-factor authentication": "二要素認証を無効化",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります", "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります",
"Please enter your password to disable two-factor authentication:": "二要素認証を無効するにはパスワードを入力してください", "Please enter your password to disable two-factor authentication:": "二要素認証を無効するにはパスワードを入力してください:",
"Two-factor authentication has been enabled": "二要素認証有効にました", "Two-factor authentication has been enabled": "二要素認証有効になりました",
"Two-factor authentication has been disabled": "二要素認証無効にました", "Two-factor authentication has been disabled": "二要素認証無効になりました",
"2-step verification": "2段階認", "2-step verification": "2段階認",
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証でアカウントを保護します", "Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証レイヤーでアカウントを保護します",
"Two-factor authentication is active on your account.": "二要素認証が有効です", "Two-factor authentication is active on your account.": "二要素認証がアカウントで有効です",
"Add 2FA method": "2FAメソッドを追加", "Add 2FA method": "2FAメソッドを追加",
"Backup codes": "バックアップコード", "Backup codes": "バックアップコード",
"Disable": "無効にする", "Disable": "無効にする",
"Invalid verification code": "無効な認証コード", "Invalid verification code": "無効な認証コード",
"New backup codes have been generated": "新しいバックアップコード生成ました", "New backup codes have been generated": "新しいバックアップコード生成されました",
"Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました", "Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました",
"About backup codes": "バックアップコードについて", "About backup codes": "バックアップコードについて",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリアクセスできない場合、バックアップコードでアカウントにアクセスできます。各コードは1回のみ使用可能です", "Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "バックアップコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "新しいバックアップコードはいつでも再生成できます。既存のコードはすべて無効になります", "You can regenerate new backup codes at any time. This will invalidate all existing codes.": "いつでも新しいバックアップコード再生成できます。これにより、既存のすべてのコードが無効になります",
"Confirm password": "パスワードを確認", "Confirm password": "パスワードを確認",
"Generate new backup codes": "新しいバックアップコードを生成", "Generate new backup codes": "新しいバックアップコードを生成",
"Save your new backup codes": "新しいバックアップコードを保存", "Save your new backup codes": "新しいバックアップコードを保存",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効になりました", "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効です。",
"Your new backup codes": "新しいバックアップコード", "Your new backup codes": "新しいバックアップコード",
"I've saved my backup codes": "バックアップコードを保存しました", "I've saved my backup codes": "バックアップコードを保存しました",
"Failed to setup MFA": "MFAの設定に失敗しました", "Failed to setup MFA": "MFAの設定に失敗しました",
@@ -453,51 +449,51 @@
"Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:", "Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:",
"2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください", "2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください",
"Verify and enable": "確認と有効化", "Verify and enable": "確認と有効化",
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。もう一度お試しください", "Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。再試行してください",
"Backup": "バックアップ", "Backup": "バックアップ",
"Save codes": "コードを保存", "Save codes": "コードを保存",
"Save your backup codes": "バックアップコードを保存", "Save your backup codes": "バックアップコードを保存",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリアクセスできない場合、これらのコードでアカウントにアクセスできます。各コードは1回のみ使用可能です", "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "これらのコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"Print": "印刷", "Print": "印刷",
"Two-factor authentication has been set up. Please log in again.": "二要素認証設定ました。再度ログインしてください", "Two-factor authentication has been set up. Please log in again.": "二要素認証設定されました。再度ログインしてください",
"Two-Factor authentication required": "二要素認証が必要です", "Two-Factor authentication required": "二要素認証が必要です",
"Your workspace requires two-factor authentication for all users": "このワークスペースではすべてのユーザーに二要素認証が必要です", "Your workspace requires two-factor authentication for all users": "ワークスペースではすべてのユーザーに二要素認証が必要です",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースアクセスるには二要素認証を設定してください。アカウントのセキュリティが強化されます", "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースへのアクセスを続けるには二要素認証を設定する必要があります。これにより、アカウントに追加のセキュリティ層が追加されます",
"Set up two-factor authentication": "二要素認証を設定", "Set up two-factor authentication": "二要素認証を設定",
"Cancel and logout": "キャンセルしてログアウト", "Cancel and logout": "キャンセルしてログアウト",
"Your workspace requires two-factor authentication. Please set it up to continue.": "このワークスペースでは二要素認証が必要です。続行するには設定してください", "Your workspace requires two-factor authentication. Please set it up to continue.": "ワークスペースでは二要素認証が必要です。続行するには設定してください",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "認証アプリからの確認コードアカウントのセキュリティが強化されます", "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "これにより、認証アプリからの確認コードが必要となり、アカウントに追加のセキュリティ層が追加されます",
"Password is required": "パスワードが必要です", "Password is required": "パスワードが必要です",
"Password must be at least 8 characters": "パスワードは8文字以上必要です", "Password must be at least 8 characters": "パスワードは8文字以上必要です",
"Please enter a 6-digit code": "6桁のコードを入力してください", "Please enter a 6-digit code": "6桁のコードを入力してください",
"Code must be exactly 6 digits": "コードは6桁で入力してください", "Code must be exactly 6 digits": "コードは正確に6桁である必要があります",
"Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください", "Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください",
"Need help authenticating?": "認証に関するヘルプが必要ですか?", "Need help authenticating?": "認証に関するヘルプが必要ですか?",
"MFA QR Code": "MFA QRコード", "MFA QR Code": "MFA QRコード",
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントを作成しました。二要素認証を設定するためにログインしてください", "Account created successfully. Please log in to set up two-factor authentication.": "アカウントが正常に作成されました。二要素認証を設定するためにログインしてください",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードリセットしました。新しいパスワードでログインし二要素認証を完了してください", "Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードリセットが成功しました。新しいパスワードでログインし二要素認証を完了してください",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードリセットしました。新しいパスワードでログインして二要素認証を設定してください", "Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードリセットが成功しました。二要素認証を設定するために新しいパスワードでログインしてください",
"Password reset was successful. Please log in with your new password.": "パスワードリセットしました。新しいパスワードでログインしてください", "Password reset was successful. Please log in with your new password.": "パスワードリセットが成功しました。新しいパスワードでログインしてください",
"Two-factor authentication": "二要素認証", "Two-factor authentication": "二要素認証",
"Use authenticator app instead": "代わりに認証アプリを使用", "Use authenticator app instead": "代わりに認証アプリを使用",
"Verify backup code": "バックアップコードを確認", "Verify backup code": "バックアップコードを確認",
"Use backup code": "バックアップコードを使用", "Use backup code": "バックアップコードを使用",
"Enter one of your backup codes": "バックアップコードのいずれかを入力してください", "Enter one of your backup codes": "バックアップコードのいずれかを入力してください",
"Backup code": "バックアップコード", "Backup code": "バックアップコード",
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードを入力してください。各コードは1回のみ使用可能です", "Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードのいずれかを入力してください。各バックアップコードは一度しか使用できません。",
"Verify": "確認", "Verify": "確認",
"Trash": "ごみ箱", "Trash": "ごみ箱",
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます", "Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます",
"Deleted": "削除", "Deleted": "削除",
"No pages in trash": "ごみ箱にページがありません", "No pages in trash": "ごみ箱にページがありません",
"Permanently delete page?": "ページを完全に削除しますか?", "Permanently delete page?": "ページを完全に削除しますか?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますかこの操作は取り消せません", "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますか? この操作は元に戻せません",
"Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?", "Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?",
"Move to trash": "ごみ箱に移動", "Move to trash": "ごみ箱に移動",
"Move this page to trash?": "このページをごみ箱に移動しますか?", "Move this page to trash?": "このページをごみ箱に移動しますか?",
"Restore page": "ページを復元", "Restore page": "ページを復元",
"Page moved to trash": "ページごみ箱に移動ました", "Page moved to trash": "ページごみ箱に移動されました",
"Page restored successfully": "ページを復元しました", "Page restored successfully": "ページが正常に復元されました",
"Deleted by": "削除者", "Deleted by": "削除者",
"Deleted at": "削除日時", "Deleted at": "削除日時",
"Preview": "プレビュー", "Preview": "プレビュー",
@@ -515,10 +511,10 @@
"Enterprise": "エンタープライズ", "Enterprise": "エンタープライズ",
"Download attachment": "添付ファイルをダウンロード", "Download attachment": "添付ファイルをダウンロード",
"Allowed email domains": "許可されたメールドメイン", "Allowed email domains": "許可されたメールドメイン",
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインのメールアドレスを持つユーザーのみSSO経由で登録できます", "Only users with email addresses from these domains can signup via SSO.": "これらのドメインからのメールアドレスを持つユーザーのみSSOで登録できます",
"Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください", "Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください",
"Enforce two-factor authentication": "二要素認証を強制する", "Enforce two-factor authentication": "二要素認証を強制する",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "有効にすると、すべてのメンバーが二要素認証を設定しないとワークスペースにアクセスできなくなります", "Once enforced, all members must enable two-factor authentication to access the workspace.": "一度強制されると、すべてのメンバーはワークスペースにアクセスするために二要素認証を有効にする必要があります",
"Toggle MFA enforcement": "MFAの強制を切り替える", "Toggle MFA enforcement": "MFAの強制を切り替える",
"Display name": "表示名", "Display name": "表示名",
"Allow signup": "登録を許可する", "Allow signup": "登録を許可する",
@@ -536,10 +532,10 @@
"Upload image": "画像をアップロード", "Upload image": "画像をアップロード",
"Remove image": "画像を削除", "Remove image": "画像を削除",
"Failed to remove image": "画像の削除に失敗しました", "Failed to remove image": "画像の削除に失敗しました",
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています", "Image exceeds 10MB limit.": "画像が10MBの制限を超えています",
"Image removed successfully": "画像を削除しました", "Image removed successfully": "画像が正常に削除されました",
"API key": "APIキー", "API key": "APIキー",
"API key created successfully": "APIキーを作成しました", "API key created successfully": "APIキーが正常に作成されました",
"API keys": "APIキー", "API keys": "APIキー",
"API management": "API管理", "API management": "API管理",
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか", "Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
@@ -554,9 +550,9 @@
"No API keys found": "APIキーが見つかりません", "No API keys found": "APIキーが見つかりません",
"No expiration": "期限なし", "No expiration": "期限なし",
"Revoke API key": "APIキーを無効にする", "Revoke API key": "APIキーを無効にする",
"Revoked successfully": "無効にしました", "Revoked successfully": "正常に無効化されました",
"Select expiration date": "有効期限を選択してください", "Select expiration date": "有効期限を選択してください",
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は取り消せません。このAPIキーを使用しているアプリケーションは動作しなくなります", "This action cannot be undone. Any applications using this API key will stop working.": "この操作は元に戻せません。このAPIキーを使用しているアプリケーションは動作を停止します",
"Update API key": "APIキーを更新", "Update API key": "APIキーを更新",
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理", "Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
"AI settings": "AI設定", "AI settings": "AI設定",
@@ -566,7 +562,7 @@
"AI is thinking...": "AIが考え中...", "AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...", "Ask a question...": "質問を入力...",
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)", "AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペースコンテンツ全体にわたって意味検索機能を提供します",
"Toggle AI search": "AI検索を切り替え", "Toggle AI search": "AI検索を切り替え",
"Sources": "ソース", "Sources": "ソース",
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません", "Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
@@ -29,7 +29,6 @@
"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": "Markdown으로 복사",
"Copy link": "링크 복사", "Copy link": "링크 복사",
"Create": "생성", "Create": "생성",
"Create group": "팀 생성", "Create group": "팀 생성",
@@ -254,7 +253,6 @@
"Export failed:": "내보내기 실패:", "Export failed:": "내보내기 실패:",
"export error": "내보내기 오류", "export error": "내보내기 오류",
"Export page": "페이지 내보내기", "Export page": "페이지 내보내기",
"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}}을 초과했습니다",
@@ -330,8 +328,6 @@
"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}}": "{{name}} 업로드 중",
"Uploading file": "파일 업로드 중",
"Table": "테이블", "Table": "테이블",
"Insert a table.": "테이블 삽입.", "Insert a table.": "테이블 삽입.",
"Insert collapsible block.": "접을 수 있는 블록 삽입.", "Insert collapsible block.": "접을 수 있는 블록 삽입.",
@@ -29,7 +29,6 @@
"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": "Kopiëren als Markdown",
"Copy link": "Link kopiëren", "Copy link": "Link kopiëren",
"Create": "Aanmaken", "Create": "Aanmaken",
"Create group": "Groep aanmaken", "Create group": "Groep aanmaken",
@@ -254,7 +253,6 @@
"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 succesvol",
"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}}",
@@ -330,8 +328,6 @@
"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}}": "Uploaden {{name}}",
"Uploading file": "Bestand uploaden",
"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,7 +29,6 @@
"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": "Copiar como Markdown",
"Copy link": "Copiar link", "Copy link": "Copiar link",
"Create": "Criar", "Create": "Criar",
"Create group": "Criar grupo", "Create group": "Criar grupo",
@@ -254,7 +253,6 @@
"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": "Exportação bem-sucedida",
"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}}",
@@ -330,8 +328,6 @@
"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}}": "Enviando {{name}}",
"Uploading file": "Enviando arquivo",
"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,7 +29,6 @@
"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": "Копировать как Markdown",
"Copy link": "Копировать ссылку", "Copy link": "Копировать ссылку",
"Create": "Создать", "Create": "Создать",
"Create group": "Создать группу", "Create group": "Создать группу",
@@ -254,7 +253,6 @@
"Export failed:": "Экспортирование не удалось:", "Export failed:": "Экспортирование не удалось:",
"export error": "ошибка экспорта", "export error": "ошибка экспорта",
"Export page": "Экспорт страницы", "Export page": "Экспорт страницы",
"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}}",
@@ -330,8 +328,6 @@
"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}}": "Загрузка {{name}}",
"Uploading file": "Загрузка файла",
"Table": "Таблица", "Table": "Таблица",
"Insert a table.": "Вставить таблицу.", "Insert a table.": "Вставить таблицу.",
"Insert collapsible block.": "Вставить сворачиваемый блок.", "Insert collapsible block.": "Вставить сворачиваемый блок.",
@@ -29,7 +29,6 @@
"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": "Скопіювати як Markdown",
"Copy link": "Копіювати посилання", "Copy link": "Копіювати посилання",
"Create": "Створити", "Create": "Створити",
"Create group": "Створити групу", "Create group": "Створити групу",
@@ -254,7 +253,6 @@
"Export failed:": "Експортування не вдалося:", "Export failed:": "Експортування не вдалося:",
"export error": "помилка експорту", "export error": "помилка експорту",
"Export page": "Експорт сторінки", "Export page": "Експорт сторінки",
"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}}",
@@ -330,8 +328,6 @@
"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}}": "Завантаження {{name}}",
"Uploading file": "Завантаження файлу",
"Table": "Таблиця", "Table": "Таблиця",
"Insert a table.": "Вставити таблицю.", "Insert a table.": "Вставити таблицю.",
"Insert collapsible block.": "Вставити блок, що згортається.", "Insert collapsible block.": "Вставити блок, що згортається.",
@@ -29,7 +29,6 @@
"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": "复制为Markdown",
"Copy link": "复制链接", "Copy link": "复制链接",
"Create": "创建", "Create": "创建",
"Create group": "创建群组", "Create group": "创建群组",
@@ -254,7 +253,6 @@
"Export failed:": "导出失败:", "Export failed:": "导出失败:",
"export error": "导出出错", "export error": "导出出错",
"Export page": "导出页面", "Export page": "导出页面",
"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}} 类型附件限制",
@@ -330,8 +328,6 @@
"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}}": "正在上传{{name}}",
"Uploading file": "正在上传文件",
"Table": "表格", "Table": "表格",
"Insert a table.": "插入一个表格", "Insert a table.": "插入一个表格",
"Insert collapsible block.": "插入一个折叠块", "Insert collapsible block.": "插入一个折叠块",
@@ -30,11 +30,9 @@ 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({
@@ -47,9 +45,6 @@ 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({
@@ -57,8 +52,6 @@ export default function ExportModal({
color: "red", color: "red",
}); });
console.error("export error", err); console.error("export error", err);
} finally {
setIsExporting(false);
} }
}; };
@@ -143,7 +136,7 @@ export default function ExportModal({
<Button onClick={onClose} variant="default"> <Button onClick={onClose} variant="default">
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button> <Button onClick={handleExport}>{t("Export")}</Button>
</Group> </Group>
</Modal.Body> </Modal.Body>
</Modal.Content> </Modal.Content>
@@ -5,27 +5,26 @@ 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) {
@@ -45,8 +44,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>
)} )}
@@ -59,23 +58,18 @@ export default function RecentChanges({ spaceId }: Props) {
{!spaceId && ( {!spaceId && (
<Table.Td> <Table.Td>
<Badge <Badge
color={getInitialsColor(page?.space.name)} color="blue"
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 <Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
>
{formattedDate(page.updatedAt)} {formattedDate(page.updatedAt)}
</Text> </Text>
</Table.Td> </Table.Td>
@@ -1,49 +0,0 @@
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,13 +1,11 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core"; import { Group, Text, Paper, ActionIcon } 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();
@@ -22,28 +20,26 @@ export default function AttachmentView(props: NodeViewProps) {
wrap="nowrap" wrap="nowrap"
h={25} h={25}
> >
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}> <Group justify="space-between" wrap="nowrap">
{url ? ( <IconPaperclip size={20} />
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
) : (
<Loader size={20} style={{ flexShrink: 0 }} />
)}
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}> <Text component="span" size="md" truncate="end">
{url ? name : t("Uploading {{name}}", { name })} {name}
</Text> </Text>
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}> <Text component="span" size="sm" c="dimmed" inline>
{formatBytes(size)} {formatBytes(size)}
</Text> </Text>
</Group> </Group>
{url && (selected || hovered) && ( {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,6 +1,10 @@
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus"; import {
import { isNodeSelection, useEditorState } from "@tiptap/react"; BubbleMenu,
import type { Editor } from "@tiptap/react"; BubbleMenuProps,
isNodeSelection,
useEditor,
useEditorState,
} from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react"; import { FC, useEffect, useRef, useState } from "react";
import { import {
IconBold, IconBold,
@@ -34,7 +38,7 @@ export interface BubbleMenuItem {
} }
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & { type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
editor: Editor | null; editor: ReturnType<typeof useEditor>;
}; };
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => { export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
@@ -129,9 +133,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
} }
return isTextSelected(editor); return isTextSelected(editor);
}, },
options: { tippyOptions: {
placement: "top", moveTransition: "transform 0.15s ease-out",
offset: 8, onCreate: (instance) => {
instance.popper.firstChild?.addEventListener("blur", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
});
},
onHide: () => { onHide: () => {
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
@@ -147,7 +156,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
return ( return (
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}> <BubbleMenu {...bubbleMenuProps}>
<div className={classes.bubbleMenu}> <div className={classes.bubbleMenu}>
<NodeSelector <NodeSelector
editor={props.editor} editor={props.editor}
@@ -1,5 +1,9 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import {
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
@@ -49,26 +53,17 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferencedVirtualElement = useCallback(() => { const getReferenceClientRect = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout"; const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect(); return dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
const domRect = posToDOMRect(editor.view, selection.from, selection.to); return posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const setCalloutType = useCallback( const setCalloutType = useCallback(
@@ -117,12 +112,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`callout-menu`} pluginKey={`callout-menu`}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} tippyOptions={{
options={{ getReferenceClientRect,
offset: [0, 10],
placement: "bottom", placement: "bottom",
// offset: 233, // // offset: [0, 10], zIndex: 99,
// zIndex: 99, popperOptions: {
flip: false, modifiers: [{ name: "flip", enabled: false }],
},
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -90,7 +90,6 @@ export default function CodeBlockView(props: NodeViewProps) {
node.textContent.length > 0 node.textContent.length > 0
} }
> >
{/* @ts-ignore */}
<NodeViewContent as="code" className={`language-${language}`} /> <NodeViewContent as="code" className={`language-${language}`} />
</pre> </pre>
@@ -1,12 +1,13 @@
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 = (
editor: Editor, view: EditorView,
event: ClipboardEvent, event: ClipboardEvent,
pageId: string, pageId: string,
creatorId?: string, creatorId?: string,
@@ -17,7 +18,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 } = editor.state.selection; const { from: pos, empty } = view.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);
@@ -33,27 +34,19 @@ 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 const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
? url.substring(0, url.indexOf("#")) createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
: 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 = editor.state.selection.from; const pos = view.state.selection.from;
uploadImageAction(file, editor, pos, pageId); uploadImageAction(file, view, pos, pageId);
uploadVideoAction(file, editor, pos, pageId); uploadVideoAction(file, view, pos, pageId);
uploadAttachmentAction(file, editor, pos, pageId); uploadAttachmentAction(file, view, pos, pageId);
} }
return true; return true;
} }
@@ -61,7 +54,7 @@ export const handlePaste = (
}; };
export const handleFileDrop = ( export const handleFileDrop = (
editor: Editor, view: EditorView,
event: DragEvent, event: DragEvent,
moved: boolean, moved: boolean,
pageId: string, pageId: string,
@@ -70,14 +63,14 @@ export const handleFileDrop = (
event.preventDefault(); event.preventDefault();
for (const file of event.dataTransfer.files) { for (const file of event.dataTransfer.files) {
const coordinates = editor.view.posAtCoords({ const coordinates = view.posAtCoords({
left: event.clientX, left: event.clientX,
top: event.clientY, top: event.clientY,
}); });
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
} }
return true; return true;
} }
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import {
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -35,26 +40,17 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferencedVirtualElement = useCallback(() => { const getReferenceClientRect = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio"; const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect(); return dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
const domRect = posToDOMRect(editor.view, selection.from, selection.to); return posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const onWidthChange = useCallback( const onWidthChange = useCallback(
@@ -69,11 +65,15 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`drawio-menu`} pluginKey={`drawio-menu`}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} tippyOptions={{
options={{ getReferenceClientRect,
placement: "top", offset: [0, 8],
offset: 8, zIndex: 99,
flip: false, popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -66,7 +66,6 @@ export default function DrawioView(props: NodeViewProps) {
const fileName = "diagram.drawio.svg"; const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName); const drawioSVGFile = await svgStringToFile(svgString, fileName);
//@ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
let attachment: IAttachment = null; let attachment: IAttachment = null;
@@ -1,41 +1,16 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import EmojiList from "./emoji-list"; import EmojiList from "./emoji-list";
import tippy from "tippy.js";
import { init } from "emoji-mart"; import { init } from "emoji-mart";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderEmojiItems = () => { const renderEmojiItems = () => {
let component: ReactRenderer | null = null; let component: ReactRenderer | null = null;
let popup: HTMLDivElement | null = null; let popup: any | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const destroy = () => {
if (cleanup) {
cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
}
if (component) {
component.destroy();
component = null;
}
};
return { return {
onBeforeStart: (props: { onBeforeStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect; clientRect: DOMRect;
}) => { }) => {
init({ init({
data: async () => (await import("@emoji-mart/data")).default, data: async () => (await import("@emoji-mart/data")).default,
@@ -50,61 +25,51 @@ const renderEmojiItems = () => {
return; return;
} }
getReferenceClientRect = props.clientRect; // @ts-ignore
popup = document.createElement("div"); popup = tippy("body", {
popup.style.zIndex = "9999"; getReferenceClientRect: props.clientRect,
popup.style.position = "absolute"; appendTo: () => document.body,
popup.style.top = "0"; content: component.element,
popup.style.left = "0"; showOnCreate: true,
popup.appendChild(component.element); interactive: true,
document.body.appendChild(popup); trigger: "manual",
placement: "bottom",
const virtualElement = {
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect(0, 0, 0, 0);
},
};
cleanup = autoUpdate(virtualElement, popup, () => {
if (!popup) return;
computePosition(virtualElement, popup, {
placement: "bottom-start",
middleware: [offset(10), flip(), shift()],
}).then(({ x, y }) => {
if (!popup) return;
Object.assign(popup.style, {
transform: `translate(${x}px, ${y}px)`,
});
});
}); });
}, },
onStart: (props: { onStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect; clientRect: DOMRect;
}) => { }) => {
component?.updateProps({ ...props, isLoading: false }); component?.updateProps({...props, isLoading: false});
if (props.clientRect) { if (!props.clientRect) {
getReferenceClientRect = props.clientRect; return;
} }
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect; clientRect: DOMRect;
}) => { }) => {
component?.updateProps(props); component?.updateProps(props);
if (props.clientRect) { if (!props.clientRect) {
getReferenceClientRect = props.clientRect; return;
} }
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key === "Escape") {
destroy(); popup?.[0].hide();
component?.destroy()
return true; return true;
} }
@@ -113,7 +78,13 @@ const renderEmojiItems = () => {
return component?.ref?.onKeyDown(props); return component?.ref?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
destroy(); if (popup && !popup[0]?.state.isDestroyed) {
popup[0]?.destroy();
}
if (component) {
component?.destroy();
}
}, },
}; };
}; };
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import {
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -37,26 +42,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferencedVirtualElement = useCallback(() => { const getReferenceClientRect = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw"; const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect(); return dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
const domRect = posToDOMRect(editor.view, selection.from, selection.to); return posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const onWidthChange = useCallback( const onWidthChange = useCallback(
@@ -69,13 +65,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`excalidraw-menu`} pluginKey={`excalidraw-menu}`}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} tippyOptions={{
options={{ getReferenceClientRect,
placement: "top", offset: [0, 8],
offset: 8, zIndex: 99,
flip: false, popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -98,7 +98,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
const fileName = "diagram.excalidraw.svg"; const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName); const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
let attachment: IAttachment = null; let attachment: IAttachment = null;
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import {
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
export function ImageMenu({ editor }: EditorMenuProps) { export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image");
},
[editor],
);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -37,37 +52,17 @@ export function ImageMenu({ editor }: EditorMenuProps) {
}, },
}); });
const shouldShow = useCallback( const getReferenceClientRect = useCallback(() => {
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image") && editor.getAttributes("image").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image"; const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect(); return dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
const domRect = posToDOMRect(editor.view, selection.from, selection.to); return posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const alignImageLeft = useCallback(() => { const alignImageLeft = useCallback(() => {
@@ -110,11 +105,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`image-menu`} pluginKey={`image-menu`}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} tippyOptions={{
options={{ getReferenceClientRect,
placement: "top", offset: [0, 8],
offset: 8, zIndex: 99,
flip: false, popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -1,27 +0,0 @@
.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,70 +1,30 @@
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 { t } = useTranslation(); const { node, selected } = props;
const { editor, node, selected } = props; const { src, width, align, title } = node.attrs;
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>
<div <Image
className={clsx( radius="md"
selected && "ProseMirror-selectednode", fit="contain"
classes.imageWrapper, w={width}
alignClass, src={getFileUrl(src)}
)} alt={title}
style={{ className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
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>
); );
} }
@@ -1,10 +1,9 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx"; import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
import { Card } from "@mantine/core"; import { Card } from "@mantine/core";
import { useEditorState } from "@tiptap/react";
export function LinkMenu({ editor, appendTo }: EditorMenuProps) { export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
@@ -60,15 +59,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`link-menu`} pluginKey={`link-menu}`}
updateDelay={0} updateDelay={0}
options={{ tippyOptions={{
onHide: () => { appendTo: () => {
return appendTo?.current;
},
onHidden: () => {
setShowEdit(false); setShowEdit(false);
}, },
placement: "bottom", placement: "bottom",
offset: 5, offset: [0, 5],
// zIndex: 101, zIndex: 101,
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -106,7 +106,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
setRenderItems(items); setRenderItems(items);
// update editor storage // update editor storage
//@ts-ignore
props.editor.storage.mentionItems = items; props.editor.storage.mentionItems = items;
} }
}, [suggestion, isLoading]); }, [suggestion, isLoading]);
@@ -164,7 +163,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);
} }
}; };
@@ -204,7 +203,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
parentPageId: page.id || null, parentPageId: page.id || null,
title: title title: title
}; };
let createdPage: IPage; let createdPage: IPage;
try { try {
createdPage = await createPageMutation.mutateAsync(payload); createdPage = await createPageMutation.mutateAsync(payload);
@@ -1,11 +1,5 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import { import tippy from "tippy.js";
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import MentionList from "@/features/editor/components/mention/mention-list.tsx"; import MentionList from "@/features/editor/components/mention/mention-list.tsx";
function getWhitespaceCount(query: string) { function getWhitespaceCount(query: string) {
@@ -15,27 +9,16 @@ function getWhitespaceCount(query: string) {
const mentionRenderItems = () => { const mentionRenderItems = () => {
let component: ReactRenderer | null = null; let component: ReactRenderer | null = null;
let activeClientRect: (() => DOMRect) | null = null; let popup: any | null = null;
let updatePositionCleanup: (() => void) | null = null;
const destroy = () => {
updatePositionCleanup?.();
updatePositionCleanup = null;
component?.destroy();
if (component?.element?.parentNode) {
component.element.parentNode.removeChild(component.element);
}
component = null;
};
return { return {
onStart: (props: { onStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect; clientRect: DOMRect;
query: string; query: string;
}) => { }) => {
// query must not start with a whitespace // query must not start with a whitespace
if (props.query.charAt(0) === " ") { if (props.query.charAt(0) === ' '){
return; return;
} }
@@ -54,95 +37,75 @@ const mentionRenderItems = () => {
return; return;
} }
activeClientRect = props.clientRect; // @ts-ignore
popup = tippy("body", {
const { element } = component; getReferenceClientRect: props.clientRect,
document.body.appendChild(element); appendTo: () => document.body,
content: component.element,
updatePositionCleanup = autoUpdate( showOnCreate: true,
{ interactive: true,
getBoundingClientRect: () => trigger: "manual",
activeClientRect ? activeClientRect() : new DOMRect(), placement: "bottom-start",
}, });
element,
() => {
if (!component?.element) return;
computePosition(
{
getBoundingClientRect: () => {
return activeClientRect ? activeClientRect() : new DOMRect();
},
},
element,
{
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
},
).then(({ x, y }) => {
Object.assign(element.style, {
left: `${x}px`,
top: `${y}px`,
position: "absolute",
zIndex: "9999",
});
});
},
);
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect; clientRect: DOMRect;
query: string; query: string;
}) => { }) => {
// query must not start with a whitespace // query must not start with a whitespace
if (props.query.charAt(0) === " ") { if (props.query.charAt(0) === ' '){
destroy(); component?.destroy();
return; return;
} }
// only update component if popup is not destroyed // only update component if popup is not destroyed
if (component) { if (!popup?.[0].state.isDestroyed) {
component.updateProps(props); component?.updateProps(props);
} }
if (!props || !props.clientRect) { if (!props || !props.clientRect) {
return; return;
} }
activeClientRect = props.clientRect;
const whitespaceCount = getWhitespaceCount(props.query); const whitespaceCount = getWhitespaceCount(props.query);
// destroy component if space is greater 3 without a match // destroy component if space is greater 3 without a match
if ( if (
whitespaceCount > 4 && whitespaceCount > 3 &&
//@ts-ignore props.editor.storage.mentionItems.length === 0
props.editor.storage.mentionItems.length === 1
) { ) {
destroy(); popup?.[0]?.destroy();
return; component?.destroy();
}
// fallback exit
if (whitespaceCount > 7) {
destroy();
return; return;
} }
popup &&
!popup?.[0].state.isDestroyed &&
popup?.[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key)
destroy(); if (
return true; props.event.key === "Escape" ||
} (props.event.key === "Enter" && !popup?.[0].state.isShown)
) {
if (props.event.key === "Enter" && !component) { popup?.[0].destroy();
destroy(); component?.destroy();
return false; return false;
} }
return (component?.ref as any)?.onKeyDown(props); return (component?.ref as any)?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
destroy(); if (popup && !popup?.[0].state.isDestroyed) {
popup[0].destroy();
}
if (component) {
component.destroy();
}
}, },
}; };
}; };
@@ -1,21 +1,19 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Anchor, Text } from "@mantine/core"; import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react"; import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { Link, useLocation, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { import {
buildPageUrl, buildPageUrl,
buildSharedPageUrl, buildSharedPageUrl,
} from "@/features/page/page.utils.ts"; } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import classes from "./mention.module.css"; import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) { export default function MentionView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs; const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const { spaceSlug, pageSlug } = useParams(); const { spaceSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const navigate = useNavigate();
const { const {
data: page, data: page,
isLoading, isLoading,
@@ -25,20 +23,6 @@ export default function MentionView(props: NodeViewProps) {
const location = useLocation(); const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share"); const isShareRoute = location.pathname.startsWith("/share");
const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId;
const handleClick = (e: React.MouseEvent) => {
if (isSamePage && anchorId) {
e.preventDefault();
const element = document.querySelector(`[id="${anchorId}"]`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
navigate(`#${anchorId}`, { replace: true });
}
}
};
const shareSlugUrl = buildSharedPageUrl({ const shareSlugUrl = buildSharedPageUrl({
shareId, shareId,
pageSlugId: slugId, pageSlugId: slugId,
@@ -61,7 +45,6 @@ export default function MentionView(props: NodeViewProps) {
to={ to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId) isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
} }
onClick={handleClick}
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
> >
@@ -73,8 +73,6 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
if (!editor) return; if (!editor) return;
const { results, resultIndex } = editor.storage.searchAndReplace; const { results, resultIndex } = editor.storage.searchAndReplace;
//TODO: check type error
//@ts-ignore
const position: Range = results[resultIndex]; const position: Range = results[resultIndex];
if (!position) return; if (!position) return;
@@ -161,7 +161,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
if (!pageId) return; if (!pageId) return;
@@ -174,13 +173,9 @@ 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();
}, },
@@ -193,7 +188,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
if (!pageId) return; if (!pageId) return;
@@ -201,18 +195,12 @@ 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) {
for (const file of input.files) { const file = input.files[0];
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();
}, },
@@ -225,7 +213,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
if (!pageId) return; if (!pageId) return;
@@ -233,18 +220,12 @@ 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) {
for (const file of input.files) { const file = input.files[0];
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();
}, },
@@ -1,35 +1,10 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import CommandList from "@/features/editor/components/slash-menu/command-list"; import CommandList from "@/features/editor/components/slash-menu/command-list";
import { import tippy from "tippy.js";
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderItems = () => { const renderItems = () => {
let component: ReactRenderer | null = null; let component: ReactRenderer | null = null;
let popup: HTMLElement | null = null; let popup: any | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const updatePosition = () => {
if (!popup || !getReferenceClientRect) return;
// @ts-ignore
const rect = getReferenceClientRect();
computePosition({ getBoundingClientRect: () => rect }, popup, {
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
}).then(({ x, y }) => {
if (popup) {
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
}
});
};
return { return {
onStart: (props: { onStart: (props: {
@@ -46,29 +21,15 @@ const renderItems = () => {
} }
// @ts-ignore // @ts-ignore
getReferenceClientRect = props.clientRect; popup = tippy("body", {
getReferenceClientRect: props.clientRect,
popup = document.createElement("div"); appendTo: () => document.body,
popup.style.zIndex = "9999"; content: component.element,
popup.style.position = "absolute"; showOnCreate: true,
popup.style.top = "0"; interactive: true,
popup.style.left = "0"; trigger: "manual",
placement: "bottom-start",
document.body.appendChild(popup); });
popup.appendChild(component.element);
cleanup = autoUpdate(
// @ts-ignore
{
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect();
},
},
popup,
updatePosition
);
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
@@ -80,15 +41,14 @@ const renderItems = () => {
return; return;
} }
// @ts-ignore popup &&
getReferenceClientRect = props.clientRect; popup[0].setProps({
updatePosition(); getReferenceClientRect: props.clientRect,
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key === "Escape") {
if (popup) { popup?.[0].hide();
popup.style.display = "none";
}
return true; return true;
} }
@@ -97,19 +57,12 @@ const renderItems = () => {
return component?.ref?.onKeyDown(props); return component?.ref?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
if (cleanup) { if (popup && !popup[0].state.isDestroyed) {
cleanup(); popup[0].destroy();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
} }
if (component) { if (component) {
component.destroy(); component.destroy();
component = null;
} }
}, },
}; };
@@ -1,11 +1,15 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import {
import { posToDOMRect, findParentNode } from "@tiptap/react"; BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react"; import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { sticky } from "tippy.js";
interface SubpagesMenuProps { interface SubpagesMenuProps {
editor: Editor; editor: Editor;
@@ -29,7 +33,7 @@ export const SubpagesMenu = React.memo(
return editor.isActive("subpages"); return editor.isActive("subpages");
}, },
[editor] [editor],
); );
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
@@ -58,8 +62,18 @@ export const SubpagesMenu = React.memo(
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`subpages-menu`} pluginKey={`subpages-menu}`}
updateDelay={0} updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<Tooltip position="top" label={t("Delete")}> <Tooltip position="top" label={t("Delete")}>
@@ -75,7 +89,7 @@ export const SubpagesMenu = React.memo(
</Tooltip> </Tooltip>
</BaseBubbleMenu> </BaseBubbleMenu>
); );
} },
); );
export default SubpagesMenu; export default SubpagesMenu;
@@ -19,7 +19,6 @@ export default function SubpagesView(props: NodeViewProps) {
const { spaceSlug, shareId } = useParams(); const { spaceSlug, shareId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
//@ts-ignore
const currentPageId = editor.storage.pageId; const currentPageId = editor.storage.pageId;
// Get subpages from shared tree if we're in a shared context // Get subpages from shared tree if we're in a shared context
@@ -1,4 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -15,7 +17,6 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color"; import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment"; import { TableTextAlignment } from "./table-text-alignment";
import { BubbleMenu } from "@tiptap/react/menus";
export const TableCellMenu = React.memo( export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => { ({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@@ -28,7 +29,7 @@ export const TableCellMenu = React.memo(
return isCellSelection(state.selection); return isCellSelection(state.selection);
}, },
[editor] [editor],
); );
const mergeCells = useCallback(() => { const mergeCells = useCallback(() => {
@@ -52,27 +53,23 @@ export const TableCellMenu = React.memo(
}, [editor]); }, [editor]);
return ( return (
<BubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey="table-cell-menu" pluginKey="table-cell-menu"
updateDelay={0} updateDelay={0}
appendTo={() => { tippyOptions={{
return appendTo?.current; appendTo: () => {
}} return appendTo?.current;
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
offset: {
mainAxis: 15,
}, },
offset: [0, 15],
zIndex: 99,
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<ActionIcon.Group> <ActionIcon.Group>
<TableBackgroundColor editor={editor} /> <TableBackgroundColor editor={editor} />
<TableTextAlignment editor={editor} /> <TableTextAlignment editor={editor} />
<Tooltip position="top" label={t("Merge cells")}> <Tooltip position="top" label={t("Merge cells")}>
<ActionIcon <ActionIcon
onClick={mergeCells} onClick={mergeCells}
@@ -128,9 +125,9 @@ export const TableCellMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
</BubbleMenu> </BaseBubbleMenu>
); );
} },
); );
export default TableCellMenu; export default TableCellMenu;
@@ -1,6 +1,11 @@
import { posToDOMRect, findParentNode } from "@tiptap/react"; import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -12,12 +17,9 @@ import {
IconColumnRemove, IconColumnRemove,
IconRowInsertBottom, IconRowInsertBottom,
IconRowInsertTop, IconRowInsertTop,
IconRowRemove, IconRowRemove, IconTableColumn, IconTableRow,
IconTableColumn,
IconTableRow,
IconTrashX, IconTrashX,
} from "@tabler/icons-react"; } from '@tabler/icons-react';
import { BubbleMenu } from "@tiptap/react/menus";
import { isCellSelection } from "@docmost/editor-ext"; import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -32,28 +34,20 @@ export const TableMenu = React.memo(
return editor.isActive("table") && !isCellSelection(state.selection); return editor.isActive("table") && !isCellSelection(state.selection);
}, },
[editor] [editor],
); );
const getReferencedVirtualElement = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table"; const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const rect = dom.getBoundingClientRect(); return dom.getBoundingClientRect();
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
} }
const rect = posToDOMRect(editor.view, selection.from, selection.to); return posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
}, [editor]); }, [editor]);
const toggleHeaderColumn = useCallback(() => { const toggleHeaderColumn = useCallback(() => {
@@ -93,33 +87,42 @@ export const TableMenu = React.memo(
}, [editor]); }, [editor]);
return ( return (
<BubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey="table-menu" pluginKey="table-menu"
resizeDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} tippyOptions={{
ref={(element) => { getReferenceClientRect: getReferenceClientRect,
element.style.zIndex = "99"; offset: [0, 15],
}} zIndex: 99,
options={{ popperOptions: {
placement: "top", modifiers: [
offset: { {
mainAxis: 15, name: "preventOverflow",
}, enabled: true,
flip: { options: {
fallbackPlacements: ["top", "bottom"], altAxis: true,
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity }, boundary: "clippingParents",
boundary: editor.options.element as HTMLElement, padding: 8,
}, },
shift: { },
padding: 8 + 15, {
crossAxis: true, name: "flip",
enabled: true,
options: {
boundary: editor.options.element,
fallbackPlacements: ["top", "bottom"],
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
},
},
],
}, },
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<ActionIcon.Group> <ActionIcon.Group>
<Tooltip position="top" label={t("Add left column")}> <Tooltip position="top" label={t("Add left column")}
>
<ActionIcon <ActionIcon
onClick={addColumnLeft} onClick={addColumnLeft}
variant="default" variant="default"
@@ -185,7 +188,8 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Toggle header row")}> <Tooltip position="top" label={t("Toggle header row")}
>
<ActionIcon <ActionIcon
onClick={toggleHeaderRow} onClick={toggleHeaderRow}
variant="default" variant="default"
@@ -196,7 +200,8 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Toggle header column")}> <Tooltip position="top" label={t("Toggle header column")}
>
<ActionIcon <ActionIcon
onClick={toggleHeaderColumn} onClick={toggleHeaderColumn}
variant="default" variant="default"
@@ -219,9 +224,9 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
</BubbleMenu> </BaseBubbleMenu>
); );
} },
); );
export default TableMenu; export default TableMenu;
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import {
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
export function VideoMenu({ editor }: EditorMenuProps) { export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video");
},
[editor],
);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -37,37 +52,17 @@ export function VideoMenu({ editor }: EditorMenuProps) {
}, },
}); });
const shouldShow = useCallback( const getReferenceClientRect = useCallback(() => {
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video") && editor.getAttributes("video").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video"; const predicate = (node: PMNode) => node.type.name === "video";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect(); return dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
const domRect = posToDOMRect(editor.view, selection.from, selection.to); return posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const alignVideoLeft = useCallback(() => { const alignVideoLeft = useCallback(() => {
@@ -110,11 +105,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`video-menu`} pluginKey={`video-menu`}
updateDelay={0} updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement} tippyOptions={{
options={{ getReferenceClientRect,
placement: "top", offset: [0, 8],
offset: 8, zIndex: 99,
flip: false, popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -1,33 +0,0 @@
.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,75 +1,29 @@
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 { t } = useTranslation(); const { node, selected } = props;
const { editor, node, selected } = props; const { src, width, align } = node.attrs;
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>
<div <video
className={clsx( preload="metadata"
selected && "ProseMirror-selectednode", width={width}
classes.videoWrapper, controls
alignClass, src={getFileUrl(src)}
)} className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
style={{ style={{ display: "block" }}
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>
); );
} }
@@ -1,7 +1,11 @@
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Placeholder } from "@tiptap/extension-placeholder";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list"; import { CharacterCount } from "@tiptap/extension-character-count";
import { Placeholder, CharacterCount } from "@tiptap/extensions"; import { TaskList } from "@tiptap/extension-task-list";
import { ListKeymap } from "@tiptap/extension-list-keymap";
import { TaskItem } from "@tiptap/extension-task-item";
import { Underline } from "@tiptap/extension-underline";
import { Superscript } from "@tiptap/extension-superscript"; import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript"; import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography"; import { Typography } from "@tiptap/extension-typography";
@@ -11,7 +15,7 @@ import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube"; import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand from "@/features/editor/extensions/slash-command"; import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration"; import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret"; import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
import { HocuspocusProvider } from "@hocuspocus/provider"; import { HocuspocusProvider } from "@hocuspocus/provider";
import { import {
Comment, Comment,
@@ -37,12 +41,11 @@ import {
Embed, Embed,
SearchAndReplace, SearchAndReplace,
Mention, Mention,
TableDndExtension,
Subpages, Subpages,
TableDndExtension,
Heading, Heading,
Highlight, Highlight,
UniqueID, UniqueID,
SharedStorage,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -94,9 +97,7 @@ lowlight.register("scala", scala);
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
heading: false, heading: false,
undoRedo: false, history: false,
link: false,
trailingNode: false,
dropcursor: { dropcursor: {
width: 3, width: 3,
color: "#70CFF8", color: "#70CFF8",
@@ -108,7 +109,6 @@ export const mainExtensions = [
}, },
}, },
}), }),
SharedStorage,
Heading, Heading,
UniqueID.configure({ UniqueID.configure({
types: ["heading", "paragraph"], types: ["heading", "paragraph"],
@@ -134,6 +134,8 @@ export const mainExtensions = [
TaskItem.configure({ TaskItem.configure({
nested: true, nested: true,
}), }),
ListKeymap,
Underline,
LinkExtension.configure({ LinkExtension.configure({
openOnClick: false, openOnClick: false,
}), }),
@@ -168,9 +170,6 @@ export const mainExtensions = [
}, },
}).extend({ }).extend({
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView); return ReactNodeViewRenderer(MentionView);
}, },
}), }),
@@ -209,7 +208,6 @@ export const mainExtensions = [
}), }),
CustomCodeBlock.configure({ CustomCodeBlock.configure({
view: CodeBlockView, view: CodeBlockView,
//@ts-ignore
lowlight, lowlight,
HTMLAttributes: { HTMLAttributes: {
spellcheck: false, spellcheck: false,
@@ -248,7 +246,7 @@ export const mainExtensions = [
Escape: () => { Escape: () => {
const event = new CustomEvent("closeFindDialogFromEditor", {}); const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event); document.dispatchEvent(event);
return false; return true;
}, },
}; };
}, },
@@ -260,9 +258,8 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
export const collabExtensions: CollabExtensions = (provider, user) => [ export const collabExtensions: CollabExtensions = (provider, user) => [
Collaboration.configure({ Collaboration.configure({
document: provider.document, document: provider.document,
provider,
}), }),
CollaborationCaret.configure({ CollaborationCursor.configure({
provider, provider,
user: { user: {
name: user.name, name: user.name,
+113 -101
View File
@@ -1,22 +1,13 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { IndexeddbPersistence } from "y-indexeddb"; import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs"; import * as Y from "yjs";
import { import {
HocuspocusProvider, HocuspocusProvider,
onStatusParameters, onAuthenticationFailedParameters,
WebSocketStatus, WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
} from "@hocuspocus/provider"; } from "@hocuspocus/provider";
import { import {
Editor,
EditorContent, EditorContent,
EditorProvider, EditorProvider,
useEditor, useEditor,
@@ -78,140 +69,161 @@ export default function PageEditor({
editable, editable,
content, content,
}: PageEditorProps) { }: PageEditorProps) {
const collaborationURL = useCollaborationUrl(); const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null); const editorCreated = useRef(false);
useEffect(() => { useEffect(() => {
isComponentMounted.current = true; isComponentMounted.current = true;
}, []); }, []);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom); const [, setEditor] = useAtom(pageEditorAtom);
const [, setAsideState] = useAtom(asideStateAtom); const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false); const ydocRef = useRef<Y.Doc | null>(null);
const [isRemoteSynced, setIsRemoteSynced] = useState(false); if (!ydocRef.current) {
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
); );
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility(); const documentState = useDocumentVisibility();
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
const userPageEditMode = const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current), const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll }); const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId // Providers only created once per pageId
const providersRef = useRef<{ const providersRef = useRef<{
local: IndexeddbPersistence; local: IndexeddbPersistence;
remote: HocuspocusProvider; remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null); } | null>(null);
const [providersReady, setProvidersReady] = useState(false); const [providersReady, setProvidersReady] = useState(false);
const localProvider = providersRef.current?.local;
const remoteProvider = providersRef.current?.remote;
// Track when collaborative provider is ready and synced
const [collabReady, setCollabReady] = useState(false);
useEffect(() => {
if (
remoteProvider?.status === WebSocketStatus.Connected &&
isLocalSynced &&
isRemoteSynced
) {
setCollabReady(true);
}
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
useEffect(() => { useEffect(() => {
if (!providersRef.current) { if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc); const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({ local.on("synced", () => setLocalSynced(true));
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onAuthenticationFailedHandler = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
}
};
const remote = new HocuspocusProvider({ const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName, name: documentName,
url: collaborationURL,
document: ydoc, document: ydoc,
token: collabQuery?.token, token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler, connect: true,
onStatus: onStatusHandler, preserveConnection: false,
onSynced: onSyncedHandler, onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
remote.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
remote.connect();
}, 100);
}
});
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
}); });
remote.on("synced", () => setRemoteSynced(true));
local.on("synced", onLocalSyncedHandler); remote.on("disconnect", () => {
providersRef.current = { socket, local, remote }; setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
providersRef.current = { local, remote };
setProvidersReady(true); setProvidersReady(true);
} else { } else {
setProvidersReady(true); setProvidersReady(true);
} }
// Only destroy on final unmount // Only destroy on final unmount
return () => { return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy(); providersRef.current?.remote.destroy();
providersRef.current?.local.destroy(); providersRef.current?.local.destroy();
providersRef.current = null; providersRef.current = null;
}; };
}, [pageId]); }, [pageId]);
/*
useEffect(() => {
// Handle token updates by reconnecting with new token
if (providersRef.current?.remote && collabQuery?.token) {
const currentToken = providersRef.current.remote.configuration.token;
if (currentToken !== collabQuery.token) {
// Token has changed, need to reconnect with new token
providersRef.current.remote.disconnect();
providersRef.current.remote.configuration.token = collabQuery.token;
providersRef.current.remote.connect();
}
}
}, [collabQuery?.token]);
*/
// Only connect/disconnect on tab/idle, not destroy // Only connect/disconnect on tab/idle, not destroy
useEffect(() => { useEffect(() => {
if (!providersReady || !providersRef.current) return; if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket; const remoteProvider = providersRef.current.remote;
if ( if (
isIdle && isIdle &&
documentState === "hidden" && documentState === "hidden" &&
yjsConnectionStatus === WebSocketStatus.Connected remoteProvider.status === WebSocketStatus.Connected
) { ) {
socket.disconnect(); remoteProvider.disconnect();
setIsCollabReady(false);
return; return;
} }
if ( if (
documentState === "visible" && documentState === "visible" &&
yjsConnectionStatus === WebSocketStatus.Disconnected remoteProvider.status === WebSocketStatus.Disconnected
) { ) {
resetIdle(); resetIdle();
socket.connect(); remoteProvider.connect();
setTimeout(() => setIsCollabReady(true), 500);
} }
}, [isIdle, documentState, providersReady, resetIdle]); }, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
const extensions = useMemo(() => { const extensions = useMemo(() => {
if (!providersReady || !providersRef.current || !currentUser?.user) { if (!remoteProvider || !currentUser?.user) return mainExtensions;
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
return [ return [
...mainExtensions, ...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user), ...collabExtensions(remoteProvider, currentUser?.user),
]; ];
}, [providersReady, currentUser?.user]); }, [remoteProvider, currentUser?.user]);
const editor = useEditor( const editor = useEditor(
{ {
@@ -254,30 +266,18 @@ export default function PageEditor({
} }
}, },
}, },
handlePaste: (_view, event) => { handlePaste: (view, event, slice) =>
if (!editorRef.current) return false; handlePaste(view, event, pageId, currentUser?.user.id),
handleDrop: (view, event, _slice, moved) =>
return handlePaste( handleFileDrop(view, event, moved, pageId),
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) {
// @ts-ignore // @ts-ignore
setEditor(editor); setEditor(editor);
// @ts-ignore
editor.storage.pageId = pageId; editor.storage.pageId = pageId;
handleScrollTo(editor); handleScrollTo(editor);
editorRef.current = editor; editorCreated.current = true;
} }
}, },
onUpdate({ editor }) { onUpdate({ editor }) {
@@ -287,7 +287,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson); debouncedUpdateContent(editorJson);
}, },
}, },
[pageId, editable, extensions], [pageId, editable, remoteProvider],
); );
const editorIsEditable = useEditorState({ const editorIsEditable = useEditorState({
@@ -343,17 +343,30 @@ export default function PageEditor({
setAsideState({ tab: "", isAsideOpen: false }); setAsideState({ tab: "", isAsideOpen: false });
}, [pageId]); }, [pageId]);
useEffect(() => {
if (remoteProvider?.status === WebSocketStatus.Connecting) {
const timeout = setTimeout(() => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
}, 5000);
return () => clearTimeout(timeout);
}
}, [remoteProvider?.status]);
const isSynced = isLocalSynced && isRemoteSynced; const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => { const collabReadyTimeout = setTimeout(() => {
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) { if (
setYjsConnectionStatus(WebSocketStatus.Disconnected); !isCollabReady &&
isSynced &&
remoteProvider?.status === WebSocketStatus.Connected
) {
setIsCollabReady(true);
} }
}, 7500); }, 500);
return () => clearTimeout(collabReadyTimeout);
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
return () => clearTimeout(timeout);
}, [yjsConnectionStatus, isSynced]);
useEffect(() => { useEffect(() => {
// Only honor user default page edit mode preference and permissions // Only honor user default page edit mode preference and permissions
if (editor) { if (editor) {
@@ -375,13 +388,12 @@ export default function PageEditor({
useEffect(() => { useEffect(() => {
if ( if (
!hasConnectedOnceRef.current && !hasConnectedOnceRef.current &&
yjsConnectionStatus === WebSocketStatus.Connected && remoteProvider?.status === WebSocketStatus.Connected
isSynced
) { ) {
hasConnectedOnceRef.current = true; hasConnectedOnceRef.current = true;
setShowStatic(false); setShowStatic(false);
} }
}, [yjsConnectionStatus, isSynced]); }, [remoteProvider?.status]);
if (showStatic) { if (showStatic) {
return ( return (
@@ -81,7 +81,6 @@ export default function ReadonlyPageEditor({
onCreate={({ editor }) => { onCreate={({ editor }) => {
if (editor) { if (editor) {
if (pageId) { if (pageId) {
// @ts-ignore
editor.storage.pageId = pageId; editor.storage.pageId = pageId;
} }
// @ts-ignore // @ts-ignore
@@ -1,5 +1,5 @@
/* Give a remote user a caret */ /* Give a remote user a caret */
.collaboration-carets__caret { .collaboration-cursor__caret {
border-left: 1px solid #0d0d0d; border-left: 1px solid #0d0d0d;
border-right: 1px solid #0d0d0d; border-right: 1px solid #0d0d0d;
margin-left: -1px; margin-left: -1px;
@@ -10,7 +10,7 @@
} }
/* Render the username above the caret */ /* Render the username above the caret */
.collaboration-carets__label { .collaboration-cursor__label {
border-radius: 3px 3px 3px 0; border-radius: 3px 3px 3px 0;
color: #0d0d0d; color: #0d0d0d;
font-size: 0.75rem; font-size: 0.75rem;
@@ -1,7 +1,5 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { IFileTask } from "@/features/file-task/types/file-task.types.ts"; import { IFileTask } from "@/features/file-task/types/file-task.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IApiKey } from "@/ee/api-key";
export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> { export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
const req = await api.post<IFileTask>("/file-tasks/info", { const req = await api.post<IFileTask>("/file-tasks/info", {
@@ -10,10 +8,7 @@ export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
return req.data; return req.data;
} }
export async function getFileTasks( export async function getFileTasks(): Promise<IFileTask[]> {
params?: QueryParams, const req = await api.post<IFileTask[]>("/file-tasks");
): Promise<IPagination<IFileTask>> {
const req = await api.post("/file-tasks", { ...params });
return req.data; return req.data;
} }
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { zodResolver } from 'mantine-form-zod-resolver'; import { zodResolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({ const formSchema = z.object({
name: z.string().trim().min(2).max(100), name: z.string().trim().min(2).max(50),
description: z.string().max(500), description: z.string().max(500),
}); });
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import { zodResolver } from "mantine-form-zod-resolver"; import { zodResolver } from "mantine-form-zod-resolver";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(100), name: z.string().min(2).max(50),
description: z.string().max(500), description: z.string().max(500),
}); });
@@ -10,7 +10,6 @@ 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,10 +50,10 @@ export default function GroupList() {
> >
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<IconGroupCircle /> <IconGroupCircle />
<div style={{ minWidth: 0, overflow: "hidden" }}> <div>
<AutoTooltipText fz="sm" fw={500} lineClamp={1}> <Text fz="sm" fw={500} lineClamp={1}>
{group.name} {group.name}
</AutoTooltipText> </Text>
<Text fz="xs" c="dimmed" lineClamp={2}> <Text fz="xs" c="dimmed" lineClamp={2}>
{group.description} {group.description}
</Text> </Text>
@@ -1,9 +1,8 @@
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;
@@ -27,9 +26,7 @@ export function HistoryEditor({ title, content }: HistoryEditorProps) {
<div> <div>
<Title order={1}>{title}</Title> <Title order={1}>{title}</Title>
{editor && ( {editor && <EditorContent editor={editor} />}
<EditorContent editor={editor} className={classes.historyEditor} />
)}
</div> </div>
</> </>
); );
@@ -67,7 +67,7 @@ function HistoryList({ pageId }: Props) {
mainEditorTitle mainEditorTitle
.chain() .chain()
.clearContent() .clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true }) .setContent(activeHistoryData.title, true)
.run(); .run();
mainEditor mainEditor
.chain() .chain()
@@ -1,49 +1,37 @@
.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( background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
var(--mantine-color-gray-2), }
var(--mantine-color-dark-8)
);
}
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
}
} }
.active { .active {
background-color: light-dark( background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
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,17 +7,22 @@ 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, { useEffect, useRef, useState } from "react"; import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom, useAtomValue } from "jotai"; import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks"; import {
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";
@@ -29,12 +34,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 { formattedDate } from "@/lib/time.ts"; import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.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";
@@ -46,6 +51,7 @@ 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(
[ [
@@ -62,7 +68,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const event = new CustomEvent("closeFindDialogFromEditor", {}); const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event); document.dispatchEvent(event);
}, },
{ preventDefault: false },
], ],
], ],
[], [],
@@ -70,7 +75,17 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
return ( return (
<> <>
<ConnectionWarning /> {yjsConnectionStatus === "disconnected" && (
<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" />}
@@ -131,15 +146,6 @@ 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();
@@ -177,13 +183,6 @@ 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} />}>
@@ -291,51 +290,3 @@ 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>
);
}
@@ -172,10 +172,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
queryKey: ["root-sidebar-pages", fileTask.spaceId], queryKey: ["root-sidebar-pages", fileTask.spaceId],
}); });
await queryClient.invalidateQueries({
queryKey: ["recent-changes", fileTask.spaceId],
});
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "refetchRootTreeNodeEvent", operation: "refetchRootTreeNodeEvent",
@@ -163,6 +163,9 @@ export function useDeletePageMutation() {
export function useMovePageMutation() { export function useMovePageMutation() {
return useMutation<void, Error, IMovePage>({ return useMutation<void, Error, IMovePage>({
mutationFn: (data) => movePage(data), mutationFn: (data) => movePage(data),
onSuccess: () => {
invalidateOnMovePage();
},
}); });
} }
@@ -455,127 +458,17 @@ export function invalidateOnUpdatePage(
}); });
} }
export function updateCacheOnMovePage( export function invalidateOnMovePage() {
spaceId: string, //for move invalidate all sidebars for now (how to do???)
pageId: string, //invalidate all root sidebar pages
oldParentId: string | null, queryClient.invalidateQueries({
newParentId: string | null, queryKey: ["root-sidebar-pages"],
pageData: Partial<IPage>, });
) { //invalidate all sub sidebar pages
// Remove page from old parent's cache queryClient.invalidateQueries({
const oldQueryKey = queryKey: ["sidebar-pages"],
oldParentId === null });
? ["root-sidebar-pages", spaceId] // ---
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
oldQueryKey,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((item) => item.id !== pageId),
})),
};
},
);
// Update old parent's hasChildren flag if it has no more children
if (oldParentId !== null) {
const oldParentCache = queryClient.getQueryData<
InfiniteData<IPagination<IPage>>
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
const remainingChildren =
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
if (remainingChildren === 0) {
// Update hasChildren in all caches where old parent appears
const allSideBarMatches = queryClient.getQueriesData({
predicate: (query) =>
query.queryKey[0] === "root-sidebar-pages" ||
query.queryKey[0] === "sidebar-pages",
});
allSideBarMatches.forEach(([key]) => {
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
key,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((item) =>
item.id === oldParentId
? { ...item, hasChildren: false }
: item,
),
})),
};
},
);
});
}
}
// Add page to new parent's cache
const newQueryKey =
newParentId === null
? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: newParentId, spaceId }];
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
newQueryKey,
(old) => {
if (!old) return old;
// Check if page already exists in new location
const exists = old.pages.some((page) =>
page.items.some((item) => item.id === pageId),
);
if (exists) return old;
return {
...old,
pages: old.pages.map((page, index) => {
if (index === old.pages.length - 1) {
return {
...page,
items: [...page.items, pageData],
};
}
return page;
}),
};
},
);
// Update new parent's hasChildren flag
if (newParentId !== null) {
const allSideBarMatches = queryClient.getQueriesData({
predicate: (query) =>
query.queryKey[0] === "root-sidebar-pages" ||
query.queryKey[0] === "sidebar-pages",
});
allSideBarMatches.forEach(([key]) => {
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((item) =>
item.id === newParentId ? { ...item, hasChildren: true } : item,
),
})),
};
});
});
}
} }
export function invalidateOnDeletePage(pageId: string) { export function invalidateOnDeletePage(pageId: string) {
@@ -118,14 +118,7 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
.split("filename=")[1] .split("filename=")[1]
.replace(/"/g, ""); .replace(/"/g, "");
let decodedFileName = fileName; saveAs(req.data, decodeURIComponent(fileName));
try {
decodedFileName = decodeURIComponent(fileName);
} catch (err) {
// fallback to raw filename
}
saveAs(req.data, decodedFileName);
} }
export async function importPage(file: File, spaceId: string) { export async function importPage(file: File, spaceId: string) {
@@ -269,15 +269,12 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const prefetchPage = () => { const prefetchPage = () => {
timerRef.current = setTimeout(async () => { timerRef.current = setTimeout(() => {
const page = await queryClient.fetchQuery({ queryClient.prefetchQuery({
queryKey: ["pages", node.data.id], queryKey: ["pages", node.data.slugId],
queryFn: () => getPageById({ pageId: node.data.id }), queryFn: () => getPageById({ pageId: node.data.slugId }),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
if (page?.slugId) {
queryClient.setQueryData(["pages", page.slugId], page);
}
}, 150); }, 150);
}; };
@@ -16,7 +16,6 @@ import {
useRemovePageMutation, useRemovePageMutation,
useMovePageMutation, useMovePageMutation,
useUpdatePageMutation, useUpdatePageMutation,
updateCacheOnMovePage,
} from "@/features/page/queries/page-query.ts"; } from "@/features/page/queries/page-query.ts";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
@@ -176,25 +175,9 @@ export function useTreeMutation<T>(spaceId: string) {
parentPageId: args.parentId, parentPageId: args.parentId,
}; };
const draggedNode = args.dragNodes[0];
const nodeData = draggedNode.data as SpaceTreeNode;
const oldParentId = nodeData.parentPageId ?? null;
const pageData = {
id: nodeData.id,
slugId: nodeData.slugId,
title: nodeData.name,
icon: nodeData.icon,
position: newPosition,
spaceId: nodeData.spaceId,
parentPageId: args.parentId,
hasChildren: nodeData.hasChildren,
};
try { try {
await movePageMutation.mutateAsync(payload); await movePageMutation.mutateAsync(payload);
updateCacheOnMovePage(spaceId, draggedNodeId, oldParentId, args.parentId, pageData);
setTimeout(() => { setTimeout(() => {
emit({ emit({
operation: "moveTreeNode", operation: "moveTreeNode",
@@ -202,10 +185,8 @@ export function useTreeMutation<T>(spaceId: string) {
payload: { payload: {
id: draggedNodeId, id: draggedNodeId,
parentId: args.parentId, parentId: args.parentId,
oldParentId,
index: args.index, index: args.index,
position: newPosition, position: newPosition,
pageData,
}, },
}); });
}, 50); }, 50);
@@ -8,6 +8,7 @@ import {
Switch, Switch,
Text, Text,
TextInput, TextInput,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react"; import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
@@ -20,12 +21,12 @@ import {
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { extractPageSlugId, getPageIcon } from "@/lib"; import { extractPageSlugId, getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import CopyTextButton from "@/components/common/copy.tsx"; import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl, isCloud } from "@/lib/config.ts"; import { getAppUrl, isCloud } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css"; import classes from "@/features/share/components/share.module.css";
import useTrial from "@/ee/hooks/use-trial.tsx"; import useTrial from "@/ee/hooks/use-trial.tsx";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
interface ShareModalProps { interface ShareModalProps {
readOnly: boolean; readOnly: boolean;
@@ -34,9 +35,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug); const pageId = extractPageSlugId(pageSlug);
const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id;
const { data: share } = useShareForPageQuery(pageId); const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { isTrial } = useTrial(); const { isTrial } = useTrial();
@@ -27,7 +27,9 @@ import {
getShares, getShares,
updateShare, updateShare,
} from "@/features/share/services/share-service.ts"; } from "@/features/share/services/share-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
import { useEffect } from "react";
export function useGetSharesQuery( export function useGetSharesQuery(
params?: QueryParams, params?: QueryParams,
@@ -70,7 +72,7 @@ export function useShareForPageQuery(
queryKey: ["share-for-page", pageId], queryKey: ["share-for-page", pageId],
queryFn: () => getShareForPage(pageId), queryFn: () => getShareForPage(pageId),
enabled: !!pageId, enabled: !!pageId,
staleTime: 60 * 1000, staleTime: 0,
retry: false, retry: false,
}); });
@@ -1,7 +1,6 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core"; import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useForm } from "@mantine/form"; import { useForm, zodResolver } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import * as z from "zod"; import * as z from "zod";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts"; import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
@@ -10,12 +9,12 @@ import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().trim().min(2).max(100), name: z.string().trim().min(2).max(50),
slug: z slug: z
.string() .string()
.trim() .trim()
.min(2) .min(2)
.max(100) .max(50)
.regex( .regex(
/^[a-zA-Z0-9]+$/, /^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters", "Space slug must be alphanumeric. No special characters",
@@ -6,7 +6,6 @@ import { ISpace } from "../types/space.types";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route"; import APP_ROUTE from "@/lib/app-route";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useState } from "react";
interface DeleteSpaceModalProps { interface DeleteSpaceModalProps {
space: ISpace; space: ISpace;
@@ -15,7 +14,6 @@ interface DeleteSpaceModalProps {
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) { export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [isDeleting, setIsDeleting] = useState(false);
const deleteSpaceMutation = useDeleteSpaceMutation(); const deleteSpaceMutation = useDeleteSpaceMutation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -37,15 +35,12 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
return; return;
} }
setIsDeleting(true);
try { try {
// pass slug too so we can clear the local cache // pass slug too so we can clear the local cache
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug }); await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
navigate(APP_ROUTE.HOME); navigate(APP_ROUTE.HOME);
} catch (error) { } catch (error) {
console.error("Failed to delete space", error); console.error("Failed to delete space", error);
} finally {
setIsDeleting(false);
} }
}; };
@@ -84,7 +79,7 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
<Button onClick={close} variant="default"> <Button onClick={close} variant="default">
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button onClick={handleDelete} color="red" loading={isDeleting}> <Button onClick={handleDelete} color="red">
{t("Confirm")} {t("Confirm")}
</Button> </Button>
</Group> </Group>
@@ -7,12 +7,12 @@ import { ISpace } from "@/features/space/types/space.types.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(100), name: z.string().min(2).max(50),
description: z.string().max(500), description: z.string().max(250),
slug: z slug: z
.string() .string()
.min(2) .min(2)
.max(100) .max(50)
.regex( .regex(
/^[a-zA-Z0-9]+$/, /^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters", "Space slug must be alphanumeric. No special characters",
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { SpaceSelect } from "./space-select"; import { SpaceSelect } from "./space-select";
import { getSpaceUrl } from "@/lib/config"; import { getSpaceUrl } from "@/lib/config";
import { Button, Popover, Text } from "@mantine/core"; import { Button, Popover, Text } from "@mantine/core";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { IconChevronDown } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
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";
@@ -21,7 +21,7 @@ export function SwitchSpace({
spaceIcon, spaceIcon,
}: SwitchSpaceProps) { }: SwitchSpaceProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [opened, { close, toggle }] = useDisclosure(false); const [opened, { close, open, toggle }] = useDisclosure(false);
const handleSelect = (value: string) => { const handleSelect = (value: string) => {
if (value) { if (value) {
@@ -44,9 +44,9 @@ export function SwitchSpace({
variant="subtle" variant="subtle"
fullWidth fullWidth
justify="space-between" justify="space-between"
rightSection={opened ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />} rightSection={<IconChevronDown size={18} />}
color="gray" color="gray"
onClick={toggle} onClick={open}
> >
<CustomAvatar <CustomAvatar
name={spaceName} name={spaceName}
@@ -8,7 +8,6 @@ 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,10 +48,10 @@ export default function SpaceList() {
variant="filled" variant="filled"
name={space.name} name={space.name}
/> />
<div style={{ minWidth: 0, overflow: "hidden" }}> <div>
<AutoTooltipText fz="sm" fw={500} lineClamp={1}> <Text fz="sm" fw={500} lineClamp={1}>
{space.name} {space.name}
</AutoTooltipText> </Text>
<Text fz="xs" c="dimmed" lineClamp={2}> <Text fz="xs" c="dimmed" lineClamp={2}>
{space.description} {space.description}
</Text> </Text>
@@ -27,7 +27,6 @@ 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";
@@ -139,10 +138,10 @@ export default function SpaceMembersList({
{member.type === "group" && <IconGroupCircle />} {member.type === "group" && <IconGroupCircle />}
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 260 }}> <div>
<AutoTooltipText fz="sm" fw={500}> <Text fz="sm" fw={500} lineClamp={1}>
{member?.name} {member?.name}
</AutoTooltipText> </Text>
<Text fz="xs" c="dimmed"> <Text fz="xs" c="dimmed">
{member.type == "user" && member?.email} {member.type == "user" && member?.email}
@@ -23,7 +23,6 @@ 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[];
@@ -97,10 +96,10 @@ export default function AllSpacesList({
variant="filled" variant="filled"
size="md" size="md"
/> />
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 350 }}> <div>
<AutoTooltipText fz="sm" fw={500} lineClamp={1}> <Text fz="sm" fw={500} lineClamp={1}>
{space.name} {space.name}
</AutoTooltipText> </Text>
{space.description && ( {space.description && (
<Text fz="xs" c="dimmed" lineClamp={2}> <Text fz="xs" c="dimmed" lineClamp={2}>
{space.description} {space.description}
@@ -69,12 +69,5 @@ export async function exportSpace(data: IExportSpaceParams): Promise<void> {
.split("filename=")[1] .split("filename=")[1]
.replace(/"/g, ""); .replace(/"/g, "");
let decodedFileName = fileName; saveAs(req.data, decodeURIComponent(fileName));
try {
decodedFileName = decodeURIComponent(fileName);
} catch (err) {
// fallback to raw filename
}
saveAs(req.data, decodedFileName);
} }
@@ -45,10 +45,8 @@ export type MoveTreeNodeEvent = {
payload: { payload: {
id: string; id: string;
parentId: string; parentId: string;
oldParentId: string | null;
index: number; index: number;
position: string; position: string;
pageData: Partial<IPage>;
}; };
}; };
@@ -8,7 +8,7 @@ import { IPagination } from "@/lib/types";
import { import {
invalidateOnCreatePage, invalidateOnCreatePage,
invalidateOnDeletePage, invalidateOnDeletePage,
updateCacheOnMovePage, invalidateOnMovePage,
invalidateOnUpdatePage, invalidateOnUpdatePage,
} from "../page/queries/page-query"; } from "../page/queries/page-query";
import { RQ_KEY } from "../comment/queries/comment-query"; import { RQ_KEY } from "../comment/queries/comment-query";
@@ -41,13 +41,7 @@ export const useQuerySubscription = () => {
invalidateOnCreatePage(data.payload.data); invalidateOnCreatePage(data.payload.data);
break; break;
case "moveTreeNode": case "moveTreeNode":
updateCacheOnMovePage( invalidateOnMovePage();
data.spaceId,
data.payload.id,
data.payload.oldParentId,
data.payload.parentId,
data.payload.pageData,
);
break; break;
case "deleteTreeNode": case "deleteTreeNode":
invalidateOnDeletePage(data.payload.node.id); invalidateOnDeletePage(data.payload.node.id);
-34
View File
@@ -1,34 +0,0 @@
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];
}
+27 -33
View File
@@ -30,83 +30,76 @@
"test:e2e": "jest --config test/jest-e2e.json" "test:e2e": "jest --config test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/google": "^3.0.9", "@ai-sdk/azure": "^2.0.47",
"@ai-sdk/openai": "^3.0.11", "@ai-sdk/google": "^2.0.18",
"@ai-sdk/openai-compatible": "^2.0.12", "@ai-sdk/openai": "^2.0.46",
"@aws-sdk/client-s3": "3.701.0", "@aws-sdk/client-s3": "3.701.0",
"@aws-sdk/lib-storage": "3.701.0", "@aws-sdk/lib-storage": "3.701.0",
"@aws-sdk/s3-request-presigner": "3.701.0", "@aws-sdk/s3-request-presigner": "3.701.0",
"@casl/ability": "^6.7.3",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.3.0", "@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.3.0", "@fastify/static": "^8.2.0",
"@langchain/core": "1.1.13", "@langchain/textsplitters": "^0.1.0",
"@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.11", "@nestjs/common": "^11.1.9",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.11", "@nestjs/core": "^11.1.9",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0", "@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.11", "@nestjs/platform-fastify": "^11.1.9",
"@nestjs/platform-socket.io": "^11.1.11", "@nestjs/platform-socket.io": "^11.1.9",
"@nestjs/schedule": "^6.1.0", "@nestjs/schedule": "^6.0.1",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.11", "@nestjs/websockets": "^11.1.9",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28", "@react-email/components": "0.0.28",
"@react-email/render": "1.0.2", "@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.37", "ai": "^5.0.65",
"ai-sdk-ollama": "^3.1.1", "ai-sdk-ollama": "^0.12.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.65.0", "bullmq": "^5.65.0",
"cache-manager": "^6.4.3", "cache-manager": "^6.4.3",
"cheerio": "^1.1.2", "cheerio": "^1.1.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"cookie": "^1.1.1", "cookie": "^1.0.2",
"fs-extra": "^11.3.3", "fs-extra": "^11.3.0",
"happy-dom": "20.1.0", "happy-dom": "20.0.10",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.2",
"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",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"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.11",
"nodemailer": "^7.0.12",
"openid-client": "^5.7.1", "openid-client": "^5.7.1",
"otpauth": "^9.4.1", "otpauth": "^9.4.0",
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
"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",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2", "sanitize-filename-ts": "1.0.2",
"sharp": "0.34.3", "sharp": "0.34.3",
"socket.io": "^4.8.3", "socket.io": "^4.8.1",
"stripe": "^17.5.0", "stripe": "^17.5.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"tseep": "^1.3.1",
"typesense": "^2.1.0", "typesense": "^2.1.0",
"ws": "^8.19.0", "ws": "^8.18.3",
"yauzl": "^3.2.0" "yauzl": "^3.2.0"
}, },
"devDependencies": { "devDependencies": {
@@ -123,6 +116,7 @@
"@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",
@@ -130,7 +124,7 @@
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0", "globals": "^15.15.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"kysely-codegen": "^0.19.0", "kysely-codegen": "^0.17.0",
"prettier": "^3.5.1", "prettier": "^3.5.1",
"react-email": "3.0.2", "react-email": "3.0.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
-2
View File
@@ -18,7 +18,6 @@ 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 {
@@ -36,7 +35,6 @@ try {
@Module({ @Module({
imports: [ imports: [
LoggerModule,
CoreModule, CoreModule,
DatabaseModule, DatabaseModule,
EnvironmentModule, EnvironmentModule,
@@ -30,20 +30,12 @@ export class CollabWsAdapter {
return this.wss; return this.wss;
} }
public close() {
try {
this.wss.close();
} catch (err) {
console.error(err);
}
}
public destroy() { public destroy() {
try { try {
this.wss.close();
this.wss.clients.forEach((client) => { this.wss.clients.forEach((client) => {
client.terminate(); client.terminate();
}); });
this.wss.close();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
@@ -1,9 +1,10 @@
import { Hocuspocus } from '@hocuspocus/server'; import { Hocuspocus, Server as HocuspocusServer } from '@hocuspocus/server';
import { IncomingMessage } from 'http'; import { IncomingMessage } from 'http';
import WebSocket from 'ws'; import WebSocket from 'ws';
import { AuthenticationExtension } from './extensions/authentication.extension'; import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension'; import { PersistenceExtension } from './extensions/persistence.extension';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Redis } from '@hocuspocus/extension-redis';
import { EnvironmentService } from '../integrations/environment/environment.service'; import { EnvironmentService } from '../integrations/environment/environment.service';
import { import {
createRetryStrategy, createRetryStrategy,
@@ -11,41 +12,21 @@ import {
RedisConfig, RedisConfig,
} from '../common/helpers'; } from '../common/helpers';
import { LoggerExtension } from './extensions/logger.extension'; import { LoggerExtension } from './extensions/logger.extension';
import {
RedisSyncExtension,
SerializedHTTPRequest,
} from './extensions/redis-sync';
import { WsSocketWrapper } from './extensions/redis-sync/ws-socket-wrapper';
import RedisClient from 'ioredis';
import { pack, unpack } from 'msgpackr';
import { nanoid } from 'nanoid';
import * as os from 'node:os';
import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import {
CollaborationHandler,
CollabEventHandlers,
} from './collaboration.handler';
@Injectable() @Injectable()
export class CollaborationGateway { export class CollaborationGateway {
private readonly hocuspocus: Hocuspocus; private hocuspocus: Hocuspocus;
private redisConfig: RedisConfig; private redisConfig: RedisConfig;
// @ts-ignore
private readonly redisSync: RedisSyncExtension<CollabEventHandlers> | null =
null;
private readonly withRedis: boolean;
constructor( constructor(
private authenticationExtension: AuthenticationExtension, private authenticationExtension: AuthenticationExtension,
private persistenceExtension: PersistenceExtension, private persistenceExtension: PersistenceExtension,
private loggerExtension: LoggerExtension, private loggerExtension: LoggerExtension,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private collabEventsService: CollaborationHandler,
) { ) {
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl()); this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
this.withRedis = !this.environmentService.isCollabDisableRedis();
this.hocuspocus = new Hocuspocus({ this.hocuspocus = HocuspocusServer.configure({
debounce: 10000, debounce: 10000,
maxDebounce: 45000, maxDebounce: 45000,
unloadImmediately: false, unloadImmediately: false,
@@ -53,80 +34,26 @@ export class CollaborationGateway {
this.authenticationExtension, this.authenticationExtension,
this.persistenceExtension, this.persistenceExtension,
this.loggerExtension, this.loggerExtension,
...(this.environmentService.isCollabDisableRedis()
? []
: [
new Redis({
host: this.redisConfig.host,
port: this.redisConfig.port,
options: {
password: this.redisConfig.password,
db: this.redisConfig.db,
family: this.redisConfig.family,
retryStrategy: createRetryStrategy(),
},
}),
]),
], ],
}); });
if (this.withRedis) {
// @ts-ignore
this.redisSync = new RedisSyncExtension({
redis: new RedisClient({
host: this.redisConfig.host,
port: this.redisConfig.port,
password: this.redisConfig.password,
db: this.redisConfig.db,
family: this.redisConfig.family,
retryStrategy: createRetryStrategy(),
}),
serverId: `collab-${os?.hostname()}-${nanoid(10)}`,
prefix: 'collab',
pack,
unpack,
// @ts-ignore
customEvents: this.collabEventsService.getHandlers(this.hocuspocus),
});
this.hocuspocus.configuration.extensions.push(this.redisSync);
// @ts-ignore
this.redisSync.onConfigure({ instance: this.hocuspocus });
}
}
private serializeRequest(request: IncomingMessage): SerializedHTTPRequest {
return {
method: request.method ?? 'GET',
url: request.url ?? '/',
headers: {
'sec-websocket-key': request.headers['sec-websocket-key'] ?? '',
'sec-websocket-protocol':
request.headers['sec-websocket-protocol'] ?? '',
},
socket: { remoteAddress: request.socket?.remoteAddress ?? '' },
};
} }
handleConnection(client: WebSocket, request: IncomingMessage): any { handleConnection(client: WebSocket, request: IncomingMessage): any {
if (this.redisSync) { this.hocuspocus.handleConnection(client, request);
const serializedHTTPRequest = this.serializeRequest(request);
const socketId = serializedHTTPRequest.headers['sec-websocket-key'];
// Create wrapper socket that only receives events via emit()
// This prevents double-handling since Hocuspocus won't listen to raw WebSocket events
const wrappedSocket = new WsSocketWrapper(client);
// Route through RedisSync extension (this calls handleConnection internally)
this.redisSync.onSocketOpen(wrappedSocket as any, serializedHTTPRequest);
// Forward raw WebSocket messages to the extension
client.on('message', (data: ArrayBuffer) => {
this.redisSync!.onSocketMessage(
wrappedSocket as any,
serializedHTTPRequest,
data,
);
});
// Forward close events
client.on('close', (code: number, reason: Buffer) => {
this.redisSync!.onSocketClose(socketId, code, reason);
});
// Forward pong events for keepalive
client.on('pong', (data: Buffer) => {
wrappedSocket.emit('pong', data);
});
} else {
// Fallback to direct Hocuspocus connection
this.hocuspocus.handleConnection(client, request);
}
} }
getConnectionCount() { getConnectionCount() {
@@ -137,52 +64,7 @@ export class CollaborationGateway {
return this.hocuspocus.getDocumentsCount(); return this.hocuspocus.getDocumentsCount();
} }
handleYjsEvent<TName extends keyof CollabEventHandlers>( async destroy(): Promise<void> {
eventName: TName, await this.hocuspocus.destroy();
documentName: string,
payload: Parameters<CollabEventHandlers[TName]>[1],
) {
return this.redisSync?.handleEvent(eventName, documentName, payload);
}
openDirectConnection(documentName: string, context?: any) {
return this.hocuspocus.openDirectConnection(documentName, context);
}
/*
*Can be used before calling openDirectConnection directly
*/
async lockDocument(documentName: string) {
return this.redisSync.lockDocument(documentName);
}
/*
*Releases a document lock and stops the interval that maintains it.
*/
async releaseLock(documentName: string) {
return this.redisSync.releaseLock(documentName);
}
async destroy(collabWsAdapter: CollabWsAdapter): Promise<void> {
// eslint-disable-next-line no-async-promise-executor
await new Promise(async (resolve) => {
try {
// Wait for all documents to unload
this.hocuspocus.configuration.extensions.push({
async afterUnloadDocument({ instance }) {
if (instance.getDocumentsCount() === 0) resolve('');
},
});
collabWsAdapter?.close();
if (this.hocuspocus.getDocumentsCount() === 0) resolve('');
this.hocuspocus.closeConnections();
} catch (error) {
console.error(error);
}
});
await this.hocuspocus.hooks('onDestroy', { instance: this.hocuspocus });
} }
} }
@@ -1,42 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Hocuspocus, Document } from '@hocuspocus/server';
export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers']
>;
@Injectable()
export class CollaborationHandler {
private readonly logger = new Logger(CollaborationHandler.name);
constructor() {}
getHandlers(hocuspocus: Hocuspocus) {
return {
alterState: async (documentName: string, payload: { pageId: string }) => {
// dummy
// this.logger.log('Processing', documentName, payload);
// await this.withYdocConnection(hocuspocus, documentName, {}, (doc) => {
// const fragment = doc.getXmlFragment('default');
//});
},
};
}
async withYdocConnection(
hocuspocus: Hocuspocus,
documentName: string,
context: any = {},
fn: (doc: Document) => void,
): Promise<void> {
const connection = await hocuspocus.openDirectConnection(
documentName,
context,
);
try {
await connection.transact(fn);
} finally {
await connection.disconnect();
}
}
}
@@ -9,7 +9,6 @@ import { WebSocket } from 'ws';
import { TokenModule } from '../core/auth/token.module'; import { TokenModule } from '../core/auth/token.module';
import { HistoryListener } from './listeners/history.listener'; import { HistoryListener } from './listeners/history.listener';
import { LoggerExtension } from './extensions/logger.extension'; import { LoggerExtension } from './extensions/logger.extension';
import { CollaborationHandler } from './collaboration.handler';
@Module({ @Module({
providers: [ providers: [
@@ -18,7 +17,6 @@ import { CollaborationHandler } from './collaboration.handler';
PersistenceExtension, PersistenceExtension,
LoggerExtension, LoggerExtension,
HistoryListener, HistoryListener,
CollaborationHandler,
], ],
exports: [CollaborationGateway], exports: [CollaborationGateway],
imports: [TokenModule], imports: [TokenModule],
@@ -48,12 +46,16 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
}); });
wss.on('error', (error) => wss.on('error', (error) =>
this.logger.error('WebSocket server error:', error), this.logger.log('WebSocket server error:', error),
); );
} }
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
await this.collaborationGateway?.destroy(this.collabWsAdapter); if (this.collaborationGateway) {
this.collabWsAdapter?.destroy(); await this.collaborationGateway.destroy();
}
if (this.collabWsAdapter) {
this.collabWsAdapter.destroy();
}
} }
} }
@@ -1,12 +1,14 @@
import { StarterKit } from '@tiptap/starter-kit'; import { StarterKit } from '@tiptap/starter-kit';
import { TextAlign } from '@tiptap/extension-text-align'; import { TextAlign } from '@tiptap/extension-text-align';
import { TaskList } from '@tiptap/extension-task-list';
import { TaskItem } from '@tiptap/extension-task-item';
import { Underline } from '@tiptap/extension-underline';
import { Superscript } from '@tiptap/extension-superscript'; import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript'; import SubScript from '@tiptap/extension-subscript';
import { Typography } from '@tiptap/extension-typography'; import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style'; import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color'; import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube'; import { Youtube } from '@tiptap/extension-youtube';
import { TaskList, TaskItem } from '@tiptap/extension-list';
import { import {
Heading, Heading,
Callout, Callout,
@@ -40,15 +42,11 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML // @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352 // see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
//import { generateJSON } from '@tiptap/html'; import { Node } from '@tiptap/pm/model';
import { Node, Schema } from '@tiptap/pm/model';
import { Logger } from '@nestjs/common';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
codeBlock: false, codeBlock: false,
link: false,
trailingNode: false,
heading: false, heading: false,
}), }),
Heading, Heading,
@@ -61,6 +59,7 @@ export const tiptapExtensions = [
TaskItem.configure({ TaskItem.configure({
nested: true, nested: true,
}), }),
Underline,
LinkExtension, LinkExtension,
Superscript, Superscript,
SubScript, SubScript,
@@ -111,53 +110,9 @@ export function jsonToText(tiptapJson: JSONContent) {
} }
export function jsonToNode(tiptapJson: JSONContent) { export function jsonToNode(tiptapJson: JSONContent) {
const schema = getSchema(tiptapExtensions); return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson);
try {
return Node.fromJSON(schema, tiptapJson);
} catch (error) {
if (
error instanceof RangeError &&
error.message.includes('Unknown node type')
) {
Logger.warn('Stripping unknown node types from document:', error.message);
const cleanedJson = stripUnknownNodes(tiptapJson, schema);
return Node.fromJSON(schema, cleanedJson);
}
throw error;
}
} }
export function getPageId(documentName: string) { export function getPageId(documentName: string) {
return documentName.split('.')[1]; return documentName.split('.')[1];
} }
function stripUnknownNodes(
json: JSONContent,
schema: Schema,
): JSONContent | null {
if (!json || typeof json !== 'object') return json;
// Recursively clean children first, flattening any unwrapped content
if (json.content && Array.isArray(json.content)) {
const newContent: JSONContent[] = [];
for (const child of json.content) {
const cleaned = stripUnknownNodes(child, schema);
if (Array.isArray(cleaned)) {
newContent.push(...cleaned);
} else if (cleaned) {
newContent.push(cleaned);
}
}
json.content = newContent;
}
// Check if this node is unknown AFTER processing children
if (json.type && !schema.nodes[json.type]) {
// Unwrap: return cleaned children directly instead of wrapping
return (
json.content && json.content.length > 0 ? json.content : null
) as any;
}
return json;
}
@@ -9,6 +9,7 @@ import { TokenService } from '../../core/auth/services/token.service';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils'; import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceRole } from '../../common/helpers/types/permission'; import { SpaceRole } from '../../common/helpers/types/permission';
import { getPageId } from '../collaboration.util'; import { getPageId } from '../collaboration.util';
@@ -23,6 +24,7 @@ export class AuthenticationExtension implements Extension {
private userRepo: UserRepo, private userRepo: UserRepo,
private pageRepo: PageRepo, private pageRepo: PageRepo,
private readonly spaceMemberRepo: SpaceMemberRepo, private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {} ) {}
async onAuthenticate(data: onAuthenticatePayload) { async onAuthenticate(data: onAuthenticatePayload) {
@@ -68,9 +70,31 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
if (userSpaceRole === SpaceRole.READER) { // Check page-level permissions
data.connectionConfig.readOnly = true; const { hasRestriction, canAccess, canEdit } =
this.logger.debug(`User granted readonly access to page: ${pageId}`); await this.pagePermissionRepo.getUserPageAccessLevel(user.id, page.id);
if (hasRestriction) {
// Page has restrictions - use page-level permissions
if (!canAccess) {
this.logger.warn(
`User ${user.id} denied page-level access to page: ${pageId}`,
);
throw new UnauthorizedException();
}
if (!canEdit) {
data.connection.readOnly = true;
this.logger.debug(
`User ${user.id} granted readonly access to restricted page: ${pageId}`,
);
}
} else {
// No restrictions - use space-level permissions
if (userSpaceRole === SpaceRole.READER) {
data.connection.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
} }
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`); this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
@@ -9,11 +9,11 @@ import { Injectable, Logger } from '@nestjs/common';
export class LoggerExtension implements Extension { export class LoggerExtension implements Extension {
private readonly logger = new Logger('Collab' + LoggerExtension.name); private readonly logger = new Logger('Collab' + LoggerExtension.name);
async onDisconnect(data: onDisconnectPayload) {
this.logger.debug(`User disconnected from "${data.documentName}".`);
}
async afterUnloadDocument(data: onLoadDocumentPayload) { async afterUnloadDocument(data: onLoadDocumentPayload) {
this.logger.debug('Unloaded ' + data.documentName + ' from memory'); this.logger.debug('Unloaded ' + data.documentName + ' from memory');
} }
async onDisconnect(data: onDisconnectPayload) {
this.logger.debug('User disconnected from ' + data.documentName);
}
} }
@@ -1,70 +0,0 @@
import type RedisClient from 'ioredis';
import { EventEmitter } from 'tseep';
import type {
Pack,
RSAMessageClose,
RSAMessagePing,
RSAMessageSend,
} from './redis-sync.types';
export class CollabProxySocket extends EventEmitter {
private readonly replyTo: string;
private readonly serverChannel: string;
private readonly socketId: string;
private pub: RedisClient;
private readonly pack: Pack;
readyState = 1;
constructor(
pub: RedisClient,
pack: Pack,
replyTo: string,
serverChannel: string,
socketId: string,
) {
super();
this.replyTo = replyTo;
this.socketId = socketId;
this.serverChannel = serverChannel;
this.pub = pub;
this.pack = pack;
this.once('close', () => {
this.readyState = 3;
});
}
private publish(msg: RSAMessageClose | RSAMessagePing | RSAMessageSend) {
this.pub.publish(this.replyTo, this.pack(msg));
}
close(code?: number, reason?: string) {
if (this.readyState !== 1) return;
const msg: RSAMessageClose = {
type: 'close',
code,
reason,
socketId: this.socketId,
};
this.publish(msg);
}
ping() {
if (this.readyState !== 1) return;
const msg: RSAMessagePing = {
type: 'ping',
socketId: this.socketId,
replyTo: this.serverChannel,
};
this.publish(msg);
}
send(message: Uint8Array) {
if (this.readyState !== 1) return;
const msg: RSAMessageSend = {
type: 'send',
socketId: this.socketId,
message,
};
this.publish(msg);
}
}
@@ -1,2 +0,0 @@
export * from './redis-sync.extension';
export type { SerializedHTTPRequest } from './redis-sync.extension';
@@ -1,378 +0,0 @@
// Source https://github.com/ueberdosis/hocuspocus/pull/1008 - MIT
import {
Extension,
Hocuspocus,
IncomingMessage,
afterUnloadDocumentPayload,
onConfigurePayload,
onLoadDocumentPayload,
} from '@hocuspocus/server';
import RedisClient from 'ioredis';
import { readVarString } from 'lib0/decoding.js';
import { CollabProxySocket } from './collab-proxy-socket';
import {
BaseWebSocket,
Configuration,
CustomEvents,
Pack,
RSAMessage,
RSAMessageCloseProxy,
RSAMessageCustomEventComplete,
RSAMessageCustomEventStart,
RSAMessagePong,
RSAMessageProxy,
RSAMessageUnload,
SerializedHTTPRequest,
Unpack,
} from './redis-sync.types';
export type { Pack, SerializedHTTPRequest } from './redis-sync.types';
type ServerId = string;
type DocumentName = string;
type SocketId = string;
export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
priority = 1000;
private readonly pub: RedisClient;
private sub: RedisClient;
private readonly pack: Pack;
private readonly unpack: Unpack;
private originSockets: Record<SocketId, BaseWebSocket> = {};
private locks: Record<DocumentName, NodeJS.Timeout> = {};
private lockPromises: Record<DocumentName, Promise<ServerId | null>> = {};
private proxySockets: Record<SocketId, CollabProxySocket> = {};
private readonly prefix: string;
private readonly lockPrefix: string;
private readonly msgChannel: string;
private readonly serverId: ServerId;
private readonly customEventTTL: number;
private readonly lockTTL: number;
private instance!: Hocuspocus;
private readonly customEvents: TCE;
private replyIdCounter: number = 0;
// @ts-ignore
private pendingReplies: Record<number, PromiseWithResolvers<any>['resolve']> =
{};
constructor(configuration: Configuration<TCE>) {
const {
redis,
pack,
unpack,
serverId,
lockTTL,
prefix,
customEvents,
customEventTTL,
} = configuration;
this.pub = redis.duplicate();
this.sub = redis.duplicate();
this.pack = pack;
this.unpack = unpack;
this.serverId = serverId;
this.lockTTL = lockTTL ?? 10_000;
this.customEventTTL = customEventTTL ?? 30_000;
this.prefix = prefix ?? 'collab';
this.lockPrefix = `${this.prefix}Lock`;
this.msgChannel = `${this.prefix}Msg`;
this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents);
this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`);
this.sub.on('messageBuffer', this.handleRedisMessage);
this.pub.on('error', () => {});
this.sub.on('error', () => {});
}
private getKey(documentName: string) {
return `${this.lockPrefix}:${documentName}`;
}
private closeProxy(socketId: string) {
const proxySocket = this.proxySockets[socketId];
if (proxySocket) {
proxySocket.emit(
'close',
1000,
Buffer.from('provider_initiated', 'utf-8'),
);
delete this.proxySockets[socketId];
}
}
private pongProxy(socketId: string) {
this.proxySockets[socketId]?.emit('pong');
}
private handleProxyMessage(
msg: Pick<RSAMessageProxy, 'replyTo' | 'message' | 'serializedHTTPRequest'>,
) {
const { replyTo, message, serializedHTTPRequest } = msg;
const { headers } = serializedHTTPRequest;
const socketId = headers['sec-websocket-key']!;
let socket = this.proxySockets[socketId];
if (!socket) {
socket = new CollabProxySocket(
this.pub,
this.pack,
replyTo,
`${this.msgChannel}:${this.serverId}`,
socketId,
);
this.proxySockets[socketId] = socket;
this.instance.handleConnection(
socket as any,
serializedHTTPRequest as any,
{},
);
}
socket.emit('message', message);
}
private getOrClaimLock(documentName: string) {
const lockPromise = this.pub.set(
this.getKey(documentName),
this.serverId,
'PX',
this.lockTTL,
'NX',
'GET',
);
this.lockPromises[documentName] = lockPromise;
// Briefly cache the serverId that claimed the doc to reduce load on redis
// When the claimant unloads the doc, it will send an unload message to immediately clear this
// a lockTTL / 2 guarantees stale reads < lockTTL upon server crash
setTimeout(() => {
delete this.lockPromises[documentName];
}, this.lockTTL / 2);
return lockPromise;
}
private getOrClaimLockThrottled(documentName: string) {
const existingWorkerIdPromise = this.lockPromises[documentName];
if (existingWorkerIdPromise) return existingWorkerIdPromise;
return this.getOrClaimLock(documentName);
}
private handleRedisMessage = async (
_channel: Buffer,
packedMessage: Buffer,
) => {
const msg = this.unpack(packedMessage) as RSAMessage;
const { type } = msg;
if (type === 'proxy') {
this.handleProxyMessage(msg);
return;
}
if (type === 'closeProxy') {
this.closeProxy(msg.socketId);
return;
}
if (type === 'pong') {
this.pongProxy(msg.socketId);
return;
}
if (type === 'unload') {
delete this.lockPromises[msg.documentName];
return;
}
if (type === 'customEventStart') {
const { documentName, eventName, payload, replyTo, replyId } = msg;
const res = await this.handleEventLocally(
eventName as Extract<keyof TCE, string>,
documentName,
payload,
);
const reply: RSAMessageCustomEventComplete = {
type: 'customEventComplete',
replyId,
payload: res,
};
this.pub.publish(`${replyTo}`, this.pack(reply));
return;
}
if (type === 'customEventComplete') {
const { replyId, payload } = msg;
const resolveFn = this.pendingReplies[replyId];
if (!resolveFn) return;
delete this.pendingReplies[replyId];
resolveFn(payload);
return;
}
const { socketId } = msg;
const socket = this.originSockets[socketId];
if (!socket) {
// origin socket already cleaned up
return;
}
if (type === 'close') {
socket.close(msg.code, msg.reason);
} else if (type === 'ping') {
// Reply instantly to the proxy socket, without forwarding to client
// The origin socket handles heartbeat for itself
const { replyTo, socketId } = msg;
const reply: RSAMessagePong = {
type: 'pong',
socketId,
};
this.pub.publish(`${replyTo}`, this.pack(reply));
} else if (type === 'send') {
socket.send(msg.message);
}
};
async maintainLock(documentName: string) {
this.locks[documentName] = setInterval(() => {
this.pub.set(
this.getKey(documentName),
this.serverId,
'PX',
this.lockTTL,
);
}, this.lockTTL / 2);
}
async releaseLock(documentName: string) {
clearInterval(this.locks[documentName]);
delete this.locks[documentName];
return this.pub.del(this.getKey(documentName));
}
private async handleEventLocally<TName extends Extract<keyof TCE, string>>(
eventName: TName,
documentName: string,
payload: any,
) {
const handler = this.customEvents[eventName];
if (!handler) throw new Error(`Invalid eventName: ${eventName}`);
const result = await handler(documentName, payload);
return result as Promise<ReturnType<TCE[TName]>>;
}
async handleEvent<TName extends Extract<keyof TCE, string>>(
eventName: TName,
documentName: string,
payload: any,
) {
const isDocLoadedOnInstance = this.instance.documents.has(documentName);
if (isDocLoadedOnInstance) {
return this.handleEventLocally(eventName, documentName, payload);
}
const proxyTo = await this.getOrClaimLockThrottled(documentName);
if (proxyTo && proxyTo !== this.serverId) {
++this.replyIdCounter; // bug in biome thinks this.replyIdCounter is not used if written on the line below
const replyId = this.replyIdCounter;
// another server owns the doc
const proxyMessage: RSAMessageCustomEventStart = {
eventName,
documentName,
payload,
replyTo: `${this.msgChannel}:${this.serverId}`,
replyId,
type: 'customEventStart',
};
const msg = this.pack(proxyMessage);
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
// @ts-ignore
const { promise, resolve, reject } = Promise.withResolvers();
this.pendingReplies[replyId] = resolve;
setTimeout(() => {
reject('TIMEOUT');
}, this.customEventTTL);
return promise as Promise<ReturnType<TCE[TName]>>;
}
// This server owns the document, but hocuspocus hasn't loaded it yet
return this.handleEventLocally(eventName, documentName, payload);
}
async lockDocument(documentName: string) {
const proxyTo = await this.getOrClaimLockThrottled(documentName);
if (proxyTo && proxyTo !== this.serverId) {
throw new Error(`Could not lock document: ${documentName}`);
}
this.maintainLock(documentName);
return () => this.releaseLock(documentName);
}
/* WebSocket Server Hooks */
onSocketOpen(
ws: BaseWebSocket,
serializedHTTPRequest: SerializedHTTPRequest,
context = {},
) {
const socketId = serializedHTTPRequest.headers['sec-websocket-key']!;
this.originSockets[socketId] = ws;
this.instance.handleConnection(
ws as any,
serializedHTTPRequest as any,
context,
);
}
async onSocketMessage(
ws: BaseWebSocket,
serializedHTTPRequest: SerializedHTTPRequest,
detachableMsg: ArrayBuffer,
) {
const message = new Uint8Array(detachableMsg.slice());
const tmpMsg = new IncomingMessage(detachableMsg);
const documentName = readVarString(tmpMsg.decoder);
const isDocLoadedOnInstance = this.instance.documents.has(documentName);
if (isDocLoadedOnInstance) {
ws.emit('message', message);
return;
}
const proxyTo = await this.getOrClaimLockThrottled(documentName);
if (proxyTo && proxyTo !== this.serverId) {
// another server owns the doc
const proxyMessage: RSAMessageProxy = {
serializedHTTPRequest: serializedHTTPRequest,
replyTo: `${this.msgChannel}:${this.serverId}`,
message,
type: 'proxy',
};
const msg = this.pack(proxyMessage);
this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg);
return;
}
// This server owns the document, but hocuspocus hasn't loaded it yet
ws.emit('message', message);
}
onSocketClose(socketId: string, code?: number, reason?: ArrayBuffer) {
const socket = this.originSockets[socketId];
if (!socket) return;
// at this point the socket is considered GC'd and we cannot call close
// The origin socket did not set up any connections for the proxy, so none of the hooks will work if we just emit
socket?.emit('close', code, reason);
delete this.originSockets[socketId];
const msg: RSAMessageCloseProxy = { type: 'closeProxy', socketId };
this.pub.publish(this.msgChannel, this.pack(msg)).catch(() => {});
}
/* Hocuspocus hooks */
async onConfigure({ instance }: onConfigurePayload) {
this.instance = instance;
}
async onLoadDocument(data: onLoadDocumentPayload) {
const { documentName } = data;
// Refresh the lock TTL
this.maintainLock(documentName);
}
async afterUnloadDocument(data: afterUnloadDocumentPayload) {
const { documentName } = data;
this.releaseLock(documentName);
// Broadcast to cluster to immediately remove the cached redis value
const msg: RSAMessageUnload = { type: 'unload', documentName };
this.pub.publish(this.msgChannel, this.pack(msg));
}
async onDestroy() {
this.pub.disconnect(false);
this.sub.disconnect(false);
}
}
@@ -1,121 +0,0 @@
import EventEmitter from 'node:events';
import { IncomingHttpHeaders } from 'node:http2';
import RedisClient from 'ioredis';
export type SecondParam<T> = T extends (
arg1: unknown,
arg2: infer A,
...args: unknown[]
) => unknown
? A
: never;
export type SerializedHTTPRequest = {
method: string;
url: string;
headers: IncomingHttpHeaders;
socket: { remoteAddress: string };
};
export type RSAMessageProxy = {
type: 'proxy';
replyTo: string;
message: Uint8Array<ArrayBufferLike>;
serializedHTTPRequest: SerializedHTTPRequest;
};
export type RSAMessageCloseProxy = {
type: 'closeProxy';
socketId: string;
};
export type RSAMessageUnload = {
type: 'unload';
documentName: string;
};
export type RSAMessageClose = {
type: 'close';
code?: number;
reason?: string;
socketId: string;
};
export type RSAMessagePing = {
type: 'ping';
socketId: string;
replyTo: string;
};
export type RSAMessagePong = {
type: 'pong';
socketId: string;
};
export type RSAMessageSend = {
type: 'send';
// @ts-ignore
message: Uint8Array<ArrayBufferLike>;
socketId: string;
};
export type RSAMessageCustomEventStart<TName = string, TPayload = unknown> = {
type: 'customEventStart';
documentName: string;
eventName: TName;
payload: TPayload;
replyTo: string;
replyId: number;
};
export type RSAMessageCustomEventComplete = {
type: 'customEventComplete';
replyId: number;
payload: unknown;
};
export type RSAMessage =
| RSAMessageProxy
| RSAMessageCloseProxy
| RSAMessageUnload
| RSAMessageClose
| RSAMessagePing
| RSAMessagePong
| RSAMessageSend
| RSAMessageCustomEventStart
| RSAMessageCustomEventComplete;
// @ts-ignore
export type Pack = (msg: RSAMessage) => string | Buffer<ArrayBufferLike>;
export type Unpack = (
// @ts-ignore
packedMessage: Uint8Array | Buffer<ArrayBufferLike>,
) => RSAMessage;
type ServerId = string;
type DocumentName = string;
type CustomEventName = string;
export type CustomEvents = Record<
CustomEventName,
(documentName: string, payload: unknown) => Promise<unknown>
>;
export interface Configuration<TCE> {
redis: RedisClient;
pack: Pack;
unpack: Unpack;
serverId: ServerId;
lockTTL?: number;
customEventTTL?: number;
prefix?: string;
customEvents?: TCE;
}
export type BaseWebSocket = EventEmitter & {
readyState: number;
close(code?: number, reason?: string): void;
ping(): void;
send(message: Uint8Array): void;
};
@@ -1,47 +0,0 @@
import { EventEmitter } from 'events';
import type WebSocket from 'ws';
/**
* Wrapper around ws WebSocket that only receives events via emit().
* This prevents double-handling when used with RedisSyncExtension.
*/
export class WsSocketWrapper extends EventEmitter {
private ws: WebSocket;
readyState = 1;
constructor(ws: WebSocket) {
super();
this.ws = ws;
this.once('close', () => {
this.readyState = 3;
});
}
close(code?: number, reason?: string) {
if (this.readyState !== 1) return;
this.readyState = 3;
try {
this.ws.close(code, reason);
} catch (e) {
// Socket already closed
}
}
ping() {
if (this.readyState !== 1) return;
try {
this.ws.ping();
} catch (e) {
// Socket already closed
}
}
send(message: Uint8Array) {
if (this.readyState !== 1) return;
try {
this.ws.send(message);
} catch (e) {
// Socket already closed
}
}
}
@@ -8,11 +8,9 @@ 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,27 +5,22 @@ 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>(
CollabAppModule, CollabAppModule,
new FastifyAdapter({ new FastifyAdapter({
routerOptions: { ignoreTrailingSlash: true,
maxParamLength: 1000, ignoreDuplicateSlashes: true,
ignoreTrailingSlash: true, maxParamLength: 500,
ignoreDuplicateSlashes: true,
},
}), }),
{ {
logger: false, logger: new InternalLogFilter(),
bufferLogs: false,
}, },
); );
app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', { exclude: ['/'] }); app.setGlobalPrefix('api', { exclude: ['/'] });
app.enableCors(); app.enableCors();
@@ -1,71 +0,0 @@
// 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);
@@ -1,16 +0,0 @@
export type ExportPageMetadata = {
pageId: string;
slugId: string;
icon: string | null;
position: string;
parentPath: string | null;
createdAt: string;
updatedAt: string;
};
export type ExportMetadata = {
exportedAt: string;
source: 'docmost';
version: string;
pages: Record<string, ExportPageMetadata>;
};
@@ -14,3 +14,12 @@ export enum SpaceVisibility {
OPEN = 'open', // any workspace member can see that it exists and join. OPEN = 'open', // any workspace member can see that it exists and join.
PRIVATE = 'private', // only added space users can see PRIVATE = 'private', // only added space users can see
} }
export enum PageAccessLevel {
RESTRICTED = 'restricted', // only specific users/groups can view or edit
}
export enum PagePermissionRole {
READER = 'reader', // can only view content and descendants
WRITER = 'writer', // can edit content, descendants, and add new users to permission
}
-36
View File
@@ -2,7 +2,6 @@ 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');
@@ -99,38 +98,3 @@ 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 };
}
@@ -1,9 +0,0 @@
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 {}
@@ -1,84 +0,0 @@
import { Params } from 'nestjs-pino';
import { stdTimeFunctions } from 'pino';
const CONTEXTS_TO_IGNORE = [
'InstanceLoader',
'RoutesResolver',
'RouterExplorer',
'LegacyRouteConverter',
'WebSocketsController',
];
export function createPinoConfig(): Params {
const isProduction = process.env.NODE_ENV?.toLowerCase() === 'production';
const isDebugMode = process.env.DEBUG_MODE?.toLowerCase() === 'true';
const logHttp = process.env.LOG_HTTP?.toLowerCase() === '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 }),
},
hooks: {
logMethod(inputArgs, method) {
if (isProduction && !isDebugMode) {
for (const arg of inputArgs) {
if (typeof arg === 'object' && arg !== null && 'context' in arg) {
const context = (arg as Record<string, unknown>)['context'];
if (typeof context === 'string' && CONTEXTS_TO_IGNORE.includes(context)) {
return;
}
}
}
}
return method.apply(this, inputArgs);
},
},
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,
},
};
}
@@ -53,6 +53,7 @@ import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload'; import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path'; import * as path from 'path';
import { RemoveIconDto } from './dto/attachment.dto'; import { RemoveIconDto } from './dto/attachment.dto';
import { PageAccessService } from '../page-access/page-access.service';
@Controller() @Controller()
export class AttachmentController { export class AttachmentController {
@@ -67,6 +68,7 @@ export class AttachmentController {
private readonly attachmentRepo: AttachmentRepo, private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService, private readonly tokenService: TokenService,
private readonly pageAccessService: PageAccessService,
) {} ) {}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -111,13 +113,8 @@ export class AttachmentController {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
const spaceAbility = await this.spaceAbility.createForUser( // Checks both space-level and page-level edit permissions
user, await this.pageAccessService.validateCanEdit(page, user);
page.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceId = page.spaceId; const spaceId = page.spaceId;
@@ -171,19 +168,16 @@ export class AttachmentController {
throw new NotFoundException(); throw new NotFoundException();
} }
const spaceAbility = await this.spaceAbility.createForUser( const page = await this.pageRepo.findById(attachment.pageId);
user, if (!page) {
attachment.spaceId, throw new NotFoundException();
);
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
} }
// Checks both space-level and page-level view permissions
await this.pageAccessService.validateCanView(page, user);
try { try {
const fileStream = await this.storageService.readStream( const fileStream = await this.storageService.read(attachment.filePath);
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',
@@ -243,9 +237,7 @@ export class AttachmentController {
} }
try { try {
const fileStream = await this.storageService.readStream( const fileStream = await this.storageService.read(attachment.filePath);
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',
@@ -371,14 +363,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.readStream(filePath); const fileStream = await this.storageService.read(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,17 +5,15 @@ 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;
@@ -24,16 +22,10 @@ export async function prepareFile(
} }
try { try {
let buffer: Buffer | undefined; const buffer = await file.toBuffer();
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 {
@@ -42,7 +34,6 @@ export async function prepareFile(
fileSize, fileSize,
fileExtension, fileExtension,
mimeType: file.mimetype, mimeType: file.mimetype,
multiPartFile: file,
}; };
} catch (error) { } catch (error) {
throw error; throw error;

Some files were not shown because too many files have changed in this diff Show More