mirror of
https://github.com/docmost/docmost.git
synced 2026-05-08 07:13:06 +08:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87590af0a0 | |||
| 66f11f85ec | |||
| f40f4daa1a | |||
| 4d5e23cad2 | |||
| 38d0556ac3 | |||
| 3e93e57fbf | |||
| 4fc35cecc7 | |||
| 6aff7b84f2 | |||
| 0245a183e1 | |||
| 75673ad964 | |||
| 3157131bf2 | |||
| 793d61a13e | |||
| 81cceb483a | |||
| e755207c3b | |||
| 353ec2559a | |||
| f4a877081a | |||
| de5f71894a | |||
| 351b075ebb | |||
| c4bf0ac0b5 | |||
| 1ca7d42203 | |||
| 1e441560f6 | |||
| 54775f537d | |||
| 40626578b1 | |||
| 5dbf0027bd | |||
| 5588ec34fb | |||
| 55b8128829 | |||
| aa6a046aa6 | |||
| 657fdf8cb7 | |||
| 98f71c95fe | |||
| d65321f5e5 | |||
| c3488608a8 | |||
| 051bc80ab7 | |||
| 78d363febb | |||
| 8681d9a8c4 | |||
| b9543b01bd | |||
| 5510434221 | |||
| f671e7a3b9 | |||
| 974bcea690 | |||
| 601ed88931 | |||
| cfbaedcd63 | |||
| 5fc04aa7df | |||
| c357f169e1 | |||
| 1cbd2854bb | |||
| 3af1482a31 | |||
| d31d1f7bbd | |||
| cc0146d0cd | |||
| 83ce9cf240 | |||
| e7e85e9fdd | |||
| 2d710612b1 | |||
| a0814ef49a | |||
| bf17289ab2 | |||
| c2cd412ac7 | |||
| 71dfcf6bce | |||
| 23e8ab032e | |||
| 0e1d4e5eee | |||
| 6f83f32d5c | |||
| 63ea2f7663 | |||
| 66a3dad632 | |||
| 2adc6a60d2 |
@@ -24,7 +24,6 @@
|
||||
"@mantine/spotlight": "^8.3.12",
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"@tanstack/react-query": "^5.90.17",
|
||||
"@tiptap/extension-character-count": "^2.27.1",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -54,7 +53,6 @@
|
||||
"react-router-dom": "^7.12.0",
|
||||
"semver": "^7.7.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.",
|
||||
"Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.",
|
||||
"Confirm": "Bestätigen",
|
||||
"Copy as Markdown": "Als Markdown kopieren",
|
||||
"Copy link": "Link kopieren",
|
||||
"Create": "Erstellen",
|
||||
"Create group": "Gruppe erstellen",
|
||||
@@ -234,7 +235,9 @@
|
||||
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
|
||||
"Invite link": "Einladungslink",
|
||||
"Copy": "Kopieren",
|
||||
"Copy to space": "In Raum kopieren",
|
||||
"Copied": "Kopiert",
|
||||
"Duplicate": "Duplizieren",
|
||||
"Select a user": "Benutzer 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.",
|
||||
@@ -251,6 +254,7 @@
|
||||
"Export failed:": "Export fehlgeschlagen:",
|
||||
"export error": "Exportfehler",
|
||||
"Export page": "Seite exportieren",
|
||||
"Export successful": "Export erfolgreich",
|
||||
"Export space": "Bereich exportieren",
|
||||
"Export {{type}}": "Exportiere {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
|
||||
@@ -326,6 +330,8 @@
|
||||
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
|
||||
"Upload any 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.",
|
||||
"Uploading {{name}}": "Lade {{name}} hoch",
|
||||
"Uploading file": "Datei wird hochgeladen",
|
||||
"Table": "Tabelle",
|
||||
"Insert a table.": "Tabelle einfügen.",
|
||||
"Insert collapsible block.": "Einklappbaren Block einfügen.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
||||
"Choose your preferred page width.": "Choose your preferred page width.",
|
||||
"Confirm": "Confirm",
|
||||
"Copy as Markdown": "Copy as Markdown",
|
||||
"Copy link": "Copy link",
|
||||
"Create": "Create",
|
||||
"Create group": "Create group",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Export failed:",
|
||||
"export error": "export error",
|
||||
"Export page": "Export page",
|
||||
"Export successful": "Export successful",
|
||||
"Export space": "Export space",
|
||||
"Export {{type}}": "Export {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Upload any image from your device.",
|
||||
"Upload any video from your device.": "Upload any video from your device.",
|
||||
"Upload any file from your device.": "Upload any file from your device.",
|
||||
"Uploading {{name}}": "Uploading {{name}}",
|
||||
"Uploading file": "Uploading file",
|
||||
"Table": "Table",
|
||||
"Insert a table.": "Insert a table.",
|
||||
"Insert collapsible block.": "Insert collapsible block.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
|
||||
"Choose your preferred page width.": "Elige el ancho de página que prefieras.",
|
||||
"Confirm": "Confirmar",
|
||||
"Copy as Markdown": "Copiar como Markdown",
|
||||
"Copy link": "Copiar enlace",
|
||||
"Create": "Crear",
|
||||
"Create group": "Crear grupo",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Exportación fallida:",
|
||||
"export error": "error de exportación",
|
||||
"Export page": "Exportar página",
|
||||
"Export successful": "Exportación exitosa",
|
||||
"Export space": "Exportar espacio",
|
||||
"Export {{type}}": "Exportar {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
|
||||
"Upload any video from your device.": "Sube cualquier video 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",
|
||||
"Insert a table.": "Insertar una tabla.",
|
||||
"Insert collapsible block.": "Insertar bloque desplegable.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.",
|
||||
"Choose your preferred page width.": "Choisissez votre largeur de page préférée.",
|
||||
"Confirm": "Confirmer",
|
||||
"Copy as Markdown": "Copier comme Markdown",
|
||||
"Copy link": "Copier le lien",
|
||||
"Create": "Créer",
|
||||
"Create group": "Créer groupe",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Échec de l'exportation :",
|
||||
"export error": "exporter l'erreur",
|
||||
"Export page": "Exporter la page",
|
||||
"Export successful": "Exportation réussie",
|
||||
"Export space": "Exporter l'espace",
|
||||
"Export {{type}}": "Exporter {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
|
||||
"Upload any 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.",
|
||||
"Uploading {{name}}": "Téléchargement de {{name}}",
|
||||
"Uploading file": "Téléchargement du fichier",
|
||||
"Table": "Tableau",
|
||||
"Insert a table.": "Insérez un tableau.",
|
||||
"Insert collapsible block.": "Insérer un bloc repliable.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.",
|
||||
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
|
||||
"Confirm": "Conferma",
|
||||
"Copy as Markdown": "Copia come Markdown",
|
||||
"Copy link": "Copia link",
|
||||
"Create": "Crea",
|
||||
"Create group": "Crea gruppo",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Esportazione fallita:",
|
||||
"export error": "errore di esportazione",
|
||||
"Export page": "Esporta pagina",
|
||||
"Export successful": "Esportazione riuscita",
|
||||
"Export space": "Esporta spazio",
|
||||
"Export {{type}}": "Esporta {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
|
||||
"Upload any video from your device.": "Carica qualsiasi video 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",
|
||||
"Insert a table.": "Inserisci una tabella.",
|
||||
"Insert collapsible block.": "Inserisci blocco comprimibile.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "お好みの言語を選択してください",
|
||||
"Choose your preferred page width.": "お好みのページ幅を選択してください",
|
||||
"Confirm": "確認",
|
||||
"Copy as Markdown": "Markdownとしてコピー",
|
||||
"Copy link": "リンクをコピー",
|
||||
"Create": "新規作成",
|
||||
"Create group": "グループを作成",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "エクスポートに失敗しました:",
|
||||
"export error": "エクスポートエラー",
|
||||
"Export page": "エクスポートページ",
|
||||
"Export successful": "エクスポート成功",
|
||||
"Export space": "エクスポートスペース",
|
||||
"Export {{type}}": "{{type}}をエクスポート",
|
||||
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "デバイスから画像をアップロードします",
|
||||
"Upload any video from your device.": "デバイスから動画をアップロードします",
|
||||
"Upload any file from your device.": "デバイスからファイルをアップロードします",
|
||||
"Uploading {{name}}": "{{name}} をアップロード中",
|
||||
"Uploading file": "ファイルをアップロード中",
|
||||
"Table": "テーブル",
|
||||
"Insert a table.": "テーブルを挿入します",
|
||||
"Insert collapsible block.": "折りたたみブロックを挿入します",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
|
||||
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
|
||||
"Confirm": "확인",
|
||||
"Copy as Markdown": "Markdown으로 복사",
|
||||
"Copy link": "링크 복사",
|
||||
"Create": "생성",
|
||||
"Create group": "팀 생성",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "내보내기 실패:",
|
||||
"export error": "내보내기 오류",
|
||||
"Export page": "페이지 내보내기",
|
||||
"Export successful": "내보내기 성공",
|
||||
"Export space": "Space 내보내기",
|
||||
"Export {{type}}": "{{type}} 내보내기",
|
||||
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
|
||||
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
|
||||
"Upload any file from your device.": "기기에서 파일을 업로드하세요.",
|
||||
"Uploading {{name}}": "{{name}} 업로드 중",
|
||||
"Uploading file": "파일 업로드 중",
|
||||
"Table": "테이블",
|
||||
"Insert a table.": "테이블 삽입.",
|
||||
"Insert collapsible block.": "접을 수 있는 블록 삽입.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
|
||||
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
|
||||
"Confirm": "Bevestig",
|
||||
"Copy as Markdown": "Kopiëren als Markdown",
|
||||
"Copy link": "Link kopiëren",
|
||||
"Create": "Aanmaken",
|
||||
"Create group": "Groep aanmaken",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Exporteren mislukt:",
|
||||
"export error": "Exporteer fout",
|
||||
"Export page": "Exporteer pagina",
|
||||
"Export successful": "Export succesvol",
|
||||
"Export space": "Exporteer ruimte",
|
||||
"Export {{type}}": "Exporteer {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
|
||||
"Upload any video from your device.": "Upload een video 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",
|
||||
"Insert a table.": "Voeg een tabel in.",
|
||||
"Insert collapsible block.": "Inklapbaar blok invoegen.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Escolha o idioma da interface.",
|
||||
"Choose your preferred page width.": "Escolha a largura preferida da página.",
|
||||
"Confirm": "Confirmar",
|
||||
"Copy as Markdown": "Copiar como Markdown",
|
||||
"Copy link": "Copiar link",
|
||||
"Create": "Criar",
|
||||
"Create group": "Criar grupo",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Falha ao exportar:",
|
||||
"export error": "erro de exportação",
|
||||
"Export page": "Exportar página",
|
||||
"Export successful": "Exportação bem-sucedida",
|
||||
"Export space": "Exportar espaço",
|
||||
"Export {{type}}": "Exportar para {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
|
||||
"Upload any video from your device.": "Envie qualquer vídeo 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",
|
||||
"Insert a table.": "Insira uma tabela.",
|
||||
"Insert collapsible block.": "Insira um bloco colapsável.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
|
||||
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
|
||||
"Confirm": "Подтвердить",
|
||||
"Copy as Markdown": "Копировать как Markdown",
|
||||
"Copy link": "Копировать ссылку",
|
||||
"Create": "Создать",
|
||||
"Create group": "Создать группу",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Экспортирование не удалось:",
|
||||
"export error": "ошибка экспорта",
|
||||
"Export page": "Экспорт страницы",
|
||||
"Export successful": "Экспорт выполнен успешно",
|
||||
"Export space": "Экспорт пространства",
|
||||
"Export {{type}}": "Экспорт {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
|
||||
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
|
||||
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
|
||||
"Uploading {{name}}": "Загрузка {{name}}",
|
||||
"Uploading file": "Загрузка файла",
|
||||
"Table": "Таблица",
|
||||
"Insert a table.": "Вставить таблицу.",
|
||||
"Insert collapsible block.": "Вставить сворачиваемый блок.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
|
||||
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
|
||||
"Confirm": "Підтвердити",
|
||||
"Copy as Markdown": "Скопіювати як Markdown",
|
||||
"Copy link": "Копіювати посилання",
|
||||
"Create": "Створити",
|
||||
"Create group": "Створити групу",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "Експортування не вдалося:",
|
||||
"export error": "помилка експорту",
|
||||
"Export page": "Експорт сторінки",
|
||||
"Export successful": "Експорт виконано успішно",
|
||||
"Export space": "Експорт простору",
|
||||
"Export {{type}}": "Експорт {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
|
||||
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
|
||||
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
|
||||
"Uploading {{name}}": "Завантаження {{name}}",
|
||||
"Uploading file": "Завантаження файлу",
|
||||
"Table": "Таблиця",
|
||||
"Insert a table.": "Вставити таблицю.",
|
||||
"Insert collapsible block.": "Вставити блок, що згортається.",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Choose your preferred interface language.": "选择您喜欢的界面语言。",
|
||||
"Choose your preferred page width.": "选择您喜欢的页面宽度。",
|
||||
"Confirm": "确认",
|
||||
"Copy as Markdown": "复制为Markdown",
|
||||
"Copy link": "复制链接",
|
||||
"Create": "创建",
|
||||
"Create group": "创建群组",
|
||||
@@ -253,6 +254,7 @@
|
||||
"Export failed:": "导出失败:",
|
||||
"export error": "导出出错",
|
||||
"Export page": "导出页面",
|
||||
"Export successful": "导出成功",
|
||||
"Export space": "导出空间",
|
||||
"Export {{type}}": "导出为 {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
|
||||
@@ -328,6 +330,8 @@
|
||||
"Upload any image from your device.": "从设备上传任何图像",
|
||||
"Upload any video from your device.": "从设备上传任何视频",
|
||||
"Upload any file from your device.": "从设备上传任何文件",
|
||||
"Uploading {{name}}": "正在上传{{name}}",
|
||||
"Uploading file": "正在上传文件",
|
||||
"Table": "表格",
|
||||
"Insert a table.": "插入一个表格",
|
||||
"Insert collapsible block.": "插入一个折叠块",
|
||||
|
||||
@@ -30,9 +30,11 @@ export default function ExportModal({
|
||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
if (type === "page") {
|
||||
await exportPage({
|
||||
@@ -45,6 +47,9 @@ export default function ExportModal({
|
||||
if (type === "space") {
|
||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Export successful"),
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
@@ -52,6 +57,8 @@ export default function ExportModal({
|
||||
color: "red",
|
||||
});
|
||||
console.error("export error", err);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,7 +143,7 @@ export default function ExportModal({
|
||||
<Button onClick={onClose} variant="default">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleExport}>{t("Export")}</Button>
|
||||
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
|
||||
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
||||
import { useHover } from "@mantine/hooks";
|
||||
import { formatBytes } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function AttachmentView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected } = props;
|
||||
const { url, name, size } = node.attrs;
|
||||
const { hovered, ref } = useHover();
|
||||
@@ -20,26 +22,28 @@ export default function AttachmentView(props: NodeViewProps) {
|
||||
wrap="nowrap"
|
||||
h={25}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<IconPaperclip size={20} />
|
||||
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
|
||||
{url ? (
|
||||
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<Text component="span" size="md" truncate="end">
|
||||
{name}
|
||||
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
|
||||
{url ? name : t("Uploading {{name}}", { name })}
|
||||
</Text>
|
||||
|
||||
<Text component="span" size="sm" c="dimmed" inline>
|
||||
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{formatBytes(size)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{selected || hovered ? (
|
||||
{url && (selected || hovered) && (
|
||||
<a href={getFileUrl(url)} target="_blank">
|
||||
<ActionIcon variant="default" aria-label="download file">
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import {
|
||||
BubbleMenu,
|
||||
BubbleMenuProps,
|
||||
isNodeSelection,
|
||||
useEditor,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
|
||||
import { isNodeSelection, useEditorState } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { FC, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
IconBold,
|
||||
@@ -38,7 +34,7 @@ export interface BubbleMenuItem {
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
editor: Editor | null;
|
||||
};
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
@@ -133,14 +129,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
}
|
||||
return isTextSelected(editor);
|
||||
},
|
||||
tippyOptions: {
|
||||
moveTransition: "transform 0.15s ease-out",
|
||||
onCreate: (instance) => {
|
||||
instance.popper.firstChild?.addEventListener("blur", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
});
|
||||
},
|
||||
options: {
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
onHide: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
@@ -156,7 +147,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<BubbleMenu {...bubbleMenuProps}>
|
||||
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
|
||||
<div className={classes.bubbleMenu}>
|
||||
<NodeSelector
|
||||
editor={props.editor}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import React, { useCallback } from "react";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
@@ -53,17 +49,26 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "callout";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
return dom.getBoundingClientRect();
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const setCalloutType = useCallback(
|
||||
@@ -112,14 +117,12 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
editor={editor}
|
||||
pluginKey={`callout-menu`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 10],
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "bottom",
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
// offset: 233, // // offset: [0, 10],
|
||||
// zIndex: 99,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -90,6 +90,7 @@ export default function CodeBlockView(props: NodeViewProps) {
|
||||
node.textContent.length > 0
|
||||
}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<NodeViewContent as="code" className={`language-${language}`} />
|
||||
</pre>
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||
import { 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 { Editor } from "@tiptap/core";
|
||||
|
||||
export const handlePaste = (
|
||||
view: EditorView,
|
||||
editor: Editor,
|
||||
event: ClipboardEvent,
|
||||
pageId: string,
|
||||
creatorId?: string,
|
||||
@@ -18,7 +17,7 @@ export const handlePaste = (
|
||||
// we have to do this validation here to allow the default link extension to takeover if needs be
|
||||
event.preventDefault();
|
||||
const url = clipboardData.trim();
|
||||
const { from: pos, empty } = view.state.selection;
|
||||
const { from: pos, empty } = editor.state.selection;
|
||||
const match = INTERNAL_LINK_REGEX.exec(url);
|
||||
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
||||
|
||||
@@ -34,19 +33,27 @@ export const handlePaste = (
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorId = match[6] ? match[6].split('#')[0] : undefined;
|
||||
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
|
||||
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
|
||||
const anchorId = match[6] ? match[6].split("#")[0] : undefined;
|
||||
const urlWithoutAnchor = anchorId
|
||||
? url.substring(0, url.indexOf("#"))
|
||||
: url;
|
||||
createMentionAction(
|
||||
urlWithoutAnchor,
|
||||
editor.view,
|
||||
pos,
|
||||
creatorId,
|
||||
anchorId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.clipboardData?.files.length) {
|
||||
event.preventDefault();
|
||||
for (const file of event.clipboardData.files) {
|
||||
const pos = view.state.selection.from;
|
||||
uploadImageAction(file, view, pos, pageId);
|
||||
uploadVideoAction(file, view, pos, pageId);
|
||||
uploadAttachmentAction(file, view, pos, pageId);
|
||||
const pos = editor.state.selection.from;
|
||||
uploadImageAction(file, editor, pos, pageId);
|
||||
uploadVideoAction(file, editor, pos, pageId);
|
||||
uploadAttachmentAction(file, editor, pos, pageId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -54,7 +61,7 @@ export const handlePaste = (
|
||||
};
|
||||
|
||||
export const handleFileDrop = (
|
||||
view: EditorView,
|
||||
editor: Editor,
|
||||
event: DragEvent,
|
||||
moved: boolean,
|
||||
pageId: string,
|
||||
@@ -63,14 +70,14 @@ export const handleFileDrop = (
|
||||
event.preventDefault();
|
||||
|
||||
for (const file of event.dataTransfer.files) {
|
||||
const coordinates = view.posAtCoords({
|
||||
const coordinates = editor.view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
import { sticky } from "tippy.js";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
@@ -40,17 +35,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "drawio";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
return dom.getBoundingClientRect();
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
@@ -65,15 +69,11 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
editor={editor}
|
||||
pluginKey={`drawio-menu`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -66,6 +66,7 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
const fileName = "diagram.drawio.svg";
|
||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
//@ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||
import EmojiList from "./emoji-list";
|
||||
import tippy from "tippy.js";
|
||||
import { init } from "emoji-mart";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
|
||||
const renderEmojiItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
let popup: HTMLDivElement | 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 {
|
||||
onBeforeStart: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: DOMRect;
|
||||
clientRect: () => DOMRect;
|
||||
}) => {
|
||||
init({
|
||||
data: async () => (await import("@emoji-mart/data")).default,
|
||||
@@ -25,51 +50,61 @@ const renderEmojiItems = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom",
|
||||
getReferenceClientRect = props.clientRect;
|
||||
popup = document.createElement("div");
|
||||
popup.style.zIndex = "9999";
|
||||
popup.style.position = "absolute";
|
||||
popup.style.top = "0";
|
||||
popup.style.left = "0";
|
||||
popup.appendChild(component.element);
|
||||
document.body.appendChild(popup);
|
||||
|
||||
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: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: DOMRect;
|
||||
clientRect: () => DOMRect;
|
||||
}) => {
|
||||
component?.updateProps({...props, isLoading: false});
|
||||
component?.updateProps({ ...props, isLoading: false });
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
if (props.clientRect) {
|
||||
getReferenceClientRect = props.clientRect;
|
||||
}
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onUpdate: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: DOMRect;
|
||||
clientRect: () => DOMRect;
|
||||
}) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
if (props.clientRect) {
|
||||
getReferenceClientRect = props.clientRect;
|
||||
}
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
component?.destroy()
|
||||
destroy();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -78,13 +113,7 @@ const renderEmojiItems = () => {
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
if (popup && !popup[0]?.state.isDestroyed) {
|
||||
popup[0]?.destroy();
|
||||
}
|
||||
|
||||
if (component) {
|
||||
component?.destroy();
|
||||
}
|
||||
destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
import { sticky } from "tippy.js";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
@@ -42,17 +37,26 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "excalidraw";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
return dom.getBoundingClientRect();
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
@@ -65,17 +69,13 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`excalidraw-menu}`}
|
||||
pluginKey={`excalidraw-menu`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -98,6 +98,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
const fileName = "diagram.excalidraw.svg";
|
||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import React, { useCallback } from "react";
|
||||
import { sticky } from "tippy.js";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
@@ -22,16 +17,6 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("image");
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
@@ -52,17 +37,37 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const shouldShow = 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 predicate = (node: PMNode) => node.type.name === "image";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
return dom.getBoundingClientRect();
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const alignImageLeft = useCallback(() => {
|
||||
@@ -105,15 +110,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
editor={editor}
|
||||
pluginKey={`image-menu`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.imageWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
|
||||
@mixin light {
|
||||
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
100% {
|
||||
background-position: -135% 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,70 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Group, Image, Loader, Text } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import { Image } from "@mantine/core";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import clsx from "clsx";
|
||||
import classes from "./image-view.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ImageView(props: NodeViewProps) {
|
||||
const { node, selected } = props;
|
||||
const { src, width, align, title } = node.attrs;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { editor, node, selected } = props;
|
||||
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
|
||||
const alignClass = useMemo(() => {
|
||||
if (align === "left") return "alignLeft";
|
||||
if (align === "right") return "alignRight";
|
||||
if (align === "center") return "alignCenter";
|
||||
return "alignCenter";
|
||||
}, [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 (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<Image
|
||||
radius="md"
|
||||
fit="contain"
|
||||
w={width}
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
selected && "ProseMirror-selectednode",
|
||||
classes.imageWrapper,
|
||||
alignClass,
|
||||
)}
|
||||
style={{
|
||||
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
|
||||
width,
|
||||
}}
|
||||
>
|
||||
{src && (
|
||||
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
|
||||
)}
|
||||
{!src && previewSrc && (
|
||||
<Group pos="relative" h="100%" w="100%">
|
||||
<Image
|
||||
radius="md"
|
||||
fit="contain"
|
||||
src={previewSrc}
|
||||
alt={placeholder?.name}
|
||||
/>
|
||||
<Loader size={20} pos="absolute" bottom={6} right={6} />
|
||||
</Group>
|
||||
)}
|
||||
{!src && !previewSrc && (
|
||||
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
<Text component="span" size="sm" truncate="end">
|
||||
{placeholder?.name
|
||||
? t("Uploading {{name}}", { name: placeholder.name })
|
||||
: t("Uploading file")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
|
||||
import { Card } from "@mantine/core";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
|
||||
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
@@ -59,18 +60,15 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`link-menu}`}
|
||||
pluginKey={`link-menu`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
appendTo: () => {
|
||||
return appendTo?.current;
|
||||
},
|
||||
onHidden: () => {
|
||||
options={{
|
||||
onHide: () => {
|
||||
setShowEdit(false);
|
||||
},
|
||||
placement: "bottom",
|
||||
offset: [0, 5],
|
||||
zIndex: 101,
|
||||
offset: 5,
|
||||
// zIndex: 101,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -106,6 +106,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
|
||||
setRenderItems(items);
|
||||
// update editor storage
|
||||
//@ts-ignore
|
||||
props.editor.storage.mentionItems = items;
|
||||
}
|
||||
}, [suggestion, isLoading]);
|
||||
@@ -163,7 +164,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
|
||||
const enterHandler = () => {
|
||||
if (!renderItems.length) return;
|
||||
if (renderItems[selectedIndex].entityType !== "header") {
|
||||
if (renderItems[selectedIndex]?.entityType !== "header") {
|
||||
selectItem(selectedIndex);
|
||||
}
|
||||
};
|
||||
@@ -203,7 +204,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
parentPageId: page.id || null,
|
||||
title: title
|
||||
};
|
||||
|
||||
|
||||
let createdPage: IPage;
|
||||
try {
|
||||
createdPage = await createPageMutation.mutateAsync(payload);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||
import tippy from "tippy.js";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
|
||||
|
||||
function getWhitespaceCount(query: string) {
|
||||
@@ -9,16 +15,27 @@ function getWhitespaceCount(query: string) {
|
||||
|
||||
const mentionRenderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
let activeClientRect: (() => DOMRect) | 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 {
|
||||
onStart: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: DOMRect;
|
||||
clientRect: () => DOMRect;
|
||||
query: string;
|
||||
}) => {
|
||||
// query must not start with a whitespace
|
||||
if (props.query.charAt(0) === ' '){
|
||||
if (props.query.charAt(0) === " ") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -37,75 +54,95 @@ const mentionRenderItems = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
activeClientRect = props.clientRect;
|
||||
|
||||
const { element } = component;
|
||||
document.body.appendChild(element);
|
||||
|
||||
updatePositionCleanup = autoUpdate(
|
||||
{
|
||||
getBoundingClientRect: () =>
|
||||
activeClientRect ? activeClientRect() : new DOMRect(),
|
||||
},
|
||||
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: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
clientRect: DOMRect;
|
||||
clientRect: () => DOMRect;
|
||||
query: string;
|
||||
}) => {
|
||||
// query must not start with a whitespace
|
||||
if (props.query.charAt(0) === ' '){
|
||||
component?.destroy();
|
||||
if (props.query.charAt(0) === " ") {
|
||||
destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// only update component if popup is not destroyed
|
||||
if (!popup?.[0].state.isDestroyed) {
|
||||
component?.updateProps(props);
|
||||
if (component) {
|
||||
component.updateProps(props);
|
||||
}
|
||||
|
||||
if (!props || !props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeClientRect = props.clientRect;
|
||||
|
||||
const whitespaceCount = getWhitespaceCount(props.query);
|
||||
|
||||
// destroy component if space is greater 3 without a match
|
||||
if (
|
||||
whitespaceCount > 3 &&
|
||||
props.editor.storage.mentionItems.length === 0
|
||||
whitespaceCount > 4 &&
|
||||
//@ts-ignore
|
||||
props.editor.storage.mentionItems.length === 1
|
||||
) {
|
||||
popup?.[0]?.destroy();
|
||||
component?.destroy();
|
||||
destroy();
|
||||
return;
|
||||
}
|
||||
// fallback exit
|
||||
if (whitespaceCount > 7) {
|
||||
destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
popup &&
|
||||
!popup?.[0].state.isDestroyed &&
|
||||
popup?.[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key)
|
||||
if (
|
||||
props.event.key === "Escape" ||
|
||||
(props.event.key === "Enter" && !popup?.[0].state.isShown)
|
||||
) {
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
return false;
|
||||
}
|
||||
if (props.event.key === "Escape") {
|
||||
destroy();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (props.event.key === "Enter" && !component) {
|
||||
destroy();
|
||||
return false;
|
||||
}
|
||||
|
||||
return (component?.ref as any)?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
if (popup && !popup?.[0].state.isDestroyed) {
|
||||
popup[0].destroy();
|
||||
}
|
||||
|
||||
if (component) {
|
||||
component.destroy();
|
||||
}
|
||||
destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+2
@@ -73,6 +73,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
if (!editor) return;
|
||||
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
//TODO: check type error
|
||||
//@ts-ignore
|
||||
const position: Range = results[resultIndex];
|
||||
|
||||
if (!position) return;
|
||||
|
||||
@@ -161,6 +161,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (!pageId) return;
|
||||
|
||||
@@ -173,9 +174,13 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
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();
|
||||
},
|
||||
@@ -188,6 +193,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (!pageId) return;
|
||||
|
||||
@@ -195,12 +201,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "video/*";
|
||||
input.multiple = true;
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const pos = editor.view.state.selection.from;
|
||||
uploadVideoAction(file, editor.view, pos, pageId);
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
|
||||
uploadVideoAction(file, editor, pos, pageId);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
@@ -213,6 +225,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (!pageId) return;
|
||||
|
||||
@@ -220,12 +233,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "";
|
||||
input.multiple = true;
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const pos = editor.view.state.selection.from;
|
||||
uploadAttachmentAction(file, editor.view, pos, pageId, true);
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
|
||||
uploadAttachmentAction(file, editor, pos, pageId, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||
import CommandList from "@/features/editor/components/slash-menu/command-list";
|
||||
import tippy from "tippy.js";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
let popup: HTMLElement | 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 {
|
||||
onStart: (props: {
|
||||
@@ -21,15 +46,29 @@ const renderItems = () => {
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
getReferenceClientRect = props.clientRect;
|
||||
|
||||
popup = document.createElement("div");
|
||||
popup.style.zIndex = "9999";
|
||||
popup.style.position = "absolute";
|
||||
popup.style.top = "0";
|
||||
popup.style.left = "0";
|
||||
|
||||
document.body.appendChild(popup);
|
||||
popup.appendChild(component.element);
|
||||
|
||||
cleanup = autoUpdate(
|
||||
// @ts-ignore
|
||||
{
|
||||
getBoundingClientRect: () => {
|
||||
return getReferenceClientRect
|
||||
? getReferenceClientRect()
|
||||
: new DOMRect();
|
||||
},
|
||||
},
|
||||
popup,
|
||||
updatePosition
|
||||
);
|
||||
},
|
||||
onUpdate: (props: {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
@@ -41,14 +80,15 @@ const renderItems = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
// @ts-ignore
|
||||
getReferenceClientRect = props.clientRect;
|
||||
updatePosition();
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
if (popup) {
|
||||
popup.style.display = "none";
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -57,12 +97,19 @@ const renderItems = () => {
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
if (popup && !popup[0].state.isDestroyed) {
|
||||
popup[0].destroy();
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
cleanup = null;
|
||||
}
|
||||
|
||||
if (popup) {
|
||||
popup.remove();
|
||||
popup = null;
|
||||
}
|
||||
|
||||
if (component) {
|
||||
component.destroy();
|
||||
component = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
posToDOMRect,
|
||||
findParentNode,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import React, { useCallback } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { sticky } from "tippy.js";
|
||||
|
||||
interface SubpagesMenuProps {
|
||||
editor: Editor;
|
||||
@@ -33,7 +29,7 @@ export const SubpagesMenu = React.memo(
|
||||
|
||||
return editor.isActive("subpages");
|
||||
},
|
||||
[editor],
|
||||
[editor]
|
||||
);
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
@@ -62,18 +58,8 @@ export const SubpagesMenu = React.memo(
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`subpages-menu}`}
|
||||
pluginKey={`subpages-menu`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<Tooltip position="top" label={t("Delete")}>
|
||||
@@ -89,7 +75,7 @@ export const SubpagesMenu = React.memo(
|
||||
</Tooltip>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export default SubpagesMenu;
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function SubpagesView(props: NodeViewProps) {
|
||||
const { spaceSlug, shareId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
//@ts-ignore
|
||||
const currentPageId = editor.storage.pageId;
|
||||
|
||||
// Get subpages from shared tree if we're in a shared context
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
@@ -17,6 +15,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TableBackgroundColor } from "./table-background-color";
|
||||
import { TableTextAlignment } from "./table-text-alignment";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
|
||||
export const TableCellMenu = React.memo(
|
||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||
@@ -29,7 +28,7 @@ export const TableCellMenu = React.memo(
|
||||
|
||||
return isCellSelection(state.selection);
|
||||
},
|
||||
[editor],
|
||||
[editor]
|
||||
);
|
||||
|
||||
const mergeCells = useCallback(() => {
|
||||
@@ -53,23 +52,27 @@ export const TableCellMenu = React.memo(
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
pluginKey="table-cell-menu"
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
appendTo: () => {
|
||||
return appendTo?.current;
|
||||
appendTo={() => {
|
||||
return appendTo?.current;
|
||||
}}
|
||||
ref={(element) => {
|
||||
element.style.zIndex = "99";
|
||||
}}
|
||||
options={{
|
||||
offset: {
|
||||
mainAxis: 15,
|
||||
},
|
||||
offset: [0, 15],
|
||||
zIndex: 99,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group>
|
||||
<TableBackgroundColor editor={editor} />
|
||||
<TableTextAlignment editor={editor} />
|
||||
|
||||
|
||||
<Tooltip position="top" label={t("Merge cells")}>
|
||||
<ActionIcon
|
||||
onClick={mergeCells}
|
||||
@@ -125,9 +128,9 @@ export const TableCellMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</BaseBubbleMenu>
|
||||
</BubbleMenu>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export default TableCellMenu;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
posToDOMRect,
|
||||
findParentNode,
|
||||
} from "@tiptap/react";
|
||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
@@ -17,9 +12,12 @@ import {
|
||||
IconColumnRemove,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconRowRemove, IconTableColumn, IconTableRow,
|
||||
IconRowRemove,
|
||||
IconTableColumn,
|
||||
IconTableRow,
|
||||
IconTrashX,
|
||||
} from '@tabler/icons-react';
|
||||
} from "@tabler/icons-react";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import { isCellSelection } from "@docmost/editor-ext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -34,20 +32,28 @@ export const TableMenu = React.memo(
|
||||
|
||||
return editor.isActive("table") && !isCellSelection(state.selection);
|
||||
},
|
||||
[editor],
|
||||
[editor]
|
||||
);
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "table";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
return dom.getBoundingClientRect();
|
||||
const rect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => rect,
|
||||
getClientRects: () => [rect],
|
||||
};
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
const rect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => rect,
|
||||
getClientRects: () => [rect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderColumn = useCallback(() => {
|
||||
@@ -87,42 +93,33 @@ export const TableMenu = React.memo(
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
pluginKey="table-menu"
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect: getReferenceClientRect,
|
||||
offset: [0, 15],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
enabled: true,
|
||||
options: {
|
||||
altAxis: true,
|
||||
boundary: "clippingParents",
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flip",
|
||||
enabled: true,
|
||||
options: {
|
||||
boundary: editor.options.element,
|
||||
fallbackPlacements: ["top", "bottom"],
|
||||
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
|
||||
},
|
||||
},
|
||||
],
|
||||
resizeDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
ref={(element) => {
|
||||
element.style.zIndex = "99";
|
||||
}}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: {
|
||||
mainAxis: 15,
|
||||
},
|
||||
flip: {
|
||||
fallbackPlacements: ["top", "bottom"],
|
||||
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
|
||||
boundary: editor.options.element as HTMLElement,
|
||||
},
|
||||
shift: {
|
||||
padding: 8 + 15,
|
||||
crossAxis: true,
|
||||
},
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group>
|
||||
<Tooltip position="top" label={t("Add left column")}
|
||||
>
|
||||
<Tooltip position="top" label={t("Add left column")}>
|
||||
<ActionIcon
|
||||
onClick={addColumnLeft}
|
||||
variant="default"
|
||||
@@ -188,8 +185,7 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header row")}
|
||||
>
|
||||
<Tooltip position="top" label={t("Toggle header row")}>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderRow}
|
||||
variant="default"
|
||||
@@ -200,8 +196,7 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header column")}
|
||||
>
|
||||
<Tooltip position="top" label={t("Toggle header column")}>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderColumn}
|
||||
variant="default"
|
||||
@@ -224,9 +219,9 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</BaseBubbleMenu>
|
||||
</BubbleMenu>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export default TableMenu;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import React, { useCallback } from "react";
|
||||
import { sticky } from "tippy.js";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
@@ -22,16 +17,6 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("video");
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
@@ -52,17 +37,37 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const shouldShow = 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 predicate = (node: PMNode) => node.type.name === "video";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
return dom.getBoundingClientRect();
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const alignVideoLeft = useCallback(() => {
|
||||
@@ -105,15 +110,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
editor={editor}
|
||||
pluginKey={`video-menu`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: "flip", enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: "popper",
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
.videoWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
|
||||
@mixin light {
|
||||
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
100% {
|
||||
background-position: -135% 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -1,29 +1,75 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Group, Loader, Text } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import clsx from "clsx";
|
||||
import classes from "./video-view.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function VideoView(props: NodeViewProps) {
|
||||
const { node, selected } = props;
|
||||
const { src, width, align } = node.attrs;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { editor, node, selected } = props;
|
||||
const { src, width, align, aspectRatio, placeholder } = node.attrs;
|
||||
const alignClass = useMemo(() => {
|
||||
if (align === "left") return "alignLeft";
|
||||
if (align === "right") return "alignRight";
|
||||
if (align === "center") return "alignCenter";
|
||||
return "alignCenter";
|
||||
}, [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 (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<video
|
||||
preload="metadata"
|
||||
width={width}
|
||||
controls
|
||||
src={getFileUrl(src)}
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
selected && "ProseMirror-selectednode",
|
||||
classes.videoWrapper,
|
||||
alignClass,
|
||||
)}
|
||||
style={{
|
||||
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
|
||||
width,
|
||||
}}
|
||||
>
|
||||
{src && (
|
||||
<video
|
||||
className={classes.video}
|
||||
preload="metadata"
|
||||
controls
|
||||
src={getFileUrl(src)}
|
||||
/>
|
||||
)}
|
||||
{!src && previewSrc && (
|
||||
<Group pos="relative" h="100%" w="100%">
|
||||
<video
|
||||
className={classes.video}
|
||||
preload="metadata"
|
||||
controls
|
||||
src={previewSrc}
|
||||
/>
|
||||
<Loader size={20} pos="absolute" top={6} right={6} />
|
||||
</Group>
|
||||
)}
|
||||
{!src && !previewSrc && (
|
||||
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
<Text component="span" size="sm" truncate="end">
|
||||
{placeholder?.name
|
||||
? t("Uploading {{name}}", { name: placeholder.name })
|
||||
: t("Uploading file")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||
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 { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||
import { Placeholder, CharacterCount } from "@tiptap/extensions";
|
||||
import { Superscript } from "@tiptap/extension-superscript";
|
||||
import SubScript from "@tiptap/extension-subscript";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
@@ -15,7 +11,7 @@ import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||
import { Youtube } from "@tiptap/extension-youtube";
|
||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import {
|
||||
Comment,
|
||||
@@ -41,11 +37,12 @@ import {
|
||||
Embed,
|
||||
SearchAndReplace,
|
||||
Mention,
|
||||
Subpages,
|
||||
TableDndExtension,
|
||||
Subpages,
|
||||
Heading,
|
||||
Highlight,
|
||||
UniqueID,
|
||||
SharedStorage,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -97,7 +94,9 @@ lowlight.register("scala", scala);
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
history: false,
|
||||
undoRedo: false,
|
||||
link: false,
|
||||
trailingNode: false,
|
||||
dropcursor: {
|
||||
width: 3,
|
||||
color: "#70CFF8",
|
||||
@@ -109,6 +108,7 @@ export const mainExtensions = [
|
||||
},
|
||||
},
|
||||
}),
|
||||
SharedStorage,
|
||||
Heading,
|
||||
UniqueID.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
@@ -134,8 +134,6 @@ export const mainExtensions = [
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
ListKeymap,
|
||||
Underline,
|
||||
LinkExtension.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
@@ -170,6 +168,9 @@ export const mainExtensions = [
|
||||
},
|
||||
}).extend({
|
||||
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);
|
||||
},
|
||||
}),
|
||||
@@ -208,6 +209,7 @@ export const mainExtensions = [
|
||||
}),
|
||||
CustomCodeBlock.configure({
|
||||
view: CodeBlockView,
|
||||
//@ts-ignore
|
||||
lowlight,
|
||||
HTMLAttributes: {
|
||||
spellcheck: false,
|
||||
@@ -246,7 +248,7 @@ export const mainExtensions = [
|
||||
Escape: () => {
|
||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -258,8 +260,9 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
provider,
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
CollaborationCaret.configure({
|
||||
provider,
|
||||
user: {
|
||||
name: user.name,
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
HocuspocusProvider,
|
||||
onAuthenticationFailedParameters,
|
||||
onStatusParameters,
|
||||
WebSocketStatus,
|
||||
HocuspocusProviderWebsocket,
|
||||
onSyncedParameters,
|
||||
} from "@hocuspocus/provider";
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
EditorProvider,
|
||||
useEditor,
|
||||
@@ -69,161 +78,140 @@ export default function PageEditor({
|
||||
editable,
|
||||
content,
|
||||
}: PageEditorProps) {
|
||||
|
||||
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const isComponentMounted = useRef(false);
|
||||
const editorCreated = useRef(false);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
isComponentMounted.current = true;
|
||||
}, []);
|
||||
|
||||
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setEditor] = useAtom(pageEditorAtom);
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const ydocRef = useRef<Y.Doc | null>(null);
|
||||
if (!ydocRef.current) {
|
||||
ydocRef.current = new Y.Doc();
|
||||
}
|
||||
const ydoc = ydocRef.current;
|
||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
yjsConnectionStatusAtom,
|
||||
);
|
||||
const menuContainerRef = useRef(null);
|
||||
const documentName = `page.${pageId}`;
|
||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||
const documentState = useDocumentVisibility();
|
||||
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const userPageEditMode =
|
||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
|
||||
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
|
||||
const canScroll = useCallback(
|
||||
() => Boolean(isComponentMounted.current && editorRef.current),
|
||||
[isComponentMounted],
|
||||
);
|
||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
remote: HocuspocusProvider;
|
||||
socket: HocuspocusProviderWebsocket;
|
||||
} | null>(null);
|
||||
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(() => {
|
||||
if (!providersRef.current) {
|
||||
const documentName = `page.${pageId}`;
|
||||
const ydoc = new Y.Doc();
|
||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||
local.on("synced", () => setLocalSynced(true));
|
||||
const remote = new HocuspocusProvider({
|
||||
name: documentName,
|
||||
const socket = new HocuspocusProviderWebsocket({
|
||||
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({
|
||||
websocketProvider: socket,
|
||||
name: documentName,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
connect: true,
|
||||
preserveConnection: false,
|
||||
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);
|
||||
}
|
||||
},
|
||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||
onStatus: onStatusHandler,
|
||||
onSynced: onSyncedHandler,
|
||||
});
|
||||
remote.on("synced", () => setRemoteSynced(true));
|
||||
remote.on("disconnect", () => {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
});
|
||||
providersRef.current = { local, remote };
|
||||
|
||||
local.on("synced", onLocalSyncedHandler);
|
||||
providersRef.current = { socket, local, remote };
|
||||
setProvidersReady(true);
|
||||
} else {
|
||||
setProvidersReady(true);
|
||||
}
|
||||
// Only destroy on final unmount
|
||||
return () => {
|
||||
providersRef.current?.socket.destroy();
|
||||
providersRef.current?.remote.destroy();
|
||||
providersRef.current?.local.destroy();
|
||||
providersRef.current = null;
|
||||
};
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (!providersReady || !providersRef.current) return;
|
||||
const remoteProvider = providersRef.current.remote;
|
||||
const socket = providersRef.current.socket;
|
||||
|
||||
if (
|
||||
isIdle &&
|
||||
documentState === "hidden" &&
|
||||
remoteProvider.status === WebSocketStatus.Connected
|
||||
yjsConnectionStatus === WebSocketStatus.Connected
|
||||
) {
|
||||
remoteProvider.disconnect();
|
||||
setIsCollabReady(false);
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
documentState === "visible" &&
|
||||
remoteProvider.status === WebSocketStatus.Disconnected
|
||||
yjsConnectionStatus === WebSocketStatus.Disconnected
|
||||
) {
|
||||
resetIdle();
|
||||
remoteProvider.connect();
|
||||
setTimeout(() => setIsCollabReady(true), 500);
|
||||
socket.connect();
|
||||
}
|
||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||
|
||||
// Attach here, to make sure the connection gets properly established
|
||||
providersRef.current?.remote.attach();
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (!remoteProvider || !currentUser?.user) return mainExtensions;
|
||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
||||
return mainExtensions;
|
||||
}
|
||||
|
||||
const remoteProvider = providersRef.current.remote;
|
||||
|
||||
return [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider, currentUser?.user),
|
||||
];
|
||||
}, [remoteProvider, currentUser?.user]);
|
||||
}, [providersReady, currentUser?.user]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
@@ -266,18 +254,30 @@ export default function PageEditor({
|
||||
}
|
||||
},
|
||||
},
|
||||
handlePaste: (view, event, slice) =>
|
||||
handlePaste(view, event, pageId, currentUser?.user.id),
|
||||
handleDrop: (view, event, _slice, moved) =>
|
||||
handleFileDrop(view, event, moved, pageId),
|
||||
handlePaste: (_view, event) => {
|
||||
if (!editorRef.current) return false;
|
||||
|
||||
return handlePaste(
|
||||
editorRef.current,
|
||||
event,
|
||||
pageId,
|
||||
currentUser?.user.id,
|
||||
);
|
||||
},
|
||||
handleDrop: (_view, event, _slice, moved) => {
|
||||
if (!editorRef.current) return false;
|
||||
|
||||
return handleFileDrop(editorRef.current, event, moved, pageId);
|
||||
},
|
||||
},
|
||||
onCreate({ editor }) {
|
||||
if (editor) {
|
||||
// @ts-ignore
|
||||
setEditor(editor);
|
||||
// @ts-ignore
|
||||
editor.storage.pageId = pageId;
|
||||
handleScrollTo(editor);
|
||||
editorCreated.current = true;
|
||||
editorRef.current = editor;
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
@@ -287,7 +287,7 @@ export default function PageEditor({
|
||||
debouncedUpdateContent(editorJson);
|
||||
},
|
||||
},
|
||||
[pageId, editable, remoteProvider],
|
||||
[pageId, editable, extensions],
|
||||
);
|
||||
|
||||
const editorIsEditable = useEditorState({
|
||||
@@ -343,30 +343,17 @@ export default function PageEditor({
|
||||
setAsideState({ tab: "", isAsideOpen: false });
|
||||
}, [pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (remoteProvider?.status === WebSocketStatus.Connecting) {
|
||||
const timeout = setTimeout(() => {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [remoteProvider?.status]);
|
||||
|
||||
const isSynced = isLocalSynced && isRemoteSynced;
|
||||
|
||||
useEffect(() => {
|
||||
const collabReadyTimeout = setTimeout(() => {
|
||||
if (
|
||||
!isCollabReady &&
|
||||
isSynced &&
|
||||
remoteProvider?.status === WebSocketStatus.Connected
|
||||
) {
|
||||
setIsCollabReady(true);
|
||||
const timeout = setTimeout(() => {
|
||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
}
|
||||
}, 500);
|
||||
return () => clearTimeout(collabReadyTimeout);
|
||||
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
||||
}, 7500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
useEffect(() => {
|
||||
// Only honor user default page edit mode preference and permissions
|
||||
if (editor) {
|
||||
@@ -388,12 +375,13 @@ export default function PageEditor({
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasConnectedOnceRef.current &&
|
||||
remoteProvider?.status === WebSocketStatus.Connected
|
||||
yjsConnectionStatus === WebSocketStatus.Connected &&
|
||||
isSynced
|
||||
) {
|
||||
hasConnectedOnceRef.current = true;
|
||||
setShowStatic(false);
|
||||
}
|
||||
}, [remoteProvider?.status]);
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
|
||||
if (showStatic) {
|
||||
return (
|
||||
|
||||
@@ -81,6 +81,7 @@ export default function ReadonlyPageEditor({
|
||||
onCreate={({ editor }) => {
|
||||
if (editor) {
|
||||
if (pageId) {
|
||||
// @ts-ignore
|
||||
editor.storage.pageId = pageId;
|
||||
}
|
||||
// @ts-ignore
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Give a remote user a caret */
|
||||
.collaboration-cursor__caret {
|
||||
.collaboration-carets__caret {
|
||||
border-left: 1px solid #0d0d0d;
|
||||
border-right: 1px solid #0d0d0d;
|
||||
margin-left: -1px;
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
|
||||
/* Render the username above the caret */
|
||||
.collaboration-cursor__label {
|
||||
.collaboration-carets__label {
|
||||
border-radius: 3px 3px 3px 0;
|
||||
color: #0d0d0d;
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import '@/features/editor/styles/index.css';
|
||||
import React, { useEffect } from 'react';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { mainExtensions } from '@/features/editor/extensions/extensions';
|
||||
import { Title } from '@mantine/core';
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, { useEffect } from "react";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||
import { Title } from "@mantine/core";
|
||||
import classes from "./history.module.css";
|
||||
|
||||
export interface HistoryEditorProps {
|
||||
title: string;
|
||||
@@ -26,7 +27,9 @@ export function HistoryEditor({ title, content }: HistoryEditorProps) {
|
||||
<div>
|
||||
<Title order={1}>{title}</Title>
|
||||
|
||||
{editor && <EditorContent editor={editor} />}
|
||||
{editor && (
|
||||
<EditorContent editor={editor} className={classes.historyEditor} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,7 @@ function HistoryList({ pageId }: Props) {
|
||||
mainEditorTitle
|
||||
.chain()
|
||||
.clearContent()
|
||||
.setContent(activeHistoryData.title, true)
|
||||
.setContent(activeHistoryData.title, { emitUpdate: true })
|
||||
.run();
|
||||
mainEditor
|
||||
.chain()
|
||||
|
||||
@@ -1,37 +1,49 @@
|
||||
.history {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
|
||||
}
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-8)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.historyEditor {
|
||||
:global(.ProseMirror) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-8)
|
||||
);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
max-height: rem(700px);
|
||||
width: rem(250px);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: rem(1px) solid
|
||||
max-height: rem(700px);
|
||||
width: rem(250px);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: rem(1px) solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.sidebarFlex {
|
||||
display: flex;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebarMain {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebarRightSection {
|
||||
flex: 1;
|
||||
padding: rem(16px) rem(40px);
|
||||
flex: 1;
|
||||
padding: rem(16px) rem(40px);
|
||||
}
|
||||
|
||||
@@ -7,22 +7,17 @@ import {
|
||||
IconHistory,
|
||||
IconLink,
|
||||
IconList,
|
||||
IconMarkdown,
|
||||
IconMessage,
|
||||
IconPrinter,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
IconWifiOff,
|
||||
} from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import {
|
||||
getHotkeyHandler,
|
||||
useClipboard,
|
||||
useDisclosure,
|
||||
useHotkeys,
|
||||
} from "@mantine/hooks";
|
||||
import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -34,12 +29,12 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.
|
||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
@@ -51,7 +46,6 @@ interface PageHeaderMenuProps {
|
||||
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const toggleAside = useToggleAside();
|
||||
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
||||
|
||||
useHotkeys(
|
||||
[
|
||||
@@ -68,6 +62,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
},
|
||||
{ preventDefault: false },
|
||||
],
|
||||
],
|
||||
[],
|
||||
@@ -75,17 +70,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
<ConnectionWarning />
|
||||
|
||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||
|
||||
@@ -146,6 +131,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
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 = () => {
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
@@ -183,6 +177,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
>
|
||||
{t("Copy link")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconMarkdown size={16} />}
|
||||
onClick={handleCopyAsMarkdown}
|
||||
>
|
||||
{t("Copy as Markdown")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||
@@ -290,3 +291,51 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionWarning() {
|
||||
const { t } = useTranslation();
|
||||
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
||||
yjsConnectionStatus,
|
||||
);
|
||||
|
||||
if (isDisconnected) {
|
||||
if (!timeoutRef.current) {
|
||||
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
||||
}
|
||||
} else {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
setShowWarning(false);
|
||||
}
|
||||
}, [yjsConnectionStatus]);
|
||||
|
||||
// Cleanup only on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!showWarning) return null;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={t("Real-time editor connection lost. Retrying...")}
|
||||
openDelay={250}
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
|
||||
<IconWifiOff size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,9 +163,6 @@ export function useDeletePageMutation() {
|
||||
export function useMovePageMutation() {
|
||||
return useMutation<void, Error, IMovePage>({
|
||||
mutationFn: (data) => movePage(data),
|
||||
onSuccess: () => {
|
||||
invalidateOnMovePage();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -458,17 +455,127 @@ export function invalidateOnUpdatePage(
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateOnMovePage() {
|
||||
//for move invalidate all sidebars for now (how to do???)
|
||||
//invalidate all root sidebar pages
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["root-sidebar-pages"],
|
||||
});
|
||||
//invalidate all sub sidebar pages
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["sidebar-pages"],
|
||||
});
|
||||
// ---
|
||||
export function updateCacheOnMovePage(
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
oldParentId: string | null,
|
||||
newParentId: string | null,
|
||||
pageData: Partial<IPage>,
|
||||
) {
|
||||
// Remove page from old parent's cache
|
||||
const oldQueryKey =
|
||||
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) {
|
||||
|
||||
@@ -118,7 +118,14 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
|
||||
.split("filename=")[1]
|
||||
.replace(/"/g, "");
|
||||
|
||||
saveAs(req.data, decodeURIComponent(fileName));
|
||||
let decodedFileName = fileName;
|
||||
try {
|
||||
decodedFileName = decodeURIComponent(fileName);
|
||||
} catch (err) {
|
||||
// fallback to raw filename
|
||||
}
|
||||
|
||||
saveAs(req.data, decodedFileName);
|
||||
}
|
||||
|
||||
export async function importPage(file: File, spaceId: string) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useRemovePageMutation,
|
||||
useMovePageMutation,
|
||||
useUpdatePageMutation,
|
||||
updateCacheOnMovePage,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
@@ -175,9 +176,25 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
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 {
|
||||
await movePageMutation.mutateAsync(payload);
|
||||
|
||||
updateCacheOnMovePage(spaceId, draggedNodeId, oldParentId, args.parentId, pageData);
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "moveTreeNode",
|
||||
@@ -185,8 +202,10 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
payload: {
|
||||
id: draggedNodeId,
|
||||
parentId: args.parentId,
|
||||
oldParentId,
|
||||
index: args.index,
|
||||
position: newPosition,
|
||||
pageData,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { SpaceSelect } from "./space-select";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { Button, Popover, Text } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
@@ -21,7 +21,7 @@ export function SwitchSpace({
|
||||
spaceIcon,
|
||||
}: SwitchSpaceProps) {
|
||||
const navigate = useNavigate();
|
||||
const [opened, { close, open, toggle }] = useDisclosure(false);
|
||||
const [opened, { close, toggle }] = useDisclosure(false);
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (value) {
|
||||
@@ -44,9 +44,9 @@ export function SwitchSpace({
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronDown size={18} />}
|
||||
rightSection={opened ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
|
||||
color="gray"
|
||||
onClick={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<CustomAvatar
|
||||
name={spaceName}
|
||||
|
||||
@@ -69,5 +69,12 @@ export async function exportSpace(data: IExportSpaceParams): Promise<void> {
|
||||
.split("filename=")[1]
|
||||
.replace(/"/g, "");
|
||||
|
||||
saveAs(req.data, decodeURIComponent(fileName));
|
||||
let decodedFileName = fileName;
|
||||
try {
|
||||
decodedFileName = decodeURIComponent(fileName);
|
||||
} catch (err) {
|
||||
// fallback to raw filename
|
||||
}
|
||||
|
||||
saveAs(req.data, decodedFileName);
|
||||
}
|
||||
|
||||
@@ -45,8 +45,10 @@ export type MoveTreeNodeEvent = {
|
||||
payload: {
|
||||
id: string;
|
||||
parentId: string;
|
||||
oldParentId: string | null;
|
||||
index: number;
|
||||
position: string;
|
||||
pageData: Partial<IPage>;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { IPagination } from "@/lib/types";
|
||||
import {
|
||||
invalidateOnCreatePage,
|
||||
invalidateOnDeletePage,
|
||||
invalidateOnMovePage,
|
||||
updateCacheOnMovePage,
|
||||
invalidateOnUpdatePage,
|
||||
} from "../page/queries/page-query";
|
||||
import { RQ_KEY } from "../comment/queries/comment-query";
|
||||
@@ -41,7 +41,13 @@ export const useQuerySubscription = () => {
|
||||
invalidateOnCreatePage(data.payload.data);
|
||||
break;
|
||||
case "moveTreeNode":
|
||||
invalidateOnMovePage();
|
||||
updateCacheOnMovePage(
|
||||
data.spaceId,
|
||||
data.payload.id,
|
||||
data.payload.oldParentId,
|
||||
data.payload.parentId,
|
||||
data.payload.pageData,
|
||||
);
|
||||
break;
|
||||
case "deleteTreeNode":
|
||||
invalidateOnDeletePage(data.payload.node.id);
|
||||
|
||||
@@ -76,8 +76,10 @@
|
||||
"kysely-migration-cli": "^0.4.2",
|
||||
"kysely-postgres-js": "^3.0.0",
|
||||
"ldapts": "^7.4.0",
|
||||
"lib0": "^0.2.117",
|
||||
"mammoth": "^1.11.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"msgpackr": "^1.11.8",
|
||||
"nanoid": "3.3.11",
|
||||
"nestjs-kysely": "^1.2.0",
|
||||
"nestjs-pino": "^4.5.0",
|
||||
@@ -102,6 +104,7 @@
|
||||
"socket.io": "^4.8.3",
|
||||
"stripe": "^17.5.0",
|
||||
"tmp-promise": "^3.0.3",
|
||||
"tseep": "^1.3.1",
|
||||
"typesense": "^2.1.0",
|
||||
"ws": "^8.19.0",
|
||||
"yauzl": "^3.2.0"
|
||||
|
||||
@@ -30,14 +30,22 @@ export class CollabWsAdapter {
|
||||
return this.wss;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
public close() {
|
||||
try {
|
||||
this.wss.clients.forEach((client) => {
|
||||
client.terminate();
|
||||
});
|
||||
this.wss.close();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
try {
|
||||
this.wss.close();
|
||||
this.wss.clients.forEach((client) => {
|
||||
client.terminate();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Hocuspocus, Server as HocuspocusServer } from '@hocuspocus/server';
|
||||
import { Hocuspocus } from '@hocuspocus/server';
|
||||
import { IncomingMessage } from 'http';
|
||||
import WebSocket from 'ws';
|
||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Redis } from '@hocuspocus/extension-redis';
|
||||
import { EnvironmentService } from '../integrations/environment/environment.service';
|
||||
import {
|
||||
createRetryStrategy,
|
||||
@@ -12,21 +11,41 @@ import {
|
||||
RedisConfig,
|
||||
} from '../common/helpers';
|
||||
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()
|
||||
export class CollaborationGateway {
|
||||
private hocuspocus: Hocuspocus;
|
||||
private readonly hocuspocus: Hocuspocus;
|
||||
private redisConfig: RedisConfig;
|
||||
// @ts-ignore
|
||||
private readonly redisSync: RedisSyncExtension<CollabEventHandlers> | null =
|
||||
null;
|
||||
private readonly withRedis: boolean;
|
||||
|
||||
constructor(
|
||||
private authenticationExtension: AuthenticationExtension,
|
||||
private persistenceExtension: PersistenceExtension,
|
||||
private loggerExtension: LoggerExtension,
|
||||
private environmentService: EnvironmentService,
|
||||
private collabEventsService: CollaborationHandler,
|
||||
) {
|
||||
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
||||
this.withRedis = !this.environmentService.isCollabDisableRedis();
|
||||
|
||||
this.hocuspocus = HocuspocusServer.configure({
|
||||
this.hocuspocus = new Hocuspocus({
|
||||
debounce: 10000,
|
||||
maxDebounce: 45000,
|
||||
unloadImmediately: false,
|
||||
@@ -34,26 +53,80 @@ export class CollaborationGateway {
|
||||
this.authenticationExtension,
|
||||
this.persistenceExtension,
|
||||
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 {
|
||||
this.hocuspocus.handleConnection(client, request);
|
||||
if (this.redisSync) {
|
||||
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() {
|
||||
@@ -64,7 +137,52 @@ export class CollaborationGateway {
|
||||
return this.hocuspocus.getDocumentsCount();
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
await this.hocuspocus.destroy();
|
||||
handleYjsEvent<TName extends keyof CollabEventHandlers>(
|
||||
eventName: TName,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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,6 +9,7 @@ import { WebSocket } from 'ws';
|
||||
import { TokenModule } from '../core/auth/token.module';
|
||||
import { HistoryListener } from './listeners/history.listener';
|
||||
import { LoggerExtension } from './extensions/logger.extension';
|
||||
import { CollaborationHandler } from './collaboration.handler';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
@@ -17,6 +18,7 @@ import { LoggerExtension } from './extensions/logger.extension';
|
||||
PersistenceExtension,
|
||||
LoggerExtension,
|
||||
HistoryListener,
|
||||
CollaborationHandler,
|
||||
],
|
||||
exports: [CollaborationGateway],
|
||||
imports: [TokenModule],
|
||||
@@ -46,16 +48,12 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
|
||||
wss.on('error', (error) =>
|
||||
this.logger.log('WebSocket server error:', error),
|
||||
this.logger.error('WebSocket server error:', error),
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.collaborationGateway) {
|
||||
await this.collaborationGateway.destroy();
|
||||
}
|
||||
if (this.collabWsAdapter) {
|
||||
this.collabWsAdapter.destroy();
|
||||
}
|
||||
await this.collaborationGateway?.destroy(this.collabWsAdapter);
|
||||
this.collabWsAdapter?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
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 SubScript from '@tiptap/extension-subscript';
|
||||
import { Typography } from '@tiptap/extension-typography';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
import { Youtube } from '@tiptap/extension-youtube';
|
||||
import { TaskList, TaskItem } from '@tiptap/extension-list';
|
||||
import {
|
||||
Heading,
|
||||
Callout,
|
||||
@@ -42,11 +40,14 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/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/4089
|
||||
//import { generateJSON } from '@tiptap/html';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
link: false,
|
||||
trailingNode: false,
|
||||
heading: false,
|
||||
}),
|
||||
Heading,
|
||||
@@ -59,7 +60,6 @@ export const tiptapExtensions = [
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
Underline,
|
||||
LinkExtension,
|
||||
Superscript,
|
||||
SubScript,
|
||||
|
||||
@@ -69,7 +69,7 @@ export class AuthenticationExtension implements Extension {
|
||||
}
|
||||
|
||||
if (userSpaceRole === SpaceRole.READER) {
|
||||
data.connection.readOnly = true;
|
||||
data.connectionConfig.readOnly = true;
|
||||
this.logger.debug(`User granted readonly access to page: ${pageId}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
export class LoggerExtension implements Extension {
|
||||
private readonly logger = new Logger('Collab' + LoggerExtension.name);
|
||||
|
||||
async onDisconnect(data: onDisconnectPayload) {
|
||||
this.logger.debug(`User disconnected from "${data.documentName}".`);
|
||||
}
|
||||
|
||||
async afterUnloadDocument(data: onLoadDocumentPayload) {
|
||||
this.logger.debug('Unloaded ' + data.documentName + ' from memory');
|
||||
}
|
||||
|
||||
async onDisconnect(data: onDisconnectPayload) {
|
||||
this.logger.debug('User disconnected from ' + data.documentName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './redis-sync.extension';
|
||||
export type { SerializedHTTPRequest } from './redis-sync.extension';
|
||||
@@ -0,0 +1,376 @@
|
||||
// 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,11 @@ async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
CollabAppModule,
|
||||
new FastifyAdapter({
|
||||
ignoreTrailingSlash: true,
|
||||
ignoreDuplicateSlashes: true,
|
||||
maxParamLength: 500,
|
||||
routerOptions: {
|
||||
maxParamLength: 1000,
|
||||
ignoreTrailingSlash: true,
|
||||
ignoreDuplicateSlashes: true,
|
||||
},
|
||||
}),
|
||||
{
|
||||
bufferLogs: true,
|
||||
|
||||
@@ -5,13 +5,14 @@ const CONTEXTS_TO_IGNORE = [
|
||||
'InstanceLoader',
|
||||
'RoutesResolver',
|
||||
'RouterExplorer',
|
||||
'LegacyRouteConverter',
|
||||
'WebSocketsController',
|
||||
];
|
||||
|
||||
export function createPinoConfig(): Params {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isDebugMode = process.env.DEBUG_MODE === 'true';
|
||||
const logHttp = process.env.LOG_HTTP === 'true';
|
||||
const 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';
|
||||
|
||||
@@ -32,14 +33,20 @@ export function createPinoConfig(): Params {
|
||||
: undefined,
|
||||
formatters: {
|
||||
level: (label) => ({ level: label }),
|
||||
log: (object: Record<string, unknown>) => {
|
||||
},
|
||||
hooks: {
|
||||
logMethod(inputArgs, method) {
|
||||
if (isProduction && !isDebugMode) {
|
||||
const context = object['context'] as string | undefined;
|
||||
if (context && CONTEXTS_TO_IGNORE.includes(context)) {
|
||||
return { filtered: true };
|
||||
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 object;
|
||||
return method.apply(this, inputArgs);
|
||||
},
|
||||
},
|
||||
serializers: {
|
||||
|
||||
@@ -181,7 +181,9 @@ export class AttachmentController {
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStream = await this.storageService.read(attachment.filePath);
|
||||
const fileStream = await this.storageService.readStream(
|
||||
attachment.filePath,
|
||||
);
|
||||
res.headers({
|
||||
'Content-Type': attachment.mimeType,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
@@ -241,7 +243,9 @@ export class AttachmentController {
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStream = await this.storageService.read(attachment.filePath);
|
||||
const fileStream = await this.storageService.readStream(
|
||||
attachment.filePath,
|
||||
);
|
||||
res.headers({
|
||||
'Content-Type': attachment.mimeType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
@@ -367,14 +371,14 @@ export class AttachmentController {
|
||||
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
|
||||
|
||||
try {
|
||||
const fileStream = await this.storageService.read(filePath);
|
||||
const fileStream = await this.storageService.readStream(filePath);
|
||||
res.headers({
|
||||
'Content-Type': getMimeType(filePath),
|
||||
'Cache-Control': 'private, max-age=86400',
|
||||
});
|
||||
return res.send(fileStream);
|
||||
} catch (err) {
|
||||
// this.logger.error(err);
|
||||
// this.logger.error(err);
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: b6844b019c...f858f127b5
@@ -55,7 +55,7 @@ export class ExportController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const zipFileBuffer = await this.exportService.exportPages(
|
||||
const zipFileStream = await this.exportService.exportPages(
|
||||
dto.pageId,
|
||||
dto.format,
|
||||
dto.includeAttachments,
|
||||
@@ -70,7 +70,7 @@ export class ExportController {
|
||||
'attachment; filename="' + encodeURIComponent(fileName) + '"',
|
||||
});
|
||||
|
||||
res.send(zipFileBuffer);
|
||||
res.send(zipFileStream);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -100,6 +100,6 @@ export class ExportController {
|
||||
'"',
|
||||
});
|
||||
|
||||
res.send(exportFile.fileBuffer);
|
||||
res.send(exportFile.fileStream);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util';
|
||||
import { turndown } from './turndown-utils';
|
||||
import { ExportFormat } from './dto/export-dto';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
getAttachmentIds,
|
||||
getProsemirrorContent,
|
||||
} from '../../common/helpers/prosemirror/utils';
|
||||
import { htmlToMarkdown } from '@docmost/editor-ext';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
@@ -83,7 +83,7 @@ export class ExportService {
|
||||
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
|
||||
'',
|
||||
);
|
||||
return turndown(newPageHtml);
|
||||
return htmlToMarkdown(newPageHtml);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -177,7 +177,7 @@ export class ExportService {
|
||||
|
||||
const fileName = `${space.name}-space-export.zip`;
|
||||
return {
|
||||
fileBuffer: zipFile,
|
||||
fileStream: zipFile,
|
||||
fileName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { jsonToNode } from 'src/collaboration/collaboration.util';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ExportFormat } from './dto/export-dto';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
import { validate as isValidUUID } from 'uuid';
|
||||
@@ -88,7 +89,7 @@ export function replaceInternalLinks(
|
||||
// if link and text are same, use page title
|
||||
if (markLink === node.text) {
|
||||
//@ts-expect-error
|
||||
node.text = getInternalLinkPageName(relativePath);
|
||||
node.text = getInternalLinkPageName(relativePath, currentPagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,10 +100,19 @@ export function replaceInternalLinks(
|
||||
return doc.toJSON();
|
||||
}
|
||||
|
||||
export function getInternalLinkPageName(path: string): string {
|
||||
return decodeURIComponent(
|
||||
path?.split('/').pop().split('.').slice(0, -1).join('.'),
|
||||
);
|
||||
export function getInternalLinkPageName(path: string, currentFilePath?: string): string {
|
||||
const name = path?.split('/').pop().split('.').slice(0, -1).join('.');
|
||||
try {
|
||||
return decodeURIComponent(name);
|
||||
} catch (err) {
|
||||
if (currentFilePath) {
|
||||
Logger.warn(
|
||||
`URI malformed in page ${currentFilePath}: ${name}. Falling back to raw name.`,
|
||||
'ExportUtils',
|
||||
);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractPageSlugId(input: string): string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getEmbedUrlAndProvider } from '@docmost/editor-ext';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import * as path from 'path';
|
||||
import { v7 } from 'uuid';
|
||||
import { InsertableBacklink } from '@docmost/db/types/entity.types';
|
||||
@@ -280,8 +281,18 @@ export async function rewriteInternalLinksToMentionHtml(
|
||||
const $a = $(el);
|
||||
const raw = $a.attr('href')!;
|
||||
if (raw.startsWith('http') || raw.startsWith('/api/')) return;
|
||||
let decodedRaw = raw;
|
||||
try {
|
||||
decodedRaw = decodeURIComponent(raw);
|
||||
} catch (err) {
|
||||
Logger.warn(
|
||||
`URI malformed in page ${currentFilePath}: ${raw}. Falling back to raw path.`,
|
||||
'ImportFormatter',
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = normalize(
|
||||
path.join(path.dirname(currentFilePath), decodeURIComponent(raw)),
|
||||
path.join(path.dirname(currentFilePath), decodedRaw),
|
||||
);
|
||||
const meta = filePathToPageMetaMap.get(resolved);
|
||||
if (!meta) return;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -30,8 +31,13 @@ export function resolveRelativeAttachmentPath(
|
||||
pageDir: string,
|
||||
attachmentCandidates: Map<string, string>,
|
||||
): string | null {
|
||||
const mainRel = decodeURIComponent(raw.replace(/^\.?\/+/, ''));
|
||||
const fallback = path.normalize(path.join(pageDir, mainRel));
|
||||
let mainRel = raw.replace(/^\.?\/+/, '');
|
||||
try {
|
||||
mainRel = decodeURIComponent(mainRel);
|
||||
} catch (err) {
|
||||
Logger.warn(`URI malformed for attachment path: ${mainRel}. Falling back to raw path.`, 'ImportUtils');
|
||||
}
|
||||
const fallback = path.normalize(path.join(pageDir, mainRel)).split(path.sep).join('/');
|
||||
|
||||
if (attachmentCandidates.has(mainRel)) {
|
||||
return mainRel;
|
||||
|
||||
+35
-40
@@ -20,56 +20,50 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.0",
|
||||
"@casl/ability": "^6.7.5",
|
||||
"@casl/ability": "6.8.0",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@floating-ui/dom": "^1.7.3",
|
||||
"@hocuspocus/extension-redis": "^2.15.3",
|
||||
"@hocuspocus/provider": "^2.15.3",
|
||||
"@hocuspocus/server": "^2.15.3",
|
||||
"@hocuspocus/transformer": "^2.15.3",
|
||||
"@hocuspocus/provider": "3.4.3",
|
||||
"@hocuspocus/server": "3.4.3",
|
||||
"@hocuspocus/transformer": "3.4.3",
|
||||
"@joplin/turndown": "^4.0.74",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
||||
"@sindresorhus/slugify": "1.1.0",
|
||||
"@tiptap/core": "2.27.1",
|
||||
"@tiptap/extension-code-block": "2.27.1",
|
||||
"@tiptap/extension-code-block-lowlight": "2.27.1",
|
||||
"@tiptap/extension-collaboration": "2.27.1",
|
||||
"@tiptap/extension-collaboration-cursor": "2.27.1",
|
||||
"@tiptap/extension-color": "2.27.1",
|
||||
"@tiptap/extension-document": "2.27.1",
|
||||
"@tiptap/extension-heading": "2.27.1",
|
||||
"@tiptap/extension-highlight": "2.27.1",
|
||||
"@tiptap/extension-history": "2.27.1",
|
||||
"@tiptap/extension-image": "2.27.1",
|
||||
"@tiptap/extension-link": "2.27.1",
|
||||
"@tiptap/extension-list-item": "2.27.1",
|
||||
"@tiptap/extension-list-keymap": "2.27.1",
|
||||
"@tiptap/extension-placeholder": "2.27.1",
|
||||
"@tiptap/extension-subscript": "2.27.1",
|
||||
"@tiptap/extension-superscript": "2.27.1",
|
||||
"@tiptap/extension-table": "2.27.1",
|
||||
"@tiptap/extension-table-cell": "2.27.1",
|
||||
"@tiptap/extension-table-header": "2.27.1",
|
||||
"@tiptap/extension-table-row": "2.27.1",
|
||||
"@tiptap/extension-task-item": "2.27.1",
|
||||
"@tiptap/extension-task-list": "2.27.1",
|
||||
"@tiptap/extension-text": "2.27.1",
|
||||
"@tiptap/extension-text-align": "2.27.1",
|
||||
"@tiptap/extension-text-style": "2.27.1",
|
||||
"@tiptap/extension-typography": "2.27.1",
|
||||
"@tiptap/extension-underline": "2.27.1",
|
||||
"@tiptap/extension-youtube": "2.27.1",
|
||||
"@tiptap/html": "2.27.1",
|
||||
"@tiptap/pm": "2.27.1",
|
||||
"@tiptap/react": "2.27.1",
|
||||
"@tiptap/starter-kit": "2.27.1",
|
||||
"@tiptap/suggestion": "2.27.1",
|
||||
"@tiptap/core": "3.17.1",
|
||||
"@tiptap/extension-code-block": "3.17.1",
|
||||
"@tiptap/extension-collaboration": "3.17.1",
|
||||
"@tiptap/extension-collaboration-caret": "3.17.1",
|
||||
"@tiptap/extension-color": "3.17.1",
|
||||
"@tiptap/extension-document": "3.17.1",
|
||||
"@tiptap/extension-heading": "3.17.1",
|
||||
"@tiptap/extension-highlight": "3.17.1",
|
||||
"@tiptap/extension-history": "3.17.1",
|
||||
"@tiptap/extension-image": "3.17.1",
|
||||
"@tiptap/extension-link": "3.17.1",
|
||||
"@tiptap/extension-list": "3.17.1",
|
||||
"@tiptap/extension-placeholder": "3.17.1",
|
||||
"@tiptap/extension-subscript": "3.17.1",
|
||||
"@tiptap/extension-superscript": "3.17.1",
|
||||
"@tiptap/extension-table": "3.17.1",
|
||||
"@tiptap/extension-text": "3.17.1",
|
||||
"@tiptap/extension-text-align": "3.17.1",
|
||||
"@tiptap/extension-text-style": "3.17.1",
|
||||
"@tiptap/extension-typography": "3.17.1",
|
||||
"@tiptap/extension-unique-id": "^3.17.1",
|
||||
"@tiptap/extension-youtube": "3.17.1",
|
||||
"@tiptap/html": "3.17.1",
|
||||
"@tiptap/pm": "3.17.1",
|
||||
"@tiptap/react": "3.17.1",
|
||||
"@tiptap/starter-kit": "3.17.1",
|
||||
"@tiptap/suggestion": "3.17.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"bytes": "^3.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"fractional-indexing-jittered": "^1.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"image-dimensions": "^2.5.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"linkifyjs": "^4.3.2",
|
||||
@@ -79,11 +73,12 @@
|
||||
"uuid": "^11.1.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "1.3.7",
|
||||
"yjs": "^13.6.27"
|
||||
"yjs": "^13.6.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nx/js": "20.4.5",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"nx": "20.4.5",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -23,3 +23,4 @@ export * from "./lib/subpages";
|
||||
export * from "./lib/highlight";
|
||||
export * from "./lib/heading/heading";
|
||||
export * from "./lib/unique-id";
|
||||
export * from "./lib/shared-storage";
|
||||
|
||||
@@ -1,126 +1,125 @@
|
||||
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import {
|
||||
insertTrailingNode,
|
||||
MediaUploadOptions,
|
||||
UploadFn,
|
||||
} from "../media-utils";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||
import { IAttachment } from "../types";
|
||||
import { generateNodeId } from "../utils";
|
||||
import { Command } from "@tiptap/core";
|
||||
|
||||
const uploadKey = new PluginKey("attachment-upload");
|
||||
const findAttachmentNodeByPlaceholderId = (
|
||||
doc: Node,
|
||||
placeholderId: string,
|
||||
): { node: Node; pos: number } | null => {
|
||||
let result: { node: Node; pos: number } | null = null;
|
||||
|
||||
export const AttachmentUploadPlugin = ({
|
||||
placeholderClass,
|
||||
}: {
|
||||
placeholderClass: string;
|
||||
}) =>
|
||||
new Plugin({
|
||||
key: uploadKey,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, set) {
|
||||
set = set.map(tr.mapping, tr.doc);
|
||||
// See if the transaction adds or removes any placeholders
|
||||
//@-ts-expect-error - not yet sure what the type I need here
|
||||
const action = tr.getMeta(this);
|
||||
if (action?.add) {
|
||||
const { id, pos, fileName } = action.add;
|
||||
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.setAttribute("class", placeholderClass);
|
||||
|
||||
const uploadingText = document.createElement("span");
|
||||
uploadingText.setAttribute("class", "uploading-text");
|
||||
uploadingText.textContent = `Uploading ${fileName}`;
|
||||
|
||||
placeholder.appendChild(uploadingText);
|
||||
|
||||
const realPos = pos + 1;
|
||||
const deco = Decoration.widget(realPos, placeholder, {
|
||||
id,
|
||||
});
|
||||
set = set.add(tr.doc, [deco]);
|
||||
} else if (action?.remove) {
|
||||
set = set.remove(
|
||||
set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id == action.remove.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
return set;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
doc.descendants((node, pos) => {
|
||||
if (result) return false;
|
||||
if (
|
||||
node.type.name === "attachment" &&
|
||||
node.attrs.placeholder?.id === placeholderId
|
||||
) {
|
||||
result = { node, pos };
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function findPlaceholder(state: EditorState, id: {}) {
|
||||
const decos = uploadKey.getState(state) as DecorationSet;
|
||||
const found = decos.find(undefined, undefined, (spec) => spec.id == id);
|
||||
return found.length ? found[0]?.from : null;
|
||||
}
|
||||
|
||||
export const handleAttachmentUpload =
|
||||
return result;
|
||||
};
|
||||
const handleAttachmentUpload =
|
||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||
async (file, view, pos, pageId, allowMedia) => {
|
||||
async (file, editor, pos, pageId, allowMedia) => {
|
||||
const validated = validateFn?.(file, allowMedia);
|
||||
// @ts-ignore
|
||||
if (!validated) return;
|
||||
// A fresh object to act as the ID for this upload
|
||||
const id = {};
|
||||
|
||||
// Replace the selection with a placeholder
|
||||
const tr = view.state.tr;
|
||||
if (!tr.selection.empty) tr.deleteSelection();
|
||||
const placeholderId = generateNodeId();
|
||||
|
||||
tr.setMeta(uploadKey, {
|
||||
add: {
|
||||
id,
|
||||
pos,
|
||||
fileName: file.name,
|
||||
},
|
||||
});
|
||||
let placeholderInserted = false;
|
||||
|
||||
insertTrailingNode(tr, pos, view);
|
||||
view.dispatch(tr);
|
||||
const insertPlaceholder = (): Command => {
|
||||
return ({ tr, state }) => {
|
||||
const initialPlaceholderNode = state.schema.nodes.attachment?.create({
|
||||
placeholder: {
|
||||
id: placeholderId,
|
||||
},
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
await onUpload(file, pageId).then(
|
||||
(attachment: IAttachment) => {
|
||||
const { schema } = view.state;
|
||||
if (!initialPlaceholderNode) return false;
|
||||
|
||||
const pos = findPlaceholder(view.state, id);
|
||||
const { parent } = tr.doc.resolve(pos);
|
||||
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
|
||||
|
||||
if (pos == null) return;
|
||||
if (isEmptyTextBlock) {
|
||||
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
|
||||
} else {
|
||||
tr.insert(pos, initialPlaceholderNode);
|
||||
}
|
||||
|
||||
if (!attachment) return;
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const replacePlaceholderWithAttachment = (
|
||||
attachment: IAttachment,
|
||||
): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
const node = schema.nodes.attachment?.create({
|
||||
// If the placeholder is not found or attachment is missing, abort the process
|
||||
if (currentPos === null || !attachment) return false;
|
||||
|
||||
// Update the placeholder node with the actual attachment data
|
||||
tr.setNodeMarkup(currentPos, undefined, {
|
||||
url: `/api/files/${attachment.id}/${attachment.fileName}`,
|
||||
name: attachment.fileName,
|
||||
mime: attachment.mimeType,
|
||||
size: attachment.fileSize,
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
if (!node) return;
|
||||
|
||||
const transaction = view.state.tr
|
||||
.replaceWith(pos, pos, node)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
() => {
|
||||
// Deletes the placeholder on error
|
||||
const transaction = view.state.tr
|
||||
.delete(pos, pos)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
);
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const removePlaceholder = (): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
if (currentPos === null) return false;
|
||||
|
||||
tr.delete(currentPos, currentPos + 2);
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
// Only show the placeholder if the upload takes more than 250ms
|
||||
const insertPlaceholderTimeout = setTimeout(() => {
|
||||
editor.commands.command(insertPlaceholder());
|
||||
placeholderInserted = true;
|
||||
}, 250);
|
||||
|
||||
try {
|
||||
const attachment: IAttachment = await onUpload(file, pageId);
|
||||
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
if (placeholderInserted) {
|
||||
setTimeout(() => {
|
||||
editor.commands.command(replacePlaceholderWithAttachment(attachment));
|
||||
}, 100);
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.command(insertPlaceholder())
|
||||
.command(replacePlaceholderWithAttachment(attachment))
|
||||
.run();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
editor.commands.command(removePlaceholder());
|
||||
}
|
||||
};
|
||||
|
||||
export { handleAttachmentUpload };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { AttachmentUploadPlugin } from "./attachment-upload";
|
||||
|
||||
export interface AttachmentOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -13,6 +12,7 @@ export interface AttachmentAttributes {
|
||||
mime?: string; // e.g. application/zip
|
||||
size?: number;
|
||||
attachmentId?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
@@ -75,6 +75,10 @@ export const Attachment = Node.create<AttachmentOptions>({
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
placeholder: {
|
||||
default: null,
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -92,7 +96,7 @@ export const Attachment = Node.create<AttachmentOptions>({
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
[
|
||||
"a",
|
||||
@@ -120,14 +124,9 @@ export const Attachment = Node.create<AttachmentOptions>({
|
||||
},
|
||||
|
||||
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(this.options.view);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
AttachmentUploadPlugin({
|
||||
placeholderClass: "attachment-placeholder",
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ export const Callout = Node.create<CalloutOptions>({
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
0,
|
||||
];
|
||||
@@ -130,6 +130,9 @@ export const Callout = Node.create<CalloutOptions>({
|
||||
},
|
||||
|
||||
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(this.options.view);
|
||||
},
|
||||
|
||||
@@ -193,7 +196,7 @@ export const Callout = Node.create<CalloutOptions>({
|
||||
|
||||
tr.delete(pos, pos + nodeSize);
|
||||
tr.setSelection(
|
||||
TextSelection.near(tr.doc.resolve(previousPosition - 1)),
|
||||
TextSelection.near(tr.doc.resolve(previousPosition - 1))
|
||||
);
|
||||
tr.insert(previousPosition - 1, content);
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import CodeBlockLowlight, {
|
||||
CodeBlockLowlightOptions,
|
||||
} from "@tiptap/extension-code-block-lowlight";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
export interface CustomCodeBlockOptions extends CodeBlockLowlightOptions {
|
||||
view: any;
|
||||
}
|
||||
|
||||
const TAB_CHAR = "\u00A0\u00A0";
|
||||
|
||||
export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
|
||||
{
|
||||
selectable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
Tab: () => {
|
||||
if (this.editor.isActive("codeBlock")) {
|
||||
this.editor
|
||||
.chain()
|
||||
.command(({ tr }) => {
|
||||
tr.insertText(TAB_CHAR);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
"Mod-a": () => {
|
||||
if (this.editor.isActive("codeBlock")) {
|
||||
const { state } = this.editor;
|
||||
const { $from } = state.selection;
|
||||
|
||||
let codeBlockNode = null;
|
||||
let codeBlockPos = null;
|
||||
let depth = 0;
|
||||
|
||||
for (depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type.name === "codeBlock") {
|
||||
codeBlockNode = node;
|
||||
codeBlockPos = $from.start(depth) - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (codeBlockNode && codeBlockPos !== null) {
|
||||
const codeBlockStart = codeBlockPos;
|
||||
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
|
||||
|
||||
const contentStart = codeBlockStart + 1;
|
||||
const contentEnd = codeBlockEnd - 1;
|
||||
|
||||
this.editor.commands.setTextSelection({
|
||||
from: contentStart,
|
||||
to: contentEnd,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { CodeBlockOptions } from "@tiptap/extension-code-block";
|
||||
import CodeBlock from "@tiptap/extension-code-block";
|
||||
|
||||
import { LowlightPlugin } from "./lowlight-plugin.js";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
export interface CodeBlockLowlightOptions extends CodeBlockOptions {
|
||||
/**
|
||||
* The lowlight instance.
|
||||
*/
|
||||
lowlight: any;
|
||||
view: any;
|
||||
}
|
||||
|
||||
const TAB_CHAR = "\u00A0\u00A0";
|
||||
|
||||
/**
|
||||
* This extension allows you to highlight code blocks with lowlight.
|
||||
* @see https://tiptap.dev/api/nodes/code-block-lowlight
|
||||
*/
|
||||
export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
||||
selectable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
lowlight: {},
|
||||
languageClassPrefix: "language-",
|
||||
exitOnTripleEnter: true,
|
||||
exitOnArrowDown: true,
|
||||
defaultLanguage: null,
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
Tab: () => {
|
||||
if (this.editor.isActive("codeBlock")) {
|
||||
this.editor
|
||||
.chain()
|
||||
.command(({ tr }) => {
|
||||
tr.insertText(TAB_CHAR);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
"Mod-a": () => {
|
||||
if (this.editor.isActive("codeBlock")) {
|
||||
const { state } = this.editor;
|
||||
const { $from } = state.selection;
|
||||
|
||||
let codeBlockNode = null;
|
||||
let codeBlockPos = null;
|
||||
let depth = 0;
|
||||
|
||||
for (depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type.name === "codeBlock") {
|
||||
codeBlockNode = node;
|
||||
codeBlockPos = $from.start(depth) - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (codeBlockNode && codeBlockPos !== null) {
|
||||
const codeBlockStart = codeBlockPos;
|
||||
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
|
||||
|
||||
const contentStart = codeBlockStart + 1;
|
||||
const contentEnd = codeBlockEnd - 1;
|
||||
|
||||
this.editor.commands.setTextSelection({
|
||||
from: contentStart,
|
||||
to: contentEnd,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
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(this.options.view);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
...(this.parent?.() || []),
|
||||
LowlightPlugin({
|
||||
name: this.name,
|
||||
lowlight: this.options.lowlight,
|
||||
defaultLanguage: this.options.defaultLanguage,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { CustomCodeBlock } from "./custom-code-block";
|
||||
@@ -0,0 +1,159 @@
|
||||
import { findChildren } from '@tiptap/core'
|
||||
import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
// @ts-ignore
|
||||
import highlight from 'highlight.js/lib/core'
|
||||
|
||||
function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] {
|
||||
return nodes
|
||||
.map(node => {
|
||||
const classes = [...className, ...(node.properties ? node.properties.className : [])]
|
||||
|
||||
if (node.children) {
|
||||
return parseNodes(node.children, classes)
|
||||
}
|
||||
|
||||
return {
|
||||
text: node.value,
|
||||
classes,
|
||||
}
|
||||
})
|
||||
.flat()
|
||||
}
|
||||
|
||||
function getHighlightNodes(result: any) {
|
||||
// `.value` for lowlight v1, `.children` for lowlight v2
|
||||
return result.value || result.children || []
|
||||
}
|
||||
|
||||
function registered(aliasOrLanguage: string) {
|
||||
return Boolean(highlight.getLanguage(aliasOrLanguage))
|
||||
}
|
||||
|
||||
function getDecorations({
|
||||
doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}: {
|
||||
doc: ProsemirrorNode
|
||||
name: string
|
||||
lowlight: any
|
||||
defaultLanguage: string | null | undefined
|
||||
}) {
|
||||
const decorations: Decoration[] = []
|
||||
|
||||
findChildren(doc, node => node.type.name === name).forEach(block => {
|
||||
let from = block.pos + 1
|
||||
const language = block.node.attrs.language || defaultLanguage
|
||||
const languages = lowlight.listLanguages()
|
||||
|
||||
const nodes =
|
||||
language && (languages.includes(language) || registered(language) || lowlight.registered?.(language))
|
||||
? getHighlightNodes(lowlight.highlight(language, block.node.textContent))
|
||||
: getHighlightNodes(lowlight.highlightAuto(block.node.textContent))
|
||||
|
||||
parseNodes(nodes).forEach(node => {
|
||||
const to = from + node.text.length
|
||||
|
||||
if (node.classes.length) {
|
||||
const decoration = Decoration.inline(from, to, {
|
||||
class: node.classes.join(' '),
|
||||
})
|
||||
|
||||
decorations.push(decoration)
|
||||
}
|
||||
|
||||
from = to
|
||||
})
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
function isFunction(param: any): param is Function {
|
||||
return typeof param === 'function'
|
||||
}
|
||||
|
||||
export function LowlightPlugin({
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}: {
|
||||
name: string
|
||||
lowlight: any
|
||||
defaultLanguage: string | null | undefined
|
||||
}) {
|
||||
if (!['highlight', 'highlightAuto', 'listLanguages'].every(api => isFunction(lowlight[api]))) {
|
||||
throw Error('You should provide an instance of lowlight to use the code-block-lowlight extension')
|
||||
}
|
||||
|
||||
const lowlightPlugin: Plugin<any> = new Plugin({
|
||||
key: new PluginKey('lowlight'),
|
||||
|
||||
state: {
|
||||
init: (_, { doc }) =>
|
||||
getDecorations({
|
||||
doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}),
|
||||
apply: (transaction, decorationSet, oldState, newState) => {
|
||||
const oldNodeName = oldState.selection.$head.parent.type.name
|
||||
const newNodeName = newState.selection.$head.parent.type.name
|
||||
const oldNodes = findChildren(oldState.doc, node => node.type.name === name)
|
||||
const newNodes = findChildren(newState.doc, node => node.type.name === name)
|
||||
|
||||
if (
|
||||
transaction.docChanged &&
|
||||
// Apply decorations if:
|
||||
// selection includes named node,
|
||||
([oldNodeName, newNodeName].includes(name) ||
|
||||
// OR transaction adds/removes named node,
|
||||
newNodes.length !== oldNodes.length ||
|
||||
// OR transaction has changes that completely encapsulte a node
|
||||
// (for example, a transaction that affects the entire document).
|
||||
// Such transactions can happen during collab syncing via y-prosemirror, for example.
|
||||
transaction.steps.some(step => {
|
||||
// @ts-ignore
|
||||
return (
|
||||
// @ts-ignore
|
||||
step.from !== undefined &&
|
||||
// @ts-ignore
|
||||
step.to !== undefined &&
|
||||
oldNodes.some(node => {
|
||||
// @ts-ignore
|
||||
return (
|
||||
// @ts-ignore
|
||||
node.pos >= step.from &&
|
||||
// @ts-ignore
|
||||
node.pos + node.node.nodeSize <= step.to
|
||||
)
|
||||
})
|
||||
)
|
||||
}))
|
||||
) {
|
||||
return getDecorations({
|
||||
doc: transaction.doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
})
|
||||
}
|
||||
|
||||
return decorationSet.map(transaction.mapping, transaction.doc)
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations(state) {
|
||||
return lowlightPlugin.getState(state)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return lowlightPlugin
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export const Details = Node.create<DetailsOptions>({
|
||||
content: "detailsSummary detailsContent",
|
||||
defining: true,
|
||||
isolating: true,
|
||||
// @ts-ignore
|
||||
allowGapCursor: false,
|
||||
addOptions() {
|
||||
return {
|
||||
|
||||
@@ -41,45 +41,45 @@ export const Drawio = Node.create<DrawioOptions>({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-src'),
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-src"),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-src': attributes.src,
|
||||
"data-src": attributes.src,
|
||||
}),
|
||||
},
|
||||
title: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute('data-title'),
|
||||
parseHTML: (element) => element.getAttribute("data-title"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-title': attributes.title,
|
||||
"data-title": attributes.title,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: '100%',
|
||||
parseHTML: (element) => element.getAttribute('data-width'),
|
||||
default: "100%",
|
||||
parseHTML: (element) => element.getAttribute("data-width"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-width': attributes.width,
|
||||
"data-width": attributes.width,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-size'),
|
||||
parseHTML: (element) => element.getAttribute("data-size"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-size': attributes.size,
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: 'center',
|
||||
parseHTML: (element) => element.getAttribute('data-align'),
|
||||
default: "center",
|
||||
parseHTML: (element) => element.getAttribute("data-align"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-align': attributes.align,
|
||||
"data-align": attributes.align,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute('data-attachment-id'),
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-attachment-id': attributes.attachmentId,
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -95,13 +95,20 @@ export const Drawio = Node.create<DrawioOptions>({
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ 'data-type': this.name },
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }],
|
||||
[
|
||||
"img",
|
||||
{
|
||||
src: HTMLAttributes["data-src"],
|
||||
alt: HTMLAttributes["data-title"],
|
||||
width: HTMLAttributes["data-width"],
|
||||
},
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
@@ -119,6 +126,9 @@ export const Drawio = Node.create<DrawioOptions>({
|
||||
},
|
||||
|
||||
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(this.options.view);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ export const embedProviders: IEmbedProvider[] = [
|
||||
id: "gsheets",
|
||||
name: "Google Sheets",
|
||||
regex:
|
||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/e\/([a-zA-Z0-9_-]+)\/.*$/,
|
||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/([a-zA-Z0-9_-]+)\/.*$/,
|
||||
getEmbedUrl: (match, url: string) => {
|
||||
return url;
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { sanitizeUrl } from './utils';
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { sanitizeUrl } from "./utils";
|
||||
|
||||
export interface EmbedOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -14,7 +14,7 @@ export interface EmbedAttributes {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
embeds: {
|
||||
setEmbed: (attributes?: EmbedAttributes) => ReturnType;
|
||||
@@ -23,9 +23,9 @@ declare module '@tiptap/core' {
|
||||
}
|
||||
|
||||
export const Embed = Node.create<EmbedOptions>({
|
||||
name: 'embed',
|
||||
name: "embed",
|
||||
inline: false,
|
||||
group: 'block',
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
@@ -40,41 +40,41 @@ export const Embed = Node.create<EmbedOptions>({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: '',
|
||||
default: "",
|
||||
parseHTML: (element) => {
|
||||
const src = element.getAttribute('data-src');
|
||||
const src = element.getAttribute("data-src");
|
||||
return sanitizeUrl(src);
|
||||
},
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
'data-src': sanitizeUrl(attributes.src),
|
||||
"data-src": sanitizeUrl(attributes.src),
|
||||
}),
|
||||
},
|
||||
provider: {
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-provider'),
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-provider"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
'data-provider': attributes.provider,
|
||||
"data-provider": attributes.provider,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: 'center',
|
||||
parseHTML: (element) => element.getAttribute('data-align'),
|
||||
default: "center",
|
||||
parseHTML: (element) => element.getAttribute("data-align"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
'data-align': attributes.align,
|
||||
"data-align": attributes.align,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: 640,
|
||||
parseHTML: (element) => element.getAttribute('data-width'),
|
||||
parseHTML: (element) => element.getAttribute("data-width"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
'data-width': attributes.width,
|
||||
"data-width": attributes.width,
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
default: 480,
|
||||
parseHTML: (element) => element.getAttribute('data-height'),
|
||||
parseHTML: (element) => element.getAttribute("data-height"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
'data-height': attributes.height,
|
||||
"data-height": attributes.height,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -91,13 +91,13 @@ export const Embed = Node.create<EmbedOptions>({
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const src = HTMLAttributes["data-src"];
|
||||
const safeHref = sanitizeUrl(src);
|
||||
|
||||
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
[
|
||||
"a",
|
||||
@@ -120,9 +120,9 @@ export const Embed = Node.create<EmbedOptions>({
|
||||
...attrs,
|
||||
src: sanitizeUrl(attrs.src),
|
||||
};
|
||||
|
||||
|
||||
return commands.insertContent({
|
||||
type: 'embed',
|
||||
type: "embed",
|
||||
attrs: validatedAttrs,
|
||||
});
|
||||
},
|
||||
@@ -130,6 +130,9 @@ export const Embed = Node.create<EmbedOptions>({
|
||||
},
|
||||
|
||||
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(this.options.view);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
export interface ExcalidrawOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -14,7 +14,7 @@ export interface ExcalidrawAttributes {
|
||||
attachmentId?: string;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
excalidraw: {
|
||||
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
|
||||
@@ -23,9 +23,9 @@ declare module '@tiptap/core' {
|
||||
}
|
||||
|
||||
export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
name: 'excalidraw',
|
||||
name: "excalidraw",
|
||||
inline: false,
|
||||
group: 'block',
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
@@ -40,45 +40,45 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-src'),
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-src"),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-src': attributes.src,
|
||||
"data-src": attributes.src,
|
||||
}),
|
||||
},
|
||||
title: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute('data-title'),
|
||||
parseHTML: (element) => element.getAttribute("data-title"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-title': attributes.title,
|
||||
"data-title": attributes.title,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: '100%',
|
||||
parseHTML: (element) => element.getAttribute('data-width'),
|
||||
default: "100%",
|
||||
parseHTML: (element) => element.getAttribute("data-width"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-width': attributes.width,
|
||||
"data-width": attributes.width,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-size'),
|
||||
parseHTML: (element) => element.getAttribute("data-size"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-size': attributes.size,
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: 'center',
|
||||
parseHTML: (element) => element.getAttribute('data-align'),
|
||||
default: "center",
|
||||
parseHTML: (element) => element.getAttribute("data-align"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-align': attributes.align,
|
||||
"data-align": attributes.align,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute('data-attachment-id'),
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-attachment-id': attributes.attachmentId,
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -94,13 +94,20 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ 'data-type': this.name },
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }],
|
||||
[
|
||||
"img",
|
||||
{
|
||||
src: HTMLAttributes["data-src"],
|
||||
alt: HTMLAttributes["data-title"],
|
||||
width: HTMLAttributes["data-width"],
|
||||
},
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
@@ -110,7 +117,7 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
(attrs: ExcalidrawAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: 'excalidraw',
|
||||
type: "excalidraw",
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
@@ -118,6 +125,9 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
},
|
||||
|
||||
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(this.options.view);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,127 +1,145 @@
|
||||
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { insertTrailingNode, MediaUploadOptions, UploadFn } from "../media-utils";
|
||||
import { imageDimensionsFromStream } from "image-dimensions";
|
||||
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||
import { IAttachment } from "../types";
|
||||
import { generateNodeId } from "../utils";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { Command } from "@tiptap/core";
|
||||
|
||||
const uploadKey = new PluginKey("image-upload");
|
||||
const findImageNodeByPlaceholderId = (
|
||||
doc: Node,
|
||||
placeholderId: string,
|
||||
): { node: Node; pos: number } | null => {
|
||||
let result: { node: Node; pos: number } | null = null;
|
||||
|
||||
export const ImageUploadPlugin = ({
|
||||
placeholderClass,
|
||||
}: {
|
||||
placeholderClass: string;
|
||||
}) =>
|
||||
new Plugin({
|
||||
key: uploadKey,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, set) {
|
||||
set = set.map(tr.mapping, tr.doc);
|
||||
// See if the transaction adds or removes any placeholders
|
||||
//@-ts-expect-error - not yet sure what the type I need here
|
||||
const action = tr.getMeta(this);
|
||||
if (action?.add) {
|
||||
const { id, pos, src } = action.add;
|
||||
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.setAttribute("class", "img-placeholder");
|
||||
const image = document.createElement("img");
|
||||
image.setAttribute("class", placeholderClass);
|
||||
image.src = src;
|
||||
placeholder.appendChild(image);
|
||||
const deco = Decoration.widget(pos + 1, placeholder, {
|
||||
id,
|
||||
});
|
||||
set = set.add(tr.doc, [deco]);
|
||||
} else if (action?.remove) {
|
||||
set = set.remove(
|
||||
set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id == action.remove.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
return set;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
doc.descendants((node, pos) => {
|
||||
if (result) return false;
|
||||
if (
|
||||
node.type.name === "image" &&
|
||||
node.attrs.placeholder?.id === placeholderId
|
||||
) {
|
||||
result = { node, pos };
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function findPlaceholder(state: EditorState, id: {}) {
|
||||
const decos = uploadKey.getState(state) as DecorationSet;
|
||||
const found = decos.find(undefined, undefined, (spec) => spec.id == id);
|
||||
return found.length ? found[0]?.from : null;
|
||||
}
|
||||
|
||||
export const handleImageUpload =
|
||||
return result;
|
||||
};
|
||||
const handleImageUpload =
|
||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||
async (file, view, pos, pageId) => {
|
||||
async (file, editor, pos, pageId) => {
|
||||
// check if the file is an image
|
||||
const validated = validateFn?.(file);
|
||||
// @ts-ignore
|
||||
if (!validated) return;
|
||||
// A fresh object to act as the ID for this upload
|
||||
const id = {};
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
const tr = view.state.tr;
|
||||
// Replace the selection with a placeholder
|
||||
if (!tr.selection.empty) tr.deleteSelection();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const imageDimensions = await imageDimensionsFromStream(file.stream());
|
||||
const placeholderId = generateNodeId();
|
||||
const aspectRatio = imageDimensions
|
||||
? imageDimensions.width / imageDimensions.height
|
||||
: undefined;
|
||||
|
||||
tr.setMeta(uploadKey, {
|
||||
add: {
|
||||
id,
|
||||
pos,
|
||||
src: reader.result,
|
||||
},
|
||||
});
|
||||
let placeholderInserted = false;
|
||||
|
||||
insertTrailingNode(tr, pos, view);
|
||||
view.dispatch(tr);
|
||||
editor.storage.shared.imagePreviews =
|
||||
editor.storage.shared.imagePreviews || {};
|
||||
editor.storage.shared.imagePreviews[placeholderId] = objectUrl;
|
||||
|
||||
const insertPlaceholder = (): Command => {
|
||||
return ({ tr, state }) => {
|
||||
const initialPlaceholderNode = state.schema.nodes.image?.create({
|
||||
placeholder: {
|
||||
id: placeholderId,
|
||||
name: file.name,
|
||||
},
|
||||
aspectRatio,
|
||||
});
|
||||
|
||||
if (!initialPlaceholderNode) return false;
|
||||
|
||||
const { parent } = tr.doc.resolve(pos);
|
||||
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
|
||||
|
||||
if (isEmptyTextBlock) {
|
||||
// Replace e.g. empty paragraph with the image
|
||||
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
|
||||
} else {
|
||||
tr.insert(pos, initialPlaceholderNode);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const replacePlaceholderWithImage = (attachment: IAttachment): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
await onUpload(file, pageId).then(
|
||||
(attachment: IAttachment) => {
|
||||
const { schema } = view.state;
|
||||
// If the placeholder is not found or attachment is missing, abort the process
|
||||
if (currentPos === null || !attachment) return false;
|
||||
|
||||
const pos = findPlaceholder(view.state, id);
|
||||
|
||||
// If the content around the placeholder has been deleted, drop
|
||||
// the image
|
||||
if (pos == null) return;
|
||||
|
||||
// Otherwise, insert it at the placeholder's position, and remove
|
||||
// the placeholder
|
||||
|
||||
if (!attachment) return;
|
||||
|
||||
const node = schema.nodes.image?.create({
|
||||
// Update the placeholder node with the actual image data
|
||||
tr.setNodeMarkup(currentPos, undefined, {
|
||||
src: `/api/files/${attachment.id}/${attachment.fileName}`,
|
||||
attachmentId: attachment.id,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
aspectRatio,
|
||||
});
|
||||
if (!node) return;
|
||||
|
||||
const transaction = view.state.tr
|
||||
.replaceWith(pos, pos, node)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
() => {
|
||||
// Deletes the image placeholder on error
|
||||
const transaction = view.state.tr
|
||||
.delete(pos, pos)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
);
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const removePlaceholder = (): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
if (currentPos === null) return false;
|
||||
|
||||
// Remove the placeholder node
|
||||
tr.delete(currentPos, currentPos + 2);
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
// Only show the placeholder if the upload takes more than 250ms
|
||||
const insertPlaceholderTimeout = setTimeout(() => {
|
||||
editor.commands.command(insertPlaceholder());
|
||||
placeholderInserted = true;
|
||||
}, 250);
|
||||
const disposePreviewFile = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
||||
if (editor.storage.shared.imagePreviews) {
|
||||
delete editor.storage.shared.imagePreviews[placeholderId];
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const attachment: IAttachment = await onUpload(file, pageId);
|
||||
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
if (placeholderInserted) {
|
||||
setTimeout(() => {
|
||||
editor.commands.command(replacePlaceholderWithImage(attachment));
|
||||
disposePreviewFile();
|
||||
}, 100);
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.command(insertPlaceholder())
|
||||
.command(replacePlaceholderWithImage(attachment))
|
||||
.run();
|
||||
disposePreviewFile();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
editor.commands.command(removePlaceholder());
|
||||
disposePreviewFile();
|
||||
}
|
||||
};
|
||||
|
||||
export { handleImageUpload };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { ImageUploadPlugin } from "./image-upload";
|
||||
import { mergeAttributes, Range } from "@tiptap/core";
|
||||
|
||||
export interface ImageOptions extends DefaultImageOptions {
|
||||
@@ -10,11 +9,15 @@ export interface ImageOptions extends DefaultImageOptions {
|
||||
export interface ImageAttributes {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
align?: string;
|
||||
attachmentId?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
aspectRatio?: number;
|
||||
placeholder?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
@@ -22,7 +25,7 @@ declare module "@tiptap/core" {
|
||||
imageBlock: {
|
||||
setImage: (attributes: ImageAttributes) => ReturnType;
|
||||
setImageAt: (
|
||||
attributes: ImageAttributes & { pos: number | Range },
|
||||
attributes: ImageAttributes & { pos: number | Range }
|
||||
) => ReturnType;
|
||||
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||
setImageWidth: (width: number) => ReturnType;
|
||||
@@ -90,6 +93,17 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
"data-aspect-ratio": attributes.aspectRatio,
|
||||
}),
|
||||
},
|
||||
placeholder: {
|
||||
default: null,
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -135,14 +149,9 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
},
|
||||
|
||||
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(this.options.view);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
ImageUploadPlugin({
|
||||
placeholderClass: "image-upload",
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./utils/marked.utils";
|
||||
export * from "./utils/turndown.utils";
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Flexible `basename` implementation for node and the browser
|
||||
* @see https://stackoverflow.com/a/59907288/2228771
|
||||
*/
|
||||
export function getBasename(path: string) {
|
||||
// make sure the basename is not empty, if string ends with separator
|
||||
let end = path.length - 1;
|
||||
while (path[end] === '/' || path[end] === '\\') {
|
||||
--end;
|
||||
}
|
||||
|
||||
// support mixing of Win + Unix path separators
|
||||
const i1 = path.lastIndexOf('/', end);
|
||||
const i2 = path.lastIndexOf('\\', end);
|
||||
|
||||
let start: number;
|
||||
if (i1 === -1) {
|
||||
if (i2 === -1) {
|
||||
// no separator in the whole thing
|
||||
return path;
|
||||
}
|
||||
start = i2;
|
||||
} else if (i2 === -1) {
|
||||
start = i1;
|
||||
} else {
|
||||
start = Math.max(i1, i2);
|
||||
}
|
||||
return path.substring(start + 1, end + 1);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Map @joplin/turndown types to @types/turndown
|
||||
declare module "@joplin/turndown" {
|
||||
import TurndownService from "turndown";
|
||||
export = TurndownService;
|
||||
}
|
||||
|
||||
declare module "@joplin/turndown-plugin-gfm" {
|
||||
import TurndownService from "turndown";
|
||||
export const tables: TurndownService.Plugin;
|
||||
export const strikethrough: TurndownService.Plugin;
|
||||
export const highlightedCodeBlock: TurndownService.Plugin;
|
||||
}
|
||||
+34
-31
@@ -1,22 +1,22 @@
|
||||
import * as TurndownService from '@joplin/turndown';
|
||||
import * as _TurndownService from '@joplin/turndown';
|
||||
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
|
||||
import * as path from 'path';
|
||||
import { getBasename } from './basename';
|
||||
|
||||
export function turndown(html: string): string {
|
||||
// CJS/ESM interop: .default exists in Vite, not in NestJS
|
||||
const TurndownService = (_TurndownService as any).default || _TurndownService;
|
||||
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
const turndownService = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
hr: '---',
|
||||
bulletListMarker: '-',
|
||||
});
|
||||
const tables = TurndownPluginGfm.tables;
|
||||
const strikethrough = TurndownPluginGfm.strikethrough;
|
||||
const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock;
|
||||
|
||||
turndownService.use([
|
||||
tables,
|
||||
strikethrough,
|
||||
highlightedCodeBlock,
|
||||
TurndownPluginGfm.tables,
|
||||
TurndownPluginGfm.strikethrough,
|
||||
TurndownPluginGfm.highlightedCodeBlock,
|
||||
taskList,
|
||||
callout,
|
||||
preserveDetail,
|
||||
@@ -29,34 +29,33 @@ export function turndown(html: string): string {
|
||||
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||
}
|
||||
|
||||
function listParagraph(turndownService: TurndownService) {
|
||||
function listParagraph(turndownService: _TurndownService) {
|
||||
turndownService.addRule('paragraph', {
|
||||
filter: ['p'],
|
||||
replacement: (content: any, node: HTMLInputElement) => {
|
||||
replacement: (content: string, node: HTMLInputElement) => {
|
||||
if (node.parentElement?.nodeName === 'LI') {
|
||||
return content;
|
||||
}
|
||||
|
||||
return `\n\n${content}\n\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function callout(turndownService: TurndownService) {
|
||||
function callout(turndownService: _TurndownService) {
|
||||
turndownService.addRule('callout', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
|
||||
);
|
||||
},
|
||||
replacement: function (content: any, node: HTMLInputElement) {
|
||||
replacement: function (content: string, node: HTMLInputElement) {
|
||||
const calloutType = node.getAttribute('data-callout-type');
|
||||
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function taskList(turndownService: TurndownService) {
|
||||
function taskList(turndownService: _TurndownService) {
|
||||
turndownService.addRule('taskListItem', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
@@ -64,32 +63,36 @@ function taskList(turndownService: TurndownService) {
|
||||
node.parentNode.nodeName === 'UL'
|
||||
);
|
||||
},
|
||||
replacement: function (content: any, node: HTMLInputElement) {
|
||||
replacement: function (content: string, node: HTMLInputElement) {
|
||||
const checkbox = node.querySelector(
|
||||
'input[type="checkbox"]',
|
||||
) as HTMLInputElement;
|
||||
const isChecked = checkbox.checked;
|
||||
|
||||
|
||||
// Process content like regular list items
|
||||
content = content
|
||||
.replace(/^\n+/, '') // remove leading newlines
|
||||
.replace(/\n+$/, '\n') // replace trailing newlines with just a single one
|
||||
.replace(/\n/gm, '\n '); // indent nested content with 2 spaces
|
||||
|
||||
|
||||
// Create the checkbox prefix
|
||||
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
|
||||
|
||||
return prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '');
|
||||
|
||||
return (
|
||||
prefix +
|
||||
content +
|
||||
(node.nextSibling && !/\n$/.test(content) ? '\n' : '')
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function preserveDetail(turndownService: TurndownService) {
|
||||
function preserveDetail(turndownService: _TurndownService) {
|
||||
turndownService.addRule('preserveDetail', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return node.nodeName === 'DETAILS';
|
||||
},
|
||||
replacement: function (content: any, node: HTMLInputElement) {
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const summary = node.querySelector(':scope > summary');
|
||||
let detailSummary = '';
|
||||
|
||||
@@ -111,7 +114,7 @@ function preserveDetail(turndownService: TurndownService) {
|
||||
});
|
||||
}
|
||||
|
||||
function mathInline(turndownService: TurndownService) {
|
||||
function mathInline(turndownService: _TurndownService) {
|
||||
turndownService.addRule('mathInline', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
@@ -119,13 +122,13 @@ function mathInline(turndownService: TurndownService) {
|
||||
node.getAttribute('data-type') === 'mathInline'
|
||||
);
|
||||
},
|
||||
replacement: function (content: any, node: HTMLInputElement) {
|
||||
replacement: function (content: string) {
|
||||
return `$${content}$`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mathBlock(turndownService: TurndownService) {
|
||||
function mathBlock(turndownService: _TurndownService) {
|
||||
turndownService.addRule('mathBlock', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
@@ -133,32 +136,32 @@ function mathBlock(turndownService: TurndownService) {
|
||||
node.getAttribute('data-type') === 'mathBlock'
|
||||
);
|
||||
},
|
||||
replacement: function (content: any, node: HTMLInputElement) {
|
||||
replacement: function (content: string) {
|
||||
return `\n$$\n${content}\n$$\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function iframeEmbed(turndownService: TurndownService) {
|
||||
function iframeEmbed(turndownService: _TurndownService) {
|
||||
turndownService.addRule('iframeEmbed', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return node.nodeName === 'IFRAME';
|
||||
},
|
||||
replacement: function (content: any, node: HTMLInputElement) {
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const src = node.getAttribute('src');
|
||||
return '[' + src + '](' + src + ')';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function video(turndownService: TurndownService) {
|
||||
function video(turndownService: _TurndownService) {
|
||||
turndownService.addRule('video', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return node.tagName === 'VIDEO';
|
||||
},
|
||||
replacement: function (content: any, node: HTMLInputElement) {
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const src = node.getAttribute('src') || '';
|
||||
const name = path.basename(src);
|
||||
const name = getBasename(src) || src;
|
||||
return '[' + name + '](' + src + ')';
|
||||
},
|
||||
});
|
||||
@@ -63,6 +63,9 @@ export const MathBlock = Node.create({
|
||||
},
|
||||
|
||||
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(this.options.view);
|
||||
},
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ export const MathInline = Node.create<MathInlineOption>({
|
||||
},
|
||||
|
||||
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(this.options.view);
|
||||
},
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user