Merge remote-tracking branch 'origin/main' into feature/doc-import

This commit is contained in:
Stephan Meijer
2026-01-08 12:02:35 +01:00
141 changed files with 9765 additions and 7085 deletions
@@ -126,6 +126,20 @@ test.describe('Config', () => {
).toBeAttached();
});
test('it checks FRONTEND_JS_URL config', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_JS_URL: 'http://localhost:123465/js/script.js',
});
await page.goto('/');
await expect(
page
.locator('script[src="http://localhost:123465/js/script.js"]')
.first(),
).toBeAttached();
});
test('it checks theme_customization.translations config', async ({
page,
}) => {
@@ -145,10 +159,6 @@ test.describe('Config', () => {
await expect(page.getByText('MyCustomDocs')).toBeAttached();
});
});
test.describe('Config: Not logged', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it checks the config api is called', async ({ page }) => {
const responsePromise = page.waitForResponse(
@@ -168,6 +178,10 @@ test.describe('Config: Not logged', () => {
expect(configApi).toStrictEqual(CONFIG_LEFT);
});
});
test.describe('Config: Not logged', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it checks that theme is configured from config endpoint', async ({
page,
@@ -1,7 +1,12 @@
import { expect, test } from '@playwright/test';
import { createDoc, getOtherBrowserName, verifyDocName } from './utils-common';
import { writeInEditor } from './utils-editor';
import {
closeHeaderMenu,
createDoc,
getOtherBrowserName,
verifyDocName,
} from './utils-common';
import { getEditor, writeInEditor } from './utils-editor';
import {
addNewMember,
connectOtherUserToDoc,
@@ -43,6 +48,7 @@ test.describe('Doc Comments', () => {
await thread.locator('[data-test="save"]').click();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
await editor.first().click();
await editor.getByText('Hello').click();
await thread.getByText('This is a comment').first().hover();
@@ -116,8 +122,7 @@ test.describe('Doc Comments', () => {
await createDoc(page, 'comment-interaction', browserName, 1);
// Checks add react reaction
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('Hello World');
const editor = await writeInEditor({ page, text: 'Hello' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
@@ -131,6 +136,7 @@ test.describe('Doc Comments', () => {
'background-color',
'rgba(237, 180, 0, 0.4)',
);
await editor.first().click();
await editor.getByText('Hello').click();
await thread.getByText('This is a comment').first().hover();
@@ -147,7 +153,7 @@ test.describe('Doc Comments', () => {
await thread.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.getByRole('button', { name: 'Save' });
const saveBtn = thread.locator('button[data-test="save"]').first();
await saveBtn.click();
await expect(saveBtn).toBeHidden();
await expect(
@@ -157,7 +163,8 @@ test.describe('Doc Comments', () => {
// Add second comment
await thread.getByRole('paragraph').last().fill('This is a second comment');
await thread.getByRole('button', { name: 'Save' }).click();
await saveBtn.click();
await expect(saveBtn).toBeHidden();
await expect(
thread.getByText('This is an edited comment').first(),
).toBeVisible();
@@ -181,6 +188,29 @@ test.describe('Doc Comments', () => {
'background-color',
'rgba(0, 0, 0, 0)',
);
/* Delete the last comment remove the thread */
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
await thread.getByRole('paragraph').first().fill('This is a new comment');
await thread.locator('[data-test="save"]').click();
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
await editor.first().click();
await editor.getByText('Hello').click();
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 expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
});
test('it checks the comments abilities', async ({ page, browserName }) => {
@@ -191,6 +221,8 @@ test.describe('Doc Comments', () => {
// We share the doc with another user
const otherBrowserName = getOtherBrowserName(browserName);
const editor = await getEditor({ page });
// Add a new member with editor role
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Editor', otherBrowserName);
@@ -214,7 +246,7 @@ test.describe('Doc Comments', () => {
text: 'Hello, I can edit the document',
});
await expect(
otherEditor.getByText('Hello, I can edit the document'),
editor.getByText('Hello, I can edit the document'),
).toBeVisible();
await otherEditor.getByText('Hello').selectText();
await otherPage.getByRole('button', { name: 'Comment' }).click();
@@ -292,4 +324,82 @@ test.describe('Doc Comments', () => {
await cleanup();
});
test('it checks comments pasting from another document', async ({
page,
browserName,
}) => {
await createDoc(page, 'comment-doc-1', browserName, 1);
// We add a comment in the first document
const editor1 = await writeInEditor({ page, text: 'Document One' });
await editor1.getByText('Document One').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
const thread1 = page.locator('.bn-thread');
await thread1.getByRole('paragraph').first().fill('Comment in Doc One');
await thread1.locator('[data-test="save"]').click();
await expect(thread1.getByText('Comment in Doc One').first()).toBeHidden();
await expect(editor1.getByText('Document One')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
await editor1.getByText('Document One').click();
// We copy the content including the comment from the first document
await editor1.getByText('Document One').selectText();
await page.keyboard.press('Control+C');
// We create a second document
await createDoc(page, 'comment-doc-2', browserName, 1);
// We paste the content into the second document
const editor2 = await writeInEditor({ page, text: '' });
await editor2.click();
await page.keyboard.press('Control+V');
await expect(editor2.getByText('Document One')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await editor2.getByText('Document One').click();
await expect(page.locator('.bn-thread')).toBeHidden();
});
});
test.describe('Doc Comments mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test('Can comments on mobile', async ({ page, browserName }) => {
const [title] = await createDoc(
page,
'comment-mobile',
browserName,
1,
true,
);
await closeHeaderMenu(page);
await verifyDocName(page, title);
// Checks add react reaction
const editor = await writeInEditor({ page, text: 'Hello' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
// Check toolbar is closed after adding a comment
await expect(page.getByRole('button', { name: 'Paragraph' })).toBeHidden();
await editor.first().click();
await editor.getByText('Hello').click();
await expect(thread.getByText('This is a comment').first()).toBeVisible();
});
});
@@ -802,6 +802,8 @@ test.describe('Doc Editor', () => {
await page.getByText('Symbols').scrollIntoViewIfNeeded();
await expect(page.getByRole('button', { name: '🛃' })).toBeVisible();
await page.keyboard.press('Escape');
await page.locator('.bn-side-menu > button').last().click();
await page.locator('.mantine-Menu-dropdown > button').last().click();
await page.locator('.bn-color-picker-dropdown > button').last().click();
@@ -878,14 +880,15 @@ test.describe('Doc Editor', () => {
// Wait for the interlink to be created and rendered
const editor = await getEditor({ page });
const interlinkChild2 = editor.getByRole('button', {
name: docChild2,
});
const interlinkChild = editor
.locator('.--docs--interlinking-link-inline-content')
.first();
await expect(interlinkChild2).toBeVisible({ timeout: 10000 });
await expect(interlinkChild2).toContainText('😀');
await expect(interlinkChild2.locator('svg').first()).toBeHidden();
await interlinkChild2.click();
await expect(interlinkChild).toBeVisible({ timeout: 10000 });
await expect(interlinkChild).toContainText('😀');
await expect(interlinkChild).toContainText(docChild2);
await expect(interlinkChild.locator('svg').first()).toBeHidden();
await interlinkChild.click();
await verifyDocName(page, docChild2);
@@ -895,11 +898,9 @@ test.describe('Doc Editor', () => {
await input.fill(docChild1);
await searchContainer.getByText(docChild1).click();
const interlinkChild1 = editor.getByRole('button', {
name: docChild1,
});
await expect(interlinkChild1).toBeVisible({ timeout: 10000 });
await expect(interlinkChild1.locator('svg').first()).toBeVisible();
await expect(interlinkChild).toContainText(docChild1);
await expect(interlinkChild).toBeVisible({ timeout: 10000 });
await expect(interlinkChild.locator('svg').first()).toBeVisible();
await page.keyboard.press('@');
@@ -959,13 +960,35 @@ test.describe('Doc Editor', () => {
test('it embeds PDF', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Reading');
await page.getByRole('button', { name: 'Close the share modal' }).click();
await openSuggestionMenu({ page });
await page.getByText('Embed a PDF file').click();
const pdfBlock = page.locator('div[data-content-type="pdf"]').first();
const pdfBlock = page.locator('div[data-content-type="pdf"]').last();
await expect(pdfBlock).toBeVisible();
// Try with invalid PDF first
await page.getByText(/Add (PDF|file)/).click();
await page.locator('[data-test="embed-tab"]').click();
await page
.locator('[data-test="embed-input"]')
.fill('https://example.test/test.test');
await page.locator('[data-test="embed-input-button"]').click();
await expect(page.getByText('Invalid or missing PDF file')).toBeVisible();
await openSuggestionMenu({ page });
await page.getByText('Embed a PDF file').click();
// Now with a valid PDF
await page.getByText(/Add (PDF|file)/).click();
const fileChooserPromise = page.waitForEvent('filechooser');
const downloadPromise = page.waitForEvent('download');
@@ -990,10 +1013,50 @@ test.describe('Doc Editor', () => {
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
// Check download with original filename
await page.locator('.bn-block-content[data-content-type="pdf"]').click();
await pdfBlock.click();
await page.locator('[data-test="downloadfile"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('test-pdf.pdf');
});
test('it preserves text when switching between mobile and desktop views', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(
page,
'doc-viewport-test',
browserName,
1,
);
await verifyDocName(page, docTitle);
const editor = await writeInEditor({
page,
text: 'Hello World - Desktop Text',
});
await expect(editor.getByText('Hello World - Desktop Text')).toBeVisible();
await page.waitForTimeout(500);
// Switch to mobile viewport
await page.setViewportSize({ width: 500, height: 1200 });
await page.waitForTimeout(500);
await expect(editor.getByText('Hello World - Desktop Text')).toBeVisible();
await writeInEditor({
page,
text: 'Mobile Text',
});
await page.waitForTimeout(500);
// Switch back to desktop viewport
await page.setViewportSize({ width: 1280, height: 720 });
await page.waitForTimeout(500);
await expect(editor.getByText('Mobile Text')).toBeVisible();
});
});
@@ -2,6 +2,7 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import JSZip from 'jszip';
import { PDFParse } from 'pdf-parse';
import {
@@ -31,7 +32,7 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
@@ -187,6 +188,89 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
});
test('it exports the doc to html zip', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(
page,
'doc-editor-html-zip',
browserName,
1,
);
await verifyDocName(page, randomDoc);
// Add some content and at least one image so that the ZIP contains media files.
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello HTML ZIP');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
// Wait for the image to be attached and have a valid src (aria-hidden prevents toBeVisible on Chromium)
await expect(image).toBeAttached({ timeout: 10000 });
await expect(image).toHaveAttribute('src', /.*\.svg/);
// Give some time for the image to be fully processed
await page.waitForTimeout(1000);
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'HTML' }).click();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.zip`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.zip`);
const zipBuffer = await cs.toBuffer(await download.createReadStream());
// Unzip and inspect contents
const zip = await JSZip.loadAsync(zipBuffer);
// Check that index.html exists
const indexHtml = zip.file('index.html');
expect(indexHtml).not.toBeNull();
// Read and verify HTML content
const htmlContent = await indexHtml!.async('string');
expect(htmlContent).toContain('Hello HTML ZIP');
expect(htmlContent).toContain('href="styles.css"');
// Check for media files (they are at the root of the ZIP, not in a media/ folder)
// Media files are named like "1-test.svg" or "media-1.png" by deriveMediaFilename
const allFiles = Object.keys(zip.files);
const mediaFiles = allFiles.filter(
(name) => name !== 'index.html' && !name.endsWith('/'),
);
expect(mediaFiles.length).toBeGreaterThan(0);
// Verify the SVG image is included
const svgFile = mediaFiles.find((name) => name.endsWith('.svg'));
expect(svgFile).toBeDefined();
const styleFile = mediaFiles.find((name) => name === 'styles.css');
expect(styleFile).toBeDefined();
});
/**
* This test tell us that the export to pdf is working with images
* but it does not tell us if the images are being displayed correctly
@@ -443,7 +527,7 @@ test.describe('Doc Export', () => {
await verifyDocName(page, docChild);
await page.locator('.bn-block-outer').last().fill('/');
const editor = await openSuggestionMenu({ page });
await page.getByText('Link a doc').first().click();
const input = page.locator(
@@ -460,12 +544,11 @@ test.describe('Doc Export', () => {
await searchContainer.getByText(randomDoc).click();
// Search the interlinking link in the editor (not in the document tree)
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('button', {
name: randomDoc,
});
const interlink = editor
.locator('.--docs--interlinking-link-inline-content')
.first();
await expect(interlink).toBeVisible();
await expect(interlink).toContainText(randomDoc);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${docChild}.pdf`);
@@ -509,7 +592,7 @@ test.describe('Doc Export', () => {
await verifyDocName(page, docChild);
await page.locator('.bn-block-outer').last().fill('/');
const editor = await openSuggestionMenu({ page });
await page.getByText('Link a doc').first().click();
const input = page.locator(
@@ -526,12 +609,11 @@ test.describe('Doc Export', () => {
await searchContainer.getByText(randomDoc).click();
// Search the interlinking link in the editor (not in the document tree)
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('button', {
name: randomDoc,
});
const interlink = editor
.locator('.--docs--interlinking-link-inline-content')
.first();
await expect(interlink).toBeVisible();
await expect(interlink).toContainText(randomDoc);
await page
.getByRole('button', {
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, mockedListDocs } from './utils-common';
import { createDoc, mockedListDocs, toggleHeaderMenu } from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc grid dnd', () => {
@@ -185,10 +185,7 @@ test.describe('Doc grid dnd mobile', () => {
true,
);
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
await toggleHeaderMenu(page);
await expect(page.locator('.--docs-sub-page-item').first()).toHaveAttribute(
'draggable',
@@ -408,40 +408,6 @@ test.describe('Doc Header', () => {
expect(clipboardContent.trim()).toBe('# Hello World');
});
test('It checks the copy as HTML button', async ({ page, browserName }) => {
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
);
// create page and navigate to it
await page
.getByRole('button', {
name: 'New doc',
})
.click();
// Add dummy content to the doc
const editor = page.locator('.ProseMirror');
const docFirstBlock = editor.locator('.bn-block-content').first();
await docFirstBlock.click();
await page.keyboard.type('# Hello World', { delay: 100 });
const docFirstBlockContent = docFirstBlock.locator('h1');
await expect(docFirstBlockContent).toHaveText('Hello World');
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
const handle = await page.evaluateHandle(() =>
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
});
test('it checks the copy link button', async ({ page, browserName }) => {
test.skip(
browserName === 'webkit',
@@ -303,6 +303,40 @@ test.describe('Doc Tree', () => {
await expect(docTree.getByText(docChild)).toBeVisible();
});
test('keyboard navigation with F2 focuses root actions button', async ({
page,
browserName,
}) => {
// Create a parent document to initialize the tree
const [docParent] = await createDoc(
page,
'doc-tree-keyboard-f2-root',
browserName,
1,
);
await verifyDocName(page, docParent);
const docTree = page.getByTestId('doc-tree');
await expect(docTree).toBeVisible();
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
// Focus the root item
await rootItem.focus();
await expect(rootItem).toBeFocused();
// Press F2 → focus should move to the root actions \"More options\" button
await page.keyboard.press('F2');
const rootActions = rootItem.locator('.doc-tree-root-item-actions');
const rootMoreOptionsButton = rootActions.getByRole('button', {
name: /more options/i,
});
await expect(rootMoreOptionsButton).toBeFocused();
});
test('it updates the child icon from the tree', async ({
page,
browserName,
@@ -6,6 +6,7 @@ import {
mockedDocument,
verifyDocName,
} from './utils-common';
import { openSuggestionMenu, writeInEditor } from './utils-editor';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -17,6 +18,7 @@ test.describe('Doc Version', () => {
await verifyDocName(page, randomDoc);
// Initially, there is no version
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
@@ -26,31 +28,49 @@ test.describe('Doc Version', () => {
await expect(panel).toBeVisible();
await expect(modal.getByText('No versions')).toBeVisible();
const editor = page.locator('.ProseMirror');
await modal.getByRole('button', { name: 'close' }).click();
await editor.click();
await page.keyboard.type('# Hello World');
await writeInEditor({ page, text: 'Hello World' });
// It will trigger a save, no version created yet (initial version is not counted)
await goToGridDoc(page, {
title: randomDoc,
});
await expect(
page.getByRole('heading', { name: 'Hello World' }),
).toBeVisible();
await expect(page.getByText('Hello World')).toBeVisible();
await page
.locator('.ProseMirror .bn-block')
.getByRole('heading', { name: 'Hello World' })
.fill('It will create a version');
// Write more
await writeInEditor({ page, text: 'It will create a version' });
await openSuggestionMenu({ page });
await page.getByText('Add a callout block').click();
const calloutBlock = page
.locator('div[data-content-type="callout"]')
.first();
await expect(calloutBlock).toBeVisible();
// It will trigger a save and create a version this time
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
await expect(calloutBlock).toBeVisible();
// Write more
await writeInEditor({ page, text: 'It will create a second version' });
// It will trigger a save and create a second version
await goToGridDoc(page, {
title: randomDoc,
});
await expect(
page.getByRole('heading', { name: 'It will create a version' }),
page.getByText('It will create a second version'),
).toBeVisible();
await page.getByLabel('Open the document options').click();
@@ -60,11 +80,33 @@ test.describe('Doc Version', () => {
await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(1);
await items[0].click();
expect(items.length).toBe(2);
await items[1].click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
await expect(
modal.locator('div[data-content-type="callout"]').first(),
).toBeHidden();
await items[0].click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeVisible();
await expect(
modal.locator('div[data-content-type="callout"]').first(),
).toBeVisible();
await expect(
modal.getByText('It will create a second version'),
).toBeHidden();
await items[1].click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
await expect(
modal.locator('div[data-content-type="callout"]').first(),
).toBeHidden();
});
test('it does not display the doc versions if not allowed', async ({
@@ -10,6 +10,7 @@ export const CONFIG = {
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
ENVIRONMENT: 'development',
FRONTEND_CSS_URL: null,
FRONTEND_JS_URL: null,
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true,
FRONTEND_THEME: null,
MEDIA_BASE_URL: 'http://localhost:8083',
@@ -83,6 +84,34 @@ export const randomName = (name: string, browserName: string, length: number) =>
return `${browserName}-${Math.floor(Math.random() * 10000)}-${index}-${name}`;
});
export const openHeaderMenu = async (page: Page) => {
const toggleButton = page.getByTestId('header-menu-toggle');
await expect(toggleButton).toBeVisible();
const isExpanded =
(await toggleButton.getAttribute('aria-expanded')) === 'true';
if (!isExpanded) {
await toggleButton.click();
}
};
export const closeHeaderMenu = async (page: Page) => {
const toggleButton = page.getByTestId('header-menu-toggle');
await expect(toggleButton).toBeVisible();
const isExpanded =
(await toggleButton.getAttribute('aria-expanded')) === 'true';
if (isExpanded) {
await toggleButton.click();
}
};
export const toggleHeaderMenu = async (page: Page) => {
const toggleButton = page.getByTestId('header-menu-toggle');
await expect(toggleButton).toBeVisible();
await toggleButton.click();
};
export const createDoc = async (
page: Page,
docName: string,
@@ -94,10 +123,7 @@ export const createDoc = async (
for (let i = 0; i < randomDocs.length; i++) {
if (isMobile) {
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
await openHeaderMenu(page);
}
await page
@@ -2,6 +2,8 @@ import { Page, expect } from '@playwright/test';
import {
BrowserName,
closeHeaderMenu,
openHeaderMenu,
randomName,
updateDocTitle,
verifyDocName,
@@ -15,10 +17,7 @@ export const createRootSubPage = async (
isMobile = false,
) => {
if (isMobile) {
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
await openHeaderMenu(page);
}
// Get response
@@ -29,10 +28,7 @@ export const createRootSubPage = async (
const subPageJson = (await response.json()) as { id: string };
if (isMobile) {
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
await openHeaderMenu(page);
}
// Get doc tree
@@ -44,13 +40,9 @@ export const createRootSubPage = async (
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
.first();
await expect(subPageItem).toBeVisible();
await subPageItem.click();
if (isMobile) {
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('close')
.click();
await closeHeaderMenu(page);
}
// Update sub page name
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "4.0.0",
"version": "4.3.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -15,7 +15,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.56.1",
"@playwright/test": "1.57.0",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-plugin-docs": "*",
+27 -27
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "4.0.0",
"version": "4.3.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -19,14 +19,14 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.42.3",
"@blocknote/core": "0.42.3",
"@blocknote/mantine": "0.42.3",
"@blocknote/react": "0.42.3",
"@blocknote/xl-docx-exporter": "0.42.3",
"@blocknote/xl-multi-column": "0.42.3",
"@blocknote/xl-odt-exporter": "0.42.3",
"@blocknote/xl-pdf-exporter": "0.42.3",
"@blocknote/code-block": "0.45.0",
"@blocknote/core": "0.45.0",
"@blocknote/mantine": "0.45.0",
"@blocknote/react": "0.45.0",
"@blocknote/xl-docx-exporter": "0.45.0",
"@blocknote/xl-multi-column": "0.45.0",
"@blocknote/xl-odt-exporter": "0.45.0",
"@blocknote/xl-pdf-exporter": "0.45.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
@@ -35,14 +35,14 @@
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.18.0",
"@hocuspocus/provider": "3.4.0",
"@mantine/core": "8.3.9",
"@mantine/hooks": "8.3.9",
"@gouvfr-lasuite/ui-kit": "0.18.4",
"@hocuspocus/provider": "3.4.3",
"@mantine/core": "8.3.10",
"@mantine/hooks": "8.3.10",
"@openfun/cunningham-react": "4.0.0",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.27.0",
"@tanstack/react-query": "5.90.10",
"@sentry/nextjs": "10.30.0",
"@tanstack/react-query": "5.90.12",
"@tiptap/extensions": "*",
"canvg": "4.0.3",
"clsx": "2.1.1",
@@ -52,18 +52,18 @@
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "25.6.3",
"i18next": "25.7.2",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.7.2",
"next": "15.5.4",
"posthog-js": "1.298.0",
"next": "15.5.9",
"posthog-js": "1.306.1",
"react": "*",
"react-aria-components": "1.13.0",
"react-dom": "*",
"react-dropzone": "14.3.8",
"react-i18next": "16.3.5",
"react-i18next": "16.5.0",
"react-intersection-observer": "10.0.0",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
@@ -71,11 +71,11 @@
"use-debounce": "10.0.6",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.8"
"zustand": "5.0.9"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.91.0",
"@tanstack/react-query-devtools": "5.91.1",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.0",
@@ -85,21 +85,21 @@
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "5.1.1",
"@vitejs/plugin-react": "5.1.2",
"copy-webpack-plugin": "13.0.1",
"cross-env": "10.1.0",
"dotenv": "17.2.3",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "27.2.0",
"jsdom": "27.3.0",
"node-fetch": "2.7.0",
"prettier": "3.6.2",
"stylelint": "16.26.0",
"prettier": "3.7.4",
"stylelint": "16.26.1",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.13",
"vite-tsconfig-paths": "6.0.1",
"vitest": "4.0.15",
"webpack": "5.103.0",
"workbox-webpack-plugin": "7.1.0"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -1,5 +1,6 @@
import { Loader } from '@openfun/cunningham-react';
import Head from 'next/head';
import Script from 'next/script';
import { PropsWithChildren, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -87,6 +88,9 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
<link rel="stylesheet" href={conf?.FRONTEND_CSS_URL} />
</Head>
)}
{conf?.FRONTEND_JS_URL && (
<Script src={conf?.FRONTEND_JS_URL} strategy="afterInteractive" />
)}
<AnalyticsProvider>
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
{children}
@@ -21,6 +21,7 @@ export interface ConfigResponse {
ENVIRONMENT: string;
FRONTEND_CSS_URL?: string;
FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean;
FRONTEND_JS_URL?: string;
FRONTEND_THEME?: Theme;
LANGUAGES: [string, string][];
LANGUAGE_CODE: string;
@@ -44,7 +44,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
if (config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED) {
if (pathname !== HOME_URL) {
setIsRedirecting(true);
void replace(HOME_URL).then(() => setIsRedirecting(false));
window.location.replace(HOME_URL);
}
return;
@@ -1,6 +1,6 @@
import { baseApiUrl } from '@/api';
export const HOME_URL = '/home';
export const HOME_URL = '/home/';
export const LOGIN_URL = `${baseApiUrl()}authenticate/`;
export const LOGOUT_URL = `${baseApiUrl()}logout/`;
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';
@@ -6,6 +6,7 @@ import {
defaultInlineContentSpecs,
withPageBreak,
} from '@blocknote/core';
import { CommentsExtension } from '@blocknote/core/comments';
import '@blocknote/core/fonts/inter.css';
import * as locales from '@blocknote/core/locales';
import { BlockNoteView } from '@blocknote/mantine';
@@ -15,13 +16,13 @@ import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import type { Awareness } from 'y-protocols/awareness';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, useProviderStore } from '@/docs/doc-management';
import { avatarUrlFromName, useAuth } from '@/features/auth';
import { useResponsiveStore } from '@/stores';
import {
useHeadings,
@@ -84,10 +85,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const { themeTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { isSynced: isConnectedToCollabServer } = useProviderStore();
const refEditorContainer = useRef<HTMLDivElement>(null);
const canSeeComment = doc.abilities.comment && isDesktop;
const canSeeComment = doc.abilities.comment;
// Determine if comments should be visible in the UI
const showComments = canSeeComment;
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
const { i18n } = useTranslation();
@@ -99,7 +101,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const cursorName = collabName || t('Anonymous');
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
const threadStore = useComments(doc.id, canSeeComment, user);
const { resolveUsers, threadStore } = useComments(
doc.id,
canSeeComment,
user,
);
const currentUserAvatarUrl = useMemo(() => {
if (canSeeComment) {
@@ -110,7 +116,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const editor: DocsBlockNoteEditor = useCreateBlockNote(
{
collaboration: {
provider: provider,
provider: provider as { awareness?: Awareness | undefined },
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: cursorName,
@@ -154,28 +160,34 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
},
showCursorLabels: showCursorLabels as 'always' | 'activity',
},
comments: { threadStore },
dictionary: {
...locales[lang as keyof typeof locales],
multi_column:
multiColumnLocales?.[lang as keyof typeof multiColumnLocales],
...(multiColumnLocales && {
multi_column:
multiColumnLocales[lang as keyof typeof multiColumnLocales],
}),
},
resolveUsers: async (userIds) => {
return Promise.resolve(
userIds.map((encodedURIUserId) => {
const fullName = decodeURIComponent(encodedURIUserId);
pasteHandler: ({ event, defaultPasteHandler }) => {
// Get clipboard data
const blocknoteData = event.clipboardData?.getData('blocknote/html');
return {
id: encodedURIUserId,
username: fullName || t('Anonymous'),
avatarUrl: avatarUrlFromName(
fullName,
themeTokens?.font?.families?.base,
),
};
}),
);
/**
* When pasting comments, the data-bn-thread-id
* attribute is present in the clipboard data.
* This indicates that the pasted content contains comments.
* But if the content with comments comes from another document,
* it will create orphaned comments that are not linked to this document
* and create errors.
* To avoid this, we refresh the threads to ensure that only comments
* relevant to the current document are displayed.
*/
if (blocknoteData && blocknoteData.includes('data-bn-thread-id')) {
void threadStore.refreshThreads();
}
return defaultPasteHandler();
},
extensions: [CommentsExtension({ threadStore, resolveUsers })],
tables: {
splitCells: true,
cellBackgroundColor: true,
@@ -185,7 +197,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
uploadFile,
schema: blockNoteSchema,
},
[cursorName, lang, provider, uploadFile, threadStore],
[cursorName, lang, provider, uploadFile, threadStore, resolveUsers],
);
useHeadings(editor);
@@ -207,7 +219,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
ref={refEditorContainer}
$css={css`
${cssEditor};
${cssComments(canSeeComment, currentUserAvatarUrl)}
${cssComments(showComments, currentUserAvatarUrl)}
`}
>
{errorAttachment && (
@@ -225,7 +237,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
formattingToolbar={false}
slashMenu={false}
theme="light"
comments={canSeeComment}
comments={showComments}
aria-label={t('Document editor')}
>
<BlockNoteSuggestionMenu />
@@ -246,7 +258,7 @@ export const BlockNoteReader = ({
}: BlockNoteReaderProps) => {
const { user } = useAuth();
const { setEditor } = useEditorStore();
const threadStore = useComments(docId, false, user);
const { threadStore } = useComments(docId, false, user);
const { t } = useTranslation();
const editor = useCreateBlockNote(
{
@@ -259,12 +271,16 @@ export const BlockNoteReader = ({
provider: undefined,
},
schema: blockNoteSchema,
comments: { threadStore },
resolveUsers: async () => {
return Promise.resolve([]);
},
extensions: [
CommentsExtension({
threadStore,
resolveUsers: async () => {
return Promise.resolve([]);
},
}),
],
},
[initialContent],
[initialContent, threadStore],
);
useEffect(() => {
@@ -1,4 +1,5 @@
import { combineByGroup, filterSuggestionItems } from '@blocknote/core';
import { combineByGroup } from '@blocknote/core';
import { filterSuggestionItems } from '@blocknote/core/extensions';
import {
DefaultReactSuggestionItem,
SuggestionMenuController,
@@ -6,24 +6,25 @@
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx
*/
import {
BlockSchema,
InlineContentSchema,
StyleSchema,
blockHasType,
} from '@blocknote/core';
import { blockHasType } from '@blocknote/core';
import {
useBlockNoteEditor,
useComponentsContext,
useDictionary,
useSelectedBlocks,
useEditorState,
} from '@blocknote/react';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { RiDownload2Fill } from 'react-icons/ri';
import { downloadFile, exportResolveFileUrl } from '@/docs/doc-export';
import { isSafeUrl } from '@/utils/url';
import {
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
} from '../../types';
export const FileDownloadButton = ({
open,
}: {
@@ -33,36 +34,43 @@ export const FileDownloadButton = ({
const Components = useComponentsContext();
const editor = useBlockNoteEditor<
BlockSchema,
InlineContentSchema,
StyleSchema
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>();
const selectedBlocks = useSelectedBlocks(editor);
const fileBlock = useEditorState({
editor,
selector: ({ editor }) => {
const selectedBlocks = editor.getSelection()?.blocks || [
editor.getTextCursorPosition().block,
];
const fileBlock = useMemo(() => {
// Checks if only one block is selected.
if (selectedBlocks.length !== 1) {
return undefined;
}
if (selectedBlocks.length !== 1) {
return undefined;
}
const block = selectedBlocks[0];
const block = selectedBlocks[0];
if (
!blockHasType(block, editor, block.type, {
url: 'string',
name: 'string',
})
) {
return undefined;
}
if (
blockHasType(block, editor, block.type, { url: 'string', name: 'string' })
) {
return block;
}
return undefined;
}, [editor, selectedBlocks]);
},
});
const onClick = useCallback(async () => {
if (fileBlock && fileBlock.props.url) {
editor.focus();
const url = fileBlock.props.url as string;
const name = fileBlock.props.name as string | undefined;
const name = fileBlock.props.name as string;
/**
* If not hosted on our domain, means not a file uploaded by the user,
@@ -110,19 +118,18 @@ export const FileDownloadButton = ({
return null;
}
const blockType = fileBlock.type as string;
const tooltipText =
dict.formatting_toolbar.file_download.tooltip[blockType] ||
dict.formatting_toolbar.file_download.tooltip['file'];
return (
<>
<Components.FormattingToolbar.Button
className="bn-button --docs--editor-file-download-button"
data-test="downloadfile"
label={
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
dict.formatting_toolbar.file_download.tooltip['file']
}
mainTooltip={
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
dict.formatting_toolbar.file_download.tooltip['file']
}
label={tooltipText}
mainTooltip={tooltipText}
icon={<RiDownload2Fill />}
onClick={() => void onClick()}
/>
@@ -90,7 +90,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
}
}, [isProviderReady, setIsSkeletonVisible]);
if (!isProviderReady) {
if (!isProviderReady || provider?.configuration.name !== doc.id) {
return <Loading />;
}
@@ -1,6 +1,14 @@
/**
* This file is adapted from BlockNote's AddCommentButton component
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx
*/
import { CommentsExtension } from '@blocknote/core/comments';
import { FormattingToolbarExtension } from '@blocknote/core/extensions';
import {
useBlockNoteEditor,
useComponentsContext,
useExtension,
useSelectedBlocks,
} from '@blocknote/react';
import { useMemo } from 'react';
@@ -22,6 +30,10 @@ export const CommentToolbarButton = () => {
const { currentDoc } = useDocStore();
const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const comments = useExtension('comments') as unknown as ReturnType<
ReturnType<typeof CommentsExtension>
>;
const { store } = useExtension(FormattingToolbarExtension);
const editor = useBlockNoteEditor<
DocsBlockSchema,
@@ -35,7 +47,18 @@ export const CommentToolbarButton = () => {
return !!selectedBlocks.find((block) => block.content !== undefined);
}, [selectedBlocks]);
const focusOnInputThread = () => {
// Use setTimeout to ensure the DOM has been updated with the new comment
setTimeout(() => {
const threadElement = document.querySelector<HTMLElement>(
'.bn-thread .bn-editor',
);
threadElement?.focus();
}, 400);
};
if (
!comments ||
!show ||
!editor.isEditable ||
!Components ||
@@ -49,8 +72,9 @@ export const CommentToolbarButton = () => {
<Components.Generic.Toolbar.Button
className="bn-button"
onClick={() => {
editor.comments?.startPendingComment();
editor.formattingToolbar.closeMenu();
comments.startPendingComment();
store.setState(false);
focusOnInputThread();
}}
aria-haspopup="dialog"
data-test="comment-toolbar-button"
@@ -37,7 +37,8 @@ export class DocsThreadStore extends ThreadStore {
if (docAuth.canSee) {
this.awareness = awareness;
this.awareness?.on('update', this.onAwarenessUpdate);
void this.refreshThreads();
this.refreshThreads();
}
}
@@ -98,9 +99,9 @@ export class DocsThreadStore extends ThreadStore {
// If we know the threadId, schedule a targeted refresh. Otherwise, fall back to full refresh.
if (ping.threadId) {
await this.refreshThread(ping.threadId);
void this.refreshThread(ping.threadId);
} else {
await this.refreshThreads();
this.refreshThreads();
}
}
};
@@ -117,6 +118,21 @@ export class DocsThreadStore extends ThreadStore {
});
}
/**
* Scrolls to the bottom of a thread modal
* @param threadId
*/
private scrollToBottomOfThread() {
// Use setTimeout to ensure the DOM has been updated with the new comment
setTimeout(() => {
const threadElement = document.querySelector('.bn-thread');
threadElement?.scrollBy({
top: threadElement.scrollHeight,
behavior: 'smooth',
});
}, 200);
}
/**
* Notifies all subscribers about the current thread state
*/
@@ -185,7 +201,7 @@ export class DocsThreadStore extends ThreadStore {
const { editor } = useEditorStore.getState();
// Should not happen
if (!editor) {
if (!editor || !editor._tiptapEditor?.view?.dom) {
console.warn('Editor to add thread not ready');
return Promise.resolve();
}
@@ -196,6 +212,17 @@ export class DocsThreadStore extends ThreadStore {
.setMark?.('comment', { orphan: false, threadId })
.run?.();
/**
* We have some issues with mobiles and the formatting toolbar reopening
* after adding a comment, so we restore the cursor position here.
* By restoring the cursor position at the head of the selection,
* it will automatically close the formatting toolbar.
*/
const cursorPos = editor._tiptapEditor?.state.selection.head;
if (cursorPos !== undefined) {
editor._tiptapEditor?.commands.setTextSelection(cursorPos);
}
return Promise.resolve();
};
@@ -225,6 +252,7 @@ export class DocsThreadStore extends ThreadStore {
this.upsertClientThreadData(threadData);
this.notifySubscribers();
this.ping(threadData.id);
return threadData;
};
@@ -265,7 +293,7 @@ export class DocsThreadStore extends ThreadStore {
}),
);
await this.refreshThreads();
this.refreshThreads();
return;
}
@@ -283,7 +311,17 @@ export class DocsThreadStore extends ThreadStore {
this.notifySubscribers();
}
public async refreshThreads(): Promise<void> {
public refreshThreads() {
this.initThreads().catch((e) => {
if (!(e instanceof APIError) || e.status !== 401) {
throw e;
}
return;
});
}
public async initThreads(): Promise<void> {
const response = await fetchAPI(`documents/${this.docId}/threads/`, {
method: 'GET',
});
@@ -345,6 +383,10 @@ export class DocsThreadStore extends ThreadStore {
await this.refreshThread(threadId);
}
this.ping(threadId);
// Auto-scroll to bottom of thread after adding comment
this.scrollToBottomOfThread();
return serverCommentToClientComment(comment);
};
@@ -405,10 +447,20 @@ export class DocsThreadStore extends ThreadStore {
// Optimistically remove the comment locally if we have the thread
const existing = this.threads.get(threadId);
if (existing) {
const updatedComments = existing.comments.filter(
(c) => c.id !== commentId,
);
// If this was the last comment, delete the thread
if (updatedComments.length === 0) {
await this.deleteThread({ threadId });
return;
}
const updated: ClientThreadData = {
...existing,
updatedAt: new Date(),
comments: existing.comments.filter((c) => c.id !== commentId),
comments: updatedComments,
};
this.upsertClientThreadData(updated);
this.notifySubscribers();
@@ -419,10 +471,6 @@ export class DocsThreadStore extends ThreadStore {
this.ping(threadId);
};
/**
* UI not implemented
* @param _options
*/
public deleteThread = async (_options: { threadId: string }) => {
const response = await fetchAPI(
`documents/${this.docId}/threads/${_options.threadId}/`,
@@ -459,7 +507,7 @@ export class DocsThreadStore extends ThreadStore {
);
}
await this.refreshThreads();
this.refreshThreads();
this.ping(threadId);
};
@@ -13,6 +13,10 @@ export const cssComments = (
background: ${canSeeComment ? '#EDB40066' : 'transparent'};
color: var(--c--globals--colors--gray-700);
}
[data-show-selection] {
color: HighlightText;
}
}
em-emoji-picker {
@@ -22,13 +26,16 @@ export const cssComments = (
// Thread modal
.bn-thread {
width: 400px;
width: 100%;
min-width: calc(min(400px, 90vw));
max-width: calc(min(400px, 90vw));
max-height: calc(min(500px, 60vh));
padding: 8px;
box-shadow: 0px 6px 18px 0px #00001229;
margin-left: 20px;
margin-right: 20px;
gap: 0;
overflow: auto;
max-height: 500px;
.bn-default-styles {
font-family: var(--c--globals--font--families--base);
@@ -113,7 +120,7 @@ export const cssComments = (
}
& svg {
color: var(--c--globals--colors--info-600);
color: var(--c--contextuals--background--semantic--brand--primary);
}
}
@@ -130,15 +137,23 @@ export const cssComments = (
padding-inline: var(--c--globals--spacings--st);
&[data-test='save'] {
border: 1px solid var(--c--globals--colors--info-600);
background: var(--c--globals--colors--info-600);
color: white;
border: 1px solid
var(--c--contextuals--background--semantic--brand--primary);
background: var(
--c--contextuals--background--semantic--brand--primary
);
color: var(
--c--contextuals--content--semantic--brand--on-brand
);
}
&[data-test='cancel'] {
background: white;
border: 1px solid var(--c--globals--colors--gray-300);
color: var(--c--globals--colors--info-600);
border: 1px solid
var(--c--contextuals--border--surface--primary);
color: var(
--c--contextuals--background--semantic--brand--primary
);
}
}
}
@@ -180,7 +195,9 @@ export const cssComments = (
button {
font-size: 0;
background: var(--c--globals--colors--info-600);
background: var(
--c--contextuals--background--semantic--brand--primary
);
width: var(--c--globals--spacings--md);
height: var(--c--globals--spacings--md);
padding: var(--c--globals--spacings--0);
@@ -193,7 +210,7 @@ export const cssComments = (
content: 'arrow_upward_alt';
font-family: 'Material Symbols Outlined Variable', sans-serif;
font-size: 18px;
color: var(--c--globals--colors--gray-100);
color: var(--c--contextuals--content--semantic--brand--on-brand);
}
}
}
@@ -1,6 +1,8 @@
import { useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { User } from '@/features/auth';
import { useCunninghamTheme } from '@/cunningham';
import { User, avatarUrlFromName } from '@/features/auth';
import { Doc, useProviderStore } from '@/features/docs/doc-management';
import { DocsThreadStore } from './DocsThreadStore';
@@ -12,6 +14,9 @@ export function useComments(
user: User | null | undefined,
) {
const { provider } = useProviderStore();
const { t } = useTranslation();
const { themeTokens } = useCunninghamTheme();
const threadStore = useMemo(() => {
return new DocsThreadStore(
docId,
@@ -29,5 +34,25 @@ export function useComments(
};
}, [threadStore]);
return threadStore;
const resolveUsers = useCallback(
async (userIds: string[]) => {
return Promise.resolve(
userIds.map((encodedURIUserId) => {
const fullName = decodeURIComponent(encodedURIUserId);
return {
id: encodedURIUserId,
username: fullName || t('Anonymous'),
avatarUrl: avatarUrlFromName(
fullName,
themeTokens?.font?.families?.base,
),
};
}),
);
},
[t, themeTokens?.font?.families?.base],
);
return { threadStore, resolveUsers };
}
@@ -5,8 +5,8 @@ import {
InlineContentSchema,
StyleSchema,
defaultProps,
insertOrUpdateBlock,
} from '@blocknote/core';
import { insertOrUpdateBlockForSlashMenu } from '@blocknote/core/extensions';
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import React, { useEffect, useState } from 'react';
@@ -97,34 +97,41 @@ const CalloutComponent = ({
`}
>
<CalloutBlockStyle />
<BoxButton
contentEditable={false}
onClick={toggleEmojiPicker}
$css={css`
font-size: 1.125rem;
cursor: ${isEditable ? 'pointer' : 'default'};
${isEditable &&
`
<Box $position="relative">
<BoxButton
contentEditable={false}
onClick={toggleEmojiPicker}
onKeyDown={(e) => {
if (e.key === 'Escape' && openEmojiPicker) {
setOpenEmojiPicker(false);
}
}}
$css={css`
font-size: 1.125rem;
cursor: ${isEditable ? 'pointer' : 'default'};
${isEditable &&
`
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
`}
`}
$align="center"
$width="28px"
$radius="4px"
>
{block.props.emoji}
</BoxButton>
`}
$align="center"
$width="28px"
$radius="4px"
>
{block.props.emoji}
</BoxButton>
{openEmojiPicker && (
<EmojiPicker
emojiData={emojidata}
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
withOverlay={true}
/>
)}
{openEmojiPicker && (
<EmojiPicker
emojiData={emojidata}
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
withOverlay={true}
/>
)}
</Box>
<Box as="p" className="inline-content" ref={contentRef} />
</Box>
);
@@ -156,7 +163,7 @@ export const getCalloutReactSlashMenuItems = (
key: 'callout',
title: t('Callout'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
insertOrUpdateBlockForSlashMenu(editor, {
type: 'callout',
});
},
@@ -4,8 +4,8 @@ import {
BlockNoteEditor,
InlineContentSchema,
StyleSchema,
insertOrUpdateBlock,
} from '@blocknote/core';
import { insertOrUpdateBlockForSlashMenu } from '@blocknote/core/extensions';
import * as locales from '@blocknote/core/locales';
import {
AddFileButton,
@@ -13,12 +13,13 @@ import {
createReactBlockSpec,
} from '@blocknote/react';
import { TFunction } from 'i18next';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { createGlobalStyle, css } from 'styled-components';
import { Box, Icon } from '@/components';
import { Box, Icon, Loading } from '@/components';
import { ANALYZE_URL } from '../../conf';
import { DocsBlockNoteEditor } from '../../types';
const PDFBlockStyle = createGlobalStyle`
@@ -66,6 +67,9 @@ const PdfBlockComponent = ({
const pdfUrl = block.props.url;
const { i18n, t } = useTranslation();
const lang = i18n.resolvedLanguage;
const [isPDFContent, setIsPDFContent] = useState<boolean | null>(null);
const [isPDFContentLoading, setIsPDFContentLoading] =
useState<boolean>(false);
useEffect(() => {
if (lang && locales[lang as keyof typeof locales]) {
@@ -82,9 +86,55 @@ const PdfBlockComponent = ({
}
}, [lang, t]);
useEffect(() => {
if (!pdfUrl || pdfUrl.includes(ANALYZE_URL)) {
return;
}
const validatePDFContent = async () => {
setIsPDFContentLoading(true);
try {
const response = await fetch(pdfUrl, {
credentials: 'include',
});
const contentType = response.headers.get('content-type');
if (response.ok && contentType?.includes('application/pdf')) {
setIsPDFContent(true);
} else {
setIsPDFContent(false);
}
} catch {
setIsPDFContent(false);
} finally {
setIsPDFContentLoading(false);
}
};
void validatePDFContent();
}, [pdfUrl]);
return (
<Box ref={contentRef} className="bn-file-block-content-wrapper">
<PDFBlockStyle />
{isPDFContentLoading && <Loading />}
{!isPDFContentLoading && isPDFContent !== null && !isPDFContent && (
<Box
$align="center"
$justify="center"
$color="#666"
$background="#f5f5f5"
$border="1px solid #ddd"
$height="300px"
$css={css`
text-align: center;
`}
contentEditable={false}
onClick={() => editor.setTextCursorPosition(block)}
>
{t('Invalid or missing PDF file.')}
</Box>
)}
<ResizableFileBlockWrapper
buttonIcon={
<Icon iconName="upload" $size="24px" $css="line-height: normal;" />
@@ -92,18 +142,21 @@ const PdfBlockComponent = ({
block={block as unknown as FileBlockBlock}
editor={editor as unknown as FileBlockEditor}
>
<Box
className="bn-visual-media"
role="presentation"
as="embed"
$width="100%"
$height="450px"
type="application/pdf"
src={pdfUrl}
contentEditable={false}
draggable={false}
onClick={() => editor.setTextCursorPosition(block)}
/>
{!isPDFContentLoading && isPDFContent && (
<Box
as="embed"
className="bn-visual-media"
role="presentation"
$width="100%"
$height="450px"
type="application/pdf"
src={pdfUrl}
aria-label={block.props.name || t('PDF document')}
contentEditable={false}
draggable={false}
onClick={() => editor.setTextCursorPosition(block)}
/>
)}
</ResizableFileBlockWrapper>
</Box>
);
@@ -139,7 +192,7 @@ export const getPdfReactSlashMenuItems = (
{
title: t('PDF'),
onItemClick: () => {
insertOrUpdateBlock(editor, { type: 'pdf' });
insertOrUpdateBlockForSlashMenu(editor, { type: 'pdf' });
},
aliases: [t('pdf'), t('document'), t('embed'), t('file')],
group,
@@ -79,10 +79,12 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
return (
<BoxButton
as="span"
className="--docs--interlinking-link-inline-content"
onClick={handleClick}
draggable="false"
$css={css`
display: contents;
display: inline;
padding: 0.1rem 0.4rem;
border-radius: 4px;
& svg {
@@ -91,7 +93,9 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
margin-right: 0.2rem;
}
&:hover {
background-color: ${colorsTokens['gray-100']};
background-color: var(
--c--contextuals--background--semantic--contextual--primary
);
}
transition: background-color var(--c--globals--transitions--duration)
var(--c--globals--transitions--ease-out);
@@ -171,6 +171,7 @@ export const SearchPage = ({
{trigger}
<Box
as="input"
name="doc-search-input"
$padding={{ left: '3px' }}
$css={inputStyle}
ref={inputRef}
@@ -0,0 +1 @@
export const ANALYZE_URL = 'media-check';
@@ -7,6 +7,11 @@ export const useHeadings = (editor: DocsBlockNoteEditor) => {
const { setHeadings, resetHeadings } = useHeadingStore();
useEffect(() => {
// Check if editor and its view are mounted before accessing document
if (!editor) {
return;
}
setHeadings(editor);
let timeoutId: NodeJS.Timeout;
@@ -7,6 +7,11 @@ export const useShortcuts = (
el: HTMLDivElement | null,
) => {
useEffect(() => {
// Check if editor and its view are mounted
if (!editor || !el) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === '@' && editor?.isFocused()) {
const selection = window.getSelection();
@@ -32,10 +37,6 @@ export const useShortcuts = (
}
};
if (!el) {
return;
}
el.addEventListener('keydown', handleKeyDown);
return () => {
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { backendUrl } from '@/api';
import { useCreateDocAttachment } from '../api';
import { ANALYZE_URL } from '../conf';
import { DocsBlockNoteEditor } from '../types';
export const useUploadFile = (docId: string) => {
@@ -46,7 +47,6 @@ export const useUploadFile = (docId: string) => {
* @param editor
*/
export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
const ANALYZE_URL = 'media-check';
const { t } = useTranslation();
/**
@@ -95,7 +95,12 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
);
useEffect(() => {
const imagesBlocks = editor?.document.filter(
// Check if editor and its view are mounted before accessing document
if (!editor?.document) {
return;
}
const imagesBlocks = editor.document.filter(
(block) =>
block.type === 'image' && block.props.url.includes(ANALYZE_URL),
);
@@ -110,6 +115,11 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
* block to show analyzing status
*/
useEffect(() => {
// Check if editor and its view are mounted before setting up handlers
if (!editor) {
return;
}
editor.onUploadEnd((blockId) => {
if (!blockId) {
return;
@@ -28,6 +28,11 @@ export interface UseHeadingStore {
export const useHeadingStore = create<UseHeadingStore>((set, get) => ({
headings: [],
setHeadings: (editor) => {
// Check if editor and its view are mounted before accessing document
if (!editor?.document) {
return;
}
const headingBlocks = editor?.document
.filter(
(block) =>
@@ -16,12 +16,12 @@ describe('useModuleExport', () => {
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toBeUndefined();
}, 10000);
}, 15000);
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toHaveProperty('ModalExport');
});
}, 15000);
});
@@ -0,0 +1,67 @@
import { deriveMediaFilename } from '../utils';
describe('deriveMediaFilename', () => {
test('uses last URL segment when src is a valid URL', () => {
const result = deriveMediaFilename({
src: 'https://example.com/path/video.mp4',
index: 0,
blob: new Blob([], { type: 'video/mp4' }),
});
expect(result).toBe('1-video.mp4');
});
test('handles URLs with query/hash and keeps the last segment', () => {
const result = deriveMediaFilename({
src: 'https://site.com/assets/file.name.svg?x=1#test',
index: 0,
blob: new Blob([], { type: 'image/svg+xml' }),
});
expect(result).toBe('1-file.name.svg');
});
test('handles relative URLs using last segment', () => {
const result = deriveMediaFilename({
src: 'not a valid url',
index: 0,
blob: new Blob([], { type: 'image/png' }),
});
// "not a valid url" becomes a relative URL, so we get the last segment
expect(result).toBe('1-not%20a%20valid%20url.png');
});
test('data URLs always use media-{index+1}', () => {
const result = deriveMediaFilename({
src: 'data:image/png;base64,xxx',
index: 0,
blob: new Blob([], { type: 'image/png' }),
});
expect(result).toBe('media-1.png');
});
test('adds extension from MIME when baseName has no extension', () => {
const result = deriveMediaFilename({
src: 'https://a.com/abc',
index: 0,
blob: new Blob([], { type: 'image/webp' }),
});
expect(result).toBe('1-abc.webp');
});
test('does not override extension if baseName already contains one', () => {
const result = deriveMediaFilename({
src: 'https://a.com/image.png',
index: 0,
blob: new Blob([], { type: 'image/jpeg' }),
});
expect(result).toBe('1-image.png');
});
test('handles complex MIME types (e.g., audio/mpeg)', () => {
const result = deriveMediaFilename({
src: 'https://a.com/song',
index: 1,
blob: new Blob([], { type: 'audio/mpeg' }),
});
expect(result).toBe('2-song.mpeg');
});
});
@@ -0,0 +1,569 @@
/* Reset and Base Styles */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: inter, 'roboto flex variable', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: normal;
color: #25252f;
background-color: #f9f9f9;
}
main {
max-width: 868px;
width: 100%;
margin: 0 auto;
padding: 24px 54px 32px;
background: #fff;
min-height: 100vh;
}
/* Editor Container Styles */
.--docs--editor-container {
width: 100%;
padding: 24px 0 32px;
background: #fff;
font-size: 16px;
color: #25252f;
}
/* Block Spacing */
.bn-block-outer {
margin: 0;
}
.bn-block-content {
margin-bottom: 0;
}
/* Typography */
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 0;
margin-bottom: 0;
line-height: 1.5;
color: #454558;
}
h1 {
font-size: 30px;
font-weight: 700;
line-height: 45px;
}
h2 {
font-size: 24px;
font-weight: 700;
line-height: 36px;
}
h3 {
font-size: 20px;
font-weight: 700;
line-height: 30px;
}
h4 {
font-size: 18px;
font-weight: 600;
line-height: 27px;
}
h5 {
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
h6 {
font-size: 14px;
font-weight: 600;
line-height: 21px;
}
p {
margin: 0;
line-height: 24px;
}
.bn-inline-content {
line-height: 24px;
}
/* Links */
a {
color: #5d5d70;
text-decoration: underline;
cursor: pointer;
}
a:hover {
color: #454558;
}
/* Text Formatting */
strong,
b {
font-weight: 700;
}
em,
i {
font-style: italic;
}
u {
text-decoration: underline;
}
s {
text-decoration: line-through;
}
/* Custom text colors */
[data-style-type='textColor'][data-value='red'] {
color: rgb(224 62 62);
}
[data-style-type='backgroundColor'][data-value='blue'] {
background-color: rgb(221 235 241);
padding: 2px 4px;
border-radius: 3px;
}
/* Lists */
.bn-block-content[data-content-type='bulletListItem'],
.bn-block-content[data-content-type='numberedListItem'] {
padding-left: 0;
margin: 0;
display: list-item;
list-style-position: outside;
}
.bn-block-content[data-content-type='bulletListItem'] {
list-style-type: disc;
margin-left: 24px;
}
.bn-block-content[data-content-type='numberedListItem'] {
list-style-type: decimal;
margin-left: 24px;
}
.bn-block-content[data-content-type='bulletListItem'] p,
.bn-block-content[data-content-type='numberedListItem'] p {
display: inline;
}
/* Checkboxes */
.bn-block-content[data-content-type='checkListItem'] {
display: flex;
align-items: center;
gap: 8px;
}
.bn-block-content[data-content-type='checkListItem'] input[type='checkbox'] {
margin: 0;
width: 16px;
height: 16px;
cursor: pointer;
flex-shrink: 0;
}
.bn-block-content[data-content-type='checkListItem'] p {
flex: 1;
margin: 0;
}
/* Quotes */
blockquote,
.bn-block-content[data-content-type='quote'] blockquote {
border-left: 4px solid #a9a9bf;
padding-left: 16px;
margin: 0;
color: #7d797a;
font-style: normal;
}
/* Code Blocks */
.bn-block-content[data-content-type='codeBlock'] {
background-color: #161616;
border-radius: 8px;
padding: 3px 0;
margin: 8px 0;
overflow: auto;
}
.bn-block-content[data-content-type='codeBlock'] pre {
background-color: transparent;
padding: 24px;
margin: 0;
border-radius: 0;
overflow-x: auto;
}
.bn-block-content[data-content-type='codeBlock'] code {
font-family: monospace;
font-size: 16px;
line-height: 24px;
color: #fff;
background: transparent;
padding: 0;
}
.bn-block-content[data-content-type='codeBlock'] select {
padding: 4px 8px;
margin: 8px 0 0 24px;
border: 1px solid #3a3a3a;
border-radius: 4px;
background-color: #2a2a2a;
color: #fff;
font-size: 12.8px;
cursor: pointer;
}
/* Inline Code */
code {
font-family: monospace;
font-size: 16px;
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
}
pre code {
background: none;
padding: 0;
}
/* Tables */
table {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
}
th,
td {
border: 1px solid #ddd;
padding: 5px 10px;
text-align: left;
vertical-align: top;
}
th {
background-color: #f5f5f5;
font-weight: 700;
}
td p {
margin: 0;
}
/* Callout Blocks */
.bn-block-content[data-content-type='callout'] {
display: flex;
align-items: flex-start;
gap: 8px;
background-color: #fbf3db;
border-radius: 4px;
padding: 12px;
margin: 8px 0;
}
.bn-block-content[data-content-type='callout'][data-background-color='yellow'] {
background-color: #fbf3db;
}
.bn-block-content[data-content-type='callout'][data-background-color='blue'] {
background-color: #ddedf1;
}
.bn-block-content[data-content-type='callout'][data-background-color='red'] {
background-color: #ffe5e5;
}
.bn-block-content[data-content-type='callout'][data-background-color='green'] {
background-color: #e5f5e5;
}
.bn-block-content[data-content-type='callout'] > div {
display: flex;
flex-direction: row;
}
.bn-block-content[data-content-type='callout'] button {
background: transparent;
border: none;
padding: 0;
cursor: default;
font-size: 20px;
line-height: 1;
flex-shrink: 0;
}
.bn-block-content[data-content-type='callout'] .inline-content {
flex: 1;
}
/* Toggle Sections */
.bn-toggle-wrapper {
display: flex;
align-items: flex-start;
gap: 4px;
}
.bn-toggle-button {
width: 24px;
height: 24px;
padding: 3px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.bn-toggle-button:hover {
background-color: #f0f0f0;
}
.bn-toggle-button svg {
width: 18px;
height: 18px;
fill: #454558;
transition: transform 0.2s;
}
.bn-toggle-wrapper[data-show-children='true'] .bn-toggle-button svg {
transform: rotate(90deg);
}
.bn-toggle-wrapper h1,
.bn-toggle-wrapper h2,
.bn-toggle-wrapper h3,
.bn-toggle-wrapper h4,
.bn-toggle-wrapper h5,
.bn-toggle-wrapper h6,
.bn-toggle-wrapper p {
flex: 1;
margin: 0;
}
/* Toggle List Items */
.bn-block-content[data-content-type='toggleListItem'] {
margin-left: 24px;
}
/* Images and Media */
img.bn-visual-media {
max-width: 100%;
height: auto;
display: block;
border-radius: 4px;
margin: 8px 0;
}
video.bn-visual-media {
width: 100% !important;
height: auto !important;
max-width: 760px;
display: block;
border-radius: 4px;
margin: 8px 0;
object-fit: contain;
background-color: #000;
}
audio.bn-audio {
width: 100%;
max-width: 760px;
margin: 8px 0;
}
/* File Blocks */
.bn-file-block-content-wrapper {
margin: 8px 0;
max-width: 100%;
}
figure.bn-file-block-content-wrapper {
margin: 8px 0;
max-width: 100%;
}
.bn-block-content[data-content-type='video'] .bn-file-block-content-wrapper {
display: flex;
flex-direction: column;
width: fit-content;
max-width: 100%;
}
.bn-visual-media-wrapper {
display: flex;
position: relative;
max-width: 100%;
}
.bn-file-caption,
figcaption.bn-file-caption {
color: #454558;
font-size: 12.8px;
margin-top: 4px;
text-align: left;
font-style: normal;
}
/* Add File Button */
.bn-add-file-button {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border: 2px dashed #d0d0d0;
border-radius: 8px;
cursor: pointer;
transition:
border-color 0.2s,
background-color 0.2s;
}
.bn-add-file-button:hover {
border-color: #a0a0a0;
background-color: #f9f9f9;
}
.bn-add-file-button-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.bn-add-file-button-icon svg {
width: 100%;
height: 100%;
fill: #757575;
}
.bn-add-file-button-text {
margin: 0;
color: #757575;
font-size: 14px;
font-weight: 500;
}
/* Buttons */
button {
cursor: pointer;
font-family: inherit;
}
/* Select/Dropdown */
select {
font-family: inherit;
cursor: pointer;
}
/* Spacing between blocks */
.bn-block-outer + .bn-block-outer {
margin-top: 4px;
}
/* Heading spacing */
.bn-block-content[data-content-type='heading'] + .bn-block-outer {
margin-top: 8px;
}
.bn-block-outer + .bn-block-content[data-content-type='heading'] {
margin-top: 16px;
}
/* Responsive */
@media (width <= 920px) {
main {
padding: 24px 32px 32px;
}
}
@media (width <= 768px) {
main {
padding: 16px 16px 32px;
}
h1 {
font-size: 26px;
line-height: 39px;
}
h2 {
font-size: 22px;
line-height: 33px;
}
h3 {
font-size: 18px;
line-height: 27px;
}
video.bn-visual-media {
max-width: 100%;
}
}
/* Print styles */
@media print {
body {
background: white;
}
main {
max-width: 100%;
padding: 0;
}
button,
select,
.bn-add-file-button {
display: none;
}
a {
color: #000;
text-decoration: underline;
}
.bn-block-content[data-content-type='codeBlock'] {
background-color: #f5f5f5;
border: 1px solid #ddd;
}
.bn-block-content[data-content-type='codeBlock'] code {
color: #000;
}
}
/* Accessibility */
*:focus-visible {
outline: 2px solid #06c;
outline-offset: 2px;
}
/* Hidden elements for export */
[contenteditable='false'] {
user-select: none;
}
@@ -13,22 +13,30 @@ import {
import { DocumentProps, pdf } from '@react-pdf/renderer';
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
import i18next from 'i18next';
import JSZip from 'jszip';
import { cloneElement, isValidElement, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, ButtonCloseModal, Text } from '@/components';
import { useMediaUrl } from '@/core';
import { useEditorStore } from '@/docs/doc-editor';
import { Doc, useTrans } from '@/docs/doc-management';
import { fallbackLng } from '@/i18n/config';
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { docxDocsSchemaMappings } from '../mappingDocx';
import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
import { downloadFile } from '../utils';
import {
addMediaFilesToZip,
downloadFile,
generateHtmlDocument,
} from '../utils';
enum DocDownloadFormat {
HTML = 'html',
PDF = 'pdf',
DOCX = 'docx',
ODT = 'odt',
@@ -52,6 +60,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
DocDownloadFormat.PDF,
);
const { untitledDocument } = useTrans();
const mediaUrl = useMediaUrl();
const templateOptions = useMemo(() => {
const templateOptions = (templates?.pages || [])
@@ -142,13 +151,47 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
});
blobExport = await exporter.toODTDocument(exportDocument);
} else if (format === DocDownloadFormat.HTML) {
// Use BlockNote "full HTML" export so that we stay closer to the editor rendering.
const fullHtml = await editor.blocksToFullHTML();
// Parse HTML and fetch media so that we can package a fully offline HTML document in a ZIP.
const domParser = new DOMParser();
const parsedDocument = domParser.parseFromString(fullHtml, 'text/html');
const zip = new JSZip();
await addMediaFilesToZip(parsedDocument, zip, mediaUrl);
const lang = i18next.language || fallbackLng;
const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;
const htmlContent = generateHtmlDocument(
documentTitle,
editorHtmlWithLocalMedia,
lang,
);
zip.file('index.html', htmlContent);
// CSS Styles
const cssResponse = await fetch(
new URL('../assets/export-html-styles.txt', import.meta.url).toString(),
);
const cssContent = await cssResponse.text();
zip.file('styles.css', cssContent);
blobExport = await zip.generateAsync({ type: 'blob' });
} else {
toast(t('The export failed'), VariantType.ERROR);
setIsExporting(false);
return;
}
downloadFile(blobExport, `${filename}.${format}`);
const downloadExtension =
format === DocDownloadFormat.HTML ? 'zip' : format;
downloadFile(blobExport, `${filename}.${downloadExtension}`);
toast(
t('Your {{format}} was downloaded succesfully', {
@@ -225,18 +268,10 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
className="--docs--modal-export-content"
>
<Text $variation="secondary" $size="sm" as="p">
{t('Download your document in a .docx, .odt or .pdf format.')}
{t(
'Download your document in a .docx, .odt, .pdf or .html(zip) format.',
)}
</Text>
<Select
clearable={false}
fullWidth
label={t('Template')}
options={templateOptions}
value={templateSelected}
onChange={(options) =>
setTemplateSelected(options.target.value as string)
}
/>
<Select
clearable={false}
fullWidth
@@ -245,12 +280,24 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
{ label: t('ODT'), value: DocDownloadFormat.ODT },
{ label: t('PDF'), value: DocDownloadFormat.PDF },
{ label: t('HTML'), value: DocDownloadFormat.HTML },
]}
value={format}
onChange={(options) =>
setFormat(options.target.value as DocDownloadFormat)
}
/>
<Select
clearable={false}
fullWidth
label={t('Template')}
options={templateOptions}
value={templateSelected}
disabled={format === DocDownloadFormat.HTML}
onChange={(options) =>
setTemplateSelected(options.target.value as string)
}
/>
{isExporting && (
<Box
@@ -5,8 +5,11 @@ import {
} from '@blocknote/core';
import { Canvg } from 'canvg';
import { IParagraphOptions, ShadingType } from 'docx';
import JSZip from 'jszip';
import React from 'react';
import { exportResolveFileUrl } from './api';
export function downloadFile(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -179,3 +182,172 @@ export function odtRegisterParagraphStyleForBlock(
return styleName;
}
// Escape user-provided text before injecting it into the exported HTML document.
export const escapeHtml = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
interface MediaFilenameParams {
src: string;
index: number;
blob: Blob;
}
/**
* Derives a stable, readable filename for media exported in the HTML ZIP.
*
* Rules:
* - Default base name is "media-{index+1}".
* - For non data: URLs, we reuse the last path segment when possible (e.g. 1-photo.png).
* - If the base name has no extension, we try to infer one from the blob MIME type.
*/
export const deriveMediaFilename = ({
src,
index,
blob,
}: MediaFilenameParams): string => {
// Default base name
let baseName = `media-${index + 1}`;
// Try to reuse the last path segment for non data URLs.
if (!src.startsWith('data:')) {
try {
const url = new URL(src, window.location.origin);
const lastSegment = url.pathname.split('/').pop();
if (lastSegment) {
baseName = `${index + 1}-${lastSegment}`;
}
} catch {
// Ignore invalid URLs, keep default baseName.
}
}
let filename = baseName;
// Ensure the filename has an extension consistent with the blob MIME type.
const mimeType = blob.type;
if (mimeType && !baseName.includes('.')) {
const slashIndex = mimeType.indexOf('/');
const rawSubtype =
slashIndex !== -1 && slashIndex < mimeType.length - 1
? mimeType.slice(slashIndex + 1)
: '';
let extension = '';
const subtype = rawSubtype.toLowerCase();
if (subtype.includes('svg')) {
extension = 'svg';
} else if (subtype.includes('jpeg') || subtype.includes('pjpeg')) {
extension = 'jpg';
} else if (subtype.includes('png')) {
extension = 'png';
} else if (subtype.includes('gif')) {
extension = 'gif';
} else if (subtype.includes('webp')) {
extension = 'webp';
} else if (subtype.includes('pdf')) {
extension = 'pdf';
} else if (subtype) {
extension = subtype.split('+')[0];
}
if (extension) {
filename = `${baseName}.${extension}`;
}
}
return filename;
};
/**
* Generates a complete HTML document structure for export.
*
* @param documentTitle - The title of the document (will be escaped)
* @param editorHtmlWithLocalMedia - The HTML content from the editor
* @param lang - The language code for the document (e.g., 'fr', 'en')
* @returns A complete HTML5 document string
*/
export const generateHtmlDocument = (
documentTitle: string,
editorHtmlWithLocalMedia: string,
lang: string,
): string => {
return `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(documentTitle)}</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main role="main">
${editorHtmlWithLocalMedia}
</main>
</body>
</html>`;
};
export const addMediaFilesToZip = async (
parsedDocument: Document,
zip: JSZip,
mediaUrl: string,
) => {
const mediaFiles: { filename: string; blob: Blob }[] = [];
const mediaElements = Array.from(
parsedDocument.querySelectorAll<
HTMLImageElement | HTMLVideoElement | HTMLAudioElement | HTMLSourceElement
>('img, video, audio, source'),
);
await Promise.all(
mediaElements.map(async (element, index) => {
const src = element.getAttribute('src');
if (!src) {
return;
}
// data: URLs are already embedded and work offline; no need to create separate files.
if (src.startsWith('data:')) {
return;
}
// Only download same-origin resources (internal media like /media/...).
// External URLs keep their original src and are not included in the ZIP
let url: URL | null = null;
try {
url = new URL(src, mediaUrl);
} catch {
url = null;
}
if (!url || url.origin !== mediaUrl) {
return;
}
const fetched = await exportResolveFileUrl(url.href);
if (!(fetched instanceof Blob)) {
return;
}
const filename = deriveMediaFilename({
src: url.href,
index,
blob: fetched,
});
element.setAttribute('src', filename);
mediaFiles.push({ filename, blob: fetched });
}),
);
mediaFiles.forEach(({ filename, blob }) => {
zip.file(filename, blob);
});
};
@@ -1,86 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { Fragment } from 'react';
import { beforeEach, describe, expect, vi } from 'vitest';
import { AbstractAnalytic, Analytics } from '@/libs';
import { AppWrapper } from '@/tests/utils';
import { DocToolBox } from '../components/DocToolBox';
let flag = true;
class TestAnalytic extends AbstractAnalytic {
public constructor() {
super();
}
public Provider() {
return <Fragment />;
}
public trackEvent() {}
public isFeatureFlagActivated(flagName: string): boolean {
if (flagName === 'CopyAsHTML') {
return flag;
}
return true;
}
}
vi.mock('next/router', async () => ({
...(await vi.importActual('next/router')),
useRouter: () => ({
push: vi.fn(),
}),
}));
const doc = {
nb_accesses: 1,
abilities: {
versions_list: true,
destroy: true,
},
};
beforeEach(() => {
Analytics.clearAnalytics();
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
});
describe('DocToolBox "Copy as HTML" option', () => {
test('renders "Copy as HTML" option when feature flag is enabled', async () => {
new TestAnalytic();
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
const optionsButton = await screen.findByLabelText(
'Open the document options',
);
await userEvent.click(optionsButton);
expect(await screen.findByText('Copy as HTML')).toBeInTheDocument();
});
test('does not render "Copy as HTML" option when feature flag is disabled', async () => {
flag = false;
new TestAnalytic();
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
const optionsButton = screen.getByLabelText('Open the document options');
await userEvent.click(optionsButton);
expect(screen.queryByText('Copy as HTML')).not.toBeInTheDocument();
});
test('render "Copy as HTML" option when we did not add analytics', async () => {
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
const optionsButton = screen.getByLabelText('Open the document options');
await userEvent.click(optionsButton);
expect(screen.getByText('Copy as HTML')).toBeInTheDocument();
});
});
@@ -42,10 +42,11 @@ describe('DocToolBox - Licence', () => {
});
const optionsButton = await screen.findByLabelText('Export the document');
await userEvent.click(optionsButton);
// Wait for the export modal to be visible, then assert on its content text.
await screen.findByTestId('modal-export-title');
expect(
await screen.findByText(
'Download your document in a .docx, .odt or .pdf format.',
),
screen.getByText(/Download your document in a .docx, .odt.*format\./i),
).toBeInTheDocument();
}, 10000);
@@ -3,15 +3,14 @@ import React from 'react';
import { Text } from '@/components';
import { useConfig } from '@/core';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
import {
Doc,
Role,
useIsCollaborativeEditable,
useTrans,
} from '../../doc-management';
} from '@/docs/doc-management';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
interface DocHeaderInfoProps {
doc: Doc;
@@ -33,7 +33,6 @@ import {
KEY_LIST_DOC_VERSIONS,
ModalSelectVersion,
} from '@/docs/doc-versioning';
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
@@ -67,7 +66,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
void router.push(`/docs/${data.id}`);
},
});
const { isFeatureFlagActivated } = useAnalytics();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
});
@@ -155,14 +153,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
callback: () => {
void copyCurrentEditorToClipboard('markdown');
},
},
{
label: t('Copy as {{format}}', { format: 'HTML' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('html');
},
show: isFeatureFlagActivated('CopyAsHTML'),
showSeparator: true,
},
{
@@ -4,12 +4,13 @@ import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, useDocUtils, useTrans } from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import ChildDocument from '../assets/child-document.svg';
import PinnedDocumentIcon from '../assets/pinned-document.svg';
import SimpleFileIcon from '../assets/simple-document.svg';
import { useDocUtils, useTrans } from '../hooks';
import { Doc } from '../types';
const ItemTextCss = css`
overflow: hidden;
@@ -1,15 +1,12 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { useCallback } from 'react';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
getEmojiAndTitle,
useUpdateDoc,
} from '@/docs/doc-management';
import { useBroadcastStore } from '@/stores';
import { KEY_DOC, KEY_LIST_DOC, useUpdateDoc } from '../api';
import { Doc } from '../types';
import { getEmojiAndTitle } from '../utils';
interface UseDocUpdateOptions {
onSuccess?: (updatedDoc: Doc) => void;
onError?: (error: Error) => void;
@@ -52,13 +52,29 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
}
},
onAuthenticationFailed() {
set({ isReady: true });
set({ isReady: true, isConnected: false });
},
onAuthenticated() {
set({ isReady: true, isConnected: true });
},
onStatus: ({ status }) => {
set((state) => {
const nextConnected = status === WebSocketStatus.Connected;
/**
* status === WebSocketStatus.Connected does not mean we are totally connected
* because authentication can still be in progress and failed
* So we only update isConnected when we loose the connection
*/
const connected =
status !== WebSocketStatus.Connected
? {
isConnected: false,
}
: undefined;
return {
isConnected: nextConnected,
...connected,
isReady: state.isReady || status === WebSocketStatus.Disconnected,
hasLostConnection:
state.isConnected && !nextConnected
@@ -5,7 +5,7 @@ import {
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -16,12 +16,10 @@ import {
DocIcon,
getEmojiAndTitle,
useTrans,
} from '@/features/docs/doc-management';
} from '@/docs/doc-management';
import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
import { useKeyboardActivation } from '../hooks/useKeyboardActivation';
import SubPageIcon from './../assets/sub-page-logo.svg';
import { DocTreeItemActions } from './DocTreeItemActions';
@@ -46,7 +44,6 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const [menuOpen, setMenuOpen] = useState(false);
const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id;
const isActive = node.isFocused || menuOpen || isSelectedNow;
const router = useRouter();
const { togglePanel } = useLeftPanelStore();
@@ -92,20 +89,34 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
}
};
useKeyboardActivation(
['Enter'],
isActive && !menuOpen,
handleActivate,
true,
'.c__tree-view',
);
const docTitle = doc.title || untitledDocument;
const hasChildren = (doc.children?.length || 0) > 0;
const isExpanded = node.isOpen;
const isSelected = isSelectedNow;
const ariaLabel = docTitle;
const isDisabled = !!doc.deleted_at;
const actionsRef = useRef<HTMLDivElement>(null);
const buttonOptionRef = useRef<HTMLDivElement | null>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
// F2: focus first action button
const shouldOpenActions = !menuOpen && node.isFocused;
if (e.key === 'F2' && shouldOpenActions) {
buttonOptionRef.current?.focus();
e.stopPropagation();
return;
}
};
const handleActionsOpenChange = (isOpen: boolean) => {
setMenuOpen(isOpen);
// When the menu closes (via Escape or activating an option),
// return focus to the tree item so focus is not lost.
if (!isOpen) {
node.focus();
}
};
return (
<Box
@@ -117,6 +128,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
aria-selected={isSelected}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-disabled={isDisabled}
onKeyDown={handleKeyDown}
$css={css`
background-color: var(--c--globals--colors--gray-000);
.light-doc-item-actions {
@@ -127,6 +139,14 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
outline: none !important;
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-500) !important;
border-radius: var(--c--globals--spacings--st);
.light-doc-item-actions {
display: flex;
}
}
/* Remove visual focus from the tree item when focus is on actions or emoji button */
&:has(.light-doc-item-actions *:focus, .--docs--doc-icon:focus-visible)
.c__tree-view--node.isFocused {
box-shadow: none !important;
}
&:hover {
background-color: var(
@@ -137,6 +157,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
display: flex;
}
}
&:focus-within {
.light-doc-item-actions {
display: flex;
}
}
.row.preview & {
background-color: inherit;
}
@@ -147,12 +172,44 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
emoji={emoji}
withEmojiPicker={doc.abilities.partial_update}
defaultIcon={
<SubPageIcon color="var(--c--contextuals--content--semantic--info--tertiary)" />
<SubPageIcon
color="var(--c--contextuals--content--semantic--info--tertiary)"
style={{ flexShrink: 0 }}
/>
}
$size="sm"
docId={doc.id}
title={doc.title}
buttonProps={{
$css: css`
&:focus-visible {
outline: 2px solid var(--c--globals--colors--brand-500);
outline-offset: var(--c--globals--spacings--4xs);
}
`,
}}
/>
<Box
$direction="row"
$align="center"
className="light-doc-item-actions actions"
role="toolbar"
aria-label={`${t('Actions for {{title}}', { title: docTitle })}`}
$css={css`
margin-left: auto;
order: 2;
`}
>
<DocTreeItemActions
doc={doc}
isOpen={menuOpen}
onOpenChange={handleActionsOpenChange}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
actionsRef={actionsRef}
buttonOptionRef={buttonOptionRef}
/>
</Box>
<BoxButton
onClick={(e) => {
e.stopPropagation();
@@ -196,21 +253,6 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
)}
</Box>
</BoxButton>
<Box
$direction="row"
$align="center"
className="light-doc-item-actions"
role="toolbar"
aria-label={`${t('Actions for {{title}}', { title: docTitle })}`}
>
<DocTreeItemActions
doc={doc}
isOpen={menuOpen}
onOpenChange={setMenuOpen}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
</TreeViewItem>
</Box>
);
@@ -6,7 +6,7 @@ import {
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -35,6 +35,9 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
const rootIsSelected =
!!treeContext?.root?.id &&
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
const rootItemRef = useRef<HTMLDivElement>(null);
const rootActionsRef = useRef<HTMLDivElement>(null);
const rootButtonOptionRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation();
@@ -60,13 +63,11 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
});
treeContext?.treeData.handleMove(result);
};
/**
* This function resets the tree states.
*/
const resetStateTree = useCallback(() => {
if (!treeContext?.root?.id) {
return;
}
treeContext?.setRoot(null);
setInitialOpenState(undefined);
}, [treeContext]);
@@ -88,18 +89,45 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
selectRoot();
}, [selectRoot]);
// activate root document with enter or space
// Handle keyboard navigation for root item
const handleRootKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// F2: focus first action button
if (e.key === 'F2' && !rootActionsOpen) {
e.preventDefault();
rootButtonOptionRef.current?.focus();
return;
}
// Ignore if focus is in actions
const target = e.target as HTMLElement | null;
if (target?.closest('.doc-tree-root-item-actions')) {
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectRoot();
navigateToRoot();
}
},
[selectRoot, navigateToRoot],
[selectRoot, navigateToRoot, rootActionsOpen],
);
// Handle menu open/close for root item - mirrors DocSubPageItem behavior
const handleRootActionsOpenChange = useCallback((isOpen: boolean) => {
setRootActionsOpen(isOpen);
// When the menu closes, return focus to the root tree item
// (same behavior as DocSubPageItem for consistency)
// Use requestAnimationFrame for smoother focus transition without flickering
if (!isOpen) {
requestAnimationFrame(() => {
rootItemRef.current?.focus();
});
}
}, []);
/**
* This effect is used to reset the tree when a new document
* that is not part of the current tree is loaded.
@@ -180,6 +208,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
$height="100%"
role="tree"
aria-label={t('Document tree')}
aria-describedby="doc-tree-keyboard-instructions"
$css={css`
/* Remove outline from TreeViewItem wrapper elements */
.c__tree-view--row {
@@ -199,6 +228,12 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}
`}
>
{/* Keyboard instructions for screen readers */}
<Box id="doc-tree-keyboard-instructions" className="sr-only">
{t(
'Use arrow keys to navigate between documents. Press Enter to open a document. Press F2 to focus the emoji button when available, then press F2 again to access document actions.',
)}
</Box>
<Box
$padding={{ horizontal: 'sm', top: 'sm', bottom: '4px' }}
$css={css`
@@ -206,6 +241,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
`}
>
<Box
ref={rootItemRef}
data-testid="doc-tree-root-item"
role="treeitem"
aria-label={`${t('Root document {{title}}', { title: treeContext.root?.title || t('Untitled document') })}`}
@@ -234,7 +270,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}
.doc-tree-root-item-actions {
display: ${rootActionsOpen ? 'flex' : 'none'};
display: flex;
opacity: ${rootActionsOpen ? '1' : '0'};
&:has(.isOpen) {
@@ -242,12 +278,17 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}
}
&:hover,
&:focus-visible {
&:focus-visible,
&:focus-within {
.doc-tree-root-item-actions {
display: flex;
opacity: 1;
}
}
/* Remove visual focus from the root item when focus is on the actions */
&:has(.doc-tree-root-item-actions *:focus) {
box-shadow: none !important;
}
`}
>
<StyledLink
@@ -281,7 +322,9 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}}
isOpen={rootActionsOpen}
isRoot={true}
onOpenChange={setRootActionsOpen}
onOpenChange={handleRootActionsOpenChange}
actionsRef={rootActionsRef}
buttonOptionRef={rootButtonOptionRef}
/>
</Box>
</StyledLink>
@@ -1,10 +1,12 @@
import {
DropdownMenu,
DropdownMenuOption,
useArrowRoving,
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import { useModal } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -17,8 +19,8 @@ import {
useCopyDocLink,
useCreateChildDoc,
useDocTitleUpdate,
useDuplicateDoc,
} from '@/docs/doc-management';
import { useDuplicateDoc } from '@/docs/doc-management/api';
import { useDetachDoc } from '../api/useDetach';
import MoveDocIcon from '../assets/doc-extract-bold.svg';
@@ -30,6 +32,8 @@ type DocTreeItemActionsProps = {
onCreateSuccess?: (newDoc: Doc) => void;
onOpenChange?: (isOpen: boolean) => void;
parentId?: string | null;
actionsRef?: React.RefObject<HTMLDivElement | null>;
buttonOptionRef?: React.RefObject<HTMLDivElement | null>;
};
export const DocTreeItemActions = ({
@@ -39,7 +43,13 @@ export const DocTreeItemActions = ({
onCreateSuccess,
onOpenChange,
parentId,
actionsRef,
buttonOptionRef,
}: DocTreeItemActionsProps) => {
const internalActionsRef = useRef<HTMLDivElement | null>(null);
const targetActionsRef = actionsRef ?? internalActionsRef;
const internalButtonRef = useRef<HTMLDivElement | null>(null);
const targetButtonRef = buttonOptionRef ?? internalButtonRef;
const router = useRouter();
const { t } = useTranslation();
const deleteModal = useModal();
@@ -47,6 +57,9 @@ export const DocTreeItemActions = ({
const { mutate: detachDoc } = useDetachDoc();
const treeContext = useTreeContext<Doc | null>();
// Keyboard navigation inside the actions toolbar (ArrowLeft / ArrowRight).
useArrowRoving(targetActionsRef.current);
const { mutate: duplicateDoc } = useDuplicateDoc({
onSuccess: (duplicatedDoc) => {
// Reset the tree context root will reset the full tree view.
@@ -160,30 +173,45 @@ export const DocTreeItemActions = ({
};
return (
<Box className="doc-tree-root-item-actions">
<Box className="doc-tree-root-item-actions actions">
<Box
ref={targetActionsRef}
$direction="row"
$align="center"
className="--docs--doc-tree-item-actions"
$gap="4px"
tabIndex={-1}
>
<DropdownMenu
options={options}
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<Icon
<BoxButton
ref={targetButtonRef}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onOpenChange?.(!isOpen);
}}
iconName="more_horiz"
variant="filled"
aria-label={t('More options')}
tabIndex={-1}
$theme="brand"
$variation="secondary"
aria-label={t('More options')}
/>
$css={css`
&:focus-visible {
outline-offset: -2px;
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Icon
iconName="more_horiz"
variant="filled"
$theme="brand"
$variation="secondary"
/>
</BoxButton>
</DropdownMenu>
{doc.abilities.children_create && (
<BoxButton
@@ -199,6 +227,13 @@ export const DocTreeItemActions = ({
$variation="secondary"
aria-label={t('Add a sub page')}
data-testid="doc-tree-item-actions-add-child"
tabIndex={-1}
$css={css`
&:focus-visible {
outline-offset: -2px;
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Icon variant="filled" $color="inherit" iconName="add_box" />
</BoxButton>
@@ -1,29 +0,0 @@
import { useEffect } from 'react';
export const useKeyboardActivation = (
keys: string[],
enabled: boolean,
action: () => void,
capture = false,
selector: string,
) => {
useEffect(() => {
if (!enabled) {
return;
}
const onKeyDown = (e: KeyboardEvent) => {
if (keys.includes(e.key)) {
e.preventDefault();
action();
}
};
const treeEl = document.querySelector<HTMLElement>(selector);
if (!treeEl) {
return;
}
treeEl.addEventListener('keydown', onKeyDown, capture);
return () => {
treeEl.removeEventListener('keydown', onKeyDown, capture);
};
}, [keys, enabled, action, capture, selector]);
};
@@ -77,7 +77,6 @@ export function useDocVersionsInfiniteQuery(
getNextPageParam(lastPage) {
return lastPage.next_version_id_marker || undefined;
},
...queryConfig,
});
}
@@ -32,13 +32,18 @@ export const DocVersionEditor = ({
const { replace } = useRouter();
const [initialContent, setInitialContent] = useState<Y.XmlFragment>();
// Reset initialContent when versionId changes to avoid conflicts between versions
useEffect(() => {
if (!version?.content) {
setInitialContent(undefined);
}, [versionId]);
useEffect(() => {
if (!version?.content || isLoading || initialContent) {
return;
}
setInitialContent(base64ToBlocknoteXmlFragment(version.content));
}, [version?.content]);
}, [versionId, version?.content, isLoading, initialContent]);
if (isError && error) {
if (error.status === 404) {
@@ -4,10 +4,10 @@ import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
import { useModal } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { AlertModal, Card, Text } from '@/components';
import { Doc, KEY_LIST_DOC } from '@/docs/doc-management';
import { Doc, KEY_LIST_DOC, useTrans } from '@/docs/doc-management';
import {
getDocAccesses,
getDocInvitations,
@@ -60,6 +60,7 @@ export const DraggableDocGridContentList = ({
const { mutate: handleDeleteInvitation } = useDeleteDocInvitation();
const { mutate: handleDeleteAccess } = useDeleteDocAccess();
const onDragData = useRef<DocDragEndData | null>(null);
const { untitledDocument } = useTrans();
const handleMoveDoc = async () => {
if (!onDragData.current) {
@@ -144,8 +145,8 @@ export const DraggableDocGridContentList = ({
return t('You must be at least the administrator of the target document');
}
return selectedDoc?.title || t('Unnamed document');
}, [canDrag, canDrop, selectedDoc, t]);
return selectedDoc?.title || untitledDocument;
}, [canDrag, canDrop, selectedDoc?.title, t, untitledDocument]);
const cannotMoveDoc =
!canDrag || (canDrop !== undefined && !canDrop) || isError;
@@ -193,17 +194,16 @@ export const DraggableDocGridContentList = ({
{...modalConfirmation}
title={t('Move document')}
description={
<span
dangerouslySetInnerHTML={{
__html: t(
'By moving this document to <strong>{{targetDocumentTitle}}</strong>, it will lose its current access rights and inherit the permissions of that document. <strong>This access change cannot be undone.</strong>',
{
targetDocumentTitle:
onDragData.current?.target.title ?? t('Unnamed document'),
},
),
}}
/>
<Text $display="inline">
<Trans
i18nKey="By moving this document to <strong>{{targetDocumentTitle}}</strong>, it will lose its current access rights and inherit the permissions of that document. <strong>This access change cannot be undone.</strong>"
values={{
targetDocumentTitle:
onDragData.current?.target.title ?? untitledDocument,
}}
components={{ strong: <strong /> }}
/>
</Text>
}
confirmLabel={t('Move')}
onConfirm={() => {
@@ -12,12 +12,16 @@ export const ButtonTogglePanel = () => {
<Button
size="medium"
onClick={() => togglePanel()}
aria-label={t('Open the header menu')}
aria-label={t(
isPanelOpen ? 'Close the header menu' : 'Open the header menu',
)}
aria-expanded={isPanelOpen}
variant="tertiary"
icon={
<Icon $withThemeInherited iconName={isPanelOpen ? 'close' : 'menu'} />
}
className="--docs--button-toggle-panel"
data-testid="header-menu-toggle"
/>
);
};
@@ -29,6 +29,7 @@ export const Header = () => {
<SkipToContent />
<Box
as="header"
className="--docs--header"
role="banner"
$css={css`
position: fixed;
@@ -45,7 +46,6 @@ export const Header = () => {
border-bottom: 1px solid
var(--c--contextuals--border--surface--primary);
`}
className="--docs--header"
>
{!isDesktop && <ButtonTogglePanel />}
<StyledLink
@@ -88,7 +88,12 @@ export const Header = () => {
<LaGaufre />
</Box>
) : (
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
<Box
className="--docs--header-block-right"
$align="center"
$gap={spacingsTokens['sm']}
$direction="row"
>
<ButtonLogin />
<LanguagePicker />
<LaGaufre />
@@ -2,6 +2,7 @@ import { useRouter } from 'next/router';
import { css } from 'styled-components';
import { Box, SeparatedSection } from '@/components';
import { useDocStore } from '@/docs/doc-management';
import { LeftPanelTargetFilters } from './LefPanelTargetFilters';
import { LeftPanelDocContent } from './LeftPanelDocContent';
@@ -11,6 +12,7 @@ export const LeftPanelContent = () => {
const router = useRouter();
const isHome = router.pathname === '/';
const isDoc = router.pathname === '/docs/[id]';
const { currentDoc } = useDocStore();
return (
<>
@@ -36,7 +38,7 @@ export const LeftPanelContent = () => {
</Box>
</>
)}
{isDoc && <LeftPanelDocContent />}
{isDoc && currentDoc && <LeftPanelDocContent doc={currentDoc} />}
</>
);
};
@@ -1,15 +1,13 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Box } from '@/components';
import { Doc, useDocStore } from '@/docs/doc-management';
import { Doc } from '@/docs/doc-management';
import { DocTree } from '@/docs/doc-tree/';
export const LeftPanelDocContent = () => {
const { currentDoc } = useDocStore();
export const LeftPanelDocContent = ({ doc }: { doc: Doc }) => {
const tree = useTreeContext<Doc>();
if (!currentDoc || !tree) {
if (!tree) {
return null;
}
@@ -20,7 +18,7 @@ export const LeftPanelDocContent = () => {
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
className="--docs--left-panel-doc-content"
>
<DocTree currentDoc={currentDoc} />
<DocTree currentDoc={doc} />
</Box>
);
};
@@ -12,7 +12,7 @@ import { useLeftPanelStore } from '../stores';
export const LeftPanelHeaderButton = () => {
const router = useRouter();
const { t } = useTranslation();
const { togglePanel } = useLeftPanelStore();
const { closePanel } = useLeftPanelStore();
const { setIsSkeletonVisible } = useSkeletonStore();
const [isNavigating, setIsNavigating] = useState(false);
@@ -25,7 +25,7 @@ export const LeftPanelHeaderButton = () => {
.then(() => {
// The skeleton will be disabled by the [id] page once the data is loaded
setIsNavigating(false);
togglePanel();
closePanel();
})
.catch(() => {
// In case of navigation error, disable the skeleton
@@ -6,6 +6,8 @@ import {
PanelResizeHandle,
} from 'react-resizable-panels';
import { useResponsiveStore } from '@/stores';
// Convert a target pixel width to a percentage of the current viewport width.
const pxToPercent = (px: number) => {
return (px / window.innerWidth) * 100;
@@ -24,18 +26,27 @@ export const ResizableLeftPanel = ({
minPanelSizePx = 300,
maxPanelSizePx = 450,
}: ResizableLeftPanelProps) => {
const { isDesktop } = useResponsiveStore();
const ref = useRef<ImperativePanelHandle>(null);
const savedWidthPxRef = useRef<number>(minPanelSizePx);
const [panelSizePercent, setPanelSizePercent] = useState(() =>
pxToPercent(minPanelSizePx),
);
const minPanelSizePercent = pxToPercent(minPanelSizePx);
const maxPanelSizePercent = Math.min(pxToPercent(maxPanelSizePx), 40);
const [panelSizePercent, setPanelSizePercent] = useState(() => {
const initialSize = pxToPercent(minPanelSizePx);
return Math.max(
minPanelSizePercent,
Math.min(initialSize, maxPanelSizePercent),
);
});
// Keep pixel width constant on window resize
useEffect(() => {
if (!isDesktop) {
return;
}
const handleResize = () => {
const newPercent = pxToPercent(savedWidthPxRef.current);
setPanelSizePercent(newPercent);
@@ -48,7 +59,7 @@ export const ResizableLeftPanel = ({
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
}, [isDesktop]);
const handleResize = (sizePercent: number) => {
const widthPx = (sizePercent / 100) * window.innerWidth;
@@ -57,29 +68,29 @@ export const ResizableLeftPanel = ({
};
return (
<>
<PanelGroup direction="horizontal">
<Panel
ref={ref}
order={0}
defaultSize={panelSizePercent}
minSize={minPanelSizePercent}
maxSize={maxPanelSizePercent}
onResize={handleResize}
>
{leftPanel}
</Panel>
<PanelResizeHandle
style={{
borderRightWidth: '1px',
borderRightStyle: 'solid',
borderRightColor: 'var(--c--contextuals--border--surface--primary)',
width: '1px',
cursor: 'col-resize',
}}
/>
<Panel order={1}>{children}</Panel>
</PanelGroup>
</>
<PanelGroup direction="horizontal">
<Panel
ref={ref}
order={0}
defaultSize={isDesktop ? panelSizePercent : 0}
minSize={isDesktop ? minPanelSizePercent : 0}
maxSize={isDesktop ? maxPanelSizePercent : 0}
onResize={handleResize}
>
{leftPanel}
</Panel>
<PanelResizeHandle
style={{
borderRightWidth: '1px',
borderRightStyle: 'solid',
borderRightColor: 'var(--c--contextuals--border--surface--primary)',
width: '1px',
cursor: 'col-resize',
}}
disabled={!isDesktop}
/>
<Panel order={1}>{children}</Panel>
</PanelGroup>
);
};
@@ -3,6 +3,7 @@ import { create } from 'zustand';
interface LeftPanelState {
isPanelOpen: boolean;
togglePanel: (value?: boolean) => void;
closePanel: () => void;
}
export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
@@ -15,4 +16,7 @@ export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
set({ isPanelOpen: sanitizedValue });
},
closePanel: () => {
set({ isPanelOpen: false });
},
}));
@@ -151,7 +151,6 @@
"Open document: {{title}}": "Digeriñ ar restr: {{title}}",
"Open root document": "Digeriñ ar restr gwrizienn",
"Open the document options": "Digeriñ dibarzhioù ar restr",
"Open the header menu": "Digeriñ lañser an talbennoù",
"Open the menu of actions for the document: {{title}}": "Digeriñ lañser oberezhioù evit ar restr: {{title}}",
"Organize": "Aozañ",
"Others are editing this document. Unfortunately your network blocks WebSockets, the technology enabling real-time co-editing.": "Tud all a zo oc'h aozañ ar restr-mañ. Siwazh e stank ho kenrouedad ar WebSockets, an deknologiezh a ro an tu da skrivañ a-stroll war an dro.",
@@ -215,7 +214,6 @@
"Too many requests. Please wait 60 seconds.": "Re a c'houlennoù. Gortozit 60 eilenn mar plij.",
"Type a name or email": "Skrivit hoc'h anv pe ur postel",
"Type the name of a document": "Skrivit anv ur restr",
"Unnamed document": "Teul hep titl",
"Unpin": "Dispilhennañ",
"Untitled document": "Restr hep titl",
"Updated": "Hizivaet",
@@ -333,7 +331,6 @@
"Docx": "Docx",
"Download": "Herunterladen",
"Download anyway": "Trotzdem herunterladen",
"Download your document in a .docx, .odt or .pdf format.": "Dokument als DOCX-, ODT- oder PDF-Datei exportieren.",
"Duplicate": "Duplizieren",
"Edit document emoji": "Dokumenten-Symbol bearbeiten",
"Editing": "Bearbeiten",
@@ -412,7 +409,6 @@
"Open document: {{title}}": "Öffne Dokument: {{title}}",
"Open root document": "Öffne Wurzel-Dokument",
"Open the document options": "Öffnen Sie die Dokumentoptionen",
"Open the header menu": "Öffne das Kopfzeilen-Menü",
"Open the menu of actions for the document: {{title}}": "Öffne das Aktionsmenü für das Dokument: {{title}}",
"Open the sharing settings for the document": "Öffne die Freigabeeinstellungen für das Dokument",
"Organize": "Organisieren",
@@ -486,7 +482,6 @@
"Trashbin": "Papierkorb",
"Type a name or email": "Geben Sie einen Namen oder eine E-Mail-Adresse ein",
"Type the name of a document": "Geben Sie den Namen eines Dokuments ein",
"Unnamed document": "Unbenanntes Dokument",
"Unpin": "Lösen",
"Untitled document": "Unbenanntes Dokument",
"Updated": "Aktualisiert",
@@ -632,7 +627,6 @@
"Only invited people can access": "Solo las personas invitadas pueden acceder",
"Open Source": "Código abierto",
"Open the document options": "Abrir las opciones del documento",
"Open the header menu": "Abrir el menú de encabezado",
"Organize": "Organiza",
"Owner": "Propietario",
"PDF": "PDF",
@@ -721,6 +715,7 @@
"All docs": "Tous les documents",
"An error occurred while restoring the document: {{error}}": "Une erreur est survenue lors de la restauration du document : {{error}}",
"An uncompromising writing experience.": "Une expérience d'écriture sans compromis.",
"An unexpected error occurred.": "Une erreur inattendue s’est produite.",
"Analyzing file...": "Analyse du fichier...",
"Anonymous": "Anonyme",
"Anyone with the link can edit the document": "N'importe qui avec le lien peut éditer le document",
@@ -795,7 +790,7 @@
"Docx": "Docx",
"Download": "Télécharger",
"Download anyway": "Télécharger malgré tout",
"Download your document in a .docx, .odt or .pdf format.": "Téléchargez votre document au format .docx, .odt ou .pdf.",
"Download your document in a .docx, .odt, .pdf or .html(zip) format.": "Téléchargez votre document au format .docx, .odt, .pdf ou .html(zip).",
"Drag and drop status": "État du glisser-déposer",
"Duplicate": "Dupliquer",
"Edit document emoji": "Modifier l'émoticône du document",
@@ -819,7 +814,9 @@
"Failed to duplicate the document...": "Échec de la duplication du document...",
"Flexible export.": "Un export flexible.",
"Format": "Format",
"Go to content": "Voir le contenu",
"Govs ❤️ Open Source.": "Gouvernements ❤️ Open Source.",
"HTML": "HTML",
"History": "Historique",
"Home": "Accueil",
"I understand": "J’ai compris",
@@ -880,7 +877,6 @@
"Open document: {{title}}": "Ouvrir le document : {{title}}",
"Open root document": "Ouvrir le document racine",
"Open the document options": "Ouvrir les options du document",
"Open the header menu": "Ouvrir le menu d'en-tête",
"Open the menu of actions for the document: {{title}}": "Ouvrir le menu des actions du document : {{title}}",
"Open the sharing settings for the document": "Ouvrir les paramètres de partage pour le document",
"Organize": "Organiser",
@@ -901,6 +897,7 @@
"Quick search input": "Saisie de recherche rapide",
"Reader": "Lecteur",
"Reading": "Lecture seule",
"Refresh page": "Actualiser la page",
"Remove access": "Supprimer l'accès",
"Remove emoji": "Supprimer les emojis",
"Remove {{name}} from the invite list": "Retirer {{name}} de la liste d'invitation",
@@ -943,6 +940,7 @@
"The document has been deleted.": "Le document a bien été supprimé.",
"The document has been restored.": "Le document a été restauré.",
"The document visibility has been updated.": "La visibilité du document a été mise à jour.",
"The document visibility restored.": "La visibilité du document a été restaurée.",
"The export failed": "L’exportation a échoué",
"The link sharing rules differ from the parent document": "Les règles de partage du lien diffèrent du document parent",
"This document and <strong>any sub-documents</strong> will be placed in the trashbin. You can restore it within {{days}} days.": "Ce document et <strong>tous les sous-documents</strong> seront placés dans la corbeille. Vous pouvez le restaurer dans {{days}} jours.",
@@ -955,12 +953,12 @@
"Trashbin": "Corbeille",
"Type a name or email": "Tapez un nom ou un email",
"Type the name of a document": "Tapez le nom d'un document",
"Unnamed document": "Document sans titre",
"Unpin": "Désépingler",
"Untitled document": "Document sans titre",
"Updated": "Mise à jour",
"Updated at": "Mise à jour le",
"Upload PDF": "Téléverser un PDF",
"Use arrow keys to navigate between documents. Press Enter to open a document. Press F2 to focus the emoji button when available, then press F2 again to access document actions.": "Utilisez les touches fléchées pour vous déplacer entre les documents. Appuyez sur Entrée pour ouvrir un document. Appuyez sur F2 pour accéder au bouton Emoji, le cas échéant, puis appuyez à nouveau sur F2 pour accéder aux actions disponibles pour le document.",
"Use as prompt": "Utiliser comme un prompt",
"Version history": "Historique des versions",
"Version restored successfully": "Version restaurée avec succès",
@@ -1078,7 +1076,6 @@
"Only invited people can access": "Solo le persone invitate possono accedere",
"Open Source": "Open Source",
"Open the document options": "Apri le opzioni del documento",
"Open the header menu": "Apri il menu dell'intestazione",
"Organize": "Organizza",
"Owner": "Proprietario",
"PDF": "PDF",
@@ -1156,6 +1153,7 @@
"All docs": "Alle documenten",
"An error occurred while restoring the document: {{error}}": "Er is een fout opgetreden tijdens het herstellen van het document: {{error}}",
"An uncompromising writing experience.": "Een compromisloze schrijfervaring.",
"An unexpected error occurred.": "Er deed zich een onverwachte fout voor.",
"Analyzing file...": "Bestand analyseren...",
"Anonymous": "Anoniem",
"Anyone with the link can edit the document": "Iedereen met de link kan het document bewerken",
@@ -1174,6 +1172,8 @@
"Cancel": "Annuleren",
"Cancel the deletion": "Verwijderen annuleren",
"Cancel the download": "Annuleer de download",
"Change role for {{email}}": "Rol wijzigen voor {{email}}",
"Change role for {{name}}": "Rol wijzigen voor {{name}}",
"Close the access request modal": "Sluit het toegangsverzoek venster",
"Close the delete modal": "Sluit het verwijder venster",
"Close the download modal": "Sluit het downloadvenster",
@@ -1222,12 +1222,14 @@
"Document sections": "Document secties",
"Document title": "Documenttitel",
"Document tree": "Boomstructuur document",
"Document viewer": "Document-viewer",
"Document visibility": "Document toegankelijkheid",
"Documents grid": "Documenten overzicht",
"Docx": "Docx",
"Download": "Download",
"Download anyway": "Download alsnog",
"Download your document in a .docx, .odt or .pdf format.": "Download uw document in een .docx, .odt of .pdf formaat.",
"Download your document in a .docx, .odt, .pdf or .html(zip) format.": "Download uw document in een .docx, .odt, .pdf of .html(zip) formaat.",
"Drag and drop status": "Drag & drop status",
"Duplicate": "Dupliceer",
"Edit document emoji": "Bewerk document emoji",
"Editing": "Bewerken",
@@ -1250,7 +1252,9 @@
"Failed to duplicate the document...": "Het dupliceren van het document is mislukt...",
"Flexible export.": "Flexibele export.",
"Format": "Formaat",
"Go to content": "Ga naar inhoud",
"Govs ❤️ Open Source.": "Govs ❤️ Open Source.",
"HTML": "HTML",
"History": "Geschiedenis",
"Home": "Startscherm",
"I understand": "Ik begrijp het",
@@ -1261,6 +1265,7 @@
"Image: {{title}}": "Afbeelding: {{title}}",
"Insufficient access rights to view the document.": "Onvoldoende toegangsrechten om het document te bekijken.",
"Invite": "Uitnodigen",
"Invite new members": "Nieuwe leden uitnodigen",
"Invite {{count}} members_many": "Nodig {{count}} leden uit",
"Invite {{count}} members_one": "Nodig {{count}} leden uit",
"Invite {{count}} members_other": "Nodig {{count}} leden uit",
@@ -1310,7 +1315,6 @@
"Open document: {{title}}": "Open document: {{title}}",
"Open root document": "Open hoofddocument",
"Open the document options": "Open document opties",
"Open the header menu": "Open het hoofdmenu",
"Open the menu of actions for the document: {{title}}": "Open het menu van acties voor het document: {{title}}",
"Open the sharing settings for the document": "Open de instellingen voor delen van het document",
"Organize": "Organiseer",
@@ -1331,6 +1335,7 @@
"Quick search input": "Snel zoeken",
"Reader": "Lezer",
"Reading": "Lezen",
"Refresh page": "Pagina vernieuwen",
"Remove access": "Toegang verwijderen",
"Remove emoji": "Emoji verwijderen",
"Remove {{name}} from the invite list": "Verwijder {{name}} uit de uitnodigingslijst",
@@ -1373,6 +1378,7 @@
"The document has been deleted.": "Het document is verwijderd",
"The document has been restored.": "Het document is hersteld.",
"The document visibility has been updated.": "De toegang van het document is bijgewerkt.",
"The document visibility restored.": "De zichtbaarheid van het document is hersteld.",
"The export failed": "Het exporteren is mislukt",
"The link sharing rules differ from the parent document": "De regels voor het delen via een link verschillen van het bovenliggende document",
"This document and <strong>any sub-documents</strong> will be placed in the trashbin. You can restore it within {{days}} days.": "Dit document en <strong>alle sub-documenten</strong> zullen in de prullenbak worden geplaatst. Je kunt het binnen {{days}} dagen herstellen.",
@@ -1385,12 +1391,12 @@
"Trashbin": "Prullenbak",
"Type a name or email": "Type een naam of email",
"Type the name of a document": "Vul de naam van een document in",
"Unnamed document": "Naamloos document",
"Unpin": "Losmaken",
"Untitled document": "Naamloos document",
"Updated": "Bijgewerkt",
"Updated at": "Bijgewerkt op",
"Upload PDF": "PDF uploaden",
"Use arrow keys to navigate between documents. Press Enter to open a document. Press F2 to focus the emoji button when available, then press F2 again to access document actions.": "Gebruik de pijltjestoetsen om tussen documenten te navigeren. Druk op Enter om een document te openen. Druk op F2 om de emoji knop te focussen wanneer beschikbaar en druk daarna opnieuw op F2 om documentacties te openen.",
"Use as prompt": "Gebruik als prompt",
"Version history": "Versiegeschiedenis",
"Version restored successfully": "Versie succesvol hersteld",
@@ -1415,7 +1421,10 @@
"home-content-open-source-part1": "Docs is gebouwd op <2>Django Rest Framework</2> en <6>Next.js</6>. We gebruiken ook <9>Yjs</9> en <13>BlockNote.js</13>, twee projecten die we met trots sponsoren.",
"home-content-open-source-part2": "U kunt Docs eenvoudig zelf hosten (zie onze <2>installatiedocumentatie</2>).<br/>Docs gebruikt een <7>licentie</7> (MIT) die is afgestemd op innovatie en ondernemingen.<br/>Bijdragen zijn welkom (zie onze routekaart <13>hier</13>).",
"home-content-open-source-part3": "Docs is het resultaat van een gezamenlijke inspanning geleid door de Franse 🇫🇷🥖 <1>(DINUM)</1> en Duitse 🇩🇪🥨 <5>(ZenDiS)</5> overheden.",
"pdf": "pdf"
"pdf": "pdf",
"{{action}}, current role: {{role}}": "{{action}}, huidige rol: {{role}}",
"{{name}} added to invite list. Add more members or press Tab to select role and invite.": "{{name}} toegevoegd aan de uitnodigingslijst. Voeg meer deelnemers toe of druk op Tab om de rol en uitnodiging te selecteren.",
"{{name}} removed from invite list": "{{name}} verwijderd uit de uitnodigingslijst"
}
},
"pt": { "translation": {} },
@@ -1443,6 +1452,7 @@
"All docs": "Все документы",
"An error occurred while restoring the document: {{error}}": "При восстановлении документа произошла ошибка: {{error}}",
"An uncompromising writing experience.": "Бескомпромиссный опыт написания.",
"An unexpected error occurred.": "Произошла непредвиденная ошибка.",
"Analyzing file...": "Анализ файла...",
"Anonymous": "Аноним",
"Anyone with the link can edit the document": "Любой, у кого есть ссылка, может редактировать документ",
@@ -1517,7 +1527,7 @@
"Docx": "Docx",
"Download": "Загрузить",
"Download anyway": "Всё равно загрузить",
"Download your document in a .docx, .odt or .pdf format.": "Загрузить документ в формате .docx, .odt или .pdf.",
"Download your document in a .docx, .odt, .pdf or .html(zip) format.": "Загрузить документ в формате .docx, .odt, .pdf или .html(zip).",
"Drag and drop status": "Состояние перетаскивания",
"Duplicate": "Дублировать",
"Edit document emoji": "Изменить документ emoji",
@@ -1541,7 +1551,9 @@
"Failed to duplicate the document...": "Не удалось дублировать документ...",
"Flexible export.": "Полезные форматы экспорта.",
"Format": "Формат",
"Go to content": "Перейти к содержимому",
"Govs ❤️ Open Source.": "Govs ❤️ Open Source.",
"HTML": "HTML",
"History": "История изменений",
"Home": "Главная",
"I understand": "Я понимаю",
@@ -1602,7 +1614,6 @@
"Open document: {{title}}": "Открыть документ: {{title}}",
"Open root document": "Открыть корневой документ",
"Open the document options": "Открыть параметры документа",
"Open the header menu": "Открыть меню заголовка",
"Open the menu of actions for the document: {{title}}": "Открыть меню действий для документа: {{title}}",
"Open the sharing settings for the document": "Открыть настройки общего доступа к документу",
"Organize": "Организация данных",
@@ -1623,6 +1634,7 @@
"Quick search input": "Быстрый поиск",
"Reader": "Читатель",
"Reading": "Чтение",
"Refresh page": "Обновить страницу",
"Remove access": "Отменить доступ",
"Remove emoji": "Убрать эмодзи",
"Remove {{name}} from the invite list": "Удалить {{name}} из списка приглашений",
@@ -1665,6 +1677,7 @@
"The document has been deleted.": "Документ удалён.",
"The document has been restored.": "Документ восстановлен.",
"The document visibility has been updated.": "Видимость документа обновлена.",
"The document visibility restored.": "Видимость документа восстановлена.",
"The export failed": "Ошибка при экспорте",
"The link sharing rules differ from the parent document": "Правила обмена ссылками отличаются от правил родительского документа",
"This document and <strong>any sub-documents</strong> will be placed in the trashbin. You can restore it within {{days}} days.": "Этот документ и <strong>все вложенные в него документы</strong> будут помещены в корзину. Вы можете восстановить их в течение {{days}} дн.",
@@ -1677,12 +1690,12 @@
"Trashbin": "Корзина",
"Type a name or email": "Введите имя или e-mail",
"Type the name of a document": "Введите название документа",
"Unnamed document": "Безымянный документ",
"Unpin": "Открепить",
"Untitled document": "Безымянный документ",
"Updated": "Обновлено",
"Updated at": "Обновлено",
"Upload PDF": "Выгрузить PDF",
"Use arrow keys to navigate between documents. Press Enter to open a document. Press F2 to focus the emoji button when available, then press F2 again to access document actions.": "Используйте клавиши со стрелками для перемещения между документами. Нажмите Enter для открытия документа. Нажмите F2, чтобы перейти к кнопке эмодзи, при их наличии, затем снова нажмите F2 для доступа к действиям с документом.",
"Use as prompt": "Использовать как запрос",
"Version history": "История версий",
"Version restored successfully": "Версия успешно восстановлена",
@@ -1841,6 +1854,7 @@
"All docs": "Всі документи",
"An error occurred while restoring the document: {{error}}": "При відновленні документа виникла помилка: {{error}}",
"An uncompromising writing experience.": "Безкомпромісне задоволення від процесу письма.",
"An unexpected error occurred.": "Сталася неочікувана помилка.",
"Analyzing file...": "Аналіз файлу...",
"Anonymous": "Анонім",
"Anyone with the link can edit the document": "Будь-хто з посиланням може редагувати документ",
@@ -1915,7 +1929,7 @@
"Docx": "Docx",
"Download": "Завантажити",
"Download anyway": "Все одно завантажити",
"Download your document in a .docx, .odt or .pdf format.": "Завантажити документ у форматі .docx, .odt або .pdf.",
"Download your document in a .docx, .odt, .pdf or .html(zip) format.": "Завантажити документ у форматі .docx, .odt, .pdf або .html(zip).",
"Drag and drop status": "Стан перетягування",
"Duplicate": "Дублювати",
"Edit document emoji": "Редагувати документ емодзі",
@@ -1939,7 +1953,9 @@
"Failed to duplicate the document...": "Не вдалося дублювати документ...",
"Flexible export.": "Гнучкий експорт.",
"Format": "Формат",
"Go to content": "Перейти до вмісту",
"Govs ❤️ Open Source.": "Govs ❤️ Open Source.",
"HTML": "HTML",
"History": "Історія",
"Home": "Головна",
"I understand": "Я розумію",
@@ -2000,7 +2016,6 @@
"Open document: {{title}}": "Відкрити документ: {{title}}",
"Open root document": "Відкрити кореневий документ",
"Open the document options": "Відкрити параметри документа",
"Open the header menu": "Відкрити меню заголовка",
"Open the menu of actions for the document: {{title}}": "Відкрити меню дій для документа: {{title}}",
"Open the sharing settings for the document": "Відкрити параметри спільного доступу для документа",
"Organize": "Організуйте",
@@ -2021,6 +2036,7 @@
"Quick search input": "Швидкий пошук",
"Reader": "Читач",
"Reading": "Читання",
"Refresh page": "Оновити сторінку",
"Remove access": "Вилучити доступ",
"Remove emoji": "Прибрати емодзі",
"Remove {{name}} from the invite list": "Видалити {{name}} зі списку запрошень",
@@ -2063,6 +2079,7 @@
"The document has been deleted.": "Документ був видалений.",
"The document has been restored.": "Документ був відновлений.",
"The document visibility has been updated.": "Видимість документа оновлено.",
"The document visibility restored.": "Видимість документа відновлено.",
"The export failed": "Не вдалося виконати експорт",
"The link sharing rules differ from the parent document": "Правила обміну посиланнями відрізняються від батьківського документа",
"This document and <strong>any sub-documents</strong> will be placed in the trashbin. You can restore it within {{days}} days.": "Цей документ та <strong>всі вкладені документи</strong> будуть переміщені до кошика. Ви можете відновити їх протягом {{days}} дн.",
@@ -2075,12 +2092,12 @@
"Trashbin": "Кошик",
"Type a name or email": "Введіть ім'я або електронну адресу",
"Type the name of a document": "Введіть назву документа",
"Unnamed document": "Документ без назви",
"Unpin": "Відкріпити",
"Untitled document": "Документ без назви",
"Updated": "Оновлено",
"Updated at": "Оновлено",
"Upload PDF": "Вивантажити PDF",
"Use arrow keys to navigate between documents. Press Enter to open a document. Press F2 to focus the emoji button when available, then press F2 again to access document actions.": "Використовуйте клавіші зі стрілками для переміщення між документами. Натисніть Enter, щоб відкрити документ. Натисніть F2, щоб перейти до кнопки емодзі, якщо вони є, потім знову натисніть F2, щоб отримати доступ до дій з документом.",
"Use as prompt": "Використати як запит",
"Version history": "Історія версій",
"Version restored successfully": "Версія успішно відновлена",
@@ -2223,7 +2240,6 @@
"Open Source": "开放源代码",
"Open document {{title}}": "打开文档{{title}}",
"Open the document options": "打开文档选项",
"Open the header menu": "打开标题菜单",
"Organize": "组织",
"Owner": "所有者",
"PDF": "PDF",
@@ -53,11 +53,51 @@ export function MainLayoutContent({
enableResizablePanel = false,
}: PropsWithChildren<MainLayoutContentProps>) {
const { isDesktop } = useResponsiveStore();
if (enableResizablePanel) {
return (
<ResizableLeftPanel leftPanel={<LeftPanel />}>
<MainContent backgroundColor={backgroundColor}>{children}</MainContent>
</ResizableLeftPanel>
);
}
if (!isDesktop) {
return (
<>
<LeftPanel />
<MainContent backgroundColor={backgroundColor}>{children}</MainContent>
</>
);
}
return (
<>
<Box
$css={css`
width: 300px;
border-right: 1px solid
var(--c--contextuals--border--surface--primary);
`}
>
<LeftPanel />
</Box>
<MainContent backgroundColor={backgroundColor}>{children}</MainContent>
</>
);
}
const MainContent = ({
children,
backgroundColor,
}: PropsWithChildren<MainLayoutContentProps>) => {
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
const mainContent = (
return (
<Box
as="main"
role="main"
@@ -80,7 +120,7 @@ export function MainLayoutContent({
$css={css`
overflow-y: auto;
overflow-x: clip;
&:focus {
&:focus-visible {
outline: 3px solid ${colorsTokens['brand-400']};
outline-offset: -3px;
}
@@ -92,36 +132,4 @@ export function MainLayoutContent({
{children}
</Box>
);
if (!isDesktop) {
return (
<>
<LeftPanel />
{mainContent}
</>
);
}
if (enableResizablePanel) {
return (
<ResizableLeftPanel leftPanel={<LeftPanel />}>
{mainContent}
</ResizableLeftPanel>
);
}
return (
<>
<Box
$css={css`
width: 300px;
border-right: 1px solid
var(--c--contextuals--border--surface--primary);
`}
>
<LeftPanel />
</Box>
{mainContent}
</>
);
}
};
+25 -11
View File
@@ -5,7 +5,7 @@ import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import img403 from '@/assets/icons/icon-403.png';
import error_img from '@/assets/icons/error-planetes.png';
import { Box, Icon, StyledLink, Text } from '@/components';
import { PageLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
@@ -32,32 +32,46 @@ const Page: NextPageWithLayout = () => {
<Box
$align="center"
$margin="auto"
$gap="1rem"
$gap="md"
$padding={{ bottom: '2rem' }}
>
<Text as="h1" $textAlign="center" className="sr-only">
{t('Page Not Found - Error 404')} - {t('Docs')}
</Text>
<Image
src={img403}
src={error_img}
alt=""
width={300}
height={300}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Box $align="center" $gap="0.8rem">
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="brand">
{t(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
)}
</Text>
<Text
as="p"
$textAlign="center"
$maxWidth="350px"
$theme="neutral"
$margin="0"
>
{t(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
)}
</Text>
<Box $direction="row" $gap="sm">
<StyledLink href="/">
<StyledButton icon={<Icon iconName="house" $color="white" />}>
<StyledButton
color="neutral"
icon={
<Icon
iconName="house"
variant="symbols-outlined"
$withThemeInherited
/>
}
>
{t('Home')}
</StyledButton>
</StyledLink>
@@ -0,0 +1,126 @@
import { Button } from '@openfun/cunningham-react';
import * as Sentry from '@sentry/nextjs';
import { NextPageContext } from 'next';
import NextError from 'next/error';
import Head from 'next/head';
import Image from 'next/image';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import error_img from '@/assets/icons/error-planetes.png';
import { Box, Icon, StyledLink, Text } from '@/components';
import { PageLayout } from '@/layouts';
const StyledButton = styled(Button)`
width: fit-content;
`;
const Error = () => {
const { t } = useTranslation();
const errorTitle = t('An unexpected error occurred.');
return (
<>
<Head>
<title>
{errorTitle} - {t('Docs')}
</title>
<meta
property="og:title"
content={`${errorTitle} - ${t('Docs')}`}
key="title"
/>
</Head>
<Box
$align="center"
$margin="auto"
$gap="md"
$padding={{ bottom: '2rem' }}
>
<Text as="h2" $textAlign="center" className="sr-only">
{errorTitle} - {t('Docs')}
</Text>
<Image
src={error_img}
alt=""
width={300}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Text
as="p"
$textAlign="center"
$maxWidth="350px"
$theme="neutral"
$margin="0"
>
{errorTitle}
</Text>
<Box $direction="row" $gap="sm">
<StyledLink href="/">
<StyledButton
color="neutral"
icon={
<Icon
iconName="house"
variant="symbols-outlined"
$withThemeInherited
/>
}
>
{t('Home')}
</StyledButton>
</StyledLink>
<StyledButton
color="neutral"
variant="bordered"
icon={
<Icon
iconName="refresh"
variant="symbols-outlined"
$withThemeInherited
/>
}
onClick={() => window.location.reload()}
>
{t('Refresh page')}
</StyledButton>
</Box>
</Box>
</>
);
};
Error.getInitialProps = async (contextData: NextPageContext) => {
const { res, err, asPath, pathname, query } = contextData;
Sentry.captureException(err, {
contexts: {
nextjs: {
page: pathname,
path: asPath,
query: query,
statusCode: res?.statusCode || err?.statusCode,
},
},
tags: {
errorPage: '_error.tsx',
statusCode: String(res?.statusCode || err?.statusCode || 'unknown'),
},
});
return NextError.getInitialProps(contextData);
};
Error.getLayout = function getLayout(page: ReactElement) {
return <PageLayout withFooter={false}>{page}</PageLayout>;
};
export default Error;
@@ -10,6 +10,12 @@ body {
padding: 0;
}
/* stylelint-disable-next-line selector-id-pattern */
body > #__next > .c__app > div:has(> .c__loader) {
min-height: 100vh;
width: 100vw;
}
* {
box-sizing: border-box;
}
@@ -1,10 +1,7 @@
import { useRouter } from 'next/router';
import { HOME_URL } from '@/features/auth';
const Page = () => {
const { replace } = useRouter();
void replace(HOME_URL);
window.location.replace(HOME_URL);
};
export default Page;
@@ -3,6 +3,7 @@ import posthog from 'posthog-js';
import { PostHogProvider as PHProvider } from 'posthog-js/react';
import { JSX, PropsWithChildren, ReactNode, useEffect } from 'react';
import { useIsOffline } from '@/features/service-worker/hooks/useOffline';
import { AbstractAnalytic, AnalyticEvent } from '@/libs/';
export class PostHogAnalytic extends AbstractAnalytic {
@@ -45,6 +46,8 @@ export function PostHogProvider({
children,
conf,
}: PropsWithChildren<PostHogProviderProps>) {
const isOffline = useIsOffline((state) => state.isOffline);
useEffect(() => {
if (!conf?.id || !conf?.host || posthog.__loaded) {
return;
@@ -53,9 +56,9 @@ export function PostHogProvider({
posthog.init(conf.id, {
api_host: conf.host,
person_profiles: 'always',
loaded: (posthog) => {
loaded: (posthogInstance) => {
if (process.env.NODE_ENV === 'development') {
posthog.debug();
posthogInstance.debug();
}
},
capture_pageview: false,
@@ -71,5 +74,14 @@ export function PostHogProvider({
};
}, [conf?.host, conf?.id]);
// Disable PostHog when offline to prevent retry requests
useEffect(() => {
if (isOffline) {
posthog.opt_out_capturing();
} else {
posthog.opt_in_capturing();
}
}, [isOffline]);
return <PHProvider client={posthog}>{children}</PHProvider>;
}
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "impress",
"version": "4.0.0",
"version": "4.3.0",
"private": true,
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
@@ -31,14 +31,15 @@
"server:test": "yarn COLLABORATION_SERVER run test"
},
"resolutions": {
"@tiptap/extensions": "3.11.0",
"@types/node": "24.10.1",
"@types/react": "19.2.6",
"@tiptap/extensions": "3.13.0",
"@types/node": "24.10.4",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"docx": "9.5.0",
"eslint": "9.39.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"eslint": "9.39.2",
"prosemirror-view": "1.41.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"typescript": "5.9.3",
"wrap-ansi": "9.0.2",
"yjs": "13.6.27"
@@ -1,6 +1,6 @@
{
"name": "eslint-plugin-docs",
"version": "4.0.0",
"version": "4.3.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -17,21 +17,21 @@
"eslint": ">=9.0.0"
},
"dependencies": {
"@next/eslint-plugin-next": "16.0.3",
"@next/eslint-plugin-next": "15.5.9",
"@tanstack/eslint-plugin-query": "5.91.2",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"@vitest/eslint-plugin": "1.4.3",
"eslint-config-next": "16.0.3",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"@vitest/eslint-plugin": "1.5.2",
"eslint-config-next": "15.5.9",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jest": "29.2.1",
"eslint-plugin-jest": "29.5.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.3.0",
"eslint-plugin-playwright": "2.4.0",
"eslint-plugin-prettier": "5.5.4",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-testing-library": "7.13.5",
"prettier": "3.6.2"
"eslint-plugin-testing-library": "7.13.6",
"prettier": "3.7.4"
},
"packageManager": "yarn@1.22.22"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "4.0.0",
"version": "4.3.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -21,7 +21,7 @@
"eslint-plugin-import": "2.32.0",
"i18next-parser": "9.3.0",
"jest": "30.2.0",
"ts-jest": "29.4.5",
"ts-jest": "29.4.6",
"typescript": "*",
"yargs": "18.0.0"
},
@@ -0,0 +1,66 @@
import axios from 'axios';
import { describe, expect, test, vi } from 'vitest';
vi.mock('../src/env', () => ({
COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000',
Y_PROVIDER_API_KEY: 'test-yprovider-key',
}));
describe('CollaborationBackend', () => {
test('fetchDocument sends X-Y-Provider-Key header', async () => {
const axiosGetSpy = vi.spyOn(axios, 'get').mockResolvedValue({
status: 200,
data: {
id: 'test-doc-id',
abilities: { retrieve: true, update: true },
},
});
const { fetchDocument } = await import('@/api/collaborationBackend');
const documentId = 'test-document-123';
await fetchDocument(documentId, { cookie: 'test-cookie' });
expect(axiosGetSpy).toHaveBeenCalledWith(
`http://app-dev:8000/api/v1.0/documents/${documentId}/`,
expect.objectContaining({
headers: expect.objectContaining({
'X-Y-Provider-Key': 'test-yprovider-key',
cookie: 'test-cookie',
}),
}),
);
axiosGetSpy.mockRestore();
});
test('fetchCurrentUser sends X-Y-Provider-Key header', async () => {
const axiosGetSpy = vi.spyOn(axios, 'get').mockResolvedValue({
status: 200,
data: {
id: 'test-user-id',
email: 'test@example.com',
},
});
const { fetchCurrentUser } = await import('@/api/collaborationBackend');
await fetchCurrentUser({
cookie: 'test-cookie',
origin: 'http://localhost:3000',
});
expect(axiosGetSpy).toHaveBeenCalledWith(
'http://app-dev:8000/api/v1.0/users/me/',
expect.objectContaining({
headers: expect.objectContaining({
'X-Y-Provider-Key': 'test-yprovider-key',
cookie: 'test-cookie',
origin: 'http://localhost:3000',
}),
}),
);
axiosGetSpy.mockRestore();
});
});
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "server-y-provider",
"version": "4.0.0",
"version": "4.3.0",
"description": "Y.js provider for docs",
"repository": "https://github.com/suitenumerique/docs",
"license": "MIT",
@@ -16,24 +16,24 @@
"node": ">=22"
},
"dependencies": {
"@blocknote/server-util": "0.42.3",
"@hocuspocus/server": "3.4.0",
"@sentry/node": "10.26.0",
"@sentry/profiling-node": "10.26.0",
"@blocknote/server-util": "0.45.0",
"@hocuspocus/server": "3.4.3",
"@sentry/node": "10.30.0",
"@sentry/profiling-node": "10.30.0",
"@tiptap/extensions": "*",
"axios": "1.13.2",
"cors": "2.8.5",
"express": "5.1.0",
"express": "5.2.1",
"express-ws": "5.0.2",
"uuid": "13.0.0",
"y-protocols": "1.0.6",
"yjs": "*"
},
"devDependencies": {
"@blocknote/core": "0.42.3",
"@hocuspocus/provider": "3.4.0",
"@blocknote/core": "0.45.0",
"@hocuspocus/provider": "3.4.3",
"@types/cors": "2.8.19",
"@types/express": "5.0.5",
"@types/express": "5.0.6",
"@types/express-ws": "3.0.6",
"@types/node": "*",
"@types/supertest": "6.0.3",
@@ -45,7 +45,7 @@
"ts-node": "10.9.2",
"tsc-alias": "1.8.16",
"typescript": "*",
"vitest": "4.0.13",
"vitest": "4.0.15",
"vitest-mock-extended": "3.1.0",
"ws": "8.18.3"
},
@@ -2,7 +2,7 @@ import { IncomingHttpHeaders } from 'http';
import axios from 'axios';
import { COLLABORATION_BACKEND_BASE_URL } from '@/env';
import { COLLABORATION_BACKEND_BASE_URL, Y_PROVIDER_API_KEY } from '@/env';
export interface User {
id: string;
@@ -61,6 +61,7 @@ async function fetch<T>(
headers: {
cookie: requestHeaders['cookie'],
origin: requestHeaders['origin'],
'X-Y-Provider-Key': Y_PROVIDER_API_KEY,
},
},
);
+2490 -2842
View File
File diff suppressed because it is too large Load Diff