Compare commits

..

3 Commits

Author SHA1 Message Date
Philipinho db404815b0 resolve comment, delete comment 2026-01-17 02:55:38 +00:00
Philipinho d9ebeb2b85 working 2026-01-17 02:42:06 +00:00
Philipinho c3a9a52b7f WIP 2026-01-17 02:23:47 +00:00
199 changed files with 3615 additions and 5348 deletions
+1 -7
View File
@@ -46,10 +46,4 @@ DRAWIO_URL=
DISABLE_TELEMETRY=false
# Enable debug logging in production (default: false)
DEBUG_MODE=false
# Log database queries
DEBUG_DB=false
# Log http requests
LOG_HTTP=false
DEBUG_MODE=false
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.25.0-beta.1",
"version": "0.24.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -24,6 +24,7 @@
"@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",
@@ -53,6 +54,7 @@
"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,7 +29,6 @@
"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",
@@ -235,9 +234,7 @@
"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.",
@@ -254,7 +251,6 @@
"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}}",
@@ -330,8 +326,6 @@
"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,7 +29,6 @@
"Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.",
"Confirm": "Confirm",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link",
"Create": "Create",
"Create group": "Create group",
@@ -254,7 +253,6 @@
"Export failed:": "Export failed:",
"export error": "export error",
"Export page": "Export page",
"Export successful": "Export successful",
"Export space": "Export space",
"Export {{type}}": "Export {{type}}",
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
@@ -330,8 +328,6 @@
"Upload any image from your device.": "Upload any image from your device.",
"Upload any 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,7 +29,6 @@
"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",
@@ -254,7 +253,6 @@
"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",
@@ -330,8 +328,6 @@
"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,7 +29,6 @@
"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",
@@ -254,7 +253,6 @@
"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",
@@ -330,8 +328,6 @@
"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,7 +29,6 @@
"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",
@@ -254,7 +253,6 @@
"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}}",
@@ -330,8 +328,6 @@
"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,7 +29,6 @@
"Choose your preferred interface language.": "お好みの言語を選択してください",
"Choose your preferred page width.": "お好みのページ幅を選択してください",
"Confirm": "確認",
"Copy as Markdown": "Markdownとしてコピー",
"Copy link": "リンクをコピー",
"Create": "新規作成",
"Create group": "グループを作成",
@@ -254,7 +253,6 @@
"Export failed:": "エクスポートに失敗しました:",
"export error": "エクスポートエラー",
"Export page": "エクスポートページ",
"Export successful": "エクスポート成功",
"Export space": "エクスポートスペース",
"Export {{type}}": "{{type}}をエクスポート",
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
@@ -330,8 +328,6 @@
"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,7 +29,6 @@
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
"Confirm": "확인",
"Copy as Markdown": "Markdown으로 복사",
"Copy link": "링크 복사",
"Create": "생성",
"Create group": "팀 생성",
@@ -254,7 +253,6 @@
"Export failed:": "내보내기 실패:",
"export error": "내보내기 오류",
"Export page": "페이지 내보내기",
"Export successful": "내보내기 성공",
"Export space": "Space 내보내기",
"Export {{type}}": "{{type}} 내보내기",
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
@@ -330,8 +328,6 @@
"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,7 +29,6 @@
"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",
@@ -254,7 +253,6 @@
"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}}",
@@ -330,8 +328,6 @@
"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,7 +29,6 @@
"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",
@@ -254,7 +253,6 @@
"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}}",
@@ -330,8 +328,6 @@
"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,7 +29,6 @@
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
"Confirm": "Подтвердить",
"Copy as Markdown": "Копировать как Markdown",
"Copy link": "Копировать ссылку",
"Create": "Создать",
"Create group": "Создать группу",
@@ -254,7 +253,6 @@
"Export failed:": "Экспортирование не удалось:",
"export error": "ошибка экспорта",
"Export page": "Экспорт страницы",
"Export successful": "Экспорт выполнен успешно",
"Export space": "Экспорт пространства",
"Export {{type}}": "Экспорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
@@ -330,8 +328,6 @@
"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,7 +29,6 @@
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
"Confirm": "Підтвердити",
"Copy as Markdown": "Скопіювати як Markdown",
"Copy link": "Копіювати посилання",
"Create": "Створити",
"Create group": "Створити групу",
@@ -254,7 +253,6 @@
"Export failed:": "Експортування не вдалося:",
"export error": "помилка експорту",
"Export page": "Експорт сторінки",
"Export successful": "Експорт виконано успішно",
"Export space": "Експорт простору",
"Export {{type}}": "Експорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
@@ -330,8 +328,6 @@
"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,7 +29,6 @@
"Choose your preferred interface language.": "选择您喜欢的界面语言。",
"Choose your preferred page width.": "选择您喜欢的页面宽度。",
"Confirm": "确认",
"Copy as Markdown": "复制为Markdown",
"Copy link": "复制链接",
"Create": "创建",
"Create group": "创建群组",
@@ -254,7 +253,6 @@
"Export failed:": "导出失败:",
"export error": "导出出错",
"Export page": "导出页面",
"Export successful": "导出成功",
"Export space": "导出空间",
"Export {{type}}": "导出为 {{type}}",
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
@@ -330,8 +328,6 @@
"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,11 +30,9 @@ 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({
@@ -47,9 +45,6 @@ export default function ExportModal({
if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments });
}
notifications.show({
message: t("Export successful"),
});
onClose();
} catch (err) {
notifications.show({
@@ -57,8 +52,6 @@ export default function ExportModal({
color: "red",
});
console.error("export error", err);
} finally {
setIsExporting(false);
}
};
@@ -143,7 +136,7 @@ export default function ExportModal({
<Button onClick={onClose} variant="default">
{t("Cancel")}
</Button>
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
<Button onClick={handleExport}>{t("Export")}</Button>
</Group>
</Modal.Body>
</Modal.Content>
@@ -2,17 +2,17 @@ import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
export interface PagePaginationProps {
currentPage: number;
hasPrevPage: boolean;
hasNextPage: boolean;
onPrev: () => void;
onNext: () => void;
onPageChange: (newPage: number) => void;
}
export default function Paginate({
currentPage,
hasPrevPage,
hasNextPage,
onPrev,
onNext,
onPageChange,
}: PagePaginationProps) {
const { t } = useTranslation();
@@ -25,7 +25,7 @@ export default function Paginate({
<Button
variant="default"
size="compact-sm"
onClick={onPrev}
onClick={() => onPageChange(currentPage - 1)}
disabled={!hasPrevPage}
>
{t("Prev")}
@@ -34,7 +34,7 @@ export default function Paginate({
<Button
variant="default"
size="compact-sm"
onClick={onNext}
onClick={() => onPageChange(currentPage + 1)}
disabled={!hasNextPage}
>
{t("Next")}
@@ -5,27 +5,26 @@ import {
Badge,
Table,
ActionIcon,
} from "@mantine/core";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription } from "@tabler/icons-react";
import { getSpaceUrl } from "@/lib/config.ts";
} from '@mantine/core';
import {Link} from 'react-router-dom';
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
import { buildPageUrl } from '@/features/page/page.utils.ts';
import { formattedDate } from '@/lib/time.ts';
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
import { IconFileDescription } from '@tabler/icons-react';
import { getSpaceUrl } from '@/lib/config.ts';
import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts";
interface Props {
spaceId?: string;
}
export default function RecentChanges({ spaceId }: Props) {
export default function RecentChanges({spaceId}: Props) {
const { t } = useTranslation();
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
if (isLoading) {
return <PageListSkeleton />;
return <PageListSkeleton/>;
}
if (isError) {
@@ -45,8 +44,8 @@ export default function RecentChanges({ spaceId }: Props) {
>
<Group wrap="nowrap">
{page.icon || (
<ActionIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
<ActionIcon variant='transparent' color='gray' size={18}>
<IconFileDescription size={18}/>
</ActionIcon>
)}
@@ -59,23 +58,18 @@ export default function RecentChanges({ spaceId }: Props) {
{!spaceId && (
<Table.Td>
<Badge
color={getInitialsColor(page?.space.name)}
color="blue"
variant="light"
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}
style={{cursor: 'pointer'}}
>
{page?.space.name}
</Badge>
</Table.Td>
)}
<Table.Td>
<Text
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
>
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
{formattedDate(page.updatedAt)}
</Text>
</Table.Td>
@@ -13,7 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" };
const params = { limit: 100, page: 1, query: "" } as QueryParams;
queryClient.prefetchQuery({
queryKey: ["workspaceMembers", params],
queryFn: () => getWorkspaceMembers(params),
@@ -22,15 +22,15 @@ export const prefetchWorkspaceMembers = () => {
export const prefetchSpaces = () => {
queryClient.prefetchQuery({
queryKey: ["spaces", {}],
queryFn: () => getSpaces({}),
queryKey: ["spaces", { page: 1 }],
queryFn: () => getSpaces({ page: 1 }),
});
};
export const prefetchGroups = () => {
queryClient.prefetchQuery({
queryKey: ["groups", {}],
queryFn: () => getGroups({}),
queryKey: ["groups", { page: 1 }],
queryFn: () => getGroups({ page: 1 }),
});
};
@@ -62,21 +62,21 @@ export const prefetchSsoProviders = () => {
export const prefetchShares = () => {
queryClient.prefetchQuery({
queryKey: ["share-list", {}],
queryFn: () => getShares({}),
queryKey: ["share-list", { page: 1 }],
queryFn: () => getShares({ page: 1, limit: 100 }),
});
};
export const prefetchApiKeys = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", {}],
queryFn: () => getApiKeys({}),
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1 }),
});
};
export const prefetchApiKeyManagement = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { adminView: true }],
queryFn: () => getApiKeys({ adminView: true }),
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1, adminView: true }),
});
};
@@ -1,49 +0,0 @@
import { useRef, useState, ReactNode } from "react";
import { Text, TextProps, Tooltip } from "@mantine/core";
type AutoTooltipTextProps = TextProps & {
children: ReactNode;
tooltipLabel?: string;
tooltipProps?: Omit<
React.ComponentProps<typeof Tooltip>,
"children" | "label"
>;
};
export function AutoTooltipText({
children,
tooltipLabel,
tooltipProps,
...textProps
}: AutoTooltipTextProps) {
const textRef = useRef<HTMLParagraphElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const handleMouseEnter = () => {
const element = textRef.current;
if (element) {
setIsTruncated(element.scrollWidth > element.clientWidth);
}
};
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
return (
<Tooltip
label={label}
disabled={!isTruncated || !label}
multiline
withArrow
{...tooltipProps}
>
<Text
ref={textRef}
truncate
onMouseEnter={handleMouseEnter}
{...textProps}
>
{children}
</Text>
</Tooltip>
);
}
@@ -10,19 +10,19 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
export default function UserApiKeys() {
const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ cursor });
const { data, isLoading } = useGetApiKeysQuery({ page });
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
@@ -65,10 +65,10 @@ export default function UserApiKeys() {
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
@@ -10,20 +10,20 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx';
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ cursor, adminView: true });
const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
const { isAdmin } = useUserRole();
if (!isAdmin) {
@@ -76,10 +76,10 @@ export default function WorkspaceApiKeys() {
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
@@ -43,7 +43,7 @@ export default function SsoProviderList() {
return null;
}
if (data?.items.length === 0) {
if (data?.length === 0) {
return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
}
@@ -81,7 +81,7 @@ export default function SsoProviderList() {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items
{data
.sort((a, b) => {
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
if (enabledDiff !== 0) return enabledDiff;
@@ -104,11 +104,7 @@ export default function SsoProviderList() {
</Group>
</Table.Td>
<Table.Td>
<Badge
color={"gray"}
variant="light"
style={{ whiteSpace: "nowrap" }}
>
<Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}>
{provider.type.toUpperCase()}
</Badge>
</Table.Td>
@@ -138,41 +134,41 @@ export default function SsoProviderList() {
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
onClick={() => handleEdit(provider)}
>
<IconPencil size={16} />
</ActionIcon>
<Menu
transitionProps={{ transition: "pop" }}
withArrow
position="bottom-end"
withinPortal
>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => handleEdit(provider)}
leftSection={<IconPencil size={16} />}
>
{t("Edit")}
</Menu.Item>
<Menu.Item
onClick={() => openDeleteModal(provider.id)}
leftSection={<IconTrash size={16} />}
color="red"
disabled={provider.type === SSO_PROVIDER.GOOGLE}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => handleEdit(provider)}
>
<IconPencil size={16} />
</ActionIcon>
<Menu
transitionProps={{ transition: "pop" }}
withArrow
position="bottom-end"
withinPortal
>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => handleEdit(provider)}
leftSection={<IconPencil size={16} />}
>
{t("Edit")}
</Menu.Item>
<Menu.Item
onClick={() => openDeleteModal(provider.id)}
leftSection={<IconTrash size={16} />}
color="red"
disabled={provider.type === SSO_PROVIDER.GOOGLE}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
@@ -13,9 +13,8 @@ import {
} from "@/ee/security/services/security-service.ts";
import { notifications } from "@mantine/notifications";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { IPagination } from "@/lib/types.ts";
export function useGetSsoProviders(): UseQueryResult<IPagination<IAuthProvider>, Error> {
export function useGetSsoProviders(): UseQueryResult<IAuthProvider[], Error> {
return useQuery({
queryKey: ["sso-providers"],
queryFn: () => getSsoProviders(),
@@ -1,6 +1,5 @@
import api from "@/lib/api-client.ts";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { IPagination } from "@/lib/types.ts";
export async function getSsoProviderById(data: {
providerId: string;
@@ -9,8 +8,8 @@ export async function getSsoProviderById(data: {
return req.data;
}
export async function getSsoProviders(): Promise<IPagination<IAuthProvider>> {
const req = await api.post<IPagination<IAuthProvider>>("/sso/providers");
export async function getSsoProviders(): Promise<IAuthProvider[]> {
const req = await api.post<IAuthProvider[]>("/sso/providers");
return req.data;
}
@@ -3,3 +3,15 @@ import { atom } from 'jotai';
export const showCommentPopupAtom = atom<boolean>(false);
export const activeCommentIdAtom = atom<string>('');
export const draftCommentIdAtom = atom<string>('');
// Read-only comment state
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);
export type YjsSelection = {
anchor: any;
head: any;
};
export type ReadOnlyCommentData = {
yjsSelection: YjsSelection;
selectedText: string;
};
export const readOnlyCommentDataAtom = atom<ReadOnlyCommentData | null>(null);
@@ -6,6 +6,8 @@ import {
} from "@tanstack/react-query";
import {
createComment,
createReadOnlyComment,
CreateReadOnlyCommentData,
deleteComment,
getPageComments,
updateComment,
@@ -106,4 +108,23 @@ export function useDeleteCommentMutation(pageId?: string) {
});
}
export function useCreateReadOnlyCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IComment, Error, CreateReadOnlyCommentData>({
mutationFn: (data) => createReadOnlyComment(data),
onSuccess: (data) => {
queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) });
notifications.show({ message: t("Comment created successfully") });
},
onError: () => {
notifications.show({
message: t("Error creating comment"),
color: "red",
});
},
});
}
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
@@ -40,3 +40,20 @@ export async function getPageComments(
export async function deleteComment(commentId: string): Promise<void> {
await api.post("/comments/delete", { commentId });
}
export type CreateReadOnlyCommentData = {
pageId: string;
content: string;
selection?: string;
yjsSelection: {
anchor: any;
head: any;
};
};
export async function createReadOnlyComment(
data: CreateReadOnlyCommentData,
): Promise<IComment> {
const req = await api.post<IComment>("/comments/create-readonly", data);
return req.data;
}
@@ -1,13 +1,11 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts";
import { 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();
@@ -22,28 +20,26 @@ export default function AttachmentView(props: NodeViewProps) {
wrap="nowrap"
h={25}
>
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
{url ? (
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
) : (
<Loader size={20} style={{ flexShrink: 0 }} />
)}
<Group justify="space-between" wrap="nowrap">
<IconPaperclip size={20} />
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
{url ? name : t("Uploading {{name}}", { name })}
<Text component="span" size="md" truncate="end">
{name}
</Text>
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
<Text component="span" size="sm" c="dimmed" inline>
{formatBytes(size)}
</Text>
</Group>
{url && (selected || hovered) && (
{selected || hovered ? (
<a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} />
</ActionIcon>
</a>
) : (
""
)}
</Group>
</Paper>
@@ -1,6 +1,10 @@
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
import { isNodeSelection, useEditorState } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import {
BubbleMenu,
BubbleMenuProps,
isNodeSelection,
useEditor,
useEditorState,
} from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react";
import {
IconBold,
@@ -34,7 +38,7 @@ export interface BubbleMenuItem {
}
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
editor: Editor | null;
editor: ReturnType<typeof useEditor>;
};
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
@@ -129,9 +133,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
}
return isTextSelected(editor);
},
options: {
placement: "top",
offset: 8,
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
onCreate: (instance) => {
instance.popper.firstChild?.addEventListener("blur", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
});
},
onHide: () => {
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
@@ -147,7 +156,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
return (
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
<BubbleMenu {...bubbleMenuProps}>
<div className={classes.bubbleMenu}>
<NodeSelector
editor={props.editor}
@@ -0,0 +1,110 @@
import {
BubbleMenu,
isNodeSelection,
isTextSelection,
useEditor,
} from "@tiptap/react";
import { FC, useEffect, useRef } from "react";
import { IconMessage } from "@tabler/icons-react";
import classes from "./bubble-menu.module.css";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
readOnlyCommentDataAtom,
showReadOnlyCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
import { useAtom } from "jotai";
import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import { ySyncPluginKey } from "y-prosemirror";
import { getRelativeSelection } from "y-prosemirror";
type ReadOnlyBubbleMenuProps = {
editor: ReturnType<typeof useEditor>;
};
export const ReadOnlyBubbleMenu: FC<ReadOnlyBubbleMenuProps> = ({ editor }) => {
const { t } = useTranslation();
const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom(
showReadOnlyCommentPopupAtom,
);
const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
const showPopupRef = useRef(showReadOnlyCommentPopup);
useEffect(() => {
showPopupRef.current = showReadOnlyCommentPopup;
}, [showReadOnlyCommentPopup]);
const handleCommentClick = () => {
if (!editor) return;
const view = editor.view;
const ystate = ySyncPluginKey.getState(view.state);
if (ystate?.binding) {
const selection = getRelativeSelection(ystate.binding, view.state);
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to);
// @ts-ignore
setReadOnlyCommentData({
yjsSelection: {
anchor: selection.anchor,
head: selection.head,
},
selectedText,
});
setShowReadOnlyCommentPopup(true);
}
};
// Don't render if editor is not available or is editable
if (!editor || editor.isEditable) return null;
return (
<BubbleMenu
editor={editor}
pluginKey="readonly"
shouldShow={({ state, editor }) => {
// Safety check - don't show if editor became editable
if (!editor || editor.isEditable || editor.isDestroyed) {
return false;
}
const { selection } = state;
const { empty, from, to } = selection;
if (
editor.isActive("image") ||
empty ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
showPopupRef?.current
) {
return false;
}
// Check if actual text is selected (not just empty block)
const hasText = state.doc.textBetween(from, to).length > 0;
return isTextSelection(selection) && hasText;
}}
tippyOptions={{
moveTransition: "transform 0.15s ease-out",
}}
>
<div className={classes.bubbleMenu}>
<Tooltip label={t("Comment")} withArrow>
<ActionIcon
variant="default"
size="lg"
radius="0"
aria-label={t("Comment")}
style={{ border: "none" }}
onClick={handleCommentClick}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
</div>
</BubbleMenu>
);
};
@@ -0,0 +1,126 @@
import React, { useState } from "react";
import { Dialog, Group, Stack, Text } from "@mantine/core";
import { useClickOutside } from "@mantine/hooks";
import { useAtom } from "jotai";
import {
activeCommentIdAtom,
readOnlyCommentDataAtom,
showReadOnlyCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { useCreateReadOnlyCommentMutation } from "@/features/comment/queries/comment-query";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { useEditor } from "@tiptap/react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
type ReadOnlyCommentDialogProps = {
editor: ReturnType<typeof useEditor>;
pageId: string;
};
function ReadOnlyCommentDialog({ editor, pageId }: ReadOnlyCommentDialogProps) {
const { t } = useTranslation();
const [comment, setComment] = useState("");
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(
readOnlyCommentDataAtom,
);
const [currentUser] = useAtom(currentUserAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const useClickOutsideRef = useClickOutside(() => {
handleDialogClose();
});
const createCommentMutation = useCreateReadOnlyCommentMutation();
const { isPending } = createCommentMutation;
const emit = useQueryEmit();
const handleDialogClose = () => {
setShowReadOnlyCommentPopup(false);
setReadOnlyCommentData(null);
};
const handleAddComment = async () => {
if (!readOnlyCommentData) return;
try {
const commentData = {
pageId: pageId,
content: JSON.stringify(comment),
selection: readOnlyCommentData.selectedText,
yjsSelection: readOnlyCommentData.yjsSelection,
};
const createdComment =
await createCommentMutation.mutateAsync(commentData);
setActiveCommentId(createdComment.id);
setAsideState({ tab: "comments", isAsideOpen: true });
setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 400);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} finally {
setShowReadOnlyCommentPopup(false);
setReadOnlyCommentData(null);
}
};
const handleCommentEditorChange = (newContent: any) => {
setComment(newContent);
};
return (
<Dialog
opened={true}
onClose={handleDialogClose}
ref={useClickOutsideRef}
size="lg"
radius="md"
w={300}
position={{ bottom: 500, right: 50 }}
withCloseButton
withBorder
>
<Stack gap={2}>
<Group>
<CustomAvatar
size="sm"
avatarUrl={currentUser.user.avatarUrl}
name={currentUser.user.name}
/>
<div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={500} lineClamp={1}>
{currentUser.user.name}
</Text>
</Group>
</div>
</Group>
<CommentEditor
onUpdate={handleCommentEditorChange}
onSave={handleAddComment}
placeholder={t("Write a comment")}
editable={true}
autofocus={true}
/>
<CommentActions onSave={handleAddComment} isLoading={isPending} />
</Stack>
</Dialog>
);
}
export default ReadOnlyCommentDialog;
@@ -1,5 +1,9 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import {
@@ -49,26 +53,17 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
},
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const getReferenceClientRect = useCallback(() => {
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;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const setCalloutType = useCallback(
@@ -117,12 +112,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`callout-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
tippyOptions={{
getReferenceClientRect,
offset: [0, 10],
placement: "bottom",
// offset: 233, // // offset: [0, 10],
// zIndex: 99,
flip: false,
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
}}
shouldShow={shouldShow}
>
@@ -90,7 +90,6 @@ export default function CodeBlockView(props: NodeViewProps) {
node.textContent.length > 0
}
>
{/* @ts-ignore */}
<NodeViewContent as="code" className={`language-${language}`} />
</pre>
@@ -1,12 +1,13 @@
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 = (
editor: Editor,
view: EditorView,
event: ClipboardEvent,
pageId: string,
creatorId?: string,
@@ -17,7 +18,7 @@ export const handlePaste = (
// we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault();
const url = clipboardData.trim();
const { from: pos, empty } = editor.state.selection;
const { from: pos, empty } = view.state.selection;
const match = INTERNAL_LINK_REGEX.exec(url);
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
@@ -33,27 +34,19 @@ 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,
editor.view,
pos,
creatorId,
anchorId,
);
const anchorId = match[6] ? match[6].split('#')[0] : undefined;
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
return true;
}
if (event.clipboardData?.files.length) {
event.preventDefault();
for (const file of event.clipboardData.files) {
const pos = editor.state.selection.from;
uploadImageAction(file, editor, pos, pageId);
uploadVideoAction(file, editor, pos, pageId);
uploadAttachmentAction(file, editor, pos, pageId);
const pos = view.state.selection.from;
uploadImageAction(file, view, pos, pageId);
uploadVideoAction(file, view, pos, pageId);
uploadAttachmentAction(file, view, pos, pageId);
}
return true;
}
@@ -61,7 +54,7 @@ export const handlePaste = (
};
export const handleFileDrop = (
editor: Editor,
view: EditorView,
event: DragEvent,
moved: boolean,
pageId: string,
@@ -70,14 +63,14 @@ export const handleFileDrop = (
event.preventDefault();
for (const file of event.dataTransfer.files) {
const coordinates = editor.view.posAtCoords({
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
}
return true;
}
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -35,26 +40,17 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
},
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const getReferenceClientRect = useCallback(() => {
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;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const onWidthChange = useCallback(
@@ -69,11 +65,15 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`drawio-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
@@ -66,7 +66,6 @@ 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,41 +1,16 @@
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: 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;
}
};
let popup: any | null = null;
return {
onBeforeStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
}) => {
init({
data: async () => (await import("@emoji-mart/data")).default,
@@ -50,61 +25,51 @@ const renderEmojiItems = () => {
return;
}
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)`,
});
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom",
});
},
onStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
}) => {
component?.updateProps({ ...props, isLoading: false });
component?.updateProps({...props, isLoading: false});
if (props.clientRect) {
getReferenceClientRect = props.clientRect;
if (!props.clientRect) {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
}) => {
component?.updateProps(props);
if (props.clientRect) {
getReferenceClientRect = props.clientRect;
if (!props.clientRect) {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
destroy();
popup?.[0].hide();
component?.destroy()
return true;
}
@@ -113,7 +78,13 @@ const renderEmojiItems = () => {
return component?.ref?.onKeyDown(props);
},
onExit: () => {
destroy();
if (popup && !popup[0]?.state.isDestroyed) {
popup[0]?.destroy();
}
if (component) {
component?.destroy();
}
},
};
};
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -37,26 +42,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
},
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const getReferenceClientRect = useCallback(() => {
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;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const onWidthChange = useCallback(
@@ -69,13 +65,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`excalidraw-menu`}
pluginKey={`excalidraw-menu}`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
@@ -98,7 +98,6 @@ 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,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
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,
@@ -17,6 +22,16 @@ 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,
@@ -37,37 +52,17 @@ export function ImageMenu({ editor }: EditorMenuProps) {
},
});
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 getReferenceClientRect = useCallback(() => {
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;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const alignImageLeft = useCallback(() => {
@@ -110,11 +105,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`image-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
@@ -1,27 +0,0 @@
.imageWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
@@ -1,70 +1,30 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { 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 { t } = useTranslation();
const { editor, node, selected } = props;
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
const { node, selected } = props;
const { src, width, align, title } = 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>
<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>
<Image
radius="md"
fit="contain"
w={width}
src={getFileUrl(src)}
alt={title}
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
/>
</NodeViewWrapper>
);
}
@@ -1,10 +1,9 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
import React, { useCallback, useState } from "react";
import { 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);
@@ -60,15 +59,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`link-menu`}
pluginKey={`link-menu}`}
updateDelay={0}
options={{
onHide: () => {
tippyOptions={{
appendTo: () => {
return appendTo?.current;
},
onHidden: () => {
setShowEdit(false);
},
placement: "bottom",
offset: 5,
// zIndex: 101,
offset: [0, 5],
zIndex: 101,
}}
shouldShow={shouldShow}
>
@@ -106,7 +106,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
setRenderItems(items);
// update editor storage
//@ts-ignore
props.editor.storage.mentionItems = items;
}
}, [suggestion, isLoading]);
@@ -164,7 +163,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);
}
};
@@ -204,7 +203,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
parentPageId: page.id || null,
title: title
};
let createdPage: IPage;
try {
createdPage = await createPageMutation.mutateAsync(payload);
@@ -1,11 +1,5 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import tippy from "tippy.js";
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
function getWhitespaceCount(query: string) {
@@ -15,27 +9,16 @@ function getWhitespaceCount(query: string) {
const mentionRenderItems = () => {
let component: ReactRenderer | 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;
};
let popup: any | null = 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;
}
@@ -54,95 +37,75 @@ const mentionRenderItems = () => {
return;
}
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",
});
});
},
);
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
query: string;
}) => {
// query must not start with a whitespace
if (props.query.charAt(0) === " ") {
destroy();
if (props.query.charAt(0) === ' '){
component?.destroy();
return;
}
// only update component if popup is not destroyed
if (component) {
component.updateProps(props);
if (!popup?.[0].state.isDestroyed) {
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 > 4 &&
//@ts-ignore
props.editor.storage.mentionItems.length === 1
whitespaceCount > 3 &&
props.editor.storage.mentionItems.length === 0
) {
destroy();
return;
}
// fallback exit
if (whitespaceCount > 7) {
destroy();
popup?.[0]?.destroy();
component?.destroy();
return;
}
popup &&
!popup?.[0].state.isDestroyed &&
popup?.[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
destroy();
return true;
}
if (props.event.key === "Enter" && !component) {
destroy();
return false;
}
if (props.event.key)
if (
props.event.key === "Escape" ||
(props.event.key === "Enter" && !popup?.[0].state.isShown)
) {
popup?.[0].destroy();
component?.destroy();
return false;
}
return (component?.ref as any)?.onKeyDown(props);
},
onExit: () => {
destroy();
if (popup && !popup?.[0].state.isDestroyed) {
popup[0].destroy();
}
if (component) {
component.destroy();
}
},
};
};
@@ -73,8 +73,6 @@ 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,7 +161,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
@@ -174,13 +173,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadImageAction(file, editor, pos, pageId);
uploadImageAction(file, editor.view, pos, pageId);
}
}
// Reset the input value to allow uploading the same file again if needed
input.value = "";
};
input.click();
},
@@ -193,7 +188,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
@@ -201,18 +195,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*";
input.multiple = true;
input.onchange = async () => {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadVideoAction(file, editor, pos, pageId);
}
const file = input.files[0];
const pos = editor.view.state.selection.from;
uploadVideoAction(file, editor.view, pos, pageId);
}
// Reset the input value to allow uploading the same file again if needed
input.value = "";
};
input.click();
},
@@ -225,7 +213,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
@@ -233,18 +220,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input");
input.type = "file";
input.accept = "";
input.multiple = true;
input.onchange = async () => {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor, pos, pageId, true);
}
const file = input.files[0];
const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor.view, pos, pageId, true);
}
// Reset the input value to allow uploading the same file again if needed
input.value = "";
};
input.click();
},
@@ -1,35 +1,10 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import CommandList from "@/features/editor/components/slash-menu/command-list";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import tippy from "tippy.js";
const renderItems = () => {
let component: ReactRenderer | 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`;
}
});
};
let popup: any | null = null;
return {
onStart: (props: {
@@ -46,29 +21,15 @@ const renderItems = () => {
}
// @ts-ignore
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
);
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
@@ -80,15 +41,14 @@ const renderItems = () => {
return;
}
// @ts-ignore
getReferenceClientRect = props.clientRect;
updatePosition();
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
if (popup) {
popup.style.display = "none";
}
popup?.[0].hide();
return true;
}
@@ -97,19 +57,12 @@ const renderItems = () => {
return component?.ref?.onKeyDown(props);
},
onExit: () => {
if (cleanup) {
cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
if (popup && !popup[0].state.isDestroyed) {
popup[0].destroy();
}
if (component) {
component.destroy();
component = null;
}
},
};
@@ -1,11 +1,15 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, findParentNode } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
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;
@@ -29,7 +33,7 @@ export const SubpagesMenu = React.memo(
return editor.isActive("subpages");
},
[editor]
[editor],
);
const getReferenceClientRect = useCallback(() => {
@@ -58,8 +62,18 @@ 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")}>
@@ -75,7 +89,7 @@ export const SubpagesMenu = React.memo(
</Tooltip>
</BaseBubbleMenu>
);
}
},
);
export default SubpagesMenu;
@@ -19,7 +19,6 @@ 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,4 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
import React, { useCallback } from "react";
import {
EditorMenuProps,
ShouldShowProps,
@@ -15,7 +17,6 @@ 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 => {
@@ -28,7 +29,7 @@ export const TableCellMenu = React.memo(
return isCellSelection(state.selection);
},
[editor]
[editor],
);
const mergeCells = useCallback(() => {
@@ -52,27 +53,23 @@ export const TableCellMenu = React.memo(
}, [editor]);
return (
<BubbleMenu
<BaseBubbleMenu
editor={editor}
pluginKey="table-cell-menu"
updateDelay={0}
appendTo={() => {
return appendTo?.current;
}}
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
offset: {
mainAxis: 15,
tippyOptions={{
appendTo: () => {
return appendTo?.current;
},
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}
@@ -128,9 +125,9 @@ export const TableCellMenu = React.memo(
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BubbleMenu>
</BaseBubbleMenu>
);
}
},
);
export default TableCellMenu;
@@ -1,6 +1,11 @@
import { posToDOMRect, findParentNode } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import {
EditorMenuProps,
ShouldShowProps,
@@ -12,12 +17,9 @@ import {
IconColumnRemove,
IconRowInsertBottom,
IconRowInsertTop,
IconRowRemove,
IconTableColumn,
IconTableRow,
IconRowRemove, IconTableColumn, IconTableRow,
IconTrashX,
} from "@tabler/icons-react";
import { BubbleMenu } from "@tiptap/react/menus";
} from '@tabler/icons-react';
import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
@@ -32,28 +34,20 @@ export const TableMenu = React.memo(
return editor.isActive("table") && !isCellSelection(state.selection);
},
[editor]
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
const getReferenceClientRect = 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;
const rect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
return dom.getBoundingClientRect();
}
const rect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const toggleHeaderColumn = useCallback(() => {
@@ -93,33 +87,42 @@ export const TableMenu = React.memo(
}, [editor]);
return (
<BubbleMenu
<BaseBubbleMenu
editor={editor}
pluginKey="table-menu"
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,
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 },
},
},
],
},
}}
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"
@@ -185,7 +188,8 @@ 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"
@@ -196,7 +200,8 @@ 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"
@@ -219,9 +224,9 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BubbleMenu>
</BaseBubbleMenu>
);
}
},
);
export default TableMenu;
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
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,
@@ -17,6 +22,16 @@ 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,
@@ -37,37 +52,17 @@ export function VideoMenu({ editor }: EditorMenuProps) {
},
});
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 getReferenceClientRect = useCallback(() => {
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;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const alignVideoLeft = useCallback(() => {
@@ -110,11 +105,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`video-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
@@ -1,33 +0,0 @@
.videoWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
.video {
display: block;
width: 100%;
height: 100%;
border-radius: 8px;
}
@@ -1,75 +1,29 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { 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 { t } = useTranslation();
const { editor, node, selected } = props;
const { src, width, align, aspectRatio, placeholder } = node.attrs;
const { node, selected } = props;
const { src, width, align } = 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>
<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>
<video
preload="metadata"
width={width}
controls
src={getFileUrl(src)}
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
style={{ display: "block" }}
/>
</NodeViewWrapper>
);
}
@@ -1,7 +1,11 @@
import { StarterKit } from "@tiptap/starter-kit";
import { Placeholder } from "@tiptap/extension-placeholder";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
import { Placeholder, CharacterCount } from "@tiptap/extensions";
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 { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography";
@@ -11,7 +15,7 @@ import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
import { HocuspocusProvider } from "@hocuspocus/provider";
import {
Comment,
@@ -37,12 +41,11 @@ import {
Embed,
SearchAndReplace,
Mention,
TableDndExtension,
Subpages,
TableDndExtension,
Heading,
Highlight,
UniqueID,
SharedStorage,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -94,9 +97,7 @@ lowlight.register("scala", scala);
export const mainExtensions = [
StarterKit.configure({
heading: false,
undoRedo: false,
link: false,
trailingNode: false,
history: false,
dropcursor: {
width: 3,
color: "#70CFF8",
@@ -108,7 +109,6 @@ export const mainExtensions = [
},
},
}),
SharedStorage,
Heading,
UniqueID.configure({
types: ["heading", "paragraph"],
@@ -134,6 +134,8 @@ export const mainExtensions = [
TaskItem.configure({
nested: true,
}),
ListKeymap,
Underline,
LinkExtension.configure({
openOnClick: false,
}),
@@ -168,9 +170,6 @@ 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);
},
}),
@@ -209,7 +208,6 @@ export const mainExtensions = [
}),
CustomCodeBlock.configure({
view: CodeBlockView,
//@ts-ignore
lowlight,
HTMLAttributes: {
spellcheck: false,
@@ -248,7 +246,7 @@ export const mainExtensions = [
Escape: () => {
const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event);
return false;
return true;
},
};
},
@@ -260,9 +258,8 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
export const collabExtensions: CollabExtensions = (provider, user) => [
Collaboration.configure({
document: provider.document,
provider,
}),
CollaborationCaret.configure({
CollaborationCursor.configure({
provider,
user: {
name: user.name,
+122 -100
View File
@@ -1,22 +1,13 @@
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,
onStatusParameters,
onAuthenticationFailedParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
} from "@hocuspocus/provider";
import {
Editor,
EditorContent,
EditorProvider,
useEditor,
@@ -37,9 +28,12 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
import {
activeCommentIdAtom,
showCommentPopupAtom,
showReadOnlyCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
import CommentDialog from "@/features/comment/components/comment-dialog";
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
import { ReadOnlyBubbleMenu } from "@/features/editor/components/bubble-menu/read-only-bubble-menu";
import ReadOnlyCommentDialog from "@/features/editor/components/bubble-menu/read-only-comment-dialog";
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
@@ -78,9 +72,11 @@ export default function PageEditor({
editable,
content,
}: PageEditorProps) {
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null);
const editorCreated = useRef(false);
useEffect(() => {
isComponentMounted.current = true;
@@ -91,127 +87,147 @@ export default function PageEditor({
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
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 [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(
() => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
);
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
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);
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);
}
});
}
};
local.on("synced", () => setLocalSynced(true));
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
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);
}
},
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { socket, local, remote };
remote.on("synced", () => setRemoteSynced(true));
remote.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
providersRef.current = { 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 socket = providersRef.current.socket;
const remoteProvider = providersRef.current.remote;
if (
isIdle &&
documentState === "hidden" &&
yjsConnectionStatus === WebSocketStatus.Connected
remoteProvider.status === WebSocketStatus.Connected
) {
socket.disconnect();
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
yjsConnectionStatus === WebSocketStatus.Disconnected
remoteProvider.status === WebSocketStatus.Disconnected
) {
resetIdle();
socket.connect();
remoteProvider.connect();
setTimeout(() => setIsCollabReady(true), 500);
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
const extensions = useMemo(() => {
if (!providersReady || !providersRef.current || !currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
if (!remoteProvider || !currentUser?.user) return mainExtensions;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [providersReady, currentUser?.user]);
}, [remoteProvider, currentUser?.user]);
const editor = useEditor(
{
@@ -254,30 +270,18 @@ export default function PageEditor({
}
},
},
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);
},
handlePaste: (view, event, slice) =>
handlePaste(view, event, pageId, currentUser?.user.id),
handleDrop: (view, event, _slice, moved) =>
handleFileDrop(view, event, moved, pageId),
},
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setEditor(editor);
// @ts-ignore
editor.storage.pageId = pageId;
handleScrollTo(editor);
editorRef.current = editor;
editorCreated.current = true;
}
},
onUpdate({ editor }) {
@@ -287,7 +291,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
[pageId, editable, extensions],
[pageId, editable, remoteProvider],
);
const editorIsEditable = useEditorState({
@@ -343,17 +347,30 @@ 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 timeout = setTimeout(() => {
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
const collabReadyTimeout = setTimeout(() => {
if (
!isCollabReady &&
isSynced &&
remoteProvider?.status === WebSocketStatus.Connected
) {
setIsCollabReady(true);
}
}, 7500);
}, 500);
return () => clearTimeout(collabReadyTimeout);
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
return () => clearTimeout(timeout);
}, [yjsConnectionStatus, isSynced]);
useEffect(() => {
// Only honor user default page edit mode preference and permissions
if (editor) {
@@ -375,13 +392,12 @@ export default function PageEditor({
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
yjsConnectionStatus === WebSocketStatus.Connected &&
isSynced
remoteProvider?.status === WebSocketStatus.Connected
) {
hasConnectedOnceRef.current = true;
setShowStatic(false);
}
}, [yjsConnectionStatus, isSynced]);
}, [remoteProvider?.status]);
if (showStatic) {
return (
@@ -417,7 +433,13 @@ export default function PageEditor({
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div>
)}
{editor && !editorIsEditable && (
<ReadOnlyBubbleMenu key="readonly-bubble" editor={editor} />
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && (
<ReadOnlyCommentDialog editor={editor} pageId={pageId} />
)}
</div>
<div
onClick={() => editor.commands.focus("end")}
@@ -81,7 +81,6 @@ 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-carets__caret {
.collaboration-cursor__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-carets__label {
.collaboration-cursor__label {
border-radius: 3px 3px 3px 0;
color: #0d0d0d;
font-size: 0.75rem;
@@ -1,25 +1,25 @@
import { Table, Group, Text, Anchor } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
import { useState } from "react";
import { Link } from "react-router-dom";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next";
import { formatMemberCount } from "@/lib";
import { IGroup } from "@/features/group/types/group.types.ts";
import Paginate from "@/components/common/paginate.tsx";
import { queryClient } from "@/main.tsx";
import { getSpaces } from "@/features/space/services/space-service.ts";
import { getGroupMembers } from "@/features/group/services/group-service.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
export default function GroupList() {
const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGetGroupsQuery({ cursor });
const [page, setPage] = useState(1);
const { data, isLoading } = useGetGroupsQuery({ page });
const prefetchGroupMembers = (groupId: string) => {
queryClient.prefetchQuery({
queryKey: ["groupMembers", groupId, {}],
queryFn: () => getGroupMembers(groupId, {}),
queryKey: ["groupMembers", groupId, { page: 1 }],
queryFn: () => getGroupMembers(groupId, { page: 1 }),
});
};
@@ -51,9 +51,9 @@ export default function GroupList() {
<Group gap="sm" wrap="nowrap">
<IconGroupCircle />
<div style={{ minWidth: 0, overflow: "hidden" }}>
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
<Text fz="sm" fw={500} lineClamp={1}>
{group.name}
</AutoTooltipText>
</Text>
<Text fz="xs" c="dimmed" lineClamp={2}>
{group.description}
</Text>
@@ -84,10 +84,10 @@ export default function GroupList() {
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</>
@@ -4,7 +4,7 @@ import {
useRemoveGroupMemberMutation,
} from "@/features/group/queries/group-query";
import { useParams } from "react-router-dom";
import React from "react";
import React, { useState } from "react";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
@@ -12,13 +12,12 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import { IUser } from "@/features/user/types/user.types.ts";
import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
export default function GroupMembersList() {
const { t } = useTranslation();
const { groupId } = useParams();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGroupMembersQuery(groupId, { cursor });
const [page, setPage] = useState(1);
const { data, isLoading } = useGroupMembersQuery(groupId, { page });
const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole();
@@ -108,10 +107,10 @@ export default function GroupMembersList() {
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</>
@@ -1,9 +1,8 @@
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";
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';
export interface HistoryEditorProps {
title: string;
@@ -27,9 +26,7 @@ export function HistoryEditor({ title, content }: HistoryEditorProps) {
<div>
<Title order={1}>{title}</Title>
{editor && (
<EditorContent editor={editor} className={classes.historyEditor} />
)}
{editor && <EditorContent editor={editor} />}
</div>
</>
);
@@ -67,7 +67,7 @@ function HistoryList({ pageId }: Props) {
mainEditorTitle
.chain()
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.setContent(activeHistoryData.title, true)
.run();
mainEditor
.chain()
@@ -1,49 +1,37 @@
.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)
);
}
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
}
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
}
}
.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,17 +7,22 @@ import {
IconHistory,
IconLink,
IconList,
IconMarkdown,
IconMessage,
IconPrinter,
IconSearch,
IconTrash,
IconWifiOff,
} from "@tabler/icons-react";
import React, { useEffect, useRef, useState } from "react";
import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom, useAtomValue } from "jotai";
import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
import {
getHotkeyHandler,
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";
@@ -29,12 +34,12 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import { 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 { formattedDate } from "@/lib/time.ts";
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
@@ -46,6 +51,7 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
useHotkeys(
[
@@ -62,7 +68,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event);
},
{ preventDefault: false },
],
],
[],
@@ -70,7 +75,17 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
return (
<>
<ConnectionWarning />
{yjsConnectionStatus === "disconnected" && (
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
<IconWifiOff size={20} stroke={2} />
</ActionIcon>
</Tooltip>
)}
{!readOnly && <PageStateSegmentedControl size="xs" />}
@@ -131,15 +146,6 @@ 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();
@@ -177,13 +183,6 @@ 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} />}>
@@ -291,51 +290,3 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</>
);
}
function ConnectionWarning() {
const { t } = useTranslation();
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
const [showWarning, setShowWarning] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const isDisconnected = ["disconnected", "connecting"].includes(
yjsConnectionStatus,
);
if (isDisconnected) {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
}
} else {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setShowWarning(false);
}
}, [yjsConnectionStatus]);
// Cleanup only on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
if (!showWarning) return null;
return (
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
<IconWifiOff size={20} stroke={2} />
</ActionIcon>
</Tooltip>
);
}
@@ -172,10 +172,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
queryKey: ["root-sidebar-pages", fileTask.spaceId],
});
await queryClient.invalidateQueries({
queryKey: ["recent-changes", fileTask.spaceId],
});
setTimeout(() => {
emit({
operation: "refetchRootTreeNodeEvent",
@@ -163,6 +163,9 @@ export function useDeletePageMutation() {
export function useMovePageMutation() {
return useMutation<void, Error, IMovePage>({
mutationFn: (data) => movePage(data),
onSuccess: () => {
invalidateOnMovePage();
},
});
}
@@ -250,10 +253,12 @@ export function useGetSidebarPagesQuery(
return useInfiniteQuery({
queryKey: ["sidebar-pages", data],
enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam }),
initialPageParam: undefined,
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
initialPageParam: 1,
getPreviousPageParam: (firstPage) =>
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
getNextPageParam: (lastPage) =>
lastPage.meta?.nextCursor ?? undefined,
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
});
}
@@ -261,11 +266,13 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({
queryKey: ["root-sidebar-pages", data.spaceId],
queryFn: async ({ pageParam }) => {
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam });
return getSidebarPages({ spaceId: data.spaceId, page: pageParam });
},
initialPageParam: undefined,
initialPageParam: 1,
getPreviousPageParam: (firstPage) =>
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
getNextPageParam: (lastPage) =>
lastPage.meta?.nextCursor ?? undefined,
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
});
}
@@ -451,127 +458,17 @@ export function invalidateOnUpdatePage(
});
}
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 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 invalidateOnDeletePage(pageId: string) {
@@ -72,19 +72,22 @@ export async function getSidebarPages(
export async function getAllSidebarPages(
params: SidebarPagesParams,
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
let cursor: string | undefined = undefined;
let page = 1;
let hasNextPage = false;
const pages: IPagination<IPage>[] = [];
const pageParams: (string | undefined)[] = [];
const pageParams: number[] = [];
do {
const req = await api.post("/pages/sidebar-pages", { ...params, cursor });
const req = await api.post("/pages/sidebar-pages", { ...params, page: page });
const data: IPagination<IPage> = req.data;
pages.push(data);
pageParams.push(cursor);
pageParams.push(page);
cursor = data.meta.nextCursor ?? undefined;
} while (cursor);
hasNextPage = data.meta.hasNextPage;
page += 1;
} while (hasNextPage);
return {
pageParams,
@@ -115,14 +118,7 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
.split("filename=")[1]
.replace(/"/g, "");
let decodedFileName = fileName;
try {
decodedFileName = decodeURIComponent(fileName);
} catch (err) {
// fallback to raw filename
}
saveAs(req.data, decodedFileName);
saveAs(req.data, decodeURIComponent(fileName));
}
export async function importPage(file: File, spaceId: string) {
@@ -30,15 +30,15 @@ import { useState } from "react";
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
import { UserInfo } from "@/components/common/user-info.tsx";
import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
export default function Trash() {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { page, setPage } = usePaginateAndSearch();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
cursor, limit: 50
page, limit: 50
});
const restorePageMutation = useRestorePageMutation();
const deletePageMutation = useDeletePageMutation();
@@ -206,10 +206,10 @@ export default function Trash() {
{deletedPages && deletedPages.items.length > 0 && (
<Paginate
hasPrevPage={deletedPages.meta?.hasPrevPage}
hasNextPage={deletedPages.meta?.hasNextPage}
onNext={() => goNext(deletedPages.meta?.nextCursor)}
onPrev={goPrev}
currentPage={page}
hasPrevPage={deletedPages.meta.hasPrevPage}
hasNextPage={deletedPages.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</Stack>
@@ -16,7 +16,6 @@ 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";
@@ -176,25 +175,9 @@ 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",
@@ -202,10 +185,8 @@ export function useTreeMutation<T>(spaceId: string) {
payload: {
id: draggedNodeId,
parentId: args.parentId,
oldParentId,
index: args.index,
position: newPosition,
pageData,
},
});
}, 50);
@@ -62,7 +62,7 @@ export interface ICopyPageToSpace {
export interface SidebarPagesParams {
spaceId?: string;
pageId?: string;
cursor?: string;
page?: number; // pagination
}
export interface IPageInput {
@@ -18,6 +18,7 @@ import {
IconFileDescription,
IconSearch,
IconCheck,
IconSparkles,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks";
@@ -25,7 +26,7 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useLicense } from "@/ee/hooks/use-license";
import classes from "./search-spotlight-filters.module.css";
import { isCloud } from "@/lib/config.ts";
import { useAtom } from "jotai";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
interface SearchSpotlightFiltersProps {
@@ -52,6 +53,7 @@ export function SearchSpotlightFilters({
const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({
page: 1,
limit: 100,
query: debouncedSpaceQuery,
});
@@ -263,9 +265,7 @@ export function SearchSpotlightFilters({
contentType !== option.value &&
handleFilterChange("contentType", option.value)
}
disabled={
option.disabled || (isAiMode && option.value === "attachment")
}
disabled={option.disabled || (isAiMode && option.value === "attachment")}
>
<Group flex="1" gap="xs">
<div>
@@ -275,13 +275,11 @@ export function SearchSpotlightFilters({
{t("Enterprise")}
</Badge>
)}
{!option.disabled &&
isAiMode &&
option.value === "attachment" && (
<Text size="xs" mt={4}>
{t("Ask AI not available for attachments")}
</Text>
)}
{!option.disabled && isAiMode && option.value === "attachment" && (
<Text size="xs" mt={4}>
{t("Ask AI not available for attachments")}
</Text>
)}
</div>
{contentType === option.value && <IconCheck size={20} />}
</Group>
@@ -10,8 +10,8 @@ import {
export async function searchPage(
params: IPageSearchParams,
): Promise<IPageSearch[]> {
const req = await api.post<{ items: IPageSearch[] }>("/search", params);
return req.data.items;
const req = await api.post<IPageSearch[]>("/search", params);
return req.data;
}
export async function searchSuggestions(
@@ -24,13 +24,13 @@ export async function searchSuggestions(
export async function searchShare(
params: IPageSearchParams,
): Promise<IPageSearch[]> {
const req = await api.post<{ items: IPageSearch[] }>("/search/share-search", params);
return req.data.items;
const req = await api.post<IPageSearch[]>("/search/share-search", params);
return req.data;
}
export async function searchAttachments(
params: IPageSearchParams,
): Promise<IAttachmentSearch[]> {
const req = await api.post<{ items: IAttachmentSearch[] }>("/search-attachments", params);
return req.data.items;
const req = await api.post<IAttachmentSearch[]>("/search-attachments", params);
return req.data;
}
@@ -1,9 +1,8 @@
import { Table, Group, Text, Anchor } from "@mantine/core";
import React from "react";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
import { ISharedItem } from "@/features/share/types/share.types.ts";
import { format } from "date-fns";
@@ -15,8 +14,8 @@ import classes from "./share.module.css";
export default function ShareList() {
const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGetSharesQuery({ cursor });
const [page, setPage] = useState(1);
const { data, isLoading } = useGetSharesQuery({ page });
return (
<>
@@ -87,10 +86,10 @@ export default function ShareList() {
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</>
@@ -33,7 +33,7 @@ export function useGetSharesQuery(
params?: QueryParams,
): UseQueryResult<IPagination<ISharedItem>, Error> {
return useQuery({
queryKey: ["share-list", params],
queryKey: ["share-list"],
queryFn: () => getShares(params),
placeholderData: keepPreviousData,
});
@@ -6,7 +6,6 @@ import { ISpace } from "../types/space.types";
import { useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route";
import { Trans, useTranslation } from "react-i18next";
import { useState } from "react";
interface DeleteSpaceModalProps {
space: ISpace;
@@ -15,7 +14,6 @@ interface DeleteSpaceModalProps {
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [isDeleting, setIsDeleting] = useState(false);
const deleteSpaceMutation = useDeleteSpaceMutation();
const navigate = useNavigate();
@@ -37,15 +35,12 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
return;
}
setIsDeleting(true);
try {
// pass slug too so we can clear the local cache
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
navigate(APP_ROUTE.HOME);
} catch (error) {
console.error("Failed to delete space", error);
} finally {
setIsDeleting(false);
}
};
@@ -84,7 +79,7 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
<Button onClick={close} variant="default">
{t("Cancel")}
</Button>
<Button onClick={handleDelete} color="red" loading={isDeleting}>
<Button onClick={handleDelete} color="red">
{t("Confirm")}
</Button>
</Group>
@@ -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, IconChevronUp } from "@tabler/icons-react";
import { IconChevronDown } 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, toggle }] = useDisclosure(false);
const [opened, { close, open, toggle }] = useDisclosure(false);
const handleSelect = (value: string) => {
if (value) {
@@ -44,9 +44,9 @@ export function SwitchSpace({
variant="subtle"
fullWidth
justify="space-between"
rightSection={opened ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
rightSection={<IconChevronDown size={18} />}
color="gray"
onClick={toggle}
onClick={open}
>
<CustomAvatar
name={spaceName}
@@ -15,7 +15,7 @@ import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts
export default function SpaceGrid() {
const { t } = useTranslation();
const { data, isLoading } = useGetSpacesQuery({ limit: 10 });
const { data, isLoading } = useGetSpacesQuery({ page: 1, limit: 10 });
const cards = data?.items.slice(0, 9).map((space, index) => (
<Card
@@ -1,6 +1,5 @@
import { Group, Table, Text } from "@mantine/core";
import React, { useState } from "react";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useDisclosure } from "@mantine/hooks";
@@ -9,12 +8,11 @@ import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
export default function SpaceList() {
const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGetSpacesQuery({ cursor });
const [page, setPage] = useState(1);
const { data, isLoading } = useGetSpacesQuery({ page });
const [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
@@ -51,9 +49,9 @@ export default function SpaceList() {
name={space.name}
/>
<div style={{ minWidth: 0, overflow: "hidden" }}>
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
<Text fz="sm" fw={500} lineClamp={1}>
{space.name}
</AutoTooltipText>
</Text>
<Text fz="xs" c="dimmed" lineClamp={2}>
{space.description}
</Text>
@@ -73,10 +71,10 @@ export default function SpaceList() {
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
@@ -27,7 +27,6 @@ import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { SearchInput } from "@/components/common/search-input.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
type MemberType = "user" | "group";
@@ -41,9 +40,9 @@ export default function SpaceMembersList({
readOnly,
}: SpaceMembersProps) {
const { t } = useTranslation();
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
const { data, isLoading } = useSpaceMembersQuery(spaceId, {
cursor,
page,
limit: 100,
query: search,
});
@@ -139,10 +138,10 @@ export default function SpaceMembersList({
{member.type === "group" && <IconGroupCircle />}
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 260 }}>
<AutoTooltipText fz="sm" fw={500}>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{member?.name}
</AutoTooltipText>
</Text>
<Text fz="xs" c="dimmed">
{member.type == "user" && member?.email}
@@ -206,10 +205,10 @@ export default function SpaceMembersList({
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</>
@@ -23,24 +23,23 @@ import SpaceSettingsModal from "@/features/space/components/settings-modal";
import classes from "./all-spaces-list.module.css";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
interface AllSpacesListProps {
spaces: any[];
onSearch: (query: string) => void;
page: number;
hasPrevPage?: boolean;
hasNextPage?: boolean;
onNext: () => void;
onPrev: () => void;
onPageChange: (page: number) => void;
}
export default function AllSpacesList({
spaces,
onSearch,
page,
hasPrevPage,
hasNextPage,
onNext,
onPrev,
onPageChange,
}: AllSpacesListProps) {
const { t } = useTranslation();
const [settingsOpened, { open: openSettings, close: closeSettings }] =
@@ -97,10 +96,10 @@ export default function AllSpacesList({
variant="filled"
size="md"
/>
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 350 }}>
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{space.name}
</AutoTooltipText>
</Text>
{space.description && (
<Text fz="xs" c="dimmed" lineClamp={2}>
{space.description}
@@ -145,10 +144,10 @@ export default function AllSpacesList({
{spaces.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={hasPrevPage}
hasNextPage={hasNextPage}
onNext={onNext}
onPrev={onPrev}
onPageChange={onPageChange}
/>
)}
@@ -69,12 +69,5 @@ export async function exportSpace(data: IExportSpaceParams): Promise<void> {
.split("filename=")[1]
.replace(/"/g, "");
let decodedFileName = fileName;
try {
decodedFileName = decodeURIComponent(fileName);
} catch (err) {
// fallback to raw filename
}
saveAs(req.data, decodedFileName);
saveAs(req.data, decodeURIComponent(fileName));
}
@@ -45,10 +45,8 @@ 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,
updateCacheOnMovePage,
invalidateOnMovePage,
invalidateOnUpdatePage,
} from "../page/queries/page-query";
import { RQ_KEY } from "../comment/queries/comment-query";
@@ -41,13 +41,7 @@ export const useQuerySubscription = () => {
invalidateOnCreatePage(data.payload.data);
break;
case "moveTreeNode":
updateCacheOnMovePage(
data.spaceId,
data.payload.id,
data.payload.oldParentId,
data.payload.parentId,
data.payload.pageData,
);
invalidateOnMovePage();
break;
case "deleteTreeNode":
invalidateOnDeletePage(data.payload.node.id);
@@ -1,6 +1,6 @@
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
import React from "react";
import React, { useState } from "react";
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
import { IconInfoCircle } from "@tabler/icons-react";
@@ -8,13 +8,12 @@ import { timeAgo } from "@/lib/time.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
export default function WorkspaceInvitesTable() {
const { t } = useTranslation();
const { cursor, goNext, goPrev } = useCursorPaginate();
const [page, setPage] = useState(1);
const { data, isLoading } = useWorkspaceInvitationsQuery({
cursor,
page,
limit: 100,
});
const { isAdmin } = useUserRole();
@@ -66,10 +65,10 @@ export default function WorkspaceInvitesTable() {
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</>
@@ -21,9 +21,9 @@ import MemberActionMenu from "@/features/workspace/components/members/components
export default function WorkspaceMembersTable() {
const { t } = useTranslation();
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
const { data, isLoading } = useWorkspaceMembersQuery({
cursor,
page,
limit: 100,
query: search,
});
@@ -111,10 +111,10 @@ export default function WorkspaceMembersTable() {
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</>
@@ -1,28 +0,0 @@
import { useState, useCallback } from "react";
export function useCursorPaginate() {
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([]);
const goNext = useCallback((nextCursor: string | null | undefined) => {
if (nextCursor) {
setCursorStack((prev) => [...prev, cursor]);
setCursor(nextCursor);
}
}, [cursor]);
const goPrev = useCallback(() => {
setCursorStack((prev) => {
const next = prev.slice(0, -1);
setCursor(prev[prev.length - 1]);
return next;
});
}, []);
const resetCursor = useCallback(() => {
setCursor(undefined);
setCursorStack([]);
}, []);
return { cursor, goNext, goPrev, resetCursor };
}
@@ -2,33 +2,16 @@ import { useState, useRef, useCallback } from "react";
export function usePaginateAndSearch(initialQuery: string = "") {
const [search, setSearch] = useState(initialQuery);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([]);
const [page, setPage] = useState(1);
const prevSearchRef = useRef(search);
const handleSearch = useCallback((newQuery: string) => {
if (prevSearchRef.current !== newQuery) {
prevSearchRef.current = newQuery;
setSearch(newQuery);
setCursor(undefined);
setCursorStack([]);
setPage(1);
}
}, []);
const goNext = useCallback((nextCursor: string | null | undefined) => {
if (nextCursor) {
setCursorStack((prev) => [...prev, cursor]);
setCursor(nextCursor);
}
}, [cursor]);
const goPrev = useCallback(() => {
setCursorStack((prev) => {
const next = prev.slice(0, -1);
setCursor(prev[prev.length - 1]);
return next;
});
}, []);
return { search, cursor, goNext, goPrev, handleSearch };
return { search, page, setPage, handleSearch };
}
-34
View File
@@ -1,34 +0,0 @@
import { MantineColor } from "@mantine/core";
function hashCode(input: string) {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
const char = input.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return hash;
}
const defaultColors: MantineColor[] = [
"blue",
"cyan",
"grape",
"green",
"indigo",
"lime",
"orange",
"pink",
"red",
"teal",
"violet",
];
export function getInitialsColor(
name: string,
colors: MantineColor[] = defaultColors,
) {
const hash = hashCode(name);
const index = Math.abs(hash) % colors.length;
return colors[index];
}
+2 -4
View File
@@ -1,7 +1,6 @@
export interface QueryParams {
query?: string;
cursor?: string;
beforeCursor?: string;
page?: number;
limit?: number;
adminView?: boolean;
}
@@ -30,10 +29,9 @@ export interface ApiResponse<T> {
export type IPaginationMeta = {
limit: number;
page: number;
hasNextPage: boolean;
hasPrevPage: boolean;
nextCursor: string | null;
prevCursor: string | null;
};
export type IPagination<T> = {
items: T[];
+4 -4
View File
@@ -11,10 +11,10 @@ import useUserRole from "@/hooks/use-user-role";
export default function Spaces() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
const { data, isLoading } = useGetSpacesQuery({
cursor,
page,
limit: 30,
query: search,
});
@@ -41,10 +41,10 @@ export default function Spaces() {
<AllSpacesList
spaces={data?.items || []}
onSearch={handleSearch}
page={page}
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
onPageChange={setPage}
/>
</Box>
</Container>
+3 -9
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.25.0-beta.1",
"version": "0.24.1",
"description": "",
"author": "",
"private": true,
@@ -74,15 +74,11 @@
"jsonwebtoken": "^9.0.3",
"kysely": "^0.28.2",
"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",
"nodemailer": "^7.0.12",
"openid-client": "^5.7.1",
"otpauth": "^9.4.1",
@@ -90,11 +86,9 @@
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"pdfjs-dist": "^5.4.394",
"pg": "^8.16.3",
"pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1",
"postgres": "^3.4.8",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"postmark": "^4.0.5",
"react": "^18.3.1",
"reflect-metadata": "^0.2.2",
@@ -104,7 +98,6 @@
"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"
@@ -123,6 +116,7 @@
"@types/nodemailer": "^6.4.17",
"@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.11.11",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.14",
"@types/yauzl": "^2.10.3",
-2
View File
@@ -18,7 +18,6 @@ import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service';
import { LoggerModule } from './common/logger/logger.module';
const enterpriseModules = [];
try {
@@ -36,7 +35,6 @@ try {
@Module({
imports: [
LoggerModule,
CoreModule,
DatabaseModule,
EnvironmentModule,
@@ -30,20 +30,12 @@ export class CollabWsAdapter {
return this.wss;
}
public close() {
try {
this.wss.close();
} catch (err) {
console.error(err);
}
}
public destroy() {
try {
this.wss.close();
this.wss.clients.forEach((client) => {
client.terminate();
});
this.wss.close();
} catch (err) {
console.error(err);
}
@@ -1,9 +1,10 @@
import { Hocuspocus } from '@hocuspocus/server';
import { Hocuspocus, Server as HocuspocusServer } from '@hocuspocus/server';
import { IncomingMessage } from 'http';
import 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,
@@ -11,41 +12,21 @@ 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 readonly hocuspocus: Hocuspocus;
private 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 = new Hocuspocus({
this.hocuspocus = HocuspocusServer.configure({
debounce: 10000,
maxDebounce: 45000,
unloadImmediately: false,
@@ -53,80 +34,26 @@ 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 {
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);
}
this.hocuspocus.handleConnection(client, request);
}
getConnectionCount() {
@@ -137,52 +64,11 @@ export class CollaborationGateway {
return this.hocuspocus.getDocumentsCount();
}
handleYjsEvent<TName extends keyof CollabEventHandlers>(
eventName: TName,
documentName: string,
payload: Parameters<CollabEventHandlers[TName]>[1],
) {
return this.redisSync?.handleEvent(eventName, documentName, payload);
async destroy(): Promise<void> {
await this.hocuspocus.destroy();
}
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 });
async openDirectConnection(documentName: string) {
return this.hocuspocus.openDirectConnection(documentName);
}
}
@@ -1,42 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Hocuspocus, Document } from '@hocuspocus/server';
export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers']
>;
@Injectable()
export class CollaborationHandler {
private readonly logger = new Logger(CollaborationHandler.name);
constructor() {}
getHandlers(hocuspocus: Hocuspocus) {
return {
alterState: async (documentName: string, payload: { pageId: string }) => {
// dummy
// this.logger.log('Processing', documentName, payload);
// await this.withYdocConnection(hocuspocus, documentName, {}, (doc) => {
// const fragment = doc.getXmlFragment('default');
//});
},
};
}
async withYdocConnection(
hocuspocus: Hocuspocus,
documentName: string,
context: any = {},
fn: (doc: Document) => void,
): Promise<void> {
const connection = await hocuspocus.openDirectConnection(
documentName,
context,
);
try {
await connection.transact(fn);
} finally {
await connection.disconnect();
}
}
}
@@ -9,7 +9,6 @@ import { WebSocket } from 'ws';
import { TokenModule } from '../core/auth/token.module';
import { HistoryListener } from './listeners/history.listener';
import { LoggerExtension } from './extensions/logger.extension';
import { CollaborationHandler } from './collaboration.handler';
@Module({
providers: [
@@ -18,7 +17,6 @@ import { CollaborationHandler } from './collaboration.handler';
PersistenceExtension,
LoggerExtension,
HistoryListener,
CollaborationHandler,
],
exports: [CollaborationGateway],
imports: [TokenModule],
@@ -48,12 +46,16 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
});
wss.on('error', (error) =>
this.logger.error('WebSocket server error:', error),
this.logger.log('WebSocket server error:', error),
);
}
async onModuleDestroy(): Promise<void> {
await this.collaborationGateway?.destroy(this.collabWsAdapter);
this.collabWsAdapter?.destroy();
if (this.collaborationGateway) {
await this.collaborationGateway.destroy();
}
if (this.collabWsAdapter) {
this.collabWsAdapter.destroy();
}
}
}
@@ -1,12 +1,20 @@
import { StarterKit } from '@tiptap/starter-kit';
import {
initProseMirrorDoc,
relativePositionToAbsolutePosition,
} from 'y-prosemirror';
import * as Y from 'yjs';
import { Document } from '@hocuspocus/server';
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,
@@ -40,15 +48,11 @@ 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, Schema } from '@tiptap/pm/model';
import { Logger } from '@nestjs/common';
import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [
StarterKit.configure({
codeBlock: false,
link: false,
trailingNode: false,
heading: false,
}),
Heading,
@@ -61,6 +65,7 @@ export const tiptapExtensions = [
TaskItem.configure({
nested: true,
}),
Underline,
LinkExtension,
Superscript,
SubScript,
@@ -111,53 +116,175 @@ export function jsonToText(tiptapJson: JSONContent) {
}
export function jsonToNode(tiptapJson: JSONContent) {
const schema = getSchema(tiptapExtensions);
try {
return Node.fromJSON(schema, tiptapJson);
} catch (error) {
if (
error instanceof RangeError &&
error.message.includes('Unknown node type')
) {
Logger.warn('Stripping unknown node types from document:', error.message);
const cleanedJson = stripUnknownNodes(tiptapJson, schema);
return Node.fromJSON(schema, cleanedJson);
}
throw error;
}
return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson);
}
export function getPageId(documentName: string) {
return documentName.split('.')[1];
}
function stripUnknownNodes(
json: JSONContent,
schema: Schema,
): JSONContent | null {
if (!json || typeof json !== 'object') return json;
export type YjsSelection = {
anchor: any;
head: any;
};
// Recursively clean children first, flattening any unwrapped content
if (json.content && Array.isArray(json.content)) {
const newContent: JSONContent[] = [];
for (const child of json.content) {
const cleaned = stripUnknownNodes(child, schema);
if (Array.isArray(cleaned)) {
newContent.push(...cleaned);
} else if (cleaned) {
newContent.push(cleaned);
export function setYjsMark(
doc: Document,
fragment: Y.XmlFragment,
yjsSelection: YjsSelection,
markName: string,
markAttributes: Record<string, any>,
) {
const schema = getSchema(tiptapExtensions);
const { mapping } = initProseMirrorDoc(fragment, schema);
// Convert JSON positions to Y.js RelativePosition objects
const anchorRelPos = Y.createRelativePositionFromJSON(yjsSelection.anchor);
const headRelPos = Y.createRelativePositionFromJSON(yjsSelection.head);
const anchor = relativePositionToAbsolutePosition(
doc,
fragment,
anchorRelPos,
mapping,
);
const head = relativePositionToAbsolutePosition(
doc,
fragment,
headRelPos,
mapping,
);
if (anchor === null || head === null) {
throw new Error(
'Could not resolve Y.js relative positions to absolute positions',
);
}
const from = Math.min(anchor, head);
const to = Math.max(anchor, head);
// Apply mark directly to Y.js XmlText nodes
// This bypasses updateYFragment which has compatibility issues
applyMarkToYFragment(fragment, from, to, markName, markAttributes);
}
function applyMarkToYFragment(
fragment: Y.XmlFragment,
from: number,
to: number,
markName: string,
markAttributes: Record<string, any>,
) {
let pos = 0;
const processItem = (item: any): boolean => {
if (pos >= to) return false;
if (item instanceof Y.XmlText) {
const textLength = item.length;
const itemEnd = pos + textLength;
if (itemEnd > from && pos < to) {
const formatFrom = Math.max(0, from - pos);
const formatTo = Math.min(textLength, to - pos);
const formatLength = formatTo - formatFrom;
if (formatLength > 0) {
item.format(formatFrom, formatLength, { [markName]: markAttributes });
}
}
pos = itemEnd;
} else if (item instanceof Y.XmlElement) {
pos++; // Opening tag
for (let i = 0; i < item.length; i++) {
if (!processItem(item.get(i))) return false;
}
pos++; // Closing tag
}
return true;
};
for (let i = 0; i < fragment.length; i++) {
if (!processItem(fragment.get(i))) break;
}
}
/**
* Removes a mark from all text in the fragment that has the specified attribute value.
* Useful for deleting comments by commentId.
*/
export function removeYjsMarkByAttribute(
fragment: Y.XmlFragment,
markName: string,
attributeName: string,
attributeValue: string,
) {
const processItem = (item: any) => {
if (item instanceof Y.XmlText) {
// Get all formatting deltas to find ranges with this mark
const deltas = item.toDelta();
let offset = 0;
for (const delta of deltas) {
const length = delta.insert?.length ?? 0;
const attributes = delta.attributes ?? {};
const markAttr = attributes[markName];
if (markAttr && markAttr[attributeName] === attributeValue) {
// Remove the mark by setting it to null
item.format(offset, length, { [markName]: null });
}
offset += length;
}
} else if (item instanceof Y.XmlElement) {
for (let i = 0; i < item.length; i++) {
processItem(item.get(i));
}
}
json.content = newContent;
}
};
// Check if this node is unknown AFTER processing children
if (json.type && !schema.nodes[json.type]) {
// Unwrap: return cleaned children directly instead of wrapping
return (
json.content && json.content.length > 0 ? json.content : null
) as any;
for (let i = 0; i < fragment.length; i++) {
processItem(fragment.get(i));
}
}
/**
* Updates a mark's attributes for all text that has the specified attribute value.
* Useful for resolving/unresolving comments by commentId.
*/
export function updateYjsMarkAttribute(
fragment: Y.XmlFragment,
markName: string,
findByAttribute: { name: string; value: string },
newAttributes: Record<string, any>,
) {
const processItem = (item: any) => {
if (item instanceof Y.XmlText) {
const deltas = item.toDelta();
let offset = 0;
for (const delta of deltas) {
const length = delta.insert?.length ?? 0;
const attributes = delta.attributes ?? {};
const markAttr = attributes[markName];
if (markAttr && markAttr[findByAttribute.name] === findByAttribute.value) {
// Update the mark with new attributes (merge with existing)
item.format(offset, length, {
[markName]: { ...markAttr, ...newAttributes },
});
}
offset += length;
}
} else if (item instanceof Y.XmlElement) {
for (let i = 0; i < item.length; i++) {
processItem(item.get(i));
}
}
};
for (let i = 0; i < fragment.length; i++) {
processItem(fragment.get(i));
}
return json;
}
@@ -69,7 +69,7 @@ export class AuthenticationExtension implements Extension {
}
if (userSpaceRole === SpaceRole.READER) {
data.connectionConfig.readOnly = true;
data.connection.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);
}
}

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