From 5f966a2d893db0be8aed3a359033b077a07a3632 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:50:51 +0000 Subject: [PATCH 01/60] chore: add clean up command --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index cfaada1b..52679b43 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "server:dev": "nx run server:start:dev", "server:start": "nx run server:start:prod", "email:dev": "nx run server:email:dev", - "dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\"" + "dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\"", + "clean": "rm -rf apps/*/dist packages/*/dist apps/*/node_modules/.vite" }, "dependencies": { "@braintree/sanitize-url": "^7.1.0", From f3f74c591f32f85b8aa9a98ed884a7dd455780f9 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:31:28 +0000 Subject: [PATCH 02/60] fix(share): escape page title in SEO meta tags (#1850) --- .../server/src/common/helpers/html-escaper.ts | 71 +++++++++++++++++++ .../src/core/share/share-seo.controller.ts | 3 +- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/common/helpers/html-escaper.ts diff --git a/apps/server/src/common/helpers/html-escaper.ts b/apps/server/src/common/helpers/html-escaper.ts new file mode 100644 index 00000000..66e9f6dc --- /dev/null +++ b/apps/server/src/common/helpers/html-escaper.ts @@ -0,0 +1,71 @@ +// https://github.com/WebReflection/html-escaper +/** + * Copyright (C) 2017-present by Andrea Giammarchi - @WebReflection + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +const { replace } = ''; + +// escape +const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g; +const ca = /[&<>'"]/g; + +const esca = { + '&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"', +}; +const pe = (m) => esca[m]; + +/** + * Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`. + * @param {string} es the input to safely escape + * @returns {string} the escaped input, and it **throws** an error if + * the input type is unexpected, except for boolean and numbers, + * converted as string. + */ +export const htmlEscape = (es) => replace.call(es, ca, pe); + +// unescape +const unes = { + '&': '&', + '&': '&', + '<': '<', + '<': '<', + '>': '>', + '>': '>', + ''': "'", + ''': "'", + '"': '"', + '"': '"', +}; +const cape = (m) => unes[m]; + +/** + * Safely unescape previously escaped entities such as `&`, `<`, `>`, `"`, + * and `'`. + * @param {string} un a previously escaped string + * @returns {string} the unescaped input, and it **throws** an error if + * the input type is unexpected, except for boolean and numbers, + * converted as string. + */ +export const htmlUnescape = (un) => replace.call(un, es, cape); diff --git a/apps/server/src/core/share/share-seo.controller.ts b/apps/server/src/core/share/share-seo.controller.ts index ecacecf0..51967ada 100644 --- a/apps/server/src/core/share/share-seo.controller.ts +++ b/apps/server/src/core/share/share-seo.controller.ts @@ -7,6 +7,7 @@ import { validate as isValidUUID } from 'uuid'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { EnvironmentService } from '../../integrations/environment/environment.service'; import { Workspace } from '@docmost/db/types/entity.types'; +import { htmlEscape } from '../../common/helpers/html-escaper'; @Controller('share') export class ShareSeoController { @@ -68,7 +69,7 @@ export class ShareSeoController { return this.sendIndex(indexFilePath, res); } - const rawTitle = share.sharedPage.title ?? 'untitled'; + const rawTitle = htmlEscape(share?.sharedPage.title ?? 'untitled'); const metaTitle = rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle; From e24bf5ed5788ed044d1f3d9a8574c1d99e14e780 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:40:06 +0000 Subject: [PATCH 03/60] feat: auto-tooltip component --- .../src/components/ui/auto-tooltip-text.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 apps/client/src/components/ui/auto-tooltip-text.tsx diff --git a/apps/client/src/components/ui/auto-tooltip-text.tsx b/apps/client/src/components/ui/auto-tooltip-text.tsx new file mode 100644 index 00000000..419ec3d9 --- /dev/null +++ b/apps/client/src/components/ui/auto-tooltip-text.tsx @@ -0,0 +1,49 @@ +import { useRef, useState, ReactNode } from "react"; +import { Text, TextProps, Tooltip } from "@mantine/core"; + +type AutoTooltipTextProps = TextProps & { + children: ReactNode; + tooltipLabel?: string; + tooltipProps?: Omit< + React.ComponentProps, + "children" | "label" + >; +}; + +export function AutoTooltipText({ + children, + tooltipLabel, + tooltipProps, + ...textProps +}: AutoTooltipTextProps) { + const textRef = useRef(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 ( + + + {children} + + + ); +} From a1260188ae1143b2703f9df05e873c342b54d2b4 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 19 Jan 2026 21:05:34 +0000 Subject: [PATCH 04/60] fix: UI improvements --- .../src/components/common/recent-changes.tsx | 38 +++++++++++-------- .../features/group/components/group-list.tsx | 5 ++- .../features/space/components/space-list.tsx | 5 ++- .../space/components/space-members.tsx | 7 ++-- .../spaces-page/all-spaces-list.tsx | 7 ++-- apps/client/src/lib/get-initials-color.ts | 34 +++++++++++++++++ 6 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 apps/client/src/lib/get-initials-color.ts diff --git a/apps/client/src/components/common/recent-changes.tsx b/apps/client/src/components/common/recent-changes.tsx index 75935fa5..81fdc899 100644 --- a/apps/client/src/components/common/recent-changes.tsx +++ b/apps/client/src/components/common/recent-changes.tsx @@ -5,26 +5,27 @@ 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 ; + return ; } if (isError) { @@ -44,8 +45,8 @@ export default function RecentChanges({spaceId}: Props) { > {page.icon || ( - - + + )} @@ -58,18 +59,23 @@ export default function RecentChanges({spaceId}: Props) { {!spaceId && ( {page?.space.name} )} - + {formattedDate(page.updatedAt)} diff --git a/apps/client/src/features/group/components/group-list.tsx b/apps/client/src/features/group/components/group-list.tsx index f1f1ffe0..5ce17973 100644 --- a/apps/client/src/features/group/components/group-list.tsx +++ b/apps/client/src/features/group/components/group-list.tsx @@ -10,6 +10,7 @@ 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(); @@ -51,9 +52,9 @@ export default function GroupList() {
- + {group.name} - + {group.description} diff --git a/apps/client/src/features/space/components/space-list.tsx b/apps/client/src/features/space/components/space-list.tsx index b7fc3ec7..79b661ea 100644 --- a/apps/client/src/features/space/components/space-list.tsx +++ b/apps/client/src/features/space/components/space-list.tsx @@ -8,6 +8,7 @@ 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(); @@ -49,9 +50,9 @@ export default function SpaceList() { name={space.name} />
- + {space.name} - + {space.description} diff --git a/apps/client/src/features/space/components/space-members.tsx b/apps/client/src/features/space/components/space-members.tsx index 06300293..9186daaf 100644 --- a/apps/client/src/features/space/components/space-members.tsx +++ b/apps/client/src/features/space/components/space-members.tsx @@ -27,6 +27,7 @@ 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"; @@ -138,10 +139,10 @@ export default function SpaceMembersList({ {member.type === "group" && } -
- +
+ {member?.name} - + {member.type == "user" && member?.email} diff --git a/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx b/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx index 4106f203..acfae399 100644 --- a/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx +++ b/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx @@ -23,6 +23,7 @@ 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[]; @@ -96,10 +97,10 @@ export default function AllSpacesList({ variant="filled" size="md" /> -
- +
+ {space.name} - + {space.description && ( {space.description} diff --git a/apps/client/src/lib/get-initials-color.ts b/apps/client/src/lib/get-initials-color.ts new file mode 100644 index 00000000..cbf90680 --- /dev/null +++ b/apps/client/src/lib/get-initials-color.ts @@ -0,0 +1,34 @@ +import { MantineColor } from "@mantine/core"; + +function hashCode(input: string) { + let hash = 0; + for (let i = 0; i < input.length; i += 1) { + const char = input.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash |= 0; + } + return hash; +} + +const defaultColors: MantineColor[] = [ + "blue", + "cyan", + "grape", + "green", + "indigo", + "lime", + "orange", + "pink", + "red", + "teal", + "violet", +]; + +export function getInitialsColor( + name: string, + colors: MantineColor[] = defaultColors, +) { + const hash = hashCode(name); + const index = Math.abs(hash) % colors.length; + return colors[index]; +} From 5cd0ba6902d912a4d733a3963967a7551a47e050 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:36:19 +0000 Subject: [PATCH 05/60] fix script --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 52679b43..e7550c6a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "server:start": "nx run server:start:prod", "email:dev": "nx run server:email:dev", "dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\"", - "clean": "rm -rf apps/*/dist packages/*/dist apps/*/node_modules/.vite" + "clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite" }, "dependencies": { "@braintree/sanitize-url": "^7.1.0", From 918f4508d2048ea70e7536c1595cf4bd3f731122 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 21 Jan 2026 01:23:50 +0000 Subject: [PATCH 06/60] feat: switch to pino for logs (#1855) - switch to json logs in production - add option to support http logging --- .env.example | 8 +- apps/server/package.json | 3 + apps/server/src/app.module.ts | 2 + .../collaboration/server/collab-app.module.ts | 2 + .../src/collaboration/server/collab-main.ts | 6 +- .../server/src/common/logger/logger.module.ts | 9 ++ apps/server/src/common/logger/pino.config.ts | 77 +++++++++++++ apps/server/src/main.ts | 10 +- pnpm-lock.yaml | 103 ++++++++++++++++++ 9 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/common/logger/logger.module.ts create mode 100644 apps/server/src/common/logger/pino.config.ts diff --git a/.env.example b/.env.example index 4a74a6b4..6d537708 100644 --- a/.env.example +++ b/.env.example @@ -46,4 +46,10 @@ DRAWIO_URL= DISABLE_TELEMETRY=false # Enable debug logging in production (default: false) -DEBUG_MODE=false \ No newline at end of file +DEBUG_MODE=false + +# Log database queries +DEBUG_DB=false + +# Log http requests +LOG_HTTP=false diff --git a/apps/server/package.json b/apps/server/package.json index 12d801a0..1bc18921 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -79,6 +79,7 @@ "mime-types": "^2.1.35", "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", @@ -89,6 +90,8 @@ "pg": "^8.16.3", "pg-tsquery": "^8.4.2", "pgvector": "^0.2.1", + "pino-http": "^11.0.0", + "pino-pretty": "^13.1.3", "postmark": "^4.0.5", "react": "^18.3.1", "reflect-metadata": "^0.2.2", diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 56691444..8036b849 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -18,6 +18,7 @@ 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 { @@ -35,6 +36,7 @@ try { @Module({ imports: [ + LoggerModule, CoreModule, DatabaseModule, EnvironmentModule, diff --git a/apps/server/src/collaboration/server/collab-app.module.ts b/apps/server/src/collaboration/server/collab-app.module.ts index 08a2f688..eb6b57fa 100644 --- a/apps/server/src/collaboration/server/collab-app.module.ts +++ b/apps/server/src/collaboration/server/collab-app.module.ts @@ -8,9 +8,11 @@ import { QueueModule } from '../../integrations/queue/queue.module'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { HealthModule } from '../../integrations/health/health.module'; import { CollaborationController } from './collaboration.controller'; +import { LoggerModule } from '../../common/logger/logger.module'; @Module({ imports: [ + LoggerModule, DatabaseModule, EnvironmentModule, CollaborationModule, diff --git a/apps/server/src/collaboration/server/collab-main.ts b/apps/server/src/collaboration/server/collab-main.ts index d71da428..1a10167f 100644 --- a/apps/server/src/collaboration/server/collab-main.ts +++ b/apps/server/src/collaboration/server/collab-main.ts @@ -5,8 +5,8 @@ import { NestFastifyApplication, } from '@nestjs/platform-fastify'; import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor'; -import { InternalLogFilter } from '../../common/logger/internal-log-filter'; import { Logger } from '@nestjs/common'; +import { Logger as PinoLogger } from 'nestjs-pino'; async function bootstrap() { const app = await NestFactory.create( @@ -17,10 +17,12 @@ async function bootstrap() { maxParamLength: 500, }), { - logger: new InternalLogFilter(), + bufferLogs: true, }, ); + app.useLogger(app.get(PinoLogger)); + app.setGlobalPrefix('api', { exclude: ['/'] }); app.enableCors(); diff --git a/apps/server/src/common/logger/logger.module.ts b/apps/server/src/common/logger/logger.module.ts new file mode 100644 index 00000000..327605a4 --- /dev/null +++ b/apps/server/src/common/logger/logger.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { LoggerModule as PinoLoggerModule } from 'nestjs-pino'; +import { createPinoConfig } from './pino.config'; + +@Module({ + imports: [PinoLoggerModule.forRoot(createPinoConfig())], + exports: [PinoLoggerModule], +}) +export class LoggerModule {} diff --git a/apps/server/src/common/logger/pino.config.ts b/apps/server/src/common/logger/pino.config.ts new file mode 100644 index 00000000..9d9a14f7 --- /dev/null +++ b/apps/server/src/common/logger/pino.config.ts @@ -0,0 +1,77 @@ +import { Params } from 'nestjs-pino'; +import { stdTimeFunctions } from 'pino'; + +const CONTEXTS_TO_IGNORE = [ + 'InstanceLoader', + 'RoutesResolver', + 'RouterExplorer', + 'WebSocketsController', +]; + +export function createPinoConfig(): Params { + const isProduction = process.env.NODE_ENV === 'production'; + const isDebugMode = process.env.DEBUG_MODE === 'true'; + const logHttp = process.env.LOG_HTTP === 'true'; + + const level = isProduction && !isDebugMode ? 'info' : 'debug'; + + return { + pinoHttp: { + level, + timestamp: stdTimeFunctions.isoTime, + transport: !isProduction + ? { + target: 'pino-pretty', + options: { + colorize: true, + singleLine: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + } + : undefined, + formatters: { + level: (label) => ({ level: label }), + log: (object: Record) => { + if (isProduction && !isDebugMode) { + const context = object['context'] as string | undefined; + if (context && CONTEXTS_TO_IGNORE.includes(context)) { + return { filtered: true }; + } + } + return object; + }, + }, + serializers: { + req: (req) => { + const forwardedFor = req.headers?.['x-forwarded-for']; + const ip = + req.headers?.['cf-connecting-ip'] || + (typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() : undefined) || + req.remoteAddress; + + return { + method: req.method, + url: req.url, + ip, + userAgent: req.headers?.['user-agent'], + }; + }, + res: (res) => ({ + statusCode: res.statusCode, + }), + }, + customLogLevel: (_req, res, err) => { + if (res.statusCode >= 500 || err) return 'error'; + if (res.statusCode >= 400) return 'warn'; + return 'info'; + }, + autoLogging: logHttp + ? { + ignore: (req) => + req.url === '/api/health' || req.url === '/api/health/live', + } + : false, + }, + }; +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 79340d6e..406921a0 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -5,9 +5,9 @@ import { NestFastifyApplication, } from '@nestjs/platform-fastify'; import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common'; +import { Logger as PinoLogger } from 'nestjs-pino'; import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor'; import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter'; -import { InternalLogFilter } from './common/logger/internal-log-filter'; import fastifyMultipart from '@fastify/multipart'; import fastifyCookie from '@fastify/cookie'; @@ -24,10 +24,12 @@ async function bootstrap() { }), { rawBody: true, - logger: new InternalLogFilter(), + bufferLogs: true, }, ); + app.useLogger(app.get(PinoLogger)); + app.setGlobalPrefix('api', { exclude: ['robots.txt', 'share/:shareId/p/:pageSlug'], }); @@ -99,9 +101,7 @@ async function bootstrap() { const port = process.env.PORT || 3000; await app.listen(port, '0.0.0.0', () => { - logger.log( - `Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`, - ); + logger.log(`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`); }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 882fa54a..7c08b66f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -581,6 +581,9 @@ importers: nestjs-kysely: specifier: ^1.2.0 version: 1.2.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(kysely@0.28.2)(reflect-metadata@0.2.2) + nestjs-pino: + specifier: ^4.5.0 + version: 4.5.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2) nodemailer: specifier: ^7.0.12 version: 7.0.12 @@ -611,6 +614,12 @@ importers: pgvector: specifier: ^0.2.1 version: 0.2.1 + pino-http: + specifier: ^11.0.0 + version: 11.0.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 postmark: specifier: ^4.0.5 version: 4.0.5 @@ -5615,6 +5624,9 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + columnify@1.6.0: resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==} engines: {node: '>=8.0.0'} @@ -5975,6 +5987,9 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} @@ -6483,6 +6498,9 @@ packages: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} + fast-copy@4.0.2: + resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -6832,6 +6850,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hexoid@1.0.0: resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} engines: {node: '>=8'} @@ -7366,6 +7387,10 @@ packages: react: optional: true + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-beautify@1.15.1: resolution: {integrity: sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==} engines: {node: '>=14'} @@ -8033,9 +8058,19 @@ packages: kysely: 0.x reflect-metadata: ^0.1.13 || ^0.2.2 + nestjs-pino@4.5.0: + resolution: {integrity: sha512-e54ChJMACSGF8gPYaHsuD07RW7l/OVoV6aI8Hqhpp0ZQ4WA8QY3eewL42JX7Z1U6rV7byNU7bGBV9l6d9V6PDQ==} + engines: {node: '>= 14'} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + pino: ^7.5.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + next@14.2.10: resolution: {integrity: sha512-sDDExXnh33cY3RkS9JuFEKaS4HmlWmDKP1VJioucCG6z5KuA008DPsDZOzi8UfqEk3Ii+2NCQSJrfbEWtZZfww==} engines: {node: '>=18.17.0'} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -8476,6 +8511,16 @@ packages: pino-abstract-transport@2.0.0: resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-http@11.0.0: + resolution: {integrity: sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + pino-std-serializers@7.0.0: resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} @@ -8733,6 +8778,9 @@ packages: prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -9417,6 +9465,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + stripe@17.5.0: resolution: {integrity: sha512-kcyeAkDFjGsVl17FqnG7q/+xIjt0ZjOo9Dm+q8deAvs2Xe4iAHrhxyoP4etUVFc+/LZJANjIPVR+ZOnt9hr/Ug==} engines: {node: '>=12.*'} @@ -16277,6 +16329,8 @@ snapshots: color-convert: 2.0.1 color-string: 1.9.1 + colorette@2.0.20: {} + columnify@1.6.0: dependencies: strip-ansi: 6.0.1 @@ -16684,6 +16738,8 @@ snapshots: date-fns@4.1.0: {} + dateformat@4.6.3: {} + dayjs@1.11.13: {} dayjs@1.11.19: {} @@ -17320,6 +17376,8 @@ snapshots: iconv-lite: 0.4.24 tmp: 0.0.33 + fast-copy@4.0.2: {} + fast-decode-uri-component@1.0.1: {} fast-deep-equal@2.0.1: {} @@ -17704,6 +17762,8 @@ snapshots: dependencies: function-bind: 1.1.2 + help-me@5.0.0: {} + hexoid@1.0.0: {} highlight.js@11.11.1: {} @@ -18472,6 +18532,8 @@ snapshots: '@types/react': 18.3.12 react: 18.3.1 + joycon@3.1.1: {} + js-beautify@1.15.1: dependencies: config-chain: 1.1.13 @@ -19229,6 +19291,13 @@ snapshots: kysely: 0.28.2 reflect-metadata: 0.2.2 + nestjs-pino@4.5.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2): + dependencies: + '@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + pino: 10.1.0 + pino-http: 11.0.0 + rxjs: 7.8.2 + next@14.2.10(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.51.0): dependencies: '@next/env': 14.2.10 @@ -19702,6 +19771,33 @@ snapshots: dependencies: split2: 4.2.0 + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-http@11.0.0: + dependencies: + get-caller-file: 2.0.5 + pino: 10.1.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.3 + secure-json-parse: 4.0.0 + sonic-boom: 4.0.1 + strip-json-comments: 5.0.3 + pino-std-serializers@7.0.0: {} pino@10.1.0: @@ -19993,6 +20089,11 @@ snapshots: prr@1.0.1: optional: true + pump@3.0.3: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -20783,6 +20884,8 @@ snapshots: strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + stripe@17.5.0: dependencies: '@types/node': 22.19.1 From aa143ad79c48c76dfa177938d00faacf894beb96 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 21 Jan 2026 18:12:16 +0000 Subject: [PATCH 07/60] refactor(db): migrate from node-postgres to postgres.js (#1846) * refactor(db): migrate from node-postgres to postgres.js * ignore schema param --- apps/server/package.json | 4 +- apps/server/src/common/helpers/utils.ts | 20 ++++ apps/server/src/database/database.module.ts | 43 +++---- apps/server/src/database/migrate.ts | 18 +-- pnpm-lock.yaml | 124 +++++++------------- 5 files changed, 95 insertions(+), 114 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 1bc18921..71e68679 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -74,6 +74,7 @@ "jsonwebtoken": "^9.0.3", "kysely": "^0.28.2", "kysely-migration-cli": "^0.4.2", + "kysely-postgres-js": "^3.0.0", "ldapts": "^7.4.0", "mammoth": "^1.11.0", "mime-types": "^2.1.35", @@ -87,9 +88,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", @@ -119,7 +120,6 @@ "@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", diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index 738c455b..313f2358 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -98,3 +98,23 @@ export function hasLicenseOrEE(opts: { const { licenseKey, plan, isCloud } = opts; return Boolean(licenseKey) || (isCloud && plan === 'business'); } + +/** + * Normalizes a database URL for postgres.js compatibility. + * - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values + * - Removes `schema` parameter (has no effect via connection string) + * Note: If we don't strip them, the connection will fail + */ +export function normalizePostgresUrl(url: string): string { + const parsed = new URL(url); + const newParams = new URLSearchParams(); + + for (const [key, value] of parsed.searchParams) { + if (key === 'sslmode' && value === 'no-verify') continue; + if (key === 'schema') continue; + newParams.append(key, value); + } + + parsed.search = newParams.toString(); + return parsed.toString(); +} diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index bd331ada..e6cb2904 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -7,8 +7,7 @@ import { } from '@nestjs/common'; import { InjectKysely, KyselyModule } from 'nestjs-kysely'; import { EnvironmentService } from '../integrations/environment/environment.service'; -import { CamelCasePlugin, LogEvent, PostgresDialect, sql } from 'kysely'; -import { Pool, types } from 'pg'; +import { CamelCasePlugin, LogEvent, sql } from 'kysely'; import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; @@ -26,9 +25,9 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { PageListener } from '@docmost/db/listeners/page.listener'; - -// https://github.com/brianc/node-postgres/issues/811 -types.setTypeParser(types.builtins.INT8, (val) => Number(val)); +import { PostgresJSDialect } from 'kysely-postgres-js'; +import * as postgres from 'postgres'; +import { normalizePostgresUrl } from '../common/helpers'; @Global() @Module({ @@ -37,26 +36,30 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); imports: [], inject: [EnvironmentService], useFactory: (environmentService: EnvironmentService) => ({ - dialect: new PostgresDialect({ - pool: new Pool({ - connectionString: environmentService.getDatabaseURL(), - max: environmentService.getDatabaseMaxPool(), - }).on('error', (err) => { - console.error('Database error:', err.message); - }), + dialect: new PostgresJSDialect({ + postgres: postgres( + normalizePostgresUrl(environmentService.getDatabaseURL()), + { + max: environmentService.getDatabaseMaxPool(), + onnotice: () => {}, + types: { + bigint: { + to: 20, + from: [20, 1700], + serialize: (value: number) => value.toString(), + parse: (value: string) => Number.parseInt(value), + }, + }, + }, + ), }), plugins: [new CamelCasePlugin()], log: (event: LogEvent) => { if (environmentService.getNodeEnv() !== 'development') return; const logger = new Logger(DatabaseModule.name); - if (event.level) { - if (process.env.DEBUG_DB?.toLowerCase() === 'true') { - logger.debug(event.query.sql); - logger.debug('query time: ' + event.queryDurationMillis + ' ms'); - //if (event.query.parameters.length > 0) { - // logger.debug('parameters: ' + event.query.parameters); - //} - } + if (process.env.DEBUG_DB?.toLowerCase() === 'true') { + logger.debug(event.query.sql); + logger.debug('query time: ' + event.queryDurationMillis + ' ms'); } }, }), diff --git a/apps/server/src/database/migrate.ts b/apps/server/src/database/migrate.ts index 22e62491..a5d58766 100644 --- a/apps/server/src/database/migrate.ts +++ b/apps/server/src/database/migrate.ts @@ -1,25 +1,19 @@ import * as path from 'path'; import { promises as fs } from 'fs'; -import pg from 'pg'; -import { - Kysely, - Migrator, - PostgresDialect, - FileMigrationProvider, -} from 'kysely'; +import { Kysely, Migrator, FileMigrationProvider } from 'kysely'; import { run } from 'kysely-migration-cli'; import * as dotenv from 'dotenv'; -import { envPath } from '../common/helpers/utils'; +import { envPath, normalizePostgresUrl } from '../common/helpers'; +import { PostgresJSDialect } from 'kysely-postgres-js'; +import postgres from 'postgres'; dotenv.config({ path: envPath }); const migrationFolder = path.join(__dirname, './migrations'); const db = new Kysely({ - dialect: new PostgresDialect({ - pool: new pg.Pool({ - connectionString: process.env.DATABASE_URL, - }) as any, + dialect: new PostgresJSDialect({ + postgres: postgres(normalizePostgresUrl(process.env.DATABASE_URL)), }), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c08b66f..759fedc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -566,6 +566,9 @@ importers: kysely-migration-cli: specifier: ^0.4.2 version: 0.4.2 + kysely-postgres-js: + specifier: ^3.0.0 + version: 3.0.0(kysely@0.28.2)(postgres@3.4.8) ldapts: specifier: ^7.4.0 version: 7.4.0 @@ -605,15 +608,15 @@ importers: pdfjs-dist: specifier: ^5.4.394 version: 5.4.394 - pg: - specifier: ^8.16.3 - version: 8.16.3 pg-tsquery: specifier: ^8.4.2 version: 8.4.2 pgvector: specifier: ^0.2.1 version: 0.2.1 + postgres: + specifier: ^3.4.8 + version: 3.4.8 pino-http: specifier: ^11.0.0 version: 11.0.0 @@ -696,9 +699,6 @@ importers: '@types/passport-jwt': specifier: ^4.0.1 version: 4.0.1 - '@types/pg': - specifier: ^8.11.11 - version: 8.11.11 '@types/supertest': specifier: ^6.0.2 version: 6.0.2 @@ -4801,9 +4801,6 @@ packages: '@types/passport@1.0.17': resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} - '@types/pg@8.11.11': - resolution: {integrity: sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==} - '@types/prop-types@15.7.11': resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} @@ -7560,6 +7557,16 @@ packages: resolution: {integrity: sha512-904MSUdzkdxl+k3C67ogvP6ogPOEr0D6ZZDtxAmDeIHEJxZAA+eC+TLAcJt3HQABTPetwsW3pj6y1MPmaveQUg==} hasBin: true + kysely-postgres-js@3.0.0: + resolution: {integrity: sha512-o2t/xNSYJQDW6rVGGFPXKmZ0BEz2dGn66c2B+cO/k9ZNcU2qPWPycQPQ+B+P2MBXbKYq0xV9BZmFIvkUrmFWAQ==} + engines: {bun: '>=1.2', node: '>=20'} + peerDependencies: + kysely: '>= 0.24.0 < 1' + postgres: ^3.4.0 + peerDependenciesMeta: + postgres: + optional: true + kysely@0.28.2: resolution: {integrity: sha512-4YAVLoF0Sf0UTqlhgQMFU9iQECdah7n+13ANkiuVfRvlK+uI0Etbgd7bVP36dKlG+NXWbhGua8vnGt+sdhvT7A==} engines: {node: '>=18.0.0'} @@ -8206,9 +8213,6 @@ packages: resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} - obuf@1.1.2: - resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} - oidc-token-hash@5.0.3: resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} engines: {node: ^10.13.0 || >=12.0.0} @@ -8437,10 +8441,6 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-numeric@1.0.2: - resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} - engines: {node: '>=4'} - pg-pool@3.10.1: resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} peerDependencies: @@ -8449,9 +8449,6 @@ packages: pg-protocol@1.10.3: resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} - pg-protocol@1.7.0: - resolution: {integrity: sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==} - pg-tsquery@8.4.2: resolution: {integrity: sha512-waJSlBIKE+shDhuDpuQglTH6dG5zakDhnrnxu8XB8V5c7yoDSuy4pOxY6t2dyoxTjaKMcMmlByJN7n9jx9eqMA==} engines: {node: '>=10'} @@ -8460,10 +8457,6 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg-types@4.0.2: - resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} - engines: {node: '>=10'} - pg@8.16.3: resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} engines: {node: '>= 16.0.0'} @@ -8621,37 +8614,22 @@ packages: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} - postgres-array@3.0.2: - resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} - engines: {node: '>=12'} - postgres-bytea@1.0.0: resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} engines: {node: '>=0.10.0'} - postgres-bytea@3.0.0: - resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} - engines: {node: '>= 6'} - postgres-date@1.0.7: resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} engines: {node: '>=0.10.0'} - postgres-date@2.1.0: - resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} - engines: {node: '>=12'} - postgres-interval@1.2.0: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} - postgres-interval@3.0.0: - resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + postgres@3.4.8: + resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} - postgres-range@1.1.4: - resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - posthog-js@1.255.1: resolution: {integrity: sha512-KMh0o9MhORhEZVjXpktXB5rJ8PfDk+poqBoTSoLzWgNjhJf6D8jcyB9jUMA6vVPfn4YeepVX5NuclDRqOwr5Mw==} peerDependencies: @@ -15299,12 +15277,6 @@ snapshots: dependencies: '@types/express': 4.17.23 - '@types/pg@8.11.11': - dependencies: - '@types/node': 22.19.1 - pg-protocol: 1.7.0 - pg-types: 4.0.2 - '@types/prop-types@15.7.11': {} '@types/qrcode@1.5.5': @@ -18705,6 +18677,12 @@ snapshots: '@commander-js/extra-typings': 11.1.0(commander@11.1.0) commander: 11.1.0 + kysely-postgres-js@3.0.0(kysely@0.28.2)(postgres@3.4.8): + dependencies: + kysely: 0.28.2 + optionalDependencies: + postgres: 3.4.8 + kysely@0.28.2: {} langium@3.3.1: @@ -19466,8 +19444,6 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 - obuf@1.1.2: {} - oidc-token-hash@5.0.3: {} ollama@0.6.3: @@ -19694,19 +19670,19 @@ snapshots: pg-cloudflare@1.2.7: optional: true - pg-connection-string@2.9.1: {} + pg-connection-string@2.9.1: + optional: true - pg-int8@1.0.1: {} - - pg-numeric@1.0.2: {} + pg-int8@1.0.1: + optional: true pg-pool@3.10.1(pg@8.16.3): dependencies: pg: 8.16.3 + optional: true - pg-protocol@1.10.3: {} - - pg-protocol@1.7.0: {} + pg-protocol@1.10.3: + optional: true pg-tsquery@8.4.2: {} @@ -19717,16 +19693,7 @@ snapshots: postgres-bytea: 1.0.0 postgres-date: 1.0.7 postgres-interval: 1.2.0 - - pg-types@4.0.2: - dependencies: - pg-int8: 1.0.1 - pg-numeric: 1.0.2 - postgres-array: 3.0.2 - postgres-bytea: 3.0.0 - postgres-date: 2.1.0 - postgres-interval: 3.0.0 - postgres-range: 1.1.4 + optional: true pg@8.16.3: dependencies: @@ -19737,10 +19704,12 @@ snapshots: pgpass: 1.0.5 optionalDependencies: pg-cloudflare: 1.2.7 + optional: true pgpass@1.0.5: dependencies: split2: 4.2.0 + optional: true pgvector@0.2.1: {} @@ -19909,27 +19878,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres-array@2.0.0: {} + postgres-array@2.0.0: + optional: true - postgres-array@3.0.2: {} + postgres-bytea@1.0.0: + optional: true - postgres-bytea@1.0.0: {} - - postgres-bytea@3.0.0: - dependencies: - obuf: 1.1.2 - - postgres-date@1.0.7: {} - - postgres-date@2.1.0: {} + postgres-date@1.0.7: + optional: true postgres-interval@1.2.0: dependencies: xtend: 4.0.2 + optional: true - postgres-interval@3.0.0: {} - - postgres-range@1.1.4: {} + postgres@3.4.8: {} posthog-js@1.255.1: dependencies: @@ -21622,7 +21585,8 @@ snapshots: xpath@0.0.34: {} - xtend@4.0.2: {} + xtend@4.0.2: + optional: true y-indexeddb@9.0.12(yjs@13.6.27): dependencies: From 063ea99b66200c882dde3d66077b2cde919b3638 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Wed, 21 Jan 2026 18:17:48 +0000 Subject: [PATCH 08/60] sync --- apps/server/src/ee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/ee b/apps/server/src/ee index fce3e9e9..b6844b01 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit fce3e9e945da114c4f7cdc4de86a6729b072515e +Subproject commit b6844b019c3778d51ff1bb236f30284a0bf8f403 From efb0a9317b633ac87af1fb6929c36a211e78cc5f Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:00:58 +0000 Subject: [PATCH 09/60] feat: allow upload of large files (#1862) * Allow upload of large files * feat: createByteCountingStream utility function. --------- Co-authored-by: gpapp --- apps/server/src/common/helpers/utils.ts | 16 +++++++++ .../src/core/attachment/attachment.utils.ts | 15 ++++++-- .../attachment/services/attachment.service.ts | 24 ++++++++++--- .../import/services/import.service.ts | 34 +++++++++++++------ .../storage/drivers/local.driver.ts | 12 +++++-- .../integrations/storage/drivers/s3.driver.ts | 18 +++++----- .../interfaces/storage-driver.interface.ts | 2 +- .../integrations/storage/storage.service.ts | 4 +-- 8 files changed, 93 insertions(+), 32 deletions(-) diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index 313f2358..7c94bb48 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import * as bcrypt from 'bcrypt'; import { sanitize } from 'sanitize-filename-ts'; import { FastifyRequest } from 'fastify'; +import { Readable, Transform } from 'stream'; export const envPath = path.resolve(process.cwd(), '..', '..', '.env'); @@ -118,3 +119,18 @@ export function normalizePostgresUrl(url: string): string { parsed.search = newParams.toString(); return parsed.toString(); } + +export function createByteCountingStream(source: Readable) { + let bytesRead = 0; + const stream = new Transform({ + transform(chunk, encoding, callback) { + bytesRead += chunk.length; + callback(null, chunk); + }, + }); + + source.pipe(stream); + source.on('error', (err) => stream.emit('error', err)); + + return { stream, getBytesRead: () => bytesRead }; +} diff --git a/apps/server/src/core/attachment/attachment.utils.ts b/apps/server/src/core/attachment/attachment.utils.ts index ee72dc9f..23512002 100644 --- a/apps/server/src/core/attachment/attachment.utils.ts +++ b/apps/server/src/core/attachment/attachment.utils.ts @@ -5,15 +5,17 @@ import { sanitizeFileName } from '../../common/helpers'; import * as sharp from 'sharp'; export interface PreparedFile { - buffer: Buffer; + buffer?: Buffer; fileName: string; fileSize: number; fileExtension: string; mimeType: string; + multiPartFile?: MultipartFile; } export async function prepareFile( filePromise: Promise, + options: { skipBuffer?: boolean } = {}, ): Promise { const file = await filePromise; @@ -22,10 +24,16 @@ export async function prepareFile( } try { - const buffer = await file.toBuffer(); + let buffer: Buffer | undefined; + let fileSize = 0; + + if (!options.skipBuffer) { + buffer = await file.toBuffer(); + fileSize = buffer.length; + } + const sanitizedFilename = sanitizeFileName(file.filename); const fileName = sanitizedFilename.slice(0, 255); - const fileSize = buffer.length; const fileExtension = path.extname(file.filename).toLowerCase(); return { @@ -34,6 +42,7 @@ export async function prepareFile( fileSize, fileExtension, mimeType: file.mimetype, + multiPartFile: file, }; } catch (error) { throw error; diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts index 77a044a2..ea94b983 100644 --- a/apps/server/src/core/attachment/services/attachment.service.ts +++ b/apps/server/src/core/attachment/services/attachment.service.ts @@ -4,6 +4,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; +import { Readable } from 'stream'; import { StorageService } from '../../../integrations/storage/storage.service'; import { MultipartFile } from '@fastify/multipart'; import { @@ -26,6 +27,7 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { InjectQueue } from '@nestjs/bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { Queue } from 'bullmq'; +import { createByteCountingStream } from '../../../common/helpers/utils'; @Injectable() export class AttachmentService { @@ -49,7 +51,9 @@ export class AttachmentService { attachmentId?: string; }) { const { filePromise, pageId, spaceId, userId, workspaceId } = opts; - const preparedFile: PreparedFile = await prepareFile(filePromise); + const preparedFile: PreparedFile = await prepareFile(filePromise, { + skipBuffer: true, + }); let isUpdate = false; let attachmentId = null; @@ -81,7 +85,14 @@ export class AttachmentService { const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`; - await this.uploadToDrive(filePath, preparedFile.buffer); + const { stream, getBytesRead } = createByteCountingStream( + preparedFile.multiPartFile.file, + ); + + await this.uploadToDrive(filePath, stream); + + // Update fileSize from the consumed stream + preparedFile.fileSize = getBytesRead(); let attachment: Attachment = null; try { @@ -142,7 +153,10 @@ export class AttachmentService { const preparedFile: PreparedFile = await prepareFile(filePromise); validateFileType(preparedFile.fileExtension, validImageExtensions); - const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type); + const processedBuffer = await compressAndResizeIcon( + preparedFile.buffer, + type, + ); preparedFile.buffer = processedBuffer; preparedFile.fileSize = processedBuffer.length; preparedFile.fileName = uuid4() + preparedFile.fileExtension; @@ -232,9 +246,9 @@ export class AttachmentService { } } - async uploadToDrive(filePath: string, fileBuffer: any) { + async uploadToDrive(filePath: string, fileContent: Buffer | Readable) { try { - await this.storageService.upload(filePath, fileBuffer); + await this.storageService.upload(filePath, fileContent); } catch (err) { this.logger.error('Error uploading file to drive:', err); throw new BadRequestException('Error uploading file to drive'); diff --git a/apps/server/src/integrations/import/services/import.service.ts b/apps/server/src/integrations/import/services/import.service.ts index 7901122a..aeeebcee 100644 --- a/apps/server/src/integrations/import/services/import.service.ts +++ b/apps/server/src/integrations/import/services/import.service.ts @@ -10,7 +10,11 @@ import { } from '../../../collaboration/collaboration.util'; import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; -import { generateSlugId, sanitizeFileName } from '../../../common/helpers'; +import { + generateSlugId, + sanitizeFileName, + createByteCountingStream, +} from '../../../common/helpers'; import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; import { TiptapTransformer } from '@hocuspocus/transformer'; import * as Y from 'yjs'; @@ -173,15 +177,24 @@ export class ImportService { }; } - async getNewPagePosition(spaceId: string): Promise { - const lastPage = await this.db + async getNewPagePosition( + spaceId: string, + parentPageId?: string, + ): Promise { + let query = this.db .selectFrom('pages') .select(['id', 'position']) .where('spaceId', '=', spaceId) .orderBy('position', (ob) => ob.collate('C').desc()) - .limit(1) - .where('parentPageId', 'is', null) - .executeTakeFirst(); + .limit(1); + + if (parentPageId) { + query = query.where('parentPageId', '=', parentPageId); + } else { + query = query.where('parentPageId', 'is', null); + } + + const lastPage = await query.executeTakeFirst(); if (lastPage) { return generateJitteredKeyBetween(lastPage.position, null); @@ -198,20 +211,21 @@ export class ImportService { workspaceId: string, ) { const file = await filePromise; - const fileBuffer = await file.toBuffer(); const fileExtension = path.extname(file.filename).toLowerCase(); const fileName = sanitizeFileName( path.basename(file.filename, fileExtension), ); - const fileSize = fileBuffer.length; - const fileNameWithExt = fileName + fileExtension; const fileTaskId = uuid7(); const filePath = `${getFileTaskFolderPath(FileTaskType.Import, workspaceId)}/${fileTaskId}/${fileNameWithExt}`; // upload file - await this.storageService.upload(filePath, fileBuffer); + const { stream, getBytesRead } = createByteCountingStream(file.file); + + await this.storageService.upload(filePath, stream); + + const fileSize = getBytesRead(); const fileTask = await this.db .insertInto('fileTasks') diff --git a/apps/server/src/integrations/storage/drivers/local.driver.ts b/apps/server/src/integrations/storage/drivers/local.driver.ts index 5171066c..aada2c05 100644 --- a/apps/server/src/integrations/storage/drivers/local.driver.ts +++ b/apps/server/src/integrations/storage/drivers/local.driver.ts @@ -20,9 +20,15 @@ export class LocalDriver implements StorageDriver { return join(this.config.storagePath, filePath); } - async upload(filePath: string, file: Buffer): Promise { + async upload(filePath: string, file: Buffer | Readable): Promise { try { - await fs.outputFile(this._fullPath(filePath), file); + const fullPath = this._fullPath(filePath); + if (file instanceof Buffer) { + await fs.outputFile(fullPath, file); + } else { + await fs.mkdir(dirname(fullPath), { recursive: true }); + await pipeline(file, createWriteStream(fullPath)); + } } catch (err) { throw new Error(`Failed to upload file: ${(err as Error).message}`); } @@ -42,7 +48,7 @@ export class LocalDriver implements StorageDriver { try { const fromFullPath = this._fullPath(fromFilePath); const toFullPath = this._fullPath(toFilePath); - + if (await this.exists(fromFilePath)) { await fs.copy(fromFullPath, toFullPath); } diff --git a/apps/server/src/integrations/storage/drivers/s3.driver.ts b/apps/server/src/integrations/storage/drivers/s3.driver.ts index f6d48677..ed44fded 100644 --- a/apps/server/src/integrations/storage/drivers/s3.driver.ts +++ b/apps/server/src/integrations/storage/drivers/s3.driver.ts @@ -23,19 +23,21 @@ export class S3Driver implements StorageDriver { this.s3Client = new S3Client(config as any); } - async upload(filePath: string, file: Buffer): Promise { + async upload(filePath: string, file: Buffer | Readable): Promise { try { const contentType = getMimeType(filePath); - const command = new PutObjectCommand({ - Bucket: this.config.bucket, - Key: filePath, - Body: file, - ContentType: contentType, - // ACL: "public-read", + const upload = new Upload({ + client: this.s3Client, + params: { + Bucket: this.config.bucket, + Key: filePath, + Body: file, + ContentType: contentType, + }, }); - await this.s3Client.send(command); + await upload.done(); } catch (err) { throw new Error(`Failed to upload file: ${(err as Error).message}`); } diff --git a/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts b/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts index 22a86d2b..f376c56f 100644 --- a/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts +++ b/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts @@ -1,7 +1,7 @@ import { Readable } from 'stream'; export interface StorageDriver { - upload(filePath: string, file: Buffer): Promise; + upload(filePath: string, file: Buffer | Readable): Promise; uploadStream(filePath: string, file: Readable, options?: { recreateClient?: boolean }): Promise; diff --git a/apps/server/src/integrations/storage/storage.service.ts b/apps/server/src/integrations/storage/storage.service.ts index d796351b..3ed887af 100644 --- a/apps/server/src/integrations/storage/storage.service.ts +++ b/apps/server/src/integrations/storage/storage.service.ts @@ -8,9 +8,9 @@ export class StorageService { private readonly logger = new Logger(StorageService.name); constructor( @Inject(STORAGE_DRIVER_TOKEN) private storageDriver: StorageDriver, - ) {} + ) { } - async upload(filePath: string, fileContent: Buffer | any) { + async upload(filePath: string, fileContent: Buffer | Readable) { await this.storageDriver.upload(filePath, fileContent); this.logger.debug(`File uploaded successfully. Path: ${filePath}`); } From 98f71c95fe48b5792917c335d8b1ec5ea5cffc82 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:54:56 +0000 Subject: [PATCH 10/60] feat: stream file serving (#1865) --- .../src/core/attachment/attachment.controller.ts | 12 ++++++++---- .../src/integrations/export/export.controller.ts | 6 +++--- .../server/src/integrations/export/export.service.ts | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index fdf17523..cc058ac6 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -181,7 +181,9 @@ export class AttachmentController { } try { - const fileStream = await this.storageService.read(attachment.filePath); + const fileStream = await this.storageService.readStream( + attachment.filePath, + ); res.headers({ 'Content-Type': attachment.mimeType, 'Cache-Control': 'private, max-age=3600', @@ -241,7 +243,9 @@ export class AttachmentController { } try { - const fileStream = await this.storageService.read(attachment.filePath); + const fileStream = await this.storageService.readStream( + attachment.filePath, + ); res.headers({ 'Content-Type': attachment.mimeType, 'Cache-Control': 'public, max-age=3600', @@ -367,14 +371,14 @@ export class AttachmentController { const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`; try { - const fileStream = await this.storageService.read(filePath); + const fileStream = await this.storageService.readStream(filePath); res.headers({ 'Content-Type': getMimeType(filePath), 'Cache-Control': 'private, max-age=86400', }); return res.send(fileStream); } catch (err) { - // this.logger.error(err); + // this.logger.error(err); throw new NotFoundException('File not found'); } } diff --git a/apps/server/src/integrations/export/export.controller.ts b/apps/server/src/integrations/export/export.controller.ts index 9d49d108..f5a5c11f 100644 --- a/apps/server/src/integrations/export/export.controller.ts +++ b/apps/server/src/integrations/export/export.controller.ts @@ -55,7 +55,7 @@ export class ExportController { throw new ForbiddenException(); } - const zipFileBuffer = await this.exportService.exportPages( + const zipFileStream = await this.exportService.exportPages( dto.pageId, dto.format, dto.includeAttachments, @@ -70,7 +70,7 @@ export class ExportController { 'attachment; filename="' + encodeURIComponent(fileName) + '"', }); - res.send(zipFileBuffer); + res.send(zipFileStream); } @UseGuards(JwtAuthGuard) @@ -100,6 +100,6 @@ export class ExportController { '"', }); - res.send(exportFile.fileBuffer); + res.send(exportFile.fileStream); } } diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index b8f3a201..91b84250 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -177,7 +177,7 @@ export class ExportService { const fileName = `${space.name}-space-export.zip`; return { - fileBuffer: zipFile, + fileStream: zipFile, fileName, }; } From 657fdf8cb7cfe40752bb330029f93c91c9690d4d Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:41:08 +0000 Subject: [PATCH 11/60] feat: Tiptap V3 migration (#1854) * Tiptap3 migration - WIP * fix collaboration * remove unused code * fix flicker * disable duplicate extensions * update tiptap version * Switch to useEditorState - Set shouldRerenderOnTransaction to false * fix editable state * add tippyoptions for reference * merge main * tiptap 3.6.1 * fix bubble menu * fix converter * fix menus * fix collaboration caret css * fix: Set `isInitialized` to force immediate react node view rendering * feat: Migrate tippy.js menus to Floating UI * feat: Update collaboration connection for HocusPocus v3 * fix: Connect/disconnect websocketProvider * cleanup * cleanup * feat: Improved placeholder and upload handling for images * feat: Improved placeholder and upload handling for videos * refactor: Image node and view clean-up * feat: Improved placeholder and upload handling for attachments * fix: Video view styles * fix: Transaction handling on asset upload * fix: Use imageDimensionsFromStream * feat: Multiple file upload, improved placeholders, local previews * fix: Drag & drop, paste upload * fix: Allow media as attachment * * add skeleton pulse animation * add translation strings * fix attachment view responsiveness * fix collab connection status display * Tiptap v3.17.0 * fix suggestion menu exit bug * fix search shortcut * fix history editor css * tiptap 3.17.1 --------- Co-authored-by: Arek Nawo --- apps/client/package.json | 2 - .../public/locales/en-US/translation.json | 2 + .../components/attachment/attachment-view.tsx | 22 +- .../components/bubble-menu/bubble-menu.tsx | 25 +- .../components/callout/callout-menu.tsx | 35 +- .../components/code-block/code-block-view.tsx | 1 + .../common/editor-paste-handler.tsx | 39 +- .../editor/components/drawio/drawio-menu.tsx | 38 +- .../editor/components/drawio/drawio-view.tsx | 1 + .../emoji-menu/render-emoji-items.ts | 105 +- .../components/excalidraw/excalidraw-menu.tsx | 40 +- .../components/excalidraw/excalidraw-view.tsx | 1 + .../editor/components/image/image-menu.tsx | 59 +- .../components/image/image-view.module.css | 27 + .../editor/components/image/image-view.tsx | 64 +- .../editor/components/link/link-menu.tsx | 16 +- .../components/mention/mention-list.tsx | 5 +- .../components/mention/mention-suggestion.ts | 127 +- .../search-and-replace-dialog.tsx | 2 + .../components/slash-menu/menu-items.ts | 33 +- .../components/slash-menu/render-items.ts | 83 +- .../components/subpages/subpages-menu.tsx | 24 +- .../components/subpages/subpages-view.tsx | 1 + .../components/table/table-cell-menu.tsx | 27 +- .../editor/components/table/table-menu.tsx | 89 +- .../editor/components/video/video-menu.tsx | 59 +- .../components/video/video-view.module.css | 33 + .../editor/components/video/video-view.tsx | 68 +- .../features/editor/extensions/extensions.ts | 29 +- .../src/features/editor/page-editor.tsx | 216 ++-- .../features/editor/readonly-page-editor.tsx | 1 + .../features/editor/styles/collaboration.css | 4 +- .../components/history-editor.tsx | 15 +- .../page-history/components/history-list.tsx | 2 +- .../components/history.module.css | 48 +- .../components/header/page-header-menu.tsx | 77 +- .../collaboration/collaboration.gateway.ts | 4 +- .../src/collaboration/collaboration.util.ts | 8 +- .../extensions/authentication.extension.ts | 2 +- package.json | 75 +- packages/editor-ext/src/index.ts | 1 + .../src/lib/attachment/attachment-upload.ts | 201 ++- .../src/lib/attachment/attachment.ts | 17 +- .../editor-ext/src/lib/callout/callout.ts | 7 +- .../editor-ext/src/lib/custom-code-block.ts | 81 -- .../custom-code-block/custom-code-block.ts | 108 ++ .../src/lib/custom-code-block/index.ts | 1 + .../lib/custom-code-block/lowlight-plugin.ts | 159 +++ .../editor-ext/src/lib/details/details.ts | 1 + packages/editor-ext/src/lib/drawio.ts | 46 +- packages/editor-ext/src/lib/embed.ts | 49 +- packages/editor-ext/src/lib/excalidraw.ts | 58 +- .../editor-ext/src/lib/image/image-upload.ts | 228 ++-- packages/editor-ext/src/lib/image/image.ts | 29 +- .../editor-ext/src/lib/math/math-block.ts | 3 + .../editor-ext/src/lib/math/math-inline.ts | 3 + packages/editor-ext/src/lib/media-utils.ts | 18 +- .../search-and-replace/search-and-replace.ts | 34 +- .../src/lib/shared-storage/index.ts | 1 + .../src/lib/shared-storage/shared-storage.ts | 17 + .../editor-ext/src/lib/subpages/subpages.ts | 5 +- packages/editor-ext/src/lib/table/cell.ts | 2 +- .../src/lib/table/dnd/dnd-extension.ts | 531 ++++---- packages/editor-ext/src/lib/table/header.ts | 2 +- packages/editor-ext/src/lib/table/row.ts | 3 +- packages/editor-ext/src/lib/table/table.ts | 2 +- .../lib/unique-id/helpers/findDuplicates.ts | 11 - .../lib/unique-id/helpers/removeDuplicates.ts | 15 - .../editor-ext/src/lib/unique-id/unique-id.ts | 381 +----- packages/editor-ext/src/lib/utils.ts | 16 +- .../editor-ext/src/lib/video/video-upload.ts | 251 ++-- packages/editor-ext/src/lib/video/video.ts | 35 +- packages/editor-ext/tsconfig.json | 1 + pnpm-lock.yaml | 1076 ++++++++--------- 74 files changed, 2532 insertions(+), 2370 deletions(-) create mode 100644 apps/client/src/features/editor/components/image/image-view.module.css create mode 100644 apps/client/src/features/editor/components/video/video-view.module.css delete mode 100644 packages/editor-ext/src/lib/custom-code-block.ts create mode 100644 packages/editor-ext/src/lib/custom-code-block/custom-code-block.ts create mode 100644 packages/editor-ext/src/lib/custom-code-block/index.ts create mode 100644 packages/editor-ext/src/lib/custom-code-block/lowlight-plugin.ts create mode 100644 packages/editor-ext/src/lib/shared-storage/index.ts create mode 100644 packages/editor-ext/src/lib/shared-storage/shared-storage.ts delete mode 100644 packages/editor-ext/src/lib/unique-id/helpers/findDuplicates.ts delete mode 100644 packages/editor-ext/src/lib/unique-id/helpers/removeDuplicates.ts diff --git a/apps/client/package.json b/apps/client/package.json index e9197ef9..504f0f5f 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -24,7 +24,6 @@ "@mantine/spotlight": "^8.3.12", "@tabler/icons-react": "^3.36.1", "@tanstack/react-query": "^5.90.17", - "@tiptap/extension-character-count": "^2.27.1", "alfaaz": "^1.1.0", "axios": "^1.13.2", "clsx": "^2.1.1", @@ -54,7 +53,6 @@ "react-router-dom": "^7.12.0", "semver": "^7.7.3", "socket.io-client": "^4.8.3", - "tippy.js": "^6.3.7", "tiptap-extension-global-drag-handle": "^0.1.18", "zod": "^3.25.76" }, diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 8cb33378..0cdfbee0 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -328,6 +328,8 @@ "Upload any image from your device.": "Upload any image from your device.", "Upload any video from your device.": "Upload any video from your device.", "Upload any file from your device.": "Upload any file from your device.", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "Table", "Insert a table.": "Insert a table.", "Insert collapsible block.": "Insert collapsible block.", diff --git a/apps/client/src/features/editor/components/attachment/attachment-view.tsx b/apps/client/src/features/editor/components/attachment/attachment-view.tsx index d3858520..e3281e64 100644 --- a/apps/client/src/features/editor/components/attachment/attachment-view.tsx +++ b/apps/client/src/features/editor/components/attachment/attachment-view.tsx @@ -1,11 +1,13 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import { Group, Text, Paper, ActionIcon } from "@mantine/core"; +import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core"; import { getFileUrl } from "@/lib/config.ts"; import { IconDownload, IconPaperclip } from "@tabler/icons-react"; import { useHover } from "@mantine/hooks"; import { formatBytes } from "@/lib"; +import { useTranslation } from "react-i18next"; export default function AttachmentView(props: NodeViewProps) { + const { t } = useTranslation(); const { node, selected } = props; const { url, name, size } = node.attrs; const { hovered, ref } = useHover(); @@ -20,26 +22,28 @@ export default function AttachmentView(props: NodeViewProps) { wrap="nowrap" h={25} > - - + + {url ? ( + + ) : ( + + )} - - {name} + + {url ? name : t("Uploading {{name}}", { name })} - + {formatBytes(size)} - {selected || hovered ? ( + {url && (selected || hovered) && ( - ) : ( - "" )} diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index e8085ca6..a6d143ff 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -1,10 +1,6 @@ -import { - BubbleMenu, - BubbleMenuProps, - isNodeSelection, - useEditor, - useEditorState, -} from "@tiptap/react"; +import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus"; +import { isNodeSelection, useEditorState } from "@tiptap/react"; +import type { Editor } from "@tiptap/react"; import { FC, useEffect, useRef, useState } from "react"; import { IconBold, @@ -38,7 +34,7 @@ export interface BubbleMenuItem { } type EditorBubbleMenuProps = Omit & { - editor: ReturnType; + editor: Editor | null; }; export const EditorBubbleMenu: FC = (props) => { @@ -133,14 +129,9 @@ export const EditorBubbleMenu: FC = (props) => { } return isTextSelected(editor); }, - tippyOptions: { - moveTransition: "transform 0.15s ease-out", - onCreate: (instance) => { - instance.popper.firstChild?.addEventListener("blur", (event) => { - event.preventDefault(); - event.stopImmediatePropagation(); - }); - }, + options: { + placement: "top", + offset: 8, onHide: () => { setIsNodeSelectorOpen(false); setIsTextAlignmentOpen(false); @@ -156,7 +147,7 @@ export const EditorBubbleMenu: FC = (props) => { const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); return ( - +
{ + const getReferencedVirtualElement = useCallback(() => { + if (!editor) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "callout"; const parent = findParentNode(predicate)(selection); if (parent) { const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; - return dom.getBoundingClientRect(); + const domRect = dom.getBoundingClientRect(); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; } - return posToDOMRect(editor.view, selection.from, selection.to); + const domRect = posToDOMRect(editor.view, selection.from, selection.to); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; }, [editor]); const setCalloutType = useCallback( @@ -112,14 +117,12 @@ export function CalloutMenu({ editor }: EditorMenuProps) { editor={editor} pluginKey={`callout-menu`} updateDelay={0} - tippyOptions={{ - getReferenceClientRect, - offset: [0, 10], + getReferencedVirtualElement={getReferencedVirtualElement} + options={{ placement: "bottom", - zIndex: 99, - popperOptions: { - modifiers: [{ name: "flip", enabled: false }], - }, + // offset: 233, // // offset: [0, 10], + // zIndex: 99, + flip: false, }} shouldShow={shouldShow} > diff --git a/apps/client/src/features/editor/components/code-block/code-block-view.tsx b/apps/client/src/features/editor/components/code-block/code-block-view.tsx index 07ad2ad0..130016a3 100644 --- a/apps/client/src/features/editor/components/code-block/code-block-view.tsx +++ b/apps/client/src/features/editor/components/code-block/code-block-view.tsx @@ -90,6 +90,7 @@ export default function CodeBlockView(props: NodeViewProps) { node.textContent.length > 0 } > + {/* @ts-ignore */} diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index 8eee02fc..61d7534e 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -1,13 +1,12 @@ -import type { EditorView } from "@tiptap/pm/view"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; -import { Slice } from "@tiptap/pm/model"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; +import { Editor } from "@tiptap/core"; export const handlePaste = ( - view: EditorView, + editor: Editor, event: ClipboardEvent, pageId: string, creatorId?: string, @@ -18,7 +17,7 @@ export const handlePaste = ( // we have to do this validation here to allow the default link extension to takeover if needs be event.preventDefault(); const url = clipboardData.trim(); - const { from: pos, empty } = view.state.selection; + const { from: pos, empty } = editor.state.selection; const match = INTERNAL_LINK_REGEX.exec(url); const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href); @@ -34,19 +33,27 @@ export const handlePaste = ( return false; } - const anchorId = match[6] ? match[6].split('#')[0] : undefined; - const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url; - createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId); + const anchorId = match[6] ? match[6].split("#")[0] : undefined; + const urlWithoutAnchor = anchorId + ? url.substring(0, url.indexOf("#")) + : url; + createMentionAction( + urlWithoutAnchor, + editor.view, + pos, + creatorId, + anchorId, + ); return true; } if (event.clipboardData?.files.length) { event.preventDefault(); for (const file of event.clipboardData.files) { - const pos = view.state.selection.from; - uploadImageAction(file, view, pos, pageId); - uploadVideoAction(file, view, pos, pageId); - uploadAttachmentAction(file, view, pos, pageId); + const pos = editor.state.selection.from; + uploadImageAction(file, editor, pos, pageId); + uploadVideoAction(file, editor, pos, pageId); + uploadAttachmentAction(file, editor, pos, pageId); } return true; } @@ -54,7 +61,7 @@ export const handlePaste = ( }; export const handleFileDrop = ( - view: EditorView, + editor: Editor, event: DragEvent, moved: boolean, pageId: string, @@ -63,14 +70,14 @@ export const handleFileDrop = ( event.preventDefault(); for (const file of event.dataTransfer.files) { - const coordinates = view.posAtCoords({ + const coordinates = editor.view.posAtCoords({ left: event.clientX, top: event.clientY, }); - uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId); - uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId); - uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId); + uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); + uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); + uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); } return true; } diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx index 0efc2ec0..937b8e7d 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx @@ -1,11 +1,6 @@ -import { - BubbleMenu as BaseBubbleMenu, - findParentNode, - posToDOMRect, - useEditorState, -} from "@tiptap/react"; +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { useCallback } from "react"; -import { sticky } from "tippy.js"; import { Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, @@ -40,17 +35,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) { }, }); - const getReferenceClientRect = useCallback(() => { + const getReferencedVirtualElement = useCallback(() => { + if (!editor) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "drawio"; const parent = findParentNode(predicate)(selection); if (parent) { const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; - return dom.getBoundingClientRect(); + const domRect = dom.getBoundingClientRect(); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; } - return posToDOMRect(editor.view, selection.from, selection.to); + const domRect = posToDOMRect(editor.view, selection.from, selection.to); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; }, [editor]); const onWidthChange = useCallback( @@ -65,15 +69,11 @@ export function DrawioMenu({ editor }: EditorMenuProps) { editor={editor} pluginKey={`drawio-menu`} updateDelay={0} - tippyOptions={{ - getReferenceClientRect, - offset: [0, 8], - zIndex: 99, - popperOptions: { - modifiers: [{ name: "flip", enabled: false }], - }, - plugins: [sticky], - sticky: "popper", + getReferencedVirtualElement={getReferencedVirtualElement} + options={{ + placement: "top", + offset: 8, + flip: false, }} shouldShow={shouldShow} > diff --git a/apps/client/src/features/editor/components/drawio/drawio-view.tsx b/apps/client/src/features/editor/components/drawio/drawio-view.tsx index 5a0fbd86..b51e8936 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-view.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-view.tsx @@ -66,6 +66,7 @@ export default function DrawioView(props: NodeViewProps) { const fileName = "diagram.drawio.svg"; const drawioSVGFile = await svgStringToFile(svgString, fileName); + //@ts-ignore const pageId = editor.storage?.pageId; let attachment: IAttachment = null; diff --git a/apps/client/src/features/editor/components/emoji-menu/render-emoji-items.ts b/apps/client/src/features/editor/components/emoji-menu/render-emoji-items.ts index 82fb24a9..0ae5e24a 100644 --- a/apps/client/src/features/editor/components/emoji-menu/render-emoji-items.ts +++ b/apps/client/src/features/editor/components/emoji-menu/render-emoji-items.ts @@ -1,16 +1,41 @@ import { ReactRenderer, useEditor } from "@tiptap/react"; import EmojiList from "./emoji-list"; -import tippy from "tippy.js"; import { init } from "emoji-mart"; +import { + autoUpdate, + computePosition, + flip, + offset, + shift, +} from "@floating-ui/dom"; const renderEmojiItems = () => { let component: ReactRenderer | null = null; - let popup: any | null = null; + let popup: HTMLDivElement | null = null; + let cleanup: (() => void) | null = null; + let getReferenceClientRect: (() => DOMRect) | null = null; + + const destroy = () => { + if (cleanup) { + cleanup(); + cleanup = null; + } + + if (popup) { + popup.remove(); + popup = null; + } + + if (component) { + component.destroy(); + component = null; + } + }; return { onBeforeStart: (props: { editor: ReturnType; - clientRect: DOMRect; + clientRect: () => DOMRect; }) => { init({ data: async () => (await import("@emoji-mart/data")).default, @@ -25,51 +50,61 @@ const renderEmojiItems = () => { return; } - // @ts-ignore - popup = tippy("body", { - getReferenceClientRect: props.clientRect, - appendTo: () => document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom", + getReferenceClientRect = props.clientRect; + popup = document.createElement("div"); + popup.style.zIndex = "9999"; + popup.style.position = "absolute"; + popup.style.top = "0"; + popup.style.left = "0"; + popup.appendChild(component.element); + document.body.appendChild(popup); + + const virtualElement = { + getBoundingClientRect: () => { + return getReferenceClientRect + ? getReferenceClientRect() + : new DOMRect(0, 0, 0, 0); + }, + }; + + cleanup = autoUpdate(virtualElement, popup, () => { + if (!popup) return; + + computePosition(virtualElement, popup, { + placement: "bottom-start", + middleware: [offset(10), flip(), shift()], + }).then(({ x, y }) => { + if (!popup) return; + + Object.assign(popup.style, { + transform: `translate(${x}px, ${y}px)`, + }); + }); }); }, onStart: (props: { editor: ReturnType; - clientRect: DOMRect; + clientRect: () => DOMRect; }) => { - component?.updateProps({...props, isLoading: false}); + component?.updateProps({ ...props, isLoading: false }); - if (!props.clientRect) { - return; + if (props.clientRect) { + getReferenceClientRect = props.clientRect; } - - popup && - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); }, onUpdate: (props: { editor: ReturnType; - clientRect: DOMRect; + clientRect: () => DOMRect; }) => { component?.updateProps(props); - if (!props.clientRect) { - return; + if (props.clientRect) { + getReferenceClientRect = props.clientRect; } - - popup && - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); }, onKeyDown: (props: { event: KeyboardEvent }) => { if (props.event.key === "Escape") { - popup?.[0].hide(); - component?.destroy() + destroy(); return true; } @@ -78,13 +113,7 @@ const renderEmojiItems = () => { return component?.ref?.onKeyDown(props); }, onExit: () => { - if (popup && !popup[0]?.state.isDestroyed) { - popup[0]?.destroy(); - } - - if (component) { - component?.destroy(); - } + destroy(); }, }; }; diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx index 42329e5c..06e79515 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx @@ -1,11 +1,6 @@ -import { - BubbleMenu as BaseBubbleMenu, - findParentNode, - posToDOMRect, - useEditorState, -} from "@tiptap/react"; +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { useCallback } from "react"; -import { sticky } from "tippy.js"; import { Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, @@ -42,17 +37,26 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { }, }); - const getReferenceClientRect = useCallback(() => { + const getReferencedVirtualElement = useCallback(() => { + if (!editor) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "excalidraw"; const parent = findParentNode(predicate)(selection); if (parent) { const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; - return dom.getBoundingClientRect(); + const domRect = dom.getBoundingClientRect(); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; } - return posToDOMRect(editor.view, selection.from, selection.to); + const domRect = posToDOMRect(editor.view, selection.from, selection.to); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; }, [editor]); const onWidthChange = useCallback( @@ -65,17 +69,13 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { return ( diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx index 779a826d..86c9665e 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx @@ -98,6 +98,7 @@ export default function ExcalidrawView(props: NodeViewProps) { const fileName = "diagram.excalidraw.svg"; const excalidrawSvgFile = await svgStringToFile(svgString, fileName); + // @ts-ignore const pageId = editor.storage?.pageId; let attachment: IAttachment = null; diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx index 723ec299..a1699f93 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -1,11 +1,6 @@ -import { - BubbleMenu as BaseBubbleMenu, - findParentNode, - posToDOMRect, - useEditorState, -} from "@tiptap/react"; +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import React, { useCallback } from "react"; -import { sticky } from "tippy.js"; import { Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, @@ -22,16 +17,6 @@ import { useTranslation } from "react-i18next"; export function ImageMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); - const shouldShow = useCallback( - ({ state }: ShouldShowProps) => { - if (!state) { - return false; - } - - return editor.isActive("image"); - }, - [editor], - ); const editorState = useEditorState({ editor, @@ -52,17 +37,37 @@ export function ImageMenu({ editor }: EditorMenuProps) { }, }); - const getReferenceClientRect = useCallback(() => { + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) { + return false; + } + + return editor.isActive("image") && editor.getAttributes("image").src; + }, + [editor], + ); + + const getReferencedVirtualElement = useCallback(() => { + if (!editor) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "image"; const parent = findParentNode(predicate)(selection); if (parent) { const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; - return dom.getBoundingClientRect(); + const domRect = dom.getBoundingClientRect(); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; } - return posToDOMRect(editor.view, selection.from, selection.to); + const domRect = posToDOMRect(editor.view, selection.from, selection.to); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; }, [editor]); const alignImageLeft = useCallback(() => { @@ -105,15 +110,11 @@ export function ImageMenu({ editor }: EditorMenuProps) { editor={editor} pluginKey={`image-menu`} updateDelay={0} - tippyOptions={{ - getReferenceClientRect, - offset: [0, 8], - zIndex: 99, - popperOptions: { - modifiers: [{ name: "flip", enabled: false }], - }, - plugins: [sticky], - sticky: "popper", + getReferencedVirtualElement={getReferencedVirtualElement} + options={{ + placement: "top", + offset: 8, + flip: false, }} shouldShow={shouldShow} > diff --git a/apps/client/src/features/editor/components/image/image-view.module.css b/apps/client/src/features/editor/components/image/image-view.module.css new file mode 100644 index 00000000..5d02184b --- /dev/null +++ b/apps/client/src/features/editor/components/image/image-view.module.css @@ -0,0 +1,27 @@ +.imageWrapper { + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; + overflow: hidden; + animation: pulse 1.2s ease-in-out infinite; + + @mixin light { + background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%); + background-size: 400% 400%; + } + + @mixin dark { + background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%); + background-size: 400% 400%; + } + + @keyframes pulse { + 0% { + background-position: 0% 0%; + } + 100% { + background-position: -135% 0%; + } + } +} diff --git a/apps/client/src/features/editor/components/image/image-view.tsx b/apps/client/src/features/editor/components/image/image-view.tsx index dbdb8396..defb64c4 100644 --- a/apps/client/src/features/editor/components/image/image-view.tsx +++ b/apps/client/src/features/editor/components/image/image-view.tsx @@ -1,30 +1,70 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { Group, Image, Loader, Text } from "@mantine/core"; import { useMemo } from "react"; -import { Image } from "@mantine/core"; import { getFileUrl } from "@/lib/config.ts"; import clsx from "clsx"; +import classes from "./image-view.module.css"; +import { useTranslation } from "react-i18next"; export default function ImageView(props: NodeViewProps) { - const { node, selected } = props; - const { src, width, align, title } = node.attrs; - + const { t } = useTranslation(); + const { editor, node, selected } = props; + const { src, width, align, title, aspectRatio, placeholder } = node.attrs; const alignClass = useMemo(() => { if (align === "left") return "alignLeft"; if (align === "right") return "alignRight"; if (align === "center") return "alignCenter"; return "alignCenter"; }, [align]); + const previewSrc = useMemo(() => { + editor.storage.shared.imagePreviews = + editor.storage.shared.imagePreviews || {}; + + if (placeholder?.id) { + return editor.storage.shared.imagePreviews[placeholder.id]; + } + + return null; + }, [placeholder, editor]); return ( - {title} +
+ {src && ( + {title} + )} + {!src && previewSrc && ( + + {placeholder?.name} + + + )} + {!src && !previewSrc && ( + + + + {placeholder?.name + ? t("Uploading {{name}}", { name: placeholder.name }) + : t("Uploading file")} + + + )} +
); } diff --git a/apps/client/src/features/editor/components/link/link-menu.tsx b/apps/client/src/features/editor/components/link/link-menu.tsx index 69f7c449..63fd10bf 100644 --- a/apps/client/src/features/editor/components/link/link-menu.tsx +++ b/apps/client/src/features/editor/components/link/link-menu.tsx @@ -1,9 +1,10 @@ -import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react"; +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import React, { useCallback, useState } from "react"; import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx"; import { Card } from "@mantine/core"; +import { useEditorState } from "@tiptap/react"; export function LinkMenu({ editor, appendTo }: EditorMenuProps) { const [showEdit, setShowEdit] = useState(false); @@ -59,18 +60,15 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) { return ( { - return appendTo?.current; - }, - onHidden: () => { + options={{ + onHide: () => { setShowEdit(false); }, placement: "bottom", - offset: [0, 5], - zIndex: 101, + offset: 5, + // zIndex: 101, }} shouldShow={shouldShow} > diff --git a/apps/client/src/features/editor/components/mention/mention-list.tsx b/apps/client/src/features/editor/components/mention/mention-list.tsx index 389c2ce5..32959146 100644 --- a/apps/client/src/features/editor/components/mention/mention-list.tsx +++ b/apps/client/src/features/editor/components/mention/mention-list.tsx @@ -106,6 +106,7 @@ const MentionList = forwardRef((props, ref) => { setRenderItems(items); // update editor storage + //@ts-ignore props.editor.storage.mentionItems = items; } }, [suggestion, isLoading]); @@ -163,7 +164,7 @@ const MentionList = forwardRef((props, ref) => { const enterHandler = () => { if (!renderItems.length) return; - if (renderItems[selectedIndex].entityType !== "header") { + if (renderItems[selectedIndex]?.entityType !== "header") { selectItem(selectedIndex); } }; @@ -203,7 +204,7 @@ const MentionList = forwardRef((props, ref) => { parentPageId: page.id || null, title: title }; - + let createdPage: IPage; try { createdPage = await createPageMutation.mutateAsync(payload); diff --git a/apps/client/src/features/editor/components/mention/mention-suggestion.ts b/apps/client/src/features/editor/components/mention/mention-suggestion.ts index 11710639..d53c422c 100644 --- a/apps/client/src/features/editor/components/mention/mention-suggestion.ts +++ b/apps/client/src/features/editor/components/mention/mention-suggestion.ts @@ -1,5 +1,11 @@ import { ReactRenderer, useEditor } from "@tiptap/react"; -import tippy from "tippy.js"; +import { + autoUpdate, + computePosition, + flip, + offset, + shift, +} from "@floating-ui/dom"; import MentionList from "@/features/editor/components/mention/mention-list.tsx"; function getWhitespaceCount(query: string) { @@ -9,16 +15,27 @@ function getWhitespaceCount(query: string) { const mentionRenderItems = () => { let component: ReactRenderer | null = null; - let popup: any | null = null; + let activeClientRect: (() => DOMRect) | null = null; + let updatePositionCleanup: (() => void) | null = null; + + const destroy = () => { + updatePositionCleanup?.(); + updatePositionCleanup = null; + component?.destroy(); + if (component?.element?.parentNode) { + component.element.parentNode.removeChild(component.element); + } + component = null; + }; return { onStart: (props: { editor: ReturnType; - clientRect: DOMRect; + clientRect: () => DOMRect; query: string; }) => { // query must not start with a whitespace - if (props.query.charAt(0) === ' '){ + if (props.query.charAt(0) === " ") { return; } @@ -37,75 +54,95 @@ const mentionRenderItems = () => { return; } - // @ts-ignore - popup = tippy("body", { - getReferenceClientRect: props.clientRect, - appendTo: () => document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - }); + activeClientRect = props.clientRect; + + const { element } = component; + document.body.appendChild(element); + + updatePositionCleanup = autoUpdate( + { + getBoundingClientRect: () => + activeClientRect ? activeClientRect() : new DOMRect(), + }, + element, + () => { + if (!component?.element) return; + computePosition( + { + getBoundingClientRect: () => { + return activeClientRect ? activeClientRect() : new DOMRect(); + }, + }, + element, + { + placement: "bottom-start", + middleware: [offset(0), flip(), shift()], + }, + ).then(({ x, y }) => { + Object.assign(element.style, { + left: `${x}px`, + top: `${y}px`, + position: "absolute", + zIndex: "9999", + }); + }); + }, + ); }, onUpdate: (props: { editor: ReturnType; - clientRect: DOMRect; + clientRect: () => DOMRect; query: string; }) => { // query must not start with a whitespace - if (props.query.charAt(0) === ' '){ - component?.destroy(); + if (props.query.charAt(0) === " ") { + destroy(); return; } // only update component if popup is not destroyed - if (!popup?.[0].state.isDestroyed) { - component?.updateProps(props); + if (component) { + component.updateProps(props); } if (!props || !props.clientRect) { return; } + activeClientRect = props.clientRect; + const whitespaceCount = getWhitespaceCount(props.query); // destroy component if space is greater 3 without a match if ( - whitespaceCount > 3 && - props.editor.storage.mentionItems.length === 0 + whitespaceCount > 4 && + //@ts-ignore + props.editor.storage.mentionItems.length === 1 ) { - popup?.[0]?.destroy(); - component?.destroy(); + destroy(); + return; + } + // fallback exit + if (whitespaceCount > 7) { + destroy(); return; } - - popup && - !popup?.[0].state.isDestroyed && - popup?.[0].setProps({ - getReferenceClientRect: props.clientRect, - }); }, onKeyDown: (props: { event: KeyboardEvent }) => { - if (props.event.key) - if ( - props.event.key === "Escape" || - (props.event.key === "Enter" && !popup?.[0].state.isShown) - ) { - popup?.[0].destroy(); - component?.destroy(); - return false; - } + if (props.event.key === "Escape") { + destroy(); + return true; + } + + if (props.event.key === "Enter" && !component) { + destroy(); + return false; + } + return (component?.ref as any)?.onKeyDown(props); }, onExit: () => { - if (popup && !popup?.[0].state.isDestroyed) { - popup[0].destroy(); - } - - if (component) { - component.destroy(); - } + destroy(); }, }; }; diff --git a/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx b/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx index df6f0031..f5c17661 100644 --- a/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx +++ b/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx @@ -73,6 +73,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo if (!editor) return; const { results, resultIndex } = editor.storage.searchAndReplace; + //TODO: check type error + //@ts-ignore const position: Range = results[resultIndex]; if (!position) return; diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index f56d7f04..bebefed4 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -161,6 +161,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).run(); + // @ts-ignore const pageId = editor.storage?.pageId; if (!pageId) return; @@ -173,9 +174,13 @@ const CommandGroups: SlashMenuGroupedItemsType = { if (input.files?.length) { for (const file of input.files) { const pos = editor.view.state.selection.from; - uploadImageAction(file, editor.view, pos, pageId); + + uploadImageAction(file, editor, pos, pageId); } } + + // Reset the input value to allow uploading the same file again if needed + input.value = ""; }; input.click(); }, @@ -188,6 +193,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).run(); + // @ts-ignore const pageId = editor.storage?.pageId; if (!pageId) return; @@ -195,12 +201,18 @@ const CommandGroups: SlashMenuGroupedItemsType = { const input = document.createElement("input"); input.type = "file"; input.accept = "video/*"; + input.multiple = true; input.onchange = async () => { if (input.files?.length) { - const file = input.files[0]; - const pos = editor.view.state.selection.from; - uploadVideoAction(file, editor.view, pos, pageId); + for (const file of input.files) { + const pos = editor.view.state.selection.from; + + uploadVideoAction(file, editor, pos, pageId); + } } + + // Reset the input value to allow uploading the same file again if needed + input.value = ""; }; input.click(); }, @@ -213,6 +225,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).run(); + // @ts-ignore const pageId = editor.storage?.pageId; if (!pageId) return; @@ -220,12 +233,18 @@ const CommandGroups: SlashMenuGroupedItemsType = { const input = document.createElement("input"); input.type = "file"; input.accept = ""; + input.multiple = true; input.onchange = async () => { if (input.files?.length) { - const file = input.files[0]; - const pos = editor.view.state.selection.from; - uploadAttachmentAction(file, editor.view, pos, pageId, true); + for (const file of input.files) { + const pos = editor.view.state.selection.from; + + uploadAttachmentAction(file, editor, pos, pageId, true); + } } + + // Reset the input value to allow uploading the same file again if needed + input.value = ""; }; input.click(); }, diff --git a/apps/client/src/features/editor/components/slash-menu/render-items.ts b/apps/client/src/features/editor/components/slash-menu/render-items.ts index db6424e8..057e8214 100644 --- a/apps/client/src/features/editor/components/slash-menu/render-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/render-items.ts @@ -1,10 +1,35 @@ import { ReactRenderer, useEditor } from "@tiptap/react"; import CommandList from "@/features/editor/components/slash-menu/command-list"; -import tippy from "tippy.js"; +import { + autoUpdate, + computePosition, + flip, + offset, + shift, +} from "@floating-ui/dom"; const renderItems = () => { let component: ReactRenderer | null = null; - let popup: any | null = null; + let popup: HTMLElement | null = null; + let cleanup: (() => void) | null = null; + let getReferenceClientRect: (() => DOMRect) | null = null; + + const updatePosition = () => { + if (!popup || !getReferenceClientRect) return; + + // @ts-ignore + const rect = getReferenceClientRect(); + + computePosition({ getBoundingClientRect: () => rect }, popup, { + placement: "bottom-start", + middleware: [offset(0), flip(), shift()], + }).then(({ x, y }) => { + if (popup) { + popup.style.left = `${x}px`; + popup.style.top = `${y}px`; + } + }); + }; return { onStart: (props: { @@ -21,15 +46,29 @@ const renderItems = () => { } // @ts-ignore - popup = tippy("body", { - getReferenceClientRect: props.clientRect, - appendTo: () => document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - }); + getReferenceClientRect = props.clientRect; + + popup = document.createElement("div"); + popup.style.zIndex = "9999"; + popup.style.position = "absolute"; + popup.style.top = "0"; + popup.style.left = "0"; + + document.body.appendChild(popup); + popup.appendChild(component.element); + + cleanup = autoUpdate( + // @ts-ignore + { + getBoundingClientRect: () => { + return getReferenceClientRect + ? getReferenceClientRect() + : new DOMRect(); + }, + }, + popup, + updatePosition + ); }, onUpdate: (props: { editor: ReturnType; @@ -41,14 +80,15 @@ const renderItems = () => { return; } - popup && - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); + // @ts-ignore + getReferenceClientRect = props.clientRect; + updatePosition(); }, onKeyDown: (props: { event: KeyboardEvent }) => { if (props.event.key === "Escape") { - popup?.[0].hide(); + if (popup) { + popup.style.display = "none"; + } return true; } @@ -57,12 +97,19 @@ const renderItems = () => { return component?.ref?.onKeyDown(props); }, onExit: () => { - if (popup && !popup[0].state.isDestroyed) { - popup[0].destroy(); + if (cleanup) { + cleanup(); + cleanup = null; + } + + if (popup) { + popup.remove(); + popup = null; } if (component) { component.destroy(); + component = null; } }, }; diff --git a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx index 6cc017e2..9f0544e6 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx @@ -1,15 +1,11 @@ -import { - BubbleMenu as BaseBubbleMenu, - posToDOMRect, - findParentNode, -} from "@tiptap/react"; +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { posToDOMRect, findParentNode } from "@tiptap/react"; import { Node as PMNode } from "@tiptap/pm/model"; import React, { useCallback } from "react"; import { ActionIcon, Tooltip } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { Editor } from "@tiptap/core"; -import { sticky } from "tippy.js"; interface SubpagesMenuProps { editor: Editor; @@ -33,7 +29,7 @@ export const SubpagesMenu = React.memo( return editor.isActive("subpages"); }, - [editor], + [editor] ); const getReferenceClientRect = useCallback(() => { @@ -62,18 +58,8 @@ export const SubpagesMenu = React.memo( return ( @@ -89,7 +75,7 @@ export const SubpagesMenu = React.memo( ); - }, + } ); export default SubpagesMenu; diff --git a/apps/client/src/features/editor/components/subpages/subpages-view.tsx b/apps/client/src/features/editor/components/subpages/subpages-view.tsx index 0da33028..697c1213 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-view.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-view.tsx @@ -19,6 +19,7 @@ export default function SubpagesView(props: NodeViewProps) { const { spaceSlug, shareId } = useParams(); const { t } = useTranslation(); + //@ts-ignore const currentPageId = editor.storage.pageId; // Get subpages from shared tree if we're in a shared context diff --git a/apps/client/src/features/editor/components/table/table-cell-menu.tsx b/apps/client/src/features/editor/components/table/table-cell-menu.tsx index 2ea2e8dd..8af896b3 100644 --- a/apps/client/src/features/editor/components/table/table-cell-menu.tsx +++ b/apps/client/src/features/editor/components/table/table-cell-menu.tsx @@ -1,6 +1,4 @@ -import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react"; import React, { useCallback } from "react"; - import { EditorMenuProps, ShouldShowProps, @@ -17,6 +15,7 @@ import { import { useTranslation } from "react-i18next"; import { TableBackgroundColor } from "./table-background-color"; import { TableTextAlignment } from "./table-text-alignment"; +import { BubbleMenu } from "@tiptap/react/menus"; export const TableCellMenu = React.memo( ({ editor, appendTo }: EditorMenuProps): JSX.Element => { @@ -29,7 +28,7 @@ export const TableCellMenu = React.memo( return isCellSelection(state.selection); }, - [editor], + [editor] ); const mergeCells = useCallback(() => { @@ -53,23 +52,27 @@ export const TableCellMenu = React.memo( }, [editor]); return ( - { - return appendTo?.current; + appendTo={() => { + return appendTo?.current; + }} + ref={(element) => { + element.style.zIndex = "99"; + }} + options={{ + offset: { + mainAxis: 15, }, - offset: [0, 15], - zIndex: 99, }} shouldShow={shouldShow} > - + - + ); - }, + } ); export default TableCellMenu; diff --git a/apps/client/src/features/editor/components/table/table-menu.tsx b/apps/client/src/features/editor/components/table/table-menu.tsx index 1d2985e8..e54a06af 100644 --- a/apps/client/src/features/editor/components/table/table-menu.tsx +++ b/apps/client/src/features/editor/components/table/table-menu.tsx @@ -1,11 +1,6 @@ -import { - BubbleMenu as BaseBubbleMenu, - posToDOMRect, - findParentNode, -} from "@tiptap/react"; +import { posToDOMRect, findParentNode } from "@tiptap/react"; import { Node as PMNode } from "@tiptap/pm/model"; import React, { useCallback } from "react"; - import { EditorMenuProps, ShouldShowProps, @@ -17,9 +12,12 @@ import { IconColumnRemove, IconRowInsertBottom, IconRowInsertTop, - IconRowRemove, IconTableColumn, IconTableRow, + IconRowRemove, + IconTableColumn, + IconTableRow, IconTrashX, -} from '@tabler/icons-react'; +} from "@tabler/icons-react"; +import { BubbleMenu } from "@tiptap/react/menus"; import { isCellSelection } from "@docmost/editor-ext"; import { useTranslation } from "react-i18next"; @@ -34,20 +32,28 @@ export const TableMenu = React.memo( return editor.isActive("table") && !isCellSelection(state.selection); }, - [editor], + [editor] ); - const getReferenceClientRect = useCallback(() => { + const getReferencedVirtualElement = useCallback(() => { const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "table"; const parent = findParentNode(predicate)(selection); if (parent) { const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; - return dom.getBoundingClientRect(); + const rect = dom.getBoundingClientRect(); + return { + getBoundingClientRect: () => rect, + getClientRects: () => [rect], + }; } - return posToDOMRect(editor.view, selection.from, selection.to); + const rect = posToDOMRect(editor.view, selection.from, selection.to); + return { + getBoundingClientRect: () => rect, + getClientRects: () => [rect], + }; }, [editor]); const toggleHeaderColumn = useCallback(() => { @@ -87,42 +93,33 @@ export const TableMenu = React.memo( }, [editor]); return ( - { + element.style.zIndex = "99"; + }} + options={{ + placement: "top", + offset: { + mainAxis: 15, + }, + flip: { + fallbackPlacements: ["top", "bottom"], + padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity }, + boundary: editor.options.element as HTMLElement, + }, + shift: { + padding: 8 + 15, + crossAxis: true, }, }} shouldShow={shouldShow} > - + - + - + - + ); - }, + } ); export default TableMenu; diff --git a/apps/client/src/features/editor/components/video/video-menu.tsx b/apps/client/src/features/editor/components/video/video-menu.tsx index 3252e621..dfece398 100644 --- a/apps/client/src/features/editor/components/video/video-menu.tsx +++ b/apps/client/src/features/editor/components/video/video-menu.tsx @@ -1,11 +1,6 @@ -import { - BubbleMenu as BaseBubbleMenu, - findParentNode, - posToDOMRect, - useEditorState, -} from "@tiptap/react"; +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import React, { useCallback } from "react"; -import { sticky } from "tippy.js"; import { Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, @@ -22,16 +17,6 @@ import { useTranslation } from "react-i18next"; export function VideoMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); - const shouldShow = useCallback( - ({ state }: ShouldShowProps) => { - if (!state) { - return false; - } - - return editor.isActive("video"); - }, - [editor], - ); const editorState = useEditorState({ editor, @@ -52,17 +37,37 @@ export function VideoMenu({ editor }: EditorMenuProps) { }, }); - const getReferenceClientRect = useCallback(() => { + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) { + return false; + } + + return editor.isActive("video") && editor.getAttributes("video").src; + }, + [editor], + ); + + const getReferencedVirtualElement = useCallback(() => { + if (!editor) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "video"; const parent = findParentNode(predicate)(selection); if (parent) { const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; - return dom.getBoundingClientRect(); + const domRect = dom.getBoundingClientRect(); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; } - return posToDOMRect(editor.view, selection.from, selection.to); + const domRect = posToDOMRect(editor.view, selection.from, selection.to); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; }, [editor]); const alignVideoLeft = useCallback(() => { @@ -105,15 +110,11 @@ export function VideoMenu({ editor }: EditorMenuProps) { editor={editor} pluginKey={`video-menu`} updateDelay={0} - tippyOptions={{ - getReferenceClientRect, - offset: [0, 8], - zIndex: 99, - popperOptions: { - modifiers: [{ name: "flip", enabled: false }], - }, - plugins: [sticky], - sticky: "popper", + getReferencedVirtualElement={getReferencedVirtualElement} + options={{ + placement: "top", + offset: 8, + flip: false, }} shouldShow={shouldShow} > diff --git a/apps/client/src/features/editor/components/video/video-view.module.css b/apps/client/src/features/editor/components/video/video-view.module.css new file mode 100644 index 00000000..c0e7f99d --- /dev/null +++ b/apps/client/src/features/editor/components/video/video-view.module.css @@ -0,0 +1,33 @@ +.videoWrapper { + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; + overflow: hidden; + animation: pulse 1.2s ease-in-out infinite; + + @mixin light { + background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%); + background-size: 400% 400%; + } + + @mixin dark { + background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%); + background-size: 400% 400%; + } + + @keyframes pulse { + 0% { + background-position: 0% 0%; + } + 100% { + background-position: -135% 0%; + } + } +} +.video { + display: block; + width: 100%; + height: 100%; + border-radius: 8px; +} diff --git a/apps/client/src/features/editor/components/video/video-view.tsx b/apps/client/src/features/editor/components/video/video-view.tsx index d47d9a4a..e2473afc 100644 --- a/apps/client/src/features/editor/components/video/video-view.tsx +++ b/apps/client/src/features/editor/components/video/video-view.tsx @@ -1,29 +1,75 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { Group, Loader, Text } from "@mantine/core"; import { useMemo } from "react"; import { getFileUrl } from "@/lib/config.ts"; import clsx from "clsx"; +import classes from "./video-view.module.css"; +import { useTranslation } from "react-i18next"; export default function VideoView(props: NodeViewProps) { - const { node, selected } = props; - const { src, width, align } = node.attrs; - + const { t } = useTranslation(); + const { editor, node, selected } = props; + const { src, width, align, aspectRatio, placeholder } = node.attrs; const alignClass = useMemo(() => { if (align === "left") return "alignLeft"; if (align === "right") return "alignRight"; if (align === "center") return "alignCenter"; return "alignCenter"; }, [align]); + const previewSrc = useMemo(() => { + editor.storage.shared.videoPreviews = + editor.storage.shared.videoPreviews || {}; + + if (placeholder?.id) { + return editor.storage.shared.videoPreviews[placeholder.id]; + } + + return null; + }, [placeholder, editor]); return ( - ); } diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index ecdea2e7..ef03108b 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -1,11 +1,7 @@ import { StarterKit } from "@tiptap/starter-kit"; -import { Placeholder } from "@tiptap/extension-placeholder"; import { TextAlign } from "@tiptap/extension-text-align"; -import { CharacterCount } from "@tiptap/extension-character-count"; -import { TaskList } from "@tiptap/extension-task-list"; -import { ListKeymap } from "@tiptap/extension-list-keymap"; -import { TaskItem } from "@tiptap/extension-task-item"; -import { Underline } from "@tiptap/extension-underline"; +import { TaskList, TaskItem } from "@tiptap/extension-list"; +import { Placeholder, CharacterCount } from "@tiptap/extensions"; import { Superscript } from "@tiptap/extension-superscript"; import SubScript from "@tiptap/extension-subscript"; import { Typography } from "@tiptap/extension-typography"; @@ -15,7 +11,7 @@ import GlobalDragHandle from "tiptap-extension-global-drag-handle"; import { Youtube } from "@tiptap/extension-youtube"; import SlashCommand from "@/features/editor/extensions/slash-command"; import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration"; -import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor"; +import { CollaborationCaret } from "@tiptap/extension-collaboration-caret"; import { HocuspocusProvider } from "@hocuspocus/provider"; import { Comment, @@ -41,11 +37,12 @@ import { Embed, SearchAndReplace, Mention, - Subpages, TableDndExtension, + Subpages, Heading, Highlight, UniqueID, + SharedStorage, } from "@docmost/editor-ext"; import { randomElement, @@ -97,7 +94,9 @@ lowlight.register("scala", scala); export const mainExtensions = [ StarterKit.configure({ heading: false, - history: false, + undoRedo: false, + link: false, + trailingNode: false, dropcursor: { width: 3, color: "#70CFF8", @@ -109,6 +108,7 @@ export const mainExtensions = [ }, }, }), + SharedStorage, Heading, UniqueID.configure({ types: ["heading", "paragraph"], @@ -134,8 +134,6 @@ export const mainExtensions = [ TaskItem.configure({ nested: true, }), - ListKeymap, - Underline, LinkExtension.configure({ openOnClick: false, }), @@ -170,6 +168,9 @@ export const mainExtensions = [ }, }).extend({ addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(MentionView); }, }), @@ -208,6 +209,7 @@ export const mainExtensions = [ }), CustomCodeBlock.configure({ view: CodeBlockView, + //@ts-ignore lowlight, HTMLAttributes: { spellcheck: false, @@ -246,7 +248,7 @@ export const mainExtensions = [ Escape: () => { const event = new CustomEvent("closeFindDialogFromEditor", {}); document.dispatchEvent(event); - return true; + return false; }, }; }, @@ -258,8 +260,9 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; export const collabExtensions: CollabExtensions = (provider, user) => [ Collaboration.configure({ document: provider.document, + provider, }), - CollaborationCursor.configure({ + CollaborationCaret.configure({ provider, user: { name: user.name, diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index b4478920..da8bd84a 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -1,13 +1,22 @@ import "@/features/editor/styles/index.css"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { IndexeddbPersistence } from "y-indexeddb"; import * as Y from "yjs"; import { HocuspocusProvider, - onAuthenticationFailedParameters, + onStatusParameters, WebSocketStatus, + HocuspocusProviderWebsocket, + onSyncedParameters, } from "@hocuspocus/provider"; import { + Editor, EditorContent, EditorProvider, useEditor, @@ -69,161 +78,140 @@ export default function PageEditor({ editable, content, }: PageEditorProps) { - - const collaborationURL = useCollaborationUrl(); const isComponentMounted = useRef(false); - const editorCreated = useRef(false); + const editorRef = useRef(null); useEffect(() => { isComponentMounted.current = true; }, []); - + const [currentUser] = useAtom(currentUserAtom); const [, setEditor] = useAtom(pageEditorAtom); const [, setAsideState] = useAtom(asideStateAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); - const ydocRef = useRef(null); - if (!ydocRef.current) { - ydocRef.current = new Y.Doc(); - } - const ydoc = ydocRef.current; - const [isLocalSynced, setLocalSynced] = useState(false); - const [isRemoteSynced, setRemoteSynced] = useState(false); + const [isLocalSynced, setIsLocalSynced] = useState(false); + const [isRemoteSynced, setIsRemoteSynced] = useState(false); const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( yjsConnectionStatusAtom, ); const menuContainerRef = useRef(null); - const documentName = `page.${pageId}`; const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); const documentState = useDocumentVisibility(); - const [isCollabReady, setIsCollabReady] = useState(false); const { pageSlug } = useParams(); const slugId = extractPageSlugId(pageSlug); const userPageEditMode = currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; - - const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]); + const canScroll = useCallback( + () => Boolean(isComponentMounted.current && editorRef.current), + [isComponentMounted], + ); const { handleScrollTo } = useEditorScroll({ canScroll }); // Providers only created once per pageId const providersRef = useRef<{ local: IndexeddbPersistence; remote: HocuspocusProvider; + socket: HocuspocusProviderWebsocket; } | null>(null); const [providersReady, setProvidersReady] = useState(false); - const localProvider = providersRef.current?.local; - const remoteProvider = providersRef.current?.remote; - - // Track when collaborative provider is ready and synced - const [collabReady, setCollabReady] = useState(false); - useEffect(() => { - if ( - remoteProvider?.status === WebSocketStatus.Connected && - isLocalSynced && - isRemoteSynced - ) { - setCollabReady(true); - } - }, [remoteProvider?.status, isLocalSynced, isRemoteSynced]); - useEffect(() => { if (!providersRef.current) { + const documentName = `page.${pageId}`; + const ydoc = new Y.Doc(); const local = new IndexeddbPersistence(documentName, ydoc); - local.on("synced", () => setLocalSynced(true)); - const remote = new HocuspocusProvider({ - name: documentName, + const socket = new HocuspocusProviderWebsocket({ url: collaborationURL, + }); + const onLocalSyncedHandler = () => { + setIsLocalSynced(true); + }; + const onStatusHandler = (event: onStatusParameters) => { + setYjsConnectionStatus(event.status); + }; + const onSyncedHandler = (event: onSyncedParameters) => { + setIsRemoteSynced(event.state); + }; + const onAuthenticationFailedHandler = () => { + const payload = jwtDecode(collabQuery?.token); + const now = Date.now().valueOf() / 1000; + const isTokenExpired = now >= payload.exp; + if (isTokenExpired) { + refetchCollabToken().then((result) => { + if (result.data?.token) { + socket.disconnect(); + setTimeout(() => { + remote.configuration.token = result.data.token; + socket.connect(); + }, 100); + } + }); + } + }; + const remote = new HocuspocusProvider({ + websocketProvider: socket, + name: documentName, document: ydoc, token: collabQuery?.token, - connect: true, - preserveConnection: false, - onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => { - const payload = jwtDecode(collabQuery?.token); - const now = Date.now().valueOf() / 1000; - const isTokenExpired = now >= payload.exp; - if (isTokenExpired) { - refetchCollabToken().then((result) => { - if (result.data?.token) { - remote.disconnect(); - setTimeout(() => { - remote.configuration.token = result.data.token; - remote.connect(); - }, 100); - } - }); - } - }, - onStatus: (status) => { - if (status.status === "connected") { - setYjsConnectionStatus(status.status); - } - }, + onAuthenticationFailed: onAuthenticationFailedHandler, + onStatus: onStatusHandler, + onSynced: onSyncedHandler, }); - remote.on("synced", () => setRemoteSynced(true)); - remote.on("disconnect", () => { - setYjsConnectionStatus(WebSocketStatus.Disconnected); - }); - providersRef.current = { local, remote }; + + local.on("synced", onLocalSyncedHandler); + providersRef.current = { socket, local, remote }; setProvidersReady(true); } else { setProvidersReady(true); } // Only destroy on final unmount return () => { + providersRef.current?.socket.destroy(); providersRef.current?.remote.destroy(); providersRef.current?.local.destroy(); providersRef.current = null; }; }, [pageId]); - /* - useEffect(() => { - // Handle token updates by reconnecting with new token - if (providersRef.current?.remote && collabQuery?.token) { - const currentToken = providersRef.current.remote.configuration.token; - if (currentToken !== collabQuery.token) { - // Token has changed, need to reconnect with new token - providersRef.current.remote.disconnect(); - providersRef.current.remote.configuration.token = collabQuery.token; - providersRef.current.remote.connect(); - } - } - }, [collabQuery?.token]); - */ - // Only connect/disconnect on tab/idle, not destroy useEffect(() => { if (!providersReady || !providersRef.current) return; - const remoteProvider = providersRef.current.remote; + const socket = providersRef.current.socket; + if ( isIdle && documentState === "hidden" && - remoteProvider.status === WebSocketStatus.Connected + yjsConnectionStatus === WebSocketStatus.Connected ) { - remoteProvider.disconnect(); - setIsCollabReady(false); + socket.disconnect(); return; } if ( documentState === "visible" && - remoteProvider.status === WebSocketStatus.Disconnected + yjsConnectionStatus === WebSocketStatus.Disconnected ) { resetIdle(); - remoteProvider.connect(); - setTimeout(() => setIsCollabReady(true), 500); + socket.connect(); } }, [isIdle, documentState, providersReady, resetIdle]); + // Attach here, to make sure the connection gets properly established + providersRef.current?.remote.attach(); + const extensions = useMemo(() => { - if (!remoteProvider || !currentUser?.user) return mainExtensions; + if (!providersReady || !providersRef.current || !currentUser?.user) { + return mainExtensions; + } + + const remoteProvider = providersRef.current.remote; + return [ ...mainExtensions, ...collabExtensions(remoteProvider, currentUser?.user), ]; - }, [remoteProvider, currentUser?.user]); + }, [providersReady, currentUser?.user]); const editor = useEditor( { @@ -266,18 +254,30 @@ export default function PageEditor({ } }, }, - handlePaste: (view, event, slice) => - handlePaste(view, event, pageId, currentUser?.user.id), - handleDrop: (view, event, _slice, moved) => - handleFileDrop(view, event, moved, pageId), + handlePaste: (_view, event) => { + if (!editorRef.current) return false; + + return handlePaste( + editorRef.current, + event, + pageId, + currentUser?.user.id, + ); + }, + handleDrop: (_view, event, _slice, moved) => { + if (!editorRef.current) return false; + + return handleFileDrop(editorRef.current, event, moved, pageId); + }, }, onCreate({ editor }) { if (editor) { // @ts-ignore setEditor(editor); + // @ts-ignore editor.storage.pageId = pageId; handleScrollTo(editor); - editorCreated.current = true; + editorRef.current = editor; } }, onUpdate({ editor }) { @@ -287,7 +287,7 @@ export default function PageEditor({ debouncedUpdateContent(editorJson); }, }, - [pageId, editable, remoteProvider], + [pageId, editable, extensions], ); const editorIsEditable = useEditorState({ @@ -343,30 +343,17 @@ export default function PageEditor({ setAsideState({ tab: "", isAsideOpen: false }); }, [pageId]); - useEffect(() => { - if (remoteProvider?.status === WebSocketStatus.Connecting) { - const timeout = setTimeout(() => { - setYjsConnectionStatus(WebSocketStatus.Disconnected); - }, 5000); - return () => clearTimeout(timeout); - } - }, [remoteProvider?.status]); - const isSynced = isLocalSynced && isRemoteSynced; useEffect(() => { - const collabReadyTimeout = setTimeout(() => { - if ( - !isCollabReady && - isSynced && - remoteProvider?.status === WebSocketStatus.Connected - ) { - setIsCollabReady(true); + const timeout = setTimeout(() => { + if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) { + setYjsConnectionStatus(WebSocketStatus.Disconnected); } - }, 500); - return () => clearTimeout(collabReadyTimeout); - }, [isRemoteSynced, isLocalSynced, remoteProvider?.status]); + }, 7500); + return () => clearTimeout(timeout); + }, [yjsConnectionStatus, isSynced]); useEffect(() => { // Only honor user default page edit mode preference and permissions if (editor) { @@ -388,12 +375,13 @@ export default function PageEditor({ useEffect(() => { if ( !hasConnectedOnceRef.current && - remoteProvider?.status === WebSocketStatus.Connected + yjsConnectionStatus === WebSocketStatus.Connected && + isSynced ) { hasConnectedOnceRef.current = true; setShowStatic(false); } - }, [remoteProvider?.status]); + }, [yjsConnectionStatus, isSynced]); if (showStatic) { return ( diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx index c81e4d19..77496fcd 100644 --- a/apps/client/src/features/editor/readonly-page-editor.tsx +++ b/apps/client/src/features/editor/readonly-page-editor.tsx @@ -81,6 +81,7 @@ export default function ReadonlyPageEditor({ onCreate={({ editor }) => { if (editor) { if (pageId) { + // @ts-ignore editor.storage.pageId = pageId; } // @ts-ignore diff --git a/apps/client/src/features/editor/styles/collaboration.css b/apps/client/src/features/editor/styles/collaboration.css index 4a43ac25..a13d2180 100644 --- a/apps/client/src/features/editor/styles/collaboration.css +++ b/apps/client/src/features/editor/styles/collaboration.css @@ -1,5 +1,5 @@ /* Give a remote user a caret */ -.collaboration-cursor__caret { +.collaboration-carets__caret { border-left: 1px solid #0d0d0d; border-right: 1px solid #0d0d0d; margin-left: -1px; @@ -10,7 +10,7 @@ } /* Render the username above the caret */ -.collaboration-cursor__label { +.collaboration-carets__label { border-radius: 3px 3px 3px 0; color: #0d0d0d; font-size: 0.75rem; diff --git a/apps/client/src/features/page-history/components/history-editor.tsx b/apps/client/src/features/page-history/components/history-editor.tsx index 4b5839af..5fa8cf42 100644 --- a/apps/client/src/features/page-history/components/history-editor.tsx +++ b/apps/client/src/features/page-history/components/history-editor.tsx @@ -1,8 +1,9 @@ -import '@/features/editor/styles/index.css'; -import React, { useEffect } from 'react'; -import { EditorContent, useEditor } from '@tiptap/react'; -import { mainExtensions } from '@/features/editor/extensions/extensions'; -import { Title } from '@mantine/core'; +import "@/features/editor/styles/index.css"; +import React, { useEffect } from "react"; +import { EditorContent, useEditor } from "@tiptap/react"; +import { mainExtensions } from "@/features/editor/extensions/extensions"; +import { Title } from "@mantine/core"; +import classes from "./history.module.css"; export interface HistoryEditorProps { title: string; @@ -26,7 +27,9 @@ export function HistoryEditor({ title, content }: HistoryEditorProps) {
{title} - {editor && } + {editor && ( + + )}
); diff --git a/apps/client/src/features/page-history/components/history-list.tsx b/apps/client/src/features/page-history/components/history-list.tsx index af178eac..7b0d9ea2 100644 --- a/apps/client/src/features/page-history/components/history-list.tsx +++ b/apps/client/src/features/page-history/components/history-list.tsx @@ -67,7 +67,7 @@ function HistoryList({ pageId }: Props) { mainEditorTitle .chain() .clearContent() - .setContent(activeHistoryData.title, true) + .setContent(activeHistoryData.title, { emitUpdate: true }) .run(); mainEditor .chain() diff --git a/apps/client/src/features/page-history/components/history.module.css b/apps/client/src/features/page-history/components/history.module.css index 5d23cb0c..66415146 100644 --- a/apps/client/src/features/page-history/components/history.module.css +++ b/apps/client/src/features/page-history/components/history.module.css @@ -1,37 +1,49 @@ .history { - display: block; - width: 100%; - padding: var(--mantine-spacing-md); - color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); + display: block; + width: 100%; + padding: var(--mantine-spacing-md); + color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); - @mixin hover { - background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8)); - } + @mixin hover { + background-color: light-dark( + var(--mantine-color-gray-2), + var(--mantine-color-dark-8) + ); + } +} + +.historyEditor { + :global(.ProseMirror) { + padding: 0 !important; + } } .active { - background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8)); + background-color: light-dark( + var(--mantine-color-gray-2), + var(--mantine-color-dark-8) + ); } .sidebar { - max-height: rem(700px); - width: rem(250px); - padding: var(--mantine-spacing-sm); - display: flex; - flex-direction: column; - border-right: rem(1px) solid + max-height: rem(700px); + width: rem(250px); + padding: var(--mantine-spacing-sm); + display: flex; + flex-direction: column; + border-right: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); } .sidebarFlex { - display: flex; + display: flex; } .sidebarMain { - flex: 1; + flex: 1; } .sidebarRightSection { - flex: 1; - padding: rem(16px) rem(40px); + flex: 1; + padding: rem(16px) rem(40px); } diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index cf61ac39..6e9625b1 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -9,20 +9,14 @@ import { IconList, IconMessage, IconPrinter, - IconSearch, IconTrash, IconWifiOff, } from "@tabler/icons-react"; -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import useToggleAside from "@/hooks/use-toggle-aside.tsx"; -import { useAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; -import { - getHotkeyHandler, - useClipboard, - useDisclosure, - useHotkeys, -} from "@mantine/hooks"; +import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks"; import { useParams } from "react-router-dom"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts"; @@ -38,8 +32,7 @@ import { pageEditorAtom, yjsConnectionStatusAtom, } from "@/features/editor/atoms/editor-atoms.ts"; -import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts"; -import { formattedDate, timeAgo } from "@/lib/time.ts"; +import { formattedDate } from "@/lib/time.ts"; import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; @@ -51,7 +44,6 @@ interface PageHeaderMenuProps { export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { const { t } = useTranslation(); const toggleAside = useToggleAside(); - const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom); useHotkeys( [ @@ -68,6 +60,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { const event = new CustomEvent("closeFindDialogFromEditor", {}); document.dispatchEvent(event); }, + { preventDefault: false }, ], ], [], @@ -75,17 +68,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { return ( <> - {yjsConnectionStatus === "disconnected" && ( - - - - - - )} + {!readOnly && } @@ -290,3 +273,51 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { ); } + +function ConnectionWarning() { + const { t } = useTranslation(); + const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom); + const [showWarning, setShowWarning] = useState(false); + const timeoutRef = useRef | 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 ( + + + + + + ); +} diff --git a/apps/server/src/collaboration/collaboration.gateway.ts b/apps/server/src/collaboration/collaboration.gateway.ts index 3f894572..f1d50671 100644 --- a/apps/server/src/collaboration/collaboration.gateway.ts +++ b/apps/server/src/collaboration/collaboration.gateway.ts @@ -26,7 +26,7 @@ export class CollaborationGateway { ) { this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl()); - this.hocuspocus = HocuspocusServer.configure({ + this.hocuspocus = new Hocuspocus({ debounce: 10000, maxDebounce: 45000, unloadImmediately: false, @@ -65,6 +65,6 @@ export class CollaborationGateway { } async destroy(): Promise { - await this.hocuspocus.destroy(); + //await this.hocuspocus.destroy(); } } diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 06133c3f..16ca5bd5 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -1,14 +1,12 @@ import { StarterKit } from '@tiptap/starter-kit'; import { TextAlign } from '@tiptap/extension-text-align'; -import { TaskList } from '@tiptap/extension-task-list'; -import { TaskItem } from '@tiptap/extension-task-item'; -import { Underline } from '@tiptap/extension-underline'; import { Superscript } from '@tiptap/extension-superscript'; import SubScript from '@tiptap/extension-subscript'; import { Typography } from '@tiptap/extension-typography'; import { TextStyle } from '@tiptap/extension-text-style'; import { Color } from '@tiptap/extension-color'; import { Youtube } from '@tiptap/extension-youtube'; +import { TaskList, TaskItem } from '@tiptap/extension-list'; import { Heading, Callout, @@ -42,11 +40,14 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; // @tiptap/html library works best for generating prosemirror json state but not HTML // see: https://github.com/ueberdosis/tiptap/issues/5352 // see:https://github.com/ueberdosis/tiptap/issues/4089 +//import { generateJSON } from '@tiptap/html'; import { Node } from '@tiptap/pm/model'; export const tiptapExtensions = [ StarterKit.configure({ codeBlock: false, + link: false, + trailingNode: false, heading: false, }), Heading, @@ -59,7 +60,6 @@ export const tiptapExtensions = [ TaskItem.configure({ nested: true, }), - Underline, LinkExtension, Superscript, SubScript, diff --git a/apps/server/src/collaboration/extensions/authentication.extension.ts b/apps/server/src/collaboration/extensions/authentication.extension.ts index 1a42bd97..04a360f7 100644 --- a/apps/server/src/collaboration/extensions/authentication.extension.ts +++ b/apps/server/src/collaboration/extensions/authentication.extension.ts @@ -69,7 +69,7 @@ export class AuthenticationExtension implements Extension { } if (userSpaceRole === SpaceRole.READER) { - data.connection.readOnly = true; + data.connectionConfig.readOnly = true; this.logger.debug(`User granted readonly access to page: ${pageId}`); } diff --git a/package.json b/package.json index e7550c6a..6a5103e8 100644 --- a/package.json +++ b/package.json @@ -20,56 +20,51 @@ }, "dependencies": { "@braintree/sanitize-url": "^7.1.0", - "@casl/ability": "^6.7.5", + "@casl/ability": "6.8.0", "@docmost/editor-ext": "workspace:*", "@floating-ui/dom": "^1.7.3", - "@hocuspocus/extension-redis": "^2.15.3", - "@hocuspocus/provider": "^2.15.3", - "@hocuspocus/server": "^2.15.3", - "@hocuspocus/transformer": "^2.15.3", + "@hocuspocus/extension-redis": "3.4.3", + "@hocuspocus/provider": "3.4.3", + "@hocuspocus/server": "3.4.3", + "@hocuspocus/transformer": "3.4.3", "@joplin/turndown": "^4.0.74", "@joplin/turndown-plugin-gfm": "^1.0.56", "@sindresorhus/slugify": "1.1.0", - "@tiptap/core": "2.27.1", - "@tiptap/extension-code-block": "2.27.1", - "@tiptap/extension-code-block-lowlight": "2.27.1", - "@tiptap/extension-collaboration": "2.27.1", - "@tiptap/extension-collaboration-cursor": "2.27.1", - "@tiptap/extension-color": "2.27.1", - "@tiptap/extension-document": "2.27.1", - "@tiptap/extension-heading": "2.27.1", - "@tiptap/extension-highlight": "2.27.1", - "@tiptap/extension-history": "2.27.1", - "@tiptap/extension-image": "2.27.1", - "@tiptap/extension-link": "2.27.1", - "@tiptap/extension-list-item": "2.27.1", - "@tiptap/extension-list-keymap": "2.27.1", - "@tiptap/extension-placeholder": "2.27.1", - "@tiptap/extension-subscript": "2.27.1", - "@tiptap/extension-superscript": "2.27.1", - "@tiptap/extension-table": "2.27.1", - "@tiptap/extension-table-cell": "2.27.1", - "@tiptap/extension-table-header": "2.27.1", - "@tiptap/extension-table-row": "2.27.1", - "@tiptap/extension-task-item": "2.27.1", - "@tiptap/extension-task-list": "2.27.1", - "@tiptap/extension-text": "2.27.1", - "@tiptap/extension-text-align": "2.27.1", - "@tiptap/extension-text-style": "2.27.1", - "@tiptap/extension-typography": "2.27.1", - "@tiptap/extension-underline": "2.27.1", - "@tiptap/extension-youtube": "2.27.1", - "@tiptap/html": "2.27.1", - "@tiptap/pm": "2.27.1", - "@tiptap/react": "2.27.1", - "@tiptap/starter-kit": "2.27.1", - "@tiptap/suggestion": "2.27.1", + "@tiptap/core": "3.17.1", + "@tiptap/extension-code-block": "3.17.1", + "@tiptap/extension-collaboration": "3.17.1", + "@tiptap/extension-collaboration-caret": "3.17.1", + "@tiptap/extension-color": "3.17.1", + "@tiptap/extension-document": "3.17.1", + "@tiptap/extension-heading": "3.17.1", + "@tiptap/extension-highlight": "3.17.1", + "@tiptap/extension-history": "3.17.1", + "@tiptap/extension-image": "3.17.1", + "@tiptap/extension-link": "3.17.1", + "@tiptap/extension-list": "3.17.1", + "@tiptap/extension-placeholder": "3.17.1", + "@tiptap/extension-subscript": "3.17.1", + "@tiptap/extension-superscript": "3.17.1", + "@tiptap/extension-table": "3.17.1", + "@tiptap/extension-text": "3.17.1", + "@tiptap/extension-text-align": "3.17.1", + "@tiptap/extension-text-style": "3.17.1", + "@tiptap/extension-typography": "3.17.1", + "@tiptap/extension-unique-id": "^3.17.1", + "@tiptap/extension-youtube": "3.17.1", + "@tiptap/html": "3.17.1", + "@tiptap/pm": "3.17.1", + "@tiptap/react": "3.17.1", + "@tiptap/starter-kit": "3.17.1", + "@tiptap/suggestion": "3.17.1", "@types/qrcode": "^1.5.5", "bytes": "^3.1.2", "cross-env": "^7.0.3", "date-fns": "^4.1.0", "dompurify": "^3.2.6", "fractional-indexing-jittered": "^1.0.0", + "highlight.js": "^11.11.1", + "image-dimensions": "^2.5.0", "ioredis": "^5.4.1", "jszip": "^3.10.1", "linkifyjs": "^4.3.2", @@ -79,7 +74,7 @@ "uuid": "^11.1.0", "y-indexeddb": "^9.0.12", "y-prosemirror": "1.3.7", - "yjs": "^13.6.27" + "yjs": "^13.6.29" }, "devDependencies": { "@nx/js": "20.4.5", diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 3ff99083..24d0ac5f 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -23,3 +23,4 @@ export * from "./lib/subpages"; export * from "./lib/highlight"; export * from "./lib/heading/heading"; export * from "./lib/unique-id"; +export * from "./lib/shared-storage"; diff --git a/packages/editor-ext/src/lib/attachment/attachment-upload.ts b/packages/editor-ext/src/lib/attachment/attachment-upload.ts index 0d2ac6c7..a3446db9 100644 --- a/packages/editor-ext/src/lib/attachment/attachment-upload.ts +++ b/packages/editor-ext/src/lib/attachment/attachment-upload.ts @@ -1,126 +1,125 @@ -import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { - insertTrailingNode, - MediaUploadOptions, - UploadFn, -} from "../media-utils"; +import { Node } from "@tiptap/pm/model"; +import { MediaUploadOptions, UploadFn } from "../media-utils"; import { IAttachment } from "../types"; +import { generateNodeId } from "../utils"; +import { Command } from "@tiptap/core"; -const uploadKey = new PluginKey("attachment-upload"); +const findAttachmentNodeByPlaceholderId = ( + doc: Node, + placeholderId: string, +): { node: Node; pos: number } | null => { + let result: { node: Node; pos: number } | null = null; -export const AttachmentUploadPlugin = ({ - placeholderClass, -}: { - placeholderClass: string; -}) => - new Plugin({ - key: uploadKey, - state: { - init() { - return DecorationSet.empty; - }, - apply(tr, set) { - set = set.map(tr.mapping, tr.doc); - // See if the transaction adds or removes any placeholders - //@-ts-expect-error - not yet sure what the type I need here - const action = tr.getMeta(this); - if (action?.add) { - const { id, pos, fileName } = action.add; - - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", placeholderClass); - - const uploadingText = document.createElement("span"); - uploadingText.setAttribute("class", "uploading-text"); - uploadingText.textContent = `Uploading ${fileName}`; - - placeholder.appendChild(uploadingText); - - const realPos = pos + 1; - const deco = Decoration.widget(realPos, placeholder, { - id, - }); - set = set.add(tr.doc, [deco]); - } else if (action?.remove) { - set = set.remove( - set.find( - undefined, - undefined, - (spec) => spec.id == action.remove.id, - ), - ); - } - return set; - }, - }, - props: { - decorations(state) { - return this.getState(state); - }, - }, + doc.descendants((node, pos) => { + if (result) return false; + if ( + node.type.name === "attachment" && + node.attrs.placeholder?.id === placeholderId + ) { + result = { node, pos }; + return false; + } + return true; }); -function findPlaceholder(state: EditorState, id: {}) { - const decos = uploadKey.getState(state) as DecorationSet; - const found = decos.find(undefined, undefined, (spec) => spec.id == id); - return found.length ? found[0]?.from : null; -} - -export const handleAttachmentUpload = + return result; +}; +const handleAttachmentUpload = ({ validateFn, onUpload }: MediaUploadOptions): UploadFn => - async (file, view, pos, pageId, allowMedia) => { + async (file, editor, pos, pageId, allowMedia) => { const validated = validateFn?.(file, allowMedia); // @ts-ignore if (!validated) return; - // A fresh object to act as the ID for this upload - const id = {}; - // Replace the selection with a placeholder - const tr = view.state.tr; - if (!tr.selection.empty) tr.deleteSelection(); + const placeholderId = generateNodeId(); - tr.setMeta(uploadKey, { - add: { - id, - pos, - fileName: file.name, - }, - }); + let placeholderInserted = false; - insertTrailingNode(tr, pos, view); - view.dispatch(tr); + const insertPlaceholder = (): Command => { + return ({ tr, state }) => { + const initialPlaceholderNode = state.schema.nodes.attachment?.create({ + placeholder: { + id: placeholderId, + }, + name: file.name, + size: file.size, + }); - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + if (!initialPlaceholderNode) return false; - const pos = findPlaceholder(view.state, id); + const { parent } = tr.doc.resolve(pos); + const isEmptyTextBlock = parent.isTextblock && !parent.childCount; - if (pos == null) return; + if (isEmptyTextBlock) { + tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode); + } else { + tr.insert(pos, initialPlaceholderNode); + } - if (!attachment) return; + return true; + }; + }; + const replacePlaceholderWithAttachment = ( + attachment: IAttachment, + ): Command => { + return ({ tr }) => { + const { pos: currentPos = null } = + findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {}; - const node = schema.nodes.attachment?.create({ + // If the placeholder is not found or attachment is missing, abort the process + if (currentPos === null || !attachment) return false; + + // Update the placeholder node with the actual attachment data + tr.setNodeMarkup(currentPos, undefined, { url: `/api/files/${attachment.id}/${attachment.fileName}`, name: attachment.fileName, mime: attachment.mimeType, size: attachment.fileSize, attachmentId: attachment.id, }); - if (!node) return; - const transaction = view.state.tr - .replaceWith(pos, pos, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - () => { - // Deletes the placeholder on error - const transaction = view.state.tr - .delete(pos, pos) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - ); + return true; + }; + }; + const removePlaceholder = (): Command => { + return ({ tr }) => { + const { pos: currentPos = null } = + findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {}; + + if (currentPos === null) return false; + + tr.delete(currentPos, currentPos + 2); + + return true; + }; + }; + // Only show the placeholder if the upload takes more than 250ms + const insertPlaceholderTimeout = setTimeout(() => { + editor.commands.command(insertPlaceholder()); + placeholderInserted = true; + }, 250); + + try { + const attachment: IAttachment = await onUpload(file, pageId); + + clearTimeout(insertPlaceholderTimeout); + + if (placeholderInserted) { + setTimeout(() => { + editor.commands.command(replacePlaceholderWithAttachment(attachment)); + }, 100); + } else { + editor + .chain() + .command(insertPlaceholder()) + .command(replacePlaceholderWithAttachment(attachment)) + .run(); + } + } catch (error) { + clearTimeout(insertPlaceholderTimeout); + + editor.commands.command(removePlaceholder()); + } }; + +export { handleAttachmentUpload }; diff --git a/packages/editor-ext/src/lib/attachment/attachment.ts b/packages/editor-ext/src/lib/attachment/attachment.ts index 5231c897..0e37e014 100644 --- a/packages/editor-ext/src/lib/attachment/attachment.ts +++ b/packages/editor-ext/src/lib/attachment/attachment.ts @@ -1,6 +1,5 @@ import { Node, mergeAttributes } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; -import { AttachmentUploadPlugin } from "./attachment-upload"; export interface AttachmentOptions { HTMLAttributes: Record; @@ -13,6 +12,7 @@ export interface AttachmentAttributes { mime?: string; // e.g. application/zip size?: number; attachmentId?: string; + placeholder?: string; } declare module "@tiptap/core" { @@ -75,6 +75,10 @@ export const Attachment = Node.create({ "data-attachment-id": attributes.attachmentId, }), }, + placeholder: { + default: null, + rendered: false, + }, }; }, @@ -120,14 +124,9 @@ export const Attachment = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - AttachmentUploadPlugin({ - placeholderClass: "attachment-placeholder", - }), - ]; - }, }); diff --git a/packages/editor-ext/src/lib/callout/callout.ts b/packages/editor-ext/src/lib/callout/callout.ts index 97c5dfcc..1dc4d800 100644 --- a/packages/editor-ext/src/lib/callout/callout.ts +++ b/packages/editor-ext/src/lib/callout/callout.ts @@ -87,7 +87,7 @@ export const Callout = Node.create({ mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes, + HTMLAttributes ), 0, ]; @@ -130,6 +130,9 @@ export const Callout = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, @@ -193,7 +196,7 @@ export const Callout = Node.create({ tr.delete(pos, pos + nodeSize); tr.setSelection( - TextSelection.near(tr.doc.resolve(previousPosition - 1)), + TextSelection.near(tr.doc.resolve(previousPosition - 1)) ); tr.insert(previousPosition - 1, content); diff --git a/packages/editor-ext/src/lib/custom-code-block.ts b/packages/editor-ext/src/lib/custom-code-block.ts deleted file mode 100644 index 702e98a9..00000000 --- a/packages/editor-ext/src/lib/custom-code-block.ts +++ /dev/null @@ -1,81 +0,0 @@ -import CodeBlockLowlight, { - CodeBlockLowlightOptions, -} from "@tiptap/extension-code-block-lowlight"; -import { ReactNodeViewRenderer } from "@tiptap/react"; - -export interface CustomCodeBlockOptions extends CodeBlockLowlightOptions { - view: any; -} - -const TAB_CHAR = "\u00A0\u00A0"; - -export const CustomCodeBlock = CodeBlockLowlight.extend( - { - selectable: true, - - addOptions() { - return { - ...this.parent?.(), - view: null, - }; - }, - - addKeyboardShortcuts() { - return { - ...this.parent?.(), - Tab: () => { - if (this.editor.isActive("codeBlock")) { - this.editor - .chain() - .command(({ tr }) => { - tr.insertText(TAB_CHAR); - return true; - }) - .run(); - return true; - } - }, - "Mod-a": () => { - if (this.editor.isActive("codeBlock")) { - const { state } = this.editor; - const { $from } = state.selection; - - let codeBlockNode = null; - let codeBlockPos = null; - let depth = 0; - - for (depth = $from.depth; depth > 0; depth--) { - const node = $from.node(depth); - if (node.type.name === "codeBlock") { - codeBlockNode = node; - codeBlockPos = $from.start(depth) - 1; - break; - } - } - - if (codeBlockNode && codeBlockPos !== null) { - const codeBlockStart = codeBlockPos; - const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize; - - const contentStart = codeBlockStart + 1; - const contentEnd = codeBlockEnd - 1; - - this.editor.commands.setTextSelection({ - from: contentStart, - to: contentEnd, - }); - - return true; - } - } - - return false; - }, - }; - }, - - addNodeView() { - return ReactNodeViewRenderer(this.options.view); - }, - } -); diff --git a/packages/editor-ext/src/lib/custom-code-block/custom-code-block.ts b/packages/editor-ext/src/lib/custom-code-block/custom-code-block.ts new file mode 100644 index 00000000..ba9fe9c1 --- /dev/null +++ b/packages/editor-ext/src/lib/custom-code-block/custom-code-block.ts @@ -0,0 +1,108 @@ +import type { CodeBlockOptions } from "@tiptap/extension-code-block"; +import CodeBlock from "@tiptap/extension-code-block"; + +import { LowlightPlugin } from "./lowlight-plugin.js"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +export interface CodeBlockLowlightOptions extends CodeBlockOptions { + /** + * The lowlight instance. + */ + lowlight: any; + view: any; +} + +const TAB_CHAR = "\u00A0\u00A0"; + +/** + * This extension allows you to highlight code blocks with lowlight. + * @see https://tiptap.dev/api/nodes/code-block-lowlight + */ +export const CustomCodeBlock = CodeBlock.extend({ + selectable: true, + + addOptions() { + return { + ...this.parent?.(), + lowlight: {}, + languageClassPrefix: "language-", + exitOnTripleEnter: true, + exitOnArrowDown: true, + defaultLanguage: null, + HTMLAttributes: {}, + view: null, + }; + }, + + addKeyboardShortcuts() { + return { + ...this.parent?.(), + Tab: () => { + if (this.editor.isActive("codeBlock")) { + this.editor + .chain() + .command(({ tr }) => { + tr.insertText(TAB_CHAR); + return true; + }) + .run(); + return true; + } + }, + "Mod-a": () => { + if (this.editor.isActive("codeBlock")) { + const { state } = this.editor; + const { $from } = state.selection; + + let codeBlockNode = null; + let codeBlockPos = null; + let depth = 0; + + for (depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if (node.type.name === "codeBlock") { + codeBlockNode = node; + codeBlockPos = $from.start(depth) - 1; + break; + } + } + + if (codeBlockNode && codeBlockPos !== null) { + const codeBlockStart = codeBlockPos; + const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize; + + const contentStart = codeBlockStart + 1; + const contentEnd = codeBlockEnd - 1; + + this.editor.commands.setTextSelection({ + from: contentStart, + to: contentEnd, + }); + + return true; + } + } + + return false; + }, + }; + }, + + addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + + return ReactNodeViewRenderer(this.options.view); + }, + + addProseMirrorPlugins() { + return [ + ...(this.parent?.() || []), + LowlightPlugin({ + name: this.name, + lowlight: this.options.lowlight, + defaultLanguage: this.options.defaultLanguage, + }), + ]; + }, +}); diff --git a/packages/editor-ext/src/lib/custom-code-block/index.ts b/packages/editor-ext/src/lib/custom-code-block/index.ts new file mode 100644 index 00000000..f6e3470f --- /dev/null +++ b/packages/editor-ext/src/lib/custom-code-block/index.ts @@ -0,0 +1 @@ +export { CustomCodeBlock } from "./custom-code-block"; diff --git a/packages/editor-ext/src/lib/custom-code-block/lowlight-plugin.ts b/packages/editor-ext/src/lib/custom-code-block/lowlight-plugin.ts new file mode 100644 index 00000000..505b8f20 --- /dev/null +++ b/packages/editor-ext/src/lib/custom-code-block/lowlight-plugin.ts @@ -0,0 +1,159 @@ +import { findChildren } from '@tiptap/core' +import type { Node as ProsemirrorNode } from '@tiptap/pm/model' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +// @ts-ignore +import highlight from 'highlight.js/lib/core' + +function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] { + return nodes + .map(node => { + const classes = [...className, ...(node.properties ? node.properties.className : [])] + + if (node.children) { + return parseNodes(node.children, classes) + } + + return { + text: node.value, + classes, + } + }) + .flat() +} + +function getHighlightNodes(result: any) { + // `.value` for lowlight v1, `.children` for lowlight v2 + return result.value || result.children || [] +} + +function registered(aliasOrLanguage: string) { + return Boolean(highlight.getLanguage(aliasOrLanguage)) +} + +function getDecorations({ + doc, + name, + lowlight, + defaultLanguage, +}: { + doc: ProsemirrorNode + name: string + lowlight: any + defaultLanguage: string | null | undefined +}) { + const decorations: Decoration[] = [] + + findChildren(doc, node => node.type.name === name).forEach(block => { + let from = block.pos + 1 + const language = block.node.attrs.language || defaultLanguage + const languages = lowlight.listLanguages() + + const nodes = + language && (languages.includes(language) || registered(language) || lowlight.registered?.(language)) + ? getHighlightNodes(lowlight.highlight(language, block.node.textContent)) + : getHighlightNodes(lowlight.highlightAuto(block.node.textContent)) + + parseNodes(nodes).forEach(node => { + const to = from + node.text.length + + if (node.classes.length) { + const decoration = Decoration.inline(from, to, { + class: node.classes.join(' '), + }) + + decorations.push(decoration) + } + + from = to + }) + }) + + return DecorationSet.create(doc, decorations) +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +function isFunction(param: any): param is Function { + return typeof param === 'function' +} + +export function LowlightPlugin({ + name, + lowlight, + defaultLanguage, +}: { + name: string + lowlight: any + defaultLanguage: string | null | undefined +}) { + if (!['highlight', 'highlightAuto', 'listLanguages'].every(api => isFunction(lowlight[api]))) { + throw Error('You should provide an instance of lowlight to use the code-block-lowlight extension') + } + + const lowlightPlugin: Plugin = new Plugin({ + key: new PluginKey('lowlight'), + + state: { + init: (_, { doc }) => + getDecorations({ + doc, + name, + lowlight, + defaultLanguage, + }), + apply: (transaction, decorationSet, oldState, newState) => { + const oldNodeName = oldState.selection.$head.parent.type.name + const newNodeName = newState.selection.$head.parent.type.name + const oldNodes = findChildren(oldState.doc, node => node.type.name === name) + const newNodes = findChildren(newState.doc, node => node.type.name === name) + + if ( + transaction.docChanged && + // Apply decorations if: + // selection includes named node, + ([oldNodeName, newNodeName].includes(name) || + // OR transaction adds/removes named node, + newNodes.length !== oldNodes.length || + // OR transaction has changes that completely encapsulte a node + // (for example, a transaction that affects the entire document). + // Such transactions can happen during collab syncing via y-prosemirror, for example. + transaction.steps.some(step => { + // @ts-ignore + return ( + // @ts-ignore + step.from !== undefined && + // @ts-ignore + step.to !== undefined && + oldNodes.some(node => { + // @ts-ignore + return ( + // @ts-ignore + node.pos >= step.from && + // @ts-ignore + node.pos + node.node.nodeSize <= step.to + ) + }) + ) + })) + ) { + return getDecorations({ + doc: transaction.doc, + name, + lowlight, + defaultLanguage, + }) + } + + return decorationSet.map(transaction.mapping, transaction.doc) + }, + }, + + props: { + decorations(state) { + return lowlightPlugin.getState(state) + }, + }, + }) + + return lowlightPlugin +} \ No newline at end of file diff --git a/packages/editor-ext/src/lib/details/details.ts b/packages/editor-ext/src/lib/details/details.ts index b28c4de7..41c66dca 100644 --- a/packages/editor-ext/src/lib/details/details.ts +++ b/packages/editor-ext/src/lib/details/details.ts @@ -27,6 +27,7 @@ export const Details = Node.create({ content: "detailsSummary detailsContent", defining: true, isolating: true, + // @ts-ignore allowGapCursor: false, addOptions() { return { diff --git a/packages/editor-ext/src/lib/drawio.ts b/packages/editor-ext/src/lib/drawio.ts index 319853b2..3cc041a2 100644 --- a/packages/editor-ext/src/lib/drawio.ts +++ b/packages/editor-ext/src/lib/drawio.ts @@ -41,45 +41,45 @@ export const Drawio = Node.create({ addAttributes() { return { src: { - default: '', - parseHTML: (element) => element.getAttribute('data-src'), + default: "", + parseHTML: (element) => element.getAttribute("data-src"), renderHTML: (attributes) => ({ - 'data-src': attributes.src, + "data-src": attributes.src, }), }, title: { default: undefined, - parseHTML: (element) => element.getAttribute('data-title'), + parseHTML: (element) => element.getAttribute("data-title"), renderHTML: (attributes: DrawioAttributes) => ({ - 'data-title': attributes.title, + "data-title": attributes.title, }), }, width: { - default: '100%', - parseHTML: (element) => element.getAttribute('data-width'), + default: "100%", + parseHTML: (element) => element.getAttribute("data-width"), renderHTML: (attributes: DrawioAttributes) => ({ - 'data-width': attributes.width, + "data-width": attributes.width, }), }, size: { default: null, - parseHTML: (element) => element.getAttribute('data-size'), + parseHTML: (element) => element.getAttribute("data-size"), renderHTML: (attributes: DrawioAttributes) => ({ - 'data-size': attributes.size, + "data-size": attributes.size, }), }, align: { - default: 'center', - parseHTML: (element) => element.getAttribute('data-align'), + default: "center", + parseHTML: (element) => element.getAttribute("data-align"), renderHTML: (attributes: DrawioAttributes) => ({ - 'data-align': attributes.align, + "data-align": attributes.align, }), }, attachmentId: { default: undefined, - parseHTML: (element) => element.getAttribute('data-attachment-id'), + parseHTML: (element) => element.getAttribute("data-attachment-id"), renderHTML: (attributes: DrawioAttributes) => ({ - 'data-attachment-id': attributes.attachmentId, + "data-attachment-id": attributes.attachmentId, }), }, }; @@ -95,13 +95,20 @@ export const Drawio = Node.create({ renderHTML({ HTMLAttributes }) { return [ - 'div', + "div", mergeAttributes( - { 'data-type': this.name }, + { "data-type": this.name }, this.options.HTMLAttributes, HTMLAttributes ), - ['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }], + [ + "img", + { + src: HTMLAttributes["data-src"], + alt: HTMLAttributes["data-title"], + width: HTMLAttributes["data-width"], + }, + ], ]; }, @@ -119,6 +126,9 @@ export const Drawio = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, }); diff --git a/packages/editor-ext/src/lib/embed.ts b/packages/editor-ext/src/lib/embed.ts index 47fc251e..a93648b1 100644 --- a/packages/editor-ext/src/lib/embed.ts +++ b/packages/editor-ext/src/lib/embed.ts @@ -1,6 +1,6 @@ -import { Node, mergeAttributes } from '@tiptap/core'; -import { ReactNodeViewRenderer } from '@tiptap/react'; -import { sanitizeUrl } from './utils'; +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { sanitizeUrl } from "./utils"; export interface EmbedOptions { HTMLAttributes: Record; @@ -14,7 +14,7 @@ export interface EmbedAttributes { height?: number; } -declare module '@tiptap/core' { +declare module "@tiptap/core" { interface Commands { embeds: { setEmbed: (attributes?: EmbedAttributes) => ReturnType; @@ -23,9 +23,9 @@ declare module '@tiptap/core' { } export const Embed = Node.create({ - name: 'embed', + name: "embed", inline: false, - group: 'block', + group: "block", isolating: true, atom: true, defining: true, @@ -40,41 +40,41 @@ export const Embed = Node.create({ addAttributes() { return { src: { - default: '', + default: "", parseHTML: (element) => { - const src = element.getAttribute('data-src'); + const src = element.getAttribute("data-src"); return sanitizeUrl(src); }, renderHTML: (attributes: EmbedAttributes) => ({ - 'data-src': sanitizeUrl(attributes.src), + "data-src": sanitizeUrl(attributes.src), }), }, provider: { - default: '', - parseHTML: (element) => element.getAttribute('data-provider'), + default: "", + parseHTML: (element) => element.getAttribute("data-provider"), renderHTML: (attributes: EmbedAttributes) => ({ - 'data-provider': attributes.provider, + "data-provider": attributes.provider, }), }, align: { - default: 'center', - parseHTML: (element) => element.getAttribute('data-align'), + default: "center", + parseHTML: (element) => element.getAttribute("data-align"), renderHTML: (attributes: EmbedAttributes) => ({ - 'data-align': attributes.align, + "data-align": attributes.align, }), }, width: { default: 640, - parseHTML: (element) => element.getAttribute('data-width'), + parseHTML: (element) => element.getAttribute("data-width"), renderHTML: (attributes: EmbedAttributes) => ({ - 'data-width': attributes.width, + "data-width": attributes.width, }), }, height: { default: 480, - parseHTML: (element) => element.getAttribute('data-height'), + parseHTML: (element) => element.getAttribute("data-height"), renderHTML: (attributes: EmbedAttributes) => ({ - 'data-height': attributes.height, + "data-height": attributes.height, }), }, }; @@ -91,13 +91,13 @@ export const Embed = Node.create({ renderHTML({ HTMLAttributes }) { const src = HTMLAttributes["data-src"]; const safeHref = sanitizeUrl(src); - + return [ "div", mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes, + HTMLAttributes ), [ "a", @@ -120,9 +120,9 @@ export const Embed = Node.create({ ...attrs, src: sanitizeUrl(attrs.src), }; - + return commands.insertContent({ - type: 'embed', + type: "embed", attrs: validatedAttrs, }); }, @@ -130,6 +130,9 @@ export const Embed = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, }); diff --git a/packages/editor-ext/src/lib/excalidraw.ts b/packages/editor-ext/src/lib/excalidraw.ts index a7e3a468..28b064e4 100644 --- a/packages/editor-ext/src/lib/excalidraw.ts +++ b/packages/editor-ext/src/lib/excalidraw.ts @@ -1,5 +1,5 @@ -import { Node, mergeAttributes } from '@tiptap/core'; -import { ReactNodeViewRenderer } from '@tiptap/react'; +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; export interface ExcalidrawOptions { HTMLAttributes: Record; @@ -14,7 +14,7 @@ export interface ExcalidrawAttributes { attachmentId?: string; } -declare module '@tiptap/core' { +declare module "@tiptap/core" { interface Commands { excalidraw: { setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType; @@ -23,9 +23,9 @@ declare module '@tiptap/core' { } export const Excalidraw = Node.create({ - name: 'excalidraw', + name: "excalidraw", inline: false, - group: 'block', + group: "block", isolating: true, atom: true, defining: true, @@ -40,45 +40,45 @@ export const Excalidraw = Node.create({ addAttributes() { return { src: { - default: '', - parseHTML: (element) => element.getAttribute('data-src'), + default: "", + parseHTML: (element) => element.getAttribute("data-src"), renderHTML: (attributes) => ({ - 'data-src': attributes.src, + "data-src": attributes.src, }), }, title: { default: undefined, - parseHTML: (element) => element.getAttribute('data-title'), + parseHTML: (element) => element.getAttribute("data-title"), renderHTML: (attributes: ExcalidrawAttributes) => ({ - 'data-title': attributes.title, + "data-title": attributes.title, }), }, width: { - default: '100%', - parseHTML: (element) => element.getAttribute('data-width'), + default: "100%", + parseHTML: (element) => element.getAttribute("data-width"), renderHTML: (attributes: ExcalidrawAttributes) => ({ - 'data-width': attributes.width, + "data-width": attributes.width, }), }, size: { default: null, - parseHTML: (element) => element.getAttribute('data-size'), + parseHTML: (element) => element.getAttribute("data-size"), renderHTML: (attributes: ExcalidrawAttributes) => ({ - 'data-size': attributes.size, + "data-size": attributes.size, }), }, align: { - default: 'center', - parseHTML: (element) => element.getAttribute('data-align'), + default: "center", + parseHTML: (element) => element.getAttribute("data-align"), renderHTML: (attributes: ExcalidrawAttributes) => ({ - 'data-align': attributes.align, + "data-align": attributes.align, }), }, attachmentId: { default: undefined, - parseHTML: (element) => element.getAttribute('data-attachment-id'), + parseHTML: (element) => element.getAttribute("data-attachment-id"), renderHTML: (attributes: ExcalidrawAttributes) => ({ - 'data-attachment-id': attributes.attachmentId, + "data-attachment-id": attributes.attachmentId, }), }, }; @@ -94,13 +94,20 @@ export const Excalidraw = Node.create({ renderHTML({ HTMLAttributes }) { return [ - 'div', + "div", mergeAttributes( - { 'data-type': this.name }, + { "data-type": this.name }, this.options.HTMLAttributes, HTMLAttributes ), - ['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }], + [ + "img", + { + src: HTMLAttributes["data-src"], + alt: HTMLAttributes["data-title"], + width: HTMLAttributes["data-width"], + }, + ], ]; }, @@ -110,7 +117,7 @@ export const Excalidraw = Node.create({ (attrs: ExcalidrawAttributes) => ({ commands }) => { return commands.insertContent({ - type: 'excalidraw', + type: "excalidraw", attrs: attrs, }); }, @@ -118,6 +125,9 @@ export const Excalidraw = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, }); diff --git a/packages/editor-ext/src/lib/image/image-upload.ts b/packages/editor-ext/src/lib/image/image-upload.ts index 9a759903..d5acdcff 100644 --- a/packages/editor-ext/src/lib/image/image-upload.ts +++ b/packages/editor-ext/src/lib/image/image-upload.ts @@ -1,127 +1,145 @@ -import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { insertTrailingNode, MediaUploadOptions, UploadFn } from "../media-utils"; +import { imageDimensionsFromStream } from "image-dimensions"; +import { MediaUploadOptions, UploadFn } from "../media-utils"; import { IAttachment } from "../types"; +import { generateNodeId } from "../utils"; +import { Node } from "@tiptap/pm/model"; +import { Command } from "@tiptap/core"; -const uploadKey = new PluginKey("image-upload"); +const findImageNodeByPlaceholderId = ( + doc: Node, + placeholderId: string, +): { node: Node; pos: number } | null => { + let result: { node: Node; pos: number } | null = null; -export const ImageUploadPlugin = ({ - placeholderClass, -}: { - placeholderClass: string; -}) => - new Plugin({ - key: uploadKey, - state: { - init() { - return DecorationSet.empty; - }, - apply(tr, set) { - set = set.map(tr.mapping, tr.doc); - // See if the transaction adds or removes any placeholders - //@-ts-expect-error - not yet sure what the type I need here - const action = tr.getMeta(this); - if (action?.add) { - const { id, pos, src } = action.add; - - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", "img-placeholder"); - const image = document.createElement("img"); - image.setAttribute("class", placeholderClass); - image.src = src; - placeholder.appendChild(image); - const deco = Decoration.widget(pos + 1, placeholder, { - id, - }); - set = set.add(tr.doc, [deco]); - } else if (action?.remove) { - set = set.remove( - set.find( - undefined, - undefined, - (spec) => spec.id == action.remove.id, - ), - ); - } - return set; - }, - }, - props: { - decorations(state) { - return this.getState(state); - }, - }, + doc.descendants((node, pos) => { + if (result) return false; + if ( + node.type.name === "image" && + node.attrs.placeholder?.id === placeholderId + ) { + result = { node, pos }; + return false; + } + return true; }); -function findPlaceholder(state: EditorState, id: {}) { - const decos = uploadKey.getState(state) as DecorationSet; - const found = decos.find(undefined, undefined, (spec) => spec.id == id); - return found.length ? found[0]?.from : null; -} - -export const handleImageUpload = + return result; +}; +const handleImageUpload = ({ validateFn, onUpload }: MediaUploadOptions): UploadFn => - async (file, view, pos, pageId) => { + async (file, editor, pos, pageId) => { // check if the file is an image const validated = validateFn?.(file); // @ts-ignore if (!validated) return; - // A fresh object to act as the ID for this upload - const id = {}; - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => { - const tr = view.state.tr; - // Replace the selection with a placeholder - if (!tr.selection.empty) tr.deleteSelection(); + const objectUrl = URL.createObjectURL(file); + const imageDimensions = await imageDimensionsFromStream(file.stream()); + const placeholderId = generateNodeId(); + const aspectRatio = imageDimensions + ? imageDimensions.width / imageDimensions.height + : undefined; - tr.setMeta(uploadKey, { - add: { - id, - pos, - src: reader.result, - }, - }); + let placeholderInserted = false; - insertTrailingNode(tr, pos, view); - view.dispatch(tr); + editor.storage.shared.imagePreviews = + editor.storage.shared.imagePreviews || {}; + editor.storage.shared.imagePreviews[placeholderId] = objectUrl; + + const insertPlaceholder = (): Command => { + return ({ tr, state }) => { + const initialPlaceholderNode = state.schema.nodes.image?.create({ + placeholder: { + id: placeholderId, + name: file.name, + }, + aspectRatio, + }); + + if (!initialPlaceholderNode) return false; + + const { parent } = tr.doc.resolve(pos); + const isEmptyTextBlock = parent.isTextblock && !parent.childCount; + + if (isEmptyTextBlock) { + // Replace e.g. empty paragraph with the image + tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode); + } else { + tr.insert(pos, initialPlaceholderNode); + } + + return true; + }; }; + const replacePlaceholderWithImage = (attachment: IAttachment): Command => { + return ({ tr }) => { + const { pos: currentPos = null } = + findImageNodeByPlaceholderId(tr.doc, placeholderId) || {}; - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + // If the placeholder is not found or attachment is missing, abort the process + if (currentPos === null || !attachment) return false; - const pos = findPlaceholder(view.state, id); - - // If the content around the placeholder has been deleted, drop - // the image - if (pos == null) return; - - // Otherwise, insert it at the placeholder's position, and remove - // the placeholder - - if (!attachment) return; - - const node = schema.nodes.image?.create({ + // Update the placeholder node with the actual image data + tr.setNodeMarkup(currentPos, undefined, { src: `/api/files/${attachment.id}/${attachment.fileName}`, attachmentId: attachment.id, - title: attachment.fileName, size: attachment.fileSize, + aspectRatio, }); - if (!node) return; - const transaction = view.state.tr - .replaceWith(pos, pos, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - () => { - // Deletes the image placeholder on error - const transaction = view.state.tr - .delete(pos, pos) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - ); + return true; + }; + }; + const removePlaceholder = (): Command => { + return ({ tr }) => { + const { pos: currentPos = null } = + findImageNodeByPlaceholderId(tr.doc, placeholderId) || {}; + + if (currentPos === null) return false; + + // Remove the placeholder node + tr.delete(currentPos, currentPos + 2); + + return true; + }; + }; + // Only show the placeholder if the upload takes more than 250ms + const insertPlaceholderTimeout = setTimeout(() => { + editor.commands.command(insertPlaceholder()); + placeholderInserted = true; + }, 250); + const disposePreviewFile = () => { + URL.revokeObjectURL(objectUrl); + + if (editor.storage.shared.imagePreviews) { + delete editor.storage.shared.imagePreviews[placeholderId]; + } + }; + + try { + const attachment: IAttachment = await onUpload(file, pageId); + + clearTimeout(insertPlaceholderTimeout); + + if (placeholderInserted) { + setTimeout(() => { + editor.commands.command(replacePlaceholderWithImage(attachment)); + disposePreviewFile(); + }, 100); + } else { + editor + .chain() + .command(insertPlaceholder()) + .command(replacePlaceholderWithImage(attachment)) + .run(); + disposePreviewFile(); + } + } catch (error) { + clearTimeout(insertPlaceholderTimeout); + + editor.commands.command(removePlaceholder()); + disposePreviewFile(); + } }; + +export { handleImageUpload }; diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index 3f7683e4..e6426f23 100644 --- a/packages/editor-ext/src/lib/image/image.ts +++ b/packages/editor-ext/src/lib/image/image.ts @@ -1,7 +1,6 @@ import Image from "@tiptap/extension-image"; import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; -import { ImageUploadPlugin } from "./image-upload"; import { mergeAttributes, Range } from "@tiptap/core"; export interface ImageOptions extends DefaultImageOptions { @@ -10,11 +9,15 @@ export interface ImageOptions extends DefaultImageOptions { export interface ImageAttributes { src?: string; alt?: string; - title?: string; align?: string; attachmentId?: string; size?: number; width?: number; + aspectRatio?: number; + placeholder?: { + id: string; + name: string; + }; } declare module "@tiptap/core" { @@ -90,6 +93,17 @@ export const TiptapImage = Image.extend({ "data-size": attributes.size, }), }, + aspectRatio: { + default: null, + parseHTML: (element) => element.getAttribute("data-aspect-ratio"), + renderHTML: (attributes: ImageAttributes) => ({ + "data-aspect-ratio": attributes.aspectRatio, + }), + }, + placeholder: { + default: null, + rendered: false, + }, }; }, @@ -135,14 +149,9 @@ export const TiptapImage = Image.extend({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - ImageUploadPlugin({ - placeholderClass: "image-upload", - }), - ]; - }, }); diff --git a/packages/editor-ext/src/lib/math/math-block.ts b/packages/editor-ext/src/lib/math/math-block.ts index a580596b..cf11e8f8 100644 --- a/packages/editor-ext/src/lib/math/math-block.ts +++ b/packages/editor-ext/src/lib/math/math-block.ts @@ -63,6 +63,9 @@ export const MathBlock = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, diff --git a/packages/editor-ext/src/lib/math/math-inline.ts b/packages/editor-ext/src/lib/math/math-inline.ts index 39c1cd49..3de9d291 100644 --- a/packages/editor-ext/src/lib/math/math-inline.ts +++ b/packages/editor-ext/src/lib/math/math-inline.ts @@ -64,6 +64,9 @@ export const MathInline = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, diff --git a/packages/editor-ext/src/lib/media-utils.ts b/packages/editor-ext/src/lib/media-utils.ts index f05c4264..02a4a1d1 100644 --- a/packages/editor-ext/src/lib/media-utils.ts +++ b/packages/editor-ext/src/lib/media-utils.ts @@ -1,9 +1,8 @@ -import type { EditorView } from "@tiptap/pm/view"; -import { Transaction } from "@tiptap/pm/state"; +import { Editor } from "@tiptap/core"; export type UploadFn = ( file: File, - view: EditorView, + editor: Editor, pos: number, pageId: string, // only applicable to file attachments @@ -14,16 +13,3 @@ export interface MediaUploadOptions { validateFn?: (file: File, allowMedia?: boolean) => void; onUpload: (file: File, pageId: string) => Promise; } - -export function insertTrailingNode( - tr: Transaction, - pos: number, - view: EditorView, -) { - // create trailing node after decoration - // if decoration is at the last node - const currentDocSize = view.state.doc.content.size; - if (pos + 1 === currentDocSize) { - tr.insert(currentDocSize, view.state.schema.nodes.paragraph.create()); - } -} diff --git a/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts b/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts index ca66958f..1ed7632d 100644 --- a/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts +++ b/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts @@ -31,6 +31,9 @@ import { import { Node as PMNode, Mark } from "@tiptap/pm/model"; declare module "@tiptap/core" { + interface Storage { + searchAndReplace: SearchAndReplaceStorage; + } interface Commands { search: { /** @@ -184,21 +187,21 @@ const replace = ( if (dispatch) { const tr = state.tr; - + // Get all marks that span the text being replaced const marksSet = new Set(); state.doc.nodesBetween(from, to, (node) => { if (node.isText && node.marks) { - node.marks.forEach(mark => marksSet.add(mark)); + node.marks.forEach((mark) => marksSet.add(mark)); } }); - + const marks = Array.from(marksSet); - + // Delete the old text and insert new text with preserved marks tr.delete(from, to); tr.insert(from, state.schema.text(replaceTerm, marks)); - + dispatch(tr); } }; @@ -215,17 +218,17 @@ const replaceAll = ( // Process replacements in reverse order to avoid position shifting issues for (let i = resultsCopy.length - 1; i >= 0; i -= 1) { const { from, to } = resultsCopy[i]; - + // Get all marks that span the text being replaced const marksSet = new Set(); tr.doc.nodesBetween(from, to, (node) => { if (node.isText && node.marks) { - node.marks.forEach(mark => marksSet.add(mark)); + node.marks.forEach((mark) => marksSet.add(mark)); } }); - + const marks = Array.from(marksSet); - + // Delete and insert with preserved marks tr.delete(from, to); tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks)); @@ -352,10 +355,17 @@ export const SearchAndReplace = Extension.create< // The results will be recalculated by the plugin, but we need to ensure // the index doesn't exceed the new bounds setTimeout(() => { - const newResultsLength = editor.storage.searchAndReplace.results.length; - if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) { + const newResultsLength = + editor.storage.searchAndReplace.results.length; + if ( + newResultsLength > 0 && + editor.storage.searchAndReplace.resultIndex >= newResultsLength + ) { // Keep the same position if possible, otherwise go to the last result - editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1); + editor.storage.searchAndReplace.resultIndex = Math.min( + resultIndex, + newResultsLength - 1, + ); } }, 0); diff --git a/packages/editor-ext/src/lib/shared-storage/index.ts b/packages/editor-ext/src/lib/shared-storage/index.ts new file mode 100644 index 00000000..5b486420 --- /dev/null +++ b/packages/editor-ext/src/lib/shared-storage/index.ts @@ -0,0 +1 @@ +export { SharedStorage } from "./shared-storage"; diff --git a/packages/editor-ext/src/lib/shared-storage/shared-storage.ts b/packages/editor-ext/src/lib/shared-storage/shared-storage.ts new file mode 100644 index 00000000..aa008d45 --- /dev/null +++ b/packages/editor-ext/src/lib/shared-storage/shared-storage.ts @@ -0,0 +1,17 @@ +import { Extension } from "@tiptap/core"; + +declare module "@tiptap/core" { + interface Storage { + shared: Record; + } +} + +const SharedStorage = Extension.create({ + name: "shared", + + addStorage() { + return {}; + }, +}); + +export { SharedStorage }; diff --git a/packages/editor-ext/src/lib/subpages/subpages.ts b/packages/editor-ext/src/lib/subpages/subpages.ts index 59eb9896..617f43ce 100644 --- a/packages/editor-ext/src/lib/subpages/subpages.ts +++ b/packages/editor-ext/src/lib/subpages/subpages.ts @@ -44,7 +44,7 @@ export const Subpages = Node.create({ mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes, + HTMLAttributes ), ]; }, @@ -63,6 +63,9 @@ export const Subpages = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, }); diff --git a/packages/editor-ext/src/lib/table/cell.ts b/packages/editor-ext/src/lib/table/cell.ts index 63df7dcf..2f693573 100644 --- a/packages/editor-ext/src/lib/table/cell.ts +++ b/packages/editor-ext/src/lib/table/cell.ts @@ -1,4 +1,4 @@ -import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell"; +import { TableCell as TiptapTableCell } from "@tiptap/extension-table"; export const TableCell = TiptapTableCell.extend({ name: "tableCell", diff --git a/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts b/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts index b4ac2950..1ad57ec1 100644 --- a/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts +++ b/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts @@ -1,7 +1,12 @@ import { Editor, Extension } from "@tiptap/core"; import { PluginKey, Plugin, PluginSpec } from "@tiptap/pm/state"; import { EditorProps, EditorView } from "@tiptap/pm/view"; -import { DraggingDOMs, getDndRelatedDOMs, getHoveringCell, HoveringCellInfo } from "./utils"; +import { + DraggingDOMs, + getDndRelatedDOMs, + getHoveringCell, + HoveringCellInfo, +} from "./utils"; import { getDragOverColumn, getDragOverRow } from "./calc-drag-over"; import { moveColumn, moveRow } from "../utils"; import { PreviewController } from "./preview/preview-controller"; @@ -10,268 +15,302 @@ import { DragHandleController } from "./handle/drag-handle-controller"; import { EmptyImageController } from "./handle/empty-image-controller"; import { AutoScrollController } from "./auto-scroll-controller"; -export const TableDndKey = new PluginKey('table-drag-and-drop') +export const TableDndKey = new PluginKey("table-drag-and-drop"); class TableDragHandlePluginSpec implements PluginSpec { - key = TableDndKey - props: EditorProps> + key = TableDndKey; + props: EditorProps>; - private _colDragHandle: HTMLElement; - private _rowDragHandle: HTMLElement; - private _hoveringCell?: HoveringCellInfo; - private _disposables: (() => void)[] = []; - private _draggingCoords: { x: number; y: number } = { x: 0, y: 0 }; - private _dragging = false; - private _draggingDirection: 'col' | 'row' = 'col'; - private _draggingIndex = -1; - private _droppingIndex = -1; - private _draggingDOMs?: DraggingDOMs | undefined - private _startCoords: { x: number; y: number } = { x: 0, y: 0 }; - private _previewController: PreviewController; - private _dropIndicatorController: DropIndicatorController; - private _dragHandleController: DragHandleController; - private _emptyImageController: EmptyImageController; - private _autoScrollController: AutoScrollController; + private _colDragHandle: HTMLElement; + private _rowDragHandle: HTMLElement; + private _hoveringCell?: HoveringCellInfo; + private _disposables: (() => void)[] = []; + private _draggingCoords: { x: number; y: number } = { x: 0, y: 0 }; + private _dragging = false; + private _draggingDirection: "col" | "row" = "col"; + private _draggingIndex = -1; + private _droppingIndex = -1; + private _draggingDOMs?: DraggingDOMs | undefined; + private _startCoords: { x: number; y: number } = { x: 0, y: 0 }; + private _previewController: PreviewController; + private _dropIndicatorController: DropIndicatorController; + private _dragHandleController: DragHandleController; + private _emptyImageController: EmptyImageController; + private _autoScrollController: AutoScrollController; - constructor(public editor: Editor) { - this.props = { - handleDOMEvents: { - pointerover: this._pointerOver, - } - } + constructor(public editor: Editor) { + this.props = { + handleDOMEvents: { + pointerover: this._pointerOver, + }, + }; - this._dragHandleController = new DragHandleController(); - this._colDragHandle = this._dragHandleController.colDragHandle; - this._rowDragHandle = this._dragHandleController.rowDragHandle; + this._dragHandleController = new DragHandleController(); + this._colDragHandle = this._dragHandleController.colDragHandle; + this._rowDragHandle = this._dragHandleController.rowDragHandle; - this._previewController = new PreviewController(); - this._dropIndicatorController = new DropIndicatorController(); - this._emptyImageController = new EmptyImageController(); + this._previewController = new PreviewController(); + this._dropIndicatorController = new DropIndicatorController(); + this._emptyImageController = new EmptyImageController(); - this._autoScrollController = new AutoScrollController(); + this._autoScrollController = new AutoScrollController(); - this._bindDragEvents(); + this._bindDragEvents(); + } + + view = () => { + const wrapper = this.editor.options.element; + //@ts-ignore + wrapper.appendChild(this._colDragHandle); + //@ts-ignore + wrapper.appendChild(this._rowDragHandle); + //@ts-ignore + wrapper.appendChild(this._previewController.previewRoot); + //@ts-ignore + wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot); + + return { + update: this.update, + destroy: this.destroy, + }; + }; + + update = () => {}; + + destroy = () => { + if (!this.editor.isDestroyed) return; + this._dragHandleController.destroy(); + this._emptyImageController.destroy(); + this._previewController.destroy(); + this._dropIndicatorController.destroy(); + this._autoScrollController.stop(); + + this._disposables.forEach((disposable) => disposable()); + }; + + private _pointerOver = (view: EditorView, event: PointerEvent) => { + if (this._dragging) return; + + // Don't show drag handles in readonly mode + if (!this.editor.isEditable) { + this._dragHandleController.hide(); + return; } - view = () => { - const wrapper = this.editor.options.element; - wrapper.appendChild(this._colDragHandle) - wrapper.appendChild(this._rowDragHandle) - wrapper.appendChild(this._previewController.previewRoot) - wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot) + const hoveringCell = getHoveringCell(view, event); + this._hoveringCell = hoveringCell; + if (!hoveringCell) { + this._dragHandleController.hide(); + } else { + this._dragHandleController.show(this.editor, hoveringCell); + } + }; - return { - update: this.update, - destroy: this.destroy, - } + private _onDragColStart = (event: DragEvent) => { + this._onDragStart(event, "col"); + }; + + private _onDraggingCol = (event: DragEvent) => { + const draggingDOMs = this._draggingDOMs; + if (!draggingDOMs) return; + + this._draggingCoords = { x: event.clientX, y: event.clientY }; + this._previewController.onDragging( + draggingDOMs, + this._draggingCoords.x, + this._draggingCoords.y, + "col", + ); + + this._autoScrollController.checkXAutoScroll(event.clientX, draggingDOMs); + + const direction = + this._startCoords.x > this._draggingCoords.x ? "left" : "right"; + const dragOverColumn = getDragOverColumn( + draggingDOMs.table, + this._draggingCoords.x, + ); + if (!dragOverColumn) return; + + const [col, index] = dragOverColumn; + this._droppingIndex = index; + this._dropIndicatorController.onDragging(col, direction, "col"); + }; + + private _onDragRowStart = (event: DragEvent) => { + this._onDragStart(event, "row"); + }; + + private _onDraggingRow = (event: DragEvent) => { + const draggingDOMs = this._draggingDOMs; + if (!draggingDOMs) return; + + this._draggingCoords = { x: event.clientX, y: event.clientY }; + this._previewController.onDragging( + draggingDOMs, + this._draggingCoords.x, + this._draggingCoords.y, + "row", + ); + + this._autoScrollController.checkYAutoScroll(event.clientY); + + const direction = + this._startCoords.y > this._draggingCoords.y ? "up" : "down"; + const dragOverRow = getDragOverRow( + draggingDOMs.table, + this._draggingCoords.y, + ); + if (!dragOverRow) return; + + const [row, index] = dragOverRow; + this._droppingIndex = index; + this._dropIndicatorController.onDragging(row, direction, "row"); + }; + + private _onDragEnd = () => { + this._dragging = false; + this._draggingIndex = -1; + this._droppingIndex = -1; + this._startCoords = { x: 0, y: 0 }; + this._autoScrollController.stop(); + this._dropIndicatorController.onDragEnd(); + this._previewController.onDragEnd(); + }; + + private _bindDragEvents = () => { + this._colDragHandle.addEventListener("dragstart", this._onDragColStart); + this._disposables.push(() => { + this._colDragHandle.removeEventListener( + "dragstart", + this._onDragColStart, + ); + }); + + this._colDragHandle.addEventListener("dragend", this._onDragEnd); + this._disposables.push(() => { + this._colDragHandle.removeEventListener("dragend", this._onDragEnd); + }); + + this._rowDragHandle.addEventListener("dragstart", this._onDragRowStart); + this._disposables.push(() => { + this._rowDragHandle.removeEventListener( + "dragstart", + this._onDragRowStart, + ); + }); + + this._rowDragHandle.addEventListener("dragend", this._onDragEnd); + this._disposables.push(() => { + this._rowDragHandle.removeEventListener("dragend", this._onDragEnd); + }); + + const ownerDocument = this.editor.view.dom?.ownerDocument; + if (ownerDocument) { + // To make `drop` event work, we need to prevent the default behavior of the + // `dragover` event for drop zone. Here we set the whole document as the + // drop zone so that even the mouse moves outside the editor, the `drop` + // event will still be triggered. + ownerDocument.addEventListener("drop", this._onDrop); + ownerDocument.addEventListener("dragover", this._onDrag); + this._disposables.push(() => { + ownerDocument.removeEventListener("drop", this._onDrop); + ownerDocument.removeEventListener("dragover", this._onDrag); + }); + } + }; + + private _onDragStart = (event: DragEvent, type: "col" | "row") => { + const dataTransfer = event.dataTransfer; + if (dataTransfer) { + dataTransfer.effectAllowed = "move"; + this._emptyImageController.hideDragImage(dataTransfer); + } + this._dragging = true; + this._draggingDirection = type; + this._startCoords = { x: event.clientX, y: event.clientY }; + const draggingIndex = + (type === "col" + ? this._hoveringCell?.colIndex + : this._hoveringCell?.rowIndex) ?? 0; + + this._draggingIndex = draggingIndex; + + const relatedDoms = getDndRelatedDOMs( + this.editor.view, + this._hoveringCell?.cellPos, + draggingIndex, + type, + ); + this._draggingDOMs = relatedDoms; + + const index = + type === "col" + ? this._hoveringCell?.colIndex + : this._hoveringCell?.rowIndex; + + this._previewController.onDragStart(relatedDoms, index, type); + this._dropIndicatorController.onDragStart(relatedDoms, type); + }; + + private _onDrag = (event: DragEvent) => { + event.preventDefault(); + if (!this._dragging) return; + if (this._draggingDirection === "col") { + this._onDraggingCol(event); + } else { + this._onDraggingRow(event); + } + }; + + private _onDrop = () => { + if (!this._dragging) return; + const direction = this._draggingDirection; + const from = this._draggingIndex; + const to = this._droppingIndex; + const tr = this.editor.state.tr; + const pos = this.editor.state.selection.from; + + if (direction === "col") { + const canMove = moveColumn({ + tr, + originIndex: from, + targetIndex: to, + select: true, + pos, + }); + if (canMove) { + this.editor.view.dispatch(tr); + } + + return; } - update = () => {} + if (direction === "row") { + const canMove = moveRow({ + tr, + originIndex: from, + targetIndex: to, + select: true, + pos, + }); + if (canMove) { + this.editor.view.dispatch(tr); + } - destroy = () => { - if (!this.editor.isDestroyed) return; - this._dragHandleController.destroy(); - this._emptyImageController.destroy(); - this._previewController.destroy(); - this._dropIndicatorController.destroy(); - this._autoScrollController.stop(); - - this._disposables.forEach(disposable => disposable()); - } - - private _pointerOver = (view: EditorView, event: PointerEvent) => { - if (this._dragging) return; - - // Don't show drag handles in readonly mode - if (!this.editor.isEditable) { - this._dragHandleController.hide(); - return; - } - - const hoveringCell = getHoveringCell(view, event) - this._hoveringCell = hoveringCell; - if (!hoveringCell) { - this._dragHandleController.hide(); - } else { - this._dragHandleController.show(this.editor, hoveringCell); - } - } - - private _onDragColStart = (event: DragEvent) => { - this._onDragStart(event, 'col'); - } - - private _onDraggingCol = (event: DragEvent) => { - const draggingDOMs = this._draggingDOMs; - if (!draggingDOMs) return; - - this._draggingCoords = { x: event.clientX, y: event.clientY }; - this._previewController.onDragging(draggingDOMs, this._draggingCoords.x, this._draggingCoords.y, 'col'); - - this._autoScrollController.checkXAutoScroll(event.clientX, draggingDOMs); - - const direction = this._startCoords.x > this._draggingCoords.x ? 'left' : 'right'; - const dragOverColumn = getDragOverColumn(draggingDOMs.table, this._draggingCoords.x); - if (!dragOverColumn) return; - - const [col, index] = dragOverColumn; - this._droppingIndex = index; - this._dropIndicatorController.onDragging(col, direction, 'col'); - } - - private _onDragRowStart = (event: DragEvent) => { - this._onDragStart(event, 'row'); - } - - private _onDraggingRow = (event: DragEvent) => { - const draggingDOMs = this._draggingDOMs; - if (!draggingDOMs) return; - - this._draggingCoords = { x: event.clientX, y: event.clientY }; - this._previewController.onDragging(draggingDOMs, this._draggingCoords.x, this._draggingCoords.y, 'row'); - - this._autoScrollController.checkYAutoScroll(event.clientY); - - const direction = this._startCoords.y > this._draggingCoords.y ? 'up' : 'down'; - const dragOverRow = getDragOverRow(draggingDOMs.table, this._draggingCoords.y); - if (!dragOverRow) return; - - const [row, index] = dragOverRow; - this._droppingIndex = index; - this._dropIndicatorController.onDragging(row, direction, 'row'); - } - - private _onDragEnd = () => { - this._dragging = false; - this._draggingIndex = -1; - this._droppingIndex = -1; - this._startCoords = { x: 0, y: 0 }; - this._autoScrollController.stop(); - this._dropIndicatorController.onDragEnd(); - this._previewController.onDragEnd(); - } - - private _bindDragEvents = () => { - this._colDragHandle.addEventListener('dragstart', this._onDragColStart); - this._disposables.push(() => { - this._colDragHandle.removeEventListener('dragstart', this._onDragColStart); - }) - - this._colDragHandle.addEventListener('dragend', this._onDragEnd); - this._disposables.push(() => { - this._colDragHandle.removeEventListener('dragend', this._onDragEnd); - }) - - this._rowDragHandle.addEventListener('dragstart', this._onDragRowStart); - this._disposables.push(() => { - this._rowDragHandle.removeEventListener('dragstart', this._onDragRowStart); - }) - - this._rowDragHandle.addEventListener('dragend', this._onDragEnd); - this._disposables.push(() => { - this._rowDragHandle.removeEventListener('dragend', this._onDragEnd); - }) - - const ownerDocument = this.editor.view.dom?.ownerDocument - if (ownerDocument) { - // To make `drop` event work, we need to prevent the default behavior of the - // `dragover` event for drop zone. Here we set the whole document as the - // drop zone so that even the mouse moves outside the editor, the `drop` - // event will still be triggered. - ownerDocument.addEventListener('drop', this._onDrop); - ownerDocument.addEventListener('dragover', this._onDrag); - this._disposables.push(() => { - ownerDocument.removeEventListener('drop', this._onDrop); - ownerDocument.removeEventListener('dragover', this._onDrag); - }); - } - } - - private _onDragStart = (event: DragEvent, type: 'col' | 'row') => { - const dataTransfer = event.dataTransfer; - if (dataTransfer) { - dataTransfer.effectAllowed = 'move'; - this._emptyImageController.hideDragImage(dataTransfer); - } - this._dragging = true; - this._draggingDirection = type; - this._startCoords = { x: event.clientX, y: event.clientY }; - const draggingIndex = (type === 'col' ? this._hoveringCell?.colIndex : this._hoveringCell?.rowIndex) ?? 0; - - this._draggingIndex = draggingIndex; - - const relatedDoms = getDndRelatedDOMs( - this.editor.view, - this._hoveringCell?.cellPos, - draggingIndex, - type - ) - this._draggingDOMs = relatedDoms; - - const index = type === 'col' ? this._hoveringCell?.colIndex : this._hoveringCell?.rowIndex; - - this._previewController.onDragStart(relatedDoms, index, type); - this._dropIndicatorController.onDragStart(relatedDoms, type); - } - - private _onDrag = (event: DragEvent) => { - event.preventDefault() - if (!this._dragging) return; - if (this._draggingDirection === 'col') { - this._onDraggingCol(event); - } else { - this._onDraggingRow(event); - } - } - - private _onDrop = () => { - if (!this._dragging) return; - const direction = this._draggingDirection; - const from = this._draggingIndex; - const to = this._droppingIndex; - const tr = this.editor.state.tr; - const pos = this.editor.state.selection.from; - - if (direction === 'col') { - const canMove = moveColumn({ - tr, - originIndex: from, - targetIndex: to, - select: true, - pos, - }) - if (canMove) { - this.editor.view.dispatch(tr); - } - - return; - } - - if (direction === 'row') { - const canMove = moveRow({ - tr, - originIndex: from, - targetIndex: to, - select: true, - pos, - }) - if (canMove) { - this.editor.view.dispatch(tr); - } - - return; - } + return; } + }; } export const TableDndExtension = Extension.create({ - name: 'table-drag-and-drop', - addProseMirrorPlugins() { - const editor = this.editor + name: "table-drag-and-drop", + addProseMirrorPlugins() { + const editor = this.editor; - const dragHandlePluginSpec = new TableDragHandlePluginSpec(editor) - const dragHandlePlugin = new Plugin(dragHandlePluginSpec) + const dragHandlePluginSpec = new TableDragHandlePluginSpec(editor); + const dragHandlePlugin = new Plugin(dragHandlePluginSpec); - return [dragHandlePlugin] - } -}) + return [dragHandlePlugin]; + }, +}); diff --git a/packages/editor-ext/src/lib/table/header.ts b/packages/editor-ext/src/lib/table/header.ts index 501f089d..77ab02f1 100644 --- a/packages/editor-ext/src/lib/table/header.ts +++ b/packages/editor-ext/src/lib/table/header.ts @@ -1,4 +1,4 @@ -import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table-header"; +import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table"; export const TableHeader = TiptapTableHeader.extend({ name: "tableHeader", diff --git a/packages/editor-ext/src/lib/table/row.ts b/packages/editor-ext/src/lib/table/row.ts index 3aa67dcd..7839afdf 100644 --- a/packages/editor-ext/src/lib/table/row.ts +++ b/packages/editor-ext/src/lib/table/row.ts @@ -1,6 +1,5 @@ -import TiptapTableRow from "@tiptap/extension-table-row"; +import { TableRow as TiptapTableRow } from "@tiptap/extension-table"; export const TableRow = TiptapTableRow.extend({ - allowGapCursor: false, content: "(tableCell | tableHeader)*", }); diff --git a/packages/editor-ext/src/lib/table/table.ts b/packages/editor-ext/src/lib/table/table.ts index 87053832..f1436c28 100644 --- a/packages/editor-ext/src/lib/table/table.ts +++ b/packages/editor-ext/src/lib/table/table.ts @@ -1,4 +1,4 @@ -import Table from "@tiptap/extension-table"; +import { Table } from "@tiptap/extension-table"; import { Editor } from "@tiptap/core"; import { DOMOutputSpec } from "@tiptap/pm/model"; diff --git a/packages/editor-ext/src/lib/unique-id/helpers/findDuplicates.ts b/packages/editor-ext/src/lib/unique-id/helpers/findDuplicates.ts deleted file mode 100644 index d193e8b3..00000000 --- a/packages/editor-ext/src/lib/unique-id/helpers/findDuplicates.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { removeDuplicates } from './removeDuplicates.js' - -/** - * Returns a list of duplicated items within an array. - */ -export function findDuplicates(items: any[]): any[] { - const filtered = items.filter((el, index) => items.indexOf(el) !== index) - const duplicates = removeDuplicates(filtered) - - return duplicates -} diff --git a/packages/editor-ext/src/lib/unique-id/helpers/removeDuplicates.ts b/packages/editor-ext/src/lib/unique-id/helpers/removeDuplicates.ts deleted file mode 100644 index 2bae38fd..00000000 --- a/packages/editor-ext/src/lib/unique-id/helpers/removeDuplicates.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Removes duplicated values within an array. - * Supports numbers, strings and objects. - */ -export function removeDuplicates(array: T[], by = JSON.stringify): T[] { - const seen: Record = {} - - return array.filter(item => { - const key = by(item) - - return Object.prototype.hasOwnProperty.call(seen, key) - ? false - : (seen[key] = true) - }) -} diff --git a/packages/editor-ext/src/lib/unique-id/unique-id.ts b/packages/editor-ext/src/lib/unique-id/unique-id.ts index 6ecf15f0..8436cbd2 100644 --- a/packages/editor-ext/src/lib/unique-id/unique-id.ts +++ b/packages/editor-ext/src/lib/unique-id/unique-id.ts @@ -1,386 +1,11 @@ -import { - combineTransactionSteps, - Extension, - findChildren, - findChildrenInRange, - getChangedRanges, -} from "@tiptap/core"; -import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { Fragment, Slice } from "@tiptap/pm/model"; -import type { Transaction } from "@tiptap/pm/state"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; - -import { findDuplicates } from "./helpers/findDuplicates.js"; import { generateNodeId } from "../utils"; +import { UniqueID as TiptapUniqueID } from "@tiptap/extension-unique-id"; -export type UniqueIDGenerationContext = { - node: ProseMirrorNode; - pos: number; -}; - -export interface UniqueIDOptions { - /** - * The name of the attribute to add the unique ID to. - * @default "id" - */ - attributeName: string; - /** - * The types of nodes to add unique IDs to. - * @default [] - */ - types: string[]; - /** - * The function that generates the unique ID. By default, a UUID v4 is - * generated. However, you can provide your own function to generate the - * unique ID based on the node type and the position. - */ - generateID: (ctx: UniqueIDGenerationContext) => any; - /** - * Ignore some mutations, for example applied from other users through the collaboration plugin. - * - * @default null - */ - filterTransaction: ((transaction: Transaction) => boolean) | null; - /** - * Whether to update the document by adding unique IDs to the nodes. Set this - * property to `false` if the document is in `readonly` mode, is immutable, or - * you don't want it to be modified. - * - * @default true - */ - updateDocument: boolean; -} - -export const UniqueID = Extension.create({ - name: "uniqueID", - - // we’ll set a very high priority to make sure this runs first - // and is compatible with `appendTransaction` hooks of other extensions - priority: 10000, - +export const UniqueID = TiptapUniqueID.extend({ addOptions() { return { - attributeName: "id", - types: [], + ...this.parent?.(), generateID: () => generateNodeId(), - filterTransaction: null, - updateDocument: true, }; }, - - addGlobalAttributes() { - return [ - { - types: this.options.types, - attributes: { - [this.options.attributeName]: { - default: null, - parseHTML: (element) => - element.getAttribute(`data-${this.options.attributeName}`), - renderHTML: (attributes) => { - if (!attributes[this.options.attributeName]) { - return {}; - } - - return { - [`data-${this.options.attributeName}`]: - attributes[this.options.attributeName], - }; - }, - }, - }, - }, - ]; - }, - - // check initial content for missing ids - onCreate() { - if (!this.options.updateDocument) { - return; - } - - const collaboration = this.editor.extensionManager.extensions.find( - (ext) => ext.name === "collaboration", - ); - const collaborationCursor = this.editor.extensionManager.extensions.find( - (ext) => ext.name === "collaborationCursor", - ); - - const collabExtensions = [collaboration, collaborationCursor].filter( - Boolean, - ); - const collab = collabExtensions.find((ext) => ext?.options?.provider); - const provider = collab?.options?.provider; - - const createIds = () => { - const { view, state } = this.editor; - const { tr, doc } = state; - const { types, attributeName, generateID } = this.options; - const nodesWithoutId = findChildren(doc, (node) => { - return ( - types.includes(node.type.name) && node.attrs[attributeName] === null - ); - }); - - nodesWithoutId.forEach(({ node, pos }) => { - tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - [attributeName]: generateID({ node, pos }), - }); - }); - - tr.setMeta("addToHistory", false); - - view.dispatch(tr); - - if (provider) { - provider.off("synced", createIds); - } - }; - - /** - * We need to handle collaboration a bit different here - * because we can't automatically add IDs when the provider is not yet synced - * otherwise we end up with empty paragraphs - */ - if (collab) { - if (!provider) { - return createIds(); - } - - provider.on("synced", createIds); - } else { - return createIds(); - } - }, - - addProseMirrorPlugins() { - if (!this.options.updateDocument) { - return []; - } - - let dragSourceElement: Element | null = null; - let transformPasted = false; - - return [ - new Plugin({ - key: new PluginKey("uniqueID"), - - appendTransaction: (transactions, oldState, newState) => { - const hasDocChanges = - transactions.some((transaction) => transaction.docChanged) && - !oldState.doc.eq(newState.doc); - const filterTransactions = - this.options.filterTransaction && - transactions.some((tr) => !this.options.filterTransaction?.(tr)); - - const isCollabTransaction = transactions.find((tr) => - tr.getMeta("y-sync$"), - ); - - if (isCollabTransaction) { - return; - } - - if (!hasDocChanges || filterTransactions) { - return; - } - - const { tr } = newState; - - const { types, attributeName, generateID } = this.options; - const transform = combineTransactionSteps( - oldState.doc, - transactions as Transaction[], - ); - const { mapping } = transform; - - // get changed ranges based on the old state - const changes = getChangedRanges(transform); - - changes.forEach(({ newRange }) => { - const newNodes = findChildrenInRange( - newState.doc, - newRange, - (node) => { - return types.includes(node.type.name); - }, - ); - - const newIds = newNodes - .map(({ node }) => node.attrs[attributeName]) - .filter((id) => id !== null); - - newNodes.forEach(({ node, pos }, i) => { - // instead of checking `node.attrs[attributeName]` directly - // we look at the current state of the node within `tr.doc`. - // this helps to prevent adding new ids to the same node - // if the node changed multiple times within one transaction - const id = tr.doc.nodeAt(pos)?.attrs[attributeName]; - - if (id === null) { - tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - [attributeName]: generateID({ node, pos }), - }); - - return; - } - - const nextNode = newNodes[i + 1]; - - if (nextNode && node.content.size === 0) { - tr.setNodeMarkup(nextNode.pos, undefined, { - ...nextNode.node.attrs, - [attributeName]: id, - }); - newIds[i + 1] = id; - - if (nextNode.node.attrs[attributeName]) { - return; - } - - const generatedId = generateID({ node, pos }); - - tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - [attributeName]: generatedId, - }); - newIds[i] = generatedId; - - return tr; - } - - const duplicatedNewIds = findDuplicates(newIds); - - // check if the node doesn’t exist in the old state - const { deleted } = mapping.invert().mapResult(pos); - - const newNode = deleted && duplicatedNewIds.includes(id); - - if (newNode) { - tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - [attributeName]: generateID({ node, pos }), - }); - } - }); - }); - - if (!tr.steps.length) { - return; - } - - // `tr.setNodeMarkup` resets the stored marks - // so we'll restore them if they exist - tr.setStoredMarks(newState.tr.storedMarks); - - // Mark this transaction as coming from UniqueID - // to prevent infinite loops with other extensions (e.g., TrailingNode) - tr.setMeta("__uniqueIDTransaction", true); - - return tr; - }, - - // we register a global drag handler to track the current drag source element - view(view) { - const handleDragstart = (event: DragEvent) => { - dragSourceElement = view.dom.parentElement?.contains( - event.target as Element, - ) - ? view.dom.parentElement - : null; - }; - - window.addEventListener("dragstart", handleDragstart); - - return { - destroy() { - window.removeEventListener("dragstart", handleDragstart); - }, - }; - }, - - props: { - // `handleDOMEvents` is called before `transformPasted` - // so we can do some checks before - handleDOMEvents: { - // only create new ids for dropped content - // or dropped content while holding `alt` - // or content is dragged from another editor - drop: (view, event) => { - if ( - dragSourceElement !== view.dom.parentElement || - event.dataTransfer?.effectAllowed === "copyMove" || - event.dataTransfer?.effectAllowed === "copy" - ) { - dragSourceElement = null; - transformPasted = true; - } - - return false; - }, - // always create new ids on pasted content - paste: () => { - transformPasted = true; - - return false; - }, - }, - - // we’ll remove ids for every pasted node - // so we can create a new one within `appendTransaction` - transformPasted: (slice) => { - if (!transformPasted) { - return slice; - } - - const { types, attributeName } = this.options; - const removeId = (fragment: Fragment): Fragment => { - const list: ProseMirrorNode[] = []; - - fragment.forEach((node) => { - // don’t touch text nodes - if (node.isText) { - list.push(node); - - return; - } - - // check for any other child nodes - if (!types.includes(node.type.name)) { - list.push(node.copy(removeId(node.content))); - - return; - } - - // remove id - const nodeWithoutId = node.type.create( - { - ...node.attrs, - [attributeName]: null, - }, - removeId(node.content), - node.marks, - ); - - list.push(nodeWithoutId); - }); - - return Fragment.from(list); - }; - - // reset check - transformPasted = false; - - return new Slice( - removeId(slice.content), - slice.openStart, - slice.openEnd, - ); - }, - }, - }), - ]; - }, }); diff --git a/packages/editor-ext/src/lib/utils.ts b/packages/editor-ext/src/lib/utils.ts index e4e7fda4..350ab3bb 100644 --- a/packages/editor-ext/src/lib/utils.ts +++ b/packages/editor-ext/src/lib/utils.ts @@ -1,6 +1,6 @@ -// @ts-nocheck import { Editor, findParentNode, isTextSelection } from "@tiptap/core"; -import { Selection, Transaction } from "@tiptap/pm/state"; +import { EditorState, Selection, Transaction } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; import { CellSelection, TableMap } from "@tiptap/pm/tables"; import { Node, ResolvedPos } from "@tiptap/pm/model"; import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url"; @@ -287,11 +287,7 @@ export const isColumnGripSelected = ({ const nodeDOM = view.nodeDOM(from) as HTMLElement; const node = nodeDOM || domAtPos; - if ( - !editor.isActive("table") || - !node || - isTableSelected(state.selection) - ) { + if (!editor.isActive("table") || !node || isTableSelected(state.selection)) { return false; } @@ -324,11 +320,7 @@ export const isRowGripSelected = ({ const nodeDOM = view.nodeDOM(from) as HTMLElement; const node = nodeDOM || domAtPos; - if ( - !editor.isActive(Table.name) || - !node || - isTableSelected(state.selection) - ) { + if (!editor.isActive("table") || !node || isTableSelected(state.selection)) { return false; } diff --git a/packages/editor-ext/src/lib/video/video-upload.ts b/packages/editor-ext/src/lib/video/video-upload.ts index 1e976ecc..404cf99e 100644 --- a/packages/editor-ext/src/lib/video/video-upload.ts +++ b/packages/editor-ext/src/lib/video/video-upload.ts @@ -1,132 +1,169 @@ -import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { - insertTrailingNode, - MediaUploadOptions, - UploadFn, -} from "../media-utils"; +import { MediaUploadOptions, UploadFn } from "../media-utils"; import { IAttachment } from "../types"; +import { generateNodeId } from "../utils"; +import { Node } from "@tiptap/pm/model"; +import { Command } from "@tiptap/core"; -const uploadKey = new PluginKey("video-upload"); +const findVideoNodeByPlaceholderId = ( + doc: Node, + placeholderId: string, +): { node: Node; pos: number } | null => { + let result: { node: Node; pos: number } | null = null; -export const VideoUploadPlugin = ({ - placeholderClass, -}: { - placeholderClass: string; -}) => - new Plugin({ - key: uploadKey, - state: { - init() { - return DecorationSet.empty; - }, - apply(tr, set) { - set = set.map(tr.mapping, tr.doc); - // See if the transaction adds or removes any placeholders - //@-ts-expect-error - not yet sure what the type I need here - const action = tr.getMeta(this); - if (action?.add) { - const { id, pos, src } = action.add; + doc.descendants((node, pos) => { + if (result) return false; - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", "video-placeholder"); - const video = document.createElement("video"); - video.setAttribute("class", placeholderClass); - video.src = src; - placeholder.appendChild(video); - const deco = Decoration.widget(pos + 1, placeholder, { - id, - }); - set = set.add(tr.doc, [deco]); - } else if (action?.remove) { - set = set.remove( - set.find( - undefined, - undefined, - (spec) => spec.id == action.remove.id, - ), - ); - } - return set; - }, - }, - props: { - decorations(state) { - return this.getState(state); - }, - }, + if ( + node.type.name === "video" && + node.attrs.placeholder?.id === placeholderId + ) { + result = { node, pos }; + return false; + } + + return true; }); -function findPlaceholder(state: EditorState, id: {}) { - const decos = uploadKey.getState(state) as DecorationSet; - const found = decos.find(undefined, undefined, (spec) => spec.id == id); - return found.length ? found[0]?.from : null; -} + return result; +}; +const getVideoDimensions = ( + url: string, +): Promise< + { width: number; height: number; aspectRatio: number } | undefined +> => { + return new Promise< + { width: number; height: number; aspectRatio: number } | undefined + >((resolve) => { + const video = document.createElement("video"); -export const handleVideoUpload = + video.preload = "metadata"; + video.onloadedmetadata = () => { + const width = video.videoWidth; + const height = video.videoHeight; + const aspectRatio = height > 0 ? width / height : 1; + + resolve({ width, height, aspectRatio }); + }; + video.onerror = () => { + resolve(undefined); + }; + video.src = url; + }); +}; +const handleVideoUpload = ({ validateFn, onUpload }: MediaUploadOptions): UploadFn => - async (file, view, pos, pageId) => { - // check if the file is an image + async (file, editor, pos, pageId) => { + // check if the file is valid const validated = validateFn?.(file); // @ts-ignore if (!validated) return; - // A fresh object to act as the ID for this upload - const id = {}; - // Replace the selection with a placeholder + const objectUrl = URL.createObjectURL(file); + const videoDimensions = await getVideoDimensions(objectUrl); + const placeholderId = generateNodeId(); + const aspectRatio = videoDimensions.aspectRatio; - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => { - const tr = view.state.tr; - if (!tr.selection.empty) tr.deleteSelection(); + let placeholderInserted = false; - tr.setMeta(uploadKey, { - add: { - id, - pos, - src: reader.result, - }, - }); + editor.storage.shared.videoPreviews = + editor.storage.shared.videoPreviews || {}; + editor.storage.shared.videoPreviews[placeholderId] = objectUrl; - insertTrailingNode(tr, pos, view); - view.dispatch(tr); + const insertPlaceholder = (): Command => { + return ({ tr, state }) => { + const initialPlaceholderNode = state.schema.nodes.video?.create({ + placeholder: { + id: placeholderId, + name: file.name, + }, + aspectRatio, + }); + + if (!initialPlaceholderNode) return false; + + const { parent } = tr.doc.resolve(pos); + const isEmptyTextBlock = parent.isTextblock && !parent.childCount; + + if (isEmptyTextBlock) { + // Replace e.g. empty paragraph with the video + tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode); + } else { + tr.insert(pos, initialPlaceholderNode); + } + + return true; + }; }; + const replacePlaceholderWithVideo = (attachment: IAttachment): Command => { + return ({ tr }) => { + const { pos: currentPos = null } = + findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {}; - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + // If the placeholder is not found or attachment is missing, abort the process + if (currentPos === null || !attachment) return; - const pos = findPlaceholder(view.state, id); - - // If the content around the placeholder has been deleted, drop - // the image - if (pos == null) return; - - // Otherwise, insert it at the placeholder's position, and remove - // the placeholder - - if (!attachment) return; - - const node = schema.nodes.video?.create({ + // Update the placeholder node with the actual video data + tr.setNodeMarkup(currentPos, undefined, { src: `/api/files/${attachment.id}/${attachment.fileName}`, attachmentId: attachment.id, title: attachment.fileName, size: attachment.fileSize, + aspectRatio, }); - if (!node) return; - const transaction = view.state.tr - .replaceWith(pos, pos, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - () => { - // Deletes the image placeholder on error - const transaction = view.state.tr - .delete(pos, pos) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - ); + return true; + }; + }; + const removePlaceholder = (): Command => { + return ({ tr }) => { + const { pos: currentPos = null } = + findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {}; + + if (currentPos === null) return false; + + tr.delete(currentPos, currentPos + 2); + + return true; + }; + }; + + // Only show the placeholder if the upload takes more than 250ms + const insertPlaceholderTimeout = setTimeout(() => { + editor.commands.command(insertPlaceholder()); + placeholderInserted = true; + }, 250); + const disposePreviewFile = () => { + URL.revokeObjectURL(objectUrl); + + if (editor.storage.shared.videoPreviews) { + delete editor.storage.shared.videoPreviews[placeholderId]; + } + }; + + try { + const attachment: IAttachment = await onUpload(file, pageId); + + clearTimeout(insertPlaceholderTimeout); + + if (placeholderInserted) { + setTimeout(() => { + editor.commands.command(replacePlaceholderWithVideo(attachment)); + disposePreviewFile(); + }, 100); + } else { + editor + .chain() + .command(insertPlaceholder()) + .command(replacePlaceholderWithVideo(attachment)) + .run(); + disposePreviewFile(); + } + } catch (error) { + clearTimeout(insertPlaceholderTimeout); + + editor.commands.command(removePlaceholder()); + disposePreviewFile(); + } }; + +export { handleVideoUpload }; diff --git a/packages/editor-ext/src/lib/video/video.ts b/packages/editor-ext/src/lib/video/video.ts index 6f28e7c0..31c68f89 100644 --- a/packages/editor-ext/src/lib/video/video.ts +++ b/packages/editor-ext/src/lib/video/video.ts @@ -1,6 +1,5 @@ import { ReactNodeViewRenderer } from "@tiptap/react"; -import { VideoUploadPlugin } from "./video-upload"; -import { mergeAttributes, Range, Node, nodeInputRule } from "@tiptap/core"; +import { Range, Node } from "@tiptap/core"; export interface VideoOptions { view: any; @@ -8,11 +7,15 @@ export interface VideoOptions { } export interface VideoAttributes { src?: string; - title?: string; align?: string; attachmentId?: string; size?: number; width?: number; + aspectRatio?: number; + placeholder?: { + id: string; + name: string; + }; } declare module "@tiptap/core" { @@ -81,15 +84,26 @@ export const TiptapVideo = Node.create({ "data-align": attributes.align, }), }, + aspectRatio: { + default: null, + parseHTML: (element) => element.getAttribute("data-aspect-ratio"), + renderHTML: (attributes: VideoAttributes) => ({ + "data-aspect-ratio": attributes.aspectRatio, + }), + }, + placeholder: { + default: null, + rendered: false, + }, }; }, parseHTML() { return [ { - tag: 'video', + tag: "video", }, - ] + ]; }, renderHTML({ HTMLAttributes }) { @@ -126,14 +140,9 @@ export const TiptapVideo = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - VideoUploadPlugin({ - placeholderClass: "video-upload", - }), - ]; - }, }); diff --git a/packages/editor-ext/tsconfig.json b/packages/editor-ext/tsconfig.json index efbfcd61..974fea06 100644 --- a/packages/editor-ext/tsconfig.json +++ b/packages/editor-ext/tsconfig.json @@ -8,6 +8,7 @@ "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "target": "ES2022", + "jsx": "react-jsx", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 759fedc6..5b5e98be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,8 +22,8 @@ importers: specifier: ^7.1.0 version: 7.1.0 '@casl/ability': - specifier: ^6.7.5 - version: 6.7.5 + specifier: 6.8.0 + version: 6.8.0 '@docmost/editor-ext': specifier: workspace:* version: link:packages/editor-ext @@ -31,17 +31,17 @@ importers: specifier: ^1.7.3 version: 1.7.3 '@hocuspocus/extension-redis': - specifier: ^2.15.3 - version: 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + specifier: 3.4.3 + version: 3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) '@hocuspocus/provider': - specifier: ^2.15.3 - version: 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + specifier: 3.4.3 + version: 3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) '@hocuspocus/server': - specifier: ^2.15.3 - version: 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + specifier: 3.4.3 + version: 3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) '@hocuspocus/transformer': - specifier: ^2.15.3 - version: 2.15.3(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27) + specifier: 3.4.3 + version: 3.4.3(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) '@joplin/turndown': specifier: ^4.0.74 version: 4.0.74 @@ -52,107 +52,86 @@ importers: specifier: 1.1.0 version: 1.1.0 '@tiptap/core': - specifier: 2.27.1 - version: 2.27.1(@tiptap/pm@2.27.1) + specifier: 3.17.1 + version: 3.17.1(@tiptap/pm@3.17.1) '@tiptap/extension-code-block': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-code-block-lowlight': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/extension-code-block@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(highlight.js@11.11.1)(lowlight@3.3.0) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-collaboration': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)) - '@tiptap/extension-collaboration-cursor': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) + '@tiptap/extension-collaboration-caret': + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)) '@tiptap/extension-color': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/extension-text-style@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))) + specifier: 3.17.1 + version: 3.17.1(@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))) '@tiptap/extension-document': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-heading': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-highlight': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-history': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + specifier: 3.17.1 + version: 3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) '@tiptap/extension-image': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-link': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-list-item': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-list-keymap': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-list': + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-placeholder': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + specifier: 3.17.1 + version: 3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) '@tiptap/extension-subscript': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-superscript': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-table': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-table-cell': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-table-header': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-table-row': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-task-item': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-task-list': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-text': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-text-align': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-text-style': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-typography': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-underline': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-unique-id': + specifier: ^3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-youtube': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/html': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(happy-dom@20.1.0) '@tiptap/pm': - specifier: 2.27.1 - version: 2.27.1 + specifier: 3.17.1 + version: 3.17.1 '@tiptap/react': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 3.17.1 + version: 3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tiptap/starter-kit': - specifier: 2.27.1 - version: 2.27.1 + specifier: 3.17.1 + version: 3.17.1 '@tiptap/suggestion': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@types/qrcode': specifier: ^1.5.5 version: 1.5.5 @@ -171,6 +150,12 @@ importers: fractional-indexing-jittered: specifier: ^1.0.0 version: 1.0.0 + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 + image-dimensions: + specifier: ^2.5.0 + version: 2.5.0 ioredis: specifier: ^5.4.1 version: 5.4.1 @@ -194,13 +179,13 @@ importers: version: 11.1.0 y-indexeddb: specifier: ^9.0.12 - version: 9.0.12(yjs@13.6.27) + version: 9.0.12(yjs@13.6.29) y-prosemirror: specifier: 1.3.7 - version: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + version: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) yjs: - specifier: ^13.6.27 - version: 13.6.27 + specifier: ^13.6.29 + version: 13.6.29 devDependencies: '@nx/js': specifier: 20.4.5 @@ -225,7 +210,7 @@ importers: dependencies: '@casl/react': specifier: ^4.0.0 - version: 4.0.0(@casl/ability@6.7.5)(react@18.3.1) + version: 4.0.0(@casl/ability@6.8.0)(react@18.3.1) '@docmost/editor-ext': specifier: workspace:* version: link:../../packages/editor-ext @@ -265,9 +250,6 @@ importers: '@tanstack/react-query': specifier: ^5.90.17 version: 5.90.17(react@18.3.1) - '@tiptap/extension-character-count': - specifier: ^2.27.1 - version: 2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) alfaaz: specifier: ^1.1.0 version: 1.1.0 @@ -355,9 +337,6 @@ importers: socket.io-client: specifier: ^4.8.3 version: 4.8.3 - tippy.js: - specifier: ^6.3.7 - version: 6.3.7 tiptap-extension-global-drag-handle: specifier: ^0.1.18 version: 0.1.18 @@ -614,15 +593,15 @@ importers: pgvector: specifier: ^0.2.1 version: 0.2.1 - postgres: - specifier: ^3.4.8 - version: 3.4.8 pino-http: specifier: ^11.0.0 version: 11.0.0 pino-pretty: specifier: ^13.1.3 version: 13.1.3 + postgres: + specifier: ^3.4.8 + version: 3.4.8 postmark: specifier: ^4.0.5 version: 4.0.5 @@ -1868,8 +1847,8 @@ packages: '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} - '@casl/ability@6.7.5': - resolution: {integrity: sha512-NaOHPi9JMn8Kesh+GRkjNKAYkl4q8qMFAlqw7w2yrE+cBQZSbV9GkBGKvgzs3CdzEc5Yl1cn3JwDxxbBN5gjog==} + '@casl/ability@6.8.0': + resolution: {integrity: sha512-Ipt4mzI4gSgnomFdaPjaLgY2MWuXqAEZLrU6qqWBB7khGiBBuuEp6ytYDnq09bRXqcjaeeHiaCvCGFbBA2SpvA==} '@casl/react@4.0.0': resolution: {integrity: sha512-ovmI4JfNw7TfVVV+XhAJ//gXgMEkkPJU6YBWFVFZGa8Oikdh8Qxr/sdXcqj71QWEHAGN7aSKMtBE0MZylPUVsg==} @@ -2398,32 +2377,32 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - '@hocuspocus/common@2.15.3': - resolution: {integrity: sha512-Rzh1HF0a2o/tf90A3w2XNdXd9Ym3aQzMDfD3lAUONCX9B9QOdqdyiORrj6M25QEaJrEIbXFy8LtAFcL0wRdWzA==} + '@hocuspocus/common@3.4.3': + resolution: {integrity: sha512-wnBBO9sWcVAoUPEXN1qO+zk3HaEF9VTemxB6kjuuH6e1dHnD0v12m4P4X1wiZVhmMIX/PMl/fu3MGtYWQJz8gA==} - '@hocuspocus/extension-redis@2.15.3': - resolution: {integrity: sha512-gKeiiuQcAoRYb+QK9vyIczRrjNy8NW6ky+oyVv7raMcaizfFxeWP3TaAHPyC2pjGKfXsqN2m3YM0GbBGZfMiCg==} + '@hocuspocus/extension-redis@3.4.3': + resolution: {integrity: sha512-r64Vpgk6tt0VZaQPEo1dQuyur2ozr243ncDcDM+4gFPuV8ZRUjL1rvaJTidb2HCcAW2zjfwshNxw4+OixeksBA==} peerDependencies: y-protocols: ^1.0.6 yjs: ^13.6.8 - '@hocuspocus/provider@2.15.3': - resolution: {integrity: sha512-oadN05m+KL4ylNKVo5YspNG4MXkT2Y+FUFzrgigpQeTjQibkPUwCNmUnkUxMgrGRgxb+O0lJCfirFIJMxedctA==} + '@hocuspocus/provider@3.4.3': + resolution: {integrity: sha512-zt+UgVXGsEQrqnDZgavc2PT9yKJjmVjV+5YxvhlmFVFLVORqawT4l601aKmLPhvyK97un4ZApZ5rso8iO6crWg==} peerDependencies: y-protocols: ^1.0.6 yjs: ^13.6.8 - '@hocuspocus/server@2.15.3': - resolution: {integrity: sha512-Ju4ty4/7JtmvivcP7gKReOLf8KrFwN7Yx/5VhXYh4TRULy4kSo2fsDVUaluPp0neZa6PbVhizJuzlOim73IEbQ==} + '@hocuspocus/server@3.4.3': + resolution: {integrity: sha512-a9bqAXUMBo9YBeuzqNf9C3eVbu1RIWUrtmFMGq+ZssQr3Jugt/5PCkZskgqhJNvPkyTARHcUtN80j/SDLylZmg==} peerDependencies: y-protocols: ^1.0.6 yjs: ^13.6.8 - '@hocuspocus/transformer@2.15.3': - resolution: {integrity: sha512-01UU3iZA9MF+MmB2SweKyC70nBM/FkBt3veWiAMoXPiegUG47wY8QO2MksBD/ucnz7C5M/0oAsTjqrx+j0ynIw==} + '@hocuspocus/transformer@3.4.3': + resolution: {integrity: sha512-jQZiqFGCvGQJLgE0nHZ4TdpEJlI7WkM8CKA1wLcs0beVs0kNXg32lykGckjveJwwJuJ/hieMqIEqj9POxTWPEw==} peerDependencies: - '@tiptap/core': ^2.6.4 - '@tiptap/pm': ^2.6.4 + '@tiptap/core': ^3.0.1 + '@tiptap/pm': ^3.0.1 y-prosemirror: 1.3.7 yjs: ^13.6.8 @@ -3392,9 +3371,6 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@popperjs/core@2.11.8': - resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@radix-ui/primitive@1.1.1': resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} @@ -3905,6 +3881,12 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@sesamecare-oss/redlock@1.4.0': + resolution: {integrity: sha512-2z589R+yxKLN4CgKxP1oN4dsg6Y548SE4bVYam/R0kHk7Q9VrQ9l66q+k1ehhSLLY4or9hcchuF9/MhuuZdjJg==} + engines: {node: '>=16'} + peerDependencies: + ioredis: '>=5' + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -4240,271 +4222,261 @@ packages: peerDependencies: react: ^18 || ^19 - '@tiptap/core@2.27.1': - resolution: {integrity: sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==} + '@tiptap/core@3.17.1': + resolution: {integrity: sha512-f8hB9MzXqsuXoF9qXEDEH5Fb3VgwhEFMBMfk9EKN88l5adri6oM8mt2XOWVxVVssjpEW0177zXSLPKWzoS/vrw==} peerDependencies: - '@tiptap/pm': ^2.7.0 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-blockquote@2.27.1': - resolution: {integrity: sha512-QrUX3muElDrNjKM3nqCSAtm3H3pT33c6ON8kwRiQboOAjT/9D57Cs7XEVY7r6rMaJPeKztrRUrNVF9w/w/6B0A==} + '@tiptap/extension-blockquote@3.17.1': + resolution: {integrity: sha512-X4jU/fllJQ8QbjCHUafU4QIHBobyXP3yGBoOcXxUaKlWbLvUs0SQTREM3n6/86m2YyAxwTPG1cn3Xypf42DMAQ==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-bold@2.27.1': - resolution: {integrity: sha512-g4l4p892x/r7mhea8syp3fNYODxsDrimgouQ+q4DKXIgQmm5+uNhyuEPexP3I8TFNXqQ4DlMNFoM9yCqk97etQ==} + '@tiptap/extension-bold@3.17.1': + resolution: {integrity: sha512-PZmrljcVBziJkQDXT/QJv4ESxVVQ0iRH+ruTzPda56Kk4h2310cSXGjI33W7rlCikGPoBAAjY/inujm46YB4bw==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-bubble-menu@2.27.1': - resolution: {integrity: sha512-ki1R27VsSvY2tT9Q2DIlcATwLOoEjf5DsN+5sExarQ8S/ZxT/tvIjRxB8Dx7lb2a818W5f/NER26YchGtmHfpg==} + '@tiptap/extension-bubble-menu@3.17.1': + resolution: {integrity: sha512-z3E8biLiWlzZJwNHnB6j/ZyBdFrJmpl1lqKHc72JqahUHZvidZHdCOYssvR3fc6IaI7MXV13XY1DXUdFbatnaw==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-bullet-list@2.27.1': - resolution: {integrity: sha512-5FmnfXkJ76wN4EbJNzBhAlmQxho8yEMIJLchTGmXdsD/n/tsyVVtewnQYaIOj/Z7naaGySTGDmjVtLgTuQ+Sxw==} + '@tiptap/extension-bullet-list@3.17.1': + resolution: {integrity: sha512-2zw17XHruOJQK7ntLVq0PmOLajFhvQ+U4/qTfJnV3VOsHkm+2GPAksFe7I7+X0XmSmDru0pcT339Yywx/6Aykw==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/extension-list': ^3.17.1 - '@tiptap/extension-character-count@2.27.2': - resolution: {integrity: sha512-EcQRIvbLbMDDzo7uFqXYgh1CfgedS9sYX4BllktY2OlXLPdNpwo9t8WMK/a7soESNv0Le3WZ5pNvnNhv7Z2YdA==} + '@tiptap/extension-code-block@3.17.1': + resolution: {integrity: sha512-h4i+Y/cN7nMi0Tmlp6V1w4dI7NTqrUFSr1W/vMqnq4vn+c6jvm35KubKU5ry/1qQp8KfndDA02BtVQiMx6DmpA==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-code-block-lowlight@2.27.1': - resolution: {integrity: sha512-Ijg9724uX/l4LXLELEeztZIgg+bDE/jJCkgS1+mavkRA/qtidpQkHo7L/Ry22fmj/ktCtZLjPXE5JAPAoRU6zA==} + '@tiptap/extension-code@3.17.1': + resolution: {integrity: sha512-4W0x1ZZqSnIVzQV0/b5VR0bktef2HykH5I/Czzir9yqoZ5zV2cLrMVuLvdFNgRIckU60tQLmHrfKWLF50OY0ew==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/extension-code-block': ^2.7.0 - '@tiptap/pm': ^2.7.0 - highlight.js: ^11 - lowlight: ^2 || ^3 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-code-block@2.27.1': - resolution: {integrity: sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==} + '@tiptap/extension-collaboration-caret@3.17.1': + resolution: {integrity: sha512-tYzujG4ABacSbjd8QOqMt1IP3QdCmAEBHP2faF4SeFauaP6Nto88JvTiZVCHad0BBwiNrj4UPGZSujcNQiLjTA==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 + '@tiptap/y-tiptap': ^3.0.2 - '@tiptap/extension-code@2.27.1': - resolution: {integrity: sha512-i65wUGJevzBTIIUBHBc1ggVa27bgemvGl/tY1/89fEuS/0Xmre+OQjw8rCtSLevoHSiYYLgLRlvjtUSUhE4kgg==} + '@tiptap/extension-collaboration@3.17.1': + resolution: {integrity: sha512-4ehZ5LL7M3nFfcogCG7bWRHIR/8366i1vz5i0PaaoArJga2N5sXnWcuBGXG7ykC8owbgrfL3agFxjHlhTl4sNw==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 + '@tiptap/y-tiptap': ^3.0.2 + yjs: ^13 - '@tiptap/extension-collaboration-cursor@2.27.1': - resolution: {integrity: sha512-k4vLA1QeGM4FfO9BMKw8O0Nxv2zDrsUpnP7wKAJp/zmr2lHbQX86cO+SGEy+kcRtPeIp6Y4Phytp6F+1HMjbLA==} + '@tiptap/extension-color@3.17.1': + resolution: {integrity: sha512-QVlzpzGB+QcZgHgvFMRPckZutpkOLzNmZzhupNA7G2CMeeoCwZOJeZkyd3zvtAnRZkf7FrQBO123On30pJt7TA==} peerDependencies: - '@tiptap/core': ^2.7.0 - y-prosemirror: 1.3.7 + '@tiptap/extension-text-style': ^3.17.1 - '@tiptap/extension-collaboration@2.27.1': - resolution: {integrity: sha512-fR35dIYDHM9870zl2sHaA2ytSVcjASv8Nfnb1Mgslt/F3Lqsu9TOv/oJWi9nYBvjjrfK0RNaoGFVH7p2z7FR3w==} + '@tiptap/extension-document@3.17.1': + resolution: {integrity: sha512-F7Q5HoAU383HWFa6AXZQ5N6t6lTJzVjYM8z93XrtH/2GzDFwy1UmDSrsXqvgznedBLAOgCNVTNh9PjXpLoOUbg==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 - y-prosemirror: 1.3.7 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-color@2.27.1': - resolution: {integrity: sha512-raYRsdG2tZvVvY1LV/VTZnDG44Y0xRBwo5CZEat0OUqdx34dfvCtYm8HIOTyWBwr7OOW+yR4O1Vc2zFkmfthZw==} + '@tiptap/extension-dropcursor@3.17.1': + resolution: {integrity: sha512-EKJYPb7OSk3p9mX1SmHt4ccw89w1P1d55hC8aPtZJ6jxAUd5MSuVwvEEVz7LGldUZD9HZz9WFQ0Sv9U73Bpkmw==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/extension-text-style': ^2.7.0 + '@tiptap/extensions': ^3.17.1 - '@tiptap/extension-document@2.27.1': - resolution: {integrity: sha512-NtJzJY7Q/6XWjpOm5OXKrnEaofrcc1XOTYlo/SaTwl8k2bZo918Vl0IDBWhPVDsUN7kx767uHwbtuQZ+9I82hA==} + '@tiptap/extension-floating-menu@3.17.1': + resolution: {integrity: sha512-zYkoYsxp+cZ8tBDODm4E8hnSaMTdDWKJuCQWY2Ep14oMPkAkSJr8sCLL1tOnNSAnhGwLJQtRLkZ41nvUEP6xKA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-dropcursor@2.27.1': - resolution: {integrity: sha512-3MBQRGHHZ0by3OT0CWbLKS7J3PH9PpobrXjmIR7kr0nde7+bHqxXiVNuuIf501oKU9rnEUSedipSHkLYGkmfsA==} + '@tiptap/extension-gapcursor@3.17.1': + resolution: {integrity: sha512-xItmJZTi+Z6UbLBhpBBL9RZDNbDXf+ntWVgblAmxtpyEyNh5k5tkM6IP9SJRhk92uVfnFpH9qkGo66a537I8QA==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/extensions': ^3.17.1 - '@tiptap/extension-floating-menu@2.27.1': - resolution: {integrity: sha512-nUk/8DbiXO69l6FDwkWso94BTf52IBoWALo+YGWT6o+FO6cI9LbUGghEX2CdmQYXCvSvwvISF2jXeLQWNZvPZQ==} + '@tiptap/extension-hard-break@3.17.1': + resolution: {integrity: sha512-28FZPUho1Q2AB3ka5SVEVib5f9dMKbE1kewLZeRIOQ5FuFNholGIPL5X1tKcwGW7G3A7Y0fGxeNmIZJ3hrqhzA==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-gapcursor@2.27.1': - resolution: {integrity: sha512-A9e1jr+jGhDWzNSXtIO6PYVYhf5j/udjbZwMja+wCE/3KvZU9V3IrnGKz1xNW+2Q2BDOe1QO7j5uVL9ElR6nTA==} + '@tiptap/extension-heading@3.17.1': + resolution: {integrity: sha512-rT+Su/YnHdlikg8f78t6RXlc1sVSfp7B0fdJdtFgS2e6BBYJQoDMp5L9nt54RR9Yy953aDW2sko7NArUCb8log==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-hard-break@2.27.1': - resolution: {integrity: sha512-W4hHa4Io6QCTwpyTlN6UAvqMIQ7t56kIUByZhyY9EWrg/+JpbfpxE1kXFLPB4ZGgwBknFOw+e4bJ1j3oAbTJFw==} + '@tiptap/extension-highlight@3.17.1': + resolution: {integrity: sha512-I4EdBhPVzJd4ECMI9kP0NE4aG4Numd46jy/AqeZyf3dqVgCxRyAbSyU7oy4aXUnsojYODrKKG6+djm07KgOGoQ==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-heading@2.27.1': - resolution: {integrity: sha512-6xoC7igZlW1EmnQ5WVH9IL7P1nCQb3bBUaIDLvk7LbweEogcTUECI4Xg1vxMOVmj9tlDe1I4BsgfcKpB5KEsZw==} + '@tiptap/extension-history@3.17.1': + resolution: {integrity: sha512-YHW4HP9ovZ/zqc1u3+cDdAY/LITaMQNRnX5foLsDFLV5FU+zqonYo2CqDkVwaQs9UfCp9PM0ehZzxMI8hc58oA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/extensions': ^3.17.1 - '@tiptap/extension-highlight@2.27.1': - resolution: {integrity: sha512-ntuYX09tvHQE/R/8WbTOxbFuQhRr2jhTkKz/gLwDD2o8IhccSy3f0nm+mVmVamKQnbsBBbLohojd5IGOnX9f1A==} + '@tiptap/extension-horizontal-rule@3.17.1': + resolution: {integrity: sha512-CHG6LBtxV+3qj5EcCRVlpvSW5udKD6KbnXIGhP+Tvy+OabLGzO4HNxz3+duDE0pMR4eKX1libsnqffj0vq7mnQ==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-history@2.27.1': - resolution: {integrity: sha512-K8PHC9gegSAt0wzSlsd4aUpoEyIJYOmVVeyniHr1P1mIblW1KYEDbRGbDlrLALTyUEfMcBhdIm8zrB9X2Nihvg==} + '@tiptap/extension-image@3.17.1': + resolution: {integrity: sha512-VbSSZ//5qijm8F0lQQ6K+DGnZgjLKYQY2c+O56QNEoN8BaCFrJlsVgF1ttrSRUmoG4XBNIMlAS07kZXvMZQr0g==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-horizontal-rule@2.27.1': - resolution: {integrity: sha512-WxXWGEEsqDmGIF2o9av+3r9Qje4CKrqrpeQY6aRO5bxvWX9AabQCfasepayBok6uwtvNzh3Xpsn9zbbSk09dNA==} + '@tiptap/extension-italic@3.17.1': + resolution: {integrity: sha512-unfRLmvf680Y0UkBToUcrDkSEKO/wAjd3nQ7CNPMfAc8m+ZMReXkcgLpeVvnDEiHNsJ0PlYSW7a45tnQD9HQdg==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-image@2.27.1': - resolution: {integrity: sha512-wu3vMKDYWJwKS6Hrw5PPCKBO2RxyHNeFLiA/uDErEV7axzNpievK/U9DyaDXmtK3K/h1XzJAJz19X+2d/pY68w==} + '@tiptap/extension-link@3.17.1': + resolution: {integrity: sha512-5kdN7vms5hMXtjiophUkgvzy8dNGvGSmol1Sawh30TEPrgXc93Ayj7YyGZlbimInKZcD8q+Od/FFc+wkrof3nA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-italic@2.27.1': - resolution: {integrity: sha512-rcm0GyniWW0UhcNI9+1eIK64GqWQLyIIrWGINslvqSUoBc+WkfocLvv4CMpRkzKlfsAxwVIBuH2eLxHKDtAREA==} + '@tiptap/extension-list-item@3.17.1': + resolution: {integrity: sha512-Qjj4oIa44cTX0E6aw/4+wleqX21t5jMDxeSqP5uQ8Q3IdD1GoR5+yo+41XAHELaeZOXLHLkAIbzIxik3pOqO8w==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/extension-list': ^3.17.1 - '@tiptap/extension-link@2.27.1': - resolution: {integrity: sha512-cCwWPZsnVh9MXnGOqSIRXPPuUixRDK8eMN2TvqwbxUBb1TU7b/HtNvfMU4tAOqAuMRJ0aJkFuf3eB0Gi8LVb1g==} + '@tiptap/extension-list-keymap@3.17.1': + resolution: {integrity: sha512-zRidxbkJNe/j3nZpOGLnPeVdyciUM8MM+NHhxcjVKoNDA+/zEBfjXJ1dKC4UBsnSr4AS/3SCWBYHGXOoSqdUaA==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/extension-list': ^3.17.1 - '@tiptap/extension-list-item@2.27.1': - resolution: {integrity: sha512-dtsxvtzxfwOJP6dKGf0vb2MJAoDF2NxoiWzpq0XTvo7NGGYUHfuHjX07Zp0dYqb4seaDXjwsi5BIQUOp3+WMFQ==} + '@tiptap/extension-list@3.17.1': + resolution: {integrity: sha512-LHKIxmXe5Me+vJZKhiwMBGHlApaBIAduNMRUpm5mkY7ER/m96zKR0VqrJd4LjVVH2iDvck5h1Ka4396MHWlKNg==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-list-keymap@2.27.1': - resolution: {integrity: sha512-k7+Ulz9B1NjqwU6NEFYkJh4rGGT/iRVaCBa8OL9YYrVS3H44LgEqUCEbRu6TeEq4XXrLwueQpkkyl4Evi15lAQ==} + '@tiptap/extension-ordered-list@3.17.1': + resolution: {integrity: sha512-pahAXbVajqX0Y51Zge9jKZlCtPV1oiq5Fbzs7gHF80KICIKf44i/AsUvfdJyT2N5/8kZrAMQHEiU/UgTMrhM3w==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/extension-list': ^3.17.1 - '@tiptap/extension-ordered-list@2.27.1': - resolution: {integrity: sha512-U1/sWxc2TciozQsZjH35temyidYUjvroHj3PUPzPyh19w2fwKh1NSbFybWuoYs6jS3XnMSwnM2vF52tOwvfEmA==} + '@tiptap/extension-paragraph@3.17.1': + resolution: {integrity: sha512-Vl+xAlINaPtX8XTPvPmeveYMEIMLs8gA7ItcKpyyo4cCzAfVCY3DKuWzOkQGUf7DKrhyJQZhpgLNMaq+h5sTSw==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-paragraph@2.27.1': - resolution: {integrity: sha512-R3QdrHcUdFAsdsn2UAIvhY0yWyHjqGyP/Rv8RRdN0OyFiTKtwTPqreKMHKJOflgX4sMJl/OpHTpNG1Kaf7Lo2A==} + '@tiptap/extension-placeholder@3.17.1': + resolution: {integrity: sha512-cE8Rij5/1t4KnWE7GaDewhBek9DKNB+97yrxyggMegILg6v195hOmOkRZkyfnFMYZoBDlrfSAtX9wBvbZBqIsg==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/extensions': ^3.17.1 - '@tiptap/extension-placeholder@2.27.1': - resolution: {integrity: sha512-UbXaibHHFE+lOTlw/vs3jPzBoj1sAfbXuTAhXChjgYIcTTY5Cr6yxwcymLcimbQ79gf04Xkua2FCN3YsJxIFmw==} + '@tiptap/extension-strike@3.17.1': + resolution: {integrity: sha512-c6fS6YIhxoU55etlJgM0Xqker+jn7I1KC7GVu6ljmda8I00K3/lOLZgvFUNPmgp8EJWtyTctj+3D3D+PaZaFAA==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-strike@2.27.1': - resolution: {integrity: sha512-S9I//K8KPgfFTC5I5lorClzXk0g4lrAv9y5qHzHO5EOWt7AFl0YTg2oN8NKSIBK4bHRnPIrjJJKv+dDFnUp5jQ==} + '@tiptap/extension-subscript@3.17.1': + resolution: {integrity: sha512-+y/sl1d+TcecX2n1r6ZTjBmY3D6cfqAW86iKsvudCFSpp9SQk85RaumPzELOXWOjz9g0mtfUnXifrLYF3dS+vA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-subscript@2.27.1': - resolution: {integrity: sha512-n2jTaYriewwz3ES1o6Wt/OwREvPwi97n+yEsJ7i31wiuxGTdCP31eAuppC6DvixEvDt3/rZMZcNp8Ah9crlbnw==} + '@tiptap/extension-superscript@3.17.1': + resolution: {integrity: sha512-FKt+lI1ocFRW0EFla9EuO71aLQINpkC/wt9zxWnJJnfPIWfxYlsTSFJLjLkVungTmwfeCnoCVcXnZ0dSKDnoGg==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-superscript@2.27.1': - resolution: {integrity: sha512-zTYOD7k3txm21rjeYHsf/VIpBe9IvVfNHSNayyY/JOgyQ/fW40cgX0gADNoT2ayAtRes4TvpcUYdgF9vC5bkJw==} + '@tiptap/extension-table@3.17.1': + resolution: {integrity: sha512-FuAMdmM330tHJUYT5IV2ooFRqtXf+0D8llcE9nIQQCXKL4J0pfGSOIm40LVpunYgx2pV8SSCL51qTBuEmR84tQ==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-table-cell@2.27.1': - resolution: {integrity: sha512-VowNmz1kub2qfntWkU8jGA6DoCl9xjJBWSypuQIeiN/IRId3BMrJodT26pTNJ3ChDMtYaanWaUvYqckRxgTC2A==} + '@tiptap/extension-text-align@3.17.1': + resolution: {integrity: sha512-CyJbZf823dqPZ/1zwRsza5pk/NQwFZwILdFYLVkV88I4+Ua9YVztI9kmwTB6dJyuKT4kTc7nhQHdaa957alGZQ==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-table-header@2.27.1': - resolution: {integrity: sha512-lSbGB6kBp/sTVzAWl4v7v7ztL5XU3aTdlS7FhfGjpdsxd4zPKYG8kx+Uxgq25W9/BlCbnqHnO0poAMfOlspDQw==} + '@tiptap/extension-text-style@3.17.1': + resolution: {integrity: sha512-TCMsEU92r/TfZkN8AKo/WIcJ1uNq/5NiZxloq5drF1HXxDDjwliurgwBw3OTGUlKQmer0N9hV0AAePY/G+5Akw==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-table-row@2.27.1': - resolution: {integrity: sha512-3xtlmZ6NWDi5a42gK0qQQTeBUpJ2j1o7qyXTFkhQaJAeIFEqsemgSRhgXZxbwSmQQZsPJ/86KWBNVkT0FaRFDw==} + '@tiptap/extension-text@3.17.1': + resolution: {integrity: sha512-rGml96vokQbvPB+w6L3+WKyYJWwqELaLdFUr1WMgg+py5uNYGJYAExYNAbDb5biWJBrX9GgMlCaNeiJj849L1w==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-table@2.27.1': - resolution: {integrity: sha512-iOoOo0vYFzAogAZlw36DgmFfNM5vOkLqnApm81soO/YWpqtKAvBn+TMY4ss4OMDsOefUzBa6xqOJ0gJR5ZygjA==} + '@tiptap/extension-typography@3.17.1': + resolution: {integrity: sha512-bEocTrK/gryk3VtthC9Ca03p2kutVIIFnDkVW6iOG8PgQWEspuQRgqE8yPnHxY8pBBDWxiaBzcGTSrp+3U9d5A==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-task-item@2.27.1': - resolution: {integrity: sha512-vaEtdos+9jApD6yRfD6F/xShikiZFHi7I0nswAmGKT/kE1wmHCUxme8OFMe7642e2OK0lqgHsUaOLxP/0nZJ5A==} + '@tiptap/extension-underline@3.17.1': + resolution: {integrity: sha512-6RdBzmkg6DYs0EqPyoqLGkISXzCnPqM/q3A6nh3EmFmORcIDfuNmcidvA6EImebK8KQGmtZKsRhQSnK4CNQ39g==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-task-list@2.27.1': - resolution: {integrity: sha512-KRlYOZ6kdURvAspUrLVsC7mLkVW2DYhpj+7QxH7gVDZuAuoPUEmpJVcBVPq7GhPF9PccaRLru+n1Ege5VqvZ+Q==} + '@tiptap/extension-unique-id@3.17.1': + resolution: {integrity: sha512-R+lXBIaEmJ23rJMMLs6dPIVMhpv+TU8vEFVtpbMoOl/yfoc9Pvr6Q0EgLnRDX6l4yAekenem4KmGeG9CmuoskA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-text-align@2.27.1': - resolution: {integrity: sha512-D7dLPk7y5mDn9ZNANQ4K2gCq4vy+Emm5AdeWOGzNeqJsYrBotiQYXd9rb1QYjdup2kzAoKduMTUXV92ujo5cEg==} + '@tiptap/extension-youtube@3.17.1': + resolution: {integrity: sha512-AarpN4vI/S6jPMuLuFGEFLgdoasGiUW+rGLj+jH/0Of6l27nKRN00MTm/fD/62qjR6At3Rd7Xsue/GuXdmDUWw==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-text-style@2.27.1': - resolution: {integrity: sha512-NagQ9qLk0Ril83gfrk+C65SvTqPjL3WVnLF2arsEVnCrxcx3uDOvdJW67f/K5HEwEHsoqJ4Zq9Irco/koXrOXA==} + '@tiptap/extensions@3.17.1': + resolution: {integrity: sha512-aQ4WA5bdRpv9yPQ6rRdiqwlMZ1eJw1HyEaNPQhOr2HVhQ0EqSDIOEXF4ymCveGAHxXbxNvtQ+4t1ymQEikGfXA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-text@2.27.1': - resolution: {integrity: sha512-a4GCT+GZ9tUwl82F4CEum9/+WsuW0/De9Be/NqrMmi7eNfAwbUTbLCTFU0gEvv25WMHCoUzaeNk/qGmzeVPJ1Q==} + '@tiptap/html@3.17.1': + resolution: {integrity: sha512-fLb2fo8+3oQ+5FTx5IGZvLI5+VLgN9BM6pHaO1+IrwqQ5w2RBFIGp8M946asBPkxJ74EtzHqFKJpVFtaY2CcpA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 + happy-dom: ^20.0.2 - '@tiptap/extension-typography@2.27.1': - resolution: {integrity: sha512-jAZU5IuWH9CtZlolQ1gRhV+bT75s19SXjadQwkk18gMMiapcaIVVTxUDWY6ycv9ge4cjRoaP3lqBviW3cGqhOA==} + '@tiptap/pm@3.17.1': + resolution: {integrity: sha512-UyVLkN8axV/zop6Se2DCBJRu5DM21X0XEQvwEC5P/vk8eC9OcQZ3FLtxeYy2ZjpAZUzBGLw0/BGsmEip/n7olw==} + + '@tiptap/react@3.17.1': + resolution: {integrity: sha512-Hn/pIP3HG9xYnhI3iGrfVhgQhfIdOaEBSxOFzJ37patqSOlIoP5aZH/b2HZ4vgo5DdRlV56q7WtRC+vLIw4Neg==} peerDependencies: - '@tiptap/core': ^2.7.0 - - '@tiptap/extension-underline@2.27.1': - resolution: {integrity: sha512-fPTmfJFAQWg1O/os1pYSPVdtvly6eW/w5sDofG7pre+bdQUN+8s1cZYelSuj/ltNVioRaB2Ws7tvNgnHL0aAJQ==} - peerDependencies: - '@tiptap/core': ^2.7.0 - - '@tiptap/extension-youtube@2.27.1': - resolution: {integrity: sha512-HjBBgE0Zbch/S2UP0YYQXervfoBd4Trw0dYmlZbX9cXJcZv+QFx0vsPGmjAGlqzXf9Y8ZioWm8fso4u6AsUfTw==} - peerDependencies: - '@tiptap/core': ^2.7.0 - - '@tiptap/html@2.27.1': - resolution: {integrity: sha512-5iPo36g4nbBVoEVBQb6my4KNpNzu38gtCFXIIlAJdAZQvPs+XC8TkrnGK/G4UGpwBXCuQjSQm0iyn4znmQPDsw==} - peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 - - '@tiptap/pm@2.27.1': - resolution: {integrity: sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==} - - '@tiptap/react@2.27.1': - resolution: {integrity: sha512-leJximSjYJuhLJQv9azOP9R7w6zuxVgKOHYT4w83Gte7GhWMpNL6xRWzld280vyq/YW/cSYjPb/8ESEOgKNBdQ==} - peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tiptap/starter-kit@2.27.1': - resolution: {integrity: sha512-uQQlP0Nmn9eq19qm8YoOeloEfmcGbPpB1cujq54Q6nPgxaBozR7rE7tXbFTinxRW2+Hr7XyNWhpjB7DMNkdU2Q==} + '@tiptap/starter-kit@3.17.1': + resolution: {integrity: sha512-3vBGqag9mwuQoWTrfQlULtHeoFs7k/2Q8CREf3Y79hv2fqAXTvTOKlWYPSgZhiGVMp6Dti7BDiE9Y1QpvAat2g==} - '@tiptap/suggestion@2.27.1': - resolution: {integrity: sha512-yTy75ZMYgVWM18cl7YxLqMJ7TorQTGysSd1aKmBA9qd8uzYlvLMmHKE9qBDxM9HXODBz1DA/BLLm9esv2enmFw==} + '@tiptap/suggestion@3.17.1': + resolution: {integrity: sha512-a188uVYjlLsUiwK3Ki7KsaWVWC0u28KsqGEAqCk9ECYmtVY99Hrb+rcAwGpMjA7tn8WAwThOxiLISoMdpuqXwg==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 + + '@tiptap/y-tiptap@3.0.1': + resolution: {integrity: sha512-F3hj5X77ckmyIywbCQpKgyX3xKra2/acJPWaV5R9wqp0cUPBmm62FYbkQ6HaqxH1VhCkUhhAZcDSQjbjj7tnWw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.5.38 '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} @@ -5269,6 +5241,9 @@ packages: async-lock@1.4.1: resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + async@3.2.5: resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} @@ -5375,9 +5350,6 @@ packages: bluebird@3.4.7: resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} - bluebird@3.7.2: - resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -6089,10 +6061,6 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - denque@1.5.1: - resolution: {integrity: sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==} - engines: {node: '>=0.10'} - denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -6272,10 +6240,6 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@5.0.0: - resolution: {integrity: sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==} - engines: {node: '>=0.12'} - entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -6507,6 +6471,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.3.4: + resolution: {integrity: sha512-d+yU9iNQbbC098NOuMlAIth/g+owbpX/uuOkH/DQcC2fMMyjOlX292Op29DrUKq388m4UUyOdWakUH/msGypOg==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -6931,6 +6899,11 @@ packages: image-blob-reduce@3.0.1: resolution: {integrity: sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==} + image-dimensions@2.5.0: + resolution: {integrity: sha512-CKZPHjAEtSg9lBV9eER0bhNn/yrY7cFEQEhkwjLhqLY+Na8lcP1pEyWsaGMGc8t2qbKWA/tuqbhFQpOKGN72Yw==} + engines: {node: '>=18'} + hasBin: true + image-size@0.5.5: resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} engines: {node: '>=0.10.0'} @@ -6983,10 +6956,6 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ioredis@4.28.5: - resolution: {integrity: sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==} - engines: {node: '>=6'} - ioredis@5.4.1: resolution: {integrity: sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==} engines: {node: '>=12.22.0'} @@ -7618,11 +7587,6 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lib0@0.2.108: - resolution: {integrity: sha512-+3eK/B0SqYoZiQu9fNk4VEc6EX8cb0Li96tPGKgugzoGj/OdRdREtuTLvUW+mtinoB2mFiJjSqOJBIaMkAGhxQ==} - engines: {node: '>=16'} - hasBin: true - lib0@0.2.114: resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==} engines: {node: '>=16'} @@ -7687,9 +7651,6 @@ packages: lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - lodash.flatten@4.4.0: - resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} - lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -8305,10 +8266,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-map@2.1.0: - resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} - engines: {node: '>=6'} - p-queue@6.6.2: resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} engines: {node: '>=8'} @@ -8725,8 +8682,8 @@ packages: prosemirror-schema-basic@1.2.3: resolution: {integrity: sha512-h+H0OQwZVqMon1PNn0AG9cTfx513zgIG2DY00eJ00Yvgb3UD+GQ/VlWW5rcaxacpCGT1Yx8nuhwXk4+QbXUfJA==} - prosemirror-schema-list@1.4.1: - resolution: {integrity: sha512-jbDyaP/6AFfDfu70VzySsD75Om2t3sXTOdl5+31Wlxlg62td1haUpty/ybajSfJ1pkGadlOfwQq9kgW5IMo1Rg==} + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} prosemirror-state@1.4.3: resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==} @@ -8996,9 +8953,6 @@ packages: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} - redis-commands@1.7.0: - resolution: {integrity: sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==} - redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -9007,10 +8961,6 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} - redlock@4.2.0: - resolution: {integrity: sha512-j+oQlG+dOwcetUt2WJWttu4CZVeRzUrcVcISFmEmfyuwCVSJ93rDT7YSgg7H7rnxwoRyk/jU46kycVka5tW7jA==} - engines: {node: '>=8.0.0'} - redux@4.2.1: resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} @@ -9566,9 +9516,6 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tippy.js@6.3.7: - resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} - tiptap-extension-global-drag-handle@0.1.18: resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==} @@ -9918,6 +9865,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf8-byte-length@1.0.4: resolution: {integrity: sha512-4+wkEYLBbWxqTahEsWrhxepcoVOJ+1z5PGIjPZxRkytcdSUaNjIjBM7Xn8E+pdSuV7SzvWovBFA54FO0JSoqhA==} @@ -10303,8 +10255,8 @@ packages: resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} engines: {node: '>=12'} - yjs@13.6.27: - resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} + yjs@13.6.29: + resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} yn@3.1.1: @@ -10323,10 +10275,6 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} - zeed-dom@0.15.1: - resolution: {integrity: sha512-dtZ0aQSFyZmoJS0m06/xBN1SazUBPL5HpzlAcs/KcRW0rzadYw12deQBjeMhGKMMeGEp7bA9vmikMLaO4exBcg==} - engines: {node: '>=14.13.1'} - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -12240,13 +12188,13 @@ snapshots: '@braintree/sanitize-url@7.1.1': {} - '@casl/ability@6.7.5': + '@casl/ability@6.8.0': dependencies: '@ucast/mongo2js': 1.3.4 - '@casl/react@4.0.0(@casl/ability@6.7.5)(react@18.3.1)': + '@casl/react@4.0.0(@casl/ability@6.8.0)(react@18.3.1)': dependencies: - '@casl/ability': 6.7.5 + '@casl/ability': 6.8.0 react: 18.3.1 '@cfworker/json-schema@4.1.1': {} @@ -12703,58 +12651,57 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@hocuspocus/common@2.15.3': + '@hocuspocus/common@3.4.3': dependencies: lib0: 0.2.114 - '@hocuspocus/extension-redis@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': + '@hocuspocus/extension-redis@3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': dependencies: - '@hocuspocus/server': 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) - ioredis: 4.28.5 + '@hocuspocus/server': 3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) + '@sesamecare-oss/redlock': 1.4.0(ioredis@5.8.2) + ioredis: 5.8.2 kleur: 4.1.5 lodash.debounce: 4.0.8 - redlock: 4.2.0 - uuid: 11.1.0 - y-protocols: 1.0.6(yjs@13.6.27) - yjs: 13.6.27 + y-protocols: 1.0.6(yjs@13.6.29) + yjs: 13.6.29 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': + '@hocuspocus/provider@3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': dependencies: - '@hocuspocus/common': 2.15.3 + '@hocuspocus/common': 3.4.3 '@lifeomic/attempt': 3.0.3 lib0: 0.2.114 ws: 8.19.0 - y-protocols: 1.0.6(yjs@13.6.27) - yjs: 13.6.27 + y-protocols: 1.0.6(yjs@13.6.29) + yjs: 13.6.29 transitivePeerDependencies: - bufferutil - utf-8-validate - '@hocuspocus/server@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': + '@hocuspocus/server@3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': dependencies: - '@hocuspocus/common': 2.15.3 + '@hocuspocus/common': 3.4.3 async-lock: 1.4.1 + async-mutex: 0.5.0 kleur: 4.1.5 lib0: 0.2.114 - uuid: 11.1.0 ws: 8.19.0 - y-protocols: 1.0.6(yjs@13.6.27) - yjs: 13.6.27 + y-protocols: 1.0.6(yjs@13.6.29) + yjs: 13.6.29 transitivePeerDependencies: - bufferutil - utf-8-validate - '@hocuspocus/transformer@2.15.3(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27)': + '@hocuspocus/transformer@3.4.3(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 - '@tiptap/starter-kit': 2.27.1 - y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) - yjs: 13.6.27 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + '@tiptap/starter-kit': 3.17.1 + y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) + yjs: 13.6.29 '@humanfs/core@0.19.1': {} @@ -13777,8 +13724,6 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@popperjs/core@2.11.8': {} - '@radix-ui/primitive@1.1.1': {} '@radix-ui/react-arrow@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -14202,6 +14147,10 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 + '@sesamecare-oss/redlock@1.4.0(ioredis@5.8.2)': + dependencies: + ioredis: 5.8.2 + '@sinclair/typebox@0.27.8': {} '@sindresorhus/slugify@1.1.0': @@ -14652,212 +14601,195 @@ snapshots: '@tanstack/query-core': 5.90.17 react: 18.3.1 - '@tiptap/core@2.27.1(@tiptap/pm@2.27.1)': + '@tiptap/core@3.17.1(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/pm': 2.27.1 + '@tiptap/pm': 3.17.1 - '@tiptap/extension-blockquote@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-blockquote@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-bold@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-bold@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-bubble-menu@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-bubble-menu@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 - tippy.js: 6.3.7 + '@floating-ui/dom': 1.7.4 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + optional: true - '@tiptap/extension-bullet-list@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-bullet-list@3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-character-count@2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-code-block@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-code-block-lowlight@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/extension-code-block@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(highlight.js@11.11.1)(lowlight@3.3.0)': + '@tiptap/extension-code@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/extension-code-block': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 - highlight.js: 11.11.1 - lowlight: 3.3.0 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-code-block@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-collaboration-caret@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + '@tiptap/y-tiptap': 3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) - '@tiptap/extension-code@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-collaboration@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + '@tiptap/y-tiptap': 3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) + yjs: 13.6.29 - '@tiptap/extension-collaboration-cursor@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))': + '@tiptap/extension-color@3.17.1(@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + '@tiptap/extension-text-style': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) - '@tiptap/extension-collaboration@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))': + '@tiptap/extension-document@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 - y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-color@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/extension-text-style@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)))': + '@tiptap/extension-dropcursor@3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/extension-text-style': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + '@tiptap/extensions': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-document@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-floating-menu@3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@floating-ui/dom': 1.7.3 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + optional: true - '@tiptap/extension-dropcursor@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-gapcursor@3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/extensions': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-floating-menu@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-hard-break@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 - tippy.js: 6.3.7 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-gapcursor@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-heading@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-hard-break@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-highlight@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-heading@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-history@3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extensions': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-highlight@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-horizontal-rule@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-history@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-image@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-horizontal-rule@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-italic@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-image@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-link@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - - '@tiptap/extension-italic@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': - dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - - '@tiptap/extension-link@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': - dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 linkifyjs: 4.3.2 - '@tiptap/extension-list-item@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-list-item@3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-list-keymap@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-list-keymap@3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-ordered-list@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-paragraph@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-ordered-list@3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-placeholder@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-paragraph@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-strike@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-placeholder@3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extensions': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-subscript@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-strike@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-superscript@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-subscript@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-table-cell@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-superscript@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-table-header@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-table@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-table-row@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-text-align@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-table@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-task-item@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-text@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-task-list@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-typography@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-text-align@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-underline@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-text-style@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-unique-id@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + uuid: 10.0.0 - '@tiptap/extension-text@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-youtube@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-typography@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-underline@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/html@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(happy-dom@20.1.0)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + happy-dom: 20.1.0 - '@tiptap/extension-youtube@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': - dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - - '@tiptap/html@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': - dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 - zeed-dom: 0.15.1 - - '@tiptap/pm@2.27.1': + '@tiptap/pm@3.17.1': dependencies: prosemirror-changeset: 2.3.1 prosemirror-collab: 1.3.1 @@ -14871,53 +14803,70 @@ snapshots: prosemirror-menu: 1.2.4 prosemirror-model: 1.25.1 prosemirror-schema-basic: 1.2.3 - prosemirror-schema-list: 1.4.1 + prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.3 prosemirror-tables: 1.7.1 prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0) prosemirror-transform: 1.10.4 prosemirror-view: 1.40.0 - '@tiptap/react@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tiptap/react@3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/extension-bubble-menu': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-floating-menu': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 '@types/use-sync-external-store': 0.0.6 - fast-deep-equal: 3.1.3 + fast-equals: 5.3.4 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - use-sync-external-store: 1.2.2(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-floating-menu': 3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + transitivePeerDependencies: + - '@floating-ui/dom' - '@tiptap/starter-kit@2.27.1': + '@tiptap/starter-kit@3.17.1': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/extension-blockquote': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-bold': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-bullet-list': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-code': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-code-block': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-document': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-dropcursor': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-gapcursor': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-hard-break': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-heading': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-history': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-horizontal-rule': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-italic': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-list-item': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-ordered-list': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-paragraph': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-strike': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-text': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-text-style': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/extension-blockquote': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-bold': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-bullet-list': 3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-code': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-code-block': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-document': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-dropcursor': 3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-gapcursor': 3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-hard-break': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-heading': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-horizontal-rule': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-italic': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-link': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-list-item': 3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-list-keymap': 3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-ordered-list': 3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-paragraph': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-strike': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-text': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-underline': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extensions': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/suggestion@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/suggestion@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + + '@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': + dependencies: + lib0: 0.2.114 + prosemirror-model: 1.25.1 + prosemirror-state: 1.4.3 + prosemirror-view: 1.40.0 + y-protocols: 1.0.6(yjs@13.6.29) + yjs: 13.6.29 '@tokenizer/inflate@0.4.1': dependencies: @@ -15844,6 +15793,10 @@ snapshots: async-lock@1.4.1: {} + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + async@3.2.5: {} asynckit@0.4.0: {} @@ -16026,8 +15979,6 @@ snapshots: bluebird@3.4.7: {} - bluebird@3.7.2: {} - boolbase@1.0.0: {} bowser@2.11.0: {} @@ -16778,8 +16729,6 @@ snapshots: delayed-stream@1.0.0: {} - denque@1.5.1: {} - denque@2.1.0: {} depd@2.0.0: {} @@ -16967,8 +16916,6 @@ snapshots: entities@4.5.0: {} - entities@5.0.0: {} - entities@6.0.1: {} env-paths@2.2.1: {} @@ -17356,6 +17303,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.3.4: {} + fast-glob@3.3.2: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -17834,6 +17783,8 @@ snapshots: dependencies: pica: 7.1.1 + image-dimensions@2.5.0: {} + image-size@0.5.5: optional: true @@ -17878,22 +17829,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis@4.28.5: - dependencies: - cluster-key-slot: 1.1.2 - debug: 4.4.1 - denque: 1.5.1 - lodash.defaults: 4.2.0 - lodash.flatten: 4.4.0 - lodash.isarguments: 3.1.0 - p-map: 2.1.0 - redis-commands: 1.7.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - ioredis@5.4.1: dependencies: '@ioredis/commands': 1.2.0 @@ -18744,10 +18679,6 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lib0@0.2.108: - dependencies: - isomorphic.js: 0.2.5 - lib0@0.2.114: dependencies: isomorphic.js: 0.2.5 @@ -18804,8 +18735,6 @@ snapshots: lodash.defaults@4.2.0: {} - lodash.flatten@4.4.0: {} - lodash.includes@4.3.0: {} lodash.isarguments@3.1.0: {} @@ -19547,8 +19476,6 @@ snapshots: dependencies: p-limit: 3.1.0 - p-map@2.1.0: {} - p-queue@6.6.2: dependencies: eventemitter3: 4.0.7 @@ -20007,7 +19934,7 @@ snapshots: dependencies: prosemirror-model: 1.25.1 - prosemirror-schema-list@1.4.1: + prosemirror-schema-list@1.5.1: dependencies: prosemirror-model: 1.25.1 prosemirror-state: 1.4.3 @@ -20312,18 +20239,12 @@ snapshots: dependencies: resolve: 1.22.8 - redis-commands@1.7.0: {} - redis-errors@1.2.0: {} redis-parser@3.0.0: dependencies: redis-errors: 1.2.0 - redlock@4.2.0: - dependencies: - bluebird: 3.7.2 - redux@4.2.1: dependencies: '@babel/runtime': 7.25.6 @@ -20967,10 +20888,6 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tippy.js@6.3.7: - dependencies: - '@popperjs/core': 2.11.8 - tiptap-extension-global-drag-handle@0.1.18: {} tldts-core@6.1.72: {} @@ -21311,6 +21228,10 @@ snapshots: dependencies: react: 18.3.1 + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + utf8-byte-length@1.0.4: {} util-deprecate@1.0.2: {} @@ -21588,24 +21509,24 @@ snapshots: xtend@4.0.2: optional: true - y-indexeddb@9.0.12(yjs@13.6.27): + y-indexeddb@9.0.12(yjs@13.6.29): dependencies: lib0: 0.2.88 - yjs: 13.6.27 + yjs: 13.6.29 - y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27): + y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29): dependencies: lib0: 0.2.114 prosemirror-model: 1.25.1 prosemirror-state: 1.4.3 prosemirror-view: 1.40.0 - y-protocols: 1.0.6(yjs@13.6.27) - yjs: 13.6.27 + y-protocols: 1.0.6(yjs@13.6.29) + yjs: 13.6.29 - y-protocols@1.0.6(yjs@13.6.27): + y-protocols@1.0.6(yjs@13.6.29): dependencies: lib0: 0.2.114 - yjs: 13.6.27 + yjs: 13.6.29 y18n@4.0.3: {} @@ -21655,9 +21576,9 @@ snapshots: buffer-crc32: 0.2.13 pend: 1.2.0 - yjs@13.6.27: + yjs@13.6.29: dependencies: - lib0: 0.2.108 + lib0: 0.2.114 yn@3.1.1: {} @@ -21667,11 +21588,6 @@ snapshots: yoctocolors-cjs@2.1.2: {} - zeed-dom@0.15.1: - dependencies: - css-what: 6.1.0 - entities: 5.0.0 - zod@3.25.76: {} zod@4.3.5: {} From aa6a046aa610ef8d29c7b222deb48ef87bd422a3 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 24 Jan 2026 23:30:17 +0000 Subject: [PATCH 12/60] feat(export): add export loading state and copy as markdown (#1867) * feat: add loading state to export * feat: copy as markdown * preserve taskList comment --- .../public/locales/en-US/translation.json | 2 + .../src/components/common/export-modal.tsx | 9 ++- .../components/header/page-header-menu.tsx | 18 ++++++ .../src/integrations/export/export.service.ts | 4 +- package.json | 1 + packages/editor-ext/.prettierrc | 4 ++ packages/editor-ext/src/lib/markdown/index.ts | 1 + .../src/lib/markdown/utils/turndown.d.ts | 12 ++++ .../src/lib/markdown/utils/turndown.utils.ts | 64 ++++++++++--------- pnpm-lock.yaml | 8 +++ 10 files changed, 89 insertions(+), 34 deletions(-) create mode 100644 packages/editor-ext/.prettierrc create mode 100644 packages/editor-ext/src/lib/markdown/utils/turndown.d.ts rename apps/server/src/integrations/export/turndown-utils.ts => packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts (70%) diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 0cdfbee0..c0578d2b 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred page width.": "Choose your preferred page width.", "Confirm": "Confirm", + "Copy as Markdown": "Copy as Markdown", "Copy link": "Copy link", "Create": "Create", "Create group": "Create group", @@ -253,6 +254,7 @@ "Export failed:": "Export failed:", "export error": "export error", "Export page": "Export page", + "Export successful": "Export successful", "Export space": "Export space", "Export {{type}}": "Export {{type}}", "File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit", diff --git a/apps/client/src/components/common/export-modal.tsx b/apps/client/src/components/common/export-modal.tsx index 25f4d328..53de8246 100644 --- a/apps/client/src/components/common/export-modal.tsx +++ b/apps/client/src/components/common/export-modal.tsx @@ -30,9 +30,11 @@ export default function ExportModal({ const [format, setFormat] = useState(ExportFormat.Markdown); const [includeChildren, setIncludeChildren] = useState(false); const [includeAttachments, setIncludeAttachments] = useState(false); + const [isExporting, setIsExporting] = useState(false); const { t } = useTranslation(); const handleExport = async () => { + setIsExporting(true); try { if (type === "page") { await exportPage({ @@ -45,6 +47,9 @@ export default function ExportModal({ if (type === "space") { await exportSpace({ spaceId: id, format, includeAttachments }); } + notifications.show({ + message: t("Export successful"), + }); onClose(); } catch (err) { notifications.show({ @@ -52,6 +57,8 @@ export default function ExportModal({ color: "red", }); console.error("export error", err); + } finally { + setIsExporting(false); } }; @@ -136,7 +143,7 @@ export default function ExportModal({ - + diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 6e9625b1..9cd4362d 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -7,6 +7,7 @@ import { IconHistory, IconLink, IconList, + IconMarkdown, IconMessage, IconPrinter, IconTrash, @@ -28,6 +29,7 @@ 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, @@ -129,6 +131,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { notifications.show({ message: t("Link copied") }); }; + const handleCopyAsMarkdown = () => { + if (!pageEditor) return; + const html = pageEditor.getHTML(); + const markdown = htmlToMarkdown(html); + const title = page?.title ? `# ${page.title}\n\n` : ""; + clipboard.copy(`${title}${markdown}`); + notifications.show({ message: t("Copied") }); + }; + const handlePrint = () => { setTimeout(() => { window.print(); @@ -166,6 +177,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { > {t("Copy link")} + + } + onClick={handleCopyAsMarkdown} + > + {t("Copy as Markdown")} + }> diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index 91b84250..e33ac11b 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -5,7 +5,6 @@ import { NotFoundException, } from '@nestjs/common'; import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util'; -import { turndown } from './turndown-utils'; import { ExportFormat } from './dto/export-dto'; import { Page } from '@docmost/db/types/entity.types'; import { InjectKysely } from 'nestjs-kysely'; @@ -31,6 +30,7 @@ import { getAttachmentIds, getProsemirrorContent, } from '../../common/helpers/prosemirror/utils'; +import { htmlToMarkdown } from '@docmost/editor-ext'; @Injectable() export class ExportService { @@ -83,7 +83,7 @@ export class ExportService { /]*>[\s\S]*?<\/colgroup>/gim, '', ); - return turndown(newPageHtml); + return htmlToMarkdown(newPageHtml); } return; diff --git a/package.json b/package.json index 6a5103e8..2b5096ef 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "devDependencies": { "@nx/js": "20.4.5", "@types/bytes": "^3.1.5", + "@types/turndown": "^5.0.6", "@types/uuid": "^10.0.0", "concurrently": "^9.1.2", "nx": "20.4.5", diff --git a/packages/editor-ext/.prettierrc b/packages/editor-ext/.prettierrc new file mode 100644 index 00000000..dcb72794 --- /dev/null +++ b/packages/editor-ext/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/packages/editor-ext/src/lib/markdown/index.ts b/packages/editor-ext/src/lib/markdown/index.ts index 96daf9c9..26eb5d48 100644 --- a/packages/editor-ext/src/lib/markdown/index.ts +++ b/packages/editor-ext/src/lib/markdown/index.ts @@ -1 +1,2 @@ export * from "./utils/marked.utils"; +export * from "./utils/turndown.utils"; diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.d.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.d.ts new file mode 100644 index 00000000..0e8a9a2d --- /dev/null +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.d.ts @@ -0,0 +1,12 @@ +// Map @joplin/turndown types to @types/turndown +declare module "@joplin/turndown" { + import TurndownService from "turndown"; + export = TurndownService; +} + +declare module "@joplin/turndown-plugin-gfm" { + import TurndownService from "turndown"; + export const tables: TurndownService.Plugin; + export const strikethrough: TurndownService.Plugin; + export const highlightedCodeBlock: TurndownService.Plugin; +} diff --git a/apps/server/src/integrations/export/turndown-utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts similarity index 70% rename from apps/server/src/integrations/export/turndown-utils.ts rename to packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts index b20e6733..0f84aa40 100644 --- a/apps/server/src/integrations/export/turndown-utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts @@ -1,22 +1,21 @@ -import * as TurndownService from '@joplin/turndown'; +import * as _TurndownService from '@joplin/turndown'; import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm'; -import * as path from 'path'; -export function turndown(html: string): string { +// CJS/ESM interop: .default exists in Vite, not in NestJS +const TurndownService = (_TurndownService as any).default || _TurndownService; + +export function htmlToMarkdown(html: string): string { const turndownService = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', hr: '---', bulletListMarker: '-', }); - const tables = TurndownPluginGfm.tables; - const strikethrough = TurndownPluginGfm.strikethrough; - const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock; turndownService.use([ - tables, - strikethrough, - highlightedCodeBlock, + TurndownPluginGfm.tables, + TurndownPluginGfm.strikethrough, + TurndownPluginGfm.highlightedCodeBlock, taskList, callout, preserveDetail, @@ -29,34 +28,33 @@ export function turndown(html: string): string { return turndownService.turndown(html).replaceAll('
', ' '); } -function listParagraph(turndownService: TurndownService) { +function listParagraph(turndownService: _TurndownService) { turndownService.addRule('paragraph', { filter: ['p'], - replacement: (content: any, node: HTMLInputElement) => { + replacement: (content: string, node: HTMLInputElement) => { if (node.parentElement?.nodeName === 'LI') { return content; } - return `\n\n${content}\n\n`; }, }); } -function callout(turndownService: TurndownService) { +function callout(turndownService: _TurndownService) { turndownService.addRule('callout', { filter: function (node: HTMLInputElement) { return ( node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout' ); }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (content: string, node: HTMLInputElement) { const calloutType = node.getAttribute('data-callout-type'); return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`; }, }); } -function taskList(turndownService: TurndownService) { +function taskList(turndownService: _TurndownService) { turndownService.addRule('taskListItem', { filter: function (node: HTMLInputElement) { return ( @@ -64,32 +62,36 @@ function taskList(turndownService: TurndownService) { node.parentNode.nodeName === 'UL' ); }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (content: string, node: HTMLInputElement) { const checkbox = node.querySelector( 'input[type="checkbox"]', ) as HTMLInputElement; const isChecked = checkbox.checked; - + // Process content like regular list items content = content .replace(/^\n+/, '') // remove leading newlines .replace(/\n+$/, '\n') // replace trailing newlines with just a single one .replace(/\n/gm, '\n '); // indent nested content with 2 spaces - + // Create the checkbox prefix const prefix = `- ${isChecked ? '[x]' : '[ ]'} `; - - return prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : ''); + + return ( + prefix + + content + + (node.nextSibling && !/\n$/.test(content) ? '\n' : '') + ); }, }); } -function preserveDetail(turndownService: TurndownService) { +function preserveDetail(turndownService: _TurndownService) { turndownService.addRule('preserveDetail', { filter: function (node: HTMLInputElement) { return node.nodeName === 'DETAILS'; }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (_content: string, node: HTMLInputElement) { const summary = node.querySelector(':scope > summary'); let detailSummary = ''; @@ -111,7 +113,7 @@ function preserveDetail(turndownService: TurndownService) { }); } -function mathInline(turndownService: TurndownService) { +function mathInline(turndownService: _TurndownService) { turndownService.addRule('mathInline', { filter: function (node: HTMLInputElement) { return ( @@ -119,13 +121,13 @@ function mathInline(turndownService: TurndownService) { node.getAttribute('data-type') === 'mathInline' ); }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (content: string) { return `$${content}$`; }, }); } -function mathBlock(turndownService: TurndownService) { +function mathBlock(turndownService: _TurndownService) { turndownService.addRule('mathBlock', { filter: function (node: HTMLInputElement) { return ( @@ -133,32 +135,32 @@ function mathBlock(turndownService: TurndownService) { node.getAttribute('data-type') === 'mathBlock' ); }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (content: string) { return `\n$$\n${content}\n$$\n`; }, }); } -function iframeEmbed(turndownService: TurndownService) { +function iframeEmbed(turndownService: _TurndownService) { turndownService.addRule('iframeEmbed', { filter: function (node: HTMLInputElement) { return node.nodeName === 'IFRAME'; }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (_content: string, node: HTMLInputElement) { const src = node.getAttribute('src'); return '[' + src + '](' + src + ')'; }, }); } -function video(turndownService: TurndownService) { +function video(turndownService: _TurndownService) { turndownService.addRule('video', { filter: function (node: HTMLInputElement) { return node.tagName === 'VIDEO'; }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (_content: string, node: HTMLInputElement) { const src = node.getAttribute('src') || ''; - const name = path.basename(src); + const name = src.split('/').pop() || src; return '[' + name + '](' + src + ')'; }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b5e98be..e3904280 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: '@types/bytes': specifier: ^3.1.5 version: 3.1.5 + '@types/turndown': + specifier: ^5.0.6 + version: 5.0.6 '@types/uuid': specifier: ^10.0.0 version: 10.0.0 @@ -4812,6 +4815,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -15274,6 +15280,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/turndown@5.0.6': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.2': {} From 55b81288297f1d1a4216dffd2144d827a5057771 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 24 Jan 2026 23:35:04 +0000 Subject: [PATCH 13/60] Fix Google sheets regex --- packages/editor-ext/src/lib/embed-provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ext/src/lib/embed-provider.ts b/packages/editor-ext/src/lib/embed-provider.ts index 7a91ae9f..4c286a8b 100644 --- a/packages/editor-ext/src/lib/embed-provider.ts +++ b/packages/editor-ext/src/lib/embed-provider.ts @@ -99,7 +99,7 @@ export const embedProviders: IEmbedProvider[] = [ id: "gsheets", name: "Google Sheets", regex: - /^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/e\/([a-zA-Z0-9_-]+)\/.*$/, + /^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/([a-zA-Z0-9_-]+)\/.*$/, getEmbedUrl: (match, url: string) => { return url; }, From 5588ec34fb945ec7dbbc131c8dc4d2303f06b96c Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 25 Jan 2026 00:04:50 +0000 Subject: [PATCH 14/60] New Crowdin updates (#1866) * New translations translation.json (Japanese) * New translations translation.json (French) * New translations translation.json (Spanish) * New translations translation.json (German) * New translations translation.json (Italian) * New translations translation.json (Korean) * New translations translation.json (Dutch) * New translations translation.json (Russian) * New translations translation.json (Ukrainian) * New translations translation.json (Chinese Simplified) * New translations translation.json (Portuguese, Brazilian) * New translations translation.json (Japanese) * New translations translation.json (French) * New translations translation.json (Spanish) * New translations translation.json (German) * New translations translation.json (Italian) * New translations translation.json (Korean) * New translations translation.json (Dutch) * New translations translation.json (Russian) * New translations translation.json (Ukrainian) * New translations translation.json (Chinese Simplified) * New translations translation.json (English) * New translations translation.json (Portuguese, Brazilian) --- apps/client/public/locales/de-DE/translation.json | 6 ++++++ apps/client/public/locales/es-ES/translation.json | 4 ++++ apps/client/public/locales/fr-FR/translation.json | 4 ++++ apps/client/public/locales/it-IT/translation.json | 4 ++++ apps/client/public/locales/ja-JP/translation.json | 4 ++++ apps/client/public/locales/ko-KR/translation.json | 4 ++++ apps/client/public/locales/nl-NL/translation.json | 4 ++++ apps/client/public/locales/pt-BR/translation.json | 4 ++++ apps/client/public/locales/ru-RU/translation.json | 4 ++++ apps/client/public/locales/uk-UA/translation.json | 4 ++++ apps/client/public/locales/zh-CN/translation.json | 4 ++++ 11 files changed, 46 insertions(+) diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index 1763e428..34fb5bb4 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.", "Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.", "Confirm": "Bestätigen", + "Copy as Markdown": "Copy as Markdown", "Copy link": "Link kopieren", "Create": "Erstellen", "Create group": "Gruppe erstellen", @@ -234,7 +235,9 @@ "Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.", "Invite link": "Einladungslink", "Copy": "Kopieren", + "Copy to space": "In Raum kopieren", "Copied": "Kopiert", + "Duplicate": "Duplizieren", "Select a user": "Benutzer auswählen", "Select a group": "Gruppe auswählen", "Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.", @@ -251,6 +254,7 @@ "Export failed:": "Export fehlgeschlagen:", "export error": "Exportfehler", "Export page": "Seite exportieren", + "Export successful": "Export successful", "Export space": "Bereich exportieren", "Export {{type}}": "Exportiere {{type}}", "File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}", @@ -326,6 +330,8 @@ "Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.", "Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.", "Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "Tabelle", "Insert a table.": "Tabelle einfügen.", "Insert collapsible block.": "Einklappbaren Block einfügen.", diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index f99e8541..d68f64a7 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.", "Choose your preferred page width.": "Elige el ancho de página que prefieras.", "Confirm": "Confirmar", + "Copy as Markdown": "Copy as Markdown", "Copy link": "Copiar enlace", "Create": "Crear", "Create group": "Crear grupo", @@ -253,6 +254,7 @@ "Export failed:": "Exportación fallida:", "export error": "error de exportación", "Export page": "Exportar página", + "Export successful": "Export successful", "Export space": "Exportar espacio", "Export {{type}}": "Exportar {{type}}", "File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.", "Upload any video from your device.": "Sube cualquier video desde tu dispositivo.", "Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "Tabla", "Insert a table.": "Insertar una tabla.", "Insert collapsible block.": "Insertar bloque desplegable.", diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 5644d719..4a70b735 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.", "Choose your preferred page width.": "Choisissez votre largeur de page préférée.", "Confirm": "Confirmer", + "Copy as Markdown": "Copy as Markdown", "Copy link": "Copier le lien", "Create": "Créer", "Create group": "Créer groupe", @@ -253,6 +254,7 @@ "Export failed:": "Échec de l'exportation :", "export error": "exporter l'erreur", "Export page": "Exporter la page", + "Export successful": "Export successful", "Export space": "Exporter l'espace", "Export {{type}}": "Exporter {{type}}", "File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.", "Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.", "Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "Tableau", "Insert a table.": "Insérez un tableau.", "Insert collapsible block.": "Insérer un bloc repliable.", diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index 8d00f451..a716a86a 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.", "Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.", "Confirm": "Conferma", + "Copy as Markdown": "Copy as Markdown", "Copy link": "Copia link", "Create": "Crea", "Create group": "Crea gruppo", @@ -253,6 +254,7 @@ "Export failed:": "Esportazione fallita:", "export error": "errore di esportazione", "Export page": "Esporta pagina", + "Export successful": "Export successful", "Export space": "Esporta spazio", "Export {{type}}": "Esporta {{type}}", "File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.", "Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.", "Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "Tabella", "Insert a table.": "Inserisci una tabella.", "Insert collapsible block.": "Inserisci blocco comprimibile.", diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index 2c3cce5a..be341728 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "お好みの言語を選択してください", "Choose your preferred page width.": "お好みのページ幅を選択してください", "Confirm": "確認", + "Copy as Markdown": "Copy as Markdown", "Copy link": "リンクをコピー", "Create": "新規作成", "Create group": "グループを作成", @@ -253,6 +254,7 @@ "Export failed:": "エクスポートに失敗しました:", "export error": "エクスポートエラー", "Export page": "エクスポートページ", + "Export successful": "Export successful", "Export space": "エクスポートスペース", "Export {{type}}": "{{type}}をエクスポート", "File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています", @@ -328,6 +330,8 @@ "Upload any image from your device.": "デバイスから画像をアップロードします", "Upload any video from your device.": "デバイスから動画をアップロードします", "Upload any file from your device.": "デバイスからファイルをアップロードします", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "テーブル", "Insert a table.": "テーブルを挿入します", "Insert collapsible block.": "折りたたみブロックを挿入します", diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index 6e1f5b24..6e83db5f 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.", "Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.", "Confirm": "확인", + "Copy as Markdown": "Copy as Markdown", "Copy link": "링크 복사", "Create": "생성", "Create group": "팀 생성", @@ -253,6 +254,7 @@ "Export failed:": "내보내기 실패:", "export error": "내보내기 오류", "Export page": "페이지 내보내기", + "Export successful": "Export successful", "Export space": "Space 내보내기", "Export {{type}}": "{{type}} 내보내기", "File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다", @@ -328,6 +330,8 @@ "Upload any image from your device.": "기기에서 이미지를 업로드하세요.", "Upload any video from your device.": "기기에서 비디오를 업로드하세요.", "Upload any file from your device.": "기기에서 파일을 업로드하세요.", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "테이블", "Insert a table.": "테이블 삽입.", "Insert collapsible block.": "접을 수 있는 블록 삽입.", diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index 7db6836d..9c16efe3 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Kies uw gewenste interfacetaal.", "Choose your preferred page width.": "Kies uw gewenste paginabreedte.", "Confirm": "Bevestig", + "Copy as Markdown": "Copy as Markdown", "Copy link": "Link kopiëren", "Create": "Aanmaken", "Create group": "Groep aanmaken", @@ -253,6 +254,7 @@ "Export failed:": "Exporteren mislukt:", "export error": "Exporteer fout", "Export page": "Exporteer pagina", + "Export successful": "Export successful", "Export space": "Exporteer ruimte", "Export {{type}}": "Exporteer {{type}}", "File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.", "Upload any video from your device.": "Upload een video vanaf uw apparaat.", "Upload any file from your device.": "Upload een bestand vanaf uw apparaat.", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "Tabel", "Insert a table.": "Voeg een tabel in.", "Insert collapsible block.": "Inklapbaar blok invoegen.", diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index 5d11ec7a..eb1442b2 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Escolha o idioma da interface.", "Choose your preferred page width.": "Escolha a largura preferida da página.", "Confirm": "Confirmar", + "Copy as Markdown": "Copy as Markdown", "Copy link": "Copiar link", "Create": "Criar", "Create group": "Criar grupo", @@ -253,6 +254,7 @@ "Export failed:": "Falha ao exportar:", "export error": "erro de exportação", "Export page": "Exportar página", + "Export successful": "Export successful", "Export space": "Exportar espaço", "Export {{type}}": "Exportar para {{type}}", "File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.", "Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.", "Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "Tabela", "Insert a table.": "Insira uma tabela.", "Insert collapsible block.": "Insira um bloco colapsável.", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index f1a9cd85..b39d13a4 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.", "Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.", "Confirm": "Подтвердить", + "Copy as Markdown": "Copy as Markdown", "Copy link": "Копировать ссылку", "Create": "Создать", "Create group": "Создать группу", @@ -253,6 +254,7 @@ "Export failed:": "Экспортирование не удалось:", "export error": "ошибка экспорта", "Export page": "Экспорт страницы", + "Export successful": "Export successful", "Export space": "Экспорт пространства", "Export {{type}}": "Экспорт {{type}}", "File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Загрузить любое изображение с вашего устройства.", "Upload any video from your device.": "Загрузить любое видео с вашего устройства.", "Upload any file from your device.": "Загрузить любой файл с вашего устройства.", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "Таблица", "Insert a table.": "Вставить таблицу.", "Insert collapsible block.": "Вставить сворачиваемый блок.", diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index 2fb44ad1..2460f38f 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.", "Choose your preferred page width.": "Оберіть бажану ширину сторінки.", "Confirm": "Підтвердити", + "Copy as Markdown": "Copy as Markdown", "Copy link": "Копіювати посилання", "Create": "Створити", "Create group": "Створити групу", @@ -253,6 +254,7 @@ "Export failed:": "Експортування не вдалося:", "export error": "помилка експорту", "Export page": "Експорт сторінки", + "Export successful": "Export successful", "Export space": "Експорт простору", "Export {{type}}": "Експорт {{type}}", "File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.", "Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.", "Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "Таблиця", "Insert a table.": "Вставити таблицю.", "Insert collapsible block.": "Вставити блок, що згортається.", diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index d4b25deb..ed26024f 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "选择您喜欢的界面语言。", "Choose your preferred page width.": "选择您喜欢的页面宽度。", "Confirm": "确认", + "Copy as Markdown": "Copy as Markdown", "Copy link": "复制链接", "Create": "创建", "Create group": "创建群组", @@ -253,6 +254,7 @@ "Export failed:": "导出失败:", "export error": "导出出错", "Export page": "导出页面", + "Export successful": "Export successful", "Export space": "导出空间", "Export {{type}}": "导出为 {{type}}", "File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制", @@ -328,6 +330,8 @@ "Upload any image from your device.": "从设备上传任何图像", "Upload any video from your device.": "从设备上传任何视频", "Upload any file from your device.": "从设备上传任何文件", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "表格", "Insert a table.": "插入一个表格", "Insert collapsible block.": "插入一个折叠块", From 5dbf0027bdaa52cf5c73740bc0e85fedbff09d59 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 25 Jan 2026 00:08:02 +0000 Subject: [PATCH 15/60] Add isomorphic basename utility --- .../src/lib/markdown/utils/basename.ts | 29 +++++++++++++++++++ .../src/lib/markdown/utils/turndown.utils.ts | 3 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 packages/editor-ext/src/lib/markdown/utils/basename.ts diff --git a/packages/editor-ext/src/lib/markdown/utils/basename.ts b/packages/editor-ext/src/lib/markdown/utils/basename.ts new file mode 100644 index 00000000..503de941 --- /dev/null +++ b/packages/editor-ext/src/lib/markdown/utils/basename.ts @@ -0,0 +1,29 @@ +/** + * Flexible `basename` implementation for node and the browser + * @see https://stackoverflow.com/a/59907288/2228771 + */ +export function getBasename(path: string) { + // make sure the basename is not empty, if string ends with separator + let end = path.length - 1; + while (path[end] === '/' || path[end] === '\\') { + --end; + } + + // support mixing of Win + Unix path separators + const i1 = path.lastIndexOf('/', end); + const i2 = path.lastIndexOf('\\', end); + + let start: number; + if (i1 === -1) { + if (i2 === -1) { + // no separator in the whole thing + return path; + } + start = i2; + } else if (i2 === -1) { + start = i1; + } else { + start = Math.max(i1, i2); + } + return path.substring(start + 1, end + 1); +} diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts index 0f84aa40..71a2b512 100644 --- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts @@ -1,5 +1,6 @@ import * as _TurndownService from '@joplin/turndown'; import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm'; +import { getBasename } from './basename'; // CJS/ESM interop: .default exists in Vite, not in NestJS const TurndownService = (_TurndownService as any).default || _TurndownService; @@ -160,7 +161,7 @@ function video(turndownService: _TurndownService) { }, replacement: function (_content: string, node: HTMLInputElement) { const src = node.getAttribute('src') || ''; - const name = src.split('/').pop() || src; + const name = getBasename(src) || src; return '[' + name + '](' + src + ')'; }, }); From 54775f537d3aaca3df373b7528bde33e07016107 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 25 Jan 2026 00:48:43 +0000 Subject: [PATCH 16/60] fix: handle malformed URLs gracefully during import/export (#1868) * Handling malformed URLs gracefully * Allow import of invalid URLs, but adding logging. --------- Co-authored-by: gpapp --- .../features/page/services/page-service.ts | 9 ++++++++- .../features/space/services/space-service.ts | 9 ++++++++- apps/server/src/integrations/export/utils.ts | 20 ++++++++++++++----- .../import/utils/import-formatter.ts | 13 +++++++++++- .../integrations/import/utils/import.utils.ts | 10 ++++++++-- 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index 8d76438a..c5b6f252 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -118,7 +118,14 @@ export async function exportPage(data: IExportPageParams): Promise { .split("filename=")[1] .replace(/"/g, ""); - saveAs(req.data, decodeURIComponent(fileName)); + let decodedFileName = fileName; + try { + decodedFileName = decodeURIComponent(fileName); + } catch (err) { + // fallback to raw filename + } + + saveAs(req.data, decodedFileName); } export async function importPage(file: File, spaceId: string) { diff --git a/apps/client/src/features/space/services/space-service.ts b/apps/client/src/features/space/services/space-service.ts index f9894099..fb6987ad 100644 --- a/apps/client/src/features/space/services/space-service.ts +++ b/apps/client/src/features/space/services/space-service.ts @@ -69,5 +69,12 @@ export async function exportSpace(data: IExportSpaceParams): Promise { .split("filename=")[1] .replace(/"/g, ""); - saveAs(req.data, decodeURIComponent(fileName)); + let decodedFileName = fileName; + try { + decodedFileName = decodeURIComponent(fileName); + } catch (err) { + // fallback to raw filename + } + + saveAs(req.data, decodedFileName); } diff --git a/apps/server/src/integrations/export/utils.ts b/apps/server/src/integrations/export/utils.ts index fe1815b0..266141c2 100644 --- a/apps/server/src/integrations/export/utils.ts +++ b/apps/server/src/integrations/export/utils.ts @@ -1,4 +1,5 @@ import { jsonToNode } from 'src/collaboration/collaboration.util'; +import { Logger } from '@nestjs/common'; import { ExportFormat } from './dto/export-dto'; import { Node } from '@tiptap/pm/model'; import { validate as isValidUUID } from 'uuid'; @@ -88,7 +89,7 @@ export function replaceInternalLinks( // if link and text are same, use page title if (markLink === node.text) { //@ts-expect-error - node.text = getInternalLinkPageName(relativePath); + node.text = getInternalLinkPageName(relativePath, currentPagePath); } } } @@ -99,10 +100,19 @@ export function replaceInternalLinks( return doc.toJSON(); } -export function getInternalLinkPageName(path: string): string { - return decodeURIComponent( - path?.split('/').pop().split('.').slice(0, -1).join('.'), - ); +export function getInternalLinkPageName(path: string, currentFilePath?: string): string { + const name = path?.split('/').pop().split('.').slice(0, -1).join('.'); + try { + return decodeURIComponent(name); + } catch (err) { + if (currentFilePath) { + Logger.warn( + `URI malformed in page ${currentFilePath}: ${name}. Falling back to raw name.`, + 'ExportUtils', + ); + } + return name; + } } export function extractPageSlugId(input: string): string { diff --git a/apps/server/src/integrations/import/utils/import-formatter.ts b/apps/server/src/integrations/import/utils/import-formatter.ts index 14a2530c..59f5eeec 100644 --- a/apps/server/src/integrations/import/utils/import-formatter.ts +++ b/apps/server/src/integrations/import/utils/import-formatter.ts @@ -1,4 +1,5 @@ import { getEmbedUrlAndProvider } from '@docmost/editor-ext'; +import { Logger } from '@nestjs/common'; import * as path from 'path'; import { v7 } from 'uuid'; import { InsertableBacklink } from '@docmost/db/types/entity.types'; @@ -280,8 +281,18 @@ export async function rewriteInternalLinksToMentionHtml( const $a = $(el); const raw = $a.attr('href')!; if (raw.startsWith('http') || raw.startsWith('/api/')) return; + let decodedRaw = raw; + try { + decodedRaw = decodeURIComponent(raw); + } catch (err) { + Logger.warn( + `URI malformed in page ${currentFilePath}: ${raw}. Falling back to raw path.`, + 'ImportFormatter', + ); + } + const resolved = normalize( - path.join(path.dirname(currentFilePath), decodeURIComponent(raw)), + path.join(path.dirname(currentFilePath), decodedRaw), ); const meta = filePathToPageMetaMap.get(resolved); if (!meta) return; diff --git a/apps/server/src/integrations/import/utils/import.utils.ts b/apps/server/src/integrations/import/utils/import.utils.ts index 1fa10d7a..c8f5fe51 100644 --- a/apps/server/src/integrations/import/utils/import.utils.ts +++ b/apps/server/src/integrations/import/utils/import.utils.ts @@ -1,3 +1,4 @@ +import { Logger } from '@nestjs/common'; import { promises as fs } from 'fs'; import * as path from 'path'; @@ -30,8 +31,13 @@ export function resolveRelativeAttachmentPath( pageDir: string, attachmentCandidates: Map, ): string | null { - const mainRel = decodeURIComponent(raw.replace(/^\.?\/+/, '')); - const fallback = path.normalize(path.join(pageDir, mainRel)); + let mainRel = raw.replace(/^\.?\/+/, ''); + try { + mainRel = decodeURIComponent(mainRel); + } catch (err) { + Logger.warn(`URI malformed for attachment path: ${mainRel}. Falling back to raw path.`, 'ImportUtils'); + } + const fallback = path.normalize(path.join(pageDir, mainRel)).split(path.sep).join('/'); if (attachmentCandidates.has(mainRel)) { return mainRel; From 1e441560f67024f149dff7de90a049a5dbda4150 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 25 Jan 2026 02:15:10 +0000 Subject: [PATCH 17/60] fix production logs filter --- apps/server/src/common/logger/pino.config.ts | 23 +++++++++++++------- apps/server/src/ee | 2 +- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/server/src/common/logger/pino.config.ts b/apps/server/src/common/logger/pino.config.ts index 9d9a14f7..7299a8e9 100644 --- a/apps/server/src/common/logger/pino.config.ts +++ b/apps/server/src/common/logger/pino.config.ts @@ -5,13 +5,14 @@ const CONTEXTS_TO_IGNORE = [ 'InstanceLoader', 'RoutesResolver', 'RouterExplorer', + 'LegacyRouteConverter', 'WebSocketsController', ]; export function createPinoConfig(): Params { - const isProduction = process.env.NODE_ENV === 'production'; - const isDebugMode = process.env.DEBUG_MODE === 'true'; - const logHttp = process.env.LOG_HTTP === 'true'; + const isProduction = process.env.NODE_ENV?.toLowerCase() === 'production'; + const isDebugMode = process.env.DEBUG_MODE?.toLowerCase() === 'true'; + const logHttp = process.env.LOG_HTTP?.toLowerCase() === 'true'; const level = isProduction && !isDebugMode ? 'info' : 'debug'; @@ -32,14 +33,20 @@ export function createPinoConfig(): Params { : undefined, formatters: { level: (label) => ({ level: label }), - log: (object: Record) => { + }, + hooks: { + logMethod(inputArgs, method) { if (isProduction && !isDebugMode) { - const context = object['context'] as string | undefined; - if (context && CONTEXTS_TO_IGNORE.includes(context)) { - return { filtered: true }; + for (const arg of inputArgs) { + if (typeof arg === 'object' && arg !== null && 'context' in arg) { + const context = (arg as Record)['context']; + if (typeof context === 'string' && CONTEXTS_TO_IGNORE.includes(context)) { + return; + } + } } } - return object; + return method.apply(this, inputArgs); }, }, serializers: { diff --git a/apps/server/src/ee b/apps/server/src/ee index b6844b01..88e3d01f 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit b6844b019c3778d51ff1bb236f30284a0bf8f403 +Subproject commit 88e3d01f8135c2dbc628b9636ba91bb9ffd2f0eb From 1ca7d422034458bc30960d87706abfbe525f5b37 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 25 Jan 2026 02:49:25 +0000 Subject: [PATCH 18/60] fix switch space toggle --- .../features/space/components/sidebar/switch-space.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/client/src/features/space/components/sidebar/switch-space.tsx b/apps/client/src/features/space/components/sidebar/switch-space.tsx index 60349ecc..89e0f64e 100644 --- a/apps/client/src/features/space/components/sidebar/switch-space.tsx +++ b/apps/client/src/features/space/components/sidebar/switch-space.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { SpaceSelect } from "./space-select"; import { getSpaceUrl } from "@/lib/config"; import { Button, Popover, Text } from "@mantine/core"; -import { IconChevronDown } from "@tabler/icons-react"; +import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { useDisclosure } from "@mantine/hooks"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; @@ -21,7 +21,7 @@ export function SwitchSpace({ spaceIcon, }: SwitchSpaceProps) { const navigate = useNavigate(); - const [opened, { close, open, toggle }] = useDisclosure(false); + const [opened, { close, toggle }] = useDisclosure(false); const handleSelect = (value: string) => { if (value) { @@ -44,9 +44,9 @@ export function SwitchSpace({ variant="subtle" fullWidth justify="space-between" - rightSection={} + rightSection={opened ? : } color="gray" - onClick={open} + onClick={toggle} > Date: Sun, 25 Jan 2026 12:38:44 +0000 Subject: [PATCH 19/60] fix(tree): update sidebar-pages cache directly instead of refetching on page move (#1870) --- .../src/features/page/queries/page-query.ts | 135 ++++++++++++++++-- .../page/tree/hooks/use-tree-mutation.ts | 19 +++ .../src/features/websocket/types/types.ts | 2 + .../websocket/use-query-subscription.ts | 10 +- 4 files changed, 150 insertions(+), 16 deletions(-) diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index 64d03ddd..e7388fe4 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -163,9 +163,6 @@ export function useDeletePageMutation() { export function useMovePageMutation() { return useMutation({ mutationFn: (data) => movePage(data), - onSuccess: () => { - invalidateOnMovePage(); - }, }); } @@ -458,17 +455,127 @@ export function invalidateOnUpdatePage( }); } -export function invalidateOnMovePage() { - //for move invalidate all sidebars for now (how to do???) - //invalidate all root sidebar pages - queryClient.invalidateQueries({ - queryKey: ["root-sidebar-pages"], - }); - //invalidate all sub sidebar pages - queryClient.invalidateQueries({ - queryKey: ["sidebar-pages"], - }); - // --- +export function updateCacheOnMovePage( + spaceId: string, + pageId: string, + oldParentId: string | null, + newParentId: string | null, + pageData: Partial, +) { + // Remove page from old parent's cache + const oldQueryKey = + oldParentId === null + ? ["root-sidebar-pages", spaceId] + : ["sidebar-pages", { pageId: oldParentId, spaceId }]; + + queryClient.setQueryData>>( + 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> + >(["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>>( + 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>>>( + 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>>(key, (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((item) => + item.id === newParentId ? { ...item, hasChildren: true } : item, + ), + })), + }; + }); + }); + } } export function invalidateOnDeletePage(pageId: string) { diff --git a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts index b2a58f30..162992dd 100644 --- a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts +++ b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts @@ -16,6 +16,7 @@ import { useRemovePageMutation, useMovePageMutation, useUpdatePageMutation, + updateCacheOnMovePage, } from "@/features/page/queries/page-query.ts"; import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; @@ -175,9 +176,25 @@ export function useTreeMutation(spaceId: string) { parentPageId: args.parentId, }; + const draggedNode = args.dragNodes[0]; + const nodeData = draggedNode.data as SpaceTreeNode; + const oldParentId = nodeData.parentPageId ?? null; + const pageData = { + id: nodeData.id, + slugId: nodeData.slugId, + title: nodeData.name, + icon: nodeData.icon, + position: newPosition, + spaceId: nodeData.spaceId, + parentPageId: args.parentId, + hasChildren: nodeData.hasChildren, + }; + try { await movePageMutation.mutateAsync(payload); + updateCacheOnMovePage(spaceId, draggedNodeId, oldParentId, args.parentId, pageData); + setTimeout(() => { emit({ operation: "moveTreeNode", @@ -185,8 +202,10 @@ export function useTreeMutation(spaceId: string) { payload: { id: draggedNodeId, parentId: args.parentId, + oldParentId, index: args.index, position: newPosition, + pageData, }, }); }, 50); diff --git a/apps/client/src/features/websocket/types/types.ts b/apps/client/src/features/websocket/types/types.ts index 21561038..3bc5b941 100644 --- a/apps/client/src/features/websocket/types/types.ts +++ b/apps/client/src/features/websocket/types/types.ts @@ -45,8 +45,10 @@ export type MoveTreeNodeEvent = { payload: { id: string; parentId: string; + oldParentId: string | null; index: number; position: string; + pageData: Partial; }; }; diff --git a/apps/client/src/features/websocket/use-query-subscription.ts b/apps/client/src/features/websocket/use-query-subscription.ts index 3aa95417..faa7139f 100644 --- a/apps/client/src/features/websocket/use-query-subscription.ts +++ b/apps/client/src/features/websocket/use-query-subscription.ts @@ -8,7 +8,7 @@ import { IPagination } from "@/lib/types"; import { invalidateOnCreatePage, invalidateOnDeletePage, - invalidateOnMovePage, + updateCacheOnMovePage, invalidateOnUpdatePage, } from "../page/queries/page-query"; import { RQ_KEY } from "../comment/queries/comment-query"; @@ -41,7 +41,13 @@ export const useQuerySubscription = () => { invalidateOnCreatePage(data.payload.data); break; case "moveTreeNode": - invalidateOnMovePage(); + updateCacheOnMovePage( + data.spaceId, + data.payload.id, + data.payload.oldParentId, + data.payload.parentId, + data.payload.pageData, + ); break; case "deleteTreeNode": invalidateOnDeletePage(data.payload.node.id); From de5f71894a7994a225cc740565746b9e261a7141 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 25 Jan 2026 12:39:19 +0000 Subject: [PATCH 20/60] New Crowdin updates (#1869) * New translations translation.json (Japanese) * New translations translation.json (French) * New translations translation.json (Spanish) * New translations translation.json (German) * New translations translation.json (Italian) * New translations translation.json (Korean) * New translations translation.json (Dutch) * New translations translation.json (Russian) * New translations translation.json (Ukrainian) * New translations translation.json (Chinese Simplified) * New translations translation.json (Portuguese, Brazilian) --- apps/client/public/locales/de-DE/translation.json | 8 ++++---- apps/client/public/locales/es-ES/translation.json | 8 ++++---- apps/client/public/locales/fr-FR/translation.json | 8 ++++---- apps/client/public/locales/it-IT/translation.json | 8 ++++---- apps/client/public/locales/ja-JP/translation.json | 8 ++++---- apps/client/public/locales/ko-KR/translation.json | 8 ++++---- apps/client/public/locales/nl-NL/translation.json | 8 ++++---- apps/client/public/locales/pt-BR/translation.json | 8 ++++---- apps/client/public/locales/ru-RU/translation.json | 8 ++++---- apps/client/public/locales/uk-UA/translation.json | 8 ++++---- apps/client/public/locales/zh-CN/translation.json | 8 ++++---- 11 files changed, 44 insertions(+), 44 deletions(-) diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index 34fb5bb4..93c6f265 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -29,7 +29,7 @@ "Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.", "Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.", "Confirm": "Bestätigen", - "Copy as Markdown": "Copy as Markdown", + "Copy as Markdown": "Als Markdown kopieren", "Copy link": "Link kopieren", "Create": "Erstellen", "Create group": "Gruppe erstellen", @@ -254,7 +254,7 @@ "Export failed:": "Export fehlgeschlagen:", "export error": "Exportfehler", "Export page": "Seite exportieren", - "Export successful": "Export successful", + "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 +330,8 @@ "Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.", "Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.", "Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.", - "Uploading {{name}}": "Uploading {{name}}", - "Uploading file": "Uploading file", + "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.", diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index d68f64a7..af02c493 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -29,7 +29,7 @@ "Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.", "Choose your preferred page width.": "Elige el ancho de página que prefieras.", "Confirm": "Confirmar", - "Copy as Markdown": "Copy as Markdown", + "Copy as Markdown": "Copiar como Markdown", "Copy link": "Copiar enlace", "Create": "Crear", "Create group": "Crear grupo", @@ -254,7 +254,7 @@ "Export failed:": "Exportación fallida:", "export error": "error de exportación", "Export page": "Exportar página", - "Export successful": "Export successful", + "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 +330,8 @@ "Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.", "Upload any video from your device.": "Sube cualquier video desde tu dispositivo.", "Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.", - "Uploading {{name}}": "Uploading {{name}}", - "Uploading file": "Uploading file", + "Uploading {{name}}": "Subiendo {{name}}", + "Uploading file": "Subiendo archivo", "Table": "Tabla", "Insert a table.": "Insertar una tabla.", "Insert collapsible block.": "Insertar bloque desplegable.", diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 4a70b735..40a1e68a 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -29,7 +29,7 @@ "Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.", "Choose your preferred page width.": "Choisissez votre largeur de page préférée.", "Confirm": "Confirmer", - "Copy as Markdown": "Copy as Markdown", + "Copy as Markdown": "Copier comme Markdown", "Copy link": "Copier le lien", "Create": "Créer", "Create group": "Créer groupe", @@ -254,7 +254,7 @@ "Export failed:": "Échec de l'exportation :", "export error": "exporter l'erreur", "Export page": "Exporter la page", - "Export successful": "Export successful", + "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 +330,8 @@ "Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.", "Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.", "Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.", - "Uploading {{name}}": "Uploading {{name}}", - "Uploading file": "Uploading file", + "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.", diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index a716a86a..ff80df0f 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -29,7 +29,7 @@ "Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.", "Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.", "Confirm": "Conferma", - "Copy as Markdown": "Copy as Markdown", + "Copy as Markdown": "Copia come Markdown", "Copy link": "Copia link", "Create": "Crea", "Create group": "Crea gruppo", @@ -254,7 +254,7 @@ "Export failed:": "Esportazione fallita:", "export error": "errore di esportazione", "Export page": "Esporta pagina", - "Export successful": "Export successful", + "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 +330,8 @@ "Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.", "Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.", "Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.", - "Uploading {{name}}": "Uploading {{name}}", - "Uploading file": "Uploading file", + "Uploading {{name}}": "Caricamento di {{name}}", + "Uploading file": "Caricamento file", "Table": "Tabella", "Insert a table.": "Inserisci una tabella.", "Insert collapsible block.": "Inserisci blocco comprimibile.", diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index be341728..4d18e074 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -29,7 +29,7 @@ "Choose your preferred interface language.": "お好みの言語を選択してください", "Choose your preferred page width.": "お好みのページ幅を選択してください", "Confirm": "確認", - "Copy as Markdown": "Copy as Markdown", + "Copy as Markdown": "Markdownとしてコピー", "Copy link": "リンクをコピー", "Create": "新規作成", "Create group": "グループを作成", @@ -254,7 +254,7 @@ "Export failed:": "エクスポートに失敗しました:", "export error": "エクスポートエラー", "Export page": "エクスポートページ", - "Export successful": "Export successful", + "Export successful": "エクスポート成功", "Export space": "エクスポートスペース", "Export {{type}}": "{{type}}をエクスポート", "File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています", @@ -330,8 +330,8 @@ "Upload any image from your device.": "デバイスから画像をアップロードします", "Upload any video from your device.": "デバイスから動画をアップロードします", "Upload any file from your device.": "デバイスからファイルをアップロードします", - "Uploading {{name}}": "Uploading {{name}}", - "Uploading file": "Uploading file", + "Uploading {{name}}": "{{name}} をアップロード中", + "Uploading file": "ファイルをアップロード中", "Table": "テーブル", "Insert a table.": "テーブルを挿入します", "Insert collapsible block.": "折りたたみブロックを挿入します", diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index 6e83db5f..d9b48b04 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -29,7 +29,7 @@ "Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.", "Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.", "Confirm": "확인", - "Copy as Markdown": "Copy as Markdown", + "Copy as Markdown": "Markdown으로 복사", "Copy link": "링크 복사", "Create": "생성", "Create group": "팀 생성", @@ -254,7 +254,7 @@ "Export failed:": "내보내기 실패:", "export error": "내보내기 오류", "Export page": "페이지 내보내기", - "Export successful": "Export successful", + "Export successful": "내보내기 성공", "Export space": "Space 내보내기", "Export {{type}}": "{{type}} 내보내기", "File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다", @@ -330,8 +330,8 @@ "Upload any image from your device.": "기기에서 이미지를 업로드하세요.", "Upload any video from your device.": "기기에서 비디오를 업로드하세요.", "Upload any file from your device.": "기기에서 파일을 업로드하세요.", - "Uploading {{name}}": "Uploading {{name}}", - "Uploading file": "Uploading file", + "Uploading {{name}}": "{{name}} 업로드 중", + "Uploading file": "파일 업로드 중", "Table": "테이블", "Insert a table.": "테이블 삽입.", "Insert collapsible block.": "접을 수 있는 블록 삽입.", diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index 9c16efe3..a7923b98 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -29,7 +29,7 @@ "Choose your preferred interface language.": "Kies uw gewenste interfacetaal.", "Choose your preferred page width.": "Kies uw gewenste paginabreedte.", "Confirm": "Bevestig", - "Copy as Markdown": "Copy as Markdown", + "Copy as Markdown": "Kopiëren als Markdown", "Copy link": "Link kopiëren", "Create": "Aanmaken", "Create group": "Groep aanmaken", @@ -254,7 +254,7 @@ "Export failed:": "Exporteren mislukt:", "export error": "Exporteer fout", "Export page": "Exporteer pagina", - "Export successful": "Export successful", + "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 +330,8 @@ "Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.", "Upload any video from your device.": "Upload een video vanaf uw apparaat.", "Upload any file from your device.": "Upload een bestand vanaf uw apparaat.", - "Uploading {{name}}": "Uploading {{name}}", - "Uploading file": "Uploading file", + "Uploading {{name}}": "Uploaden {{name}}", + "Uploading file": "Bestand uploaden", "Table": "Tabel", "Insert a table.": "Voeg een tabel in.", "Insert collapsible block.": "Inklapbaar blok invoegen.", diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index eb1442b2..30cc0b21 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -29,7 +29,7 @@ "Choose your preferred interface language.": "Escolha o idioma da interface.", "Choose your preferred page width.": "Escolha a largura preferida da página.", "Confirm": "Confirmar", - "Copy as Markdown": "Copy as Markdown", + "Copy as Markdown": "Copiar como Markdown", "Copy link": "Copiar link", "Create": "Criar", "Create group": "Criar grupo", @@ -254,7 +254,7 @@ "Export failed:": "Falha ao exportar:", "export error": "erro de exportação", "Export page": "Exportar página", - "Export successful": "Export successful", + "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 +330,8 @@ "Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.", "Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.", "Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.", - "Uploading {{name}}": "Uploading {{name}}", - "Uploading file": "Uploading file", + "Uploading {{name}}": "Enviando {{name}}", + "Uploading file": "Enviando arquivo", "Table": "Tabela", "Insert a table.": "Insira uma tabela.", "Insert collapsible block.": "Insira um bloco colapsável.", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index b39d13a4..88e1f701 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -29,7 +29,7 @@ "Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.", "Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.", "Confirm": "Подтвердить", - "Copy as Markdown": "Copy as Markdown", + "Copy as Markdown": "Копировать как Markdown", "Copy link": "Копировать ссылку", "Create": "Создать", "Create group": "Создать группу", @@ -254,7 +254,7 @@ "Export failed:": "Экспортирование не удалось:", "export error": "ошибка экспорта", "Export page": "Экспорт страницы", - "Export successful": "Export successful", + "Export successful": "Экспорт выполнен успешно", "Export space": "Экспорт пространства", "Export {{type}}": "Экспорт {{type}}", "File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}", @@ -330,8 +330,8 @@ "Upload any image from your device.": "Загрузить любое изображение с вашего устройства.", "Upload any video from your device.": "Загрузить любое видео с вашего устройства.", "Upload any file from your device.": "Загрузить любой файл с вашего устройства.", - "Uploading {{name}}": "Uploading {{name}}", - "Uploading file": "Uploading file", + "Uploading {{name}}": "Загрузка {{name}}", + "Uploading file": "Загрузка файла", "Table": "Таблица", "Insert a table.": "Вставить таблицу.", "Insert collapsible block.": "Вставить сворачиваемый блок.", diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index 2460f38f..e5cdaa40 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -29,7 +29,7 @@ "Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.", "Choose your preferred page width.": "Оберіть бажану ширину сторінки.", "Confirm": "Підтвердити", - "Copy as Markdown": "Copy as Markdown", + "Copy as Markdown": "Скопіювати як Markdown", "Copy link": "Копіювати посилання", "Create": "Створити", "Create group": "Створити групу", @@ -254,7 +254,7 @@ "Export failed:": "Експортування не вдалося:", "export error": "помилка експорту", "Export page": "Експорт сторінки", - "Export successful": "Export successful", + "Export successful": "Експорт виконано успішно", "Export space": "Експорт простору", "Export {{type}}": "Експорт {{type}}", "File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}", @@ -330,8 +330,8 @@ "Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.", "Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.", "Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.", - "Uploading {{name}}": "Uploading {{name}}", - "Uploading file": "Uploading file", + "Uploading {{name}}": "Завантаження {{name}}", + "Uploading file": "Завантаження файлу", "Table": "Таблиця", "Insert a table.": "Вставити таблицю.", "Insert collapsible block.": "Вставити блок, що згортається.", diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index ed26024f..a5eb84f1 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -29,7 +29,7 @@ "Choose your preferred interface language.": "选择您喜欢的界面语言。", "Choose your preferred page width.": "选择您喜欢的页面宽度。", "Confirm": "确认", - "Copy as Markdown": "Copy as Markdown", + "Copy as Markdown": "复制为Markdown", "Copy link": "复制链接", "Create": "创建", "Create group": "创建群组", @@ -254,7 +254,7 @@ "Export failed:": "导出失败:", "export error": "导出出错", "Export page": "导出页面", - "Export successful": "Export successful", + "Export successful": "导出成功", "Export space": "导出空间", "Export {{type}}": "导出为 {{type}}", "File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制", @@ -330,8 +330,8 @@ "Upload any image from your device.": "从设备上传任何图像", "Upload any video from your device.": "从设备上传任何视频", "Upload any file from your device.": "从设备上传任何文件", - "Uploading {{name}}": "Uploading {{name}}", - "Uploading file": "Uploading file", + "Uploading {{name}}": "正在上传{{name}}", + "Uploading file": "正在上传文件", "Table": "表格", "Insert a table.": "插入一个表格", "Insert collapsible block.": "插入一个折叠块", From 0245a183e1f58b77d05eeb8794b5fe6d30ff7c42 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 26 Jan 2026 02:08:54 +0000 Subject: [PATCH 21/60] sync --- apps/server/src/ee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/ee b/apps/server/src/ee index 88e3d01f..f858f127 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 88e3d01f8135c2dbc628b9636ba91bb9ffd2f0eb +Subproject commit f858f127b55e32d34e5a2b9867bf512dd152ba09 From 6ccb2bb8727549239562774e886ddbef01993c8c Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:39:39 +0000 Subject: [PATCH 22/60] feat(export): add metadata file to preserve page icons and ordering on import (#1877) * feat(export): add metadata file to preserve page icons and ordering on import - Export includes `docmost-metadata.json` - Import reads metadata to restore icons and sort siblings by original position * cleanup * bonus fixes * handle unknown prosemirror nodes * add docmost app version --- .../page/components/page-import-modal.tsx | 4 ++ .../space/components/delete-space-modal.tsx | 7 ++- .../src/collaboration/collaboration.util.ts | 49 ++++++++++++++++++- .../helpers/types/export-metadata.types.ts | 14 ++++++ .../src/integrations/export/export.service.ts | 34 ++++++++++++- .../integrations/import/dto/file-task-dto.ts | 1 + .../services/file-import-task.service.ts | 44 +++++++++++++++-- .../integrations/import/utils/import.utils.ts | 34 ++++++++++++- 8 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/common/helpers/types/export-metadata.types.ts diff --git a/apps/client/src/features/page/components/page-import-modal.tsx b/apps/client/src/features/page/components/page-import-modal.tsx index a2df380e..be0264b6 100644 --- a/apps/client/src/features/page/components/page-import-modal.tsx +++ b/apps/client/src/features/page/components/page-import-modal.tsx @@ -172,6 +172,10 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) { queryKey: ["root-sidebar-pages", fileTask.spaceId], }); + await queryClient.invalidateQueries({ + queryKey: ["recent-changes", fileTask.spaceId], + }); + setTimeout(() => { emit({ operation: "refetchRootTreeNodeEvent", diff --git a/apps/client/src/features/space/components/delete-space-modal.tsx b/apps/client/src/features/space/components/delete-space-modal.tsx index f697322d..8a89e720 100644 --- a/apps/client/src/features/space/components/delete-space-modal.tsx +++ b/apps/client/src/features/space/components/delete-space-modal.tsx @@ -6,6 +6,7 @@ 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; @@ -14,6 +15,7 @@ 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(); @@ -35,12 +37,15 @@ 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); } }; @@ -79,7 +84,7 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) { - diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 16ca5bd5..afe1be08 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -41,7 +41,8 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; // see: https://github.com/ueberdosis/tiptap/issues/5352 // see:https://github.com/ueberdosis/tiptap/issues/4089 //import { generateJSON } from '@tiptap/html'; -import { Node } from '@tiptap/pm/model'; +import { Node, Schema } from '@tiptap/pm/model'; +import { Logger } from '@nestjs/common'; export const tiptapExtensions = [ StarterKit.configure({ @@ -110,9 +111,53 @@ export function jsonToText(tiptapJson: JSONContent) { } export function jsonToNode(tiptapJson: JSONContent) { - return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson); + 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; + } } export function getPageId(documentName: string) { return documentName.split('.')[1]; } + +function stripUnknownNodes( + json: JSONContent, + schema: Schema, +): JSONContent | null { + if (!json || typeof json !== 'object') return json; + + // Recursively clean children first, flattening any unwrapped content + if (json.content && Array.isArray(json.content)) { + const newContent: JSONContent[] = []; + for (const child of json.content) { + const cleaned = stripUnknownNodes(child, schema); + if (Array.isArray(cleaned)) { + newContent.push(...cleaned); + } else if (cleaned) { + newContent.push(cleaned); + } + } + json.content = newContent; + } + + // Check if this node is unknown AFTER processing children + if (json.type && !schema.nodes[json.type]) { + // Unwrap: return cleaned children directly instead of wrapping + return ( + json.content && json.content.length > 0 ? json.content : null + ) as any; + } + + return json; +} diff --git a/apps/server/src/common/helpers/types/export-metadata.types.ts b/apps/server/src/common/helpers/types/export-metadata.types.ts new file mode 100644 index 00000000..42ef4c68 --- /dev/null +++ b/apps/server/src/common/helpers/types/export-metadata.types.ts @@ -0,0 +1,14 @@ +export type ExportPageMetadata = { + pageId: string; + slugId: string; + icon: string | null; + position: string; + parentPath: string | null; +}; + +export type ExportMetadata = { + exportedAt: string; + source: 'docmost'; + version: string; + pages: Record; +}; diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index e33ac11b..44047174 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -20,11 +20,17 @@ import { replaceInternalLinks, updateAttachmentUrlsToLocalPaths, } from './utils'; +import { + ExportMetadata, + ExportPageMetadata, +} from '../../common/helpers/types/export-metadata.types'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { Node } from '@tiptap/pm/model'; import { EditorState } from '@tiptap/pm/state'; // eslint-disable-next-line @typescript-eslint/no-require-imports import slugify = require('@sindresorhus/slugify'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const packageJson = require('../../../package.json'); import { EnvironmentService } from '../environment/environment.service'; import { getAttachmentIds, @@ -155,12 +161,15 @@ export class ExportService { 'pages.id', 'pages.slugId', 'pages.title', + 'pages.icon', + 'pages.position', 'pages.content', 'pages.parentPageId', 'pages.spaceId', 'pages.workspaceId', ]) .where('spaceId', '=', spaceId) + .where('deletedAt', 'is', null) .execute(); const tree = buildTree(pages as Page[]); @@ -189,10 +198,12 @@ export class ExportService { includeAttachments: boolean, ): Promise { const slugIdToPath: Record = {}; + const pageIdToFilePath: Record = {}; + const pagesMetadata: Record = {}; computeLocalPath(tree, format, null, '', slugIdToPath); - const stack: { folder: JSZip; parentPageId: string }[] = [ + const stack: { folder: JSZip; parentPageId: string | null }[] = [ { folder: zip, parentPageId: null }, ]; @@ -232,12 +243,33 @@ export class ExportService { `${pageTitle}${getExportExtension(format)}`, pageExportContent, ); + + pageIdToFilePath[page.id] = currentPagePath; + + const parentPath = parentPageId ? pageIdToFilePath[parentPageId] : null; + pagesMetadata[currentPagePath] = { + pageId: page.id, + slugId: page.slugId, + icon: page.icon ?? null, + position: page.position, + parentPath, + }; + if (childPages.length > 0) { const pageFolder = folder.folder(pageTitle); stack.push({ folder: pageFolder, parentPageId: page.id }); } } } + + const metadata: ExportMetadata = { + exportedAt: new Date().toISOString(), + source: 'docmost', + version: packageJson.version, + pages: pagesMetadata, + }; + + zip.file('docmost-metadata.json', JSON.stringify(metadata, null, 2)); } async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) { diff --git a/apps/server/src/integrations/import/dto/file-task-dto.ts b/apps/server/src/integrations/import/dto/file-task-dto.ts index 9cdea395..84736813 100644 --- a/apps/server/src/integrations/import/dto/file-task-dto.ts +++ b/apps/server/src/integrations/import/dto/file-task-dto.ts @@ -15,4 +15,5 @@ export type ImportPageNode = { parentPageId: string | null; fileExtension: string; filePath: string; + icon?: string | null; }; \ No newline at end of file diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts index 5cf39054..8ae79598 100644 --- a/apps/server/src/integrations/import/services/file-import-task.service.ts +++ b/apps/server/src/integrations/import/services/file-import-task.service.ts @@ -24,6 +24,8 @@ import { formatImportHtml } from '../utils/import-formatter'; import { buildAttachmentCandidates, collectMarkdownAndHtmlFiles, + encodeFilePath, + readDocmostMetadata, stripNotionID, } from '../utils/import.utils'; import { executeTx } from '@docmost/db/utils'; @@ -154,6 +156,7 @@ export class FileImportTaskService { const { extractDir, fileTask } = opts; const allFiles = await collectMarkdownAndHtmlFiles(extractDir); const attachmentCandidates = await buildAttachmentCandidates(extractDir); + const docmostMetadata = await readDocmostMetadata(extractDir); const pagesMap = new Map(); @@ -164,6 +167,9 @@ export class FileImportTaskService { .join('/'); // normalize to forward-slashes const ext = path.extname(relPath).toLowerCase(); + const encodedPath = encodeFilePath(relPath); + const pageMetadata = docmostMetadata?.pages[encodedPath]; + pagesMap.set(relPath, { id: v7(), slugId: generateSlugId(), @@ -172,6 +178,7 @@ export class FileImportTaskService { parentPageId: null, fileExtension: ext, filePath: relPath, + icon: pageMetadata?.icon ?? null, }); } @@ -224,6 +231,8 @@ export class FileImportTaskService { if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) { const folderName = path.basename(folderPath); + const encodedMdPath = encodeFilePath(mdPath); + const placeholderMetadata = docmostMetadata?.pages[encodedMdPath]; pagesMap.set(mdPath, { id: v7(), slugId: generateSlugId(), @@ -232,6 +241,7 @@ export class FileImportTaskService { parentPageId: null, fileExtension: '.md', filePath: mdPath, + icon: placeholderMetadata?.icon ?? null, }); } }); @@ -266,11 +276,39 @@ export class FileImportTaskService { siblingsMap.set(page.parentPageId, group); }); + const encodedPathsMap = new Map(); + if (docmostMetadata) { + pagesMap.forEach((_, filePath) => { + encodedPathsMap.set(filePath, encodeFilePath(filePath)); + }); + } + + // Sort siblings by metadata position if available, otherwise alphabetically + const sortSiblings = (siblings: ImportPageNode[]) => { + if (docmostMetadata) { + siblings.sort((a, b) => { + const posA = + docmostMetadata.pages[encodedPathsMap.get(a.filePath)]?.position; + const posB = + docmostMetadata.pages[encodedPathsMap.get(b.filePath)]?.position; + if (posA && posB) { + // Use direct comparison to match PostgreSQL collation 'C' (byte order) + if (posA < posB) return -1; + if (posA > posB) return 1; + return 0; + } + return a.name.localeCompare(b.name); + }); + } else { + siblings.sort((a, b) => a.name.localeCompare(b.name)); + } + }; + // get root pages const rootSibs = siblingsMap.get(null); if (rootSibs?.length) { - rootSibs.sort((a, b) => a.name.localeCompare(b.name)); + sortSiblings(rootSibs); // get first position key from the server const nextPosition = await this.pageService.nextPagePosition( @@ -292,7 +330,7 @@ export class FileImportTaskService { siblingsMap.forEach((sibs, parentId) => { if (parentId === null) return; // root already done - sibs.sort((a, b) => a.name.localeCompare(b.name)); + sortSiblings(sibs); let prevPos: string | null = null; for (const page of sibs) { @@ -426,7 +464,7 @@ export class FileImportTaskService { id: page.id, slugId: page.slugId, title: title || page.name, - icon: pageIcon || null, + icon: page.icon || pageIcon || null, content: prosemirrorJson, textContent: jsonToText(prosemirrorJson), ydoc: await this.importService.createYdoc(prosemirrorJson), diff --git a/apps/server/src/integrations/import/utils/import.utils.ts b/apps/server/src/integrations/import/utils/import.utils.ts index c8f5fe51..cd348652 100644 --- a/apps/server/src/integrations/import/utils/import.utils.ts +++ b/apps/server/src/integrations/import/utils/import.utils.ts @@ -1,6 +1,7 @@ import { Logger } from '@nestjs/common'; import { promises as fs } from 'fs'; import * as path from 'path'; +import { ExportMetadata } from '../../../common/helpers/types/export-metadata.types'; export async function buildAttachmentCandidates( extractDir: string, @@ -35,9 +36,15 @@ export function resolveRelativeAttachmentPath( try { mainRel = decodeURIComponent(mainRel); } catch (err) { - Logger.warn(`URI malformed for attachment path: ${mainRel}. Falling back to raw path.`, 'ImportUtils'); + Logger.warn( + `URI malformed for attachment path: ${mainRel}. Falling back to raw path.`, + 'ImportUtils', + ); } - const fallback = path.normalize(path.join(pageDir, mainRel)).split(path.sep).join('/'); + const fallback = path + .normalize(path.join(pageDir, mainRel)) + .split(path.sep) + .join('/'); if (attachmentCandidates.has(mainRel)) { return mainRel; @@ -76,3 +83,26 @@ export function stripNotionID(fileName: string): string { const notionIdPattern = /[ -]?[a-z0-9]{32}$/i; return fileName.replace(notionIdPattern, '').trim(); } + +export function encodeFilePath(filePath: string): string { + return filePath + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); +} + +export async function readDocmostMetadata( + extractDir: string, +): Promise { + const metadataPath = path.join(extractDir, 'docmost-metadata.json'); + try { + const content = await fs.readFile(metadataPath, 'utf-8'); + const metadata = JSON.parse(content) as ExportMetadata; + if (metadata.source === 'docmost' && metadata.pages) { + return metadata; + } + return null; + } catch { + return null; + } +} From 3523600f40f281407253b71b365e410f2d2f2d46 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:49:22 +0000 Subject: [PATCH 23/60] add timestamps --- apps/server/src/common/helpers/types/export-metadata.types.ts | 2 ++ apps/server/src/database/repos/page/page.repo.ts | 4 ++++ apps/server/src/integrations/export/export.service.ts | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/apps/server/src/common/helpers/types/export-metadata.types.ts b/apps/server/src/common/helpers/types/export-metadata.types.ts index 42ef4c68..f901c0e2 100644 --- a/apps/server/src/common/helpers/types/export-metadata.types.ts +++ b/apps/server/src/common/helpers/types/export-metadata.types.ts @@ -4,6 +4,8 @@ export type ExportPageMetadata = { icon: string | null; position: string; parentPath: string | null; + createdAt: string; + updatedAt: string; }; export type ExportMetadata = { diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index f2b27abb..52337bb1 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -422,6 +422,8 @@ export class PageRepo { 'parentPageId', 'spaceId', 'workspaceId', + 'createdAt', + 'updatedAt', ]) .$if(opts?.includeContent, (qb) => qb.select('content')) .where('id', '=', parentPageId) @@ -438,6 +440,8 @@ export class PageRepo { 'p.parentPageId', 'p.spaceId', 'p.workspaceId', + 'p.createdAt', + 'p.updatedAt', ]) .$if(opts?.includeContent, (qb) => qb.select('p.content')) .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id') diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index 44047174..655e31d3 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -167,6 +167,8 @@ export class ExportService { 'pages.parentPageId', 'pages.spaceId', 'pages.workspaceId', + 'pages.createdAt', + 'pages.updatedAt', ]) .where('spaceId', '=', spaceId) .where('deletedAt', 'is', null) @@ -253,6 +255,8 @@ export class ExportService { icon: page.icon ?? null, position: page.position, parentPath, + createdAt: page.createdAt?.toISOString() ?? new Date().toISOString(), + updatedAt: page.updatedAt?.toISOString() ?? new Date().toISOString(), }; if (childPages.length > 0) { From 74e915546b537ea4134e2315e9d5ae175fba5f64 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:05:05 +0000 Subject: [PATCH 24/60] feat: collab redis extension with server affinity (#1873) * feat(collab): better redis extension * move types to own file * debug logging * fix: graceful collab shutdown * rename default prefix * pass wsAdapter to gateway * expose event handler * unique collab serverId generation * uninstall @hocuspocus/extension-redis package * expose more functions * sync with latest * cleanup * fastify router options * cleanup type --- apps/server/package.json | 3 + .../adapter/collab-ws.adapter.ts | 16 +- .../collaboration/collaboration.gateway.ts | 158 +++++++- .../collaboration/collaboration.handler.ts | 42 ++ .../src/collaboration/collaboration.module.ts | 12 +- .../extensions/logger.extension.ts | 8 +- .../redis-sync/collab-proxy-socket.ts | 70 ++++ .../extensions/redis-sync/index.ts | 2 + .../redis-sync/redis-sync.extension.ts | 376 ++++++++++++++++++ .../extensions/redis-sync/redis-sync.types.ts | 121 ++++++ .../redis-sync/ws-socket-wrapper.ts | 47 +++ .../src/collaboration/server/collab-main.ts | 8 +- package.json | 1 - .../src/lib/attachment/attachment.ts | 2 +- packages/editor-ext/src/lib/image/image.ts | 2 +- packages/editor-ext/src/lib/video/video.ts | 2 +- pnpm-lock.yaml | 86 ++-- 17 files changed, 857 insertions(+), 99 deletions(-) create mode 100644 apps/server/src/collaboration/collaboration.handler.ts create mode 100644 apps/server/src/collaboration/extensions/redis-sync/collab-proxy-socket.ts create mode 100644 apps/server/src/collaboration/extensions/redis-sync/index.ts create mode 100644 apps/server/src/collaboration/extensions/redis-sync/redis-sync.extension.ts create mode 100644 apps/server/src/collaboration/extensions/redis-sync/redis-sync.types.ts create mode 100644 apps/server/src/collaboration/extensions/redis-sync/ws-socket-wrapper.ts diff --git a/apps/server/package.json b/apps/server/package.json index 71e68679..edecf07a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -76,8 +76,10 @@ "kysely-migration-cli": "^0.4.2", "kysely-postgres-js": "^3.0.0", "ldapts": "^7.4.0", + "lib0": "^0.2.117", "mammoth": "^1.11.0", "mime-types": "^2.1.35", + "msgpackr": "^1.11.8", "nanoid": "3.3.11", "nestjs-kysely": "^1.2.0", "nestjs-pino": "^4.5.0", @@ -102,6 +104,7 @@ "socket.io": "^4.8.3", "stripe": "^17.5.0", "tmp-promise": "^3.0.3", + "tseep": "^1.3.1", "typesense": "^2.1.0", "ws": "^8.19.0", "yauzl": "^3.2.0" diff --git a/apps/server/src/collaboration/adapter/collab-ws.adapter.ts b/apps/server/src/collaboration/adapter/collab-ws.adapter.ts index 352fe01f..18685bf0 100644 --- a/apps/server/src/collaboration/adapter/collab-ws.adapter.ts +++ b/apps/server/src/collaboration/adapter/collab-ws.adapter.ts @@ -30,14 +30,22 @@ export class CollabWsAdapter { return this.wss; } - public destroy() { + public close() { try { - this.wss.clients.forEach((client) => { - client.terminate(); - }); this.wss.close(); } catch (err) { console.error(err); } } + + public destroy() { + try { + this.wss.close(); + this.wss.clients.forEach((client) => { + client.terminate(); + }); + } catch (err) { + console.error(err); + } + } } diff --git a/apps/server/src/collaboration/collaboration.gateway.ts b/apps/server/src/collaboration/collaboration.gateway.ts index f1d50671..b296c520 100644 --- a/apps/server/src/collaboration/collaboration.gateway.ts +++ b/apps/server/src/collaboration/collaboration.gateway.ts @@ -1,10 +1,9 @@ -import { Hocuspocus, Server as HocuspocusServer } from '@hocuspocus/server'; +import { Hocuspocus } from '@hocuspocus/server'; import { IncomingMessage } from 'http'; import WebSocket from 'ws'; import { AuthenticationExtension } from './extensions/authentication.extension'; import { PersistenceExtension } from './extensions/persistence.extension'; import { Injectable } from '@nestjs/common'; -import { Redis } from '@hocuspocus/extension-redis'; import { EnvironmentService } from '../integrations/environment/environment.service'; import { createRetryStrategy, @@ -12,19 +11,39 @@ import { RedisConfig, } from '../common/helpers'; import { LoggerExtension } from './extensions/logger.extension'; +import { + RedisSyncExtension, + SerializedHTTPRequest, +} from './extensions/redis-sync'; +import { WsSocketWrapper } from './extensions/redis-sync/ws-socket-wrapper'; +import RedisClient from 'ioredis'; +import { pack, unpack } from 'msgpackr'; +import { nanoid } from 'nanoid'; +import * as os from 'node:os'; +import { CollabWsAdapter } from './adapter/collab-ws.adapter'; +import { + CollaborationHandler, + CollabEventHandlers, +} from './collaboration.handler'; @Injectable() export class CollaborationGateway { - private hocuspocus: Hocuspocus; + private readonly hocuspocus: Hocuspocus; private redisConfig: RedisConfig; + // @ts-ignore + private readonly redisSync: RedisSyncExtension | 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({ debounce: 10000, @@ -34,26 +53,80 @@ export class CollaborationGateway { this.authenticationExtension, this.persistenceExtension, this.loggerExtension, - ...(this.environmentService.isCollabDisableRedis() - ? [] - : [ - new Redis({ - host: this.redisConfig.host, - port: this.redisConfig.port, - options: { - password: this.redisConfig.password, - db: this.redisConfig.db, - family: this.redisConfig.family, - retryStrategy: createRetryStrategy(), - }, - }), - ]), ], }); + + if (this.withRedis) { + // @ts-ignore + this.redisSync = new RedisSyncExtension({ + redis: new RedisClient({ + host: this.redisConfig.host, + port: this.redisConfig.port, + password: this.redisConfig.password, + db: this.redisConfig.db, + family: this.redisConfig.family, + retryStrategy: createRetryStrategy(), + }), + serverId: `collab-${os?.hostname()}-${nanoid(10)}`, + prefix: 'collab', + pack, + unpack, + // @ts-ignore + customEvents: this.collabEventsService.getHandlers(this.hocuspocus), + }); + this.hocuspocus.configuration.extensions.push(this.redisSync); + // @ts-ignore + this.redisSync.onConfigure({ instance: this.hocuspocus }); + } + } + + private serializeRequest(request: IncomingMessage): SerializedHTTPRequest { + return { + method: request.method ?? 'GET', + url: request.url ?? '/', + headers: { + 'sec-websocket-key': request.headers['sec-websocket-key'] ?? '', + 'sec-websocket-protocol': + request.headers['sec-websocket-protocol'] ?? '', + }, + socket: { remoteAddress: request.socket?.remoteAddress ?? '' }, + }; } handleConnection(client: WebSocket, request: IncomingMessage): any { - this.hocuspocus.handleConnection(client, request); + if (this.redisSync) { + const serializedHTTPRequest = this.serializeRequest(request); + const socketId = serializedHTTPRequest.headers['sec-websocket-key']; + + // Create wrapper socket that only receives events via emit() + // This prevents double-handling since Hocuspocus won't listen to raw WebSocket events + const wrappedSocket = new WsSocketWrapper(client); + + // Route through RedisSync extension (this calls handleConnection internally) + this.redisSync.onSocketOpen(wrappedSocket as any, serializedHTTPRequest); + + // Forward raw WebSocket messages to the extension + client.on('message', (data: ArrayBuffer) => { + this.redisSync!.onSocketMessage( + wrappedSocket as any, + serializedHTTPRequest, + data, + ); + }); + + // Forward close events + client.on('close', (code: number, reason: Buffer) => { + this.redisSync!.onSocketClose(socketId, code, reason); + }); + + // Forward pong events for keepalive + client.on('pong', (data: Buffer) => { + wrappedSocket.emit('pong', data); + }); + } else { + // Fallback to direct Hocuspocus connection + this.hocuspocus.handleConnection(client, request); + } } getConnectionCount() { @@ -64,7 +137,52 @@ export class CollaborationGateway { return this.hocuspocus.getDocumentsCount(); } - async destroy(): Promise { - //await this.hocuspocus.destroy(); + handleYjsEvent( + eventName: TName, + documentName: string, + payload: Parameters[1], + ) { + return this.redisSync?.handleEvent(eventName, documentName, payload); + } + + openDirectConnection(documentName: string, context?: any) { + return this.hocuspocus.openDirectConnection(documentName, context); + } + + /* + *Can be used before calling openDirectConnection directly + */ + async lockDocument(documentName: string) { + return this.redisSync.lockDocument(documentName); + } + + /* + *Releases a document lock and stops the interval that maintains it. + */ + async releaseLock(documentName: string) { + return this.redisSync.releaseLock(documentName); + } + + async destroy(collabWsAdapter: CollabWsAdapter): Promise { + // 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 }); } } diff --git a/apps/server/src/collaboration/collaboration.handler.ts b/apps/server/src/collaboration/collaboration.handler.ts new file mode 100644 index 00000000..ec746550 --- /dev/null +++ b/apps/server/src/collaboration/collaboration.handler.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Hocuspocus, Document } from '@hocuspocus/server'; + +export type CollabEventHandlers = ReturnType< + CollaborationHandler['getHandlers'] +>; + +@Injectable() +export class CollaborationHandler { + private readonly logger = new Logger(CollaborationHandler.name); + + constructor() {} + + getHandlers(hocuspocus: Hocuspocus) { + return { + alterState: async (documentName: string, payload: { pageId: string }) => { + // dummy + // this.logger.log('Processing', documentName, payload); + // await this.withYdocConnection(hocuspocus, documentName, {}, (doc) => { + // const fragment = doc.getXmlFragment('default'); + //}); + }, + }; + } + + async withYdocConnection( + hocuspocus: Hocuspocus, + documentName: string, + context: any = {}, + fn: (doc: Document) => void, + ): Promise { + const connection = await hocuspocus.openDirectConnection( + documentName, + context, + ); + try { + await connection.transact(fn); + } finally { + await connection.disconnect(); + } + } +} diff --git a/apps/server/src/collaboration/collaboration.module.ts b/apps/server/src/collaboration/collaboration.module.ts index 30cb0ccf..e9374c53 100644 --- a/apps/server/src/collaboration/collaboration.module.ts +++ b/apps/server/src/collaboration/collaboration.module.ts @@ -9,6 +9,7 @@ import { WebSocket } from 'ws'; import { TokenModule } from '../core/auth/token.module'; import { HistoryListener } from './listeners/history.listener'; import { LoggerExtension } from './extensions/logger.extension'; +import { CollaborationHandler } from './collaboration.handler'; @Module({ providers: [ @@ -17,6 +18,7 @@ import { LoggerExtension } from './extensions/logger.extension'; PersistenceExtension, LoggerExtension, HistoryListener, + CollaborationHandler, ], exports: [CollaborationGateway], imports: [TokenModule], @@ -46,16 +48,12 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy { }); wss.on('error', (error) => - this.logger.log('WebSocket server error:', error), + this.logger.error('WebSocket server error:', error), ); } async onModuleDestroy(): Promise { - if (this.collaborationGateway) { - await this.collaborationGateway.destroy(); - } - if (this.collabWsAdapter) { - this.collabWsAdapter.destroy(); - } + await this.collaborationGateway?.destroy(this.collabWsAdapter); + this.collabWsAdapter?.destroy(); } } diff --git a/apps/server/src/collaboration/extensions/logger.extension.ts b/apps/server/src/collaboration/extensions/logger.extension.ts index 969fa712..bbca47bd 100644 --- a/apps/server/src/collaboration/extensions/logger.extension.ts +++ b/apps/server/src/collaboration/extensions/logger.extension.ts @@ -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); + } } diff --git a/apps/server/src/collaboration/extensions/redis-sync/collab-proxy-socket.ts b/apps/server/src/collaboration/extensions/redis-sync/collab-proxy-socket.ts new file mode 100644 index 00000000..8ecfa00a --- /dev/null +++ b/apps/server/src/collaboration/extensions/redis-sync/collab-proxy-socket.ts @@ -0,0 +1,70 @@ +import type RedisClient from 'ioredis'; +import { EventEmitter } from 'tseep'; +import type { + Pack, + RSAMessageClose, + RSAMessagePing, + RSAMessageSend, +} from './redis-sync.types'; + +export class CollabProxySocket extends EventEmitter { + private readonly replyTo: string; + private readonly serverChannel: string; + private readonly socketId: string; + private pub: RedisClient; + private readonly pack: Pack; + readyState = 1; + + constructor( + pub: RedisClient, + pack: Pack, + replyTo: string, + serverChannel: string, + socketId: string, + ) { + super(); + this.replyTo = replyTo; + this.socketId = socketId; + this.serverChannel = serverChannel; + this.pub = pub; + this.pack = pack; + this.once('close', () => { + this.readyState = 3; + }); + } + + private publish(msg: RSAMessageClose | RSAMessagePing | RSAMessageSend) { + this.pub.publish(this.replyTo, this.pack(msg)); + } + + close(code?: number, reason?: string) { + if (this.readyState !== 1) return; + const msg: RSAMessageClose = { + type: 'close', + code, + reason, + socketId: this.socketId, + }; + this.publish(msg); + } + + ping() { + if (this.readyState !== 1) return; + const msg: RSAMessagePing = { + type: 'ping', + socketId: this.socketId, + replyTo: this.serverChannel, + }; + this.publish(msg); + } + + send(message: Uint8Array) { + if (this.readyState !== 1) return; + const msg: RSAMessageSend = { + type: 'send', + socketId: this.socketId, + message, + }; + this.publish(msg); + } +} diff --git a/apps/server/src/collaboration/extensions/redis-sync/index.ts b/apps/server/src/collaboration/extensions/redis-sync/index.ts new file mode 100644 index 00000000..a5847477 --- /dev/null +++ b/apps/server/src/collaboration/extensions/redis-sync/index.ts @@ -0,0 +1,2 @@ +export * from './redis-sync.extension'; +export type { SerializedHTTPRequest } from './redis-sync.extension'; diff --git a/apps/server/src/collaboration/extensions/redis-sync/redis-sync.extension.ts b/apps/server/src/collaboration/extensions/redis-sync/redis-sync.extension.ts new file mode 100644 index 00000000..5b78b9c0 --- /dev/null +++ b/apps/server/src/collaboration/extensions/redis-sync/redis-sync.extension.ts @@ -0,0 +1,376 @@ +// Source https://github.com/ueberdosis/hocuspocus/pull/1008 - MIT +import { + Extension, + Hocuspocus, + IncomingMessage, + afterUnloadDocumentPayload, + onConfigurePayload, + onLoadDocumentPayload, +} from '@hocuspocus/server'; +import RedisClient from 'ioredis'; +import { readVarString } from 'lib0/decoding.js'; +import { CollabProxySocket } from './collab-proxy-socket'; +import { + BaseWebSocket, + Configuration, + CustomEvents, + Pack, + RSAMessage, + RSAMessageCloseProxy, + RSAMessageCustomEventComplete, + RSAMessageCustomEventStart, + RSAMessagePong, + RSAMessageProxy, + RSAMessageUnload, + SerializedHTTPRequest, + Unpack, +} from './redis-sync.types'; + +export type { Pack, SerializedHTTPRequest } from './redis-sync.types'; + +type ServerId = string; +type DocumentName = string; +type SocketId = string; + +export class RedisSyncExtension implements Extension { + priority = 1000; + private readonly pub: RedisClient; + private sub: RedisClient; + private readonly pack: Pack; + private readonly unpack: Unpack; + private originSockets: Record = {}; + private locks: Record = {}; + private lockPromises: Record> = {}; + private proxySockets: Record = {}; + private readonly prefix: string; + private readonly lockPrefix: string; + private readonly msgChannel: string; + private readonly serverId: ServerId; + private readonly customEventTTL: number; + private readonly lockTTL: number; + private instance!: Hocuspocus; + private readonly customEvents: TCE; + private replyIdCounter: number = 0; + // @ts-ignore + private pendingReplies: Record['resolve']> = + {}; + + constructor(configuration: Configuration) { + const { + redis, + pack, + unpack, + serverId, + lockTTL, + prefix, + customEvents, + customEventTTL, + } = configuration; + this.pub = redis.duplicate(); + this.sub = redis.duplicate(); + this.pack = pack; + this.unpack = unpack; + this.serverId = serverId; + this.lockTTL = lockTTL ?? 10_000; + this.customEventTTL = customEventTTL ?? 30_000; + this.prefix = prefix ?? 'collab'; + this.lockPrefix = `${this.prefix}Lock`; + this.msgChannel = `${this.prefix}Msg`; + this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents); + this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`); + this.sub.on('messageBuffer', this.handleRedisMessage); + } + private getKey(documentName: string) { + return `${this.lockPrefix}:${documentName}`; + } + + private closeProxy(socketId: string) { + const proxySocket = this.proxySockets[socketId]; + if (proxySocket) { + proxySocket.emit( + 'close', + 1000, + Buffer.from('provider_initiated', 'utf-8'), + ); + delete this.proxySockets[socketId]; + } + } + + private pongProxy(socketId: string) { + this.proxySockets[socketId]?.emit('pong'); + } + + private handleProxyMessage( + msg: Pick, + ) { + const { replyTo, message, serializedHTTPRequest } = msg; + const { headers } = serializedHTTPRequest; + const socketId = headers['sec-websocket-key']!; + let socket = this.proxySockets[socketId]; + if (!socket) { + socket = new CollabProxySocket( + this.pub, + this.pack, + replyTo, + `${this.msgChannel}:${this.serverId}`, + socketId, + ); + this.proxySockets[socketId] = socket; + this.instance.handleConnection( + socket as any, + serializedHTTPRequest as any, + {}, + ); + } + socket.emit('message', message); + } + + private getOrClaimLock(documentName: string) { + const lockPromise = this.pub.set( + this.getKey(documentName), + this.serverId, + 'PX', + this.lockTTL, + 'NX', + 'GET', + ); + this.lockPromises[documentName] = lockPromise; + // Briefly cache the serverId that claimed the doc to reduce load on redis + // When the claimant unloads the doc, it will send an unload message to immediately clear this + // a lockTTL / 2 guarantees stale reads < lockTTL upon server crash + setTimeout(() => { + delete this.lockPromises[documentName]; + }, this.lockTTL / 2); + return lockPromise; + } + + private getOrClaimLockThrottled(documentName: string) { + const existingWorkerIdPromise = this.lockPromises[documentName]; + if (existingWorkerIdPromise) return existingWorkerIdPromise; + return this.getOrClaimLock(documentName); + } + + private handleRedisMessage = async ( + _channel: Buffer, + packedMessage: Buffer, + ) => { + const msg = this.unpack(packedMessage) as RSAMessage; + const { type } = msg; + if (type === 'proxy') { + this.handleProxyMessage(msg); + return; + } + if (type === 'closeProxy') { + this.closeProxy(msg.socketId); + return; + } + if (type === 'pong') { + this.pongProxy(msg.socketId); + return; + } + if (type === 'unload') { + delete this.lockPromises[msg.documentName]; + return; + } + if (type === 'customEventStart') { + const { documentName, eventName, payload, replyTo, replyId } = msg; + const res = await this.handleEventLocally( + eventName as Extract, + documentName, + payload, + ); + const reply: RSAMessageCustomEventComplete = { + type: 'customEventComplete', + replyId, + payload: res, + }; + this.pub.publish(`${replyTo}`, this.pack(reply)); + return; + } + if (type === 'customEventComplete') { + const { replyId, payload } = msg; + const resolveFn = this.pendingReplies[replyId]; + if (!resolveFn) return; + delete this.pendingReplies[replyId]; + resolveFn(payload); + return; + } + const { socketId } = msg; + const socket = this.originSockets[socketId]; + if (!socket) { + // origin socket already cleaned up + return; + } + if (type === 'close') { + socket.close(msg.code, msg.reason); + } else if (type === 'ping') { + // Reply instantly to the proxy socket, without forwarding to client + // The origin socket handles heartbeat for itself + const { replyTo, socketId } = msg; + const reply: RSAMessagePong = { + type: 'pong', + socketId, + }; + this.pub.publish(`${replyTo}`, this.pack(reply)); + } else if (type === 'send') { + socket.send(msg.message); + } + }; + + async maintainLock(documentName: string) { + this.locks[documentName] = setInterval(() => { + this.pub.set( + this.getKey(documentName), + this.serverId, + 'PX', + this.lockTTL, + ); + }, this.lockTTL / 2); + } + + async releaseLock(documentName: string) { + clearInterval(this.locks[documentName]); + delete this.locks[documentName]; + return this.pub.del(this.getKey(documentName)); + } + + private async handleEventLocally>( + eventName: TName, + documentName: string, + payload: any, + ) { + const handler = this.customEvents[eventName]; + if (!handler) throw new Error(`Invalid eventName: ${eventName}`); + const result = await handler(documentName, payload); + return result as Promise>; + } + + async handleEvent>( + eventName: TName, + documentName: string, + payload: any, + ) { + const isDocLoadedOnInstance = this.instance.documents.has(documentName); + + if (isDocLoadedOnInstance) { + return this.handleEventLocally(eventName, documentName, payload); + } + + const proxyTo = await this.getOrClaimLockThrottled(documentName); + if (proxyTo && proxyTo !== this.serverId) { + ++this.replyIdCounter; // bug in biome thinks this.replyIdCounter is not used if written on the line below + const replyId = this.replyIdCounter; + // another server owns the doc + const proxyMessage: RSAMessageCustomEventStart = { + eventName, + documentName, + payload, + replyTo: `${this.msgChannel}:${this.serverId}`, + replyId, + type: 'customEventStart', + }; + const msg = this.pack(proxyMessage); + this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg); + // @ts-ignore + const { promise, resolve, reject } = Promise.withResolvers(); + this.pendingReplies[replyId] = resolve; + setTimeout(() => { + reject('TIMEOUT'); + }, this.customEventTTL); + return promise as Promise>; + } + // This server owns the document, but hocuspocus hasn't loaded it yet + return this.handleEventLocally(eventName, documentName, payload); + } + + async lockDocument(documentName: string) { + const proxyTo = await this.getOrClaimLockThrottled(documentName); + if (proxyTo && proxyTo !== this.serverId) { + throw new Error(`Could not lock document: ${documentName}`); + } + this.maintainLock(documentName); + return () => this.releaseLock(documentName); + } + + /* WebSocket Server Hooks */ + onSocketOpen( + ws: BaseWebSocket, + serializedHTTPRequest: SerializedHTTPRequest, + context = {}, + ) { + const socketId = serializedHTTPRequest.headers['sec-websocket-key']!; + this.originSockets[socketId] = ws; + this.instance.handleConnection( + ws as any, + serializedHTTPRequest as any, + context, + ); + } + + async onSocketMessage( + ws: BaseWebSocket, + serializedHTTPRequest: SerializedHTTPRequest, + detachableMsg: ArrayBuffer, + ) { + const message = new Uint8Array(detachableMsg.slice()); + const tmpMsg = new IncomingMessage(detachableMsg); + const documentName = readVarString(tmpMsg.decoder); + const isDocLoadedOnInstance = this.instance.documents.has(documentName); + + if (isDocLoadedOnInstance) { + ws.emit('message', message); + return; + } + + const proxyTo = await this.getOrClaimLockThrottled(documentName); + if (proxyTo && proxyTo !== this.serverId) { + // another server owns the doc + const proxyMessage: RSAMessageProxy = { + serializedHTTPRequest: serializedHTTPRequest, + replyTo: `${this.msgChannel}:${this.serverId}`, + message, + type: 'proxy', + }; + const msg = this.pack(proxyMessage); + this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg); + return; + } + // This server owns the document, but hocuspocus hasn't loaded it yet + ws.emit('message', message); + } + + onSocketClose(socketId: string, code?: number, reason?: ArrayBuffer) { + const socket = this.originSockets[socketId]; + if (!socket) return; + // at this point the socket is considered GC'd and we cannot call close + // The origin socket did not set up any connections for the proxy, so none of the hooks will work if we just emit + socket?.emit('close', code, reason); + delete this.originSockets[socketId]; + const msg: RSAMessageCloseProxy = { type: 'closeProxy', socketId }; + this.pub.publish(this.msgChannel, this.pack(msg)).catch(() => {}); + } + + /* Hocuspocus hooks */ + async onConfigure({ instance }: onConfigurePayload) { + this.instance = instance; + } + + async onLoadDocument(data: onLoadDocumentPayload) { + const { documentName } = data; + // Refresh the lock TTL + this.maintainLock(documentName); + } + + async afterUnloadDocument(data: afterUnloadDocumentPayload) { + const { documentName } = data; + this.releaseLock(documentName); + // Broadcast to cluster to immediately remove the cached redis value + const msg: RSAMessageUnload = { type: 'unload', documentName }; + this.pub.publish(this.msgChannel, this.pack(msg)); + } + + async onDestroy() { + this.pub.disconnect(false); + this.sub.disconnect(false); + } +} diff --git a/apps/server/src/collaboration/extensions/redis-sync/redis-sync.types.ts b/apps/server/src/collaboration/extensions/redis-sync/redis-sync.types.ts new file mode 100644 index 00000000..1bbab80a --- /dev/null +++ b/apps/server/src/collaboration/extensions/redis-sync/redis-sync.types.ts @@ -0,0 +1,121 @@ +import EventEmitter from 'node:events'; +import { IncomingHttpHeaders } from 'node:http2'; +import RedisClient from 'ioredis'; + +export type SecondParam = T extends ( + arg1: unknown, + arg2: infer A, + ...args: unknown[] +) => unknown + ? A + : never; + +export type SerializedHTTPRequest = { + method: string; + url: string; + headers: IncomingHttpHeaders; + socket: { remoteAddress: string }; +}; + +export type RSAMessageProxy = { + type: 'proxy'; + replyTo: string; + message: Uint8Array; + serializedHTTPRequest: SerializedHTTPRequest; +}; + +export type RSAMessageCloseProxy = { + type: 'closeProxy'; + socketId: string; +}; + +export type RSAMessageUnload = { + type: 'unload'; + documentName: string; +}; + +export type RSAMessageClose = { + type: 'close'; + code?: number; + reason?: string; + socketId: string; +}; + +export type RSAMessagePing = { + type: 'ping'; + socketId: string; + replyTo: string; +}; + +export type RSAMessagePong = { + type: 'pong'; + socketId: string; +}; + +export type RSAMessageSend = { + type: 'send'; + // @ts-ignore + message: Uint8Array; + socketId: string; +}; + +export type RSAMessageCustomEventStart = { + type: 'customEventStart'; + documentName: string; + eventName: TName; + payload: TPayload; + replyTo: string; + replyId: number; +}; + +export type RSAMessageCustomEventComplete = { + type: 'customEventComplete'; + replyId: number; + payload: unknown; +}; + +export type RSAMessage = + | RSAMessageProxy + | RSAMessageCloseProxy + | RSAMessageUnload + | RSAMessageClose + | RSAMessagePing + | RSAMessagePong + | RSAMessageSend + | RSAMessageCustomEventStart + | RSAMessageCustomEventComplete; + +// @ts-ignore +export type Pack = (msg: RSAMessage) => string | Buffer; + +export type Unpack = ( + // @ts-ignore + packedMessage: Uint8Array | Buffer, +) => RSAMessage; + +type ServerId = string; +type DocumentName = string; +type CustomEventName = string; + +export type CustomEvents = Record< + CustomEventName, + (documentName: string, payload: unknown) => Promise +>; + +export interface Configuration { + redis: RedisClient; + pack: Pack; + unpack: Unpack; + serverId: ServerId; + lockTTL?: number; + customEventTTL?: number; + prefix?: string; + customEvents?: TCE; +} + +export type BaseWebSocket = EventEmitter & { + readyState: number; + close(code?: number, reason?: string): void; + ping(): void; + send(message: Uint8Array): void; +}; diff --git a/apps/server/src/collaboration/extensions/redis-sync/ws-socket-wrapper.ts b/apps/server/src/collaboration/extensions/redis-sync/ws-socket-wrapper.ts new file mode 100644 index 00000000..258e6e12 --- /dev/null +++ b/apps/server/src/collaboration/extensions/redis-sync/ws-socket-wrapper.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from 'events'; +import type WebSocket from 'ws'; + +/** + * Wrapper around ws WebSocket that only receives events via emit(). + * This prevents double-handling when used with RedisSyncExtension. + */ +export class WsSocketWrapper extends EventEmitter { + private ws: WebSocket; + readyState = 1; + + constructor(ws: WebSocket) { + super(); + this.ws = ws; + this.once('close', () => { + this.readyState = 3; + }); + } + + close(code?: number, reason?: string) { + if (this.readyState !== 1) return; + this.readyState = 3; + try { + this.ws.close(code, reason); + } catch (e) { + // Socket already closed + } + } + + ping() { + if (this.readyState !== 1) return; + try { + this.ws.ping(); + } catch (e) { + // Socket already closed + } + } + + send(message: Uint8Array) { + if (this.readyState !== 1) return; + try { + this.ws.send(message); + } catch (e) { + // Socket already closed + } + } +} diff --git a/apps/server/src/collaboration/server/collab-main.ts b/apps/server/src/collaboration/server/collab-main.ts index 1a10167f..4a86a71b 100644 --- a/apps/server/src/collaboration/server/collab-main.ts +++ b/apps/server/src/collaboration/server/collab-main.ts @@ -12,9 +12,11 @@ async function bootstrap() { const app = await NestFactory.create( CollabAppModule, new FastifyAdapter({ - ignoreTrailingSlash: true, - ignoreDuplicateSlashes: true, - maxParamLength: 500, + routerOptions: { + maxParamLength: 1000, + ignoreTrailingSlash: true, + ignoreDuplicateSlashes: true, + }, }), { bufferLogs: true, diff --git a/package.json b/package.json index 2b5096ef..9ffe3d83 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "@casl/ability": "6.8.0", "@docmost/editor-ext": "workspace:*", "@floating-ui/dom": "^1.7.3", - "@hocuspocus/extension-redis": "3.4.3", "@hocuspocus/provider": "3.4.3", "@hocuspocus/server": "3.4.3", "@hocuspocus/transformer": "3.4.3", diff --git a/packages/editor-ext/src/lib/attachment/attachment.ts b/packages/editor-ext/src/lib/attachment/attachment.ts index 0e37e014..a1e851a4 100644 --- a/packages/editor-ext/src/lib/attachment/attachment.ts +++ b/packages/editor-ext/src/lib/attachment/attachment.ts @@ -96,7 +96,7 @@ export const Attachment = Node.create({ mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes, + HTMLAttributes ), [ "a", diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index e6426f23..e0f5053d 100644 --- a/packages/editor-ext/src/lib/image/image.ts +++ b/packages/editor-ext/src/lib/image/image.ts @@ -25,7 +25,7 @@ declare module "@tiptap/core" { imageBlock: { setImage: (attributes: ImageAttributes) => ReturnType; setImageAt: ( - attributes: ImageAttributes & { pos: number | Range }, + attributes: ImageAttributes & { pos: number | Range } ) => ReturnType; setImageAlign: (align: "left" | "center" | "right") => ReturnType; setImageWidth: (width: number) => ReturnType; diff --git a/packages/editor-ext/src/lib/video/video.ts b/packages/editor-ext/src/lib/video/video.ts index 31c68f89..c3c6ab3e 100644 --- a/packages/editor-ext/src/lib/video/video.ts +++ b/packages/editor-ext/src/lib/video/video.ts @@ -23,7 +23,7 @@ declare module "@tiptap/core" { videoBlock: { setVideo: (attributes: VideoAttributes) => ReturnType; setVideoAt: ( - attributes: VideoAttributes & { pos: number | Range }, + attributes: VideoAttributes & { pos: number | Range } ) => ReturnType; setVideoAlign: (align: "left" | "center" | "right") => ReturnType; setVideoWidth: (width: number) => ReturnType; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3904280..7af8c424 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,9 +30,6 @@ importers: '@floating-ui/dom': specifier: ^1.7.3 version: 1.7.3 - '@hocuspocus/extension-redis': - specifier: 3.4.3 - version: 3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) '@hocuspocus/provider': specifier: 3.4.3 version: 3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) @@ -554,12 +551,18 @@ importers: ldapts: specifier: ^7.4.0 version: 7.4.0 + lib0: + specifier: ^0.2.117 + version: 0.2.117 mammoth: specifier: ^1.11.0 version: 1.11.0 mime-types: specifier: ^2.1.35 version: 2.1.35 + msgpackr: + specifier: ^1.11.8 + version: 1.11.8 nanoid: specifier: 3.3.11 version: 3.3.11 @@ -632,6 +635,9 @@ importers: tmp-promise: specifier: ^3.0.3 version: 3.0.3 + tseep: + specifier: ^1.3.1 + version: 1.3.1 typesense: specifier: ^2.1.0 version: 2.1.0(@babel/runtime@7.25.6) @@ -2383,12 +2389,6 @@ packages: '@hocuspocus/common@3.4.3': resolution: {integrity: sha512-wnBBO9sWcVAoUPEXN1qO+zk3HaEF9VTemxB6kjuuH6e1dHnD0v12m4P4X1wiZVhmMIX/PMl/fu3MGtYWQJz8gA==} - '@hocuspocus/extension-redis@3.4.3': - resolution: {integrity: sha512-r64Vpgk6tt0VZaQPEo1dQuyur2ozr243ncDcDM+4gFPuV8ZRUjL1rvaJTidb2HCcAW2zjfwshNxw4+OixeksBA==} - peerDependencies: - y-protocols: ^1.0.6 - yjs: ^13.6.8 - '@hocuspocus/provider@3.4.3': resolution: {integrity: sha512-zt+UgVXGsEQrqnDZgavc2PT9yKJjmVjV+5YxvhlmFVFLVORqawT4l601aKmLPhvyK97un4ZApZ5rso8iO6crWg==} peerDependencies: @@ -3884,12 +3884,6 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} - '@sesamecare-oss/redlock@1.4.0': - resolution: {integrity: sha512-2z589R+yxKLN4CgKxP1oN4dsg6Y548SE4bVYam/R0kHk7Q9VrQ9l66q+k1ehhSLLY4or9hcchuF9/MhuuZdjJg==} - engines: {node: '>=16'} - peerDependencies: - ioredis: '>=5' - '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -7593,13 +7587,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lib0@0.2.114: - resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==} - engines: {node: '>=16'} - hasBin: true - - lib0@0.2.88: - resolution: {integrity: sha512-KyroiEvCeZcZEMx5Ys+b4u4eEBbA1ch7XUaBhYpwa/nPMrzTjUhI4RfcytmQfYoTBPcdyx+FX6WFNIoNuJzJfQ==} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} engines: {node: '>=16'} hasBin: true @@ -7975,8 +7964,8 @@ packages: resolution: {integrity: sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==} hasBin: true - msgpackr@1.11.2: - resolution: {integrity: sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==} + msgpackr@1.11.8: + resolution: {integrity: sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==} multimath@2.0.0: resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==} @@ -9671,6 +9660,9 @@ packages: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} + tseep@1.3.1: + resolution: {integrity: sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ==} + tslib@2.8.0: resolution: {integrity: sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==} @@ -12659,27 +12651,13 @@ snapshots: '@hocuspocus/common@3.4.3': dependencies: - lib0: 0.2.114 - - '@hocuspocus/extension-redis@3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': - dependencies: - '@hocuspocus/server': 3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) - '@sesamecare-oss/redlock': 1.4.0(ioredis@5.8.2) - ioredis: 5.8.2 - kleur: 4.1.5 - lodash.debounce: 4.0.8 - y-protocols: 1.0.6(yjs@13.6.29) - yjs: 13.6.29 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate + lib0: 0.2.117 '@hocuspocus/provider@3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': dependencies: '@hocuspocus/common': 3.4.3 '@lifeomic/attempt': 3.0.3 - lib0: 0.2.114 + lib0: 0.2.117 ws: 8.19.0 y-protocols: 1.0.6(yjs@13.6.29) yjs: 13.6.29 @@ -12693,7 +12671,7 @@ snapshots: async-lock: 1.4.1 async-mutex: 0.5.0 kleur: 4.1.5 - lib0: 0.2.114 + lib0: 0.2.117 ws: 8.19.0 y-protocols: 1.0.6(yjs@13.6.29) yjs: 13.6.29 @@ -14153,10 +14131,6 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 - '@sesamecare-oss/redlock@1.4.0(ioredis@5.8.2)': - dependencies: - ioredis: 5.8.2 - '@sinclair/typebox@0.27.8': {} '@sindresorhus/slugify@1.1.0': @@ -14867,7 +14841,7 @@ snapshots: '@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': dependencies: - lib0: 0.2.114 + lib0: 0.2.117 prosemirror-model: 1.25.1 prosemirror-state: 1.4.3 prosemirror-view: 1.40.0 @@ -16065,7 +16039,7 @@ snapshots: dependencies: cron-parser: 4.9.0 ioredis: 5.8.2 - msgpackr: 1.11.2 + msgpackr: 1.11.8 node-abort-controller: 3.1.1 semver: 7.7.2 tslib: 2.8.1 @@ -18687,11 +18661,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lib0@0.2.114: - dependencies: - isomorphic.js: 0.2.5 - - lib0@0.2.88: + lib0@0.2.117: dependencies: isomorphic.js: 0.2.5 @@ -19166,7 +19136,7 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.2 optional: true - msgpackr@1.11.2: + msgpackr@1.11.8: optionalDependencies: msgpackr-extract: 3.0.2 @@ -21046,6 +21016,8 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tseep@1.3.1: {} + tslib@2.8.0: {} tslib@2.8.1: {} @@ -21519,12 +21491,12 @@ snapshots: y-indexeddb@9.0.12(yjs@13.6.29): dependencies: - lib0: 0.2.88 + lib0: 0.2.117 yjs: 13.6.29 y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29): dependencies: - lib0: 0.2.114 + lib0: 0.2.117 prosemirror-model: 1.25.1 prosemirror-state: 1.4.3 prosemirror-view: 1.40.0 @@ -21533,7 +21505,7 @@ snapshots: y-protocols@1.0.6(yjs@13.6.29): dependencies: - lib0: 0.2.114 + lib0: 0.2.117 yjs: 13.6.29 y18n@4.0.3: {} @@ -21586,7 +21558,7 @@ snapshots: yjs@13.6.29: dependencies: - lib0: 0.2.114 + lib0: 0.2.117 yn@3.1.1: {} From 60501de992d7c0bfcedbe2f91eca55ced5717a89 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:25:23 +0000 Subject: [PATCH 25/60] fix: missing logs on `OnApplicationBootstrap` hook (#1882) * - fix: set default Nest logger and bufferLogs to false for pino compatibility - handle redis error event * fix collab server logging too --- .../extensions/redis-sync/redis-sync.extension.ts | 2 ++ apps/server/src/collaboration/server/collab-main.ts | 3 ++- apps/server/src/main.ts | 10 ++++++++-- apps/server/src/ws/adapter/ws-redis.adapter.ts | 3 +++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/server/src/collaboration/extensions/redis-sync/redis-sync.extension.ts b/apps/server/src/collaboration/extensions/redis-sync/redis-sync.extension.ts index 5b78b9c0..38747465 100644 --- a/apps/server/src/collaboration/extensions/redis-sync/redis-sync.extension.ts +++ b/apps/server/src/collaboration/extensions/redis-sync/redis-sync.extension.ts @@ -79,6 +79,8 @@ export class RedisSyncExtension implements Extension { this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents); this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`); this.sub.on('messageBuffer', this.handleRedisMessage); + this.pub.on('error', () => {}); + this.sub.on('error', () => {}); } private getKey(documentName: string) { return `${this.lockPrefix}:${documentName}`; diff --git a/apps/server/src/collaboration/server/collab-main.ts b/apps/server/src/collaboration/server/collab-main.ts index 4a86a71b..4cac6878 100644 --- a/apps/server/src/collaboration/server/collab-main.ts +++ b/apps/server/src/collaboration/server/collab-main.ts @@ -19,7 +19,8 @@ async function bootstrap() { }, }), { - bufferLogs: true, + logger: false, + bufferLogs: false, }, ); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 406921a0..e8634a09 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -24,7 +24,11 @@ async function bootstrap() { }), { rawBody: true, - bufferLogs: true, + // disable Nest logger so pino handles all logs + // bufferLogs must be false else pino will fail + // to log OnApplicationBootstrap logs + logger: false, + bufferLogs: false, }, ); @@ -101,7 +105,9 @@ async function bootstrap() { const port = process.env.PORT || 3000; await app.listen(port, '0.0.0.0', () => { - logger.log(`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`); + logger.log( + `Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`, + ); }); } diff --git a/apps/server/src/ws/adapter/ws-redis.adapter.ts b/apps/server/src/ws/adapter/ws-redis.adapter.ts index 5aae1c7c..a221c84a 100644 --- a/apps/server/src/ws/adapter/ws-redis.adapter.ts +++ b/apps/server/src/ws/adapter/ws-redis.adapter.ts @@ -23,6 +23,9 @@ export class WsRedisIoAdapter extends IoAdapter { const pubClient = new Redis(process.env.REDIS_URL, options); const subClient = new Redis(process.env.REDIS_URL, options); + pubClient.on('error', (err) => () => {}); + subClient.on('error', (err) => () => {}); + this.adapterConstructor = createAdapter(pubClient, subClient); } From 96ed98619f1b4a0817da5680bd0bbd6737b12412 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:33:10 +0000 Subject: [PATCH 26/60] feat: add IPv6 support via configurable HOST binding (#1885) --- apps/server/src/collaboration/server/collab-main.ts | 3 ++- apps/server/src/main.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/server/src/collaboration/server/collab-main.ts b/apps/server/src/collaboration/server/collab-main.ts index 4cac6878..1839c2e3 100644 --- a/apps/server/src/collaboration/server/collab-main.ts +++ b/apps/server/src/collaboration/server/collab-main.ts @@ -37,7 +37,8 @@ async function bootstrap() { const logger = new Logger('CollabServer'); const port = process.env.COLLAB_PORT || 3001; - await app.listen(port, '0.0.0.0', () => { + const host = process.env.HOST || '0.0.0.0'; + await app.listen(port, host, () => { logger.log(`Listening on http://127.0.0.1:${port}`); }); } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index e8634a09..8b18a011 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -104,7 +104,8 @@ async function bootstrap() { }); const port = process.env.PORT || 3000; - await app.listen(port, '0.0.0.0', () => { + const host = process.env.HOST || '0.0.0.0'; + await app.listen(port, host, () => { logger.log( `Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`, ); From 78b1c1a453a5d8e019494ced5058d6642722651a Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 30 Jan 2026 19:28:54 +0000 Subject: [PATCH 27/60] feat: switch to cursor pagination (#1884) * add cursor pagination function * support custom order modifier * refactor returned object * feat(db): migrate paginated endpoints to cursor-based pagination * sync * support hasPrevPage boolean * feat(client): migrate pagination from offset to cursor-based * support beforeCursor/prevCursor * wrap search results in items array for API consistency --- .../client/src/components/common/paginate.tsx | 12 +- .../components/settings/settings-queries.tsx | 22 +- .../src/ee/api-key/pages/user-api-keys.tsx | 14 +- .../ee/api-key/pages/workspace-api-keys.tsx | 14 +- .../security/components/sso-provider-list.tsx | 80 ++-- .../src/ee/security/queries/security-query.ts | 3 +- .../ee/security/services/security-service.ts | 5 +- .../features/group/components/group-list.tsx | 19 +- .../group/components/group-members.tsx | 15 +- .../src/features/page/queries/page-query.ts | 16 +- .../features/page/services/page-service.ts | 15 +- .../features/page/trash/components/trash.tsx | 14 +- .../src/features/page/types/page.types.ts | 2 +- .../components/search-spotlight-filters.tsx | 20 +- .../search/services/search-service.ts | 12 +- .../features/share/components/share-list.tsx | 15 +- .../src/features/share/queries/share-query.ts | 2 +- .../features/space/components/space-grid.tsx | 2 +- .../features/space/components/space-list.tsx | 13 +- .../space/components/space-members.tsx | 12 +- .../spaces-page/all-spaces-list.tsx | 12 +- .../components/workspace-invites-table.tsx | 15 +- .../components/workspace-members-table.tsx | 12 +- apps/client/src/hooks/use-cursor-paginate.tsx | 28 ++ .../src/hooks/use-paginate-and-search.tsx | 23 +- apps/client/src/lib/types.ts | 6 +- apps/client/src/pages/spaces/spaces.tsx | 8 +- .../src/core/comment/comment.service.ts | 8 +- .../src/core/group/services/group.service.ts | 10 +- .../page/services/page-history.service.ts | 8 +- .../src/core/page/services/page.service.ts | 49 ++- apps/server/src/core/search/search.service.ts | 14 +- .../space/services/space-member.service.ts | 12 +- .../src/core/space/services/space.service.ts | 11 +- .../services/workspace-invitation.service.ts | 11 +- .../workspace/services/workspace.service.ts | 12 +- .../database/pagination/cursor-pagination.ts | 348 ++++++++++++++++++ .../database/pagination/pagination-options.ts | 13 +- .../database/repos/comment/comment.repo.ts | 14 +- .../database/repos/group/group-user.repo.ts | 12 +- .../src/database/repos/group/group.repo.ts | 36 +- .../database/repos/page/page-history.repo.ts | 14 +- .../src/database/repos/page/page.repo.ts | 54 ++- .../src/database/repos/share/share.repo.ts | 18 +- .../database/repos/space/space-member.repo.ts | 36 +- .../src/database/repos/space/space.repo.ts | 14 +- .../src/database/repos/user/user.repo.ts | 14 +- apps/server/src/ee | 2 +- .../import/file-task.controller.ts | 12 +- 49 files changed, 792 insertions(+), 341 deletions(-) create mode 100644 apps/client/src/hooks/use-cursor-paginate.tsx create mode 100644 apps/server/src/database/pagination/cursor-pagination.ts diff --git a/apps/client/src/components/common/paginate.tsx b/apps/client/src/components/common/paginate.tsx index d8e8106f..721c2f43 100644 --- a/apps/client/src/components/common/paginate.tsx +++ b/apps/client/src/components/common/paginate.tsx @@ -2,17 +2,17 @@ import { Button, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; export interface PagePaginationProps { - currentPage: number; hasPrevPage: boolean; hasNextPage: boolean; - onPageChange: (newPage: number) => void; + onPrev: () => void; + onNext: () => void; } export default function Paginate({ - currentPage, hasPrevPage, hasNextPage, - onPageChange, + onPrev, + onNext, }: PagePaginationProps) { const { t } = useTranslation(); @@ -25,7 +25,7 @@ export default function Paginate({
@@ -39,6 +61,6 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) { ); -} +}); export default HistoryItem; diff --git a/apps/client/src/features/page-history/components/history-list.tsx b/apps/client/src/features/page-history/components/history-list.tsx index 7b0d9ea2..0853b6d1 100644 --- a/apps/client/src/features/page-history/components/history-list.tsx +++ b/apps/client/src/features/page-history/components/history-list.tsx @@ -1,29 +1,27 @@ import { usePageHistoryListQuery, - usePageHistoryQuery, + prefetchPageHistory, } from "@/features/page-history/queries/page-history-query"; import HistoryItem from "@/features/page-history/components/history-item"; import { activeHistoryIdAtom, + activeHistoryPrevIdAtom, historyAtoms, } from "@/features/page-history/atoms/history-atoms"; -import { useAtom } from "jotai"; -import { useCallback, useEffect } from "react"; -import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core"; +import { useAtom, useSetAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { - pageEditorAtom, - titleEditorAtom, -} from "@/features/editor/atoms/editor-atoms"; -import { modals } from "@mantine/modals"; -import { notifications } from "@mantine/notifications"; + Button, + ScrollArea, + Group, + Divider, + Loader, + Center, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; -import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; -import { useParams } from "react-router-dom"; -import { - SpaceCaslAction, - SpaceCaslSubject, -} from "@/features/space/permissions/permissions.type.ts"; +import { useHistoryRestore } from "@/features/page-history/hooks"; + +const PREFETCH_DELAY_MS = 150; interface Props { pageId: string; @@ -32,62 +30,89 @@ interface Props { function HistoryList({ pageId }: Props) { const { t } = useTranslation(); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); + const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); + const setHistoryModalOpen = useSetAtom(historyAtoms); + const { - data: pageHistoryList, + data: pageHistoryData, isLoading, isError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, } = usePageHistoryListQuery(pageId); - const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId); - const [mainEditor] = useAtom(pageEditorAtom); - const [mainEditorTitle] = useAtom(titleEditorAtom); - const [, setHistoryModalOpen] = useAtom(historyAtoms); + const historyItems = useMemo( + () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [], + [pageHistoryData], + ); - const { spaceSlug } = useParams(); - const { data: space } = useSpaceQuery(spaceSlug); - const spaceRules = space?.membership?.permissions; - const spaceAbility = useSpaceAbility(spaceRules); + const loadMoreRef = useRef(null); + const prefetchTimeoutRef = useRef | null>(null); - const confirmModal = () => - modals.openConfirmModal({ - title: t("Please confirm your action"), - children: ( - - {t( - "Are you sure you want to restore this version? Any changes not versioned will be lost.", - )} - - ), - labels: { confirm: t("Confirm"), cancel: t("Cancel") }, - onConfirm: handleRestore, - }); + const { canRestore, confirmRestore } = useHistoryRestore(); - const handleRestore = useCallback(() => { - if (activeHistoryData) { - mainEditorTitle - .chain() - .clearContent() - .setContent(activeHistoryData.title, { emitUpdate: true }) - .run(); - mainEditor - .chain() - .clearContent() - .setContent(activeHistoryData.content) - .run(); - setHistoryModalOpen(false); - notifications.show({ message: t("Successfully restored") }); + const clearPrefetchTimeout = useCallback(() => { + if (prefetchTimeoutRef.current) { + clearTimeout(prefetchTimeoutRef.current); + prefetchTimeoutRef.current = null; } - }, [activeHistoryData]); + }, []); + + const handleHover = useCallback( + (historyId: string, index: number) => { + clearPrefetchTimeout(); + prefetchTimeoutRef.current = setTimeout(() => { + prefetchPageHistory(historyId); + const prevId = historyItems[index + 1]?.id; + if (prevId) { + prefetchPageHistory(prevId); + } + }, PREFETCH_DELAY_MS); + }, + [clearPrefetchTimeout, historyItems], + ); useEffect(() => { - if ( - pageHistoryList && - pageHistoryList.items.length > 0 && - !activeHistoryId - ) { - setActiveHistoryId(pageHistoryList.items[0].id); + return clearPrefetchTimeout; + }, [clearPrefetchTimeout]); + + const handleSelect = useCallback( + (id: string, index: number) => { + setActiveHistoryId(id); + setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); + }, + [historyItems, setActiveHistoryId, setActiveHistoryPrevId], + ); + + useEffect(() => { + if (historyItems.length > 0 && !activeHistoryId) { + setActiveHistoryId(historyItems[0].id); + setActiveHistoryPrevId(historyItems[1]?.id ?? ""); } - }, [pageHistoryList]); + }, [ + historyItems, + activeHistoryId, + setActiveHistoryId, + setActiveHistoryPrevId, + ]); + + useEffect(() => { + const sentinel = loadMoreRef.current; + if (!sentinel || !hasNextPage) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); if (isLoading) { return <>; @@ -97,40 +122,45 @@ function HistoryList({ pageId }: Props) { return
{t("Error loading page history.")}
; } - if (!pageHistoryList || pageHistoryList.items.length === 0) { + if (historyItems.length === 0) { return <>{t("No page history saved yet.")}; } return (
- {pageHistoryList && - pageHistoryList.items.map((historyItem, index) => ( - - ))} + {historyItems.map((historyItem, index) => ( + + ))} + {hasNextPage &&
} + {isFetchingNextPage && ( +
+ +
+ )} - {spaceAbility.cannot( - SpaceCaslAction.Manage, - SpaceCaslSubject.Page, - ) ? null : ( + {canRestore && ( <> - + diff --git a/apps/client/src/features/page-history/components/history-modal-body.tsx b/apps/client/src/features/page-history/components/history-modal-body.tsx index 199601fc..5673c82a 100644 --- a/apps/client/src/features/page-history/components/history-modal-body.tsx +++ b/apps/client/src/features/page-history/components/history-modal-body.tsx @@ -1,21 +1,45 @@ -import { ScrollArea } from "@mantine/core"; +import { + ActionIcon, + Group, + Paper, + ScrollArea, + Switch, + Text, +} from "@mantine/core"; import HistoryList from "@/features/page-history/components/history-list"; -import classes from "./history.module.css"; -import { useAtom } from "jotai"; -import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms"; +import classes from "./css/history.module.css"; +import { useAtom, useAtomValue } from "jotai"; +import { + activeHistoryIdAtom, + activeHistoryPrevIdAtom, + diffCountsAtom, + highlightChangesAtom, +} from "@/features/page-history/atoms/history-atoms"; import HistoryView from "@/features/page-history/components/history-view"; -import { useEffect } from "react"; +import { useRef } from "react"; +import { IconChevronUp, IconChevronDown } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { + useDiffNavigation, + useHistoryReset, +} from "@/features/page-history/hooks"; interface Props { pageId: string; } export default function HistoryModalBody({ pageId }: Props) { - const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); + const { t } = useTranslation(); + const scrollViewportRef = useRef(null); - useEffect(() => { - setActiveHistoryId(""); - }, [pageId]); + const activeHistoryId = useAtomValue(activeHistoryIdAtom); + const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom); + const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); + const diffCounts = useAtomValue(diffCountsAtom); + + useHistoryReset(pageId); + const { currentChangeIndex, handlePrevChange, handleNextChange } = + useDiffNavigation(scrollViewportRef); return (
@@ -25,11 +49,63 @@ export default function HistoryModalBody({ pageId }: Props) {
- -
- {activeHistoryId && } -
-
+
+ +
+ {activeHistoryId && } +
+
+ + {activeHistoryId && activeHistoryPrevId && ( + + + setHighlightChanges(e.currentTarget.checked)} + styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }} + /> + {highlightChanges && diffCounts && diffCounts.total > 0 && ( + + + {currentChangeIndex} of {diffCounts.total} + + + + + + + + + )} + + + )} +
); } diff --git a/apps/client/src/features/page-history/components/history-modal-mobile.tsx b/apps/client/src/features/page-history/components/history-modal-mobile.tsx new file mode 100644 index 00000000..1f2362a9 --- /dev/null +++ b/apps/client/src/features/page-history/components/history-modal-mobile.tsx @@ -0,0 +1,208 @@ +import { + ActionIcon, + Box, + Button, + Group, + Paper, + ScrollArea, + Select, + Switch, + Text, +} from "@mantine/core"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { + activeHistoryIdAtom, + activeHistoryPrevIdAtom, + diffCountsAtom, + highlightChangesAtom, + historyAtoms, +} from "@/features/page-history/atoms/history-atoms"; +import HistoryView from "@/features/page-history/components/history-view"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query"; +import { formattedDate } from "@/lib/time"; +import { + useDiffNavigation, + useHistoryReset, + useHistoryRestore, +} from "@/features/page-history/hooks"; +import classes from "./css/history-mobile.module.css"; + +interface Props { + pageId: string; + pageTitle?: string; +} + +export default function HistoryModalMobile({ pageId, pageTitle }: Props) { + const { t } = useTranslation(); + + const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); + const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); + const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); + const diffCounts = useAtomValue(diffCountsAtom); + const setHistoryModalOpen = useSetAtom(historyAtoms); + + const scrollViewportRef = useRef(null); + const dropdownViewportRef = useRef(null); + + const { + data: pageHistoryData, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = usePageHistoryListQuery(pageId); + + const historyItems = useMemo( + () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [], + [pageHistoryData], + ); + + const selectData = useMemo( + () => + historyItems.map((item) => ({ + value: item.id, + label: formattedDate(new Date(item.createdAt)), + userName: item.lastUpdatedBy?.name, + })), + [historyItems], + ); + + useHistoryReset(pageId); + const { canRestore, confirmRestore } = useHistoryRestore(); + const { currentChangeIndex, handlePrevChange, handleNextChange } = + useDiffNavigation(scrollViewportRef); + + useEffect(() => { + if (historyItems.length > 0 && !activeHistoryId) { + setActiveHistoryId(historyItems[0].id); + setActiveHistoryPrevId(historyItems[1]?.id ?? ""); + } + }, [ + historyItems, + activeHistoryId, + setActiveHistoryId, + setActiveHistoryPrevId, + ]); + + const handleDropdownScroll = useCallback(() => { + const viewport = dropdownViewportRef.current; + if (!viewport || !hasNextPage || isFetchingNextPage) return; + + const { scrollTop, scrollHeight, clientHeight } = viewport; + const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50; + + if (isNearBottom) { + fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + const handleSelectVersion = useCallback( + (value: string | null) => { + if (!value) return; + const index = historyItems.findIndex((item) => item.id === value); + if (index >= 0) { + setActiveHistoryId(value); + setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); + } + }, + [historyItems, setActiveHistoryId, setActiveHistoryPrevId], + ); + + if (isLoading) { + return null; + } + + return ( + + +