diff --git a/CHANGELOG.md b/CHANGELOG.md index d33cfbf9cf..07c65520e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to ### Changed +- โ™ป๏ธ(frontend) hide unavailable manual version actions in the history sidebar - โฌ†๏ธ(frontend) update BlockNote preview packages to 83153fda2e - ๐Ÿ›(frontend) adapt to the BlockNote 0.56 editor and exporter APIs - โ™ป๏ธ(frontend) restore bounded editor history through native yhub rollback @@ -26,6 +27,14 @@ and this project adheres to ### Fixed +- โœ…(e2e) insert tall-slide filler without per-character keyboard overhead +- ๐Ÿ›(frontend) reconcile current comment state after leaving version history +- ๐Ÿ›(frontend) authenticate history requests and use consistent snapshot IDs +- โœ…(e2e) exercise sidebar history and renderer-independent PDF metadata +- ๐Ÿ›(frontend) bind collaborative editing after HTTP or offline readiness +- โœ…(e2e) locate editor and comment menus outside their trigger containers +- ๐Ÿ›(frontend) confirm history restores and show author-only attribution +- ๐Ÿ›(export) preserve DOCX table children and accept modern Yjs updates - ๐Ÿ›(frontend) fetch named versions fresh after deleting one - ๐Ÿ›(collaboration) restore editor versions without exposing full history - ๐Ÿ›(collaboration) grant bounded named-version access for the history sidebar diff --git a/src/frontend/apps/e2e/__tests__/app-impress/assets/doc-export-regressions.pdf b/src/frontend/apps/e2e/__tests__/app-impress/assets/doc-export-regressions.pdf index 4a2ae1fd7b..2332e5adb6 100644 Binary files a/src/frontend/apps/e2e/__tests__/app-impress/assets/doc-export-regressions.pdf and b/src/frontend/apps/e2e/__tests__/app-impress/assets/doc-export-regressions.pdf differ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts index aad5244952..102a139994 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts @@ -162,7 +162,7 @@ test.describe('Doc Comments', () => { // Edit Comment await thread.getByText('This is a comment').first().hover(); await thread.locator('[data-test="moreactions"]').first().click(); - await thread.getByRole('menuitem', { name: 'Edit comment' }).click(); + await page.getByRole('menuitem', { name: 'Edit comment' }).click(); const commentEditor = thread.getByText('This is a comment').first(); await commentEditor.fill('This is an edited comment'); const saveBtn = thread.locator('button[data-test="save"]').first(); @@ -187,7 +187,7 @@ test.describe('Doc Comments', () => { // Delete second comment await thread.getByText('This is a second comment').first().hover(); await thread.locator('[data-test="moreactions"]').first().click(); - await thread.getByRole('menuitem', { name: 'Delete comment' }).click(); + await page.getByRole('menuitem', { name: 'Delete comment' }).click(); await expect( thread.getByText('This is a second comment').first(), ).toBeHidden(); @@ -240,7 +240,7 @@ test.describe('Doc Comments', () => { await thread.getByText('This is a new comment').first().hover(); await thread.locator('[data-test="moreactions"]').first().click(); - await thread.getByRole('menuitem', { name: 'Delete comment' }).click(); + await page.getByRole('menuitem', { name: 'Delete comment' }).click(); await expect(editor.getByText('Hello')).not.toHaveClass('bn-thread-mark'); await expect(editor.getByText('Hello')).toHaveCSS( diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index d6508c4883..435338472e 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -65,20 +65,23 @@ test.describe('Doc Editor', () => { // Only heading levels 1 to 3 are offered in the block type select await toolbar.getByRole('button', { name: 'Paragraph' }).click(); + const blockTypeMenu = page.getByRole('menu', { name: 'Paragraph' }); for (const level of [1, 2, 3]) { await expect( - toolbar.getByRole('menuitem', { + blockTypeMenu.getByRole('menuitem', { name: `Heading ${level}`, exact: true, }), ).toBeVisible(); await expect( - toolbar.getByRole('menuitem', { name: `Toggle Heading ${level}` }), + blockTypeMenu.getByRole('menuitem', { + name: `Toggle Heading ${level}`, + }), ).toBeVisible(); } for (const level of [4, 5, 6]) { await expect( - toolbar.getByRole('menuitem', { name: `Heading ${level}` }), + blockTypeMenu.getByRole('menuitem', { name: `Heading ${level}` }), ).toBeHidden(); } await page.keyboard.press('Escape'); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts index 7816c8c3ac..16410cbd70 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts @@ -379,9 +379,15 @@ test.describe('Doc Export', () => { await page.waitForTimeout(1000); const pdfBuffer = await cs.toBuffer(await download.createReadStream()); - const pdfString = pdfBuffer.toString('latin1'); - - expect(pdfString).toContain('/Lang (fr)'); + const pdf = new PDFParse({ data: pdfBuffer }); + try { + // Read the catalog's language, independent of PDF token whitespace or + // whether the exporter stores the catalog in a compressed object stream. + const { info } = await pdf.getInfo(); + expect(info?.Language).toBe('fr'); + } finally { + await pdf.destroy(); + } }); test('it exports the doc to PDF with PRINT feature and checks regressions', async ({ @@ -461,7 +467,8 @@ test.describe('Doc Export', () => { // If we need to update the PDF regression fixture, uncomment the line below //await savePDFToAssetFolder(pdfBuffer, 'doc-export-regressions.pdf'); - // Assert the generated PDF matches "assets/doc-export-regressions.pdf" + // Reviewed Typst baseline: keep exact text (including emoji and math), + // page count, dimensions and per-page pixel comparisons enabled. await comparePDFWithAssetFolder({ originPdfBuffer: pdfBuffer, filename: 'doc-export-regressions.pdf', diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts index ba8bf3e843..3b28ef21e2 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts @@ -1,37 +1,56 @@ -import { expect, test } from '@playwright/test'; +import { Page, expect, test } from '@playwright/test'; import { createDoc, goToGridDoc, mockedDocument, - overrideConfig, verifyDocName, } from './utils-common'; import { openSuggestionMenu, writeInEditor } from './utils-editor'; const COLLABORATION_VERSION_GRANULARITY_MS = 2000; +async function openHistory(page: Page) { + await page.getByLabel('Open the document options').click(); + await page.getByRole('menuitem', { name: 'History', exact: true }).click(); + const panel = page.getByRole('complementary', { + name: 'Version history side panel', + }); + await expect(panel).toBeVisible(); + // Production grouping has a five-minute minimum. Exercise the real debug + // control instead of overriding a setting which the editor deliberately clamps. + await panel.locator('summary').click(); + await panel + .getByRole('combobox', { name: 'Grouping time unit' }) + .selectOption('1000'); + await panel.getByLabel('Group by', { exact: true }).fill('2'); + await panel.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect( + panel.getByText('Grouping updated.', { exact: true }), + ).toBeVisible(); + const versions = panel.getByRole('list', { name: 'Versions', exact: true }); + await expect(versions.getByRole('listitem').first()).toBeVisible(); + await expect(panel.getByRole('alert')).toBeHidden(); + return { panel, versions }; +} + +async function captureLatestVersion(page: Page) { + const { panel, versions } = await openHistory(page); + const id = await versions.getByRole('listitem').first().getAttribute('id'); + expect(id).toContain('-snapshot-'); + const snapshotId = id!.split('-snapshot-')[1]; + await panel.getByRole('button', { name: 'Close', exact: true }).click(); + return snapshotId; +} + test.describe('Doc Version', () => { test('it displays the doc versions', async ({ page, browserName }) => { - await overrideConfig(page, { - COLLABORATION_VERSION_GRANULARITY_MS: `${COLLABORATION_VERSION_GRANULARITY_MS}`, - }); - await page.goto('/'); await createDoc(page, 'doc-version', browserName, 1); - // Initially, there is no version - await page.getByLabel('Open the document options').click(); - await page.getByRole('menuitem', { name: 'History' }).click(); - await expect(page.getByText('History', { exact: true })).toBeVisible(); - - const modal = page.getByRole('dialog', { name: 'Version history' }); - const panel = modal.getByLabel('Version list'); - - await modal.getByRole('button', { name: 'close' }).click(); - - await writeInEditor({ page, text: 'Hello World' }); + const editor = await writeInEditor({ page, text: 'Hello World' }); + const firstVersion = await captureLatestVersion(page); await page.waitForTimeout(COLLABORATION_VERSION_GRANULARITY_MS + 10); @@ -46,6 +65,7 @@ test.describe('Doc Version', () => { .first(); await expect(calloutBlock).toBeVisible(); + const secondVersion = await captureLatestVersion(page); await page.waitForTimeout(COLLABORATION_VERSION_GRANULARITY_MS + 10); @@ -54,42 +74,39 @@ test.describe('Doc Version', () => { await page.waitForTimeout(COLLABORATION_VERSION_GRANULARITY_MS + 10); - await page.getByLabel('Open the document options').click(); - await page.getByRole('menuitem', { name: 'History' }).click(); - - await expect(panel).toBeVisible(); - await expect(page.getByText('History', { exact: true })).toBeVisible(); - await expect(page.getByRole('status')).toBeHidden(); - const items = panel.locator('.version-item'); - await expect(items).toHaveCount(3); - await items.nth(2).click(); + const { versions } = await openHistory(page); + const items = versions.getByRole('listitem'); + // The start of the document is pinned separately from its first edit. + await items.last().click(); + await expect(editor.getByText('Hello World')).toBeHidden(); + await versions.locator(`[id$="-snapshot-${firstVersion}"]`).click(); - await expect(modal.getByText('Hello World')).toBeVisible(); - await expect(modal.getByText('It will create a version')).toBeHidden(); + await expect(editor.getByText('Hello World')).toBeVisible(); + await expect(editor.getByText('It will create a version')).toBeHidden(); await expect( - modal.locator('div[data-content-type="callout"]').first(), + editor.locator('div[data-content-type="callout"]').first(), ).toBeHidden(); - await items.nth(1).click(); + await versions.locator(`[id$="-snapshot-${secondVersion}"]`).click(); - await expect(modal.getByText('Hello World')).toBeVisible(); - await expect(modal.getByText('It will create a version')).toBeVisible(); + await expect(editor.getByText('Hello World')).toBeVisible(); + await expect(editor.getByText('It will create a version')).toBeVisible(); await expect( - modal.locator('div[data-content-type="callout"]').first(), + editor.locator('div[data-content-type="callout"]').first(), ).toBeVisible(); await expect( - modal.getByText('It will create a second version'), + editor.getByText('It will create a second version'), ).toBeHidden(); await items.nth(0).click(); - await expect(modal.getByText('Hello World')).toBeVisible(); - await expect(modal.getByText('It will create a version')).toBeVisible(); + await expect(editor.getByText('Hello World')).toBeVisible(); + await expect(editor.getByText('It will create a version')).toBeVisible(); await expect( - modal.locator('div[data-content-type="callout"]').first(), + editor.locator('div[data-content-type="callout"]').first(), ).toBeVisible(); await expect( - modal.getByText('It will create a second version'), + editor.getByText('It will create a second version'), ).toBeVisible(); }); @@ -114,10 +131,6 @@ test.describe('Doc Version', () => { }); test('it restores the doc version', async ({ page, browserName }) => { - await overrideConfig(page, { - COLLABORATION_VERSION_GRANULARITY_MS: `${COLLABORATION_VERSION_GRANULARITY_MS}`, - }); - await page.goto('/'); await createDoc(page, 'doc-version', browserName, 1); @@ -132,6 +145,7 @@ test.describe('Doc Version', () => { await thread.getByRole('paragraph').first().fill('This is a comment'); await thread.locator('[data-test="save"]').click(); await expect(thread).toBeHidden(); + const firstVersion = await captureLatestVersion(page); await page.waitForTimeout(COLLABORATION_VERSION_GRANULARITY_MS + 10); @@ -144,28 +158,22 @@ test.describe('Doc Version', () => { await thread.locator('[data-test="resolve"]').click(); await expect(thread).toBeHidden(); - await page.getByLabel('Open the document options').click(); - await page.getByRole('menuitem', { name: 'History' }).click(); - - const modal = page.getByRole('dialog', { name: 'Version history' }); - const panel = modal.getByLabel('Version list'); - await expect(panel).toBeVisible(); - - await expect(page.getByText('History', { exact: true })).toBeVisible(); - const items = panel.locator('.version-item'); - await expect(items).toHaveCount(3); - await items.nth(2).click(); - - await expect(modal.getByText('World')).toBeHidden(); - - await page.getByRole('button', { name: 'Restore', exact: true }).click(); - await expect( - page.getByText( - "The current document will be replaced, but you'll still find it in the version history.", - ), - ).toBeVisible(); - - await page.getByLabel('Restore', { exact: true }).click(); + const { panel, versions } = await openHistory(page); + const version = versions.locator(`[id$="-snapshot-${firstVersion}"]`); + await version.click(); + await expect(editor.getByText('World')).toBeHidden(); + + await version + .getByRole('button', { name: 'More actions', exact: true }) + .click(); + await page.getByRole('menuitem', { name: 'Restore', exact: true }).click(); + const confirmation = page.getByRole('dialog', { name: 'Restore version' }); + await expect(confirmation).toBeVisible(); + await confirmation + .getByRole('button', { name: 'Restore', exact: true }) + .click(); + await expect(confirmation).toBeHidden(); + await expect(panel).toBeHidden(); const mainEditor = page.getByLabel('Document editor'); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts index 5aac4d57ad..6054d35bbc 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts @@ -137,6 +137,10 @@ test.describe('Language', () => { LANGUAGE_CODE: 'en-us', }); + // The beforeEach navigation has already loaded the default config. Reload + // after installing the override; client-side navigation keeps that cache. + await page.goto('/'); + await createDoc(page, 'doc-toolbar', browserName, 1); const { editor, suggestionMenu } = await openSuggestionMenu({ page }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts index 405b8ad91d..374ceda250 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts @@ -366,8 +366,14 @@ test.describe('Presenter Mode', () => { const editor = await writeInEditor({ page, text: 'TOP MARKER' }); for (let i = 0; i < 40; i += 1) { await editor.press('Enter'); - await editor.pressSequentially(`Filler line ${i} to make the slide tall`); + // This exercises layout, not per-character keyboard handling. + await page.keyboard.insertText(`Filler line ${i} to make the slide tall`); } + await expect( + editor.getByText('Filler line 39 to make the slide tall', { + exact: true, + }), + ).toBeVisible(); const overlay = await openPresenter(page); await overlay.getByRole('button', { name: 'Next slide' }).click(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-editor.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-editor.ts index f529abfce6..ca3d924a1a 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-editor.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-editor.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { Page } from '@playwright/test'; +import { Page, expect } from '@playwright/test'; export const getEditor = async ({ page }: { page: Page }) => { const editor = page.locator('.--docs--editor-container .ProseMirror'); @@ -34,10 +34,13 @@ export const openSuggestionMenu = async ({ suggestion?: string; }) => { const editor = await tryFocusEditorContent({ page }); - await page.keyboard.press('Enter'); - await page.keyboard.type('/'); + // Resolve and focus the current editor for each key: a collaborative editor + // rerender can replace the focused DOM node between these actions. + await editor.press('Enter'); + await editor.press('/'); const suggestionMenu = page.locator('.bn-suggestion-menu'); + await expect(suggestionMenu).toBeVisible(); if (suggestion) { await suggestionMenu diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-export.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-export.ts index 487a0246a7..d0389f0d15 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-export.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-export.ts @@ -162,6 +162,25 @@ export const comparePDFWithAssetFolder = async ({ const referencePdfPath = path.join(__dirname, 'assets', filename); const referencePdfBuffer = fs.readFileSync(referencePdfPath); + // Keep both PDFs even when page-count or text assertions fail, not only + // pixel comparisons: these are needed to review an exporter migration. + if (testInfo) { + await writeReport( + testInfo, + 'generated.pdf', + 'pdf-generated', + originPdfBuffer, + 'application/pdf', + ); + await writeReport( + testInfo, + 'reference.pdf', + 'pdf-reference', + referencePdfBuffer, + 'application/pdf', + ); + } + // Parse both PDFs const generatedPdf = new PDFParse({ data: originPdfBuffer }); const referencePdf = new PDFParse({ data: referencePdfBuffer }); @@ -232,20 +251,6 @@ export const comparePDFWithAssetFolder = async ({ if (testInfo) { const pageNo = String(i + 1).padStart(2, '0'); - await writeReport( - testInfo, - `generated.pdf`, - `pdf-generated`, - originPdfBuffer, - 'application/pdf', - ); - await writeReport( - testInfo, - `reference.pdf`, - `pdf-reference`, - referencePdfBuffer, - 'application/pdf', - ); await writeReport( testInfo, `page-${pageNo}-diff.png`, diff --git a/src/frontend/apps/impress/src/components/modal/AlertModal.tsx b/src/frontend/apps/impress/src/components/modal/AlertModal.tsx index 3735fda5af..f67b6842ce 100644 --- a/src/frontend/apps/impress/src/components/modal/AlertModal.tsx +++ b/src/frontend/apps/impress/src/components/modal/AlertModal.tsx @@ -20,11 +20,13 @@ export type AlertModalProps = { title: string; cancelLabel?: string; confirmLabel?: string; + confirmDisabled?: boolean; } & Partial; export const AlertModal = ({ cancelLabel, confirmLabel, + confirmDisabled, description, isOpen, onClose, @@ -73,6 +75,7 @@ export const AlertModal = ({ t('Confirm', { description: 'Button to validate a dialog' }) } color={themeCTA ?? 'error'} + disabled={confirmDisabled} onClick={onConfirm} > {confirmLabel ?? diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/authorOnlyAttribution.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/authorOnlyAttribution.ts new file mode 100644 index 0000000000..eb8984e768 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/authorOnlyAttribution.ts @@ -0,0 +1,10 @@ +import { en } from '@blocknote/core/locales'; + +/** Shared by version diffs and suggestions; keep authorless labels intact. */ +export const authorOnlyAttribution = ( + base: typeof en.suggestion_changes, +): typeof en.suggestion_changes => ({ + ...base, + inserted_by: (users: string) => users, + deleted_by: (users: string) => users, +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index d1b980ce34..723e3f0774 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -59,6 +59,7 @@ import { userColorForeground } from '@/features/auth/userColors'; import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore'; import { useAnalytics } from '@/libs/Analytics'; +import { authorOnlyAttribution } from '../authorOnlyAttribution'; import { AI_FEATURE_FLAG, DEFAULT_LOCALE } from '../conf'; import { createDocsVersionStorage } from '../createDocsVersionStorage'; import { @@ -79,6 +80,7 @@ import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; import { DocsSideMenu } from './DocsSideMenu/DocsSideMenu'; import { type HistoryDebugSettings } from './VersionHistoryDebug'; +import { VersioningComponentsProvider } from './VersioningComponentsProvider'; import { VersioningSidebarPanel } from './VersioningSidebarPanel'; import { CalloutBlock, PdfBlock, UploadLoaderBlock } from './custom-blocks'; const AIMenu = BlockNoteAI?.AIMenu; @@ -175,8 +177,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const historyDebugSettings = useRef( undefined, ); - // BlockNote reads this object each time it shows a version, so a change - // applies without creating the editor again (which would lose the selection). + // Keep the options object stable so changing a diff fix preserves selection. const [experimentalOptions] = useState(() => ({})); const [experimentalDiffs, setExperimentalDiffs] = useState({}); @@ -259,7 +260,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const editor: DocsBlockNoteEditor = useCreateBlockNote( withCollaboration({ collaboration: { - provider, + // DocEditor already waits for websocket, HTTP, or local readiness. + // Passing the socket's `synced` flag would prevent the Yjs binding + // from starting when only the HTTP fallback is available. + provider: { awareness: provider.awareness }, fragment: provider.doc.get('document-store'), user: { id: user?.id ?? 'anonymous', @@ -312,6 +316,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }, dictionary: { ...localesBN[langLocalesBN as keyof typeof localesBN], + suggestion_changes: authorOnlyAttribution( + localesBN[langLocalesBN as keyof typeof localesBN].suggestion_changes, + ), math: mathLocales[langLocalesBNMathDiagram as keyof typeof mathLocales], diagram: diagramLocales[ @@ -475,54 +482,45 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { threadsSidebarTarget, )} {isVersioningSidebarOpen && ( - setIsVersioningSidebarOpen(false)} - debug={{ - defaults: historyDefaults, - initialSettings: historyDebugSettings.current ?? historyDefaults, - canCreate: !!doc.abilities.partial_update, - experimental: experimentalDiffs, - onExperimentalChange: (experimental) => { - experimentalOptions.versionDiffFixes = - experimental.versionDiffFixes; - setExperimentalDiffs(experimental); - // Show the selected versions again with the new setting. - const mode = - editor.getExtension('versioning'); - const state = mode?.store.state; - if (mode && state?.mode === 'versions') { - void mode.select(state.displayed, { - compareTo: state.compareTo, - }); - } - }, - 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(), - }), - ); - }, - }} - /> + + { + setIsVersioningSidebarOpen(false); + // Historical text can reintroduce comment marks, but threads + // live outside the restored fragment. Reconcile their current + // resolved/deleted state after returning to the live editor. + threadStore.refreshThreads(); + }} + debug={{ + defaults: historyDefaults, + initialSettings: + historyDebugSettings.current ?? historyDefaults, + canCreate: false, + experimental: experimentalDiffs, + onExperimentalChange: (experimental) => { + experimentalOptions.versionDiffFixes = + experimental.versionDiffFixes; + setExperimentalDiffs(experimental); + const mode = + editor.getExtension('versioning'); + const state = mode?.store.state; + if (mode && state?.mode === 'versions') { + void mode.select(state.displayed, { + compareTo: state.compareTo, + }); + } + }, + onApply: async (settings) => { + historyDebugSettings.current = settings; + const mode = + editor.getExtension('versioning'); + return mode ? mode.list() : { status: 'unavailable' }; + }, + onCreate: async () => ({ status: 'unavailable' }), + }} + /> + )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/VersioningComponentsProvider.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/VersioningComponentsProvider.tsx new file mode 100644 index 0000000000..aa15edc995 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/VersioningComponentsProvider.tsx @@ -0,0 +1,50 @@ +import { components as mantineComponents } from '@blocknote/mantine'; +import { ComponentsContext, useComponentsContext } from '@blocknote/react'; +import { + useDeleteVersionAction, + useNameVersionAction, + useRestoreVersionAction, +} from '@blocknote/react/versioning'; +import { type ComponentProps, type ReactNode, useMemo } from 'react'; + +const SkinSnapshot = mantineComponents.Versioning.Snapshot; + +const SnapshotWithAvailableActions = ( + props: ComponentProps, +) => { + const name = useNameVersionAction(); + const restore = useRestoreVersionAction(); + const deleteVersion = useDeleteVersionAction(); + // Keep this aligned with the three items in our snapshotMenu. The restore + // confirmation item has no eligibility conditions beyond this action hook. + const hasActions = + name.available || restore.available || deleteVersion.available; + + return ( + + ); +}; + +export const VersioningComponentsProvider = ({ + children, +}: { + children: ReactNode; +}) => { + const components = useComponentsContext() ?? mantineComponents; + const value = useMemo( + () => ({ + ...components, + Versioning: { + ...components.Versioning, + Snapshot: SnapshotWithAvailableActions, + }, + }), + [components], + ); + + return ( + + {children} + + ); +}; 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 index 264d595e2e..75ca5e0326 100644 --- 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 @@ -1,12 +1,60 @@ -import { RenderInPortalElement } from '@blocknote/react'; -import { VersioningSidebar } from '@blocknote/react/versioning'; -import { useState } from 'react'; +import { formatVersionDate } from '@blocknote/core/extensions'; +import { RenderInPortalElement, useBlockNoteEditor } from '@blocknote/react'; +import { + DeleteVersionItem, + NameVersionItem, + VersionMenu, + VersionMenuItem, + VersioningSidebar, + useRestoreVersionAction, + useVersionSnapshot, +} from '@blocknote/react/versioning'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Box } from '@/components'; +import { AlertModal, Box, Icon } from '@/components'; import { VersionHistoryDebug } from './VersionHistoryDebug'; +interface RestoreRequest { + snapshotId: string; + label: string; + execute: () => void | Promise; +} + +const ConfirmRestoreVersionItem = ({ + onRequest, +}: { + onRequest: (request: RestoreRequest) => void; +}) => { + const action = useRestoreVersionAction(); + const { snapshot, isStart } = useVersionSnapshot(); + const { dictionary } = useBlockNoteEditor(); + + if (!action.available) { + return null; + } + + return ( + } + onClick={() => + onRequest({ + snapshotId: snapshot.id, + label: + snapshot.name ?? + (isStart + ? dictionary.versioning.start_of_document + : formatVersionDate(snapshot.createdAt)), + execute: action.execute, + }) + } + > + {dictionary.versioning.restore_menuitem} + + ); +}; + /** * Full-page right-side panel hosting the BlockNote version history sidebar. * @@ -22,6 +70,54 @@ export const VersioningSidebarPanel = ({ }) => { const { t } = useTranslation(); const [showDebug, setShowDebug] = useState(true); + const [pending, setPending] = useState(); + const pendingRef = useRef(undefined); + const submitting = useRef(false); + const [isRestoring, setIsRestoring] = useState(false); + + useEffect( + () => () => { + pendingRef.current = undefined; + }, + [], + ); + + const requestRestore = (request: RestoreRequest) => { + if (pendingRef.current || submitting.current) { + return; + } + pendingRef.current = request; + setPending(request); + }; + + const cancelRestore = () => { + if (!submitting.current) { + pendingRef.current = undefined; + setPending(undefined); + } + }; + + const confirmRestore = async () => { + const request = pendingRef.current; + if (!request || submitting.current) { + return; + } + submitting.current = true; + setIsRestoring(true); + try { + // BlockNote owns restore, preview, errors and closing the sidebar. + await request.execute(); + } finally { + submitting.current = false; + // Closing history or changing documents unmounts this owner. The + // submitted restore still runs, but must not update stale dialog state. + if (pendingRef.current === request) { + pendingRef.current = undefined; + setPending(undefined); + setIsRestoring(false); + } + } + }; return ( @@ -51,8 +147,28 @@ export const VersioningSidebarPanel = ({ onDismiss={() => setShowDebug(false)} /> )} - + + + + + + } + /> + {pending && ( + void confirmRestore()} + /> + )} ); }; 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 index 92588bce17..fe3b950d7e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/createDocsVersionStorage.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/createDocsVersionStorage.ts @@ -1,8 +1,11 @@ -import { type VersionResult } from '@blocknote/core/extensions'; +import { + type VersionResult, + type VersionSnapshot, +} from '@blocknote/core/extensions'; import { type YHubVersionStorageOptions, collectFragmentIds, - createYHubVersionStorage, + type createYHubVersionStorage, } from '@blocknote/core/y'; import * as Y from '@y/y'; import { decodeAny, encodeAny } from 'lib0/buffer'; @@ -13,14 +16,20 @@ export const createDocsVersionStorage = ( fragment: Y.Node; beforeRestoreName: string; }, -): ReturnType => { +): Omit< + ReturnType, + 'create' | 'rename' | 'remove' +> => { 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 timeoutMs = options.timeoutMs ?? 30_000; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('YHub timeout must be a positive finite number'); + } const documentPath = `${encodeURIComponent(options.org)}/${encodeURIComponent(options.docId)}`; async function request( @@ -34,17 +43,25 @@ export const createDocsVersionStorage = ( .filter(([, value]) => value !== undefined) .map(([key, value]) => [key, String(value)]), ); - const timeout = AbortSignal.timeout(options.timeoutMs ?? 30_000); + const url = new URL( + `${options.baseUrl}/${endpoint}/v1/${documentPath}${query.size ? `?${query}` : ''}`, + ); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('YHub requires an HTTP or HTTPS URL'); + } + const headers = new Headers(options.headers); + new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); + const timeout = AbortSignal.timeout(timeoutMs); let data: ArrayBuffer; try { - const response = await fetch( - `${options.baseUrl}/${endpoint}/v1/${documentPath}${query.size ? `?${query}` : ''}`, - { - ...init, - signal: timeout, - headers: { ...options.headers, ...init?.headers }, - }, - ); + const response = await fetch(url, { + ...init, + credentials: 'include', + signal: init?.signal + ? AbortSignal.any([init.signal, timeout]) + : timeout, + headers, + }); if (!response.ok) { return { ok: false, @@ -60,6 +77,9 @@ export const createDocsVersionStorage = ( } data = await response.arrayBuffer(); } catch (error) { + if (init?.signal?.aborted) { + throw error; + } if (timeout.aborted) { return { ok: false, @@ -77,17 +97,182 @@ export const createDocsVersionStorage = ( return { ok: true, value: decode(data) }; } + // The retained preview's client has no credential/fetch option. Keep its + // storage contract, but use Docs' authenticated transport for every operation. + interface Activity { + from: number; + to: number; + by?: string | Array | null; + customAttributions?: Array<{ k: string; v: string }>; + version?: { name: string; custom?: unknown }; + } + const activityParams = () => ({ + limit: 50, + groupMaxGap: 3_600_000, + groupMaxDuration: 43_200_000, + groupByUser: false, + customAttributions: true, + ...options.activityParams, + order: 'desc', + versions: true, + }); + const getActivity = ( + params: Record, + signal: AbortSignal, + ) => + request( + 'activity', + (data) => + (decodeAny(new Uint8Array(data)) as { activity: Activity[] }).activity, + { signal }, + params, + ); + const timestamp = (id: string) => { + const value = Number(id); + if ( + !id || + !Number.isSafeInteger(value) || + value < 0 || + value === Number.MAX_SAFE_INTEGER + ) { + throw new Error('Invalid version timestamp'); + } + return value; + }; + const storage: Omit< + ReturnType, + 'create' | 'rename' | 'remove' | 'restore' + > = { + // Keep checkpoint creation internal; named-version actions are unavailable. + showCurrentVersion: false, + historyIncludesBeginning: true, + async list(signal, cursor) { + const params: Record = + cursor === undefined + ? activityParams() + : Object.fromEntries(new URLSearchParams(cursor)); + const limit = Number(params.limit); + if (!Number.isSafeInteger(limit) || limit <= 0) { + throw new Error('Invalid history page size'); + } + const [result, first] = await Promise.all([ + getActivity({ ...params, limit: limit + 1 }, signal), + cursor === undefined + ? getActivity( + { + from: 0, + order: 'asc', + limit: 1, + group: false, + versions: true, + customAttributions: true, + }, + signal, + ) + : undefined, + ]); + if (!result.ok) { + return result; + } + if (first && !first.ok) { + return first; + } + const entries = result.value.slice(0, limit); + const oldest = entries.at(-1); + let nextCursor: string | undefined; + if (result.value.length > limit && oldest && oldest.from > 0) { + nextCursor = new URLSearchParams( + Object.entries({ ...params, to: oldest.from - 1 }) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, String(value)]), + ).toString(); + } + if (first?.value[0]) { + const start = Math.max(0, first.value[0].from - 1); + entries.push({ + ...first.value[0], + from: start, + to: start, + version: undefined, + }); + } + return { + ok: true, + value: { + nextCursor, + snapshots: entries.map((entry): VersionSnapshot => { + const custom = entry.version?.custom; + const restoredFrom = + custom !== null && + typeof custom === 'object' && + 'restoredFrom' in custom + ? custom.restoredFrom + : undefined; + return { + id: String(entry.to), + createdAt: entry.to, + by: (Array.isArray(entry.by) + ? entry.by + : (entry.by?.split(',') ?? []) + ) + .filter((id): id is string => !!id?.trim()) + .map((id) => id.trim()), + name: entry.version?.name || undefined, + metadata: custom, + restoredFrom: + typeof restoredFrom === 'number' && + Number.isFinite(restoredFrom) + ? { id: String(restoredFrom), createdAt: restoredFrom } + : undefined, + customAttributions: Object.fromEntries( + entry.customAttributions?.map(({ k, v }) => [k, v]) ?? [], + ), + }; + }), + }, + }; + }, + getContent(id, signal) { + return request( + 'changeset', + (data) => { + const { ydoc } = decodeAny(new Uint8Array(data)) as { + ydoc?: Uint8Array; + }; + if (!ydoc) { + throw new Error('YHub returned no changeset document'); + } + return Y.convertUpdateFormatV1ToV2(ydoc); + }, + { signal }, + { ydoc: true, to: timestamp(id) }, + ); + }, + getAttributions(target, baselineId, _capturedAt, signal) { + return request( + 'changeset', + (data) => { + const { attributions } = decodeAny(new Uint8Array(data)) as { + attributions?: Uint8Array; + }; + if (!attributions) { + throw new Error('YHub returned no attributions'); + } + return Y.decodeContentMap(attributions); + }, + { signal }, + { + from: timestamp(baselineId) + 1, + ...(target.type === 'current' ? {} : { to: timestamp(target.id) }), + attributions: true, + }, + ); + }, + }; 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 to = timestamp(id); const signal = new AbortController().signal; const versions = await storage.list(signal); if (!versions.ok) { @@ -100,19 +285,33 @@ export const createDocsVersionStorage = ( !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 (current) { + // Pin the known server head, never the selected historical preview. + // Later edits remain in additive history even if they race this label. + const existing = await request( + 'version', + (data) => + (decodeAny(new Uint8Array(data)) as { versions: unknown[] }) + .versions[0], + undefined, + { from: current.createdAt, to: current.createdAt }, ); - if (!saved.ok) { - return saved; + if (!existing.ok) { + return existing; + } + if (!existing.value) { + const saved = await request('version', () => undefined, { + method: 'POST', + headers: { 'Content-Type': 'application/x-lib0any' }, + body: encodeAny({ + type: 'version:v1', + t: current.createdAt, + name: options.beforeRestoreName, + }) as BodyInit, + }); + if (!saved.ok) { + return saved; + } } } // changeset retains deleted descendants inside this window, but collects diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx index ce1048b7f5..c2d6d74bb0 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx @@ -14,6 +14,15 @@ import { import { createInlineContentMappingInterlinkingLinkDocx } from './inline-content-mapping'; import { DocsExporterDocx } from './types'; +const tableMapping = docxDefaultSchemaMappings.blockMapping.table; + +// NaN widths are not supported by DOCX. Keep the mapping's shape intact: +// function mappings let the exporter append and indent child blocks itself. +const normalizeColumnWidths = (widths: (number | undefined)[]) => + widths.map((width) => + typeof width === 'number' && Number.isNaN(width) ? undefined : width, + ); + export const getDocxDocsSchemaMappings = ( interlinkTitles: Map, ): DocsExporterDocx['mappings'] => ({ @@ -32,33 +41,39 @@ export const getDocxDocsSchemaMappings = ( quote: blockMappingQuoteDocx, image: blockMappingImageDocx, uploadLoader: blockMappingUploadLoaderDocx, - table: (block, exporter, nestedLevel, numberedListIndex) => { - /** - * Nan values are not supported, so we need to replace them with undefined - * to avoid issues during the export. - */ - const { columnWidths } = block.content; - const hasNaN = columnWidths.some( - (width) => typeof width === 'number' && Number.isNaN(width), - ); - if (hasNaN) { - block.content.columnWidths = columnWidths.map((width) => - typeof width === 'number' && Number.isNaN(width) ? undefined : width, - ); - } - - const tableMapping = docxDefaultSchemaMappings.blockMapping.table; - // A table has no child blocks. - return typeof tableMapping === 'function' - ? tableMapping(block, exporter, nestedLevel, numberedListIndex) - : tableMapping.withChildren( - block, - exporter, - nestedLevel, - numberedListIndex, - [], - ); - }, + table: + typeof tableMapping === 'function' + ? (block, exporter, nestedLevel, numberedListIndex) => { + block.content.columnWidths = normalizeColumnWidths( + block.content.columnWidths, + ); + return tableMapping( + block, + exporter, + nestedLevel, + numberedListIndex, + ); + } + : { + withChildren: ( + block, + exporter, + nestedLevel, + numberedListIndex, + children, + ) => { + block.content.columnWidths = normalizeColumnWidths( + block.content.columnWidths, + ); + return tableMapping.withChildren( + block, + exporter, + nestedLevel, + numberedListIndex, + children, + ); + }, + }, }, inlineContentMapping: { ...docxDefaultSchemaMappings.inlineContentMapping, diff --git a/src/frontend/servers/y-provider/__tests__/convert.test.ts b/src/frontend/servers/y-provider/__tests__/convert.test.ts index 6013c49d9e..77d4dab772 100644 --- a/src/frontend/servers/y-provider/__tests__/convert.test.ts +++ b/src/frontend/servers/y-provider/__tests__/convert.test.ts @@ -2,8 +2,10 @@ import { CommentsExtension, DefaultThreadStoreAuth, } from '@blocknote/core/comments'; +import { blocksToYDoc } from '@blocknote/core/y'; import { YjsThreadStore } from '@blocknote/core/yjs'; import { ServerBlockNoteEditor } from '@blocknote/server-util'; +import * as YCurrent from '@y/y'; import { Fragment, Node as PMNode } from 'prosemirror-model'; import request from 'supertest'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; @@ -325,6 +327,36 @@ describe('Conversion Testing', () => { expect(response.text).toBe(expectedHTML); }); + test.each(['text/html', 'text/markdown', 'application/json'])( + 'POST /api/convert current Yjs document to %s', + async (accept) => { + const app = initApp(); + const editor = ServerBlockNoteEditor.create(); + const blocks = await editor.tryParseMarkdownToBlocks(expectedMarkdown); + const ydoc = blocksToYDoc(editor.editor, blocks, 'document-store'); + try { + const response = await request(app) + .post('/api/convert') + .set('origin', origin) + .set('authorization', `Bearer ${apiKey}`) + .set('content-type', 'application/vnd.yjs.doc') + .set('accept', accept) + .send(Buffer.from(YCurrent.encodeStateAsUpdate(ydoc))); + + expect(response.status).toBe(200); + if (accept === 'application/json') { + expect(response.body).toStrictEqual(expectedBlocks); + } else { + expect(response.text.trim()).toBe( + accept === 'text/html' ? expectedHTML : expectedMarkdown, + ); + } + } finally { + ydoc.destroy(); + } + }, + ); + test('POST /api/convert Yjs to Markdown', async () => { const app = initApp(); const editor = ServerBlockNoteEditor.create(); @@ -348,7 +380,7 @@ describe('Conversion Testing', () => { }); test('POST /api/convert Yjs to JSON', async () => { - const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy'); + const destroySpy = vi.spyOn(YCurrent.Doc.prototype, 'destroy'); const app = initApp(); const editor = ServerBlockNoteEditor.create(); const blocks = await editor.tryParseMarkdownToBlocks(expectedMarkdown); @@ -704,7 +736,7 @@ describe('Conversion Testing', () => { }); test('POST /api/convert with invalid Yjs content returns 400', async () => { - const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy'); + const destroySpy = vi.spyOn(YCurrent.Doc.prototype, 'destroy'); const app = initApp(); const response = await request(app) .post('/api/convert') diff --git a/src/frontend/servers/y-provider/package.json b/src/frontend/servers/y-provider/package.json index 215e60ad27..054419ac63 100644 --- a/src/frontend/servers/y-provider/package.json +++ b/src/frontend/servers/y-provider/package.json @@ -21,6 +21,9 @@ "@sentry/node": "10.75.0", "@sentry/profiling-node": "10.75.0", "@tiptap/extensions": "*", + "@y/prosemirror": "^2.0.0-15", + "@y/protocols": "^1.0.6-rc.1", + "@y/y": "^14.0.0-rc.26", "cors": "2.8.6", "express": "5.2.1", "jose": "6.2.12", diff --git a/src/frontend/servers/y-provider/src/handlers/convertHandler.ts b/src/frontend/servers/y-provider/src/handlers/convertHandler.ts index 385d5a3796..075e67a2e1 100644 --- a/src/frontend/servers/y-provider/src/handlers/convertHandler.ts +++ b/src/frontend/servers/y-provider/src/handlers/convertHandler.ts @@ -3,9 +3,11 @@ import { CommentsExtension, DefaultThreadStoreAuth, } from '@blocknote/core/comments'; +import { yDocToBlocks } from '@blocknote/core/y'; import { YjsThreadStore } from '@blocknote/core/yjs'; import { ServerBlockNoteEditor } from '@blocknote/server-util'; import * as Sentry from '@sentry/node'; +import * as YCurrent from '@y/y'; import { Request, Response } from 'express'; import * as Y from 'yjs'; @@ -101,10 +103,12 @@ const readers: InputReader[] = [ { supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream], read: async (data) => { - const ydoc = new Y.Doc(); + // The v14 adapter also reads legacy v13 updates. The reverse is not + // supported: v13's XML reader cannot read the frontend's Y.Node content. + const ydoc = new YCurrent.Doc(); try { - Y.applyUpdate(ydoc, data); - return editor.yDocToBlocks(ydoc, 'document-store'); + YCurrent.applyUpdate(ydoc, data); + return yDocToBlocks(editor.editor, ydoc, 'document-store'); } finally { ydoc.destroy(); }