From 476b6b628cac08214723af546897e6a59730736d Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 7 Oct 2026 16:54:18 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8(frontend)=20integrate=20blocknote?= =?UTF-8?q?=20version=20history=20proof=20of=20concept?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the BlockNote preview to exercise native version history in Docs while preserving access-date-bounded restores and strict document creation. Migrate collaboration clients to Yjs 14, retain offline and HTTP fallback support, and align author colors and Typst PDF export with the preview. Signed-off-by: Nick the Sick --- CHANGELOG.md | 12 + src/frontend/apps/impress/package.json | 34 +- .../features/auth/components/AvatarSvg.tsx | 4 +- .../features/auth/components/UserAvatar.tsx | 36 +- .../impress/src/features/auth/userColors.ts | 38 ++ .../docs/doc-comments/api/DocsThreadStore.tsx | 4 +- .../docs/doc-comments/hooks/useComments.ts | 20 +- .../doc-editor/__tests__/DocEditor.spec.tsx | 2 +- .../doc-editor/components/AI/AIButtonMIT.tsx | 10 +- .../doc-editor/components/BlockNoteEditor.tsx | 85 ++- .../docs/doc-editor/components/DocEditor.tsx | 2 +- .../components/VersioningSidebarPanel.tsx | 46 ++ .../doc-editor/createDocsVersionStorage.ts | 171 ++++++ .../stores/useVersioningSidebarStore.tsx | 15 + .../src/features/docs/doc-editor/utils.ts | 31 +- .../docs/doc-export/hooks/useExportAGPL.tsx | 47 +- .../features/docs/doc-export/mappingPDF.tsx | 82 ++- .../src/features/docs/doc-export/types.ts | 10 + .../__tests__/localDocs.test.tsx | 8 +- .../doc-management/__tests__/utils.test.tsx | 6 +- .../doc-management/components/DocToolBox.tsx | 22 +- .../docs/doc-management/httpFallback.ts | 159 ++++++ .../features/docs/doc-management/localDocs.ts | 5 +- .../docs/doc-management/localPersistence.ts | 97 ++++ .../__tests__/useProviderStore.test.tsx | 496 +++++------------ .../stores/useProviderStore.tsx | 509 +++++++----------- .../src/features/docs/doc-management/utils.ts | 4 +- .../doc-share/components/SearchUserRow.tsx | 1 + .../features/docs/doc-share/hooks/index.ts | 2 + .../docs/doc-share/hooks/useDocUserStore.tsx | 84 +++ .../docs/doc-share/hooks/userColors.ts | 1 + .../components/DocVersionEditor.tsx | 4 +- .../footer/components/FooterActions.tsx | 14 +- .../service-worker/service-worker-api.ts | 4 +- .../apps/impress/src/i18n/translations.json | 48 +- .../impress/src/stores/useBroadcastStore.tsx | 28 +- src/frontend/package.json | 8 + src/frontend/servers/y-provider/package.json | 4 +- src/frontend/yarn.lock | 405 ++++++++------ src/helm/impress/README.md | 2 +- src/helm/impress/values.yaml | 1 + src/yhub-server/README.md | 32 +- src/yhub-server/__tests__/permissions.spec.ts | 44 ++ src/yhub-server/package.json | 4 +- src/yhub-server/src/api.ts | 50 +- src/yhub-server/src/permissions.ts | 6 + src/yhub-server/yarn.lock | 29 +- 47 files changed, 1585 insertions(+), 1141 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/auth/userColors.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/VersioningSidebarPanel.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/createDocsVersionStorage.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/stores/useVersioningSidebarStore.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/httpFallback.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/localPersistence.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/hooks/useDocUserStore.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/hooks/userColors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b5005e99..3ce1328a87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to - ✨(backend) add mention endpoint with cooldown-limited email notification #2447 - 🚩(setting) add feature flag on Duplicate with Children #2721 +- ✨(frontend) export PDF with Typst, including math and diagrams +- ✨(frontend) show yhub version history in the BlockNote sidebar +- ✨(frontend) provide consistent author and cursor colors in BlockNote +- 💄(frontend) share deterministic user colors across cursors and avatars - 💄(frontend) redesign 404 error standalone page #2696 - 💄(frontend) redesign 403 access denied page #2720 - ✨(frontend) duplicate with subdocuments #2584 @@ -76,6 +80,11 @@ and this project adheres to ### Changed - 🛂(backend) let users allowed to comment list each other's accesses #2447 +- ⬆️(frontend) update BlockNote preview packages to 8d235ba9eb +- ♻️(frontend) restore bounded editor history through native yhub rollback +- ♻️(frontend) use standalone YHub version storage and history extension +- ♻️(frontend) migrate the history panel to RenderInPortalElement +- ⬆️(collaboration) upgrade yhub to 0.9.1 with matching Yjs - ♻️(collaboration) migrate the collaboration server from hocuspocus to yhub - 💥(y-provider) y-provider becomes converter-only - 💥(backend) move the resource server JWKS from `/api/{version}/jwks` to @@ -87,11 +96,14 @@ and this project adheres to ### Fixed +- 🐛(frontend) fetch named versions fresh after deleting one - 🐛(documentation) fix minio port and keycloak realm in the k8s guide #2751 - 🐛(collaboration) bound the calls yhub makes to the backend with a timeout, `YHUB_BACKEND_REQUEST_TIMEOUT_MS`, 5s by default #2753 - 🐛(frontend) cache hashed `/_next/static/` assets and revalidate HTML #2770 - 🐛(backend) compensate document duplication failures #2755 +- 🐛(collaboration) restore editor versions without exposing full history +- 🐛(collaboration) grant bounded named-version access for the history sidebar - 🐛(frontend) open search results in a new tab with ctrl/cmd+click #2719 - 🐛(docker) pull minio images from pgsty - 🐛(backend) retry the duplicate of a document on a tree path collision diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index b0eb57ded8..b885c96436 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -24,17 +24,17 @@ "dependencies": { "@ag-media/react-pdf-table": "2.0.3", "@ai-sdk/openai": "3.0.119", - "@blocknote/code-block": "0.54.0", - "@blocknote/core": "0.54.0", - "@blocknote/diagram-block": "0.54.0", - "@blocknote/mantine": "0.54.0", - "@blocknote/math-block": "0.54.0", - "@blocknote/react": "0.54.0", - "@blocknote/xl-ai": "0.54.0", - "@blocknote/xl-docx-exporter": "0.54.0", - "@blocknote/xl-multi-column": "0.54.0", - "@blocknote/xl-odt-exporter": "0.54.0", - "@blocknote/xl-pdf-exporter": "0.54.0", + "@blocknote/code-block": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/code-block@8d235ba9eb", + "@blocknote/core": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/core@8d235ba9eb", + "@blocknote/diagram-block": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/diagram-block@8d235ba9eb", + "@blocknote/mantine": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/mantine@8d235ba9eb", + "@blocknote/math-block": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/math-block@8d235ba9eb", + "@blocknote/react": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/react@8d235ba9eb", + "@blocknote/xl-ai": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-ai@8d235ba9eb", + "@blocknote/xl-docx-exporter": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-docx-exporter@8d235ba9eb", + "@blocknote/xl-multi-column": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-multi-column@8d235ba9eb", + "@blocknote/xl-odt-exporter": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-odt-exporter@8d235ba9eb", + "@blocknote/xl-pdf-exporter": "https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-pdf-exporter@8d235ba9eb", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", "@emoji-mart/data": "1.2.1", @@ -54,7 +54,10 @@ "@tanstack/react-query": "5.103.2", "@tiptap/extension-find-and-replace": "3.31.3", "@tiptap/extensions": "*", - "@y/yhub-http-fallback": "0.1.1", + "@y/prosemirror": "^2.0.0-14", + "@y/protocols": "^1.0.6-rc.1", + "@y/websocket": "^4.0.0-3", + "@y/y": "^14.0.0-rc.26", "ai": "6.0.295", "canvg": "4.0.3", "clsx": "2.1.1", @@ -80,13 +83,10 @@ "react-resizable-panels": "3.0.6", "react-select": "5.10.2", "styled-components": "6.5.3", + "tex2typst": "0.6.2", "use-debounce": "10.1.1", "uuid": "14.0.2", - "y-indexeddb": "9.0.12", - "y-prosemirror": "1.3.7", - "y-protocols": "1.0.7", - "y-websocket": "3.1.0", - "yjs": "*", + "lib0": "1.0.0-rc.33", "zod": "4.6.5", "zustand": "5.0.15" }, diff --git a/src/frontend/apps/impress/src/features/auth/components/AvatarSvg.tsx b/src/frontend/apps/impress/src/features/auth/components/AvatarSvg.tsx index 4173f8ede5..d4995d70d2 100644 --- a/src/frontend/apps/impress/src/features/auth/components/AvatarSvg.tsx +++ b/src/frontend/apps/impress/src/features/auth/components/AvatarSvg.tsx @@ -6,12 +6,14 @@ type AvatarSvgProps = BoxProps & Omit, keyof BoxProps> & { initials: string; background: string; + foreground?: string; fontFamily?: string; }; export const AvatarSvg: React.FC = ({ initials, background, + foreground = 'rgba(255,255,255,0.9)', fontFamily, ...props }) => ( @@ -42,7 +44,7 @@ export const AvatarSvg: React.FC = ({ textAnchor="middle" fontSize="10" fontWeight="600" - fill="rgba(255,255,255,0.9)" + fill={foreground} fontFamily={fontFamily || 'Arial'} > {initials} diff --git a/src/frontend/apps/impress/src/features/auth/components/UserAvatar.tsx b/src/frontend/apps/impress/src/features/auth/components/UserAvatar.tsx index 21c234fca7..7d9166fe30 100644 --- a/src/frontend/apps/impress/src/features/auth/components/UserAvatar.tsx +++ b/src/frontend/apps/impress/src/features/auth/components/UserAvatar.tsx @@ -1,47 +1,33 @@ import { renderToStaticMarkup } from 'react-dom/server'; -import { tokens } from '@/cunningham'; +import { userColorsForId } from '../userColors'; import { AvatarSvg } from './AvatarSvg'; -const colors = tokens.themes.default.globals.colors; - -const avatarsColors = [ - colors['blue-1-500'], - colors['brown-500'], - colors['green-500'], - colors['orange-500'], - colors['pink-500'], - colors['purple-500'], - colors['yellow-500'], -]; - -const getColorFromName = (name: string) => { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - } - return avatarsColors[Math.abs(hash) % avatarsColors.length]; -}; - const getInitialFromName = (name: string) => { const splitName = name?.split(' '); return (splitName[0]?.charAt(0) || '?') + (splitName?.[1]?.charAt(0) || ''); }; type UserAvatarProps = { + userId?: string; fullName?: string; background?: string; }; -export const UserAvatar = ({ fullName, background }: UserAvatarProps) => { +export const UserAvatar = ({ + userId, + fullName, + background, +}: UserAvatarProps) => { const name = fullName?.trim() || '?'; return ( ); }; @@ -49,16 +35,18 @@ export const UserAvatar = ({ fullName, background }: UserAvatarProps) => { export const avatarUrlFromName = ( fullName?: string, fontFamily?: string, + userId?: string, ): string => { const name = fullName?.trim() || '?'; const initials = getInitialFromName(name).toUpperCase(); - const background = getColorFromName(name); + const background = userColorsForId(userId ?? name).color; const svgMarkup = renderToStaticMarkup( , ); diff --git a/src/frontend/apps/impress/src/features/auth/userColors.ts b/src/frontend/apps/impress/src/features/auth/userColors.ts new file mode 100644 index 0000000000..9a98885ba7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/userColors.ts @@ -0,0 +1,38 @@ +// Use the original cursor's pastel HSL ranges, seeded by identity rather than +// Math.random so cursors, avatars and author highlights agree across sessions. +export const userColorsForId = (id: string) => { + let hash = 2166136261; + for (let i = 0; i < id.length; i++) { + hash = Math.imul(hash ^ id.charCodeAt(i), 16777619); + } + + const nextInt = (min: number, max: number) => { + hash = (hash + 0x6d2b79f5) | 0; + let value = Math.imul(hash ^ (hash >>> 15), 1 | hash); + value ^= value + Math.imul(value ^ (value >>> 7), 61 | value); + const fraction = ((value ^ (value >>> 14)) >>> 0) / 4294967296; + return min + Math.floor(fraction * (max - min + 1)); + }; + + const hue = nextInt(0, 360); + const saturation = nextInt(42, 98); + const lightness = nextInt(70, 90); + + return { + color: hslToHex(hue, saturation, lightness), + colorLight: hslToHex(hue, saturation, 96), + }; +}; + +function hslToHex(h: number, s: number, l: number) { + l /= 100; + const a = (s * Math.min(l, 1 - l)) / 100; + const channel = (n: number) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color) + .toString(16) + .padStart(2, '0'); + }; + return `#${channel(0)}${channel(8)}${channel(4)}`; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-comments/api/DocsThreadStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-comments/api/DocsThreadStore.tsx index 549fa330ea..89e5066506 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-comments/api/DocsThreadStore.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-comments/api/DocsThreadStore.tsx @@ -1,6 +1,6 @@ import { CommentBody, ThreadStore } from '@blocknote/core/comments'; -import type { Awareness } from 'y-protocols/awareness'; -import * as Y from 'yjs'; +import type { Awareness } from '@y/protocols/awareness'; +import * as Y from '@y/y'; import { APIError, errorCauses, fetchAPI } from '@/api'; import { Doc } from '@/features/docs/doc-management'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-comments/hooks/useComments.ts b/src/frontend/apps/impress/src/features/docs/doc-comments/hooks/useComments.ts index 5903d39768..d0152091af 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-comments/hooks/useComments.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-comments/hooks/useComments.ts @@ -5,6 +5,7 @@ import { useConfig } from '@/core'; import { useCunninghamTheme } from '@/cunningham'; import { User, avatarUrlFromName } from '@/features/auth'; import { Doc, useProviderStore } from '@/features/docs/doc-management'; +import { useDocAccesses } from '@/features/docs/doc-share/api/useDocAccesses'; import { DocsThreadStore } from '../api/DocsThreadStore'; import { DocsThreadStoreAuth } from '../api/DocsThreadStoreAuth'; @@ -20,6 +21,7 @@ export function useComments( const { themeTokens } = useCunninghamTheme(); const { setThreadStore } = useThreadStore(); const { data: config } = useConfig(); + const { data: accesses } = useDocAccesses({ docId }); const threadStore = useMemo(() => { return new DocsThreadStore( @@ -61,9 +63,24 @@ export function useComments( const resolveUsers = useCallback( async (userIds: string[]) => { + // Legacy comments identify authors by name, not by user id. Only + // resolve an unambiguous name; unknown authors keep a stable fallback. + const knownUsers = new Map(); + if (user) { + knownUsers.set(user.id, user); + } + accesses?.forEach(({ user: author }) => { + if (author) { + knownUsers.set(author.id, author); + } + }); return Promise.resolve( userIds.map((encodedURIUserId) => { const fullName = decodeURIComponent(encodedURIUserId); + const matches = Array.from(knownUsers.values()).filter( + (author) => author.full_name === fullName, + ); + const authorId = matches.length === 1 ? matches[0].id : undefined; return { id: encodedURIUserId, @@ -71,12 +88,13 @@ export function useComments( avatarUrl: avatarUrlFromName( fullName, themeTokens?.font?.families?.base, + authorId, ), }; }), ); }, - [t, themeTokens?.font?.families?.base], + [t, themeTokens?.font?.families?.base, user, accesses], ); return { threadStore, resolveUsers }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx index 9db8681311..243f95e433 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx @@ -29,7 +29,7 @@ vi.mock('../../doc-management', async () => { provider: { roomname: 'test-doc-id', doc: { - getXmlFragment: () => null, + get: () => null, }, }, isReady: true, diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIButtonMIT.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIButtonMIT.tsx index 02e13215be..814a0ff69d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIButtonMIT.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/AI/AIButtonMIT.tsx @@ -3,6 +3,7 @@ import { ComponentProps, useBlockNoteEditor, useComponentsContext, + usePortalElement, useSelectedBlocks, } from '@blocknote/react'; import { Loader, VariantType } from '@gouvfr-lasuite/ui-components'; @@ -55,6 +56,7 @@ const sortByPopularLanguages = ( export function AIGroupButton() { const editor = useBlockNoteEditor(); + const portalElement = usePortalElement(); const Components = useComponentsContext(); const selectedBlocks = useSelectedBlocks(editor); const { t } = useTranslation(); @@ -98,7 +100,7 @@ export function AIGroupButton() { } return ( - + )} {canAITranslate && ( - + { const refEditorContainer = useRef(null); const { i18n, t } = useTranslation(); + const { + isOpen: isVersioningSidebarOpen, + setIsOpen: setIsVersioningSidebarOpen, + } = useVersioningSidebarStore(); const langLocalesBN = !i18n.resolvedLanguage || !(i18n.resolvedLanguage in localesBN) ? DEFAULT_LOCALE @@ -143,6 +153,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { doc.abilities?.ai_proxy ); const aiExtension = useAI?.(doc.id, aiBlockNoteAllowed); + const versionGranularityMs = Math.max( + conf?.COLLABORATION_VERSION_GRANULARITY_MS ?? MIN_VERSION_GRANULARITY_MS, + MIN_VERSION_GRANULARITY_MS, + ); const collabName = user?.full_name || user?.email; const cursorName = collabName || t('Anonymous'); @@ -156,6 +170,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { canSeeComment, user, ); + // Resolve author ids stored in the document (versioning, suggestions) to users. + const docUserStore = useDocUserStore(doc.id); // Comment sidebar const { threadsSidebarTarget, filter: threadsSidebarFilter } = @@ -165,19 +181,53 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const currentUserAvatarUrl = useMemo(() => { if (canSeeComment) { - return avatarUrlFromName(collabName, themeTokens?.font?.families?.base); + return avatarUrlFromName( + collabName, + themeTokens?.font?.families?.base, + user?.id ?? 'anonymous', + ); + } + }, [canSeeComment, collabName, themeTokens?.font?.families?.base, user?.id]); + + const collabTarget = useMemo(() => { + if (!provider.serverUrl) { + return undefined; + } + return collaborationHttpTarget(provider.serverUrl); + }, [provider.serverUrl]); + + const versioningExtension = useMemo(() => { + if (!collabTarget) { + return undefined; } - }, [canSeeComment, collabName, themeTokens?.font?.families?.base]); + const storage = createDocsVersionStorage({ + baseUrl: collabTarget.serverUrl, + org: collabTarget.org, + docId: doc.id, + fragment: provider.doc.get('document-store'), + beforeRestoreName: + localesBN[langLocalesBN as keyof typeof localesBN].versioning + .before_restore, + activityParams: { + group: true, + groupMaxGap: versionGranularityMs, + groupMaxDuration: versionGranularityMs, + }, + }); + return YVersioningExtension({ storage }); + }, [collabTarget, doc.id, langLocalesBN, provider.doc, versionGranularityMs]); const editor: DocsBlockNoteEditor = useCreateBlockNote( withCollaboration({ collaboration: { provider, - fragment: provider.doc.getXmlFragment('document-store'), + fragment: provider.doc.get('document-store'), user: { + id: user?.id ?? 'anonymous', name: cursorName, - color: randomColor(), + color: userColorsForId(user?.id ?? 'anonymous').color, }, + resolveUsers: docUserStore, /** * We render the cursor with a custom element to: * - fix rendering issue with the default cursor @@ -283,6 +333,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { // Highlights the source of code blocks and of the math / diagram // blocks' editable LaTeX / Mermaid popups. syntaxHighlighter, + ...(versioningExtension ? [versioningExtension] : []), CommentsExtension({ threadStore, resolveUsers }), ...(aiExtension ? [aiExtension] : []), ], @@ -311,6 +362,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { [ aiExtension, cursorName, + docUserStore, langLocalesBN, langLocalesBNMultiColumn, langLocalesBNAI, @@ -319,6 +371,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { uploadFile, threadStore, resolveUsers, + versioningExtension, ], ); @@ -339,7 +392,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }, [setEditor, editor]); return ( - + { comments={false} aria-label={t('Document editor')} // To not clipped the floating part in the editor area - portalElements={{ default: null }} + portalElements={{ default: 'body' }} > {aiBlockNoteAllowed && AIMenuController && AIMenu && ( @@ -383,6 +440,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { />, threadsSidebarTarget, )} + {isVersioningSidebarOpen && ( + setIsVersioningSidebarOpen(false)} + /> + )} ); @@ -390,7 +452,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { interface BlockNoteReaderProps { docId: Doc['id']; - initialContent: Y.XmlFragment; + initialContent: Y.Node; isMainEditor?: boolean; } @@ -407,6 +469,7 @@ export const BlockNoteReader = ({ collaboration: { fragment: initialContent, user: { + id: '', name: '', color: '', }, diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index f9b526cfce..c57ba656cf 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -158,7 +158,7 @@ export const DocCoreEditor = ({ doc, readOnly }: DocCoreEditorProps) => { if (readOnly) { return ( ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/VersioningSidebarPanel.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/VersioningSidebarPanel.tsx new file mode 100644 index 0000000000..8681413351 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/VersioningSidebarPanel.tsx @@ -0,0 +1,46 @@ +import { RenderInPortalElement } from '@blocknote/react'; +import { VersioningSidebar } from '@blocknote/react/versioning'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components'; + +/** + * Full-page right-side panel hosting the BlockNote version history sidebar. + * + * RenderInPortalElement renders the panel outside the editor's scroll container + * with the editor's theme and focus handling. + */ +export const VersioningSidebarPanel = ({ + onClose, +}: { + onClose: () => void; +}) => { + const { t } = useTranslation(); + + return ( + + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/createDocsVersionStorage.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/createDocsVersionStorage.ts new file mode 100644 index 0000000000..92588bce17 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/createDocsVersionStorage.ts @@ -0,0 +1,171 @@ +import { type VersionResult } from '@blocknote/core/extensions'; +import { + type YHubVersionStorageOptions, + collectFragmentIds, + createYHubVersionStorage, +} from '@blocknote/core/y'; +import * as Y from '@y/y'; +import { decodeAny, encodeAny } from 'lib0/buffer'; + +/** Restore the editor using only history inside Docs' access-date boundary. */ +export const createDocsVersionStorage = ( + options: YHubVersionStorageOptions & { + fragment: Y.Node; + beforeRestoreName: string; + }, +): ReturnType => { + const liveDoc: unknown = options.fragment.doc; + if (!(liveDoc instanceof Y.Doc)) { + throw new Error( + 'Version history requires an attached collaboration document', + ); + } + const storage = createYHubVersionStorage(options); + const documentPath = `${encodeURIComponent(options.org)}/${encodeURIComponent(options.docId)}`; + + async function request( + endpoint: string, + decode: (data: ArrayBuffer) => T, + init?: RequestInit, + params?: Record, + ): Promise> { + const query = new URLSearchParams( + Object.entries({ ...params, ...options.queryParams }) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, String(value)]), + ); + const timeout = AbortSignal.timeout(options.timeoutMs ?? 30_000); + let data: ArrayBuffer; + try { + const response = await fetch( + `${options.baseUrl}/${endpoint}/v1/${documentPath}${query.size ? `?${query}` : ''}`, + { + ...init, + signal: timeout, + headers: { ...options.headers, ...init?.headers }, + }, + ); + if (!response.ok) { + return { + ok: false, + error: + response.status === 401 || response.status === 403 + ? { type: 'forbidden' } + : response.status === 409 || response.status === 412 + ? { type: 'conflict' } + : response.status === 404 + ? { type: 'not-found' } + : { type: 'server', status: response.status }, + }; + } + data = await response.arrayBuffer(); + } catch (error) { + if (timeout.aborted) { + return { + ok: false, + error: { + type: 'timeout', + outcome: init?.method ? 'unknown' : 'unchanged', + }, + }; + } + if (error instanceof TypeError) { + return { ok: false, error: { type: 'network' } }; + } + throw error; + } + return { ok: true, value: decode(data) }; + } + + return { + ...storage, + async restore(id) { + const to = Number(id); + if ( + !Number.isSafeInteger(to) || + to < 0 || + to === Number.MAX_SAFE_INTEGER + ) { + throw new Error('Invalid version timestamp'); + } + const signal = new AbortController().signal; + const versions = await storage.list(signal); + if (!versions.ok) { + return versions; + } + const current = versions.value.snapshots.reduce< + (typeof versions.value.snapshots)[number] | undefined + >( + (latest, version) => + !latest || version.createdAt > latest.createdAt ? version : latest, + undefined, + ); + if (current && !current.name) { + // The new storage validates content against the latest checkpoint. + // Pin the old head, never the selected historical preview. + const content = await storage.getContent(current.id, signal); + if (!content.ok) { + return content; + } + const saved = await storage.create( + content.value, + options.beforeRestoreName, + ); + if (!saved.ok) { + return saved; + } + } + // changeset retains deleted descendants inside this window, but collects + // older deleted content. Unlike gc=false, it respects the access-date ray. + // No finite `to`: the IDs must cover the latest available editor state. + const from = to + 1; + const retained = await request( + 'changeset', + (data) => { + const { ydoc } = decodeAny(new Uint8Array(data)) as { + ydoc?: Uint8Array; + }; + if (!ydoc) { + throw new Error('YHub returned no changeset document'); + } + return ydoc; + }, + undefined, + { from, ydoc: true }, + ); + if (!retained.ok) { + return retained; + } + const ids = collectFragmentIds(options.fragment, retained.value); + // Native rollback is additive, not a lock: new IDs written after this + // snapshot may survive. Other roots are excluded from both ID sets. + const restored = await request('rollback', () => undefined, { + method: 'POST', + headers: { 'Content-Type': 'application/x-lib0any' }, + body: encodeAny({ + from, + contentIds: Y.encodeContentIds({ inserts: ids, deletes: ids }), + }) as BodyInit, + }); + if (!restored.ok) { + return restored; + } + // Success means the live doc received the restore, even if the websocket + // is delayed. Fetch only current GC content, not restricted full history. + const document = await request('ydoc', (data) => { + const { doc } = decodeAny(new Uint8Array(data)) as { doc?: Uint8Array }; + if (!doc) { + throw new Error('YHub returned no document state'); + } + return doc; + }); + if (!document.ok) { + return document.error.type === 'timeout' + ? { ok: false, error: { type: 'timeout', outcome: 'unknown' } } + : document; + } + Y.applyUpdate(liveDoc, document.value); + return { ok: true, value: undefined }; + }, + }; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useVersioningSidebarStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useVersioningSidebarStore.tsx new file mode 100644 index 0000000000..d8c7cabab8 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useVersioningSidebarStore.tsx @@ -0,0 +1,15 @@ +import { create } from 'zustand'; + +export interface UseVersioningSidebarStore { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + toggle: () => void; +} + +export const useVersioningSidebarStore = create( + (set) => ({ + isOpen: false, + setIsOpen: (isOpen) => set({ isOpen }), + toggle: () => set((state) => ({ isOpen: !state.isOpen })), + }), +); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/utils.ts index 804f29e989..609aa52843 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/utils.ts @@ -1,3 +1,5 @@ +import { userColorsForId } from '@/features/auth/userColors'; + import { DocsBlockNoteEditor } from './types'; const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; @@ -11,30 +13,7 @@ export const getWordCount = (editor?: DocsBlockNoteEditor): number => { }; export const sanitizeColor = (color: string): string => { - return HEX_COLOR_REGEX.test(color) ? color : randomColor(); -}; - -export const randomColor = () => { - const randomInt = (min: number, max: number) => { - return Math.floor(Math.random() * (max - min + 1)) + min; - }; - - const h = randomInt(0, 360); // hue - const s = randomInt(42, 98); // saturation - const l = randomInt(70, 90); // lightness - - return hslToHex(h, s, l); + return HEX_COLOR_REGEX.test(color) + ? color + : userColorsForId('anonymous').color; }; - -function hslToHex(h: number, s: number, l: number) { - l /= 100; - const a = (s * Math.min(l, 1 - l)) / 100; - const f = (n: number) => { - const k = (n + h / 30) % 12; - const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); - return Math.round(255 * color) - .toString(16) - .padStart(2, '0'); - }; - return `#${f(0)}${f(8)}${f(4)}`; -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx index 4e02985e78..b10a4a361f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx @@ -5,14 +5,12 @@ import { DOCXExporter } from '@blocknote/xl-docx-exporter'; import { ODTExporter } from '@blocknote/xl-odt-exporter'; import { PDFExporter } from '@blocknote/xl-pdf-exporter'; -import { DocumentProps, pdf } from '@react-pdf/renderer'; -import jsonemoji from 'emoji-datasource-apple' with { type: 'json' }; import i18next from 'i18next'; -import { cloneElement, isValidElement } from 'react'; import { useTranslation } from 'react-i18next'; import { DocsBlockNoteEditor } from '@/docs/doc-editor/types'; import { Doc } from '@/docs/doc-management/types'; +import { fallbackLng } from '@/i18n/config'; import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl'; import { getDocxDocsSchemaMappings } from '../mappingDocx'; @@ -37,42 +35,17 @@ export const useExportAGPL = (doc: Doc, editor?: DocsBlockNoteEditor) => { getPdfDocsSchemaMappings(interlinkTitles), { resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url), - emojiSource: { - format: 'png', - builder(code) { - const emojisFound = jsonemoji.filter( - (e) => - e.unified.split('-')[0].toLowerCase() === - code.split('-')[0].toLowerCase(), - ); - - const emoji = emojisFound.find((e) => - e.unified.toLocaleLowerCase().includes(code.toLowerCase()), - ); - - if (emoji) { - return `/assets/fonts/emoji/${emoji.image}`; - } - - return '/assets/fonts/emoji/fallback.png'; - }, - }, }, ); - const rawPdfDocument = (await exporter.toReactPDFDocument( - exportDocument, - )) as React.ReactElement; - - // Add language, title and outline properties to improve PDF accessibility and navigation - const pdfDocument = isValidElement(rawPdfDocument) - ? cloneElement(rawPdfDocument, { - language: i18next.language, - title: documentTitle, - pageMode: 'useOutlines', - }) - : rawPdfDocument; - - blobExport = await pdf(pdfDocument).toBlob(); + const result = await exporter.toPDF(exportDocument, { + title: documentTitle, + lang: (i18next.language || fallbackLng).split(/[-_]/)[0], + }); + if (result.error) { + console.error('PDF export failed', result.compileErrors); + return; + } + blobExport = result.blob; } else if (format === 'docx') { const exporter = new DOCXExporter( editor.schema, diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx index 7129b50f98..1fb8c4ddf0 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx @@ -1,56 +1,54 @@ -import { diagramBlockMapping } from '@blocknote/diagram-block/pdf-exporter'; +import { diagramBlockMapping } from '@blocknote/diagram-block/typst-exporter'; import { inlineMathMapping, mathBlockMapping, -} from '@blocknote/math-block/pdf-exporter'; -import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter'; +} from '@blocknote/math-block/typst-exporter'; +import { strLit, typstDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter'; -import { - blockMappingCalloutPDF, - blockMappingHeadingPDF, - blockMappingImagePDF, - blockMappingParagraphPDF, - blockMappingQuotePDF, - blockMappingTablePDF, - blockMappingUploadLoaderPDF, -} from './blocks-mapping'; -import { createInlineContentMappingInterlinkingLinkPDF } from './inline-content-mapping'; -import { DocsExporterPDF } from './types'; +import { getEmojiAndTitle } from '@/docs/doc-management'; + +import { DocsExporterTypst } from './types'; export const getPdfDocsSchemaMappings = ( interlinkTitles: Map, -): DocsExporterPDF['mappings'] => ({ - ...pdfDefaultSchemaMappings, +): DocsExporterTypst['mappings'] => ({ + ...typstDefaultSchemaMappings, blockMapping: { - ...pdfDefaultSchemaMappings.blockMapping, - callout: blockMappingCalloutPDF, - heading: blockMappingHeadingPDF, - image: blockMappingImagePDF, - paragraph: blockMappingParagraphPDF, - quote: blockMappingQuotePDF, - table: blockMappingTablePDF, - // We're using the file block mapping for PDF blocks - // The types don't match exactly but the implementation is compatible - // eslint-disable-next-line @typescript-eslint/no-explicit-any - pdf: pdfDefaultSchemaMappings.blockMapping.file as any, - uploadLoader: blockMappingUploadLoaderPDF, - // Renders the LaTeX as a vector formula (via @react-pdf/math). - mathBlock: mathBlockMapping, - // Renders the Mermaid source to a PNG in the browser (async mapping). + ...typstDefaultSchemaMappings.blockMapping, + callout: (block, exporter) => + `#block(width: 100%, inset: 8pt)[#${strLit(block.props.emoji)} ${exporter.transformInlineContent(block.content).join('')}]`, + // PDF blocks use the same link rendering as file blocks. + pdf: (block) => { + const { url, name, caption } = block.props; + if (!url && !name) { + return ''; + } + const label = name || 'Open PDF file'; + const link = url + ? `#link(${strLit(url)})[#${strLit(label)}]` + : `#${strLit(label)}`; + return caption + ? `${link}#linebreak()#text(size: 9.6pt, fill: luma(110))[#${strLit(caption)}]` + : link; + }, + uploadLoader: (block) => + `#${strLit(block.props.type === 'loading' ? '⏳' : '⚠️')} #${strLit(block.props.information)}`, diagram: diagramBlockMapping, + mathBlock: mathBlockMapping, }, inlineContentMapping: { - ...pdfDefaultSchemaMappings.inlineContentMapping, - interlinkingLinkInline: - createInlineContentMappingInterlinkingLinkPDF(interlinkTitles), - // Inline math is rasterized to an image that flows with the text. + ...typstDefaultSchemaMappings.inlineContentMapping, + interlinkingLinkInline: (inline) => { + const title = + inline.props.docId && interlinkTitles.get(inline.props.docId); + if (!inline.props.docId || !title || inline.props.disabled) { + return ''; + } + + const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title); + const href = `${window.location.origin}/docs/${inline.props.docId}/${inline.props.blockId ? `#${inline.props.blockId}` : ''}`; + return `#link(${strLit(href)})[#${strLit(`${emoji || '📄'} ${titleWithoutEmoji}`)}]`; + }, math: inlineMathMapping, }, - styleMapping: { - ...pdfDefaultSchemaMappings.styleMapping, - // Switch to core PDF "Courier" font to avoid relying on GeistMono - // that is not available in italics - code: (enabled?: boolean) => - enabled ? { fontFamily: 'Courier', backgroundColor: '#dcdcdc' } : {}, - }, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/types.ts b/src/frontend/apps/impress/src/features/docs/doc-export/types.ts index 65a2d78340..20fb67706d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/types.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-export/types.ts @@ -24,6 +24,16 @@ export type DocsExporterPDF = Exporter< React.ReactElement >; +export type DocsExporterTypst = Exporter< + NoInfer, + NoInfer, + NoInfer, + string, + string, + (inner: string) => string, + string +>; + export type DocsExporterDocx = Exporter< DocsBlockSchema, DocsInlineContentSchema, diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/__tests__/localDocs.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/__tests__/localDocs.test.tsx index a466d3ae56..2348a8fdf7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/__tests__/localDocs.test.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/__tests__/localDocs.test.tsx @@ -12,8 +12,10 @@ const sweep = (days: number = RETENTION_DAYS) => sweepLocalDocs(days); * one store. */ const store = new Map(); +const mockedClearDocument = vi.fn().mockResolvedValue(undefined); vi.mock('idb', () => ({ + deleteDB: (name: string) => mockedClearDocument(name), openDB: vi.fn(() => Promise.resolve({ get: (_s: string, key: string) => Promise.resolve(store.get(key)), @@ -32,12 +34,6 @@ vi.mock('idb', () => ({ ), })); -const mockedClearDocument = vi.fn().mockResolvedValue(undefined); - -vi.mock('y-indexeddb', () => ({ - clearDocument: (name: string) => mockedClearDocument(name), -})); - const uuid = (n: number) => `0000000${n}-0000-4000-8000-000000000000`.slice(-36); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/__tests__/utils.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/__tests__/utils.test.tsx index 4c545d5096..bb3046a071 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/__tests__/utils.test.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/__tests__/utils.test.tsx @@ -1,5 +1,5 @@ +import * as Y from '@y/y'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import * as Y from 'yjs'; import { LinkReach, LinkRole } from '../types'; import { @@ -10,9 +10,9 @@ import { getEmojiAndTitle, } from '../utils'; -vi.mock('yjs', () => { +vi.mock('@y/y', () => { class MockDoc { - getXmlFragment = vi.fn().mockReturnValue('mocked-xml-fragment'); + get = vi.fn().mockReturnValue('mocked-xml-fragment'); } return { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/DocToolBox.tsx index 9b597fa2d4..1263a11d1c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/DocToolBox.tsx @@ -17,6 +17,7 @@ import { Box } from '@/components/Box'; import { Text } from '@/components/Text'; import { useConfig } from '@/core/config/api'; import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore'; +import { useVersioningSidebarStore } from '@/docs/doc-editor/stores/useVersioningSidebarStore'; import { getWordCount } from '@/docs/doc-editor/utils'; import { printDocumentWithStyles } from '@/docs/doc-export/utils_print'; import { useDuplicatedDoc } from '@/docs/doc-management/components/ConfirmationDuplicateModal'; @@ -82,14 +83,6 @@ const ModalRemoveDoc = dynamic( { ssr: false }, ); -const ModalSelectVersion = dynamic( - () => - import('@/docs/doc-versioning/components/ModalSelectVersion').then( - (mod) => ({ default: mod.ModalSelectVersion }), - ), - { ssr: false }, -); - const DocShareModal = dynamic( () => import('@/docs/doc-share/components/DocShareModal').then((mod) => ({ @@ -154,7 +147,6 @@ const DocToolBoxComponent = ({ const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); const [isModalExportOpen, setIsModalExportOpen] = useState(false); const [isModalShareOpen, setIsModalShareOpen] = useState(false); - const [isModalHistoryOpen, setIsModalHistoryOpen] = useState(false); const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false); const [isModalMoveOpen, setIsModalMoveOpen] = useState(false); const { onClick: onButtonClick, ...buttonPropsLeft } = buttonProps || {}; @@ -165,6 +157,7 @@ const DocToolBoxComponent = ({ conf?.DUPLICATE_CHILDREN_FEATURE_ENABLED && doc.numchild ); + const { setIsOpen: setIsVersioningSidebarOpen } = useVersioningSidebarStore(); const editor = useEditorStore((state) => state.editor); const wordCountLabel = useMemo(() => { @@ -365,7 +358,7 @@ const DocToolBoxComponent = ({ icon: