Compare commits
19 Commits
release/3.
...
documentat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0483a80784 | ||
|
|
2f010cf36d | ||
|
|
9d3c1eb9d5 | ||
|
|
08f3ceaf3f | ||
|
|
b1d033edc9 | ||
|
|
192fa76b54 | ||
|
|
b667200ebd | ||
|
|
294922f966 | ||
|
|
8b73aa3644 | ||
|
|
dd56a8abeb | ||
|
|
145c688830 | ||
|
|
950d215632 | ||
|
|
7d5cc4e84b | ||
|
|
3e5bcf96ea | ||
|
|
fe24c00178 | ||
|
|
aca334f81f | ||
|
|
2003e41c22 | ||
|
|
5ebdf4b4d4 | ||
|
|
35e771a1ce |
21
CHANGELOG.md
@@ -6,8 +6,14 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) create skeleton component for DocEditor #1491
|
||||
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) adapt custom blocks to new implementation #1375
|
||||
- ♻️(backend) increase user short_name field length
|
||||
|
||||
### Fixed
|
||||
@@ -15,6 +21,8 @@ and this project adheres to
|
||||
- 🐛(frontend) fix duplicate document entries in grid #1479
|
||||
- 🐛(frontend) show full nested doc names with ajustable bar #1456
|
||||
- 🐛(backend) fix trashbin list
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516
|
||||
|
||||
## [3.8.2] - 2025-10-17
|
||||
|
||||
@@ -43,10 +51,6 @@ and this project adheres to
|
||||
- ✨(frontend) add pdf block to the editor #1293
|
||||
- ✨List and restore deleted docs #1450
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) show full nested doc names with ajustable bar #1456
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) Refactor Auth component for improved redirection logic #1461
|
||||
@@ -89,11 +93,18 @@ and this project adheres to
|
||||
- ✨(frontend) load docs logo from public folder via url #1462
|
||||
- 🔧(keycloak) Fix https required issue in dev mode #1286
|
||||
|
||||
## Removed
|
||||
|
||||
- 🔥(frontend) remove custom DividerBlock ##1375
|
||||
|
||||
## [3.7.0] - 2025-09-12
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) add API route to fetch document content #1206
|
||||
- ✨(frontend) doc emojis improvements #1381
|
||||
- add an EmojiPicker in the document tree and document title
|
||||
- remove emoji buttons in menus
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -107,6 +118,8 @@ and this project adheres to
|
||||
- ✨unify tab focus style for better visual consistency #1341
|
||||
- ♿hide decorative icons, label menus, avoid accessible name… #1362
|
||||
- ♻️(tilt) use helm dev-backend chart
|
||||
- 🩹(frontend) on main pages do not display leading emoji as page icon #1381
|
||||
- 🩹(frontend) handle properly emojis in interlinking #1381
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/assets/export-template-tutorial/three-dots-copy-as-html.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -85,4 +85,44 @@ THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||
|
||||
### Example of JSON
|
||||
|
||||
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||
|
||||
----
|
||||
|
||||
# **Custom Export Templates** 📄
|
||||
|
||||
You can define custom export templates to add introductory content, such as headers or titles, to documents before exporting them as PDF, Docx, etc...
|
||||
|
||||
Export Templates are managed through the admin interface and can be selected by users during the export process.
|
||||
|
||||
### Benefits
|
||||
|
||||
This feature offers several advantages:
|
||||
* **Header customization** 📄: Add custom headers, titles, or branding to exported documents.
|
||||
* **No code changes required** 🔧: Templates are managed through the admin interface without needing developer intervention.
|
||||
* **Flexible content** 🌟: Use HTML to create headers that match your organization's style.
|
||||
|
||||
### Limitations ⚠️
|
||||
|
||||
- Currently, templates are only prepended to the document.
|
||||
More complex layouts are not supported at this time.
|
||||
- The `CSS` and `Description` fields are being ignored at this time.
|
||||
- <b>Due to technical conversion limitations, not all HTML can be converted to the internal format!</b>
|
||||
|
||||
### How to Use
|
||||
|
||||
1. Create the Template in a new document.
|
||||

|
||||
2. Copy it as HTML code using the `Copy as HTML` feature:
|
||||

|
||||
3. Log in to the admin interface at `/admin` (backend container).
|
||||
2. Create a new template:
|
||||

|
||||
- **Title**: Enter a descriptive name for the template.
|
||||
- **Code**: Paste the HTML content from step 2.
|
||||
- **Public**: Check this box to make the template available in the frontend export modal.
|
||||
- **Save** the template.
|
||||
|
||||
Once saved, users can select the template from the export modal in the frontend during the export process.
|
||||

|
||||
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"@hocuspocus/provider",
|
||||
"@hocuspocus/server",
|
||||
"docx",
|
||||
"fetch-mock",
|
||||
"node",
|
||||
|
||||
@@ -306,9 +306,12 @@ def test_api_documents_trashbin_empty_queryset_bug():
|
||||
|
||||
# Create some deleted documents owned by other users
|
||||
other_user = factories.UserFactory()
|
||||
factories.DocumentFactory(users=[(other_user, "owner")], deleted_at=timezone.now())
|
||||
factories.DocumentFactory(users=[(other_user, "owner")], deleted_at=timezone.now())
|
||||
factories.DocumentFactory(users=[(other_user, "owner")], deleted_at=timezone.now())
|
||||
item1 = factories.DocumentFactory(users=[(other_user, "owner")])
|
||||
item1.soft_delete()
|
||||
item2 = factories.DocumentFactory(users=[(other_user, "owner")])
|
||||
item2.soft_delete()
|
||||
item3 = factories.DocumentFactory(users=[(other_user, "owner")])
|
||||
item3.soft_delete()
|
||||
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "3.8.21"
|
||||
version = "3.8.2"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
} from './utils-common';
|
||||
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
|
||||
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
|
||||
import { createRootSubPage, navigateToPageFromTree } from './utils-sub-pages';
|
||||
import {
|
||||
createRootSubPage,
|
||||
getTreeRow,
|
||||
navigateToPageFromTree,
|
||||
} from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -676,10 +680,9 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await calloutBlock.locator('.inline-content').fill('example text');
|
||||
|
||||
await expect(page.locator('.bn-block').first()).toHaveAttribute(
|
||||
'data-background-color',
|
||||
'yellow',
|
||||
);
|
||||
await expect(
|
||||
page.locator('.bn-block-content[data-content-type="callout"]').first(),
|
||||
).toHaveAttribute('data-background-color', 'yellow');
|
||||
|
||||
const emojiButton = calloutBlock.getByRole('button');
|
||||
await expect(emojiButton).toHaveText('💡');
|
||||
@@ -703,10 +706,9 @@ test.describe('Doc Editor', () => {
|
||||
await page.locator('.mantine-Menu-dropdown > button').last().click();
|
||||
await page.locator('.bn-color-picker-dropdown > button').last().click();
|
||||
|
||||
await expect(page.locator('.bn-block').first()).toHaveAttribute(
|
||||
'data-background-color',
|
||||
'pink',
|
||||
);
|
||||
await expect(
|
||||
page.locator('.bn-block-content[data-content-type="callout"]').first(),
|
||||
).toHaveAttribute('data-background-color', 'pink');
|
||||
});
|
||||
|
||||
test('it checks interlink feature', async ({ page, browserName }) => {
|
||||
@@ -730,7 +732,13 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await verifyDocName(page, docChild2);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
const treeRow = await getTreeRow(page, docChild2);
|
||||
await treeRow.locator('.--docs--doc-icon').click();
|
||||
await page.getByRole('button', { name: '😀' }).first().click();
|
||||
|
||||
await navigateToPageFromTree({ page, title: docChild1 });
|
||||
|
||||
await openSuggestionMenu({ page });
|
||||
await page.getByText('Link a doc').first().click();
|
||||
|
||||
const input = page.locator(
|
||||
@@ -744,6 +752,16 @@ test.describe('Doc Editor', () => {
|
||||
await expect(searchContainer.getByText(docChild1)).toBeVisible();
|
||||
await expect(searchContainer.getByText(docChild2)).toBeVisible();
|
||||
|
||||
const searchContainerRow = searchContainer
|
||||
.getByRole('option')
|
||||
.filter({
|
||||
hasText: docChild2,
|
||||
})
|
||||
.first();
|
||||
|
||||
await expect(searchContainerRow).toContainText('😀');
|
||||
await expect(searchContainerRow.locator('svg').first()).toBeHidden();
|
||||
|
||||
await input.pressSequentially('-child');
|
||||
|
||||
await expect(searchContainer.getByText(docChild1)).toBeVisible();
|
||||
@@ -758,32 +776,30 @@ test.describe('Doc Editor', () => {
|
||||
await expect(searchContainer).toBeHidden();
|
||||
|
||||
// Wait for the interlink to be created and rendered
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
const editor = await getEditor({ page });
|
||||
|
||||
const interlink = editor.getByRole('button', {
|
||||
const interlinkChild2 = editor.getByRole('button', {
|
||||
name: docChild2,
|
||||
});
|
||||
|
||||
await expect(interlink).toBeVisible({ timeout: 10000 });
|
||||
await interlink.click();
|
||||
await expect(interlinkChild2).toBeVisible({ timeout: 10000 });
|
||||
await expect(interlinkChild2).toContainText('😀');
|
||||
await expect(interlinkChild2.locator('svg').first()).toBeHidden();
|
||||
await interlinkChild2.click();
|
||||
|
||||
await verifyDocName(page, docChild2);
|
||||
});
|
||||
|
||||
test('it checks interlink shortcut @', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const editor = page.locator('.bn-block-outer').last();
|
||||
await editor.click();
|
||||
await page.keyboard.press('@');
|
||||
|
||||
await expect(
|
||||
page.locator(
|
||||
"span[data-inline-content-type='interlinkingSearchInline'] input",
|
||||
),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press('@');
|
||||
await input.fill(docChild1);
|
||||
await searchContainer.getByText(docChild1).click();
|
||||
|
||||
const interlinkChild1 = editor.getByRole('button', {
|
||||
name: docChild1,
|
||||
});
|
||||
await expect(interlinkChild1).toBeVisible({ timeout: 10000 });
|
||||
await expect(interlinkChild1.locator('svg').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks multiple big doc scroll to the top', async ({
|
||||
@@ -844,10 +860,10 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(pdfBlock).toBeVisible();
|
||||
|
||||
await page.getByText('Add PDF').click();
|
||||
await page.getByText(/Add (PDF|file)/).click();
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByText('Upload file').click();
|
||||
await page.getByText(/Upload (PDF|file)/).click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test-pdf.pdf'));
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
import pdf from 'pdf-parse';
|
||||
import { pdf } from 'pdf-parse';
|
||||
|
||||
import {
|
||||
TestLanguage,
|
||||
@@ -59,20 +59,16 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
|
||||
await editor.click();
|
||||
await editor.locator('.bn-block-outer').last().fill('Hello');
|
||||
|
||||
const editor = await writeInEditor({ page, text: 'Hello' });
|
||||
await page.keyboard.press('Enter');
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await openSuggestionMenu({ page });
|
||||
await page.getByText('Page Break').click();
|
||||
|
||||
await expect(editor.locator('.bn-page-break')).toBeVisible();
|
||||
await expect(
|
||||
editor.locator('div[data-content-type="pageBreak"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('World');
|
||||
await writeInEditor({ page, text: 'World' });
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
@@ -92,9 +88,9 @@ test.describe('Doc Export', () => {
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
|
||||
expect(pdfData.numpages).toBe(2);
|
||||
expect(pdfData.text).toContain('\n\nHello\n\nWorld'); // This is the doc text
|
||||
expect(pdfData.info.Title).toBe(randomDoc);
|
||||
expect(pdfData.total).toBe(2);
|
||||
expect(pdfData.text).toContain('Hello\n\nWorld\n\n'); // This is the doc text
|
||||
expect(pdfData.info?.Title).toBe(randomDoc);
|
||||
});
|
||||
|
||||
test('it exports the doc to docx', async ({ page, browserName }) => {
|
||||
@@ -274,49 +270,6 @@ test.describe('Doc Export', () => {
|
||||
expect(pdfData.text).toContain('Hello World'); // This is the pdf text
|
||||
});
|
||||
|
||||
/**
|
||||
* We cannot assert the line break is visible in the pdf, but we can assert the
|
||||
* line break is visible in the editor and that the pdf is generated.
|
||||
*/
|
||||
test('it exports the doc with divider', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'export-divider', browserName, 1);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await editor.fill('Hello World');
|
||||
|
||||
// Trigger slash menu to show menu
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Add a horizontal line').click();
|
||||
|
||||
await expect(
|
||||
editor.locator('.bn-block-content[data-content-type="divider"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export the document',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('doc-open-modal-download-button'),
|
||||
).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
void page.getByTestId('doc-export-download-button').click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
expect(pdfData.text).toContain('Hello World');
|
||||
});
|
||||
|
||||
test('it exports the doc with multi columns', async ({
|
||||
page,
|
||||
browserName,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { mockedAccesses, mockedInvitations } from './utils-share';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
import { createRootSubPage, getTreeRow } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -65,25 +65,36 @@ test.describe('Doc Header', () => {
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'doc-update', browserName, 1);
|
||||
await createDoc(page, 'doc-update-emoji', browserName, 1);
|
||||
|
||||
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
|
||||
|
||||
// Top parent should not have emoji picker
|
||||
await expect(emojiPicker).toBeHidden();
|
||||
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-update-emoji-child',
|
||||
);
|
||||
|
||||
await verifyDocName(page, docChild);
|
||||
|
||||
await expect(emojiPicker).toBeVisible();
|
||||
await emojiPicker.click({
|
||||
delay: 100,
|
||||
});
|
||||
await page.getByRole('button', { name: '😀' }).first().click();
|
||||
await expect(emojiPicker).toHaveText('😀');
|
||||
|
||||
const docTitle = page.getByRole('textbox', { name: 'Document title' });
|
||||
await expect(docTitle).toBeVisible();
|
||||
await docTitle.fill('👍 Hello Emoji World');
|
||||
await docTitle.fill('Hello Emoji World');
|
||||
await docTitle.blur();
|
||||
await verifyDocName(page, '👍 Hello Emoji World');
|
||||
await verifyDocName(page, 'Hello Emoji World');
|
||||
|
||||
// Check the tree
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree.getByText('Hello Emoji World')).toBeVisible();
|
||||
await expect(docTree.getByTestId('doc-emoji-icon')).toBeVisible();
|
||||
await expect(docTree.getByTestId('doc-simple-icon')).toBeHidden();
|
||||
|
||||
await page.getByTestId('home-button').click();
|
||||
|
||||
// Check the documents grid
|
||||
const gridRow = await getGridRow(page, 'Hello Emoji World');
|
||||
await expect(gridRow.getByTestId('doc-emoji-icon')).toBeVisible();
|
||||
await expect(gridRow.getByTestId('doc-simple-icon')).toBeHidden();
|
||||
const row = await getTreeRow(page, 'Hello Emoji World');
|
||||
await expect(row.getByText('😀')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it deletes the doc', async ({ page, browserName }) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
randomName,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { writeInEditor } from './utils-editor';
|
||||
import { connectOtherUserToDoc, updateRoleUser } from './utils-share';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
@@ -240,11 +241,7 @@ test.describe('Document create member', () => {
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page
|
||||
.locator('.ProseMirror')
|
||||
.locator('.bn-block-outer')
|
||||
.last()
|
||||
.fill('Hello World');
|
||||
await writeInEditor({ page, text: 'Hello World' });
|
||||
|
||||
const docUrl = page.url();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { writeInEditor } from './utils-editor';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Doc Routing', () => {
|
||||
@@ -58,16 +59,23 @@ test.describe('Doc Routing', () => {
|
||||
|
||||
await createRootSubPage(page, browserName, '401-doc-child');
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
await writeInEditor({ page, text: 'Hello World' });
|
||||
|
||||
const responsePromise = page.route(
|
||||
/.*\/documents\/.*\/$|users\/me\/$/,
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
// When we quit a document, a PATCH request is sent to save the document.
|
||||
// We intercept this request to simulate a 401 error from the backend.
|
||||
// The GET request to users/me is also intercepted to simulate the user
|
||||
// being logged out when trying to fetch user info.
|
||||
// This way we can test the 401 error handling when saving the document
|
||||
if (
|
||||
request.method().includes('PATCH') ||
|
||||
request.method().includes('GET')
|
||||
(request.url().includes('/documents/') &&
|
||||
request.method().includes('PATCH')) ||
|
||||
(request.url().includes('/users/me/') &&
|
||||
request.method().includes('GET'))
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { addNewMember } from './utils-share';
|
||||
import { clickOnAddRootSubPage, createRootSubPage } from './utils-sub-pages';
|
||||
import {
|
||||
clickOnAddRootSubPage,
|
||||
createRootSubPage,
|
||||
getTreeRow,
|
||||
} from './utils-sub-pages';
|
||||
|
||||
test.describe('Doc Tree', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -298,6 +302,58 @@ test.describe('Doc Tree', () => {
|
||||
// Now test keyboard navigation on sub-document
|
||||
await expect(docTree.getByText(docChild)).toBeVisible();
|
||||
});
|
||||
|
||||
test('it updates the child icon from the tree', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [docParent] = await createDoc(
|
||||
page,
|
||||
'doc-child-emoji',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, docParent);
|
||||
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-child-emoji-child',
|
||||
);
|
||||
|
||||
const row = await getTreeRow(page, docChild);
|
||||
|
||||
// Check Remove emoji is not present initially
|
||||
await row.hover();
|
||||
const menu = row.getByText(`more_horiz`);
|
||||
await menu.click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Remove emoji' }),
|
||||
).toBeHidden();
|
||||
|
||||
// Close the menu
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Update the emoji from the tree
|
||||
await row.locator('.--docs--doc-icon').click();
|
||||
await page.getByRole('button', { name: '😀' }).first().click();
|
||||
|
||||
// Verify the emoji is updated in the tree and in the document title
|
||||
await expect(row.getByText('😀')).toBeVisible();
|
||||
|
||||
const titleEmojiPicker = page
|
||||
.locator('.--docs--doc-title')
|
||||
.getByRole('button');
|
||||
await expect(titleEmojiPicker).toHaveText('😀');
|
||||
|
||||
// Now remove the emoji using the new action
|
||||
await row.hover();
|
||||
await menu.click();
|
||||
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
|
||||
|
||||
await expect(row.getByText('😀')).toBeHidden();
|
||||
await expect(titleEmojiPicker).not.toHaveText('😀');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc Tree: Inheritance', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
keyCloakSignIn,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { writeInEditor } from './utils-editor';
|
||||
import { addNewMember, connectOtherUserToDoc } from './utils-share';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
@@ -151,18 +152,15 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page
|
||||
.locator('.ProseMirror')
|
||||
.locator('.bn-block-outer')
|
||||
.last()
|
||||
.fill('Hello World');
|
||||
await writeInEditor({ page, text: 'Hello World' });
|
||||
|
||||
const docUrl = page.url();
|
||||
|
||||
const { otherBrowserName, otherPage } = await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl,
|
||||
});
|
||||
const { otherBrowserName, otherPage, cleanup } =
|
||||
await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl,
|
||||
});
|
||||
|
||||
await expect(
|
||||
otherPage.getByText('Insufficient access rights to view the document.'),
|
||||
@@ -175,7 +173,11 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
await addNewMember(page, 0, 'Reader', otherBrowserName);
|
||||
|
||||
await otherPage.reload();
|
||||
await expect(otherPage.getByText('Hello World')).toBeVisible();
|
||||
await expect(otherPage.getByText('Hello World')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ test.describe('Header: Override configuration', () => {
|
||||
logo: {
|
||||
src: '/assets/logo-gouv.svg',
|
||||
width: '220px',
|
||||
alt: 'Gouvernement Logo',
|
||||
alt: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -168,8 +168,11 @@ test.describe('Header: Override configuration', () => {
|
||||
await page.goto('/');
|
||||
const header = page.locator('header').first();
|
||||
|
||||
await expect(header.getByAltText('Gouvernement Logo')).toBeVisible();
|
||||
const logoImage = header.getByTestId('header-icon-docs');
|
||||
await expect(logoImage).toBeVisible();
|
||||
|
||||
await expect(header.getByAltText('Docs')).toBeHidden();
|
||||
await expect(logoImage).not.toHaveAttribute('src', '/assets/icon-docs.svg');
|
||||
await expect(logoImage).toHaveAttribute('src', '/assets/logo-gouv.svg');
|
||||
await expect(logoImage).toHaveAttribute('alt', '');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { TestLanguage, createDoc, waitForLanguageSwitch } from './utils-common';
|
||||
import { openSuggestionMenu } from './utils-editor';
|
||||
|
||||
test.describe('Language', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -51,6 +52,7 @@ test.describe('Language', () => {
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
|
||||
await expect(languagePicker).toContainText('English');
|
||||
});
|
||||
|
||||
test('can switch language using only keyboard', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLanguageSwitch(page, TestLanguage.English);
|
||||
@@ -106,18 +108,18 @@ test.describe('Language', () => {
|
||||
}) => {
|
||||
await createDoc(page, 'doc-toolbar', browserName, 1);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
|
||||
// Trigger slash menu to show english menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
const editor = await openSuggestionMenu({ page });
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
|
||||
|
||||
await editor.click(); // close the menu
|
||||
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
|
||||
|
||||
// Change language to French
|
||||
await waitForLanguageSwitch(page, TestLanguage.French);
|
||||
|
||||
// Trigger slash menu to show french menu
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await openSuggestionMenu({ page });
|
||||
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
37
src/frontend/apps/e2e/__tests__/app-impress/types/pdf-parse.d.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Type definitions for pdf-parse library
|
||||
* The library doesn't export complete type definitions for the parsed PDF data
|
||||
*/
|
||||
|
||||
declare module 'pdf-parse' {
|
||||
export interface PdfInfo {
|
||||
Title?: string;
|
||||
Author?: string;
|
||||
Subject?: string;
|
||||
Keywords?: string;
|
||||
Creator?: string;
|
||||
Producer?: string;
|
||||
CreationDate?: string;
|
||||
ModDate?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PdfData {
|
||||
/** Total number of pages */
|
||||
numpages: number;
|
||||
/** Alias for numpages */
|
||||
total?: number;
|
||||
/** Extracted text content from the PDF */
|
||||
text: string;
|
||||
/** PDF metadata information */
|
||||
info?: PdfInfo;
|
||||
/** PDF metadata (alternative structure) */
|
||||
metadata?: unknown;
|
||||
/** PDF version */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export function pdf(buffer: Buffer): Promise<PdfData>;
|
||||
|
||||
export default pdf;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export const getEditor = async ({ page }: { page: Page }) => {
|
||||
export const openSuggestionMenu = async ({ page }: { page: Page }) => {
|
||||
const editor = await getEditor({ page });
|
||||
await editor.click();
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await writeInEditor({ page, text: '/' });
|
||||
|
||||
return editor;
|
||||
};
|
||||
@@ -22,6 +22,6 @@ export const writeInEditor = async ({
|
||||
text: string;
|
||||
}) => {
|
||||
const editor = await getEditor({ page });
|
||||
await editor.locator('.bn-block-outer').last().fill(text);
|
||||
await editor.locator('.bn-block-outer .bn-inline-content').last().fill(text);
|
||||
return editor;
|
||||
};
|
||||
|
||||
@@ -107,6 +107,20 @@ export const addChild = async ({
|
||||
return name;
|
||||
};
|
||||
|
||||
export const getTreeRow = async (page: Page, title: string) => {
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
const row = docTree
|
||||
.getByRole('treeitem')
|
||||
.filter({
|
||||
hasText: title,
|
||||
})
|
||||
.first();
|
||||
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
export const navigateToTopParentFromTree = async ({ page }: { page: Page }) => {
|
||||
await page.getByRole('link', { name: /Open root document/ }).click();
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "3.8.21",
|
||||
"version": "3.8.2",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:ui::chromium": "yarn test:ui --project=chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.55.0",
|
||||
"@playwright/test": "1.55.1",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.5",
|
||||
"eslint-plugin-docs": "*",
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"convert-stream": "1.0.2",
|
||||
"pdf-parse": "1.1.1"
|
||||
"pdf-parse": "2.1.7"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "3.8.21",
|
||||
"version": "3.8.2",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -19,46 +19,49 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.3",
|
||||
"@blocknote/code-block": "0.37.0",
|
||||
"@blocknote/core": "0.37.0",
|
||||
"@blocknote/mantine": "0.37.0",
|
||||
"@blocknote/react": "0.37.0",
|
||||
"@blocknote/xl-docx-exporter": "0.37.0",
|
||||
"@blocknote/xl-multi-column": "0.37.0",
|
||||
"@blocknote/xl-pdf-exporter": "0.37.0",
|
||||
"@blocknote/code-block": "0.41.1",
|
||||
"@blocknote/core": "0.41.1",
|
||||
"@blocknote/mantine": "0.41.1",
|
||||
"@blocknote/react": "0.41.1",
|
||||
"@blocknote/xl-docx-exporter": "0.41.1",
|
||||
"@blocknote/xl-multi-column": "0.41.1",
|
||||
"@blocknote/xl-pdf-exporter": "0.41.1",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@emoji-mart/react": "1.1.1",
|
||||
"@fontsource-variable/inter": "5.2.8",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.25",
|
||||
"@fontsource/material-icons": "5.2.5",
|
||||
"@fontsource/material-icons": "5.2.7",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.16.1",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@gouvfr-lasuite/ui-kit": "0.16.2",
|
||||
"@hocuspocus/provider": "3.3.0",
|
||||
"@mantine/core": "8.3.4",
|
||||
"@mantine/hooks": "8.3.4",
|
||||
"@openfun/cunningham-react": "3.2.3",
|
||||
"@react-pdf/renderer": "4.3.0",
|
||||
"@sentry/nextjs": "10.11.0",
|
||||
"@tanstack/react-query": "5.87.4",
|
||||
"@react-pdf/renderer": "4.3.1",
|
||||
"@sentry/nextjs": "10.17.0",
|
||||
"@tanstack/react-query": "5.90.2",
|
||||
"@tiptap/extensions": "3.4.4",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"docx": "9.5.0",
|
||||
"docx": "*",
|
||||
"emoji-datasource-apple": "16.0.0",
|
||||
"emoji-mart": "5.6.0",
|
||||
"emoji-regex": "10.5.0",
|
||||
"i18next": "25.5.2",
|
||||
"i18next": "25.5.3",
|
||||
"i18next-browser-languagedetector": "8.2.0",
|
||||
"idb": "8.0.3",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.7.2",
|
||||
"next": "15.5.3",
|
||||
"posthog-js": "1.264.2",
|
||||
"next": "15.5.4",
|
||||
"posthog-js": "1.271.0",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.12.1",
|
||||
"react-aria-components": "1.13.0",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.7.3",
|
||||
"react-i18next": "16.0.0",
|
||||
"react-intersection-observer": "9.16.0",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
"react-select": "5.10.2",
|
||||
@@ -70,9 +73,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.87.4",
|
||||
"@tanstack/react-query-devtools": "5.90.2",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.8.0",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/lodash": "4.17.20",
|
||||
@@ -80,22 +83,22 @@
|
||||
"@types/node": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"@vitejs/plugin-react": "5.0.2",
|
||||
"@vitejs/plugin-react": "5.0.4",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "10.0.0",
|
||||
"dotenv": "17.2.2",
|
||||
"cross-env": "10.1.0",
|
||||
"dotenv": "17.2.3",
|
||||
"eslint-plugin-docs": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jsdom": "26.1.0",
|
||||
"jsdom": "27.0.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.6.2",
|
||||
"stylelint": "16.24.0",
|
||||
"stylelint-config-standard": "39.0.0",
|
||||
"stylelint": "16.25.0",
|
||||
"stylelint-config-standard": "39.0.1",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.2.4",
|
||||
"webpack": "5.101.3",
|
||||
"webpack": "5.102.0",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
<svg width="18" height="23" viewBox="0 0 18 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.57954 5.93282C4.44486 5.79021 4.37793 5.61484 4.37793 5.41299C4.37793 5.21112 4.44495 5.03795 4.58154 4.90136C4.72456 4.75833 4.90437 4.6875 5.11367 4.6875H12.8964C13.0991 4.6875 13.2722 4.75857 13.408 4.90133C13.5508 5.03718 13.6219 5.21027 13.6219 5.41299C13.6219 5.61613 13.5506 5.7918 13.409 5.93387C13.273 6.07732 13.0996 6.14873 12.8964 6.14873H5.11367C4.90437 6.14873 4.72456 6.0779 4.58154 5.93487L4.57954 5.93282ZM4.57954 9.51144C4.44486 9.36882 4.37793 9.19346 4.37793 8.9916C4.37793 8.78973 4.44495 8.61656 4.58154 8.47997C4.72456 8.33695 4.90437 8.26611 5.11367 8.26611H12.8964C13.0991 8.26611 13.2722 8.33719 13.408 8.47995C13.5508 8.61579 13.6219 8.78888 13.6219 8.9916C13.6219 9.19475 13.5506 9.37042 13.409 9.51249C13.273 9.65593 13.0996 9.72734 12.8964 9.72734H5.11367C4.90437 9.72734 4.72456 9.65651 4.58154 9.51348L4.57954 9.51144ZM4.57954 13.1003C4.44561 12.9585 4.37793 12.7869 4.37793 12.5907C4.37793 12.3831 4.44414 12.204 4.57954 12.0606L4.58151 12.0586C4.72453 11.9155 4.90437 11.8447 5.11367 11.8447H8.79482C9.00363 11.8447 9.18092 11.9153 9.3177 12.0596C9.46006 12.2024 9.53057 12.3819 9.53057 12.5907C9.53057 12.7887 9.45812 12.9609 9.31671 13.1024C9.17936 13.2397 9.00235 13.306 8.79482 13.306H5.11367C4.90609 13.306 4.72695 13.2397 4.58358 13.1043L4.57954 13.1003ZM1.09476 0.851519C1.65317 0.285946 2.47955 0.0117188 3.55508 0.0117188H14.4447C15.52 0.0117188 16.3433 0.28583 16.895 0.851748C17.4529 1.41698 17.7234 2.24966 17.7234 3.33145V18.8866C17.7234 19.975 17.4531 20.8082 16.8945 21.3668C16.3427 21.9256 15.5196 22.1961 14.4447 22.1961H3.55508C2.47988 22.1961 1.65367 21.9255 1.09521 21.367C0.543652 20.8083 0.276367 19.9747 0.276367 18.8866V3.33145C0.276367 2.24984 0.543796 1.41679 1.09476 0.851519ZM15.5624 20.0351C15.2958 20.3085 14.8959 20.4452 14.3627 20.4452H3.63711C3.10391 20.4452 2.70059 20.3085 2.42715 20.0351L2.49875 19.9652L2.49786 19.9643L2.42715 20.0351C2.16055 19.7616 2.02725 19.3686 2.02725 18.8559V3.36221C2.02725 2.84951 2.16055 2.45645 2.42715 2.18301C2.70059 1.90273 3.10391 1.7626 3.63711 1.7626H14.3627C14.8959 1.7626 15.2958 1.90273 15.5624 2.18301C15.8358 2.45645 15.9726 2.84951 15.9726 3.36221V18.8559C15.9726 19.3686 15.8358 19.7616 15.5624 20.0351Z" fill="#3A3A3A"/>
|
||||
<svg viewBox="0 0 18 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.57954 5.93282C4.44486 5.79021 4.37793 5.61484 4.37793 5.41299C4.37793 5.21112 4.44495 5.03795 4.58154 4.90136C4.72456 4.75833 4.90437 4.6875 5.11367 4.6875H12.8964C13.0991 4.6875 13.2722 4.75857 13.408 4.90133C13.5508 5.03718 13.6219 5.21027 13.6219 5.41299C13.6219 5.61613 13.5506 5.7918 13.409 5.93387C13.273 6.07732 13.0996 6.14873 12.8964 6.14873H5.11367C4.90437 6.14873 4.72456 6.0779 4.58154 5.93487L4.57954 5.93282ZM4.57954 9.51144C4.44486 9.36882 4.37793 9.19346 4.37793 8.9916C4.37793 8.78973 4.44495 8.61656 4.58154 8.47997C4.72456 8.33695 4.90437 8.26611 5.11367 8.26611H12.8964C13.0991 8.26611 13.2722 8.33719 13.408 8.47995C13.5508 8.61579 13.6219 8.78888 13.6219 8.9916C13.6219 9.19475 13.5506 9.37042 13.409 9.51249C13.273 9.65593 13.0996 9.72734 12.8964 9.72734H5.11367C4.90437 9.72734 4.72456 9.65651 4.58154 9.51348L4.57954 9.51144ZM4.57954 13.1003C4.44561 12.9585 4.37793 12.7869 4.37793 12.5907C4.37793 12.3831 4.44414 12.204 4.57954 12.0606L4.58151 12.0586C4.72453 11.9155 4.90437 11.8447 5.11367 11.8447H8.79482C9.00363 11.8447 9.18092 11.9153 9.3177 12.0596C9.46006 12.2024 9.53057 12.3819 9.53057 12.5907C9.53057 12.7887 9.45812 12.9609 9.31671 13.1024C9.17936 13.2397 9.00235 13.306 8.79482 13.306H5.11367C4.90609 13.306 4.72695 13.2397 4.58358 13.1043L4.57954 13.1003ZM1.09476 0.851519C1.65317 0.285946 2.47955 0.0117188 3.55508 0.0117188H14.4447C15.52 0.0117188 16.3433 0.28583 16.895 0.851748C17.4529 1.41698 17.7234 2.24966 17.7234 3.33145V18.8866C17.7234 19.975 17.4531 20.8082 16.8945 21.3668C16.3427 21.9256 15.5196 22.1961 14.4447 22.1961H3.55508C2.47988 22.1961 1.65367 21.9255 1.09521 21.367C0.543652 20.8083 0.276367 19.9747 0.276367 18.8866V3.33145C0.276367 2.24984 0.543796 1.41679 1.09476 0.851519ZM15.5624 20.0351C15.2958 20.3085 14.8959 20.4452 14.3627 20.4452H3.63711C3.10391 20.4452 2.70059 20.3085 2.42715 20.0351L2.49875 19.9652L2.49786 19.9643L2.42715 20.0351C2.16055 19.7616 2.02725 19.3686 2.02725 18.8559V3.36221C2.02725 2.84951 2.16055 2.45645 2.42715 2.18301C2.70059 1.90273 3.10391 1.7626 3.63711 1.7626H14.3627C14.8959 1.7626 15.2958 1.90273 15.5624 2.18301C15.8358 2.45645 15.9726 2.84951 15.9726 3.36221V18.8559C15.9726 19.3686 15.8358 19.7616 15.5624 20.0351Z"
|
||||
fill="#3A3A3A"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,6 +1,6 @@
|
||||
<svg viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3.40918 4.70117C3.28613 4.70117 3.18359 4.66016 3.10156 4.57812C3.02409 4.49609 2.98535 4.39583 2.98535 4.27734C2.98535 4.15885 3.02409 4.06087 3.10156 3.9834C3.18359 3.90137 3.28613 3.86035 3.40918 3.86035H8.59766C8.71615 3.86035 8.81413 3.90137 8.8916 3.9834C8.97363 4.06087 9.01465 4.15885 9.01465 4.27734C9.01465 4.39583 8.97363 4.49609 8.8916 4.57812C8.81413 4.66016 8.71615 4.70117 8.59766 4.70117H3.40918ZM3.40918 7.08691C3.28613 7.08691 3.18359 7.0459 3.10156 6.96387C3.02409 6.88184 2.98535 6.78158 2.98535 6.66309C2.98535 6.5446 3.02409 6.44661 3.10156 6.36914C3.18359 6.28711 3.28613 6.24609 3.40918 6.24609H8.59766C8.71615 6.24609 8.81413 6.28711 8.8916 6.36914C8.97363 6.44661 9.01465 6.5446 9.01465 6.66309C9.01465 6.78158 8.97363 6.88184 8.8916 6.96387C8.81413 7.0459 8.71615 7.08691 8.59766 7.08691H3.40918ZM3.40918 9.47266C3.28613 9.47266 3.18359 9.43392 3.10156 9.35645C3.02409 9.27441 2.98535 9.17643 2.98535 9.0625C2.98535 8.93945 3.02409 8.83691 3.10156 8.75488C3.18359 8.67285 3.28613 8.63184 3.40918 8.63184H5.86328C5.98633 8.63184 6.08659 8.67285 6.16406 8.75488C6.24609 8.83691 6.28711 8.93945 6.28711 9.0625C6.28711 9.17643 6.24609 9.27441 6.16406 9.35645C6.08659 9.43392 5.98633 9.47266 5.86328 9.47266H3.40918ZM0.250977 13.2598V2.88965C0.250977 2.17871 0.426432 1.64323 0.777344 1.2832C1.13281 0.923177 1.66374 0.743164 2.37012 0.743164H9.62988C10.3363 0.743164 10.8649 0.923177 11.2158 1.2832C11.5713 1.64323 11.749 2.17871 11.749 2.88965V13.2598C11.749 13.9753 11.5713 14.5107 11.2158 14.8662C10.8649 15.2217 10.3363 15.3994 9.62988 15.3994H2.37012C1.66374 15.3994 1.13281 15.2217 0.777344 14.8662C0.426432 14.5107 0.250977 13.9753 0.250977 13.2598ZM1.35156 13.2393C1.35156 13.5811 1.44043 13.8431 1.61816 14.0254C1.80046 14.2077 2.06934 14.2988 2.4248 14.2988H9.5752C9.93066 14.2988 10.1973 14.2077 10.375 14.0254C10.5573 13.8431 10.6484 13.5811 10.6484 13.2393V2.91016C10.6484 2.56836 10.5573 2.30632 10.375 2.12402C10.1973 1.93717 9.93066 1.84375 9.5752 1.84375H2.4248C2.06934 1.84375 1.80046 1.93717 1.61816 2.12402C1.44043 2.30632 1.35156 2.56836 1.35156 2.91016V13.2393Z"
|
||||
fill="#8585F6"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
@@ -1,6 +1,7 @@
|
||||
import { codeBlock } from '@blocknote/code-block';
|
||||
import { codeBlockOptions } from '@blocknote/code-block';
|
||||
import {
|
||||
BlockNoteSchema,
|
||||
createCodeBlockSpec,
|
||||
defaultBlockSpecs,
|
||||
defaultInlineContentSpecs,
|
||||
withPageBreak,
|
||||
@@ -16,7 +17,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, TextErrors } from '@/components';
|
||||
import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management';
|
||||
import {
|
||||
Doc,
|
||||
useIsCollaborativeEditable,
|
||||
useProviderStore,
|
||||
} from '@/docs/doc-management';
|
||||
import { useAuth } from '@/features/auth';
|
||||
|
||||
import {
|
||||
@@ -36,7 +41,6 @@ import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import {
|
||||
AccessibleImageBlock,
|
||||
CalloutBlock,
|
||||
DividerBlock,
|
||||
PdfBlock,
|
||||
UploadLoaderBlock,
|
||||
} from './custom-blocks';
|
||||
@@ -53,11 +57,11 @@ const baseBlockNoteSchema = withPageBreak(
|
||||
BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
callout: CalloutBlock,
|
||||
divider: DividerBlock,
|
||||
image: AccessibleImageBlock,
|
||||
pdf: PdfBlock,
|
||||
uploadLoader: UploadLoaderBlock,
|
||||
callout: CalloutBlock(),
|
||||
codeBlock: createCodeBlockSpec(codeBlockOptions),
|
||||
image: AccessibleImageBlock(),
|
||||
pdf: PdfBlock(),
|
||||
uploadLoader: UploadLoaderBlock(),
|
||||
},
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
@@ -79,9 +83,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
const { user } = useAuth();
|
||||
const { setEditor } = useEditorStore();
|
||||
const { t } = useTranslation();
|
||||
const { isSynced: isConnectedToCollabServer } = useProviderStore();
|
||||
|
||||
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
|
||||
const isConnectedToCollabServer = provider.isSynced;
|
||||
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
|
||||
const isDeletedDoc = !!doc.deleted_at;
|
||||
|
||||
@@ -98,7 +102,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
|
||||
const editor: DocsBlockNoteEditor = useCreateBlockNote(
|
||||
{
|
||||
codeBlock,
|
||||
collaboration: {
|
||||
provider,
|
||||
fragment: provider.document.getXmlFragment('document-store'),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { combineByGroup, filterSuggestionItems } from '@blocknote/core';
|
||||
import {
|
||||
DefaultReactSuggestionItem,
|
||||
SuggestionMenuController,
|
||||
getDefaultReactSlashMenuItems,
|
||||
getPageBreakReactSlashMenuItems,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
|
||||
import {
|
||||
getCalloutReactSlashMenuItems,
|
||||
getDividerReactSlashMenuItems,
|
||||
getPdfReactSlashMenuItems,
|
||||
} from './custom-blocks';
|
||||
import { useGetInterlinkingMenuItems } from './custom-inline-content';
|
||||
@@ -42,29 +42,29 @@ export const BlockNoteSuggestionMenu = () => {
|
||||
const getSlashMenuItems = useMemo(() => {
|
||||
// We insert it after the "Code Block" item to have the interlinking block displayed after the basic blocks
|
||||
const defaultMenu = getDefaultReactSlashMenuItems(editor);
|
||||
const index = defaultMenu.findIndex(
|
||||
(item) => item.aliases?.includes('code') && item.aliases?.includes('pre'),
|
||||
|
||||
const combinedMenu = combineByGroup(
|
||||
defaultMenu,
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
getMultiColumnSlashMenuItems?.(editor) || [],
|
||||
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
|
||||
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
);
|
||||
|
||||
const index = combinedMenu.findIndex(
|
||||
(item) =>
|
||||
(item as DefaultReactSuggestionItem & { key: string })?.key ===
|
||||
'callout',
|
||||
);
|
||||
|
||||
const newSlashMenuItems = [
|
||||
...defaultMenu.slice(0, index + 1),
|
||||
...combinedMenu.slice(0, index + 1),
|
||||
...getInterlinkingMenuItems(editor, t),
|
||||
...defaultMenu.slice(index + 1),
|
||||
...combinedMenu.slice(index + 1),
|
||||
];
|
||||
|
||||
return async (query: string) =>
|
||||
Promise.resolve(
|
||||
filterSuggestionItems(
|
||||
combineByGroup(
|
||||
newSlashMenuItems,
|
||||
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
getMultiColumnSlashMenuItems?.(editor) || [],
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
|
||||
),
|
||||
query,
|
||||
),
|
||||
);
|
||||
Promise.resolve(filterSuggestionItems(newSlashMenuItems, query));
|
||||
}, [basicBlocksName, editor, getInterlinkingMenuItems, t, fileBlocksName]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
/**
|
||||
* We added some custom logic to the original Blocknote FileDownloadButton
|
||||
* component to handle our file download use case.
|
||||
*
|
||||
* Original source:
|
||||
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx
|
||||
*/
|
||||
|
||||
import {
|
||||
BlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
checkBlockIsFileBlock,
|
||||
checkBlockIsFileBlockWithPlaceholder,
|
||||
blockHasType,
|
||||
} from '@blocknote/core';
|
||||
import {
|
||||
useBlockNoteEditor,
|
||||
@@ -41,7 +48,9 @@ export const FileDownloadButton = ({
|
||||
|
||||
const block = selectedBlocks[0];
|
||||
|
||||
if (checkBlockIsFileBlock(block, editor)) {
|
||||
if (
|
||||
blockHasType(block, editor, block.type, { url: 'string', name: 'string' })
|
||||
) {
|
||||
return block;
|
||||
}
|
||||
|
||||
@@ -53,6 +62,7 @@ export const FileDownloadButton = ({
|
||||
editor.focus();
|
||||
|
||||
const url = fileBlock.props.url as string;
|
||||
const name = fileBlock.props.name as string | undefined;
|
||||
|
||||
/**
|
||||
* If not hosted on our domain, means not a file uploaded by the user,
|
||||
@@ -76,16 +86,12 @@ export const FileDownloadButton = ({
|
||||
|
||||
if (!url.includes('-unsafe')) {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
downloadFile(
|
||||
blob,
|
||||
fileBlock.props.name || url.split('/').pop() || 'file',
|
||||
);
|
||||
downloadFile(blob, name || url.split('/').pop() || 'file');
|
||||
} else {
|
||||
const onConfirm = async () => {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
|
||||
const baseName =
|
||||
fileBlock.props.name || url.split('/').pop() || 'file';
|
||||
const baseName = name || url.split('/').pop() || 'file';
|
||||
|
||||
const regFindLastDot = /(\.[^/.]+)$/;
|
||||
const unsafeName = baseName.includes('.')
|
||||
@@ -100,11 +106,7 @@ export const FileDownloadButton = ({
|
||||
}
|
||||
}, [editor, fileBlock, open]);
|
||||
|
||||
if (
|
||||
!fileBlock ||
|
||||
checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) ||
|
||||
!Components
|
||||
) {
|
||||
if (!fileBlock || fileBlock.props.url === '' || !Components) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { Box, Loading, Text, TextErrors } from '@/components';
|
||||
import { DocHeader, DocVersionHeader } from '@/docs/doc-header/';
|
||||
import {
|
||||
Doc,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/docs/doc-management';
|
||||
import { TableContent } from '@/docs/doc-table-content/';
|
||||
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { BlockNoteEditor, BlockNoteEditorVersion } from './BlockNoteEditor';
|
||||
@@ -25,10 +26,18 @@ interface DocEditorProps {
|
||||
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const isVersion = !!versionId && typeof versionId === 'string';
|
||||
const { provider } = useProviderStore();
|
||||
const { provider, isReady } = useProviderStore();
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
const isProviderReady = isReady && provider;
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
useEffect(() => {
|
||||
if (isProviderReady) {
|
||||
setIsSkeletonVisible(false);
|
||||
}
|
||||
}, [isProviderReady, setIsSkeletonVisible]);
|
||||
|
||||
if (!isProviderReady) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,16 +9,18 @@ interface EmojiPickerProps {
|
||||
emojiData: EmojiMartData;
|
||||
onClickOutside: () => void;
|
||||
onEmojiSelect: ({ native }: { native: string }) => void;
|
||||
withOverlay?: boolean;
|
||||
}
|
||||
|
||||
export const EmojiPicker = ({
|
||||
emojiData,
|
||||
onClickOutside,
|
||||
onEmojiSelect,
|
||||
withOverlay = false,
|
||||
}: EmojiPickerProps) => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
const pickerContent = (
|
||||
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
|
||||
<Picker
|
||||
data={emojiData}
|
||||
@@ -30,4 +32,27 @@ export const EmojiPicker = ({
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (withOverlay) {
|
||||
return (
|
||||
<>
|
||||
{/* Overlay transparent pour fermer en cliquant à l'extérieur */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: 999,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
onClick={onClickOutside}
|
||||
/>
|
||||
{pickerContent}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return pickerContent;
|
||||
};
|
||||
|
||||
@@ -1,43 +1,62 @@
|
||||
/**
|
||||
* AccessibleImageBlock.tsx
|
||||
*
|
||||
* This file defines a custom BlockNote block specification for an accessible image block.
|
||||
* It extends the default image block to ensure compliance with accessibility standards,
|
||||
* specifically RGAA 1.9.1, by using <figure> and <figcaption> elements when a caption is provided.
|
||||
*
|
||||
* The accessible image block ensures that:
|
||||
* - Images with captions are wrapped in <figure> and <figcaption> elements.
|
||||
* - The <img> element has an appropriate alt attribute based on the caption.
|
||||
* - Accessibility attributes such as role and aria-label are added for better screen reader support.
|
||||
* - Images without captions have alt="" and are marked as decorative with aria-hidden="true".
|
||||
*
|
||||
* This implementation leverages BlockNote's existing image block functionality while enhancing it for accessibility.
|
||||
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/core/src/blocks/Image/block.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
BlockFromConfig,
|
||||
BlockNoteEditor,
|
||||
BlockSchemaWithBlock,
|
||||
ImageOptions,
|
||||
InlineContentSchema,
|
||||
InlineContentSchemaFromSpecs,
|
||||
StyleSchema,
|
||||
createBlockSpec,
|
||||
imageBlockConfig,
|
||||
createImageBlockConfig,
|
||||
defaultInlineContentSpecs,
|
||||
imageParse,
|
||||
imageRender,
|
||||
imageToExternalHTML,
|
||||
} from '@blocknote/core';
|
||||
import { t } from 'i18next';
|
||||
|
||||
type ImageBlockConfig = typeof imageBlockConfig;
|
||||
type CreateImageBlockConfig = ReturnType<typeof createImageBlockConfig>;
|
||||
|
||||
export const accessibleImageRender = (
|
||||
block: BlockFromConfig<ImageBlockConfig, InlineContentSchema, StyleSchema>,
|
||||
editor: BlockNoteEditor<
|
||||
BlockSchemaWithBlock<ImageBlockConfig['type'], ImageBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>,
|
||||
) => {
|
||||
const imageRenderComputed = imageRender(block, editor);
|
||||
const dom = imageRenderComputed.dom;
|
||||
const imgSelector = dom.querySelector('img');
|
||||
|
||||
const withCaption =
|
||||
block.props.caption && dom.querySelector('.bn-file-caption');
|
||||
|
||||
const accessibleImageWithCaption = () => {
|
||||
imgSelector?.setAttribute('alt', block.props.caption);
|
||||
imgSelector?.removeAttribute('aria-hidden');
|
||||
imgSelector?.setAttribute('tabindex', '0');
|
||||
export const accessibleImageRender =
|
||||
(config: ImageOptions) =>
|
||||
(
|
||||
block: BlockFromConfig<
|
||||
CreateImageBlockConfig,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>,
|
||||
editor: BlockNoteEditor<
|
||||
Record<'image', CreateImageBlockConfig>,
|
||||
InlineContentSchemaFromSpecs<typeof defaultInlineContentSpecs>,
|
||||
StyleSchema
|
||||
>,
|
||||
) => {
|
||||
const imageRenderComputed = imageRender(config);
|
||||
const dom = imageRenderComputed(block, editor).dom;
|
||||
const imgSelector = dom.querySelector('img');
|
||||
|
||||
// Fix RGAA 1.9.1: Convert to figure/figcaption structure if caption exists
|
||||
const captionElement = dom.querySelector('.bn-file-caption');
|
||||
const accessibleImageWithCaption = () => {
|
||||
imgSelector?.setAttribute('alt', block.props.caption);
|
||||
imgSelector?.removeAttribute('aria-hidden');
|
||||
imgSelector?.setAttribute('tabindex', '0');
|
||||
|
||||
if (captionElement) {
|
||||
const figureElement = document.createElement('figure');
|
||||
|
||||
// Copy all attributes from the original div
|
||||
@@ -76,32 +95,36 @@ export const accessibleImageRender = (
|
||||
...imageRenderComputed,
|
||||
dom: figureElement,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const accessibleImage = () => {
|
||||
imgSelector?.setAttribute('alt', '');
|
||||
imgSelector?.setAttribute('role', 'presentation');
|
||||
imgSelector?.setAttribute('aria-hidden', 'true');
|
||||
imgSelector?.setAttribute('tabindex', '-1');
|
||||
|
||||
return {
|
||||
...imageRenderComputed,
|
||||
dom,
|
||||
};
|
||||
};
|
||||
|
||||
const withCaption =
|
||||
block.props.caption && dom.querySelector('.bn-file-caption');
|
||||
|
||||
// Set accessibility attributes for the image
|
||||
return withCaption ? accessibleImageWithCaption() : accessibleImage();
|
||||
};
|
||||
|
||||
const accessibleImage = () => {
|
||||
imgSelector?.setAttribute('alt', '');
|
||||
imgSelector?.setAttribute('role', 'presentation');
|
||||
imgSelector?.setAttribute('aria-hidden', 'true');
|
||||
imgSelector?.setAttribute('tabindex', '-1');
|
||||
};
|
||||
|
||||
// Set accessibility attributes for the image
|
||||
const result = withCaption ? accessibleImageWithCaption() : accessibleImage();
|
||||
|
||||
// Return the result if accessibleImageWithCaption created a figure, otherwise return original
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
...imageRenderComputed,
|
||||
dom,
|
||||
};
|
||||
};
|
||||
|
||||
export const AccessibleImageBlock = createBlockSpec(imageBlockConfig, {
|
||||
render: accessibleImageRender,
|
||||
parse: imageParse,
|
||||
toExternalHTML: imageToExternalHTML,
|
||||
});
|
||||
export const AccessibleImageBlock = createBlockSpec(
|
||||
createImageBlockConfig,
|
||||
(config) => ({
|
||||
meta: {
|
||||
fileBlockAccept: ['image/*'],
|
||||
},
|
||||
render: accessibleImageRender(config),
|
||||
parse: imageParse(config),
|
||||
toExternalHTML: imageToExternalHTML(config),
|
||||
runsBefore: ['file'],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
|
||||
import {
|
||||
BlockConfig,
|
||||
BlockNoDefaults,
|
||||
BlockNoteEditor,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
defaultProps,
|
||||
insertOrUpdateBlock,
|
||||
} from '@blocknote/core';
|
||||
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
|
||||
import { Box, BoxButton, Icon } from '@/components';
|
||||
|
||||
@@ -12,90 +19,131 @@ import { EmojiPicker } from '../EmojiPicker';
|
||||
|
||||
import emojidata from './initEmojiCallout';
|
||||
|
||||
const CalloutBlockStyle = createGlobalStyle`
|
||||
.bn-block-content[data-content-type="callout"][data-background-color] {
|
||||
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
`;
|
||||
|
||||
type CreateCalloutBlockConfig = BlockConfig<
|
||||
'callout',
|
||||
{
|
||||
textAlignment: typeof defaultProps.textAlignment;
|
||||
backgroundColor: typeof defaultProps.backgroundColor;
|
||||
emoji: { default: '💡' };
|
||||
},
|
||||
'inline'
|
||||
>;
|
||||
|
||||
interface CalloutComponentProps {
|
||||
block: BlockNoDefaults<
|
||||
Record<'callout', CreateCalloutBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
editor: BlockNoteEditor<
|
||||
Record<'callout', CreateCalloutBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
const CalloutComponent = ({
|
||||
block,
|
||||
editor,
|
||||
contentRef,
|
||||
}: CalloutComponentProps) => {
|
||||
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const toggleEmojiPicker = (e: React.MouseEvent) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenEmojiPicker(!openEmojiPicker);
|
||||
};
|
||||
|
||||
const onClickOutside = () => setOpenEmojiPicker(false);
|
||||
|
||||
const onEmojiSelect = ({ native }: { native: string }) => {
|
||||
editor.updateBlock(block, { props: { emoji: native } });
|
||||
setOpenEmojiPicker(false);
|
||||
};
|
||||
|
||||
// Temporary: sets a yellow background color to a callout block when added by
|
||||
// the user, while keeping the colors menu on the drag handler usable for
|
||||
// this custom block.
|
||||
useEffect(() => {
|
||||
if (!block.content.length && block.props.backgroundColor === 'default') {
|
||||
// Delay the update to avoid interfering with the block insertion process
|
||||
setTimeout(() => {
|
||||
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
|
||||
}, 0);
|
||||
}
|
||||
}, [block, editor]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$padding="1rem"
|
||||
$gap="0.625rem"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$css={css`
|
||||
flex-grow: 1;
|
||||
`}
|
||||
>
|
||||
<CalloutBlockStyle />
|
||||
<BoxButton
|
||||
contentEditable={false}
|
||||
onClick={toggleEmojiPicker}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
cursor: ${isEditable ? 'pointer' : 'default'};
|
||||
${isEditable &&
|
||||
`
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
`}
|
||||
$align="center"
|
||||
$width="28px"
|
||||
$radius="4px"
|
||||
>
|
||||
{block.props.emoji}
|
||||
</BoxButton>
|
||||
|
||||
{openEmojiPicker && (
|
||||
<EmojiPicker
|
||||
emojiData={emojidata}
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
withOverlay={true}
|
||||
/>
|
||||
)}
|
||||
<Box as="p" className="inline-content" ref={contentRef} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const CalloutBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'callout',
|
||||
propSchema: {
|
||||
textAlignment: defaultProps.textAlignment,
|
||||
backgroundColor: defaultProps.backgroundColor,
|
||||
backgroundColor: { default: 'default' as const },
|
||||
emoji: { default: '💡' },
|
||||
},
|
||||
content: 'inline',
|
||||
},
|
||||
{
|
||||
render: ({ block, editor, contentRef }) => {
|
||||
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const toggleEmojiPicker = (e: React.MouseEvent) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenEmojiPicker(!openEmojiPicker);
|
||||
};
|
||||
|
||||
const onClickOutside = () => setOpenEmojiPicker(false);
|
||||
|
||||
const onEmojiSelect = ({ native }: { native: string }) => {
|
||||
editor.updateBlock(block, { props: { emoji: native } });
|
||||
setOpenEmojiPicker(false);
|
||||
};
|
||||
|
||||
// Temporary: sets a yellow background color to a callout block when added by
|
||||
// the user, while keeping the colors menu on the drag handler usable for
|
||||
// this custom block.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!block.content.length &&
|
||||
block.props.backgroundColor === 'default'
|
||||
) {
|
||||
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
|
||||
}
|
||||
}, [block, editor]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$padding="1rem"
|
||||
$gap="0.625rem"
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
>
|
||||
<BoxButton
|
||||
contentEditable={false}
|
||||
onClick={toggleEmojiPicker}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
cursor: ${isEditable ? 'pointer' : 'default'};
|
||||
${isEditable &&
|
||||
`
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
`}
|
||||
$align="center"
|
||||
$height="28px"
|
||||
$width="28px"
|
||||
$radius="4px"
|
||||
>
|
||||
{block.props.emoji}
|
||||
</BoxButton>
|
||||
|
||||
{openEmojiPicker && (
|
||||
<EmojiPicker
|
||||
emojiData={emojidata}
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
/>
|
||||
)}
|
||||
<Box as="p" className="inline-content" ref={contentRef} />
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
render: ({ block, editor, contentRef }) => (
|
||||
<CalloutComponent block={block} editor={editor} contentRef={contentRef} />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -105,6 +153,7 @@ export const getCalloutReactSlashMenuItems = (
|
||||
group: string,
|
||||
) => [
|
||||
{
|
||||
key: 'callout',
|
||||
title: t('Callout'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { Box, Icon } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
|
||||
export const DividerBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'divider',
|
||||
propSchema: {},
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
render: () => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="hr"
|
||||
$width="100%"
|
||||
$background={colorsTokens['greyscale-300']}
|
||||
$margin="1rem 0"
|
||||
$css={`border: 1px solid ${colorsTokens['greyscale-300']};`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const getDividerReactSlashMenuItems = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
group: string,
|
||||
) => [
|
||||
{
|
||||
title: t('Divider'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
type: 'divider',
|
||||
});
|
||||
},
|
||||
aliases: ['divider', 'hr', 'horizontal rule', 'line', 'separator'],
|
||||
group,
|
||||
icon: <Icon iconName="remove" $size="18px" />,
|
||||
subtext: t('Add a horizontal line'),
|
||||
},
|
||||
];
|
||||
@@ -1,11 +1,19 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { insertOrUpdateBlock } from '@blocknote/core';
|
||||
import {
|
||||
BlockConfig,
|
||||
BlockNoDefaults,
|
||||
BlockNoteEditor,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
insertOrUpdateBlock,
|
||||
} from '@blocknote/core';
|
||||
import * as locales from '@blocknote/core/locales';
|
||||
import {
|
||||
AddFileButton,
|
||||
ResizableFileBlockWrapper,
|
||||
createReactBlockSpec,
|
||||
} from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
@@ -20,57 +28,106 @@ const PDFBlockStyle = createGlobalStyle`
|
||||
`;
|
||||
|
||||
type FileBlockEditor = Parameters<typeof AddFileButton>[0]['editor'];
|
||||
type FileBlockBlock = Parameters<typeof AddFileButton>[0]['block'];
|
||||
|
||||
type CreatePDFBlockConfig = BlockConfig<
|
||||
'pdf',
|
||||
{
|
||||
backgroundColor: { default: 'default' };
|
||||
caption: { default: '' };
|
||||
name: { default: '' };
|
||||
previewWidth: { default: undefined; type: 'number' };
|
||||
showPreview: { default: true };
|
||||
textAlignment: { default: 'left' };
|
||||
url: { default: '' };
|
||||
},
|
||||
'none'
|
||||
>;
|
||||
|
||||
interface PdfBlockComponentProps {
|
||||
block: BlockNoDefaults<
|
||||
Record<'callout', CreatePDFBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
editor: BlockNoteEditor<
|
||||
Record<'pdf', CreatePDFBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}
|
||||
|
||||
const PdfBlockComponent = ({
|
||||
editor,
|
||||
block,
|
||||
contentRef,
|
||||
}: PdfBlockComponentProps) => {
|
||||
const pdfUrl = block.props.url;
|
||||
const { i18n, t } = useTranslation();
|
||||
const lang = i18n.resolvedLanguage;
|
||||
|
||||
useEffect(() => {
|
||||
if (lang && locales[lang as keyof typeof locales]) {
|
||||
locales[lang as keyof typeof locales].file_blocks.add_button_text['pdf'] =
|
||||
t('Add PDF');
|
||||
(
|
||||
locales[lang as keyof typeof locales].file_panel.embed
|
||||
.embed_button as Record<string, string>
|
||||
)['pdf'] = t('Add PDF');
|
||||
(
|
||||
locales[lang as keyof typeof locales].file_panel.upload
|
||||
.file_placeholder as Record<string, string>
|
||||
)['pdf'] = t('Upload PDF');
|
||||
}
|
||||
}, [lang, t]);
|
||||
|
||||
return (
|
||||
<Box ref={contentRef} className="bn-file-block-content-wrapper">
|
||||
<PDFBlockStyle />
|
||||
<ResizableFileBlockWrapper
|
||||
buttonIcon={
|
||||
<Icon iconName="upload" $size="24px" $css="line-height: normal;" />
|
||||
}
|
||||
block={block as unknown as FileBlockBlock}
|
||||
editor={editor as unknown as FileBlockEditor}
|
||||
>
|
||||
<Box
|
||||
className="bn-visual-media"
|
||||
role="presentation"
|
||||
as="embed"
|
||||
$width="100%"
|
||||
$height="450px"
|
||||
type="application/pdf"
|
||||
src={pdfUrl}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
onClick={() => editor.setTextCursorPosition(block)}
|
||||
/>
|
||||
</ResizableFileBlockWrapper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const PdfBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'pdf',
|
||||
content: 'none',
|
||||
propSchema: {
|
||||
name: { default: '' as const },
|
||||
url: { default: '' as const },
|
||||
backgroundColor: { default: 'default' as const },
|
||||
caption: { default: '' as const },
|
||||
showPreview: { default: true },
|
||||
name: { default: '' as const },
|
||||
previewWidth: { default: undefined, type: 'number' },
|
||||
showPreview: { default: true },
|
||||
textAlignment: { default: 'left' as const },
|
||||
url: { default: '' as const },
|
||||
},
|
||||
isFileBlock: true,
|
||||
fileBlockAccept: ['application/pdf'],
|
||||
},
|
||||
{
|
||||
render: ({ editor, block, contentRef }) => {
|
||||
const { t } = useTranslation();
|
||||
const pdfUrl = block.props.url;
|
||||
|
||||
return (
|
||||
<Box ref={contentRef} className="bn-file-block-content-wrapper">
|
||||
<PDFBlockStyle />
|
||||
<ResizableFileBlockWrapper
|
||||
buttonIcon={
|
||||
<Icon
|
||||
iconName="upload"
|
||||
$size="24px"
|
||||
$css="line-height: normal;"
|
||||
/>
|
||||
}
|
||||
block={block}
|
||||
editor={editor as unknown as FileBlockEditor}
|
||||
buttonText={t('Add PDF')}
|
||||
>
|
||||
<Box
|
||||
className="bn-visual-media"
|
||||
role="presentation"
|
||||
as="embed"
|
||||
$width="100%"
|
||||
$height="450px"
|
||||
type="application/pdf"
|
||||
src={pdfUrl}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
onClick={() => editor.setTextCursorPosition(block)}
|
||||
/>
|
||||
</ResizableFileBlockWrapper>
|
||||
</Box>
|
||||
);
|
||||
meta: {
|
||||
fileBlockAccept: ['application/pdf'],
|
||||
},
|
||||
render: (props) => <PdfBlockComponent {...props} />,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from './AccessibleImageBlock';
|
||||
export * from './CalloutBlock';
|
||||
export * from './DividerBlock';
|
||||
export { default as emojidata } from './initEmojiCallout';
|
||||
export * from './PdfBlock';
|
||||
export * from './UploadLoaderBlock';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { css } from 'styled-components';
|
||||
import { BoxButton, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
|
||||
import { useDoc } from '@/docs/doc-management';
|
||||
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management';
|
||||
|
||||
export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
|
||||
{
|
||||
@@ -52,6 +52,8 @@ interface LinkSelectedProps {
|
||||
}
|
||||
const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -78,9 +80,21 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
`}
|
||||
>
|
||||
<SelectedPageIcon width={11.5} />
|
||||
<Text $weight="500" spellCheck="false" $size="16px" $display="inline">
|
||||
{title}
|
||||
{emoji ? (
|
||||
<Text $size="16px">{emoji}</Text>
|
||||
) : (
|
||||
<SelectedPageIcon width={11.5} color={colorsTokens['primary-400']} />
|
||||
)}
|
||||
<Text
|
||||
$weight="500"
|
||||
spellCheck="false"
|
||||
$size="16px"
|
||||
$display="inline"
|
||||
$css={css`
|
||||
margin-left: 2px;
|
||||
`}
|
||||
>
|
||||
{titleWithoutEmoji}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
);
|
||||
|
||||
@@ -47,6 +47,7 @@ export const getInterlinkinghMenuItems = (
|
||||
createPage: () => void,
|
||||
) => [
|
||||
{
|
||||
key: 'link-doc',
|
||||
title: t('Link a doc'),
|
||||
onItemClick: () => {
|
||||
editor.insertInlineContent([
|
||||
@@ -65,6 +66,7 @@ export const getInterlinkinghMenuItems = (
|
||||
subtext: t('Link this doc to another doc'),
|
||||
},
|
||||
{
|
||||
key: 'new-sub-doc',
|
||||
title: t('New sub-doc'),
|
||||
onItemClick: createPage,
|
||||
aliases: ['new sub-doc'],
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import FoundPageIcon from '@/docs/doc-editor/assets/doc-found.svg';
|
||||
import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg';
|
||||
import {
|
||||
getEmojiAndTitle,
|
||||
useCreateChildDocTree,
|
||||
useDocStore,
|
||||
useTrans,
|
||||
@@ -43,17 +44,19 @@ const inputStyle = css`
|
||||
`;
|
||||
|
||||
type SearchPageProps = {
|
||||
trigger: string;
|
||||
trigger: '/' | '@';
|
||||
updateInlineContent: (
|
||||
update: PartialCustomInlineContentFromConfig<
|
||||
{
|
||||
type: string;
|
||||
type: 'interlinkingSearchInline';
|
||||
propSchema: {
|
||||
disabled: {
|
||||
default: boolean;
|
||||
default: false;
|
||||
values: [true, false];
|
||||
};
|
||||
trigger: {
|
||||
default: string;
|
||||
default: '/';
|
||||
values: ['/', '@'];
|
||||
};
|
||||
};
|
||||
content: 'styled';
|
||||
@@ -234,35 +237,56 @@ export const SearchPage = ({
|
||||
|
||||
editor.focus();
|
||||
}}
|
||||
renderElement={(doc) => (
|
||||
<QuickSearchItemContent
|
||||
left={
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="0.6rem"
|
||||
$align="center"
|
||||
$padding={{ vertical: '0.5rem', horizontal: '0.2rem' }}
|
||||
$width="100%"
|
||||
>
|
||||
<FoundPageIcon />
|
||||
<Text
|
||||
$size="14px"
|
||||
$color="var(--c--theme--colors--greyscale-1000)"
|
||||
spellCheck="false"
|
||||
renderElement={(doc) => {
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(
|
||||
doc.title || untitledDocument,
|
||||
);
|
||||
|
||||
return (
|
||||
<QuickSearchItemContent
|
||||
left={
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="0.2rem"
|
||||
$align="center"
|
||||
$padding={{ vertical: '0.5rem', horizontal: '0.2rem' }}
|
||||
$width="100%"
|
||||
>
|
||||
{doc.title}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
right={
|
||||
<Icon
|
||||
iconName="keyboard_return"
|
||||
$variation="600"
|
||||
spellCheck="false"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
$css={css`
|
||||
width: 24px;
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
>
|
||||
{emoji ? (
|
||||
<Text $size="18px">{emoji}</Text>
|
||||
) : (
|
||||
<FoundPageIcon
|
||||
width="100%"
|
||||
style={{ maxHeight: '24px' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
$size="14px"
|
||||
$color="var(--c--theme--colors--greyscale-1000)"
|
||||
spellCheck="false"
|
||||
>
|
||||
{titleWithoutEmoji}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
right={
|
||||
<Icon
|
||||
iconName="keyboard_return"
|
||||
$variation="600"
|
||||
spellCheck="false"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<QuickSearchGroup
|
||||
group={{
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './DocEditor';
|
||||
export * from './EmojiPicker';
|
||||
export * from './custom-blocks/';
|
||||
|
||||
@@ -16,6 +16,9 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure long placeholder text is truncated with ellipsis
|
||||
*/
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
text-overflow: ellipsis;
|
||||
@@ -29,14 +32,16 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bn-side-menu .mantine-UnstyledButton-root svg {
|
||||
color: #767676 !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure images with unsafe URLs are not interactive
|
||||
*/
|
||||
img.bn-visual-media[src*='-unsafe'] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collaboration cursor styles
|
||||
*/
|
||||
.collaboration-cursor-custom__base {
|
||||
position: relative;
|
||||
}
|
||||
@@ -87,6 +92,9 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
|
||||
.bn-side-menu[data-block-type='divider'] {
|
||||
height: 38px;
|
||||
}
|
||||
.bn-side-menu .mantine-UnstyledButton-root svg {
|
||||
color: #767676 !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callout, Paragraph and Heading blocks
|
||||
@@ -94,21 +102,17 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
|
||||
.bn-block {
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
|
||||
.bn-block-outer {
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
|
||||
.bn-block[data-background-color] > .bn-block-content {
|
||||
.bn-block > .bn-block-content[data-background-color] {
|
||||
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
|
||||
.bn-inline-content {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
@@ -146,6 +150,16 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
|
||||
border-left: 4px solid var(--c--theme--colors--greyscale-300);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divider
|
||||
*/
|
||||
[data-content-type='divider'] hr {
|
||||
background: #d3d2cf;
|
||||
margin: 1rem 0;
|
||||
width: 100%;
|
||||
border: 1px solid #d3d2cf;
|
||||
}
|
||||
}
|
||||
|
||||
& .bn-block-outer:not(:first-child) {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Paragraph } from 'docx';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsExporterDocx } from '../types';
|
||||
|
||||
export const blockMappingDividerDocx: DocsExporterDocx['mappings']['blockMapping']['divider'] =
|
||||
() => {
|
||||
const { colorsTokens } = useCunninghamTheme.getState();
|
||||
|
||||
return new Paragraph({
|
||||
spacing: {
|
||||
before: 200,
|
||||
},
|
||||
border: {
|
||||
top: {
|
||||
color: colorsTokens['greyscale-300'],
|
||||
size: 1,
|
||||
style: 'single',
|
||||
space: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Text } from '@react-pdf/renderer';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsExporterPDF } from '../types';
|
||||
|
||||
export const blockMappingDividerPDF: DocsExporterPDF['mappings']['blockMapping']['divider'] =
|
||||
() => {
|
||||
const { colorsTokens } = useCunninghamTheme.getState();
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
marginVertical: 10,
|
||||
backgroundColor: colorsTokens['greyscale-300'],
|
||||
height: '2px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -100,16 +100,13 @@ function blockPropsToStyles(
|
||||
? undefined
|
||||
: {
|
||||
type: ShadingType.SOLID,
|
||||
color:
|
||||
colors[
|
||||
props.backgroundColor as keyof typeof colors
|
||||
].background.slice(1),
|
||||
color: colors[props.backgroundColor].background.slice(1),
|
||||
},
|
||||
run:
|
||||
props.textColor === 'default' || !props.textColor
|
||||
? undefined
|
||||
: {
|
||||
color: colors[props.textColor as keyof typeof colors].text.slice(1),
|
||||
color: colors[props.textColor].text.slice(1),
|
||||
},
|
||||
alignment:
|
||||
!props.textAlignment || props.textAlignment === 'left'
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export * from './calloutDocx';
|
||||
export * from './calloutPDF';
|
||||
export * from './dividerDocx';
|
||||
export * from './dividerPDF';
|
||||
export * from './headingPDF';
|
||||
export * from './imageDocx';
|
||||
export * from './imagePDF';
|
||||
|
||||
@@ -92,15 +92,11 @@ export const blockMappingTablePDF: DocsExporterPDF['mappings']['blockMapping']['
|
||||
color:
|
||||
cellProps.textColor === 'default'
|
||||
? undefined
|
||||
: options.colors[
|
||||
cellProps.textColor as keyof typeof options.colors
|
||||
].text,
|
||||
: options.colors[cellProps.textColor].text,
|
||||
backgroundColor:
|
||||
cellProps.backgroundColor === 'default'
|
||||
? undefined
|
||||
: options.colors[
|
||||
cellProps.backgroundColor as keyof typeof options.colors
|
||||
].background,
|
||||
: options.colors[cellProps.backgroundColor].background,
|
||||
textAlign: cellProps.textAlignment,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter';
|
||||
import { Paragraph } from 'docx';
|
||||
import { TextRun } from 'docx';
|
||||
|
||||
import {
|
||||
blockMappingCalloutDocx,
|
||||
blockMappingDividerDocx,
|
||||
blockMappingImageDocx,
|
||||
blockMappingQuoteDocx,
|
||||
blockMappingUploadLoaderDocx,
|
||||
@@ -16,9 +15,8 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
|
||||
blockMapping: {
|
||||
...docxDefaultSchemaMappings.blockMapping,
|
||||
callout: blockMappingCalloutDocx,
|
||||
divider: blockMappingDividerDocx,
|
||||
// We're using the file block mapping for PDF blocks
|
||||
// The types don't match exactly but the implementation is compatible
|
||||
// We're reusing the file block mapping for PDF blocks; both share the same
|
||||
// implementation signature, so we can reuse the handler directly.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
pdf: docxDefaultSchemaMappings.blockMapping.file as any,
|
||||
quote: blockMappingQuoteDocx,
|
||||
@@ -27,7 +25,7 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
|
||||
},
|
||||
inlineContentMapping: {
|
||||
...docxDefaultSchemaMappings.inlineContentMapping,
|
||||
interlinkingSearchInline: () => new Paragraph(''),
|
||||
interlinkingSearchInline: () => new TextRun(''),
|
||||
interlinkingLinkInline: inlineContentMappingInterlinkingLinkDocx,
|
||||
},
|
||||
styleMapping: {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter';
|
||||
|
||||
import {
|
||||
blockMappingCalloutPDF,
|
||||
blockMappingDividerPDF,
|
||||
blockMappingHeadingPDF,
|
||||
blockMappingImagePDF,
|
||||
blockMappingParagraphPDF,
|
||||
@@ -21,7 +20,6 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
|
||||
heading: blockMappingHeadingPDF,
|
||||
image: blockMappingImagePDF,
|
||||
paragraph: blockMappingParagraphPDF,
|
||||
divider: blockMappingDividerPDF,
|
||||
quote: blockMappingQuotePDF,
|
||||
table: blockMappingTablePDF,
|
||||
// We're using the file block mapping for PDF blocks
|
||||
|
||||
@@ -76,16 +76,13 @@ export function docxBlockPropsToStyles(
|
||||
? undefined
|
||||
: {
|
||||
type: ShadingType.SOLID,
|
||||
color:
|
||||
colors[
|
||||
props.backgroundColor as keyof typeof colors
|
||||
].background.slice(1),
|
||||
color: colors[props.backgroundColor].background.slice(1),
|
||||
},
|
||||
run:
|
||||
props.textColor === 'default' || !props.textColor
|
||||
? undefined
|
||||
: {
|
||||
color: colors[props.textColor as keyof typeof colors].text.slice(1),
|
||||
color: colors[props.textColor].text.slice(1),
|
||||
},
|
||||
alignment:
|
||||
!props.textAlignment || props.textAlignment === 'left'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { Tooltip } from '@openfun/cunningham-react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -8,14 +7,16 @@ import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
DocIcon,
|
||||
getEmojiAndTitle,
|
||||
useDocStore,
|
||||
useDocTitleUpdate,
|
||||
useDocUtils,
|
||||
useIsCollaborativeEditable,
|
||||
useTrans,
|
||||
useUpdateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import { useBroadcastStore, useResponsiveStore } from '@/stores';
|
||||
import SimpleFileIcon from '@/features/docs/doc-management/assets/simple-document.svg';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
interface DocTitleProps {
|
||||
doc: Doc;
|
||||
@@ -49,52 +50,77 @@ export const DocTitleText = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { emoji } = getEmojiAndTitle(doc.title ?? '');
|
||||
|
||||
return (
|
||||
<Tooltip content={t('Document emoji')} aria-hidden={true} placement="top">
|
||||
<Box
|
||||
$css={css`
|
||||
padding: 4px;
|
||||
padding-top: 3px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: ${colorsTokens['greyscale-100']};
|
||||
border-radius: 4px;
|
||||
}
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
`}
|
||||
>
|
||||
<DocIcon
|
||||
withEmojiPicker={doc.abilities.partial_update}
|
||||
docId={doc.id}
|
||||
title={doc.title}
|
||||
emoji={emoji}
|
||||
$size="25px"
|
||||
defaultIcon={
|
||||
<SimpleFileIcon
|
||||
width="25px"
|
||||
height="25px"
|
||||
aria-hidden="true"
|
||||
aria-label={t('Simple document icon')}
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const [titleDisplay, setTitleDisplay] = useState(doc.title);
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const { isTopRoot } = useDocUtils(doc);
|
||||
const { untitledDocument } = useTrans();
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title ?? '');
|
||||
const [titleDisplay, setTitleDisplay] = useState(
|
||||
isTopRoot ? doc.title : titleWithoutEmoji,
|
||||
);
|
||||
|
||||
const { broadcast } = useBroadcastStore();
|
||||
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
|
||||
onSuccess(updatedDoc) {
|
||||
// Broadcast to every user connected to the document
|
||||
broadcast(`${KEY_DOC}-${updatedDoc.id}`);
|
||||
|
||||
if (!treeContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (treeContext.root?.id === updatedDoc.id) {
|
||||
treeContext?.setRoot(updatedDoc);
|
||||
} else {
|
||||
treeContext?.treeData.updateNode(updatedDoc.id, updatedDoc);
|
||||
}
|
||||
},
|
||||
});
|
||||
const { updateDocTitle } = useDocTitleUpdate();
|
||||
|
||||
const handleTitleSubmit = useCallback(
|
||||
(inputText: string) => {
|
||||
let sanitizedTitle = inputText.trim();
|
||||
sanitizedTitle = sanitizedTitle.replace(/(\r\n|\n|\r)/gm, '');
|
||||
|
||||
// When blank we set to untitled
|
||||
if (!sanitizedTitle) {
|
||||
setTitleDisplay('');
|
||||
}
|
||||
|
||||
// If mutation we update
|
||||
if (sanitizedTitle !== doc.title) {
|
||||
if (isTopRoot) {
|
||||
const sanitizedTitle = updateDocTitle(doc, inputText);
|
||||
setTitleDisplay(sanitizedTitle);
|
||||
updateDoc({ id: doc.id, title: sanitizedTitle });
|
||||
} else {
|
||||
const sanitizedTitle = updateDocTitle(
|
||||
doc,
|
||||
emoji ? `${emoji} ${inputText}` : inputText,
|
||||
);
|
||||
const { titleWithoutEmoji: sanitizedTitleWithoutEmoji } =
|
||||
getEmojiAndTitle(sanitizedTitle);
|
||||
|
||||
setTitleDisplay(sanitizedTitleWithoutEmoji);
|
||||
}
|
||||
},
|
||||
[doc.id, doc.title, updateDoc],
|
||||
[updateDocTitle, doc, emoji, isTopRoot],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -105,43 +131,62 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTitleDisplay(doc.title);
|
||||
}, [doc]);
|
||||
setTitleDisplay(isTopRoot ? doc.title : titleWithoutEmoji);
|
||||
}, [doc.title, isTopRoot, titleWithoutEmoji]);
|
||||
|
||||
return (
|
||||
<Tooltip content={t('Rename')} aria-hidden={true} placement="top">
|
||||
<Box
|
||||
as="span"
|
||||
role="textbox"
|
||||
className="--docs--doc-title-input"
|
||||
contentEditable
|
||||
defaultValue={titleDisplay || undefined}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
suppressContentEditableWarning={true}
|
||||
aria-label={`${t('Document title')}`}
|
||||
aria-multiline={false}
|
||||
onBlurCapture={(event) =>
|
||||
handleTitleSubmit(event.target.textContent || '')
|
||||
}
|
||||
$color={colorsTokens['greyscale-1000']}
|
||||
$minHeight="40px"
|
||||
$padding={{ right: 'big' }}
|
||||
$css={css`
|
||||
&[contenteditable='true']:empty:not(:focus):before {
|
||||
content: '${untitledDocument}';
|
||||
color: grey;
|
||||
pointer-events: none;
|
||||
font-style: italic;
|
||||
<Box
|
||||
className="--docs--doc-title"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap={spacingsTokens['xs']}
|
||||
$minHeight="40px"
|
||||
>
|
||||
{isTopRoot && (
|
||||
<SimpleFileIcon
|
||||
width="25px"
|
||||
height="25px"
|
||||
aria-hidden="true"
|
||||
aria-label={t('Simple document icon')}
|
||||
color={colorsTokens['primary-500']}
|
||||
style={{ flexShrink: '0' }}
|
||||
/>
|
||||
)}
|
||||
{!isTopRoot && <DocTitleEmojiPicker doc={doc} />}
|
||||
|
||||
<Tooltip content={t('Rename')} aria-hidden={true} placement="top">
|
||||
<Box
|
||||
as="span"
|
||||
role="textbox"
|
||||
className="--docs--doc-title-input"
|
||||
contentEditable
|
||||
defaultValue={titleDisplay || undefined}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
suppressContentEditableWarning={true}
|
||||
aria-label={`${t('Document title')}`}
|
||||
aria-multiline={false}
|
||||
onBlurCapture={(event) =>
|
||||
handleTitleSubmit(event.target.textContent || '')
|
||||
}
|
||||
font-size: ${isDesktop
|
||||
? css`var(--c--theme--font--sizes--h2)`
|
||||
: css`var(--c--theme--font--sizes--sm)`};
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
`}
|
||||
>
|
||||
{titleDisplay}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
$color={colorsTokens['greyscale-1000']}
|
||||
$padding={{ right: 'big' }}
|
||||
$css={css`
|
||||
&[contenteditable='true']:empty:not(:focus):before {
|
||||
content: '${untitledDocument}';
|
||||
color: grey;
|
||||
pointer-events: none;
|
||||
font-style: italic;
|
||||
}
|
||||
font-size: ${isDesktop
|
||||
? css`var(--c--theme--font--sizes--h2)`
|
||||
: css`var(--c--theme--font--sizes--sm)`};
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
`}
|
||||
>
|
||||
{titleDisplay}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
ModalRemoveDoc,
|
||||
getEmojiAndTitle,
|
||||
useCopyDocLink,
|
||||
useCreateFavoriteDoc,
|
||||
useDeleteFavoriteDoc,
|
||||
useDocTitleUpdate,
|
||||
useDocUtils,
|
||||
useDuplicateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
@@ -49,7 +51,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const { isChild } = useDocUtils(doc);
|
||||
const { isChild, isTopRoot } = useDocUtils(doc);
|
||||
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
|
||||
@@ -83,6 +85,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
});
|
||||
}, [selectHistoryModal.isOpen, queryClient]);
|
||||
|
||||
// Emoji Management
|
||||
const { emoji } = getEmojiAndTitle(doc.title ?? '');
|
||||
const { updateDocEmoji } = useDocTitleUpdate();
|
||||
|
||||
const options: DropdownMenuOption[] = [
|
||||
...(isSmallMobile
|
||||
? [
|
||||
@@ -118,6 +124,17 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
},
|
||||
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
|
||||
},
|
||||
...(emoji && doc.abilities.partial_update && !isTopRoot
|
||||
? [
|
||||
{
|
||||
label: t('Remove emoji'),
|
||||
icon: 'emoji_emotions',
|
||||
callback: () => {
|
||||
updateDocEmoji(doc.id, doc.title ?? '', '');
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t('Version history'),
|
||||
icon: 'history',
|
||||
|
||||
@@ -9,4 +9,3 @@ export * from './useDuplicateDoc';
|
||||
export * from './useRestoreDoc';
|
||||
export * from './useSubDocs';
|
||||
export * from './useUpdateDoc';
|
||||
export * from './useUpdateDocLink';
|
||||
|
||||
@@ -20,9 +20,10 @@ export const createDoc = async (): Promise<Doc> => {
|
||||
|
||||
interface CreateDocProps {
|
||||
onSuccess: (data: Doc) => void;
|
||||
onError?: (error: APIError) => void;
|
||||
}
|
||||
|
||||
export function useCreateDoc({ onSuccess }: CreateDocProps) {
|
||||
export function useCreateDoc({ onSuccess, onError }: CreateDocProps) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Doc, APIError>({
|
||||
mutationFn: createDoc,
|
||||
@@ -32,5 +33,8 @@ export function useCreateDoc({ onSuccess }: CreateDocProps) {
|
||||
});
|
||||
onSuccess(data);
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
|
||||
|
||||
return await duplicateDoc(variables);
|
||||
},
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
@@ -89,14 +89,14 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
void options?.onSuccess?.(data, variables, context);
|
||||
void options?.onSuccess?.(data, variables, onMutateResult, context);
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
toast(t('Failed to duplicate the document...'), VariantType.ERROR, {
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
void options?.onError?.(error, variables, context);
|
||||
void options?.onError?.(error, variables, onMutateResult, context);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,19 +33,19 @@ export const useRemoveDoc = ({
|
||||
return useMutation<void, APIError, RemoveDocProps>({
|
||||
mutationFn: removeDoc,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, context);
|
||||
void options.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,19 +36,19 @@ export const useRestoreDoc = ({
|
||||
return useMutation<void, APIError, RestoreDocProps>({
|
||||
mutationFn: restoreDoc,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, context);
|
||||
void options.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
|
||||
return useMutation<Doc, APIError, UpdateDocParams>({
|
||||
mutationFn: updateDoc,
|
||||
...queryConfig,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
queryConfig?.listInvalideQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
@@ -50,10 +50,10 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
|
||||
});
|
||||
|
||||
if (queryConfig?.onSuccess) {
|
||||
void queryConfig.onSuccess(data, variables, context);
|
||||
void queryConfig.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
// If error it means the user is probably not allowed to edit the doc
|
||||
// so we invalidate the canEdit query to update the UI accordingly
|
||||
void queryClient.invalidateQueries({
|
||||
@@ -61,7 +61,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
|
||||
});
|
||||
|
||||
if (queryConfig?.onError) {
|
||||
queryConfig.onError(error, variables, context);
|
||||
queryConfig.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<svg
|
||||
width="33"
|
||||
height="33"
|
||||
viewBox="0 0 33 33"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
@@ -1,8 +1,18 @@
|
||||
import { Text, TextType } from '@/components';
|
||||
import { MouseEvent, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { BoxButton, Icon, TextType } from '@/components';
|
||||
import { EmojiPicker, emojidata } from '@/docs/doc-editor/';
|
||||
|
||||
import { useDocTitleUpdate } from '../hooks/useDocTitleUpdate';
|
||||
|
||||
type DocIconProps = TextType & {
|
||||
emoji?: string | null;
|
||||
defaultIcon: React.ReactNode;
|
||||
docId?: string;
|
||||
title?: string;
|
||||
onEmojiUpdate?: (emoji: string) => void;
|
||||
withEmojiPicker?: boolean;
|
||||
};
|
||||
|
||||
export const DocIcon = ({
|
||||
@@ -11,22 +21,102 @@ export const DocIcon = ({
|
||||
$size = 'sm',
|
||||
$variation = '1000',
|
||||
$weight = '400',
|
||||
docId,
|
||||
title,
|
||||
onEmojiUpdate,
|
||||
withEmojiPicker = false,
|
||||
...textProps
|
||||
}: DocIconProps) => {
|
||||
if (!emoji) {
|
||||
return <>{defaultIcon}</>;
|
||||
const { updateDocEmoji } = useDocTitleUpdate();
|
||||
|
||||
const iconRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [openEmojiPicker, setOpenEmojiPicker] = useState<boolean>(false);
|
||||
const [pickerPosition, setPickerPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
}>({ top: 0, left: 0 });
|
||||
|
||||
if (!withEmojiPicker && !emoji) {
|
||||
return defaultIcon;
|
||||
}
|
||||
|
||||
const toggleEmojiPicker = (e: MouseEvent) => {
|
||||
if (withEmojiPicker) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (!openEmojiPicker && iconRef.current) {
|
||||
const rect = iconRef.current.getBoundingClientRect();
|
||||
setPickerPosition({
|
||||
top: rect.bottom + window.scrollY + 8,
|
||||
left: rect.left + window.scrollX,
|
||||
});
|
||||
}
|
||||
|
||||
setOpenEmojiPicker(!openEmojiPicker);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmojiSelect = ({ native }: { native: string }) => {
|
||||
setOpenEmojiPicker(false);
|
||||
|
||||
// Update document emoji if docId is provided
|
||||
if (docId && title !== undefined) {
|
||||
updateDocEmoji(docId, title ?? '', native);
|
||||
}
|
||||
|
||||
// Call the optional callback
|
||||
onEmojiUpdate?.(native);
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
setOpenEmojiPicker(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Text
|
||||
{...textProps}
|
||||
$size={$size}
|
||||
$variation={$variation}
|
||||
$weight={$weight}
|
||||
aria-hidden="true"
|
||||
data-testid="doc-emoji-icon"
|
||||
>
|
||||
{emoji}
|
||||
</Text>
|
||||
<>
|
||||
<BoxButton
|
||||
className="--docs--doc-icon"
|
||||
ref={iconRef}
|
||||
onClick={toggleEmojiPicker}
|
||||
color="tertiary-text"
|
||||
>
|
||||
{!emoji ? (
|
||||
defaultIcon
|
||||
) : (
|
||||
<Icon
|
||||
{...textProps}
|
||||
iconName={emoji}
|
||||
$size={$size}
|
||||
$variation={$variation}
|
||||
$weight={$weight}
|
||||
aria-hidden="true"
|
||||
data-testid="doc-emoji-icon"
|
||||
>
|
||||
{emoji}
|
||||
</Icon>
|
||||
)}
|
||||
</BoxButton>
|
||||
{openEmojiPicker &&
|
||||
createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: pickerPosition.top,
|
||||
left: pickerPosition.left,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<EmojiPicker
|
||||
emojiData={emojidata}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
onClickOutside={handleClickOutside}
|
||||
withOverlay={true}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
@@ -8,6 +9,7 @@ import img403 from '@/assets/icons/icon-403.png';
|
||||
import { Box, Icon, Loading, StyledLink, Text } from '@/components';
|
||||
import { ButtonAccessRequest } from '@/docs/doc-share';
|
||||
import { useDocAccessRequests } from '@/docs/doc-share/api/useDocAccessRequest';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
width: fit-content;
|
||||
@@ -19,6 +21,13 @@ interface DocProps {
|
||||
|
||||
export const DocPage403 = ({ id }: DocProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure the skeleton overlay is hidden on 403 page
|
||||
setIsSkeletonVisible(false);
|
||||
}, [setIsSkeletonVisible]);
|
||||
|
||||
const {
|
||||
data: requests,
|
||||
isLoading: isLoadingRequest,
|
||||
|
||||
@@ -4,20 +4,13 @@ import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
getEmojiAndTitle,
|
||||
useDocUtils,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management';
|
||||
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 { DocIcon } from './DocIcon';
|
||||
|
||||
const ItemTextCss = css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -45,10 +38,6 @@ export const SimpleDocItem = ({
|
||||
const { untitledDocument } = useTrans();
|
||||
const { isChild } = useDocUtils(doc);
|
||||
|
||||
const { emoji, titleWithoutEmoji: displayTitle } = getEmojiAndTitle(
|
||||
doc.title || untitledDocument,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
@@ -76,25 +65,19 @@ export const SimpleDocItem = ({
|
||||
data-testid="doc-pinned-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
) : isChild ? (
|
||||
<ChildDocument
|
||||
aria-hidden="true"
|
||||
data-testid="doc-child-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
) : (
|
||||
<DocIcon
|
||||
emoji={emoji}
|
||||
defaultIcon={
|
||||
isChild ? (
|
||||
<ChildDocument
|
||||
aria-hidden="true"
|
||||
data-testid="doc-child-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
) : (
|
||||
<SimpleFileIcon
|
||||
aria-hidden="true"
|
||||
data-testid="doc-simple-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
$size="25px"
|
||||
<SimpleFileIcon
|
||||
width="32px"
|
||||
height="32px"
|
||||
aria-hidden="true"
|
||||
data-testid="doc-simple-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -106,7 +89,7 @@ export const SimpleDocItem = ({
|
||||
$css={ItemTextCss}
|
||||
data-testid="doc-title"
|
||||
>
|
||||
{displayTitle}
|
||||
{doc.title || untitledDocument}
|
||||
</Text>
|
||||
{(!isDesktop || showAccesses) && (
|
||||
<Box
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './DocIcon';
|
||||
export * from './DocPage403';
|
||||
export * from './ModalRemoveDoc';
|
||||
export * from './SimpleDocItem';
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { Doc } from '../../types';
|
||||
import { useDocTitleUpdate } from '../useDocTitleUpdate';
|
||||
|
||||
// Mock useBroadcastStore
|
||||
vi.mock('@/stores', () => ({
|
||||
useBroadcastStore: () => ({
|
||||
broadcast: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useDocTitleUpdate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('should return the correct functions and state', () => {
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(result.current.updateDocTitle).toBeDefined();
|
||||
expect(result.current.updateDocEmoji).toBeDefined();
|
||||
expect(typeof result.current.updateDocTitle).toBe('function');
|
||||
expect(typeof result.current.updateDocEmoji).toBe('function');
|
||||
});
|
||||
|
||||
describe('updateDocTitle', () => {
|
||||
it('should call updateDoc with sanitized title', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
const sanitizedTitle = result.current.updateDocTitle(
|
||||
{ id: 'test-doc-id', title: '' } as Doc,
|
||||
' My Document \n\r',
|
||||
);
|
||||
|
||||
expect(sanitizedTitle).toBe('My Document');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
expect(fetchMock.calls()[0][0]).toBe(
|
||||
'http://test.jest/api/v1.0/documents/test-doc-id/',
|
||||
);
|
||||
expect(fetchMock.calls()[0][1]).toEqual({
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ title: 'My Document' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty title and not call updateDoc', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
const sanitizedTitle = result.current.updateDocTitle(
|
||||
{ id: 'test-doc-id', title: '' } as Doc,
|
||||
'',
|
||||
);
|
||||
|
||||
expect(sanitizedTitle).toBe('');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove newlines and carriage returns', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
const sanitizedTitle = result.current.updateDocTitle(
|
||||
{ id: 'test-doc-id', title: '' } as Doc,
|
||||
'Title\nwith\r\nnewlines',
|
||||
);
|
||||
|
||||
expect(sanitizedTitle).toBe('Titlewithnewlines');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDocEmoji', () => {
|
||||
it('should call updateDoc with emoji and title without existing emoji', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
result.current.updateDocEmoji('test-doc-id', 'My Document', '🚀');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
expect(fetchMock.calls()[0][0]).toBe(
|
||||
'http://test.jest/api/v1.0/documents/test-doc-id/',
|
||||
);
|
||||
expect(fetchMock.calls()[0][1]).toEqual({
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ title: '🚀 My Document' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace existing emoji with new one', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
result.current.updateDocEmoji('test-doc-id', '📝 My Document', '🚀');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
expect(fetchMock.calls()[0][1]).toEqual({
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ title: '🚀 My Document' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle title with only emoji', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
result.current.updateDocEmoji('test-doc-id', '📝', '🚀');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
expect(fetchMock.calls()[0][1]).toEqual({
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ title: '🚀 ' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty title', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'My Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDocTitleUpdate(), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
result.current.updateDocEmoji('test-doc-id', '', '🚀');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
expect(fetchMock.calls()[0][1]).toEqual({
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ title: '🚀 ' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('onSuccess callback', () => {
|
||||
it('should call onSuccess when provided', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
title: 'Updated Document',
|
||||
}),
|
||||
});
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
const { result } = renderHook(() => useDocTitleUpdate({ onSuccess }), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
result.current.updateDocTitle(
|
||||
{ id: 'test-doc-id', title: 'Old Document' } as Doc,
|
||||
'Updated Document',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith({
|
||||
id: 'test-doc-id',
|
||||
title: 'Updated Document',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('onError callback', () => {
|
||||
it('should call onError when provided', async () => {
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
throws: new Error('Update failed'),
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
const { result } = renderHook(() => useDocTitleUpdate({ onError }), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
try {
|
||||
result.current.updateDocTitle(
|
||||
{ id: 'test-doc-id', title: 'Old Document' } as Doc,
|
||||
'Updated Document',
|
||||
);
|
||||
} catch {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
expect(onError).toHaveBeenCalledWith(new Error('Update failed'));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './useCollaboration';
|
||||
export * from './useCopyDocLink';
|
||||
export * from './useCreateChildDocTree';
|
||||
export * from './useDocTitleUpdate';
|
||||
export * from './useDocUtils';
|
||||
export * from './useIsCollaborativeEditable';
|
||||
export * from './useTrans';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
getEmojiAndTitle,
|
||||
useUpdateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import { useBroadcastStore } from '@/stores';
|
||||
|
||||
interface UseDocUpdateOptions {
|
||||
onSuccess?: (updatedDoc: Doc) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export const useDocTitleUpdate = (options?: UseDocUpdateOptions) => {
|
||||
const { broadcast } = useBroadcastStore();
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
|
||||
const { mutate: updateDoc, ...mutationResult } = useUpdateDoc({
|
||||
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
|
||||
onSuccess: (updatedDoc) => {
|
||||
// Broadcast to every user connected to the document
|
||||
broadcast(`${KEY_DOC}-${updatedDoc.id}`);
|
||||
|
||||
if (treeContext) {
|
||||
if (treeContext.root?.id === updatedDoc.id) {
|
||||
treeContext?.setRoot(updatedDoc);
|
||||
} else {
|
||||
treeContext?.treeData.updateNode(updatedDoc.id, updatedDoc);
|
||||
}
|
||||
}
|
||||
|
||||
options?.onSuccess?.(updatedDoc);
|
||||
},
|
||||
onError: (error) => {
|
||||
options?.onError?.(error);
|
||||
},
|
||||
});
|
||||
|
||||
const updateDocTitle = useCallback(
|
||||
(doc: Doc, title: string) => {
|
||||
const sanitizedTitle = title.trim().replace(/(\r\n|\n|\r)/gm, '');
|
||||
|
||||
// When blank we set to untitled
|
||||
if (!sanitizedTitle) {
|
||||
updateDoc({ id: doc.id, title: '' });
|
||||
return '';
|
||||
}
|
||||
|
||||
// If mutation we update
|
||||
if (sanitizedTitle !== doc.title) {
|
||||
updateDoc({ id: doc.id, title: sanitizedTitle });
|
||||
}
|
||||
|
||||
return sanitizedTitle;
|
||||
},
|
||||
[updateDoc],
|
||||
);
|
||||
|
||||
const updateDocEmoji = useCallback(
|
||||
(docId: string, title: string, emoji: string) => {
|
||||
const { titleWithoutEmoji } = getEmojiAndTitle(title);
|
||||
updateDoc({ id: docId, title: `${emoji} ${titleWithoutEmoji}` });
|
||||
},
|
||||
[updateDoc],
|
||||
);
|
||||
|
||||
return {
|
||||
...mutationResult,
|
||||
updateDocTitle,
|
||||
updateDocEmoji,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CloseEvent } from '@hocuspocus/common';
|
||||
import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider';
|
||||
import * as Y from 'yjs';
|
||||
import { create } from 'zustand';
|
||||
@@ -13,6 +14,8 @@ export interface UseCollaborationStore {
|
||||
destroyProvider: () => void;
|
||||
provider: HocuspocusProvider | undefined;
|
||||
isConnected: boolean;
|
||||
isReady: boolean;
|
||||
isSynced: boolean;
|
||||
hasLostConnection: boolean;
|
||||
resetLostConnection: () => void;
|
||||
}
|
||||
@@ -20,9 +23,13 @@ export interface UseCollaborationStore {
|
||||
const defaultValues = {
|
||||
provider: undefined,
|
||||
isConnected: false,
|
||||
isReady: false,
|
||||
isSynced: false,
|
||||
hasLostConnection: false,
|
||||
};
|
||||
|
||||
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
|
||||
|
||||
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
...defaultValues,
|
||||
createProvider: (wsUrl, storeId, initialDoc) => {
|
||||
@@ -38,11 +45,21 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
url: wsUrl,
|
||||
name: storeId,
|
||||
document: doc,
|
||||
onDisconnect(data) {
|
||||
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
|
||||
if ((data.event as ExtendedCloseEvent).wasClean) {
|
||||
void provider.connect();
|
||||
}
|
||||
},
|
||||
onAuthenticationFailed() {
|
||||
set({ isReady: true });
|
||||
},
|
||||
onStatus: ({ status }) => {
|
||||
set((state) => {
|
||||
const nextConnected = status === WebSocketStatus.Connected;
|
||||
return {
|
||||
isConnected: nextConnected,
|
||||
isReady: state.isReady || status === WebSocketStatus.Disconnected,
|
||||
hasLostConnection:
|
||||
state.isConnected && !nextConnected
|
||||
? true
|
||||
@@ -50,6 +67,21 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
};
|
||||
});
|
||||
},
|
||||
onSynced: ({ state }) => {
|
||||
set({ isSynced: state, isReady: true });
|
||||
},
|
||||
onClose(data) {
|
||||
/**
|
||||
* Handle the "Reset Connection" event from the server
|
||||
* This is triggered when the server wants to reset the connection
|
||||
* for clients in the room.
|
||||
* A disconnect is made automatically but it takes time to be triggered,
|
||||
* so we force the disconnection here.
|
||||
*/
|
||||
if (data.event.code === 1000) {
|
||||
provider.disconnect();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
set({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export * from './useDeleteDocAccess';
|
||||
export * from './useDocAccesses';
|
||||
export * from './useUpdateDocAccess';
|
||||
export * from './useCreateDocAccess';
|
||||
export * from './useUsers';
|
||||
export * from './useCreateDocInvitation';
|
||||
export * from './useDeleteDocAccess';
|
||||
export * from './useDeleteDocInvitation';
|
||||
export * from './useDocAccesses';
|
||||
export * from './useDocAccessRequest';
|
||||
export * from './useDocInvitations';
|
||||
export * from './useUpdateDocAccess';
|
||||
export * from './useUpdateDocInvitation';
|
||||
export * from './useUsers';
|
||||
|
||||
@@ -45,7 +45,7 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
|
||||
return useMutation<void, APIError, DeleteDocAccessProps>({
|
||||
mutationFn: deleteDocAccess,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESSES],
|
||||
});
|
||||
@@ -63,7 +63,7 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
|
||||
queryKey: [KEY_LIST_USER],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,17 +53,17 @@ export const useDeleteDocInvitation = (
|
||||
>({
|
||||
mutationFn: deleteDocInvitation,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_INVITATIONS],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, context);
|
||||
void options.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,12 +59,12 @@ export function useCreateDocAccessRequest(
|
||||
return useMutation<void, APIError, CreateDocAccessRequestParams>({
|
||||
mutationFn: createDocAccessRequest,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS],
|
||||
});
|
||||
|
||||
void options?.onSuccess?.(data, variables, context);
|
||||
void options?.onSuccess?.(data, variables, onMutateResult, context);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -169,7 +169,7 @@ export const useAcceptDocAccessRequest = (
|
||||
return useMutation<void, APIError, acceptDocAccessRequestsParams>({
|
||||
mutationFn: acceptDocAccessRequests,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESSES],
|
||||
});
|
||||
@@ -179,7 +179,7 @@ export const useAcceptDocAccessRequest = (
|
||||
});
|
||||
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -223,13 +223,13 @@ export const useDeleteDocAccessRequest = (
|
||||
return useMutation<void, APIError, DeleteDocAccessRequestParams>({
|
||||
mutationFn: deleteDocAccessRequest,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS],
|
||||
});
|
||||
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { Access, KEY_DOC, KEY_LIST_DOC, Role } from '@/docs/doc-management';
|
||||
import { useBroadcastStore } from '@/stores';
|
||||
|
||||
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
|
||||
|
||||
@@ -45,12 +44,11 @@ type UseUpdateDocAccessOptions = UseMutationOptions<
|
||||
|
||||
export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { broadcast } = useBroadcastStore();
|
||||
|
||||
return useMutation<Access, APIError, UpdateDocAccessProps>({
|
||||
mutationFn: updateDocAccess,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESSES],
|
||||
});
|
||||
@@ -58,14 +56,12 @@ export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
|
||||
queryKey: [KEY_DOC],
|
||||
});
|
||||
|
||||
// Broadcast to every user connected to the document
|
||||
broadcast(`${KEY_DOC}-${variables.docId}`);
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -62,17 +62,17 @@ export const useUpdateDocInvitation = (
|
||||
>({
|
||||
mutationFn: updateDocInvitation,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_INVITATIONS],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, context);
|
||||
void options.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,11 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { useBroadcastStore } from '@/stores';
|
||||
|
||||
import { Doc } from '../types';
|
||||
|
||||
import { KEY_DOC } from './useDoc';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
|
||||
export type UpdateDocLinkParams = Pick<Doc, 'id' | 'link_reach'> &
|
||||
Partial<Pick<Doc, 'link_role'>>;
|
||||
@@ -43,22 +39,18 @@ export function useUpdateDocLink({
|
||||
listInvalideQueries,
|
||||
}: UpdateDocLinkProps = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { broadcast } = useBroadcastStore();
|
||||
const { toast } = useToastProvider();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<Doc, APIError, UpdateDocLinkParams>({
|
||||
mutationFn: updateDocLink,
|
||||
onSuccess: (data, variable) => {
|
||||
onSuccess: (data) => {
|
||||
listInvalideQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
|
||||
// Broadcast to every user connected to the document
|
||||
broadcast(`${KEY_DOC}-${variable.id}`);
|
||||
|
||||
toast(
|
||||
t('The document visibility has been updated.'),
|
||||
VariantType.SUCCESS,
|
||||
@@ -4,12 +4,9 @@ import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
useUpdateDocLink,
|
||||
} from '@/docs/doc-management';
|
||||
import { Doc, KEY_DOC, KEY_LIST_DOC } from '@/docs/doc-management';
|
||||
|
||||
import { useUpdateDocLink } from '../api/useUpdateDocLink';
|
||||
|
||||
import Desync from './../assets/desynchro.svg';
|
||||
import Undo from './../assets/undo.svg';
|
||||
|
||||
@@ -112,7 +112,7 @@ export const QuickSearchGroupMember = ({
|
||||
elements: members,
|
||||
endActions: undefined,
|
||||
};
|
||||
}, [membersQuery, t]);
|
||||
}, [membersQuery.data, t]);
|
||||
|
||||
return (
|
||||
<Box aria-label={t('List members card')} $padding={{ bottom: '3xs' }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
@@ -15,7 +16,14 @@ import { User } from '@/features/auth';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
import { isValidEmail } from '@/utils';
|
||||
|
||||
import { KEY_LIST_USER, useDocAccesses, useUsers } from '../api';
|
||||
import {
|
||||
KEY_LIST_DOC_ACCESSES,
|
||||
KEY_LIST_DOC_ACCESS_REQUESTS,
|
||||
KEY_LIST_DOC_INVITATIONS,
|
||||
KEY_LIST_USER,
|
||||
useDocAccesses,
|
||||
useUsers,
|
||||
} from '../api';
|
||||
|
||||
import { DocInheritedShareContent } from './DocInheritedShareContent';
|
||||
import {
|
||||
@@ -48,6 +56,7 @@ type Props = {
|
||||
export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedUsersRef = useRef<HTMLDivElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
@@ -128,6 +137,19 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
const showInheritedShareContent =
|
||||
inheritedAccesses.length > 0 && showMemberSection && !isRootDoc;
|
||||
|
||||
// Invalidate relevant queries to ensure fresh data on modal open
|
||||
useEffect(() => {
|
||||
[
|
||||
KEY_LIST_DOC_INVITATIONS,
|
||||
KEY_LIST_DOC_ACCESS_REQUESTS,
|
||||
KEY_LIST_DOC_ACCESSES,
|
||||
].forEach((key) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [key],
|
||||
});
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
|
||||
@@ -19,10 +19,10 @@ import {
|
||||
getDocLinkReach,
|
||||
getDocLinkRole,
|
||||
useDocUtils,
|
||||
useUpdateDocLink,
|
||||
} from '@/docs/doc-management';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useUpdateDocLink } from '../api/useUpdateDocLink';
|
||||
import { useTranslatedShareSettings } from '../hooks/';
|
||||
|
||||
import { DocDesynchronized } from './DocDesynchronized';
|
||||
|
||||
@@ -12,10 +12,10 @@ import { Box, BoxButton, Icon, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
DocIcon,
|
||||
getEmojiAndTitle,
|
||||
useTrans,
|
||||
} from '@/features/docs/doc-management';
|
||||
import { DocIcon } from '@/features/docs/doc-management/components/DocIcon';
|
||||
import { useLeftPanelStore } from '@/features/left-panel';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
@@ -166,11 +166,14 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
min-width: 0;
|
||||
`}
|
||||
>
|
||||
<Box $width="16px" $height="16px">
|
||||
<Box>
|
||||
<DocIcon
|
||||
emoji={emoji}
|
||||
withEmojiPicker={doc.abilities.partial_update}
|
||||
defaultIcon={<SubPageIcon color={colorsTokens['primary-400']} />}
|
||||
$size="sm"
|
||||
docId={doc.id}
|
||||
title={doc.title}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
Doc,
|
||||
ModalRemoveDoc,
|
||||
Role,
|
||||
getEmojiAndTitle,
|
||||
useCopyDocLink,
|
||||
useCreateChildDoc,
|
||||
useDocTitleUpdate,
|
||||
useDuplicateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
|
||||
@@ -44,6 +46,7 @@ export const DocTreeItemActions = ({
|
||||
const copyLink = useCopyDocLink(doc.id);
|
||||
const { mutate: detachDoc } = useDetachDoc();
|
||||
const treeContext = useTreeContext<Doc | null>();
|
||||
|
||||
const { mutate: duplicateDoc } = useDuplicateDoc({
|
||||
onSuccess: (duplicatedDoc) => {
|
||||
// Reset the tree context root will reset the full tree view.
|
||||
@@ -52,6 +55,13 @@ export const DocTreeItemActions = ({
|
||||
},
|
||||
});
|
||||
|
||||
// Emoji Management
|
||||
const { emoji } = getEmojiAndTitle(doc.title ?? '');
|
||||
const { updateDocEmoji } = useDocTitleUpdate();
|
||||
const removeEmoji = () => {
|
||||
updateDocEmoji(doc.id, doc.title ?? '', '');
|
||||
};
|
||||
|
||||
const handleDetachDoc = () => {
|
||||
if (!treeContext?.root) {
|
||||
return;
|
||||
@@ -82,6 +92,15 @@ export const DocTreeItemActions = ({
|
||||
},
|
||||
...(!isRoot
|
||||
? [
|
||||
...(emoji && doc.abilities.partial_update
|
||||
? [
|
||||
{
|
||||
label: t('Remove emoji'),
|
||||
icon: <Icon iconName="emoji_emotions" $size="24px" />,
|
||||
callback: removeEmoji,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t('Move to my docs'),
|
||||
isDisabled: doc.user_role !== Role.OWNER,
|
||||
|
||||
@@ -31,7 +31,10 @@ describe('DocsGridItemDate', () => {
|
||||
});
|
||||
|
||||
[
|
||||
{ updated_at: DateTime.now().toISO(), rendered: '0 seconds ago' },
|
||||
{
|
||||
updated_at: DateTime.now().minus({ minutes: 1 }).toISO(),
|
||||
rendered: '1 minute ago',
|
||||
},
|
||||
{
|
||||
updated_at: DateTime.now().minus({ days: 1 }).toISO(),
|
||||
rendered: '1 day ago',
|
||||
@@ -100,10 +103,10 @@ describe('DocsGridItemDate', () => {
|
||||
updated_at: DateTime.now().toISO(),
|
||||
},
|
||||
{
|
||||
deleted_at: DateTime.now().toISO(),
|
||||
rendered: '0 seconds ago',
|
||||
deleted_at: DateTime.now().minus({ minutes: 1 }).toISO(),
|
||||
rendered: '1 minute ago',
|
||||
trashbin_cutoff_days: 0,
|
||||
updated_at: DateTime.now().toISO(),
|
||||
updated_at: DateTime.now().minus({ minutes: 1 }).toISO(),
|
||||
},
|
||||
].forEach(({ deleted_at, rendered, trashbin_cutoff_days, updated_at }) => {
|
||||
it(`should render "${rendered}" when we are in the trashbin`, async () => {
|
||||
|
||||
@@ -68,7 +68,7 @@ export const Header = () => {
|
||||
className="c__image-system-filter"
|
||||
data-testid="header-icon-docs"
|
||||
src={logo?.src || '/assets/icon-docs.svg'}
|
||||
alt={logo?.alt || t('Docs')}
|
||||
alt=""
|
||||
width={0}
|
||||
height={0}
|
||||
style={{
|
||||
|
||||
@@ -3,6 +3,5 @@ export interface HeaderType {
|
||||
src?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
alt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon } from '@/components';
|
||||
import { useCreateDoc } from '@/features/docs/doc-management';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
|
||||
import { useLeftPanelStore } from '../stores';
|
||||
|
||||
@@ -11,19 +13,47 @@ export const LeftPanelHeaderButton = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
const { mutate: createDoc, isPending: isDocCreating } = useCreateDoc({
|
||||
onSuccess: (doc) => {
|
||||
void router.push(`/docs/${doc.id}`);
|
||||
togglePanel();
|
||||
setIsNavigating(true);
|
||||
// Wait for navigation to complete
|
||||
router
|
||||
.push(`/docs/${doc.id}`)
|
||||
.then(() => {
|
||||
// The skeleton will be disabled by the [id] page once the data is loaded
|
||||
setIsNavigating(false);
|
||||
togglePanel();
|
||||
})
|
||||
.catch(() => {
|
||||
// In case of navigation error, disable the skeleton
|
||||
setIsSkeletonVisible(false);
|
||||
setIsNavigating(false);
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
// If there's an error, disable the skeleton
|
||||
setIsSkeletonVisible(false);
|
||||
setIsNavigating(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
setIsSkeletonVisible(true);
|
||||
createDoc();
|
||||
};
|
||||
|
||||
const isLoading = isDocCreating || isNavigating;
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-testid="new-doc-button"
|
||||
color="primary"
|
||||
onClick={() => createDoc()}
|
||||
onClick={handleClick}
|
||||
icon={<Icon $variation="000" iconName="add" aria-hidden="true" />}
|
||||
disabled={isDocCreating}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('New doc')}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { css, keyframes } from 'styled-components';
|
||||
|
||||
import { Box, BoxType } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
type SkeletonLineProps = Partial<BoxType>;
|
||||
|
||||
type SkeletonCircleProps = Partial<BoxType>;
|
||||
|
||||
export const DocEditorSkeleton = () => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const SkeletonLine = ({ $css, ...props }: SkeletonLineProps) => {
|
||||
return (
|
||||
<Box
|
||||
$width="100%"
|
||||
$height="16px"
|
||||
$css={css`
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${colorsTokens['greyscale-100']} 0%,
|
||||
${colorsTokens['greyscale-200']} 50%,
|
||||
${colorsTokens['greyscale-100']} 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
animation: ${shimmer} 2s infinite linear;
|
||||
border-radius: 4px;
|
||||
${$css}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SkeletonCircle = ({ $css, ...props }: SkeletonCircleProps) => {
|
||||
return (
|
||||
<Box
|
||||
$width="32px"
|
||||
$height="32px"
|
||||
$css={css`
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${colorsTokens['greyscale-100']} 0%,
|
||||
${colorsTokens['greyscale-200']} 50%,
|
||||
${colorsTokens['greyscale-100']} 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
animation: ${shimmer} 2s infinite linear;
|
||||
border-radius: 50%;
|
||||
${$css}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Editor Container */}
|
||||
<Box
|
||||
$maxWidth="868px"
|
||||
$width="100%"
|
||||
$height="100%"
|
||||
className="--docs--doc-editor-skeleton"
|
||||
>
|
||||
{/* Header Skeleton */}
|
||||
<Box
|
||||
$padding={{ horizontal: isDesktop ? '70px' : 'base' }}
|
||||
className="--docs--doc-editor-header-skeleton"
|
||||
>
|
||||
<Box
|
||||
$width="100%"
|
||||
$padding={{ top: isDesktop ? '65px' : 'md' }}
|
||||
$gap={spacingsTokens['base']}
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$width="100%"
|
||||
$padding={{ bottom: 'xs' }}
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$css="flex:1;"
|
||||
$gap="0.5rem 1rem"
|
||||
$align="center"
|
||||
$maxWidth="100%"
|
||||
>
|
||||
{/* Title and metadata skeleton */}
|
||||
<Box $gap="0.25rem" $css="flex:1;">
|
||||
{/* Title - "Document sans titre" style */}
|
||||
<SkeletonLine $width="35%" $height="40px" />
|
||||
|
||||
{/* Metadata (role and last update) */}
|
||||
<Box $direction="row" $gap="0.5rem" $align="center">
|
||||
<SkeletonLine $maxWidth="260px" $height="12px" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Toolbox skeleton (buttons) */}
|
||||
<Box $direction="row" $gap="0.75rem" $align="center">
|
||||
{/* Partager button */}
|
||||
<SkeletonLine $width="90px" $height="40px" />
|
||||
{/* Download icon */}
|
||||
<SkeletonCircle $width="40px" $height="40px" />
|
||||
{/* Menu icon */}
|
||||
<SkeletonCircle $width="40px" $height="40px" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Separator */}
|
||||
<SkeletonLine $height="1px" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content Skeleton */}
|
||||
<Box
|
||||
$direction="row"
|
||||
$width="100%"
|
||||
$css="overflow-x: clip; flex: 1;"
|
||||
$position="relative"
|
||||
className="--docs--doc-editor-content-skeleton"
|
||||
>
|
||||
<Box
|
||||
$css="flex:1;"
|
||||
$position="relative"
|
||||
$width="100%"
|
||||
$padding={{ horizontal: isDesktop ? '70px' : 'base', top: 'lg' }}
|
||||
>
|
||||
{/* Placeholder text similar to screenshot */}
|
||||
<Box $gap="0rem">
|
||||
{/* Single placeholder line like in the screenshot */}
|
||||
<SkeletonLine $width="85%" $height="20px" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
|
||||
import { css, keyframes } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
|
||||
const FADE_DURATION_MS = 250;
|
||||
|
||||
const fadeOut = keyframes`
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Skeleton = ({ children }: PropsWithChildren) => {
|
||||
const { isSkeletonVisible } = useSkeletonStore();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const [isVisible, setIsVisible] = useState(isSkeletonVisible);
|
||||
const [isFadingOut, setIsFadingOut] = useState(true);
|
||||
const timeoutVisibleRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSkeletonVisible) {
|
||||
setIsVisible(true);
|
||||
setIsFadingOut(false);
|
||||
} else {
|
||||
setIsFadingOut(true);
|
||||
if (!timeoutVisibleRef.current) {
|
||||
timeoutVisibleRef.current = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
}, FADE_DURATION_MS * 2);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutVisibleRef.current) {
|
||||
clearTimeout(timeoutVisibleRef.current);
|
||||
timeoutVisibleRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isSkeletonVisible]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="--docs--skeleton"
|
||||
$align="center"
|
||||
$width="100%"
|
||||
$height="100%"
|
||||
$background={colorsTokens['greyscale-000']}
|
||||
$css={css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
overflow: hidden;
|
||||
will-change: opacity;
|
||||
animation: ${isFadingOut && fadeOut} ${FADE_DURATION_MS}ms ease-in-out
|
||||
forwards;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DocEditorSkeleton';
|
||||
export * from './Skeleton';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './components';
|
||||
export * from './store';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useSkeletonStore';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface SkeletonStore {
|
||||
isSkeletonVisible: boolean;
|
||||
setIsSkeletonVisible: (isSkeletonVisible: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSkeletonStore = create<SkeletonStore>((set) => ({
|
||||
isSkeletonVisible: false,
|
||||
setIsSkeletonVisible: (isSkeletonVisible: boolean) =>
|
||||
set({ isSkeletonVisible }),
|
||||
}));
|
||||
@@ -70,6 +70,7 @@
|
||||
"Document access mode": "Doare moned ar restr",
|
||||
"Document accessible to any connected person": "Restr a c'hall bezañ tizhet gant ne vern piv a vefe kevreet",
|
||||
"Document duplicated successfully!": "Restr eilet gant berzh!",
|
||||
"Document emoji": "Emoju ar restr",
|
||||
"Document owner": "Perc'henn ar restr",
|
||||
"Document role text": "Testenn rol ar restr",
|
||||
"Document sections": "Kevrennoù ar restr",
|
||||
@@ -175,6 +176,7 @@
|
||||
"Reader": "Lenner",
|
||||
"Reading": "Lenn hepken",
|
||||
"Remove access": "Dilemel ar moned",
|
||||
"Remove emoji": "Dilemel ar emoju",
|
||||
"Rename": "Adenvel",
|
||||
"Rephrase": "Adformulenniñ",
|
||||
"Request access": "Goulenn mont e-barzh",
|
||||
@@ -296,6 +298,7 @@
|
||||
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Pages: Ihr neuer Begleiter für eine effiziente, intuitive und sichere Zusammenarbeit bei Dokumenten.",
|
||||
"Document accessible to any connected person": "Dokument für jeden angemeldeten Benutzer zugänglich",
|
||||
"Document duplicated successfully!": "Dokument erfolgreich dupliziert!",
|
||||
"Document emoji": "Dokument-Emoji",
|
||||
"Document owner": "Besitzer des Dokuments",
|
||||
"Docx": "Docx",
|
||||
"Download": "Herunterladen",
|
||||
@@ -378,6 +381,7 @@
|
||||
"Reader": "Leser",
|
||||
"Reading": "Lesen",
|
||||
"Remove access": "Zugriff entziehen",
|
||||
"Remove emoji": "Emoji entfernen",
|
||||
"Rename": "Umbenennen",
|
||||
"Rephrase": "Umformulieren",
|
||||
"Request access": "Zugriff anfragen",
|
||||
@@ -496,6 +500,7 @@
|
||||
"Docs transforms your documents into knowledge bases thanks to subpages, powerful search and the ability to pin your important documents.": "Docs transforma sus documentos en bases de conocimiento gracias a las subpáginas, una potente herramienta de búsqueda y la capacidad de marcar como favorito sus documentos más importantes.",
|
||||
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs: su nuevo compañero para colaborar en documentos de forma eficiente, intuitiva y segura.",
|
||||
"Document accessible to any connected person": "Documento accesible a cualquier persona conectada",
|
||||
"Document emoji": "Emoji del documento",
|
||||
"Document owner": "Propietario del documento",
|
||||
"Docx": "Docx",
|
||||
"Download": "Descargar",
|
||||
@@ -565,6 +570,7 @@
|
||||
"Quick search input": "Entrada de búsqueda rápida",
|
||||
"Reader": "Lector",
|
||||
"Reading": "Lectura",
|
||||
"Remove emoji": "Eliminar emoji",
|
||||
"Rename": "Cambiar el nombre",
|
||||
"Rephrase": "Reformular",
|
||||
"Request access": "Solicitar acceso",
|
||||
@@ -694,6 +700,7 @@
|
||||
"Document accessible to any connected person": "Document accessible à toute personne connectée",
|
||||
"Document deleted": "Document supprimé",
|
||||
"Document duplicated successfully!": "Document dupliqué avec succès !",
|
||||
"Document emoji": "Emoji du document",
|
||||
"Document owner": "Propriétaire du document",
|
||||
"Document role text": "Texte du rôle du document",
|
||||
"Document sections": "Sections du document",
|
||||
@@ -806,6 +813,7 @@
|
||||
"Reader": "Lecteur",
|
||||
"Reading": "Lecture seule",
|
||||
"Remove access": "Supprimer l'accès",
|
||||
"Remove emoji": "Supprimer l'emoji",
|
||||
"Rename": "Renommer",
|
||||
"Rephrase": "Reformuler",
|
||||
"Request access": "Demander l'accès",
|
||||
@@ -930,6 +938,7 @@
|
||||
"Docs transforms your documents into knowledge bases thanks to subpages, powerful search and the ability to pin your important documents.": "Docs trasforma i tuoi documenti in piattaforme di conoscenza grazie alle sotto-pagine, alla ricerca potente e alla capacità di fissare i tuoi documenti importanti.",
|
||||
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs: Il tuo nuovo compagno di collaborare sui documenti in modo efficiente, intuitivo e sicuro.",
|
||||
"Document accessible to any connected person": "Documento accessibile a qualsiasi persona collegata",
|
||||
"Document emoji": "Emoji del documento",
|
||||
"Document owner": "Proprietario del documento",
|
||||
"Docx": "Docx",
|
||||
"Download": "Scarica",
|
||||
@@ -990,6 +999,7 @@
|
||||
"Public document": "Documento pubblico",
|
||||
"Reader": "Lettore",
|
||||
"Reading": "Leggendo",
|
||||
"Remove emoji": "Rimuovi emoji",
|
||||
"Rename": "Rinomina",
|
||||
"Rephrase": "Riformula",
|
||||
"Restore": "Ripristina",
|
||||
@@ -1103,6 +1113,7 @@
|
||||
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs: Je nieuwe metgezel om efficiënt, intuïtief en veilig samen te werken aan documenten.",
|
||||
"Document access mode": "Document toegangsmodus",
|
||||
"Document accessible to any connected person": "Document is toegankelijk voor ieder verbonden persoon",
|
||||
"Document emoji": "Document emoji",
|
||||
"Document duplicated successfully!": "Document met succes gedupliceerd!",
|
||||
"Document owner": "Document eigenaar",
|
||||
"Document role text": "Document roltekst",
|
||||
@@ -1214,6 +1225,7 @@
|
||||
"Reader": "Lezer",
|
||||
"Reading": "Lezen",
|
||||
"Remove access": "Toegang verwijderen",
|
||||
"Remove emoji": "Emoji verwijderen",
|
||||
"Rename": "Hernoemen",
|
||||
"Rephrase": "Herschrijf",
|
||||
"Request access": "Toegang aanvragen",
|
||||
@@ -1287,7 +1299,11 @@
|
||||
"pdf": "PDF"
|
||||
}
|
||||
},
|
||||
"pt": { "translation": {} },
|
||||
"pt": {
|
||||
"translation": {
|
||||
"Remove emoji": "Remover emoji"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"translation": {
|
||||
"\"{{email}}\" is already invited to the document.": "\"{{email}}\" уже имеет приглашение для этого документа.",
|
||||
@@ -1368,6 +1384,7 @@
|
||||
"Document accessible to any connected person": "Документ доступен всем, кто присоединится",
|
||||
"Document deleted": "Документ удалён",
|
||||
"Document duplicated successfully!": "Документ успешно дублирован!",
|
||||
"Document emoji": "Эмодзи документа",
|
||||
"Document owner": "Владелец документа",
|
||||
"Document role text": "Текст роли документа",
|
||||
"Document sections": "Разделы документа",
|
||||
@@ -1480,6 +1497,7 @@
|
||||
"Reader": "Читатель",
|
||||
"Reading": "Чтение",
|
||||
"Remove access": "Отменить доступ",
|
||||
"Remove emoji": "Убрать эмодзи",
|
||||
"Rename": "Переименовать",
|
||||
"Rephrase": "Переформулировать",
|
||||
"Request access": "Запрос доступа",
|
||||
@@ -1565,6 +1583,7 @@
|
||||
"sl": {
|
||||
"translation": {
|
||||
"Load more": "Naloži več",
|
||||
"Remove emoji": "Odstrani emoji",
|
||||
"Untitled document": "Dokument brez naslova"
|
||||
}
|
||||
},
|
||||
@@ -1598,7 +1617,8 @@
|
||||
"This file is flagged as unsafe.": "Denna fil är flaggad som osäker.",
|
||||
"Too many requests. Please wait 60 seconds.": "För många förfrågningar. Vänligen vänta 60 sekunder.",
|
||||
"Use as prompt": "Använd som prompt",
|
||||
"Warning": "Varning"
|
||||
"Warning": "Varning",
|
||||
"Remove emoji": "Ta bort emoji"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
@@ -1625,6 +1645,7 @@
|
||||
"Docs": "Docs",
|
||||
"Docs Logo": "Docs logosu",
|
||||
"Document accessible to any connected person": "Bağlanan herhangi bir kişi tarafından erişilebilen belge",
|
||||
"Document emoji": "Belge emojisi",
|
||||
"Docx": "Docx",
|
||||
"Download": "İndir",
|
||||
"Download anyway": "Yine de indir",
|
||||
@@ -1668,7 +1689,8 @@
|
||||
"Version history": "Sürüm geçmişi",
|
||||
"Warning": "Uyarı",
|
||||
"Write": "Yaz",
|
||||
"Your {{format}} was downloaded succesfully": "{{format}} indirildi"
|
||||
"Your {{format}} was downloaded succesfully": "{{format}} indirildi",
|
||||
"Remove emoji": "Emoji kaldır"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
@@ -1751,6 +1773,7 @@
|
||||
"Document accessible to any connected person": "Документ, доступний для будь-якої особи, що приєдналася",
|
||||
"Document deleted": "Документ видалено",
|
||||
"Document duplicated successfully!": "Документ успішно продубльовано!",
|
||||
"Document emoji": "Емодзі документа",
|
||||
"Document owner": "Власник документа",
|
||||
"Document role text": "Текст ролі документа",
|
||||
"Document sections": "Розділи документу",
|
||||
@@ -1863,6 +1886,7 @@
|
||||
"Reader": "Читач",
|
||||
"Reading": "Читання",
|
||||
"Remove access": "Вилучити доступ",
|
||||
"Remove emoji": "Видалити емодзі",
|
||||
"Rename": "Перейменувати",
|
||||
"Rephrase": "Перефразувати",
|
||||
"Request access": "Запит доступу",
|
||||
@@ -2000,6 +2024,7 @@
|
||||
"Docs transforms your documents into knowledge bases thanks to subpages, powerful search and the ability to pin your important documents.": "Docs 通过子页面、强大的搜索功能以及固定重要文档的能力,将您的文档转化为知识库。",
|
||||
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs 为您提供高效、直观且安全的文档协作解决方案。",
|
||||
"Document accessible to any connected person": "任何来访的人都可以访问文档",
|
||||
"Document emoji": "文档表情符号",
|
||||
"Document owner": "文档所有者",
|
||||
"Document title": "文档标题",
|
||||
"Docx": "Doc",
|
||||
@@ -2076,6 +2101,7 @@
|
||||
"Quick search input": "快速搜索",
|
||||
"Reader": "阅读者",
|
||||
"Reading": "阅读中",
|
||||
"Remove emoji": "移除表情符号",
|
||||
"Rename": "重命名",
|
||||
"Rephrase": "改写",
|
||||
"Reset": "重置",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Header } from '@/features/header';
|
||||
import { HEADER_HEIGHT } from '@/features/header/conf';
|
||||
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
|
||||
import { DocEditorSkeleton, Skeleton } from '@/features/skeletons';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { MAIN_LAYOUT_ID } from './conf';
|
||||
@@ -66,6 +67,7 @@ export function MainLayoutContent({
|
||||
$flex={1}
|
||||
$width="100%"
|
||||
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
|
||||
$position="relative"
|
||||
$padding={{
|
||||
all: isDesktop ? 'base' : '0',
|
||||
}}
|
||||
@@ -79,6 +81,9 @@ export function MainLayoutContent({
|
||||
overflow-x: clip;
|
||||
`}
|
||||
>
|
||||
<Skeleton>
|
||||
<DocEditorSkeleton />
|
||||
</Skeleton>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/docs/doc-management/';
|
||||
import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth';
|
||||
import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
import { MainLayout } from '@/layouts';
|
||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||
import { useBroadcastStore } from '@/stores';
|
||||
@@ -61,6 +62,7 @@ interface DocProps {
|
||||
|
||||
const DocPage = ({ id }: DocProps) => {
|
||||
const { hasLostConnection, resetLostConnection } = useProviderStore();
|
||||
const { isSkeletonVisible, setIsSkeletonVisible } = useSkeletonStore();
|
||||
const {
|
||||
data: docQuery,
|
||||
isError,
|
||||
@@ -92,6 +94,15 @@ const DocPage = ({ id }: DocProps) => {
|
||||
const { authenticated } = useAuth();
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
/**
|
||||
* Show skeleton when loading a document
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!doc && !isError && !isSkeletonVisible) {
|
||||
setIsSkeletonVisible(true);
|
||||
}
|
||||
}, [doc, isError, isSkeletonVisible, setIsSkeletonVisible]);
|
||||
|
||||
/**
|
||||
* Scroll to top when navigating to a new document
|
||||
* We use a timeout to ensure the scroll happens after the layout has updated.
|
||||
@@ -129,7 +140,13 @@ const DocPage = ({ id }: DocProps) => {
|
||||
|
||||
setDoc(docQuery);
|
||||
setCurrentDoc(docQuery);
|
||||
}, [docQuery, setCurrentDoc, isFetching]);
|
||||
}, [
|
||||
docQuery,
|
||||
setCurrentDoc,
|
||||
isFetching,
|
||||
isSkeletonVisible,
|
||||
setIsSkeletonVisible,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -147,7 +164,7 @@ const DocPage = ({ id }: DocProps) => {
|
||||
}
|
||||
|
||||
addTask(`${KEY_DOC}-${doc.id}`, () => {
|
||||
void queryClient.resetQueries({
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_DOC, { id: doc.id }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,11 +78,11 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
broadcast: (taskLabel) => {
|
||||
const { task } = get().tasks[taskLabel];
|
||||
if (!task) {
|
||||
const obTask = get().tasks?.[taskLabel];
|
||||
if (!obTask || !obTask.task) {
|
||||
console.warn(`Task ${taskLabel} is not defined`);
|
||||
return;
|
||||
}
|
||||
task.push([`broadcast: ${taskLabel}`]);
|
||||
obTask.task.push([`broadcast: ${taskLabel}`]);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "impress",
|
||||
"version": "3.8.21",
|
||||
"version": "3.8.2",
|
||||
"private": true,
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
@@ -31,15 +31,16 @@
|
||||
"server:test": "yarn COLLABORATION_SERVER run test"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/node": "22.18.1",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"@typescript-eslint/eslint-plugin": "8.43.0",
|
||||
"@typescript-eslint/parser": "8.43.0",
|
||||
"eslint": "9.35.0",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"typescript": "5.9.2",
|
||||
"@types/node": "22.18.8",
|
||||
"@types/react": "19.2.0",
|
||||
"@types/react-dom": "19.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.45.0",
|
||||
"@typescript-eslint/parser": "8.45.0",
|
||||
"docx": "9.5.0",
|
||||
"eslint": "9.37.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"yjs": "13.6.27"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eslint-plugin-docs",
|
||||
"version": "3.8.21",
|
||||
"version": "3.8.2",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -17,12 +17,12 @@
|
||||
"eslint": ">=9.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "15.5.3",
|
||||
"@tanstack/eslint-plugin-query": "5.86.0",
|
||||
"@next/eslint-plugin-next": "15.5.4",
|
||||
"@tanstack/eslint-plugin-query": "5.91.0",
|
||||
"@typescript-eslint/eslint-plugin": "*",
|
||||
"@typescript-eslint/parser": "*",
|
||||
"@vitest/eslint-plugin": "1.0.1",
|
||||
"eslint-config-next": "15.5.3",
|
||||
"@vitest/eslint-plugin": "1.3.16",
|
||||
"eslint-config-next": "15.5.4",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jest": "29.0.1",
|
||||
@@ -30,7 +30,7 @@
|
||||
"eslint-plugin-playwright": "2.2.2",
|
||||
"eslint-plugin-prettier": "5.5.4",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-testing-library": "7.6.8",
|
||||
"eslint-plugin-testing-library": "7.11.0",
|
||||
"prettier": "3.6.2"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "packages-i18n",
|
||||
"version": "3.8.21",
|
||||
"version": "3.8.2",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -20,8 +20,8 @@
|
||||
"eslint-plugin-docs": "*",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"i18next-parser": "9.3.0",
|
||||
"jest": "30.1.3",
|
||||
"ts-jest": "29.4.1",
|
||||
"jest": "30.2.0",
|
||||
"ts-jest": "29.4.4",
|
||||
"typescript": "*",
|
||||
"yargs": "18.0.0"
|
||||
},
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('Server Tests', () => {
|
||||
|
||||
test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with correct API key should reset connections', async () => {
|
||||
const closeConnectionsMock = vi
|
||||
.spyOn(hocuspocusServer, 'closeConnections')
|
||||
.spyOn(hocuspocusServer.hocuspocus, 'closeConnections')
|
||||
.mockResolvedValue();
|
||||
|
||||
const app = initApp();
|
||||
|
||||
@@ -70,11 +70,11 @@ describe('Server Tests', () => {
|
||||
});
|
||||
|
||||
test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key existing', async () => {
|
||||
const document = await hocuspocusServer.createDocument(
|
||||
const document = await hocuspocusServer.hocuspocus.createDocument(
|
||||
'test-room',
|
||||
{},
|
||||
uuid(),
|
||||
{ isAuthenticated: true, readOnly: false, requiresAuthentication: true },
|
||||
{ isAuthenticated: true, readOnly: false },
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -138,11 +138,11 @@ describe('Server Tests', () => {
|
||||
});
|
||||
|
||||
test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing', async () => {
|
||||
const document = await hocuspocusServer.createDocument(
|
||||
const document = await hocuspocusServer.hocuspocus.createDocument(
|
||||
'test-room',
|
||||
{},
|
||||
uuid(),
|
||||
{ isAuthenticated: true, readOnly: false, requiresAuthentication: true },
|
||||
{ isAuthenticated: true, readOnly: false },
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -206,11 +206,11 @@ describe('Server Tests', () => {
|
||||
});
|
||||
|
||||
test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing, read only connection', async () => {
|
||||
const document = await hocuspocusServer.createDocument(
|
||||
const document = await hocuspocusServer.hocuspocus.createDocument(
|
||||
'test-room',
|
||||
{},
|
||||
uuid(),
|
||||
{ isAuthenticated: true, readOnly: false, requiresAuthentication: true },
|
||||
{ isAuthenticated: true, readOnly: false },
|
||||
{},
|
||||
);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('Server Tests', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
server = initApp().listen(port);
|
||||
await hocuspocusServer.configure({ port: portWS }).listen();
|
||||
await hocuspocusServer.listen(portWS);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -62,14 +62,11 @@ describe('Server Tests', () => {
|
||||
test('WebSocket connection with bad origin should be closed', () => {
|
||||
const { promise, done } = promiseDone();
|
||||
const room = uuidv4();
|
||||
const ws = new WebSocket(
|
||||
`ws://localhost:${port}/collaboration/ws/?room=${room}`,
|
||||
{
|
||||
headers: {
|
||||
Origin: 'http://bad-origin.com',
|
||||
},
|
||||
const ws = new WebSocket(`ws://localhost:${port}/?room=${room}`, {
|
||||
headers: {
|
||||
Origin: 'http://bad-origin.com',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
ws.onclose = () => {
|
||||
expect(ws.readyState).toBe(ws.CLOSED);
|
||||
@@ -82,14 +79,11 @@ describe('Server Tests', () => {
|
||||
test('WebSocket connection without cookies header should be closed', () => {
|
||||
const { promise, done } = promiseDone();
|
||||
const room = uuidv4();
|
||||
const ws = new WebSocket(
|
||||
`ws://localhost:${port}/collaboration/ws/?room=${room}`,
|
||||
{
|
||||
headers: {
|
||||
Origin: origin,
|
||||
},
|
||||
const ws = new WebSocket(`ws://localhost:${port}/?room=${room}`, {
|
||||
headers: {
|
||||
Origin: origin,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
ws.onclose = () => {
|
||||
expect(ws.readyState).toBe(ws.CLOSED);
|
||||
@@ -106,17 +100,13 @@ describe('Server Tests', () => {
|
||||
url: `ws://localhost:${portWS}/?room=${room}`,
|
||||
WebSocketPolyfill: WebSocket,
|
||||
maxAttempts: 1,
|
||||
quiet: true,
|
||||
});
|
||||
|
||||
const providerName = uuidv4();
|
||||
const provider = new HocuspocusProvider({
|
||||
websocketProvider: wsHocus,
|
||||
name: providerName,
|
||||
broadcast: false,
|
||||
quiet: true,
|
||||
preserveConnection: false,
|
||||
onClose: (data) => {
|
||||
onAuthenticationFailed(data) {
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
' --- ',
|
||||
@@ -126,7 +116,7 @@ describe('Server Tests', () => {
|
||||
);
|
||||
|
||||
wsHocus.stopConnectionAttempt();
|
||||
expect(data.event.reason).toBe('Forbidden');
|
||||
expect(data.reason).toBe('permission-denied');
|
||||
wsHocus.webSocket?.close();
|
||||
wsHocus.disconnect();
|
||||
provider.destroy();
|
||||
@@ -135,6 +125,8 @@ describe('Server Tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
provider.attach();
|
||||
|
||||
return promise;
|
||||
});
|
||||
|
||||
@@ -145,16 +137,12 @@ describe('Server Tests', () => {
|
||||
url: `ws://localhost:${portWS}/?room=${room}`,
|
||||
WebSocketPolyfill: WebSocket,
|
||||
maxAttempts: 1,
|
||||
quiet: true,
|
||||
});
|
||||
|
||||
const provider = new HocuspocusProvider({
|
||||
websocketProvider: wsHocus,
|
||||
name: room,
|
||||
broadcast: false,
|
||||
quiet: true,
|
||||
preserveConnection: false,
|
||||
onClose: (data) => {
|
||||
onAuthenticationFailed: (data) => {
|
||||
expect(console.log).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
' --- ',
|
||||
@@ -163,7 +151,7 @@ describe('Server Tests', () => {
|
||||
);
|
||||
|
||||
wsHocus.stopConnectionAttempt();
|
||||
expect(data.event.reason).toBe('Forbidden');
|
||||
expect(data.reason).toBe('permission-denied');
|
||||
wsHocus.webSocket?.close();
|
||||
wsHocus.disconnect();
|
||||
provider.destroy();
|
||||
@@ -172,6 +160,8 @@ describe('Server Tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
provider.attach();
|
||||
|
||||
return promise;
|
||||
});
|
||||
|
||||
@@ -182,16 +172,12 @@ describe('Server Tests', () => {
|
||||
url: `ws://localhost:${portWS}/?room=${room}`,
|
||||
WebSocketPolyfill: WebSocket,
|
||||
maxAttempts: 1,
|
||||
quiet: true,
|
||||
});
|
||||
|
||||
const provider = new HocuspocusProvider({
|
||||
websocketProvider: wsHocus,
|
||||
name: room,
|
||||
broadcast: false,
|
||||
quiet: true,
|
||||
preserveConnection: false,
|
||||
onClose: (data) => {
|
||||
onAuthenticationFailed: (data) => {
|
||||
expect(console.log).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
' --- ',
|
||||
@@ -200,7 +186,7 @@ describe('Server Tests', () => {
|
||||
);
|
||||
|
||||
wsHocus.stopConnectionAttempt();
|
||||
expect(data.event.reason).toBe('Forbidden');
|
||||
expect(data.reason).toBe('permission-denied');
|
||||
wsHocus.webSocket?.close();
|
||||
wsHocus.disconnect();
|
||||
provider.destroy();
|
||||
@@ -209,6 +195,8 @@ describe('Server Tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
provider.attach();
|
||||
|
||||
return promise;
|
||||
});
|
||||
|
||||
@@ -224,23 +212,19 @@ describe('Server Tests', () => {
|
||||
url: `ws://localhost:${portWS}/?room=${room}`,
|
||||
WebSocketPolyfill: WebSocket,
|
||||
maxAttempts: 1,
|
||||
quiet: true,
|
||||
});
|
||||
|
||||
const provider = new HocuspocusProvider({
|
||||
websocketProvider: wsHocus,
|
||||
name: room,
|
||||
broadcast: false,
|
||||
quiet: true,
|
||||
preserveConnection: false,
|
||||
onClose: (data) => {
|
||||
onAuthenticationFailed: (data) => {
|
||||
expect(console.error).toHaveBeenLastCalledWith(
|
||||
'[onConnect]',
|
||||
'Backend error: Unauthorized',
|
||||
);
|
||||
|
||||
wsHocus.stopConnectionAttempt();
|
||||
expect(data.event.reason).toBe('Forbidden');
|
||||
expect(data.reason).toBe('permission-denied');
|
||||
expect(fetchDocumentMock).toHaveBeenCalledExactlyOnceWith(
|
||||
room,
|
||||
expect.any(Object),
|
||||
@@ -253,6 +237,8 @@ describe('Server Tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
provider.attach();
|
||||
|
||||
return promise;
|
||||
});
|
||||
|
||||
@@ -269,16 +255,12 @@ describe('Server Tests', () => {
|
||||
url: `ws://localhost:${portWS}/?room=${room}`,
|
||||
WebSocketPolyfill: WebSocket,
|
||||
maxAttempts: 1,
|
||||
quiet: true,
|
||||
});
|
||||
|
||||
const provider = new HocuspocusProvider({
|
||||
websocketProvider: wsHocus,
|
||||
name: room,
|
||||
broadcast: false,
|
||||
quiet: true,
|
||||
preserveConnection: false,
|
||||
onClose: (data) => {
|
||||
onAuthenticationFailed: (data) => {
|
||||
expect(console.log).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
' --- ',
|
||||
@@ -287,7 +269,7 @@ describe('Server Tests', () => {
|
||||
);
|
||||
|
||||
wsHocus.stopConnectionAttempt();
|
||||
expect(data.event.reason).toBe('Forbidden');
|
||||
expect(data.reason).toBe('permission-denied');
|
||||
expect(fetchDocumentMock).toHaveBeenCalledExactlyOnceWith(
|
||||
room,
|
||||
expect.any(Object),
|
||||
@@ -300,6 +282,8 @@ describe('Server Tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
provider.attach();
|
||||
|
||||
return promise;
|
||||
});
|
||||
|
||||
@@ -322,10 +306,8 @@ describe('Server Tests', () => {
|
||||
const provider = new HocuspocusProvider({
|
||||
websocketProvider: wsHocus,
|
||||
name: room,
|
||||
broadcast: false,
|
||||
quiet: true,
|
||||
onConnect: () => {
|
||||
void hocuspocusServer
|
||||
void hocuspocusServer.hocuspocus
|
||||
.openDirectConnection(room)
|
||||
.then((connection) => {
|
||||
connection.document?.getConnections().forEach((connection) => {
|
||||
@@ -347,6 +329,8 @@ describe('Server Tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
provider.attach();
|
||||
|
||||
return promise;
|
||||
});
|
||||
});
|
||||
@@ -373,30 +357,30 @@ describe('Server Tests', () => {
|
||||
const provider = new HocuspocusProvider({
|
||||
websocketProvider: wsHocus,
|
||||
name: room,
|
||||
broadcast: false,
|
||||
quiet: true,
|
||||
onConnect: () => {
|
||||
void hocuspocusServer.openDirectConnection(room).then((connection) => {
|
||||
connection.document?.getConnections().forEach((connection) => {
|
||||
const document = hocuspocusServer.hocuspocus.documents.get(room);
|
||||
if (document) {
|
||||
document.getConnections().forEach((connection) => {
|
||||
expect(connection.context.userId).toBe('test-user-id');
|
||||
});
|
||||
}
|
||||
|
||||
void connection.disconnect();
|
||||
provider.destroy();
|
||||
wsHocus.destroy();
|
||||
provider.destroy();
|
||||
wsHocus.destroy();
|
||||
|
||||
expect(fetchDocumentMock).toHaveBeenCalledWith(
|
||||
room,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(fetchDocumentMock).toHaveBeenCalledWith(
|
||||
room,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
expect(fetchCurrentUserMock).toHaveBeenCalled();
|
||||
expect(fetchCurrentUserMock).toHaveBeenCalled();
|
||||
|
||||
done();
|
||||
});
|
||||
done();
|
||||
},
|
||||
});
|
||||
|
||||
provider.attach();
|
||||
|
||||
return promise;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server-y-provider",
|
||||
"version": "3.8.21",
|
||||
"version": "3.8.2",
|
||||
"description": "Y.js provider for docs",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"license": "MIT",
|
||||
@@ -10,17 +10,18 @@
|
||||
"dev": "cross-env COLLABORATION_LOGGING=true && nodemon --config nodemon.json",
|
||||
"start": "node ./dist/start-server.js",
|
||||
"lint": "eslint",
|
||||
"test": "vitest --run --disable-console-intercept"
|
||||
"test": "vitest"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocknote/server-util": "0.37.0",
|
||||
"@hocuspocus/server": "2.15.2",
|
||||
"@sentry/node": "10.11.0",
|
||||
"@sentry/profiling-node": "10.11.0",
|
||||
"axios": "1.12.0",
|
||||
"@blocknote/server-util": "0.41.1",
|
||||
"@hocuspocus/server": "3.3.0",
|
||||
"@sentry/node": "10.17.0",
|
||||
"@sentry/profiling-node": "10.17.0",
|
||||
"@tiptap/extensions": "*",
|
||||
"axios": "1.12.2",
|
||||
"cors": "2.8.5",
|
||||
"express": "5.1.0",
|
||||
"express-ws": "5.0.2",
|
||||
@@ -29,14 +30,15 @@
|
||||
"yjs": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@blocknote/core": "0.41.1",
|
||||
"@hocuspocus/provider": "3.3.0",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/express-ws": "3.0.5",
|
||||
"@types/node": "*",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/ws": "8.18.1",
|
||||
"cross-env": "10.0.0",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint-plugin-docs": "*",
|
||||
"nodemon": "3.1.10",
|
||||
"supertest": "7.1.4",
|
||||
|
||||
@@ -25,12 +25,12 @@ export const collaborationResetConnectionsHandler = (
|
||||
* If no user ID is provided, close all connections in the room
|
||||
*/
|
||||
if (!userId) {
|
||||
hocuspocusServer.closeConnections(room);
|
||||
hocuspocusServer.hocuspocus.closeConnections(room);
|
||||
} else {
|
||||
/**
|
||||
* Close connections for the user in the room
|
||||
*/
|
||||
hocuspocusServer.documents.forEach((doc) => {
|
||||
hocuspocusServer.hocuspocus.documents.forEach((doc) => {
|
||||
if (doc.name !== room) {
|
||||
return;
|
||||
}
|
||||
|
||||