mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 23:22:15 +02:00
Compare commits
10 Commits
feature/lo
...
feature/pu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eff663a78 | ||
|
|
feabab030d | ||
|
|
4002a049eb | ||
|
|
c7d8211aac | ||
|
|
25a24eaf12 | ||
|
|
9adecf93ec | ||
|
|
6a3a07db31 | ||
|
|
79b56ccd4e | ||
|
|
611d77f3bb | ||
|
|
de0acedc0a |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -11,22 +11,12 @@ and this project adheres to
|
||||
|
||||
## Added
|
||||
|
||||
- ✨Add link public/authenticated/restricted access with read/editor roles #234
|
||||
- ✨(frontend) add copy link button #235
|
||||
- 🛂(frontend) access public docs without being logged #235
|
||||
- 🌐(frontend) add localization to editor #268
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️ Allow null titles on documents for easier creation #234
|
||||
- 🛂(backend) stop to list public doc to everyone #234
|
||||
- 🚚(frontend) change visibility in share modal #235
|
||||
- ⚡️(frontend) Improve summary #244
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛 Fix forcing ID when creating a document via API endpoint #234
|
||||
- 🐛 Rebuild frontend dev container from makefile #248
|
||||
|
||||
|
||||
## [1.3.0] - 2024-09-05
|
||||
@@ -42,6 +32,7 @@ and this project adheres to
|
||||
|
||||
- 💄(frontend) code background darkened on editor #214
|
||||
- 🔥(frontend) hide markdown button if not text #213
|
||||
- 🛂(backend) stop to list public doc to everyone #234
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -157,4 +148,4 @@ and this project adheres to
|
||||
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
|
||||
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
|
||||
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
|
||||
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
|
||||
1
Makefile
1
Makefile
@@ -92,7 +92,6 @@ bootstrap: \
|
||||
# -- Docker/compose
|
||||
build: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev --no-cache
|
||||
@$(COMPOSE) build frontend-dev --no-cache
|
||||
.PHONY: build
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
|
||||
@@ -7,8 +7,8 @@ from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import (
|
||||
OuterRef,
|
||||
Q,
|
||||
@@ -358,11 +358,11 @@ class DocumentViewSet(
|
||||
try:
|
||||
# Add a trace that the user visited the document (this is needed to include
|
||||
# the document in the user's list view)
|
||||
models.LinkTrace.objects.create(
|
||||
models.LinkTrace.objects.update_or_create(
|
||||
document=instance,
|
||||
user=self.request.user,
|
||||
)
|
||||
except ValidationError:
|
||||
except IntegrityError:
|
||||
# The trace already exists, so we just pass without doing anything
|
||||
pass
|
||||
|
||||
@@ -375,7 +375,7 @@ class DocumentViewSet(
|
||||
to the document
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
raise exceptions.PermissionDenied("Authentication required.")
|
||||
return drf_response.Response([])
|
||||
|
||||
document = self.get_object()
|
||||
user = request.user
|
||||
|
||||
@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='link_reach',
|
||||
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
|
||||
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
|
||||
@@ -324,7 +324,7 @@ class Document(BaseModel):
|
||||
link_reach = models.CharField(
|
||||
max_length=20,
|
||||
choices=LinkReachChoices.choices,
|
||||
default=LinkReachChoices.AUTHENTICATED,
|
||||
default=LinkReachChoices.RESTRICTED,
|
||||
)
|
||||
link_role = models.CharField(
|
||||
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
|
||||
|
||||
@@ -29,8 +29,8 @@ def test_api_document_versions_list_anonymous(role, reach):
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/versions/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Authentication required."}
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
|
||||
@@ -5,7 +5,7 @@ Tests for Documents API endpoint in impress's core app: retrieve
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -94,38 +94,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
assert (
|
||||
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_retrieve_authenticated_trace_twice(reach):
|
||||
"""
|
||||
Accessing a document several times should not raise any error even though the
|
||||
trace already exists for this document and user.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
assert (
|
||||
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
|
||||
)
|
||||
|
||||
client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert (
|
||||
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
|
||||
)
|
||||
|
||||
# A second visit should not raise any error
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_api_documents_retrieve_authenticated_unrelated_restricted():
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Doc Summary', () => {
|
||||
test('it checks the doc summary', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-summary', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Summary',
|
||||
})
|
||||
.click();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
const editor = page.locator('.ProseMirror');
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 1').click();
|
||||
await page.keyboard.type('Hello World');
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
|
||||
// Create space to fill the viewport
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 2').click();
|
||||
await page.keyboard.type('Super World');
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
|
||||
// Create space to fill the viewport
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 3').click();
|
||||
await page.keyboard.type('Another World');
|
||||
|
||||
await expect(panel.getByText('Hello World')).toBeVisible();
|
||||
await expect(panel.getByText('Super World')).toBeVisible();
|
||||
|
||||
await panel.getByText('Another World').click();
|
||||
|
||||
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
||||
|
||||
await panel.getByText('Back to top').click();
|
||||
await expect(editor.getByText('Hello World')).toBeInViewport();
|
||||
|
||||
await panel.getByText('Go to bottom').click();
|
||||
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Doc Table Content', () => {
|
||||
test('it checks the doc table content', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-table-content',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Table of content',
|
||||
})
|
||||
.click();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
const editor = page.locator('.ProseMirror');
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 1').click();
|
||||
await page.keyboard.type('Hello World');
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
|
||||
// Create space to fill the viewport
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 2').click();
|
||||
await page.keyboard.type('Super World');
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
|
||||
// Create space to fill the viewport
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 3').click();
|
||||
await page.keyboard.type('Another World');
|
||||
|
||||
const hello = panel.getByText('Hello World');
|
||||
const superW = panel.getByText('Super World');
|
||||
const another = panel.getByText('Another World');
|
||||
|
||||
await expect(hello).toBeVisible();
|
||||
await expect(hello).toHaveCSS('font-size', '19.2px');
|
||||
await expect(hello).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await expect(superW).toBeVisible();
|
||||
await expect(superW).toHaveCSS('font-size', '16px');
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await expect(another).toBeVisible();
|
||||
await expect(another).toHaveCSS('font-size', '12.8px');
|
||||
await expect(another).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await hello.click();
|
||||
|
||||
await expect(editor.getByText('Hello World')).toBeInViewport();
|
||||
await expect(hello).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await another.click();
|
||||
|
||||
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
||||
await expect(hello).toHaveAttribute('aria-selected', 'false');
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await panel.getByText('Back to top').click();
|
||||
await expect(editor.getByText('Hello World')).toBeInViewport();
|
||||
await expect(hello).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
await panel.getByText('Go to bottom').click();
|
||||
await expect(editor.getByText('Hello World')).not.toBeInViewport();
|
||||
await expect(superW).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@
|
||||
"test:ui::chromium": "yarn test:ui --project=chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.47.1",
|
||||
"@playwright/test": "1.46.1",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.4",
|
||||
"eslint-config-impress": "*",
|
||||
|
||||
2
src/frontend/apps/impress/next-env.d.ts
vendored
2
src/frontend/apps/impress/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
|
||||
@@ -21,34 +21,33 @@
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@hocuspocus/provider": "2.13.5",
|
||||
"@openfun/cunningham-react": "2.9.4",
|
||||
"@tanstack/react-query": "5.56.2",
|
||||
"i18next": "23.15.1",
|
||||
"@tanstack/react-query": "5.53.2",
|
||||
"i18next": "23.14.0",
|
||||
"idb": "8.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.5.0",
|
||||
"next": "14.2.11",
|
||||
"next": "14.2.7",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.3.3",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.0.2",
|
||||
"react-i18next": "15.0.1",
|
||||
"react-select": "5.8.0",
|
||||
"styled-components": "6.1.13",
|
||||
"yjs": "*",
|
||||
"y-protocols": "1.0.6",
|
||||
"zustand": "4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.56.2",
|
||||
"@tanstack/react-query-devtools": "5.53.2",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.5.0",
|
||||
"@testing-library/react": "16.0.1",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "29.5.13",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.3.6",
|
||||
"@types/react": "18.3.5",
|
||||
"@types/react-dom": "*",
|
||||
"cross-env": "*",
|
||||
"dotenv": "16.4.5",
|
||||
@@ -62,7 +61,6 @@
|
||||
"stylelint-config-standard": "36.0.1",
|
||||
"stylelint-prettier": "5.0.2",
|
||||
"typescript": "*",
|
||||
"webpack": "5.94.0",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Box, Card, IconBG, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
interface PanelProps {
|
||||
title?: string;
|
||||
title: string;
|
||||
setIsPanelOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -53,14 +53,11 @@ export const Panel = ({
|
||||
{...closedOverridingStyles}
|
||||
>
|
||||
<Box
|
||||
$overflow="inherit"
|
||||
$position="sticky"
|
||||
$overflow="hidden"
|
||||
$css={`
|
||||
top: 0;
|
||||
opacity: ${isOpen ? '1' : '0'};
|
||||
transition: ${transition};
|
||||
`}
|
||||
$maxHeight="100%"
|
||||
>
|
||||
<Box
|
||||
$padding={{ all: 'small' }}
|
||||
@@ -93,11 +90,9 @@ export const Panel = ({
|
||||
}}
|
||||
$radius="2px"
|
||||
/>
|
||||
{title && (
|
||||
<Text $weight="bold" $size="l" $theme="primary">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text $weight="bold" $size="l" $theme="primary">
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
BlockNoteEditor as BlockNoteEditorCore,
|
||||
locales,
|
||||
} from '@blocknote/core';
|
||||
import { BlockNoteEditor as BlockNoteEditorCore } from '@blocknote/core';
|
||||
import '@blocknote/core/fonts/inter.css';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
import '@blocknote/mantine/style.css';
|
||||
@@ -21,8 +18,6 @@ import { randomColor } from '../utils';
|
||||
|
||||
import { BlockNoteToolbar } from './BlockNoteToolbar';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const cssEditor = `
|
||||
&, & > .bn-container, & .ProseMirror {
|
||||
height:100%
|
||||
@@ -99,19 +94,6 @@ export const BlockNoteContent = ({
|
||||
[createDocAttachment, doc.id],
|
||||
);
|
||||
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
|
||||
const resetStore = () => {
|
||||
setStore(storeId, { editor: undefined });
|
||||
};
|
||||
|
||||
// Invalidate the stored editor when the language changes
|
||||
useEffect(() => {
|
||||
resetStore();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lang]);
|
||||
|
||||
const editor = useMemo(() => {
|
||||
if (storedEditor) {
|
||||
return storedEditor;
|
||||
@@ -126,10 +108,9 @@ export const BlockNoteContent = ({
|
||||
color: randomColor(),
|
||||
},
|
||||
},
|
||||
dictionary: locales[lang as keyof typeof locales],
|
||||
uploadFile,
|
||||
});
|
||||
}, [provider, storedEditor, uploadFile, userData?.email, lang]);
|
||||
}, [provider, storedEditor, uploadFile, userData?.email]);
|
||||
|
||||
useEffect(() => {
|
||||
setStore(storeId, { editor });
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from '@blocknote/react';
|
||||
import { forEach, isArray } from 'lodash';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const BlockNoteToolbar = () => {
|
||||
return (
|
||||
@@ -94,7 +93,6 @@ export function MarkdownButton() {
|
||||
const editor = useBlockNoteEditor();
|
||||
const Components = useComponentsContext();
|
||||
const selectedBlocks = useSelectedBlocks(editor);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleConvertMarkdown = () => {
|
||||
const blocks = editor.getSelection()?.blocks;
|
||||
@@ -128,7 +126,7 @@ export function MarkdownButton() {
|
||||
|
||||
return (
|
||||
<Components.FormattingToolbar.Button
|
||||
mainTooltip={t('Convert Markdown')}
|
||||
mainTooltip="Convert Markdown"
|
||||
onClick={handleConvertMarkdown}
|
||||
>
|
||||
M
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Panel } from '@/components/Panel';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { DocHeader } from '@/features/docs/doc-header';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
import { TableContent } from '@/features/docs/doc-table-content';
|
||||
import { Summary, useDocSummaryStore } from '@/features/docs/doc-summary';
|
||||
import {
|
||||
VersionList,
|
||||
Versions,
|
||||
@@ -28,6 +28,8 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
query: { versionId },
|
||||
} = useRouter();
|
||||
const { isPanelVersionOpen, setIsPanelVersionOpen } = useDocVersionStore();
|
||||
const { isPanelSummaryOpen, setIsPanelSummaryOpen } = useDocSummaryStore();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isVersion = versionId && typeof versionId === 'string';
|
||||
@@ -70,7 +72,11 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
<VersionList doc={doc} />
|
||||
</Panel>
|
||||
)}
|
||||
<TableContent doc={doc} />
|
||||
{isPanelSummaryOpen && (
|
||||
<Panel title={t('SUMMARY')} setIsPanelOpen={setIsPanelSummaryOpen}>
|
||||
<Summary doc={doc} />
|
||||
</Panel>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ModalShare,
|
||||
ModalUpdateDoc,
|
||||
} from '@/features/docs/doc-management';
|
||||
import { useDocTableContentStore } from '@/features/docs/doc-table-content';
|
||||
import { useDocSummaryStore } from '@/features/docs/doc-summary';
|
||||
import { useDocVersionStore } from '@/features/docs/doc-versioning';
|
||||
|
||||
import { ModalPDF } from './ModalExport';
|
||||
@@ -26,7 +26,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
|
||||
const [isDropOpen, setIsDropOpen] = useState(false);
|
||||
const { setIsPanelVersionOpen } = useDocVersionStore();
|
||||
const { setIsPanelTableContentOpen } = useDocTableContentStore();
|
||||
const { setIsPanelSummaryOpen } = useDocSummaryStore();
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -83,7 +83,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsPanelTableContentOpen(true);
|
||||
setIsPanelSummaryOpen(true);
|
||||
setIsPanelVersionOpen(false);
|
||||
setIsDropOpen(false);
|
||||
}}
|
||||
@@ -91,7 +91,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
icon={<span className="material-icons">summarize</span>}
|
||||
size="small"
|
||||
>
|
||||
<Text $theme="primary">{t('Table of content')}</Text>
|
||||
<Text $theme="primary">{t('Summary')}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, BoxButton, Text } from '@/components';
|
||||
|
||||
import { useDocStore } from '../../doc-editor';
|
||||
import { Doc } from '../../doc-management';
|
||||
import { useDocSummaryStore } from '../stores';
|
||||
|
||||
interface SummaryProps {
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
export const Summary = ({ doc }: SummaryProps) => {
|
||||
const { docsStore } = useDocStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const editor = docsStore?.[doc.id]?.editor;
|
||||
const headingFiltering = useCallback(
|
||||
() => editor?.document.filter((block) => block.type === 'heading'),
|
||||
[editor?.document],
|
||||
);
|
||||
|
||||
const [headings, setHeadings] = useState(headingFiltering());
|
||||
const { setIsPanelSummaryOpen } = useDocSummaryStore();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setIsPanelSummaryOpen(false);
|
||||
};
|
||||
}, [setIsPanelSummaryOpen]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
editor.onEditorContentChange(() => {
|
||||
setHeadings(headingFiltering());
|
||||
});
|
||||
|
||||
return (
|
||||
<Box $overflow="auto" $padding="small">
|
||||
{headings?.map((heading) => (
|
||||
<BoxButton
|
||||
key={heading.id}
|
||||
onClick={() => {
|
||||
editor.focus();
|
||||
editor?.setTextCursorPosition(heading.id, 'end');
|
||||
document
|
||||
.querySelector(`[data-id="${heading.id}"]`)
|
||||
?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}}
|
||||
style={{ textAlign: 'left' }}
|
||||
>
|
||||
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
|
||||
{heading.content?.[0]?.type === 'text' && heading.content?.[0]?.text
|
||||
? `- ${heading.content[0].text}`
|
||||
: ''}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
))}
|
||||
<Box
|
||||
$height="1px"
|
||||
$width="auto"
|
||||
$background="#e5e5e5"
|
||||
$margin={{ vertical: 'small' }}
|
||||
$css="flex: none;"
|
||||
/>
|
||||
<BoxButton
|
||||
onClick={() => {
|
||||
editor.focus();
|
||||
document.querySelector(`.bn-editor`)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
|
||||
{t('Back to top')}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
<BoxButton
|
||||
onClick={() => {
|
||||
editor.focus();
|
||||
document
|
||||
.querySelector(
|
||||
`.bn-editor > .bn-block-group > .bn-block-outer:last-child`,
|
||||
)
|
||||
?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
|
||||
{t('Go to bottom')}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Summary';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useDocSummaryStore';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface UseDocSummaryStore {
|
||||
isPanelSummaryOpen: boolean;
|
||||
setIsPanelSummaryOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const useDocSummaryStore = create<UseDocSummaryStore>((set) => ({
|
||||
isPanelSummaryOpen: false,
|
||||
setIsPanelSummaryOpen: (isPanelSummaryOpen) => {
|
||||
set(() => ({ isPanelSummaryOpen }));
|
||||
},
|
||||
}));
|
||||
@@ -1,66 +0,0 @@
|
||||
import { BlockNoteEditor } from '@blocknote/core';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { BoxButton, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
const sizeMap: { [key: number]: string } = {
|
||||
1: '1.2rem',
|
||||
2: '1rem',
|
||||
3: '0.8rem',
|
||||
};
|
||||
|
||||
export type HeadingsHighlight = {
|
||||
headingId: string;
|
||||
isVisible: boolean;
|
||||
}[];
|
||||
|
||||
interface HeadingProps {
|
||||
editor: BlockNoteEditor;
|
||||
level: number;
|
||||
text: string;
|
||||
headingId: string;
|
||||
isHighlight: boolean;
|
||||
}
|
||||
|
||||
export const Heading = ({
|
||||
headingId,
|
||||
editor,
|
||||
isHighlight,
|
||||
level,
|
||||
text,
|
||||
}: HeadingProps) => {
|
||||
const [isHover, setIsHover] = useState(isHighlight);
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<BoxButton
|
||||
key={headingId}
|
||||
onMouseOver={() => setIsHover(true)}
|
||||
onMouseLeave={() => setIsHover(false)}
|
||||
onClick={() => {
|
||||
editor.focus();
|
||||
editor.setTextCursorPosition(headingId, 'end');
|
||||
document.querySelector(`[data-id="${headingId}"]`)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
$theme="primary"
|
||||
$padding={{ vertical: 'xtiny', left: 'tiny' }}
|
||||
$size={sizeMap[level]}
|
||||
$hasTransition
|
||||
$css={
|
||||
isHover || isHighlight
|
||||
? `box-shadow: -2px 0px 0px ${colorsTokens()[isHighlight ? 'primary-500' : 'primary-400']};`
|
||||
: ''
|
||||
}
|
||||
aria-selected={isHighlight}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
);
|
||||
};
|
||||
@@ -1,180 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, BoxButton, Text } from '@/components';
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { useDocStore } from '@/features/docs/doc-editor';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
|
||||
import { useDocTableContentStore } from '../stores';
|
||||
|
||||
import { Heading } from './Heading';
|
||||
|
||||
type HeadingBlock = {
|
||||
id: string;
|
||||
type: string;
|
||||
text: string;
|
||||
content: HeadingBlock[];
|
||||
props: {
|
||||
level: number;
|
||||
};
|
||||
};
|
||||
|
||||
interface TableContentProps {
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
export const TableContent = ({ doc }: TableContentProps) => {
|
||||
const { docsStore } = useDocStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const editor = docsStore?.[doc.id]?.editor;
|
||||
const headingFiltering = useCallback(
|
||||
() =>
|
||||
editor?.document.filter(
|
||||
(block) => block.type === 'heading',
|
||||
) as unknown as HeadingBlock[],
|
||||
[editor?.document],
|
||||
);
|
||||
|
||||
const [headings, setHeadings] = useState<HeadingBlock[]>();
|
||||
const { setIsPanelTableContentOpen, isPanelTableContentOpen } =
|
||||
useDocTableContentStore();
|
||||
const [hasBeenClose, setHasBeenClose] = useState(false);
|
||||
const setClosePanel = () => {
|
||||
setHasBeenClose(true);
|
||||
setIsPanelTableContentOpen(false);
|
||||
};
|
||||
|
||||
const [headingIdHighlight, setHeadingIdHighlight] = useState<string>();
|
||||
|
||||
// Open the panel if there are more than 1 heading
|
||||
useEffect(() => {
|
||||
if (headings?.length && headings.length > 1 && !hasBeenClose) {
|
||||
setIsPanelTableContentOpen(true);
|
||||
}
|
||||
}, [setIsPanelTableContentOpen, headings, hasBeenClose]);
|
||||
|
||||
// Close the panel unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setIsPanelTableContentOpen(false);
|
||||
};
|
||||
}, [setIsPanelTableContentOpen]);
|
||||
|
||||
// To highlight the first heading in the viewport
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (!headings) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const heading of headings) {
|
||||
const elHeading = document.body.querySelector(
|
||||
`.bn-block-outer[data-id="${heading.id}"]`,
|
||||
);
|
||||
|
||||
if (!elHeading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = elHeading.getBoundingClientRect();
|
||||
const isVisible =
|
||||
rect.top + rect.height >= 1 &&
|
||||
rect.bottom <=
|
||||
(window.innerHeight || document.documentElement.clientHeight);
|
||||
|
||||
if (isVisible) {
|
||||
setHeadingIdHighlight(heading.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
setTimeout(() => {
|
||||
handleScroll();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [headings, setHeadingIdHighlight]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update the headings when the editor content changes
|
||||
editor?.onEditorContentChange(() => {
|
||||
setHeadings(headingFiltering());
|
||||
});
|
||||
|
||||
if (!isPanelTableContentOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel setIsPanelOpen={setClosePanel}>
|
||||
<Box $padding="small" $maxHeight="95%">
|
||||
<Box $overflow="auto">
|
||||
{headings?.map((heading) => {
|
||||
const content = heading.content?.[0];
|
||||
const text = content?.type === 'text' ? content.text : '';
|
||||
|
||||
return (
|
||||
<Heading
|
||||
editor={editor}
|
||||
headingId={heading.id}
|
||||
level={heading.props.level}
|
||||
text={text}
|
||||
key={heading.id}
|
||||
isHighlight={headingIdHighlight === heading.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box
|
||||
$height="1px"
|
||||
$width="auto"
|
||||
$background="#e5e5e5"
|
||||
$margin={{ vertical: 'small' }}
|
||||
$css="flex: none;"
|
||||
/>
|
||||
<BoxButton
|
||||
onClick={() => {
|
||||
editor.focus();
|
||||
document.querySelector(`.bn-editor`)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
|
||||
{t('Back to top')}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
<BoxButton
|
||||
onClick={() => {
|
||||
editor.focus();
|
||||
document
|
||||
.querySelector(
|
||||
`.bn-editor > .bn-block-group > .bn-block-outer:last-child`,
|
||||
)
|
||||
?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
|
||||
{t('Go to bottom')}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
</Box>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './TableContent';
|
||||
@@ -1 +0,0 @@
|
||||
export * from './useDocTableContentStore';
|
||||
@@ -1,15 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface UseDocTableContentStore {
|
||||
isPanelTableContentOpen: boolean;
|
||||
setIsPanelTableContentOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const useDocTableContentStore = create<UseDocTableContentStore>(
|
||||
(set) => ({
|
||||
isPanelTableContentOpen: false,
|
||||
setIsPanelTableContentOpen: (isPanelTableContentOpen) => {
|
||||
set(() => ({ isPanelTableContentOpen }));
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -125,7 +125,7 @@
|
||||
"Validate the modification": "Valider les modifications",
|
||||
"Version restored successfully": "Version restaurée avec succès",
|
||||
"We didn't find a mail matching, try to be more accurate": "Nous n'avons pas trouvé de correspondance par mail, essayez d'être plus précis",
|
||||
"We simply comply with the law, which states that certain audience measurement tools, properly configured to respect privacy, are exempt from prior authorization.": "Nous nous conformons simplement à la loi, qui stipule que certains outils de mesure d'audience, correctement configurés pour respecter la vie privée, sont exemptés de toute autorisation préalable.",
|
||||
"We simply comply with the law, which states that certain audience measurement tools, properly configured to respect privacy, are exempt from prior authorization.": "Nous nous conformons simplement à la loi, qui stipule que certains outils de mesure d’audience, correctement configurés pour respecter la vie privée, sont exemptés de toute autorisation préalable.",
|
||||
"We try to respond within 2 working days.": "Nous essayons de répondre dans les 2 jours ouvrables.",
|
||||
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Vous êtes le seul propriétaire de ce groupe, faites d'un autre membre le propriétaire du groupe, avant de pouvoir modifier votre propre rôle ou vous supprimer du document.",
|
||||
"You can oppose the tracking of your browsing on this website.": "Vous pouvez vous opposer au suivi de votre navigation sur ce site.",
|
||||
@@ -140,8 +140,7 @@
|
||||
"accessibility-dinum-services": "<strong>DINUM</strong> s'engage à rendre accessibles ses services numériques, conformément à l'article 47 de la loi n° 2005-102 du 11 février 2005.",
|
||||
"accessibility-form-defenseurdesdroits": "Écrire un message au<1>Défenseur des droits</1>",
|
||||
"accessibility-not-audit": "<strong>docs.numerique.gouv.fr</strong> n'est pas en conformité avec le RGAA 4.1. Le site n'a <strong>pas encore été audité.</strong>",
|
||||
"you have reported to the website manager a lack of accessibility that prevents you from accessing content or one of the services of the portal and you have not received a satisfactory response.": "vous avez signalé au responsable du site internet un défaut d'accessibilité qui vous empêche d'accéder à un contenu ou à un des services du portail et vous n'avez pas obtenu de réponse satisfaisante.",
|
||||
"Convert Markdown": "Convertir Markdown"
|
||||
"you have reported to the website manager a lack of accessibility that prevents you from accessing content or one of the services of the portal and you have not received a satisfactory response.": "vous avez signalé au responsable du site internet un défaut d'accessibilité qui vous empêche d'accéder à un contenu ou à un des services du portail et vous n'avez pas obtenu de réponse satisfaisante."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,18 +25,15 @@
|
||||
"i18n:test": "yarn I18N run test"
|
||||
},
|
||||
"resolutions": {
|
||||
"@blocknote/core": "0.15.7",
|
||||
"@blocknote/mantine": "0.15.7",
|
||||
"@blocknote/react": "0.15.7",
|
||||
"@types/node": "20.16.5",
|
||||
"@blocknote/core": "0.15.6",
|
||||
"@blocknote/mantine": "0.15.6",
|
||||
"@blocknote/react": "0.15.6",
|
||||
"@types/node": "20.16.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.5.0",
|
||||
"@typescript-eslint/parser": "8.5.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.57.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"typescript": "5.6.2",
|
||||
"yjs": "13.6.19"
|
||||
"typescript": "5.5.4",
|
||||
"yjs": "13.6.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,19 @@
|
||||
"lint": "eslint --ext .js ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "14.2.11",
|
||||
"@tanstack/eslint-plugin-query": "5.56.1",
|
||||
"@typescript-eslint/eslint-plugin": "*",
|
||||
"@typescript-eslint/parser": "*",
|
||||
"eslint": "*",
|
||||
"eslint-config-next": "14.2.11",
|
||||
"@next/eslint-plugin-next": "14.2.7",
|
||||
"@tanstack/eslint-plugin-query": "5.53.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.3.0",
|
||||
"@typescript-eslint/parser": "8.3.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "14.2.7",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-import": "2.30.0",
|
||||
"eslint-plugin-jest": "28.8.3",
|
||||
"eslint-plugin-jsx-a11y": "6.10.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-jest": "28.8.2",
|
||||
"eslint-plugin-jsx-a11y": "6.9.0",
|
||||
"eslint-plugin-playwright": "1.6.2",
|
||||
"eslint-plugin-prettier": "5.2.1",
|
||||
"eslint-plugin-react": "7.36.1",
|
||||
"eslint-plugin-testing-library": "6.3.0",
|
||||
"prettier": "3.3.3"
|
||||
"eslint-plugin-react": "7.35.0",
|
||||
"eslint-plugin-testing-library": "6.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/jest": "29.5.13",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "*",
|
||||
"eslint-config-impress": "*",
|
||||
"eslint-plugin-import": "2.30.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"i18next-parser": "9.0.2",
|
||||
"jest": "29.7.0",
|
||||
"ts-jest": "29.2.5",
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hocuspocus/server": "2.13.5",
|
||||
"y-protocols": "1.0.6"
|
||||
"@hocuspocus/server": "2.13.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "*",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -72,8 +72,7 @@ frontend:
|
||||
envVars:
|
||||
PORT: 8080
|
||||
NEXT_PUBLIC_API_ORIGIN: https://impress.127.0.0.1.nip.io
|
||||
NEXT_PUBLIC_Y_PROVIDER_URL: wss://impress.127.0.0.1.nip.io/ws
|
||||
NEXT_PUBLIC_MEDIA_URL: https://impress.127.0.0.1.nip.io
|
||||
NEXT_PUBLIC_SIGNALING_URL: wss://impress.127.0.0.1.nip.io/ws
|
||||
|
||||
replicas: 1
|
||||
command:
|
||||
|
||||
Reference in New Issue
Block a user