Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12cc79b640 | |||
| af15e77713 | |||
| 99131dc917 | |||
| 90651a8ea6 | |||
| 9c575e397c | |||
| a6b472aa51 | |||
| 9fcc221b33 | |||
| acdde81a3d | |||
| 9b03754f88 | |||
| 0805216cc6 | |||
| 5e398e8e79 | |||
| 00ae7fdd60 | |||
| 8036f16cc3 | |||
| 54fe70d662 | |||
| 1e37007be9 |
+21
-1
@@ -6,6 +6,25 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [4.1.0] - 2025-12-09
|
||||
|
||||
### Added
|
||||
|
||||
- ⚡️(frontend) export html #1669
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) add skip to content button for keyboard accessibility #1624
|
||||
- ♿(frontend) fix toggle panel button a11y labels #1634
|
||||
- 🔒️(frontend) remove dangerouslySetInnerHTML from codebase #1712
|
||||
- ⚡️(frontend) improve Comments feature #1687
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(nginx) fix / location to handle new static pages #1682
|
||||
- 🐛(frontend) rerendering during resize window #1715
|
||||
|
||||
## [4.0.0] - 2025-12-01
|
||||
|
||||
### Added
|
||||
@@ -900,7 +919,8 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.0.0...main
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.1.0...main
|
||||
[v4.1.0]: https://github.com/suitenumerique/docs/releases/v4.1.0
|
||||
[v4.0.0]: https://github.com/suitenumerique/docs/releases/v4.0.0
|
||||
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
|
||||
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Breton\n"
|
||||
"Language: br_FR\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Language: es_ES\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Italian\n"
|
||||
"Language: it_IT\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Portuguese\n"
|
||||
"Language: pt_PT\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Slovenian\n"
|
||||
"Language: sl_SI\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Swedish\n"
|
||||
"Language: sv_SE\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Turkish\n"
|
||||
"Language: tr_TR\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Chinese Simplified\n"
|
||||
"Language: zh_CN\n"
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "4.0.0"
|
||||
version = "4.1.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -39,7 +39,7 @@ dependencies = [
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.8",
|
||||
"django==5.2.9",
|
||||
"django-treebeard==4.7.1",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, getOtherBrowserName, verifyDocName } from './utils-common';
|
||||
import {
|
||||
closeHeaderMenu,
|
||||
createDoc,
|
||||
getOtherBrowserName,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { writeInEditor } from './utils-editor';
|
||||
import {
|
||||
addNewMember,
|
||||
@@ -116,8 +121,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();
|
||||
|
||||
@@ -181,6 +185,28 @@ 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.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 }) => {
|
||||
@@ -293,3 +319,27 @@ test.describe('Doc Comments', () => {
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc Comments mobile', () => {
|
||||
test.use({ viewport: { width: 500, height: 1200 } });
|
||||
|
||||
test('Comments are not visible 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 expect(page.getByRole('button', { name: 'Comment' })).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Paragraph' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -996,4 +996,44 @@ test.describe('Doc Editor', () => {
|
||||
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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -177,3 +177,27 @@ test.describe('Header: Override configuration', () => {
|
||||
await expect(logoImage).toHaveAttribute('alt', '');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Header: Skip to Content', () => {
|
||||
test('it displays skip link on first TAB and focuses main content on click', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for skip button to be mounted (client-side only component)
|
||||
const skipButton = page.getByRole('button', { name: 'Go to content' });
|
||||
await skipButton.waitFor({ state: 'attached' });
|
||||
|
||||
// First TAB shows the skip button
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// The skip button should be visible and focused
|
||||
await expect(skipButton).toBeFocused();
|
||||
await expect(skipButton).toBeVisible();
|
||||
|
||||
// Clicking moves focus to the main content
|
||||
await skipButton.click();
|
||||
const mainContent = page.locator('main#mainContent');
|
||||
await expect(mainContent).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ test.describe('Language', () => {
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
|
||||
@@ -83,6 +83,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 +122,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.0",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -6,7 +6,7 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location / {
|
||||
try_files $uri index.html $uri/ =404;
|
||||
try_files $uri index.html $uri/index.html =404;
|
||||
|
||||
add_header X-Frame-Options DENY always;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.0",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -57,7 +57,7 @@
|
||||
"idb": "8.0.3",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.7.2",
|
||||
"next": "15.5.4",
|
||||
"next": "15.5.7",
|
||||
"posthog-js": "1.298.0",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.13.0",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||
|
||||
export const SkipToContent = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Reset focus after route change so first TAB goes to skip link
|
||||
useEffect(() => {
|
||||
const handleRouteChange = () => {
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
|
||||
document.body.setAttribute('tabindex', '-1');
|
||||
document.body.focus({ preventScroll: true });
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeAttribute('tabindex');
|
||||
}, 100);
|
||||
};
|
||||
|
||||
router.events.on('routeChangeComplete', handleRouteChange);
|
||||
return () => {
|
||||
router.events.off('routeChangeComplete', handleRouteChange);
|
||||
};
|
||||
}, [router.events]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
|
||||
if (mainContent) {
|
||||
mainContent.focus();
|
||||
mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
$css={css`
|
||||
.c__button--brand--primary.--docs--skip-to-content:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 1px var(--c--globals--colors--white-000),
|
||||
0 0 0 4px var(--c--contextuals--border--semantic--brand--primary);
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
color="brand"
|
||||
className="--docs--skip-to-content"
|
||||
onFocus={() => setIsVisible(true)}
|
||||
onBlur={() => setIsVisible(false)}
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
pointerEvents: isVisible ? 'auto' : 'none',
|
||||
position: 'fixed',
|
||||
top: spacingsTokens['2xs'],
|
||||
// padding header + logo(32px) + gap(3xs≈4px) + text "Docs"(≈70px) + 12px
|
||||
left: `calc(${spacingsTokens['base']} + 32px + ${spacingsTokens['3xs']} + 70px + 12px)`,
|
||||
zIndex: 9999,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{t('Go to content')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -11,5 +11,6 @@ export * from './Loading';
|
||||
export * from './modal';
|
||||
export * from './Overlayer';
|
||||
export * from './separators';
|
||||
export * from './SkipToContent';
|
||||
export * from './Text';
|
||||
export * from './TextErrors';
|
||||
|
||||
+5
-3
@@ -87,7 +87,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
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 && isDesktop;
|
||||
|
||||
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
|
||||
const { i18n } = useTranslation();
|
||||
@@ -207,7 +209,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
ref={refEditorContainer}
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(canSeeComment, currentUserAvatarUrl)}
|
||||
${cssComments(showComments, currentUserAvatarUrl)}
|
||||
`}
|
||||
>
|
||||
{errorAttachment && (
|
||||
@@ -225,7 +227,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
theme="light"
|
||||
comments={canSeeComment}
|
||||
comments={showComments}
|
||||
aria-label={t('Document editor')}
|
||||
>
|
||||
<BlockNoteSuggestionMenu />
|
||||
|
||||
+19
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 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 {
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
@@ -10,6 +15,7 @@ import { css } from 'styled-components';
|
||||
import { Box, Icon } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useDocStore } from '@/features/docs/doc-management';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import {
|
||||
DocsBlockSchema,
|
||||
@@ -22,6 +28,7 @@ export const CommentToolbarButton = () => {
|
||||
const { currentDoc } = useDocStore();
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
const editor = useBlockNoteEditor<
|
||||
DocsBlockSchema,
|
||||
@@ -35,7 +42,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 (
|
||||
!isDesktop ||
|
||||
!show ||
|
||||
!editor.isEditable ||
|
||||
!Components ||
|
||||
@@ -51,6 +69,7 @@ export const CommentToolbarButton = () => {
|
||||
onClick={() => {
|
||||
editor.comments?.startPendingComment();
|
||||
editor.formattingToolbar.closeMenu();
|
||||
focusOnInputThread();
|
||||
}}
|
||||
aria-haspopup="dialog"
|
||||
data-test="comment-toolbar-button"
|
||||
|
||||
+30
-5
@@ -117,6 +117,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
|
||||
*/
|
||||
@@ -345,6 +360,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 +424,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 +448,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}/`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 || !editor._tiptapEditor?.view?.dom) {
|
||||
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 || !editor._tiptapEditor?.view?.dom || !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 () => {
|
||||
|
||||
@@ -95,6 +95,11 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if editor and its view are mounted before accessing document
|
||||
if (!editor || !editor._tiptapEditor?.view?.dom) {
|
||||
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 || !editor._tiptapEditor?.view?.dom) {
|
||||
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 || !editor._tiptapEditor?.view?.dom) {
|
||||
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);
|
||||
});
|
||||
|
||||
+67
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
+4
-3
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+15
-15
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import Image from 'next/image';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, StyledLink } from '@/components/';
|
||||
import { Box, SkipToContent, StyledLink } from '@/components/';
|
||||
import { useConfig } from '@/core/config';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { ButtonLogin } from '@/features/auth';
|
||||
@@ -25,72 +25,76 @@ export const Header = () => {
|
||||
config?.theme_customization?.header?.icon || componentTokens.icon;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="header"
|
||||
role="banner"
|
||||
$css={css`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: ${HEADER_HEIGHT}px;
|
||||
padding: 0 ${spacingsTokens['base']};
|
||||
background-color: var(--c--contextuals--background--surface--primary);
|
||||
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
|
||||
`}
|
||||
className="--docs--header"
|
||||
>
|
||||
{!isDesktop && <ButtonTogglePanel />}
|
||||
<StyledLink
|
||||
href="/"
|
||||
data-testid="header-logo-link"
|
||||
aria-label={t('Back to homepage')}
|
||||
<>
|
||||
<SkipToContent />
|
||||
<Box
|
||||
as="header"
|
||||
role="banner"
|
||||
$css={css`
|
||||
outline: none;
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400) !important;
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
}
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: ${HEADER_HEIGHT}px;
|
||||
padding: 0 ${spacingsTokens['base']};
|
||||
background-color: var(--c--contextuals--background--surface--primary);
|
||||
border-bottom: 1px solid
|
||||
var(--c--contextuals--border--surface--primary);
|
||||
`}
|
||||
className="--docs--header"
|
||||
>
|
||||
<Box
|
||||
$align="center"
|
||||
$gap={spacingsTokens['3xs']}
|
||||
$direction="row"
|
||||
$position="relative"
|
||||
$height="fit-content"
|
||||
$margin={{ top: 'auto' }}
|
||||
{!isDesktop && <ButtonTogglePanel />}
|
||||
<StyledLink
|
||||
href="/"
|
||||
data-testid="header-logo-link"
|
||||
aria-label={t('Back to homepage')}
|
||||
$css={css`
|
||||
outline: none;
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400) !important;
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Image
|
||||
data-testid="header-icon-docs"
|
||||
src={icon.src || ''}
|
||||
alt=""
|
||||
width={0}
|
||||
height={0}
|
||||
style={{
|
||||
width: icon.width,
|
||||
height: icon.height,
|
||||
}}
|
||||
priority
|
||||
/>
|
||||
<Title headingLevel="h1" aria-hidden="true" />
|
||||
</Box>
|
||||
</StyledLink>
|
||||
{!isDesktop ? (
|
||||
<Box $direction="row" $gap={spacingsTokens['sm']}>
|
||||
<LaGaufre />
|
||||
</Box>
|
||||
) : (
|
||||
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
|
||||
<ButtonLogin />
|
||||
<LanguagePicker />
|
||||
<LaGaufre />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
$align="center"
|
||||
$gap={spacingsTokens['3xs']}
|
||||
$direction="row"
|
||||
$position="relative"
|
||||
$height="fit-content"
|
||||
$margin={{ top: 'auto' }}
|
||||
>
|
||||
<Image
|
||||
data-testid="header-icon-docs"
|
||||
src={icon.src || ''}
|
||||
alt=""
|
||||
width={0}
|
||||
height={0}
|
||||
style={{
|
||||
width: icon.width,
|
||||
height: icon.height,
|
||||
}}
|
||||
priority
|
||||
/>
|
||||
<Title headingLevel="h1" aria-hidden="true" />
|
||||
</Box>
|
||||
</StyledLink>
|
||||
{!isDesktop ? (
|
||||
<Box $direction="row" $gap={spacingsTokens['sm']}>
|
||||
<LaGaufre />
|
||||
</Box>
|
||||
) : (
|
||||
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
|
||||
<ButtonLogin />
|
||||
<LanguagePicker />
|
||||
<LaGaufre />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { css } from 'styled-components';
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { Footer } from '@/features/footer';
|
||||
import { LeftPanel } from '@/features/left-panel';
|
||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import SC1ResponsiveEn from '../assets/SC1-responsive-en.png';
|
||||
@@ -34,8 +35,19 @@ export function HomeContent() {
|
||||
<Box
|
||||
as="main"
|
||||
role="main"
|
||||
id={MAIN_LAYOUT_ID}
|
||||
tabIndex={-1}
|
||||
className="--docs--home-content"
|
||||
aria-label={t('Main content')}
|
||||
$css={css`
|
||||
&:focus {
|
||||
outline: 3px solid var(--c--theme--colors--primary-600);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
&:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<HomeHeader />
|
||||
{isSmallMobile && (
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+40
-29
@@ -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",
|
||||
@@ -795,7 +789,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 +813,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 +876,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",
|
||||
@@ -943,6 +938,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,7 +951,6 @@
|
||||
"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",
|
||||
@@ -1078,7 +1073,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",
|
||||
@@ -1174,6 +1168,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 +1218,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 +1248,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 +1261,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 +1311,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",
|
||||
@@ -1373,6 +1373,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,7 +1386,6 @@
|
||||
"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",
|
||||
@@ -1415,7 +1415,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": {} },
|
||||
@@ -1517,7 +1520,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 +1544,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 +1607,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": "Организация данных",
|
||||
@@ -1665,6 +1669,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,7 +1682,6 @@
|
||||
"Trashbin": "Корзина",
|
||||
"Type a name or email": "Введите имя или e-mail",
|
||||
"Type the name of a document": "Введите название документа",
|
||||
"Unnamed document": "Безымянный документ",
|
||||
"Unpin": "Открепить",
|
||||
"Untitled document": "Безымянный документ",
|
||||
"Updated": "Обновлено",
|
||||
@@ -1915,7 +1919,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 +1943,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 +2006,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": "Організуйте",
|
||||
@@ -2063,6 +2068,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,7 +2081,6 @@
|
||||
"Trashbin": "Кошик",
|
||||
"Type a name or email": "Введіть ім'я або електронну адресу",
|
||||
"Type the name of a document": "Введіть назву документа",
|
||||
"Unnamed document": "Документ без назви",
|
||||
"Unpin": "Відкріпити",
|
||||
"Untitled document": "Документ без назви",
|
||||
"Updated": "Оновлено",
|
||||
@@ -2223,7 +2228,6 @@
|
||||
"Open Source": "开放源代码",
|
||||
"Open document {{title}}": "打开文档{{title}}",
|
||||
"Open the document options": "打开文档选项",
|
||||
"Open the header menu": "打开标题菜单",
|
||||
"Organize": "组织",
|
||||
"Owner": "所有者",
|
||||
"PDF": "PDF",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Header } from '@/features/header';
|
||||
import { HEADER_HEIGHT } from '@/features/header/conf';
|
||||
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
|
||||
@@ -52,15 +53,57 @@ 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"
|
||||
aria-label={t('Main content')}
|
||||
id={MAIN_LAYOUT_ID}
|
||||
tabIndex={-1}
|
||||
$align="center"
|
||||
$flex={1}
|
||||
$width="100%"
|
||||
@@ -77,6 +120,10 @@ export function MainLayoutContent({
|
||||
$css={css`
|
||||
overflow-y: auto;
|
||||
overflow-x: clip;
|
||||
&:focus {
|
||||
outline: 3px solid ${colorsTokens['brand-400']};
|
||||
outline-offset: -3px;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Skeleton>
|
||||
@@ -85,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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { Footer } from '@/features/footer';
|
||||
@@ -7,6 +8,8 @@ import { HEADER_HEIGHT, Header } from '@/features/header';
|
||||
import { LeftPanel } from '@/features/left-panel';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { MAIN_LAYOUT_ID } from './conf';
|
||||
|
||||
interface PageLayoutProps {
|
||||
withFooter?: boolean;
|
||||
}
|
||||
@@ -27,8 +30,19 @@ export function PageLayout({
|
||||
<Box
|
||||
as="main"
|
||||
role="main"
|
||||
id={MAIN_LAYOUT_ID}
|
||||
tabIndex={-1}
|
||||
$width="100%"
|
||||
$css="flex-grow:1;"
|
||||
$css={css`
|
||||
flex-grow: 1;
|
||||
&:focus {
|
||||
outline: 3px solid var(--c--theme--colors--primary-600);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
&:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
`}
|
||||
aria-label={t('Main content')}
|
||||
>
|
||||
{!isDesktop && <LeftPanel />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "impress",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.0",
|
||||
"private": true,
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eslint-plugin-docs",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.0",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "packages-i18n",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.0",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server-y-provider",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.0",
|
||||
"description": "Y.js provider for docs",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"license": "MIT",
|
||||
|
||||
+49
-49
@@ -2530,10 +2530,10 @@
|
||||
"@emnapi/runtime" "^1.4.3"
|
||||
"@tybys/wasm-util" "^0.10.0"
|
||||
|
||||
"@next/env@15.5.4":
|
||||
version "15.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.5.4.tgz#1d4aa6b238662d9cd95aea356b149b6f73061f95"
|
||||
integrity sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==
|
||||
"@next/env@15.5.7":
|
||||
version "15.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.5.7.tgz#4168db34ae3bc9fd9ad3b951d327f4cfc38d4362"
|
||||
integrity sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==
|
||||
|
||||
"@next/eslint-plugin-next@16.0.3":
|
||||
version "16.0.3"
|
||||
@@ -2542,45 +2542,45 @@
|
||||
dependencies:
|
||||
fast-glob "3.3.1"
|
||||
|
||||
"@next/swc-darwin-arm64@15.5.4":
|
||||
version "15.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.4.tgz#80cba1bec831d4b01fd03cbc48dfb7050775e5ee"
|
||||
integrity sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA==
|
||||
"@next/swc-darwin-arm64@15.5.7":
|
||||
version "15.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz#f0c9ccfec2cd87cbd4b241ce4c779a7017aed958"
|
||||
integrity sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==
|
||||
|
||||
"@next/swc-darwin-x64@15.5.4":
|
||||
version "15.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.4.tgz#d5408b19298f40da2b3dc9c2f9d1063ad98bd626"
|
||||
integrity sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA==
|
||||
"@next/swc-darwin-x64@15.5.7":
|
||||
version "15.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz#18009e9fcffc5c0687cc9db24182ddeac56280d9"
|
||||
integrity sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==
|
||||
|
||||
"@next/swc-linux-arm64-gnu@15.5.4":
|
||||
version "15.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.4.tgz#3b6b389bb4a1c9728a14afbbd59d2366ccd80b55"
|
||||
integrity sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA==
|
||||
"@next/swc-linux-arm64-gnu@15.5.7":
|
||||
version "15.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz#fe7c7e08264cf522d4e524299f6d3e63d68d579a"
|
||||
integrity sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==
|
||||
|
||||
"@next/swc-linux-arm64-musl@15.5.4":
|
||||
version "15.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.4.tgz#956127ecdfd56cda535af4651eed72a3b7270971"
|
||||
integrity sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A==
|
||||
"@next/swc-linux-arm64-musl@15.5.7":
|
||||
version "15.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz#94228fe293475ec34a5a54284e1056876f43a3cf"
|
||||
integrity sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==
|
||||
|
||||
"@next/swc-linux-x64-gnu@15.5.4":
|
||||
version "15.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.4.tgz#9386de65e86c0b34ef19e14f0ffbd4328a08d5e6"
|
||||
integrity sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA==
|
||||
"@next/swc-linux-x64-gnu@15.5.7":
|
||||
version "15.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz#078c71201dfe7fcfb8fa6dc92aae6c94bc011cdc"
|
||||
integrity sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==
|
||||
|
||||
"@next/swc-linux-x64-musl@15.5.4":
|
||||
version "15.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.4.tgz#c9094e5479b58c89d35b465f165b69be68de5a75"
|
||||
integrity sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw==
|
||||
"@next/swc-linux-x64-musl@15.5.7":
|
||||
version "15.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz#72947f5357f9226292353e0bb775643da3c7a182"
|
||||
integrity sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==
|
||||
|
||||
"@next/swc-win32-arm64-msvc@15.5.4":
|
||||
version "15.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.4.tgz#e83ca6b5ce9499bde5a4f3351cf74dc9e92cc83e"
|
||||
integrity sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA==
|
||||
"@next/swc-win32-arm64-msvc@15.5.7":
|
||||
version "15.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz#397b912cd51c6a80e32b9c0507ecd82514353941"
|
||||
integrity sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==
|
||||
|
||||
"@next/swc-win32-x64-msvc@15.5.4":
|
||||
version "15.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.4.tgz#5b5baf1bcb0ecba70d1768a0c8be59dfdcb2f111"
|
||||
integrity sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg==
|
||||
"@next/swc-win32-x64-msvc@15.5.7":
|
||||
version "15.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz#e02b543d9dc6c1631d4ac239cb1177245dfedfe4"
|
||||
integrity sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==
|
||||
|
||||
"@noble/hashes@^2.0.1":
|
||||
version "2.0.1"
|
||||
@@ -12080,25 +12080,25 @@ neo-async@^2.6.2:
|
||||
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
|
||||
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
|
||||
|
||||
next@15.5.4:
|
||||
version "15.5.4"
|
||||
resolved "https://registry.yarnpkg.com/next/-/next-15.5.4.tgz#e7412c805c0b686ceaf294de703b7c9be59a4081"
|
||||
integrity sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==
|
||||
next@15.5.7:
|
||||
version "15.5.7"
|
||||
resolved "https://registry.yarnpkg.com/next/-/next-15.5.7.tgz#4507700b2bbcaf2c9fb7a9ad25c0dac2ba4a9a75"
|
||||
integrity sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==
|
||||
dependencies:
|
||||
"@next/env" "15.5.4"
|
||||
"@next/env" "15.5.7"
|
||||
"@swc/helpers" "0.5.15"
|
||||
caniuse-lite "^1.0.30001579"
|
||||
postcss "8.4.31"
|
||||
styled-jsx "5.1.6"
|
||||
optionalDependencies:
|
||||
"@next/swc-darwin-arm64" "15.5.4"
|
||||
"@next/swc-darwin-x64" "15.5.4"
|
||||
"@next/swc-linux-arm64-gnu" "15.5.4"
|
||||
"@next/swc-linux-arm64-musl" "15.5.4"
|
||||
"@next/swc-linux-x64-gnu" "15.5.4"
|
||||
"@next/swc-linux-x64-musl" "15.5.4"
|
||||
"@next/swc-win32-arm64-msvc" "15.5.4"
|
||||
"@next/swc-win32-x64-msvc" "15.5.4"
|
||||
"@next/swc-darwin-arm64" "15.5.7"
|
||||
"@next/swc-darwin-x64" "15.5.7"
|
||||
"@next/swc-linux-arm64-gnu" "15.5.7"
|
||||
"@next/swc-linux-arm64-musl" "15.5.7"
|
||||
"@next/swc-linux-x64-gnu" "15.5.7"
|
||||
"@next/swc-linux-x64-musl" "15.5.7"
|
||||
"@next/swc-win32-arm64-msvc" "15.5.7"
|
||||
"@next/swc-win32-x64-msvc" "15.5.7"
|
||||
sharp "^0.34.3"
|
||||
|
||||
no-case@^3.0.4:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
environments:
|
||||
dev:
|
||||
values:
|
||||
- version: 4.0.0
|
||||
- version: 4.1.0
|
||||
feature:
|
||||
values:
|
||||
- version: 4.0.0
|
||||
- version: 4.1.0
|
||||
feature: ci
|
||||
domain: example.com
|
||||
imageTag: demo
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: docs
|
||||
version: 4.0.0
|
||||
version: 4.1.0
|
||||
appVersion: latest
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user