diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b5005e99..e0d0383b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,18 @@ and this project adheres to ### Added +- ✨(frontend) add local history grouping controls and test checkpoints - 👷(ci) lint, typecheck, build and test the collaboration server (yhub) - 👷(ci) check the load-test tooling: swarm, canary, k6 and dashboards - 👷(ci) cancel the superseded runs of a pull request - ✨(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) derive readable author colors with matching pastel highlights - 💄(frontend) redesign 404 error standalone page #2696 - 💄(frontend) redesign 403 access denied page #2720 - ✨(frontend) duplicate with subdocuments #2584 @@ -76,6 +82,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 +98,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..a3927752b8 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,34 @@ import { renderToStaticMarkup } from 'react-dom/server'; -import { tokens } from '@/cunningham'; +import { userColorForeground, 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() || '?'; + const color = background || userColorsForId(userId ?? name).color; return ( ); }; @@ -49,16 +36,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..ee371ae7d7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/userColors.ts @@ -0,0 +1,64 @@ +// Seed one hue per identity. Use a readable dark shade for text/cursors and +// the original pastel shade for author highlights. +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); + const colorLight = hslToHex(hue, saturation, lightness); + const lightLuminance = relativeLuminance(colorLight); + let darkLightness = 40; + let color = hslToHex(hue, saturation, darkLightness); + + // Yellow/green hues need more darkening than blue to keep text readable. + while ( + darkLightness > 1 && + (lightLuminance + 0.05) / (relativeLuminance(color) + 0.05) < 4.5 + ) { + color = hslToHex(hue, saturation, --darkLightness); + } + + return { + color, + colorLight, + }; +}; + +// Same relative-luminance threshold as BlockNote's default cursor renderer. +export const userColorForeground = (background: string) => + relativeLuminance(background) <= 0.179 ? '#ffffff' : '#000000'; + +function relativeLuminance(hex: string) { + const channels = [1, 3, 5].map((offset) => { + const value = parseInt(hex.slice(offset, offset + 2), 16) / 255; + return value <= 0.03928 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); + }); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +} + +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 +156,18 @@ 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 historyDebugSettings = useRef( + undefined, + ); + const historyDefaults = { + groupMaxGap: versionGranularityMs, + groupMaxDuration: versionGranularityMs, + limit: 50, + }; const collabName = user?.full_name || user?.email; const cursorName = collabName || t('Anonymous'); @@ -156,6 +181,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 +192,60 @@ 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, + // Read overrides for each request, without reinstalling the editor. + get activityParams() { + return { + group: true, + groupByUser: false, + ...(historyDebugSettings.current ?? { + groupMaxGap: versionGranularityMs, + groupMaxDuration: versionGranularityMs, + limit: 50, + }), + }; + }, + }); + 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 @@ -203,7 +271,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { labelElement.setAttribute('spellcheck', `false`); labelElement.setAttribute( 'style', - `background-color: ${safeColor};border: 1px solid ${safeColor};`, + `background-color: ${safeColor};border: 1px solid ${safeColor};color: ${userColorForeground(safeColor)};`, ); labelElement.insertBefore(document.createTextNode(user.name), null); @@ -283,6 +351,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 +380,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { [ aiExtension, cursorName, + docUserStore, langLocalesBN, langLocalesBNMultiColumn, langLocalesBNAI, @@ -319,6 +389,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { uploadFile, threadStore, resolveUsers, + versioningExtension, ], ); @@ -339,7 +410,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 +458,41 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { />, threadsSidebarTarget, )} + {isVersioningSidebarOpen && ( + setIsVersioningSidebarOpen(false)} + debug={{ + defaults: historyDefaults, + initialSettings: historyDebugSettings.current ?? historyDefaults, + canCreate: !!doc.abilities.partial_update, + onApply: async (settings) => { + historyDebugSettings.current = settings; + const mode = + editor.getExtension('versioning'); + return mode ? mode.list() : { status: 'unavailable' }; + }, + onCreate: async () => { + const mode = + editor.getExtension('versioning'); + if (!mode || !doc.abilities.partial_update) { + return { status: 'unavailable' }; + } + const latest = mode.store.state; + if ( + latest.mode === 'versions' && + latest.history.data?.[0]?.name + ) { + return { status: 'error', error: { type: 'conflict' } }; + } + return mode.create( + t('Test version · {{time}}', { + time: new Date().toLocaleTimeString(), + }), + ); + }, + }} + /> + )} ); @@ -390,7 +500,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { interface BlockNoteReaderProps { docId: Doc['id']; - initialContent: Y.XmlFragment; + initialContent: Y.Node; isMainEditor?: boolean; } @@ -407,6 +517,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/VersionHistoryDebug.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/VersionHistoryDebug.tsx new file mode 100644 index 0000000000..b8404507ea --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/VersionHistoryDebug.tsx @@ -0,0 +1,344 @@ +import { type VersionOperationResult } from '@blocknote/core/extensions'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components'; + +export interface HistoryDebugSettings { + groupMaxGap: number; + groupMaxDuration: number; + limit: number; +} + +export const HISTORY_DEBUG_PRESETS = { + fine: { groupMaxGap: 10_000, groupMaxDuration: 60_000 }, + sessions: { groupMaxGap: 300_000, groupMaxDuration: 1_800_000 }, +}; + +interface Props { + defaults: HistoryDebugSettings; + initialSettings: HistoryDebugSettings; + canCreate: boolean; + onDismiss: () => void; + onApply: (settings: HistoryDebugSettings) => Promise; + onCreate: () => Promise; +} + +/** Session-local request overrides; never changes yhub's stored history. */ +export const VersionHistoryDebug = ({ + defaults, + initialSettings, + canCreate, + onDismiss, + onApply, + onCreate, +}: Props) => { + const { t } = useTranslation(); + const [settings, setSettings] = useState(initialSettings); + const [unit, setUnit] = useState(60_000); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + const valid = + Number.isSafeInteger(settings.groupMaxGap) && + settings.groupMaxGap > 0 && + Number.isSafeInteger(settings.groupMaxDuration) && + settings.groupMaxDuration > 0 && + Number.isSafeInteger(settings.limit) && + settings.limit > 0 && + settings.limit <= 1000; + + const run = async ( + action: () => Promise, + success: string, + ) => { + setBusy(true); + setMessage(''); + try { + const result = await action(); + setMessage( + result.status === 'done' + ? success + : result.status === 'error' && result.error.type === 'conflict' + ? t( + 'Make an edit and wait for it to sync before creating a version.', + ) + : t('The action could not be completed. Please try again.'), + ); + } catch { + setMessage(t('The action could not be completed. Please try again.')); + } finally { + setBusy(false); + } + }; + + return ( + + + {t('History debug')} + + +
+ +
+
+ +
+ { + const duration = Number(event.target.value) * unit; + setSettings({ + ...settings, + groupMaxGap: duration, + groupMaxDuration: duration, + }); + }} + /> + +
+
+
+ + + setSettings({ + ...settings, + limit: Number(event.target.value), + }) + } + /> +
+
+
+ + +
+ {settings.groupMaxGap !== settings.groupMaxDuration && ( + + {t('Pause: {{pause}} s · Span: {{span}} s', { + pause: settings.groupMaxGap / 1000, + span: settings.groupMaxDuration / 1000, + })} + + )} + + {t('Row limit is per page; the beginning stays pinned.')} + +
+ + +
+
+ + + {t('Local settings. Test versions are saved to the document.')} + +
+
+
+ + {busy ? t('Loading…') : message} + +
+ ); +}; 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..264d595e2e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/VersioningSidebarPanel.tsx @@ -0,0 +1,58 @@ +import { RenderInPortalElement } from '@blocknote/react'; +import { VersioningSidebar } from '@blocknote/react/versioning'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components'; + +import { VersionHistoryDebug } from './VersionHistoryDebug'; + +/** + * 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, + debug, +}: { + onClose: () => void; + debug: Omit, 'onDismiss'>; +}) => { + const { t } = useTranslation(); + const [showDebug, setShowDebug] = useState(true); + + return ( + + + {showDebug && ( + setShowDebug(false)} + /> + )} + + + + ); +}; 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/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx index 83e0f45a39..1d13c99713 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx @@ -82,7 +82,6 @@ export const DocsEditorStyle = createGlobalStyle` left: -1px; } .collaboration-cursor-custom__label { - color: #0d0d0d; font-size: 12px; font-weight: 600; -webkit-user-select: none; 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: