Compare commits

..

23 Commits

Author SHA1 Message Date
Your name b2a70ecd36 💄 (frontend) the focus style has been harmonized across all button 2025-02-25 12:21:26 +01:00
MarineM1 293c30a997 Merge branch 'main' into accessibility-clean 2025-02-24 13:18:04 +01:00
Your name 73d9a6a243 💄 (frontend) several icon modifications
- Decorative icons are no longer keyboard-focusable.
- Home icons appear with an aria-label.
- In the second navigation, hide the icons with aria-hidden="true".
- Add a lang="en" attribute to the term 'English' and a lang="de" attribute to 'Deutsch'
2025-02-24 11:42:33 +01:00
Anthony LC 54a75bc338 ♻️(frontend) update some elements
- add panel information when document is
authenticated
- add a copy link button in the toolbox
on the document
- fix when long title document
- modals fit design
- mobile responsive changes
2025-02-24 10:49:20 +01:00
Anthony LC 50d098c777 💄(frontend) adapt language picker design
Adapt the language picker design to
to fit with the new design of the app.
2025-02-24 10:49:20 +01:00
Anthony LC 757c09b189 ♻️(frontend) adapt other error pages to new design
Adapt the other error pages to the new design.
2025-02-24 10:27:42 +01:00
Anthony LC 30c5cfab62 💄(frontend) add page 401
Add a 401 page when user try to access a
doc that need authentication.
2025-02-24 10:27:42 +01:00
Anthony LC f069329e18 💄(frontend) add page 403
Add a page and integrate the design for the
403 error page.
2025-02-24 10:27:42 +01:00
Your name 23864ea563 Merge branch 'accessibility-clean' of https://github.com/suitenumerique/docs into accessibility-clean
?
2025-02-24 08:51:37 +01:00
Your name 22521a1b55 essaie de mise à jour 2025-02-21 11:12:20 +01:00
MarineM1 d7d6a8efab Merge branch 'main' into accessibility-clean 2025-02-20 11:06:09 +01:00
MarineM1 1b5af360fb Merge branch 'main' into accessibility-clean 2025-02-20 09:47:56 +01:00
MarineM1 fe93caaf02 Merge branch 'main' into accessibility-clean 2025-02-19 10:00:14 +01:00
Your name 5b6bedeb85 💄(frontend) add focus
To highlight the focus on the buttons.
2025-02-18 10:41:27 +01:00
MarineM1 423f78a13d Merge branch 'main' into accessibility-clean 2025-02-18 09:32:09 +01:00
Your name 7e85e7b62c Merge branch 'accessibility-clean' of https://github.com/suitenumerique/docs into accessibility-clean 2025-02-17 14:24:57 +01:00
Your name 9833d9d9cf 🌐 (frontend) define html language
Modifications made following the feedback.
2025-02-17 14:15:28 +01:00
MarineM1 92380bf292 Update src/frontend/apps/impress/src/pages/_app.tsx
Co-authored-by: Anthony LC <anthony.le-courric@mail.numerique.gouv.fr>
2025-02-17 14:10:05 +01:00
MarineM1 0c0521f8bd Update src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx
Co-authored-by: Anthony LC <anthony.le-courric@mail.numerique.gouv.fr>
2025-02-17 14:09:45 +01:00
Your name 0bccd95d92 🌐(frontend) define html language
Definition of the language in the HTML code dynamically
2025-02-17 10:24:28 +01:00
Your name f414019ad8 pull
Merge branch 'main' of https://github.com/suitenumerique/docs into accessibility-clean
2025-02-17 09:27:09 +01:00
Your name fc28616a82 Merge branch 'main' of https://github.com/suitenumerique/docs into accessibility-clean 2025-02-17 09:25:47 +01:00
Your name a8d2e2b7bd 💄 (frontend) add missing background
By following the "Docs - Accessibility" documentation, a background color is applied to the left block.
2025-02-14 15:37:33 +01:00
84 changed files with 1675 additions and 1846 deletions
-1
View File
@@ -6,7 +6,6 @@ on:
push:
branches:
- 'main'
- 'feature/collab-long-polling'
tags:
- 'v*'
pull_request:
+2 -2
View File
@@ -10,19 +10,19 @@ and this project adheres to
## Added
- ✨Collaboration long polling fallback #517
- 💄(frontend) add error pages #643
## Changed
- 🛂(frontend) Restore version visibility #629
- 📝(doc) minor README.md formatting and wording enhancements
- ♻️Stop setting a default title on doc creation #634
- ♻️(frontend) misc ui improvements #644
## Fixed
- ♻️(frontend) improve table pdf rendering
## [2.2.0] - 2025-02-10
## Added
@@ -1 +0,0 @@
proxy_cache_path /tmp/auth_cache levels=1:2 keys_zone=auth_cache:10m inactive=60s max_size=100m;
+3 -15
View File
@@ -1,3 +1,4 @@
server {
listen 8083;
server_name localhost;
@@ -5,14 +6,6 @@ server {
# Proxy auth for collaboration server
location /collaboration/ws/ {
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' 'http://localhost:3000';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type';
return 204;
}
# Collaboration Auth request configuration
auth_request /collaboration-auth;
auth_request_set $authHeader $upstream_http_authorization;
@@ -41,10 +34,6 @@ server {
}
location /collaboration-auth {
proxy_cache auth_cache;
proxy_cache_key "$http_authorization-$arg_room";
proxy_cache_valid 200 30s;
proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -54,11 +43,10 @@ server {
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Accept "application/json";
proxy_set_header X-Original-Method $request_method;
}
location /collaboration/api/ {
location /collaboration/api/ {
# Collaboration server
proxy_pass http://y-provider:4444;
proxy_set_header Host $host;
@@ -88,7 +76,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
-84
View File
@@ -1,84 +0,0 @@
# Architecture Overview
This architecture showcases different ways for clients to interact with a **Hocus Pocus Server** (a [Y.js](https://github.com/yjs/yjs) provider) through either WebSockets, HTTP fallbacks, or Server-Sent Events (SSE) when WebSockets are not available.
**Main Components**:
- **Client**: The front-end application or user agent.
- **Nginx**: A reverse proxy handling incoming requests, forwarding them to the appropriate services, and managing SSL/TLS termination if needed.
- **Auth Sub Request (Django)**: Handles authentication/authorization, ensuring requests have valid credentials or permissions.
- **Hocus Pocus Server**: The core collaborative editing server (powered by [Y.js](https://github.com/yjs/yjs) libraries) that manages document state and synchronization.
- **Express**: Fallback server to handle push or pull requests when WebSocket connections fail.
- **SSE**: A mechanism (Server-Sent Events) for real-time updates when WebSockets are unavailable.
## Mermaid Diagram
```mermaid
flowchart TD
title1[WebSocket Success]-->Client1(Client)<--->|WebSocket Success|WS1(Websocket) --> Nginx1(Ngnix) <--> Auth1("Auth Sub Request (Django)") --->|With the good right|YServer1("Hocus Pocus Server")
YServer1 --> WS1
YServer1 <--> clients(Dispatch to clients)
title2[WebSocket Fails - Push data]-->Client2(Client)---|WebSocket fails|HTTP2(HTTP) --> Nginx2(Ngnix) <--> Auth2("Auth Sub Request (Django)")--->|With the good right|Express2(Express) --> YServer2("Hocus Pocus Server") --> clients(Dispatch to clients)
title3[WebSocket Fails - Pull data]-->Client3(Client)<--->|WebSocket fails|SSE(SSE) --> Nginx3(Ngnix) <--> Auth3("Auth Sub Request (Django)") --->|With the good right|Express3(Express) --> YServer3("Listen Hocus Pocus Server")
YServer3("Listen Hocus Pocus Server") --> SSE
YServer3("Listen Hocus Pocus Server") <--> clients(Data from clients)
```
---
## Detailed Flows
### 1. WebSocket Success
1. **Client** attempts a WebSocket connection.
2. **Nginx** proxies the WebSocket connection through the **Auth Sub Request (Django)** for authentication.
3. Once authenticated, traffic is routed to the **Hocus Pocus Server**.
4. The server can broadcast data to all clients connected through WebSockets.
- Note: The path `YServer1 --> WS1` indicates the two-way real-time communication between the server and client(s).
### 2. WebSocket Fails — Push Data (HTTP)
If WebSocket connections fail, clients can **push** data via HTTP:
1. **Client** detects WebSocket failure and falls back to sending data over **HTTP**.
2. **Nginx** handles HTTP requests and authenticates them via the **Auth Sub Request (Django)**.
3. After successful authentication, the requests go to an **Express** server.
4. The **Express** server relays changes to the **Hocus Pocus Server**.
5. The **Hocus Pocus Server** dispatches updated content to connected clients.
### 3. WebSocket Fails — Pull Data (SSE)
For continuously receiving data when WebSockets fail, the client can **pull** data using SSE:
1. **Client** sets up an **SSE** connection.
2. **Nginx** proxies the SSE stream request through the **Auth Sub Request (Django)** for authentication.
3. Once authenticated, the **Express** server listens to the **Hocus Pocus Server** for changes.
4. The server then sends updates back to the **Client** through SSE in near real-time.
---
## Component Responsibilities
| **Component** | **Responsibility** |
|-----------------------------|-----------------------------------------------------------------------------------------|
| **Client** | Initiates connections (WebSocket/HTTP/SSE), displays and interacts with data |
| **Nginx** | Acts as a reverse proxy, routes traffic, handles SSL, and passes auth sub requests |
| **Auth Sub Request (Django)** | Validates requests, ensuring correct permissions and tokens |
| **WebSocket** | Real-time two-way communication channel |
| **HTTP** | Fallback method for sending updates when WebSockets are not available |
| **Express** | Fallback server for handling requests (push/pull of data) |
| **SSE** | Mechanism for real-time one-way updates from server to client |
| **Hocus Pocus Server** | Core Y.js server for collaboration, managing document states and synchronization |
---
## Why This Setup?
- **Reliability:** Ensures that when a users browser or network environment does not support WebSockets, there are fallback mechanisms (HTTP for push updates and SSE for server-initiated updates).
- **Scalability:** Nginx can efficiently proxy requests and scale horizontally, while the authentication step is centralized in Django.
- **Security:** The Auth Sub Request in Django enforces proper permissions before data is relayed to the collaboration server.
- **Real-time Collaboration:** The Hocus Pocus Server provides low-latency updates, essential for collaborative editing, supported by [Y.js](https://github.com/yjs/yjs).
---
### Contributing
If you have any suggestions or improvements, feel free to open an issue or submit a pull request.
**Thank you for exploring this architecture!** If you have any questions or need more detailed explanations, please let us know.
+745
View File
@@ -0,0 +1,745 @@
{
"name": "docs",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@ag-media/react-pdf-table": "^2.0.1"
}
},
"node_modules/@ag-media/react-pdf-table": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@ag-media/react-pdf-table/-/react-pdf-table-2.0.1.tgz",
"integrity": "sha512-UMNdGYAfuI6L1wLRziYmwcp/8I2JgbwX+PY7bHXGb2+P6MwgFJH8W71qZO1bxfxrmVUTP8YblQwl1PkXG2m6Rg==",
"license": "MIT",
"peerDependencies": {
"@react-pdf/renderer": "^2.0.2 || ^3.0.0 || ^4.0.0",
"@react-pdf/stylesheet": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
"react": "^16.8.6 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
"integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
"license": "MIT",
"peer": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@react-pdf/fns": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.1.tgz",
"integrity": "sha512-fYvgOWWRxTdkCciLSla2iek8W/oDLhExPTLPw3aArGPJHgVUc86V2c3YLULNHIBuy/64QVpPLB7gwNkTEW5m/A==",
"license": "MIT",
"peer": true
},
"node_modules/@react-pdf/font": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-3.1.0.tgz",
"integrity": "sha512-5q+r3DhZK41gVZp2Uw5M69FEVWeoasnM/HscW3kdpYnwjcB2bhCRWmBGCjm8fmuwQstwNPM1ZxyCWZRTRchwnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/types": "^2.8.0",
"fontkit": "^2.0.2",
"is-url": "^1.2.4"
}
},
"node_modules/@react-pdf/image": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.2.tgz",
"integrity": "sha512-GrlApNDxLdFKN1ia+nt1svrnpBJIwf2ncK4Km/hQzAkbALn0HQ5YVrOEtMpnp/c0L0o9zO4hSoPL9iEQne5vzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/png-js": "^3.0.0",
"jay-peg": "^1.1.1"
}
},
"node_modules/@react-pdf/layout": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.2.3.tgz",
"integrity": "sha512-sSL14ki0nC8YwSrjkOzKI1ZV4xZC68v/wA5EFWW6IhmJ3qjX4+KbNdprWBCtih/Xbq2Kt9K1erZpKFiWoVf5/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.1",
"@react-pdf/image": "^3.0.2",
"@react-pdf/pdfkit": "^4.0.2",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.0.0",
"@react-pdf/textkit": "^5.0.3",
"@react-pdf/types": "^2.8.0",
"emoji-regex": "^10.3.0",
"queue": "^6.0.1",
"yoga-layout": "^3.2.1"
}
},
"node_modules/@react-pdf/layout/node_modules/@react-pdf/stylesheet": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.0.0.tgz",
"integrity": "sha512-uAwuMjbcEaxhRl7tGlqxAbLzo/KoYr6v9JksUJwgzd+rkvAp8jDq8NcG3sUp88tzgIyyRjBGl4FewgdxbAa2uw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/fns": "3.1.1",
"@react-pdf/types": "^2.8.0",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/pdfkit": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.0.2.tgz",
"integrity": "sha512-pyYFAI7YL5Oud60W+wcu9zsN73tg8XgHGtEM8FQ6PY4RgEKp+AXkj+YE2hKuX3eOVB65MPzbJbVWtTjO3MPa5g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/png-js": "^3.0.0",
"browserify-zlib": "^0.2.0",
"crypto-js": "^4.2.0",
"fontkit": "^2.0.2",
"jay-peg": "^1.1.1",
"linebreak": "^1.1.0",
"vite-compatible-readable-stream": "^3.6.1"
}
},
"node_modules/@react-pdf/png-js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz",
"integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==",
"license": "MIT",
"peer": true,
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/@react-pdf/primitives": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz",
"integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==",
"license": "MIT",
"peer": true
},
"node_modules/@react-pdf/reconciler": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-1.1.3.tgz",
"integrity": "sha512-4vqY0klmUH32kTFvuqdAszkOpwfZYKMLO4VpJ5xZWTsoUOLQSyhC2QM2QCj9eaxpB2Nd5Kl9uW+KfyutvZnMzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"object-assign": "^4.1.1",
"scheduler": "0.25.0-rc-603e6108-20241029"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
"version": "0.25.0-rc-603e6108-20241029",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
"license": "MIT",
"peer": true
},
"node_modules/@react-pdf/render": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.1.2.tgz",
"integrity": "sha512-x9R7yaU/EisU2loWLAeVZqUEhkPR1EDa4CXM6PPiPhB2hTZAXgqeZCTVOODX0iGkUBM3scOjzrf5gPPnoMf0jg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.1",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/textkit": "^5.0.3",
"@react-pdf/types": "^2.8.0",
"abs-svg-path": "^0.1.1",
"color-string": "^1.9.1",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
}
},
"node_modules/@react-pdf/renderer": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.2.2.tgz",
"integrity": "sha512-ldWT9Mi+Ie50oqH0NxYZ1UsnZF7BmhoUiI9GMyXBxMvaiG4lIGR5ki8KWLmA6Ti6+Yp7jXNWg0sYDrvZRLiLjg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.1",
"@react-pdf/font": "^3.1.0",
"@react-pdf/layout": "^4.2.3",
"@react-pdf/pdfkit": "^4.0.2",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/reconciler": "^1.1.3",
"@react-pdf/render": "^4.1.2",
"@react-pdf/types": "^2.8.0",
"events": "^3.3.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"queue": "^6.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/stylesheet": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-5.2.2.tgz",
"integrity": "sha512-oHP+hZakETrecnZCSRPqNvFhSyBgoZSDOkonY9WJOxRkUb6P6A+mAVSOWBaNt2eM4FHMDpYDeR9stx+gAWn6gg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.0",
"@react-pdf/types": "^2.7.1",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/stylesheet/node_modules/@react-pdf/fns": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.0.tgz",
"integrity": "sha512-BjT7C/IeYlrF4Pevlrlo+fILhSxsWSm6Ka/rQrQzYsyQuOsqI6bmBzsTW+T6ghqrD5HLRKr1n8vjAaE9g4rFhA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13"
}
},
"node_modules/@react-pdf/textkit": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-5.0.3.tgz",
"integrity": "sha512-gRQBw2lOlGl/gZR2O9Joxu3TqlP0u3wy8KVMw3R6glqDSrgLH43cNfdOWIchNvL6adRIjxd8l/FCv2u7zcHqOQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/fns": "3.1.1",
"bidi-js": "^1.0.2",
"hyphen": "^1.6.4",
"unicode-properties": "^1.4.1"
}
},
"node_modules/@react-pdf/types": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.8.0.tgz",
"integrity": "sha512-lBnLonM2GupyTzUGlWTEoUUGvsRcgbWLn0Py3i3lK/tgn2rPCYwJ9gQ5A3warT5g4jQWyc7HmaNoPU/Zy5iBbQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/font": "^3.1.0",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.0.0"
}
},
"node_modules/@react-pdf/types/node_modules/@react-pdf/stylesheet": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.0.0.tgz",
"integrity": "sha512-uAwuMjbcEaxhRl7tGlqxAbLzo/KoYr6v9JksUJwgzd+rkvAp8jDq8NcG3sUp88tzgIyyRjBGl4FewgdxbAa2uw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/fns": "3.1.1",
"@react-pdf/types": "^2.8.0",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT",
"peer": true
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"peer": true
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"peer": true,
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"peer": true,
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT",
"peer": true
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT",
"peer": true
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT",
"peer": true
},
"node_modules/emoji-regex": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
"license": "MIT",
"peer": true
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT",
"peer": true
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
"license": "MIT",
"peer": true,
"dependencies": {
"hsl-to-rgb-for-reals": "^1.1.0"
}
},
"node_modules/hsl-to-rgb-for-reals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC",
"peer": true
},
"node_modules/hyphen": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.10.6.tgz",
"integrity": "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw==",
"license": "ISC",
"peer": true
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"peer": true
},
"node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
"license": "MIT",
"peer": true
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT",
"peer": true
},
"node_modules/jay-peg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
"license": "MIT",
"peer": true,
"dependencies": {
"restructure": "^3.0.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT",
"peer": true
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/media-engine": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
"license": "MIT",
"peer": true
},
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"license": "MIT",
"peer": true,
"dependencies": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)",
"peer": true
},
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT",
"peer": true
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT",
"peer": true
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT",
"peer": true
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT",
"peer": true
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT",
"peer": true
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"peer": true
},
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC",
"peer": true
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT",
"peer": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"peer": true,
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT",
"peer": true
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT",
"peer": true
},
"node_modules/vite-compatible-readable-stream": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT",
"peer": true
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"@ag-media/react-pdf-table": "^2.0.1"
}
}
+314
View File
@@ -0,0 +1,314 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^S _n Search for match in _n-th parenthesized subpattern.
^W WRAP search if no match found.
^L Enter next character literally into pattern.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< * Go to first line in file (or line _N).
G > ESC-> * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
'_<_l_e_t_t_e_r_> Go to a previously marked position.
'' Go to the previous position.
^X^X Same as '.
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
^O^O Open the currently selected OSC8 hyperlink.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
#_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k _f_i_l_e ... --lesskey-file=_f_i_l_e
Use a compiled lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n ......... --line-numbers
Suppress line numbers in prompts and messages.
-N ......... --LINE-NUMBERS
Display line number at start of each line.
-o [_f_i_l_e] .. --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t _t_a_g .... --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces, tabs and carriage returns.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after forward-screen.
-W ........ --HILITE-UNREAD
Highlight first new line after any forward movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--exit-follow-on-close
Exit F command on a pipe when writer closes pipe.
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--header=[_L[,_C[,_N]]]
Use _L lines (starting at line _N) and _C columns as headers.
--incsearch
Search file as each pattern character is typed in.
--intr=[_C]
Use _C instead of ^X to interrupt a read.
--lesskey-context=_t_e_x_t
Use lesskey source file contents.
--lesskey-src=_f_i_l_e
Use a lesskey source file.
--line-num-width=[_N]
Set the width of the -N line number field to _N characters.
--match-shift=[_N]
Show at least _N characters to the left of a search match.
--modelines=[_N]
Read _N lines from the input file and look for vim modelines.
--mouse
Enable mouse input.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--no-number-headers
Don't give line numbers to header lines.
--no-search-header-lines
Searches do not include header lines.
--no-search-header-columns
Searches do not include header columns.
--no-search-headers
Searches do not include header lines or columns.
--no-vbell
Disable the terminal's visual bell.
--redraw-on-quit
Redraw final screen when quitting.
--rscroll=[_C]
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--search-options=[EFKNRW-]
Set default options for every search.
--show-preproc-errors
Display a message if preprocessor exits with an error status.
--proc-backspace
Process backspaces for bold/underline.
--PROC-BACKSPACE
Treat backspaces as control characters.
--proc-return
Delete carriage returns before newline.
--PROC-RETURN
Treat carriage returns as control characters.
--proc-tab
Expand tabs to spaces.
--PROC-TAB
Treat tabs as control characters.
--status-col-width=[_N]
Set the width of the -J status column to _N characters.
--status-line
Highlight or color the entire line containing a mark.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=[_N]
Each click of the mouse wheel moves _N lines.
--wordwrap
Wrap lines at spaces.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.
@@ -17,13 +17,13 @@ test.describe('404', () => {
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
),
).toBeVisible();
await expect(page.getByText('Back to home page')).toBeVisible();
await expect(page.getByText('Home')).toBeVisible();
});
test('checks go back to home page redirects to home page', async ({
page,
}) => {
await page.getByText('Back to home page').click();
await page.getByText('Home').click();
await expect(page).toHaveURL('/');
});
});
@@ -97,7 +97,7 @@ export const addNewMember = async (
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: role }).click();
await page.getByRole('menuitem', { name: role }).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
@@ -1,97 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Collaboration', () => {
/**
* We check:
* - connection to the collaborative server
* - signal of the backend to the collaborative server (connection should close)
* - reconnection to the collaborative server
*/
test('checks the connection with collaborative server', async ({
page,
browserName,
}) => {
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:8083/collaboration/ws/?room=');
});
const [title] = await createDoc(page, 'doc-editor', browserName, 1);
await verifyDocName(page, title);
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
'ws://localhost:8083/collaboration/ws/?room=',
);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
// When the visibility is changed, the ws should closed the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page
.getByRole('button', {
name: 'Connected',
})
.click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Checkt the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:8083/collaboration/ws/?room=');
});
webSocket = await webSocketPromise;
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
});
test('checks the connection switch to polling after websocket failure', async ({
page,
browserName,
}) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/poll/') && response.status() === 200,
);
await page.routeWebSocket(
'ws://localhost:8083/collaboration/ws/**',
async (ws) => {
await ws.close();
},
);
await page.reload();
await createDoc(page, 'doc-polling', browserName, 1);
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
});
});
@@ -37,10 +37,10 @@ test.describe('Doc Editor', () => {
// Change language to French
await header.click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await header.getByRole('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await expect(
header.getByRole('combobox').getByText('Français'),
header.getByRole('button').getByText('Français'),
).toBeVisible();
// Trigger slash menu to show french menu
@@ -88,6 +88,70 @@ test.describe('Doc Editor', () => {
).toBeVisible();
});
/**
* We check:
* - connection to the collaborative server
* - signal of the backend to the collaborative server (connection should close)
* - reconnection to the collaborative server
*/
test('checks the connection with collaborative server', async ({
page,
browserName,
}) => {
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:8083/collaboration/ws/?room=');
});
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
await verifyDocName(page, randomDoc[0]);
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
'ws://localhost:8083/collaboration/ws/?room=',
);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
// When the visibility is changed, the ws should closed the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Connected',
})
.click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Checkt the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:8083/collaboration/ws/?room=');
});
webSocket = await webSocketPromise;
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
});
test('markdown button converts from markdown to the editor syntax json', async ({
page,
browserName,
@@ -7,17 +7,9 @@ type SmallDoc = {
title: string;
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it checks the grid when mobile', async ({ page }) => {
await page.route('**/documents/**', async (route) => {
const request = route.request();
@@ -94,6 +86,10 @@ test.describe('Documents Grid mobile', () => {
});
test.describe('Document grid item options', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it pins a document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `Favorite doc`, browserName);
@@ -212,6 +208,8 @@ test.describe('Document grid item options', () => {
test.describe('Documents filters', () => {
test('it checks the prebuild left panel filters', async ({ page }) => {
await page.goto('/');
// All Docs
const response = await page.waitForResponse(
(response) =>
@@ -282,11 +280,9 @@ test.describe('Documents filters', () => {
});
test.describe('Documents Grid', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
await page.goto('/');
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
@@ -314,11 +310,12 @@ test.describe('Documents Grid', () => {
test('checks the infinite scroll', async ({ page }) => {
let docs: SmallDoc[] = [];
const responsePromisePage1 = page.waitForResponse(
(response) =>
const responsePromisePage1 = page.waitForResponse((response) => {
return (
response.url().endsWith(`/documents/?page=1`) &&
response.status() === 200,
);
response.status() === 200
);
});
const responsePromisePage2 = page.waitForResponse(
(response) =>
@@ -326,6 +323,8 @@ test.describe('Documents Grid', () => {
response.status() === 200,
);
await page.goto('/');
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
let result = await responsePage1.json();
@@ -89,7 +89,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Delete document',
})
.click();
@@ -153,7 +153,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Delete document' }),
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
@@ -177,7 +177,7 @@ test.describe('Doc Header', () => {
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('button', {
page.getByRole('menuitem', {
name: 'delete',
}),
).toBeEnabled();
@@ -195,7 +195,7 @@ test.describe('Doc Header', () => {
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('button', {
page.getByRole('menuitem', {
name: 'delete',
}),
).toBeEnabled();
@@ -233,7 +233,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Delete document' }),
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
@@ -295,7 +295,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Delete document' }),
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
@@ -352,7 +352,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Copy as Markdown' }).click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in Markdown format
@@ -387,7 +387,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Copy as HTML' }).click();
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
@@ -460,7 +460,7 @@ test.describe('Documents Header mobile', () => {
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
// Test that clipboard is in HTML format
@@ -494,7 +494,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(page.getByLabel('Share modal')).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
@@ -65,15 +65,15 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByRole('button', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Owner' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Administrator' }),
page.getByRole('menuitem', { name: 'Administrator' }),
).toBeVisible();
// Validate
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
@@ -121,7 +121,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Owner' }).click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -139,7 +139,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Owner' }).click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -162,8 +162,8 @@ test.describe('Document create member', () => {
await createDoc(page, 'user-invitation', browserName, 1);
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'translate Français' }).click();
await header.getByRole('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await page.getByRole('button', { name: 'Partager' }).click();
@@ -178,7 +178,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Administrateur' }).click();
await page.getByRole('menuitem', { name: 'Administrateur' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -212,7 +212,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -232,14 +232,14 @@ test.describe('Document create member', () => {
await expect(userInvitation).toBeVisible();
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Reader' }).click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
const moreActions = userInvitation.getByRole('button', {
name: 'more_horiz',
});
await moreActions.click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(userInvitation).toBeHidden();
});
@@ -161,12 +161,12 @@ test.describe('Document list members', () => {
await list.click();
await currentUserRole.click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await list.click();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
await page.getByRole('button', { name: 'Reader' }).click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await list.click();
await expect(currentUserRole).toBeHidden();
});
@@ -215,13 +215,13 @@ test.describe('Document list members', () => {
await expect(mySelfMoreActions).toBeVisible();
await userReaderMoreActions.click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(userReader).toBeHidden();
await mySelfMoreActions.click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(
page.getByText('You do not have permission to perform this action.'),
page.getByText('You do not have permission to view this document.'),
).toBeVisible();
});
});
@@ -19,7 +19,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Version history',
})
.click();
@@ -59,7 +59,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Version history',
})
.click();
@@ -91,7 +91,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Version history' }),
page.getByRole('menuitem', { name: 'Version history' }),
).toBeDisabled();
});
@@ -120,7 +120,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Version history',
})
.click();
@@ -50,7 +50,7 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Connected',
})
.click();
@@ -60,7 +60,7 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Public',
})
.click();
@@ -100,7 +100,9 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
});
test('A doc is not accessible when authentified but not member.', async ({
@@ -133,7 +135,7 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await expect(
page.getByText('You do not have permission to perform this action.'),
page.getByText('You do not have permission to view this document.'),
).toBeVisible();
});
@@ -160,7 +162,7 @@ test.describe('Doc Visibility: Restricted', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
@@ -213,7 +215,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Public',
})
.click();
@@ -225,7 +227,7 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Reading',
})
.click();
@@ -287,7 +289,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Public',
})
.click();
@@ -355,7 +357,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Connected',
})
.click();
@@ -379,7 +381,10 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeHidden();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
});
test('It checks a authenticated doc in read only mode', async ({
@@ -402,7 +407,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Connected',
})
.click();
@@ -411,6 +416,14 @@ test.describe('Doc Visibility: Authenticated', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Document accessible to any connected person', {
exact: true,
}),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
const urlDoc = page.url();
@@ -456,7 +469,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('menuitem', {
name: 'Connected',
})
.click();
@@ -12,7 +12,7 @@ test.describe('Home page', () => {
const footer = page.locator('footer').first();
await expect(header).toBeVisible();
await expect(
header.getByRole('combobox', { name: 'Language' }),
header.getByRole('button', { name: /Language/ }),
).toBeVisible();
await expect(
header.getByRole('button', { name: 'Les services de La Suite numé' }),
@@ -9,19 +9,20 @@ test.describe('Language', () => {
await expect(page.getByLabel('Logout')).toBeVisible();
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await expect(
header.getByRole('combobox').getByText('Français'),
header.getByRole('button').getByText('Français'),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
await expect(
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await header.getByRole('button').getByText('Français').click();
await page.getByRole('menuitem', { name: 'Deutsch' }).click();
await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
});
@@ -53,8 +54,11 @@ test.describe('Language', () => {
// Switch language to French
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
// Check for French 404 response
await check404Response('Pas trouvé.');
@@ -29,7 +29,7 @@ test.describe('Left panel mobile', () => {
const header = page.locator('header').first();
const homeButton = page.getByRole('button', { name: 'house' });
const newDocButton = page.getByRole('button', { name: 'New doc' });
const languageButton = page.getByRole('combobox', { name: 'Language' });
const languageButton = page.getByRole('button', { name: /Language/ });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport();
+1
View File
@@ -381,6 +381,7 @@ const config = {
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-800)',
},
secondary: {
background: {
-1
View File
@@ -62,7 +62,6 @@
"@types/node": "*",
"@types/react": "18.3.12",
"@types/react-dom": "*",
"@types/ws": "8.5.13",
"cross-env": "7.0.3",
"dotenv": "16.4.7",
"eslint-config-impress": "*",
Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

@@ -8,17 +8,20 @@ import {
import { Button, Popover } from 'react-aria-components';
import styled from 'styled-components';
import { BoxProps } from './Box';
const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
border: 1px solid #dddddd;
transition: opacity 0.2s ease-in-out;
`;
const StyledButton = styled(Button)`
interface StyledButtonProps {
$css?: BoxProps['$css'];
}
const StyledButton = styled(Button)<StyledButtonProps>`
cursor: pointer;
border: none;
background: none;
@@ -29,10 +32,12 @@ const StyledButton = styled(Button)`
font-size: 0.938rem;
padding: 0;
text-wrap: nowrap;
${({ $css }) => $css};
`;
export interface DropButtonProps {
button: ReactNode;
buttonCss?: BoxProps['$css'];
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
label?: string;
@@ -40,6 +45,7 @@ export interface DropButtonProps {
export const DropButton = ({
button,
buttonCss,
isOpen = false,
onOpenChange,
children,
@@ -47,11 +53,14 @@ export const DropButton = ({
}: PropsWithChildren<DropButtonProps>) => {
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
const triggerRef = useRef(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const firstFocusableRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
setIsLocalOpen(isOpen);
}, [isOpen]);
if (isLocalOpen && firstFocusableRef.current) {
firstFocusableRef.current.focus();
}
}, [isLocalOpen]);
const onOpenChangeHandler = (isOpen: boolean) => {
setIsLocalOpen(isOpen);
@@ -64,6 +73,7 @@ export const DropButton = ({
ref={triggerRef}
onPress={() => onOpenChangeHandler(true)}
aria-label={label}
$css={buttonCss}
>
{button}
</StyledButton>
@@ -1,4 +1,4 @@
import { PropsWithChildren, useState } from 'react';
import { PropsWithChildren, useRef, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
@@ -20,6 +20,7 @@ export type DropdownMenuProps = {
showArrow?: boolean;
label?: string;
arrowCss?: BoxProps['$css'];
buttonCss?: BoxProps['$css'];
disabled?: boolean;
topMessage?: string;
};
@@ -30,6 +31,7 @@ export const DropdownMenu = ({
disabled = false,
showArrow = false,
arrowCss,
buttonCss,
label,
topMessage,
}: PropsWithChildren<DropdownMenuProps>) => {
@@ -37,6 +39,7 @@ export const DropdownMenu = ({
const spacings = theme.spacingsTokens();
const colors = theme.colorsTokens();
const [isOpen, setIsOpen] = useState(false);
const blockButtonRef = useRef<HTMLDivElement>(null);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
@@ -51,10 +54,17 @@ export const DropdownMenu = ({
isOpen={isOpen}
onOpenChange={onOpenChange}
label={label}
buttonCss={buttonCss}
button={
showArrow ? (
<Box $direction="row" $align="center">
<div>{children}</div>
<Box
ref={blockButtonRef}
$direction="row"
$align="center"
$position="relative"
aria-controls="menu"
>
<Box>{children}</Box>
<Icon
$variation="600"
$css={
@@ -67,11 +77,17 @@ export const DropdownMenu = ({
/>
</Box>
) : (
children
<Box ref={blockButtonRef} aria-controls="menu">
{children}
</Box>
)
}
>
<Box $maxWidth="320px">
<Box
$maxWidth="320px"
$minWidth={`${blockButtonRef.current?.clientWidth}px`}
role="menu"
>
{topMessage && (
<Text
$variation="700"
@@ -90,6 +106,7 @@ export const DropdownMenu = ({
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<BoxButton
role="menuitem"
aria-label={option.label}
data-testid={option.testId}
$direction="row"
@@ -25,7 +25,9 @@ export interface TextProps extends BoxProps {
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
| 'primary-text'
| 'secondary'
| 'secondary-text'
| 'info'
| 'success'
| 'warning'
@@ -406,6 +406,10 @@ input:-webkit-autofill:focus {
);
}
.c__button--primary-text {
color: var(--c--components--button--primary-text--color);
}
.c__button--primary-text:hover,
.c__button--primary-text:focus-visible {
background-color: var(
@@ -605,3 +609,20 @@ input:-webkit-autofill:focus {
.c__tooltip {
padding: 4px 6px;
}
button:focus {
background-color: var(
--c--components--button--primary-text--background--color-hover
);
border-radius: var(--c--components--button--border-radius--focus);
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400)
}
.new-doc-button {
color: white !important;
}
.new-doc-button:focus,
.new-doc-button:focus-visible {
color: white !important;
}
@@ -505,6 +505,9 @@
--c--components--button--primary-text--color-hover: var(
--c--theme--colors--primary-text
);
--c--components--button--primary-text--color: var(
--c--theme--colors--primary-800
);
--c--components--button--secondary--background--color-hover: #f6f6f6;
--c--components--button--secondary--background--color-active: #ededed;
--c--components--button--secondary--border--color: var(
@@ -505,6 +505,7 @@ export const tokens = {
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-800)',
},
secondary: {
background: { 'color-hover': '#F6F6F6', 'color-active': '#EDEDED' },
@@ -14,7 +14,11 @@ export const ButtonLogin = () => {
if (!authenticated) {
return (
<Button onClick={gotoLogin} color="primary-text" aria-label={t('Login')}>
<Button
onClick={() => gotoLogin()}
color="primary-text"
aria-label={t('Login')}
>
{t('Login')}
</Button>
);
@@ -32,7 +36,7 @@ export const ProConnectButton = () => {
return (
<BoxButton
onClick={gotoLogin}
onClick={() => gotoLogin()}
aria-label={t('Proconnect Login')}
$css={css`
background-color: var(--c--theme--colors--primary-text);
@@ -16,8 +16,11 @@ export const setAuthUrl = () => {
}
};
export const gotoLogin = () => {
setAuthUrl();
export const gotoLogin = (withRedirect = true) => {
if (withRedirect) {
setAuthUrl();
}
window.location.replace(LOGIN_URL);
};
@@ -1,9 +1,4 @@
import {
BlockNoteSchema,
Dictionary,
locales,
withPageBreak,
} from '@blocknote/core';
import { BlockNoteSchema, Dictionary, locales } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
@@ -27,7 +22,7 @@ import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolbar';
export const blockNoteSchema = withPageBreak(BlockNoteSchema.create());
export const blockNoteSchema = BlockNoteSchema.create();
interface BlockNoteEditorProps {
doc: Doc;
@@ -2,10 +2,12 @@ import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Y from 'yjs';
import { toBase64, useUpdateDoc } from '@/features/docs/doc-management/';
import { useUpdateDoc } from '@/features/docs/doc-management/';
import { KEY_LIST_DOC_VERSIONS } from '@/features/docs/doc-versioning';
import { isFirefox } from '@/utils/userAgent';
import { toBase64 } from '../utils';
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
@@ -76,13 +76,13 @@ export const cssEditor = (readonly: boolean) => css`
.bn-block-outer:not(:first-child) {
&:has(h1) {
padding-top: 32px;
margin-top: 32px;
}
&:has(h2) {
padding-top: 24px;
margin-top: 24px;
}
&:has(h3) {
padding-top: 16px;
margin-top: 16px;
}
}
@@ -92,9 +92,16 @@ export const cssEditor = (readonly: boolean) => css`
border-radius: 4px;
}
@media screen and (width <= 768px) {
& .bn-editor {
padding-right: 36px;
}
}
@media screen and (width <= 560px) {
& .bn-editor {
${readonly && `padding-left: 10px;`}
padding-right: 10px;
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 46px;
@@ -22,3 +22,6 @@ function hslToHex(h: number, s: number, l: number) {
};
return `#${f(0)}${f(8)}${f(4)}`;
}
export const toBase64 = (str: Uint8Array) =>
Buffer.from(str).toString('base64');
@@ -27,6 +27,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
const { t } = useTranslation();
const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED;
const { transRole } = useTrans();
@@ -38,7 +39,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$gap={spacings['base']}
aria-label={t('It is the card information about the document.')}
>
{docIsPublic && (
{(docIsPublic || docIsAuth) && (
<Box
aria-label={t('Public document')}
$color={colors['primary-800']}
@@ -57,10 +58,12 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$theme="primary"
$variation="800"
data-testid="public-icon"
iconName="public"
iconName={docIsPublic ? 'public' : 'vpn_lock'}
/>
<Text $theme="primary" $variation="800">
{t('Public document')}
{docIsPublic
? t('Public document')
: t('Document accessible to any connected person')}
</Text>
</Box>
)}
@@ -76,8 +79,9 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$css="flex:1;"
$gap="0.5rem 1rem"
$align="center"
$maxWidth="100%"
>
<Box $gap={spacings['3xs']}>
<Box $gap={spacings['3xs']} $overflow="auto">
<DocTitle doc={doc} />
<Box $direction="row">
@@ -101,39 +101,35 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
}, [doc]);
return (
<>
<Tooltip content={t('Rename')} placement="top">
<Box
as="span"
role="textbox"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label="doc title input"
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
<Tooltip content={t('Rename')} placement="top">
<Box
as="span"
role="textbox"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label="doc title input"
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
$color={colorsTokens()['greyscale-1000']}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
}
$color={colorsTokens()['greyscale-1000']}
$margin={{ left: '-2px', right: '10px' }}
$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>
</>
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>
);
};
@@ -18,7 +18,11 @@ import {
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/features/docs/doc-editor/';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
import {
Doc,
ModalRemoveDoc,
useCopyDocLink,
} from '@/features/docs/doc-management';
import { DocShareModal } from '@/features/docs/doc-share';
import {
KEY_LIST_DOC_VERSIONS,
@@ -34,7 +38,7 @@ interface DocToolBoxProps {
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const hasAccesses = doc.nb_accesses > 1;
const hasAccesses = doc.nb_accesses > 1 && doc.abilities.accesses_view;
const queryClient = useQueryClient();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
@@ -50,6 +54,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const copyDocLink = useCopyDocLink(doc.id);
const options: DropdownMenuOption[] = [
...(isSmallMobile
@@ -66,6 +71,11 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
setIsModalExportOpen(true);
},
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),
@@ -1,67 +0,0 @@
import { APIError, errorCauses } from '@/api';
interface PollOutgoingMessageParams {
pollUrl: string;
message64: string;
}
interface PollOutgoingMessageResponse {
updated?: boolean;
}
export const pollOutgoingMessageRequest = async ({
pollUrl,
message64,
}: PollOutgoingMessageParams): Promise<PollOutgoingMessageResponse> => {
const response = await fetch(pollUrl, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message64,
}),
});
if (!response.ok) {
throw new APIError(
`Post poll message request failed`,
await errorCauses(response),
);
}
return response.json() as Promise<PollOutgoingMessageResponse>;
};
interface PollSyncParams {
pollUrl: string;
localDoc64: string;
}
interface PollSyncResponse {
syncDoc64?: string;
}
export const postPollSyncRequest = async ({
pollUrl,
localDoc64,
}: PollSyncParams): Promise<PollSyncResponse> => {
const response = await fetch(pollUrl, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
localDoc64,
}),
});
if (!response.ok) {
throw new APIError(
`Sync request failed: ${response.status} ${response.statusText}`,
await errorCauses(response),
);
}
return response.json() as Promise<PollSyncResponse>;
};
@@ -71,9 +71,15 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
</Button>
</>
}
size={ModalSize.MEDIUM}
size={ModalSize.SMALL}
title={
<Text $size="h6" as="h6" $margin={{ all: '0' }} $align="flex-start">
<Text
$size="h6"
as="h6"
$margin={{ all: '0' }}
$align="flex-start"
$variation="1000"
>
{t('Delete a doc')}
</Text>
}
@@ -6,29 +6,17 @@ import { useBroadcastStore } from '@/stores';
import { useProviderStore } from '../stores/useProviderStore';
import { Base64 } from '../types';
export const useCollaboration = (
room?: string,
initialContent?: Base64,
canEdit?: boolean,
) => {
export const useCollaboration = (room?: string, initialContent?: Base64) => {
const collaborationUrl = useCollaborationUrl(room);
const { setBroadcastProvider } = useBroadcastStore();
const { provider, createProvider, destroyProvider } = useProviderStore();
/**
* Initialize the provider
*/
useEffect(() => {
if (!room || !collaborationUrl || provider || canEdit === undefined) {
if (!room || !collaborationUrl || provider) {
return;
}
const newProvider = createProvider(
collaborationUrl,
room,
canEdit,
initialContent,
);
const newProvider = createProvider(collaborationUrl, room, initialContent);
setBroadcastProvider(newProvider);
}, [
provider,
@@ -37,7 +25,6 @@ export const useCollaboration = (
initialContent,
createProvider,
setBroadcastProvider,
canEdit,
]);
/**
@@ -1,333 +0,0 @@
import crypto from 'crypto';
import {
CompleteHocuspocusProviderConfiguration,
CompleteHocuspocusProviderWebsocketConfiguration,
HocuspocusProvider,
HocuspocusProviderConfiguration,
WebSocketStatus,
onOutgoingMessageParameters,
onStatusParameters,
} from '@hocuspocus/provider';
import type { MessageEvent } from 'ws';
import * as Y from 'yjs';
import { isAPIError } from '@/api';
import {
pollOutgoingMessageRequest,
postPollSyncRequest,
} from '../api/collaborationRequests';
import { toBase64 } from '../utils';
type HocuspocusProviderConfigurationUrl = Required<
Pick<CompleteHocuspocusProviderConfiguration, 'name'>
> &
Partial<CompleteHocuspocusProviderConfiguration> &
Required<Pick<CompleteHocuspocusProviderWebsocketConfiguration, 'url'>>;
export const isHocuspocusProviderConfigurationUrl = (
data: HocuspocusProviderConfiguration,
): data is HocuspocusProviderConfigurationUrl => {
return 'url' in data;
};
type CollaborationProviderConfiguration = HocuspocusProviderConfiguration & {
canEdit: boolean;
};
export class CollaborationProvider extends HocuspocusProvider {
/**
* If the user can edit the document
*/
public canEdit = false;
/**
* If the long polling is started
* it is used to avoid starting it multiple times
* when the websocket is failed.
*/
public isLongPollingStarted = false;
/**
* If the document is syncing with the server
* it is used to avoid starting it multiple times.
*/
public isSyncing = false;
/**
* The document can pass out of sync
* then sync again with a next updates so
* we add a counter to avoid syncing the document
* to quickly.
*/
public seemsUnsyncCount = 0;
public seemsUnsyncMaxCount = 5;
/**
* In Safari or Firefox the websocket takes time before passing in
* mode failed, it can takes up to 1 minutes. To avoid this latence
* we set isWebsocketFailed to true, it is connects it will switch
* to false.
*/
public isWebsocketFailed = true;
/**
* There is a ping-pong mechanism with awareness, receipt awareness is send again,
* it creates useless requests.
* We use this variable to avoid treating the same awareness message twice.
*/
private treatedAwarenessMessage: string | null = null;
/**
* Server-Sent Events
*/
protected sse: EventSource | null = null;
/**
* Polling timeout
* It is used to avoid starting the polling to quickly
* to let the class init properly.
*/
protected pollTimeout: NodeJS.Timeout | null = null;
/**
* Easy way to get the url of the server
*/
protected url = '';
public constructor(configuration: CollaborationProviderConfiguration) {
let url = '';
if (isHocuspocusProviderConfigurationUrl(configuration)) {
url = configuration.url;
let withWS = true;
if (
new URLSearchParams(window.location.search).get('withoutWS') === 'true'
) {
withWS = false;
}
configuration.url = !withWS ? 'ws://localhost:6666' : configuration.url;
}
super(configuration);
this.url = url;
this.canEdit = configuration.canEdit;
if (configuration.canEdit) {
this.on('outgoingMessage', this.onPollOutgoingMessage.bind(this));
}
}
public setPollDefaultValues(): void {
this.isLongPollingStarted = false;
this.isWebsocketFailed = false;
this.seemsUnsyncCount = 0;
this.sse?.close();
this.sse = null;
if (this.pollTimeout) {
clearTimeout(this.pollTimeout);
}
}
public destroy(): void {
super.destroy();
this.setPollDefaultValues();
}
public onStatus({ status }: onStatusParameters) {
if (status === WebSocketStatus.Connecting) {
this.isWebsocketFailed = true;
if (this.pollTimeout) {
clearTimeout(this.pollTimeout);
}
this.pollTimeout = setTimeout(() => {
this.initPolling();
}, 5000);
} else if (status === WebSocketStatus.Connected) {
this.setPollDefaultValues();
}
super.onStatus({ status });
}
public initPolling() {
if (this.isLongPollingStarted || !this.isWebsocketFailed) {
return;
}
this.isLongPollingStarted = true;
void this.pollSync(true);
this.initCollaborationSSE();
}
protected toPollUrl(endpoint: string): string {
let pollUrl = this.url.replace('ws:', 'http:');
if (pollUrl.includes('wss:')) {
pollUrl = pollUrl.replace('wss:', 'https:');
}
pollUrl = pollUrl.replace('/ws/', '/ws/poll/' + endpoint + '/');
// To have our requests not cached
return `${pollUrl}&${Date.now()}`;
}
protected isDuplicateAwareness(message64: string): boolean {
if (this.treatedAwarenessMessage === message64) {
return true;
}
this.treatedAwarenessMessage = message64;
return false;
}
/**
* Outgoing message event
*
* Sent to the server the message to
* be sent to the other users
*/
public async onPollOutgoingMessage({ message }: onOutgoingMessageParameters) {
if (!this.isWebsocketFailed || !this.canEdit) {
return;
}
const message64 = Buffer.from(message.toUint8Array()).toString('base64');
if (this.isDuplicateAwareness(message64)) {
return;
}
try {
const { updated } = await pollOutgoingMessageRequest({
pollUrl: this.toPollUrl('message'),
message64,
});
if (!updated) {
await this.pollSync();
}
} catch (error: unknown) {
if (isAPIError(error)) {
// The user is not allowed to send messages
if (error.status === 403) {
this.off('outgoingMessage', this.onPollOutgoingMessage.bind(this));
this.canEdit = false;
}
}
}
}
/**
* EventSource is a API for opening an HTTP
* connection for receiving push notifications
* from a server in real-time.
* We use it to sync the document with the server
*/
protected initCollaborationSSE() {
if (!this.isWebsocketFailed) {
return;
}
this.sse = new EventSource(this.toPollUrl('message'), {
withCredentials: true,
});
this.sse.onmessage = (event) => {
const { updatedDoc64, stateFingerprint, awareness64 } = JSON.parse(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
event.data,
) as {
updatedDoc64?: string;
stateFingerprint?: string;
awareness64?: string;
};
if (awareness64) {
if (this.isDuplicateAwareness(awareness64)) {
return;
}
this.treatedAwarenessMessage = awareness64;
const awareness = Buffer.from(awareness64, 'base64');
this.onMessage({
data: awareness,
} as MessageEvent);
}
if (updatedDoc64) {
this.document.transact(() => {
Y.applyUpdate(this.document, Buffer.from(updatedDoc64, 'base64'));
}, this);
}
const localStateFingerprint = this.getStateFingerprint(this.document);
if (localStateFingerprint !== stateFingerprint) {
void this.pollSync();
} else {
this.seemsUnsyncCount = 0;
}
};
this.sse.onopen = () => {};
this.sse.onerror = (err) => {
console.error('SSE error:', err);
this.sse?.close();
setTimeout(() => {
this.initCollaborationSSE();
}, 5000);
};
}
/**
* Sync the document with the server.
*
* In some rare cases, the document may be out of sync.
* We use a fingerprint to compare documents,
* it happens that the local fingerprint is different from the server one
* when awareness plus the document are updated quickly.
* The system is resilient to this kind of problems, so `seemsUnsyncCount` should
* go back to 0 after a few seconds. If not, we will force a sync.
*/
public async pollSync(forseSync = false) {
if (!this.isWebsocketFailed || this.isSyncing) {
return;
}
this.seemsUnsyncCount++;
if (this.seemsUnsyncCount < this.seemsUnsyncMaxCount && !forseSync) {
return;
}
this.isSyncing = true;
try {
const { syncDoc64 } = await postPollSyncRequest({
pollUrl: this.toPollUrl('sync'),
localDoc64: toBase64(Y.encodeStateAsUpdate(this.document)),
});
if (syncDoc64) {
const uint8Array = Buffer.from(syncDoc64, 'base64');
Y.applyUpdate(this.document, uint8Array);
this.seemsUnsyncCount = 0;
}
} catch (error) {
console.error('Polling sync failed:', error);
} finally {
this.isSyncing = false;
}
}
/**
* Create a hash SHA-256 of the state vector of the document.
* Usefull to compare the state of the document.
* @param doc
* @returns
*/
public getStateFingerprint(doc: Y.Doc): string {
const stateVector = Y.encodeStateVector(doc);
return crypto.createHash('sha256').update(stateVector).digest('base64');
}
}
@@ -1,179 +0,0 @@
import { WebSocketStatus } from '@hocuspocus/provider';
import fetchMock from 'fetch-mock';
import * as Y from 'yjs';
if (typeof EventSource === 'undefined') {
const mockEventSource = jest.fn();
class MockEventSource {
constructor(...args: any[]) {
return mockEventSource(...args);
}
}
(global as any).EventSource = MockEventSource;
}
import { CollaborationProvider } from '../CollaborationProvider';
const mockApplyUpdate = jest.fn();
jest.mock('yjs', () => ({
...jest.requireActual('yjs'),
applyUpdate: (...args: any) => mockApplyUpdate(...args),
}));
describe('CollaborationProvider', () => {
let config: any;
let provider: CollaborationProvider;
let fakeWebsocketProvider: any;
beforeEach(() => {
fakeWebsocketProvider = {
on: jest.fn(),
open: jest.fn(),
attach: jest.fn(),
};
config = {
name: 'test',
url: 'ws://localhost/ws/',
canEdit: true,
websocketProvider: fakeWebsocketProvider,
};
provider = new CollaborationProvider(config);
});
afterEach(() => {
jest.clearAllMocks();
fetchMock.restore();
});
test('constructor initializes properties and attaches event handlers', () => {
expect(provider.canEdit).toBe(true);
expect((provider as any).url).toBe('ws://localhost/ws/');
expect(fakeWebsocketProvider.on).toHaveBeenCalled();
});
test('getStateFingerprint returns a consistent hash', () => {
const fingerprint1 = provider.getStateFingerprint(provider.document);
const fingerprint2 = provider.getStateFingerprint(provider.document);
expect(typeof fingerprint1).toBe('string');
expect(fingerprint1).toBe(fingerprint2);
});
test('onPollOutgoingMessage does nothing when websocket is not failed', async () => {
fetchMock.post(/http:\/\/localhost\/ws\/poll\/message\/.*/, {
body: JSON.stringify({ updated: false }),
});
provider.isWebsocketFailed = false;
const dummyMessage = {
toUint8Array: () => new Uint8Array([1, 2, 3]),
} as any;
await provider.onPollOutgoingMessage({ message: dummyMessage });
expect(fetchMock.called()).toBe(false);
});
test('onPollOutgoingMessage calls pollOutgoingMessageRequest and pollSync when updated is false', async () => {
provider.isWebsocketFailed = true;
const dummyMessage = {
toUint8Array: () => new Uint8Array([4, 5, 6]),
} as any;
fetchMock.post(/http:\/\/localhost\/ws\/poll\/message\/.*/, {
body: JSON.stringify({ updated: false }),
});
const pollSyncSpy = jest.spyOn(provider, 'pollSync').mockResolvedValue();
await provider.onPollOutgoingMessage({ message: dummyMessage });
expect(fetchMock.lastUrl()).toContain('http://localhost/ws/poll/message/');
expect(pollSyncSpy).toHaveBeenCalled();
});
test('onPollOutgoingMessage disables editing (canEdit becomes false) if API returns a 403 error', async () => {
provider.isWebsocketFailed = true;
const dummyMessage = {
toUint8Array: () => new Uint8Array([7, 8, 9]),
} as any;
fetchMock.post(/http:\/\/localhost\/ws\/poll\/message\/.*/, {
status: 403,
body: JSON.stringify({}),
});
// Stub the off method (inherited from event emitter) to observe its call.
provider.off = jest.fn();
await provider.onPollOutgoingMessage({ message: dummyMessage });
expect(fetchMock.lastUrl()).toContain('http://localhost/ws/poll/message/');
expect(provider.off).toHaveBeenCalled();
expect(provider.canEdit).toBe(false);
});
test('pollSync does nothing if websocket is not failed', async () => {
fetchMock.post(/http:\/\/localhost\/ws\/poll\/sync\/.*/, {
body: JSON.stringify({ syncDoc64: '123456' }),
});
provider.isWebsocketFailed = false;
await provider.pollSync();
expect(fetchMock.called()).toBe(false);
});
test('pollSync calls postPollSyncRequest when unsync count threshold is reached', async () => {
const update = Y.encodeStateAsUpdate(provider.document);
const syncDoc64 = Buffer.from(update).toString('base64');
fetchMock.post(/http:\/\/localhost\/ws\/poll\/sync\/.*/, {
body: JSON.stringify({ syncDoc64 }),
});
provider.isWebsocketFailed = true;
provider.seemsUnsyncCount = provider.seemsUnsyncMaxCount - 1;
await provider.pollSync();
const uint8Array = Buffer.from(syncDoc64, 'base64');
expect(mockApplyUpdate).toHaveBeenCalledWith(provider.document, uint8Array);
});
describe('onStatus', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('sets websocket failed and schedules polling on Connecting', () => {
const initPollingSpy = jest
.spyOn(provider, 'initPolling')
.mockImplementation(() => {});
const superOnStatusSpy = jest.spyOn(
Object.getPrototypeOf(provider),
'onStatus',
);
provider.onStatus({ status: WebSocketStatus.Connecting });
expect(provider.isWebsocketFailed).toBe(true);
// Fast-forward timer to trigger the scheduled initPolling call.
jest.runAllTimers();
expect(initPollingSpy).toHaveBeenCalled();
expect(superOnStatusSpy).toHaveBeenCalledWith({
status: WebSocketStatus.Connecting,
});
});
test('calls setPollDefaultValues on Connected', () => {
const setPollDefaultValuesSpy = jest
.spyOn(provider, 'setPollDefaultValues')
.mockImplementation(() => {});
const superOnStatusSpy = jest.spyOn(
Object.getPrototypeOf(provider),
'onStatus',
);
provider.onStatus({ status: WebSocketStatus.Connected });
expect(setPollDefaultValuesSpy).toHaveBeenCalled();
expect(superOnStatusSpy).toHaveBeenCalledWith({
status: WebSocketStatus.Connected,
});
});
});
});
@@ -4,13 +4,10 @@ import { create } from 'zustand';
import { Base64 } from '@/features/docs/doc-management';
import { CollaborationProvider } from '../libs/CollaborationProvider';
export interface UseCollaborationStore {
createProvider: (
providerUrl: string,
storeId: string,
canEdit: boolean,
initialDoc?: Base64,
) => HocuspocusProvider;
destroyProvider: () => void;
@@ -23,7 +20,7 @@ const defaultValues = {
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
...defaultValues,
createProvider: (wsUrl, storeId, canEdit, initialDoc) => {
createProvider: (wsUrl, storeId, initialDoc) => {
const doc = new Y.Doc({
guid: storeId,
});
@@ -32,11 +29,10 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
}
const provider = new CollaborationProvider({
const provider = new HocuspocusProvider({
url: wsUrl,
name: storeId,
document: doc,
canEdit,
});
set({
@@ -12,9 +12,6 @@ export const currentDocRole = (abilities: Doc['abilities']): Role => {
: Role.READER;
};
export const toBase64 = (str: Uint8Array) =>
Buffer.from(str).toString('base64');
export const base64ToYDoc = (base64: string) => {
const uint8Array = Buffer.from(base64, 'base64');
const ydoc = new Y.Doc();
@@ -33,9 +33,7 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
>
<Button
fullWidth={false}
onClick={() => {
copyDocLink();
}}
onClick={copyDocLink}
color="tertiary"
icon={<span className="material-icons">add_link</span>}
>
@@ -100,17 +100,21 @@ export const ModalConfirmationVersion = ({
</Button>
</>
}
size={ModalSize.MEDIUM}
size={ModalSize.SMALL}
title={
<Text $size="h6" $align="flex-start">
<Text $size="h6" $align="flex-start" $variation="1000">
{t('Warning')}
</Text>
}
>
<Box aria-label={t('Modal confirmation to restore the version')}>
<Box>
<Text>{t('Your current document will revert to this version.')}</Text>
<Text>{t('If a member is editing, his works can be lost.')}</Text>
<Text $variation="600">
{t('Your current document will revert to this version.')}
</Text>
<Text $variation="600">
{t('If a member is editing, his works can be lost.')}
</Text>
</Box>
</Box>
</Modal>
@@ -61,12 +61,8 @@ export const DocsGrid = ({
$position="relative"
$width="100%"
$maxWidth="960px"
$maxHeight="calc(100vh - 52px - 1rem)"
$maxHeight="calc(100vh - 52px - 2rem)"
$align="center"
$css={css`
overflow-x: hidden;
overflow-y: auto;
`}
>
<DocsGridLoader isLoading={isRefetching || loading} />
<Card
@@ -75,8 +71,7 @@ export const DocsGrid = ({
$height="100%"
$width="100%"
$css={css`
overflow-x: hidden;
overflow-y: auto;
${!isDesktop ? 'border: none;' : ''}
`}
$padding={{
top: 'base',
@@ -101,7 +96,7 @@ export const DocsGrid = ({
</Box>
)}
{hasDocs && (
<Box $gap="6px">
<Box $gap="6px" $overflow="auto">
<Box
$direction="row"
$padding={{ horizontal: 'xs' }}
@@ -122,27 +117,29 @@ export const DocsGrid = ({
)}
</Box>
{/* Body */}
{data?.pages.map((currentPage) => {
return currentPage.results.map((doc) => (
<DocsGridItem doc={doc} key={doc.id} />
));
})}
</Box>
)}
{hasNextPage && !loading && (
<InView
data-testid="infinite-scroll-trigger"
as="div"
onChange={loadMore}
>
{!isFetching && hasNextPage && (
<Button onClick={() => void fetchNextPage()} color="primary-text">
{t('More docs')}
</Button>
{hasNextPage && !loading && (
<InView
data-testid="infinite-scroll-trigger"
as="div"
onChange={loadMore}
>
{!isFetching && hasNextPage && (
<Button
onClick={() => void fetchNextPage()}
color="primary-text"
>
{t('More docs')}
</Button>
)}
</InView>
)}
</InView>
</Box>
)}
</Card>
</Box>
@@ -54,6 +54,10 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$css={css`
flex: ${flexLeft};
align-items: center;
&:focus {
outline: 2px solidrgb(33, 34, 82);
}
min-width: 0;
`}
href={`/docs/${doc.id}`}
>
@@ -64,6 +68,7 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$gap={spacings.xs}
$flex={flexLeft}
$padding={{ right: isDesktop ? 'md' : '3xs' }}
$maxWidth="100%"
>
<SimpleDocItem isPinned={doc.is_favorite} doc={doc} />
{showAccesses && (
@@ -38,7 +38,7 @@ export const SimpleDocItem = ({
const { untitledDocument } = useTrans();
return (
<Box $direction="row" $gap={spacings.sm}>
<Box $direction="row" $gap={spacings.sm} $overflow="auto">
<Box
$direction="row"
$align="center"
@@ -50,10 +50,10 @@ export const SimpleDocItem = ({
{isPinned ? (
<PinnedDocumentIcon aria-label={t('Pin document icon')} />
) : (
<SimpleFileIcon aria-label={t('Simple document icon')} />
<SimpleFileIcon aria-hidden="true" />
)}
</Box>
<Box $justify="center">
<Box $justify="center" $overflow="auto">
<Text
aria-describedby="doc-title"
aria-label={doc.title}
@@ -7,9 +7,11 @@ import { createGlobalStyle } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
const GaufreStyle = createGlobalStyle`
.lasuite-gaufre-btn{
box-shadow: inset 0 0 0 0 !important;
&:focus {
outline: 2px solidrgb(33, 34, 82);
}
`;
export const LaGaufre = () => {
@@ -55,7 +55,7 @@ export default function HomeBanner() {
$textAlign="center"
$margin="none"
$css={css`
line-height: 56px;
line-height: ${!isMobile ? '56px' : '45px'};
`}
>
{t('Collaborative writing, Simplified.')}
@@ -74,7 +74,7 @@ export default function HomeBanner() {
<ProConnectButton />
) : (
<Button
onClick={gotoLogin}
onClick={() => gotoLogin()}
icon={
<Text $isMaterialIcon $color="white">
bolt
@@ -116,8 +116,8 @@ export const HomeSection = ({
`}
$variation="1000"
$weight="bold"
$size={!isSmallDevice ? 'xs-alt' : 'h4'}
$textAlign={isSmallMobile ? 'center' : 'left'}
$size={!isSmallDevice ? 'xs-alt' : isSmallMobile ? 'h6' : 'h4'}
$textAlign="left"
$margin="none"
>
{title}
@@ -1,41 +1,16 @@
import { Select } from '@openfun/cunningham-react';
import { Settings } from 'luxon';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { css } from 'styled-components';
import { Box, Text } from '@/components/';
import { DropdownMenu, Text } from '@/components/';
import { LANGUAGES_ALLOWED } from '@/i18n/conf';
const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`
flex-shrink: 0;
width: auto;
.c__select__wrapper {
min-height: 2rem;
height: auto;
border-color: transparent;
padding: 0 0.15rem 0 0.45rem;
border-radius: 1px;
.labelled-box .labelled-box__children {
padding-right: 2rem;
.c_select__render .typo-text {
${({ $isSmall }) => $isSmall && `display: none;`}
}
}
&:hover {
box-shadow: none !important;
}
}
`;
export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { preload: languages } = i18n.options;
Settings.defaultLocale = i18n.language;
const language = i18n.language;
Settings.defaultLocale = language;
const optionsPicker = useMemo(() => {
return (languages || []).map((lang) => ({
@@ -57,28 +32,53 @@ export const LanguagePicker = () => {
>
translate
</Text>
<Text $theme="primary" $weight="500" $variation="800">
<Text $theme="primary" $weight="500" $variation="800" lang={lang}>
{LANGUAGES_ALLOWED[lang]}
</Text>
</Box>
),
}));
}, [languages]);
return (
<SelectStyled
label={t('Language')}
showLabelWhenSelected={false}
clearable={false}
hideLabel
defaultValue={i18n.language}
className="c_select__no_bg"
options={optionsPicker}
onChange={(e) => {
i18n.changeLanguage(e.target.value as string).catch((err) => {
label: LANGUAGES_ALLOWED[lang],
isSelected: language === lang,
callback: () => {
i18n.changeLanguage(lang).catch((err) => {
console.error('Error changing language', err);
});
}}
/>
},
}));
}, [i18n, language, languages]);
return (
<DropdownMenu
options={optionsPicker}
showArrow
buttonCss={css`
&:hover {
background-color: var(
--c--components--button--primary-text--background--color-hover
);
}
border-radius: 4px;
padding: 0.5rem 0.6rem;
& > div {
gap: 0.2rem;
display: flex;
}
& .material-icons {
color: var(--c--components--button--primary-text--color) !important;
}
`}
>
<Text
$theme="primary"
aria-label={t('Language')}
$direction="row"
$gap="0.5rem"
>
<Text $isMaterialIcon $color="inherit" $size="xl">
translate
</Text>
{LANGUAGES_ALLOWED[language]}
</Text>
</DropdownMenu>
);
};
@@ -50,6 +50,7 @@ export const LeftPanel = () => {
overflow: hidden;
border-right: 1px solid ${colors['greyscale-200']};
`}
$background={colors['greyscale-000']}
>
<Box
$css={css`
@@ -39,7 +39,7 @@ export const LeftPanelFavoriteItem = ({ doc }: LeftPanelFavoriteItemProps) => {
`}
key={doc.id}
>
<StyledLink href={`/docs/${doc.id}`}>
<StyledLink href={`/docs/${doc.id}`} $css="overflow: auto;">
<SimpleDocItem showAccesses doc={doc} />
</StyledLink>
<div className="pinned-actions">
@@ -50,8 +50,11 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
onClick={goToHome}
size="medium"
color="tertiary-text"
aria-label={t('Back to home page')}
icon={
<Icon $variation="800" $theme="primary" iconName="house" />
<span aria-hidden="true">
<Icon $variation="800" $theme="primary" iconName="house" />
</span>
}
/>
{authenticated && (
@@ -59,14 +62,27 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
onClick={searchModal.open}
size="medium"
color="tertiary-text"
aria-label={t('Search')}
icon={
<Icon $variation="800" $theme="primary" iconName="search" />
<span aria-hidden="true">
<Icon
$variation="800"
$theme="primary"
iconName="search"
/>
</span>
}
/>
)}
</Box>
{authenticated && (
<Button onClick={createNewDoc}>{t('New doc')}</Button>
<Button
onClick={createNewDoc}
className="new-doc-button"
aria-label={t('New document')}
>
{t('New doc')}
</Button>
)}
</Box>
</SeparatedSection>
@@ -270,6 +270,7 @@
"My docs": "Mes documents",
"Name": "Nom",
"New doc": "Nouveau doc",
"New document": "Nouveau document",
"No active search": "Aucune recherche active",
"No document found": "Aucun document trouvé",
"No documents found": "Aucun document trouvé",
@@ -19,8 +19,8 @@ export function MainLayout({
}: PropsWithChildren<MainLayoutProps>) {
const { isDesktop } = useResponsiveStore();
const { colorsTokens } = useCunninghamTheme();
const colors = colorsTokens();
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
return (
<div>
@@ -39,10 +39,10 @@ export function MainLayout({
$width="100%"
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
$padding={{
all: isDesktop ? 'base' : '2xs',
all: isDesktop ? 'base' : '0',
}}
$background={
backgroundColor === 'white'
currentBackgroundColor === 'white'
? colors['greyscale-000']
: colors['greyscale-050']
}
@@ -6,17 +6,27 @@ import { HEADER_HEIGHT, Header } from '@/features/header';
import { LeftPanel } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
export function PageLayout({ children }: PropsWithChildren) {
interface PageLayoutProps {
withFooter?: boolean;
}
export function PageLayout({
children,
withFooter = true,
}: PropsWithChildren<PageLayoutProps>) {
const { isDesktop } = useResponsiveStore();
return (
<Box $minHeight="100vh" $margin={{ top: `${HEADER_HEIGHT}px` }}>
<Box
$minHeight={`calc(100vh - ${HEADER_HEIGHT}px)`}
$margin={{ top: `${HEADER_HEIGHT}px` }}
>
<Header />
<Box as="main" $width="100%" $css="flex-grow:1;">
{!isDesktop && <LeftPanel />}
{children}
</Box>
<Footer />
{withFooter && <Footer />}
</Box>
);
}
@@ -0,0 +1,57 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { ReactElement, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import img401 from '@/assets/icons/icon-401.png';
import { Box, Text } from '@/components';
import { gotoLogin, useAuth } from '@/features/auth';
import { PageLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const { authenticated } = useAuth();
const { replace } = useRouter();
useEffect(() => {
if (authenticated) {
void replace(`/`);
}
}, [authenticated, replace]);
return (
<Box
$align="center"
$margin="auto"
$gap="1rem"
$padding={{ bottom: '2rem' }}
>
<Image
src={img401}
alt={t('Image 401')}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Box $align="center" $gap="0.8rem">
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
{t('Log in to access the document.')}
</Text>
<Button onClick={() => gotoLogin(false)} aria-label={t('Login')}>
{t('Login')}
</Button>
</Box>
</Box>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <PageLayout withFooter={false}>{page}</PageLayout>;
};
export default Page;
@@ -0,0 +1,60 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import img403 from '@/assets/icons/icon-403.png';
import { Box, StyledLink, Text } from '@/components';
import { PageLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const StyledButton = styled(Button)`
width: fit-content;
`;
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
return (
<Box
$align="center"
$margin="auto"
$gap="1rem"
$padding={{ bottom: '2rem' }}
>
<Image
src={img403}
alt={t('Image 403')}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Box $align="center" $gap="0.8rem">
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
{t('You do not have permission to view this document.')}
</Text>
<StyledLink href="/">
<StyledButton
icon={
<Text $isMaterialIcon $color="white">
house
</Text>
}
>
{t('Home')}
</StyledButton>
</StyledLink>
</Box>
</Box>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <PageLayout withFooter={false}>{page}</PageLayout>;
};
export default Page;
+11 -3
View File
@@ -5,7 +5,7 @@ import styled from 'styled-components';
import Icon404 from '@/assets/icons/icon-404.svg';
import { Box, StyledLink, Text } from '@/components';
import { MainLayout } from '@/layouts';
import { PageLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const StyledButton = styled(Button)`
@@ -33,7 +33,15 @@ const Page: NextPageWithLayout = () => {
<Box $margin={{ top: 'large' }}>
<StyledLink href="/">
<StyledButton>{t('Back to home page')}</StyledButton>
<StyledButton
icon={
<Text $isMaterialIcon $color="white">
house
</Text>
}
>
{t('Home')}
</StyledButton>
</StyledLink>
</Box>
</Box>
@@ -41,7 +49,7 @@ const Page: NextPageWithLayout = () => {
};
Page.getLayout = function getLayout(page: ReactElement) {
return <MainLayout>{page}</MainLayout>;
return <PageLayout withFooter={false}>{page}</PageLayout>;
};
export default Page;
+5 -1
View File
@@ -16,7 +16,11 @@ type AppPropsWithLayout = AppProps & {
export default function App({ Component, pageProps }: AppPropsWithLayout) {
useSWRegister();
const getLayout = Component.getLayout ?? ((page) => page);
const { t } = useTranslation();
const { t, i18n } = useTranslation();
useEffect(() => {
document.documentElement.lang = i18n.language;
}, [i18n.language]);
useEffect(() => {
console.log(
@@ -5,7 +5,7 @@ import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Box, Text, TextErrors } from '@/components';
import { gotoLogin } from '@/features/auth';
import { setAuthUrl } from '@/features/auth';
import { DocEditor } from '@/features/docs/doc-editor';
import {
Doc,
@@ -63,7 +63,7 @@ const DocPage = ({ id }: DocProps) => {
const { addTask } = useBroadcastStore();
const queryClient = useQueryClient();
const { replace } = useRouter();
useCollaboration(doc?.id, doc?.content, doc?.abilities.partial_update);
useCollaboration(doc?.id, doc?.content);
useEffect(() => {
if (doc?.title) {
@@ -99,13 +99,19 @@ const DocPage = ({ id }: DocProps) => {
}, [addTask, doc?.id, queryClient]);
if (isError && error) {
if (error.status === 403) {
void replace(`/403`);
return null;
}
if (error.status === 404) {
void replace(`/404`);
return null;
}
if (error.status === 401) {
gotoLogin();
setAuthUrl();
void replace(`/401`);
return null;
}
@@ -31,7 +31,15 @@ const Page: NextPageWithLayout = () => {
<Box $margin={{ top: 'large' }}>
<StyledLink href="/">
<StyledButton>{t('Back to home page')}</StyledButton>
<StyledButton
icon={
<Text $isMaterialIcon $color="white">
house
</Text>
}
>
{t('Home')}
</StyledButton>
</StyledLink>
</Box>
</Box>
@@ -1,185 +0,0 @@
/* eslint-disable @typescript-eslint/no-require-imports */
import { EventEmitter } from 'events';
import { MessageType } from '@hocuspocus/server';
import { Response } from 'express';
import * as Y from 'yjs';
import { PollSync, PollSyncRequest } from '../src/libs/PollSync';
const { base64ToYDoc } = require('@/utils');
const { logger } = require('@/utils');
const mockEncodeStateAsUpdate = jest.fn();
jest.mock('yjs', () => ({
...jest.requireActual('yjs'),
encodeStateAsUpdate: () => mockEncodeStateAsUpdate(),
}));
jest.mock('@/utils', () => ({
base64ToYDoc: jest.fn((_b64: string) => new Y.Doc()),
toBase64: jest.fn((data: Uint8Array) => Buffer.from(data).toString('base64')),
logger: jest.fn(),
}));
jest.mock('@hocuspocus/server', () => {
const originalModule = jest.requireActual('@hocuspocus/server');
return {
__esModule: true,
...originalModule,
IncomingMessage: jest.fn().mockImplementation((buf: Buffer) => ({
buffer: buf,
readVarString: jest.fn(() => 'testRoom'),
readVarUint: jest.fn(() => MessageType.Sync),
writeVarUint: jest.fn(),
decoder: {},
encoder: {},
})),
};
});
describe('PollSync', () => {
let req: PollSyncRequest<any>;
let res: Response;
let dummyDoc: any;
let pollSync: PollSync<any>;
let mockHocuspocusServer: any;
beforeEach(() => {
req = Object.assign(new EventEmitter(), {
on: jest.fn((event, cb) => {
// For 'close' event, store the callback for manual trigger in tests.
if (event === 'close') {
req.destroy = cb;
}
}),
}) as unknown as PollSyncRequest<any>;
res = {
write: jest.fn(),
end: jest.fn(),
} as unknown as Response;
// Create a dummy document with required methods/properties
dummyDoc = {
name: 'testRoom',
merge: jest.fn((_other: Y.Doc) => {
// Simulate merging by returning a new Y.Doc instance
return new Y.Doc();
}),
getConnections: jest.fn(() => [{ handleMessage: jest.fn() }]),
awareness: {
on: jest.fn(),
off: jest.fn(),
},
addDirectConnection: jest.fn(),
removeDirectConnection: jest.fn(),
on: jest.fn(),
off: jest.fn(),
};
pollSync = new PollSync(req, 'testRoom', true);
// Pre-set the document for non-init tests
(pollSync as any)._hpDocument = dummyDoc;
// Create a dummy Hocuspocus server
mockHocuspocusServer = {
loadingDocuments: { get: jest.fn() },
documents: new Map<string, any>(),
createDocument: jest.fn(() => ({
name: 'newDoc',
merge: (doc: Y.Doc) => doc,
})),
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('initHocuspocusDocument', () => {
it('should return document from loadingDocuments when available', async () => {
mockHocuspocusServer.loadingDocuments.get.mockResolvedValue(dummyDoc);
pollSync = new PollSync(req, 'testRoom', true);
const doc = await pollSync.initHocuspocusDocument(mockHocuspocusServer);
expect(doc).toBe(dummyDoc);
expect(mockHocuspocusServer.loadingDocuments.get).toHaveBeenCalledWith(
'testRoom',
);
});
it('should return document from documents when available', async () => {
mockHocuspocusServer.loadingDocuments.get.mockResolvedValue(undefined);
mockHocuspocusServer.documents.set('testRoom', dummyDoc);
pollSync = new PollSync(req, 'testRoom', false);
const doc = await pollSync.initHocuspocusDocument(mockHocuspocusServer);
expect(doc).toBe(dummyDoc);
});
it('should create a new document when none exists and canEdit is true', async () => {
mockHocuspocusServer.loadingDocuments.get.mockResolvedValue(undefined);
pollSync = new PollSync(req, 'testRoom', true);
const newDoc = { name: 'newDoc', merge: (doc: any) => doc };
mockHocuspocusServer.createDocument.mockResolvedValue(newDoc);
const doc = await pollSync.initHocuspocusDocument(mockHocuspocusServer);
expect(doc).toBe(newDoc);
expect(mockHocuspocusServer.createDocument).toHaveBeenCalled();
});
});
describe('sync', () => {
it('should encode state without merging when canEdit is false', () => {
// When user cannot edit, merge should not be called.
pollSync = new PollSync(req, 'testRoom', false);
(pollSync as any)._hpDocument = dummyDoc;
mockEncodeStateAsUpdate.mockReturnValue(Uint8Array.from([1, 2, 3]));
const result = pollSync.sync('dummyLocalDoc64');
expect(dummyDoc.merge).not.toHaveBeenCalled();
expect(result).toBe(
Buffer.from(Uint8Array.from([1, 2, 3])).toString('base64'),
);
});
it('should merge local doc when canEdit is true', () => {
pollSync = new PollSync(req, 'testRoom', true);
(pollSync as any)._hpDocument = dummyDoc;
const localDoc = new Y.Doc();
// Mock base64ToYDoc to return our localDoc
base64ToYDoc.mockReturnValue(localDoc);
mockEncodeStateAsUpdate.mockReturnValue(Uint8Array.from([4, 5, 6]));
pollSync.sync('localDoc64');
expect(dummyDoc.merge).toHaveBeenCalledWith(localDoc);
});
});
describe('sendClientsMessages', () => {
it('should log an error if room names do not match', () => {
// Set doc name different from what IncomingMessage returns ('testRoom')
dummyDoc.name = 'differentRoom';
(pollSync as any)._hpDocument = dummyDoc;
const fakeMessage = Buffer.from('fakeMessage').toString('base64');
pollSync.sendClientsMessages(fakeMessage);
expect(logger).toHaveBeenCalled();
});
});
describe('pullClientsMessages', () => {
it('should register event listeners and cleanup on request close', () => {
(pollSync as any)._hpDocument = dummyDoc;
pollSync.pullClientsMessages(res);
expect(dummyDoc.addDirectConnection).toHaveBeenCalled();
expect(dummyDoc.on).toHaveBeenCalled();
expect(dummyDoc.awareness.on).toHaveBeenCalled();
// Simulate a 'close' event on the request
if (req.destroy) {
req.destroy();
}
expect(dummyDoc.removeDirectConnection).toHaveBeenCalled();
// Verify that document listeners are removed (off was called)
expect(dummyDoc.off).toHaveBeenCalled();
expect(dummyDoc.awareness.off).toHaveBeenCalled();
});
});
});
@@ -1,193 +0,0 @@
import { Response } from 'express';
import {
collaborationPollPostMessageHandler,
collaborationPollSSEMessageHandler,
collaborationPollSyncDocHandler,
} from '../src/handlers/collaborationPollHandler';
const mockInitHocuspocusDocument = jest.fn();
const mockSendClientsMessages = jest.fn();
const mockSync = jest.fn();
const mockPullClientsMessages = jest.fn();
jest.mock('@/libs/PollSync', () => {
return {
PollSync: jest.fn().mockImplementation(() => ({
initHocuspocusDocument: mockInitHocuspocusDocument,
sendClientsMessages: mockSendClientsMessages,
sync: mockSync,
pullClientsMessages: mockPullClientsMessages,
})),
};
});
jest.mock('@/servers/hocusPocusServer', () => ({
hocusPocusServer: {},
}));
// Helper function to create a mock response
function createResponse<T>() {
const res: Partial<Response<T>> = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.setHeader = jest.fn();
res.write = jest.fn();
res.headersSent = false;
return res as Response<T>;
}
describe('collaborationPollPostMessageHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
mockInitHocuspocusDocument.mockResolvedValue({ doc: 'exists' });
});
it('should return 403 if user is not allowed to edit', async () => {
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'False' },
body: { message64: 'testMessage' },
} as any;
const res = createResponse();
await collaborationPollPostMessageHandler(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' });
});
it('should return 400 if room is not provided', async () => {
const req = {
query: {},
headers: { 'x-can-edit': 'True' },
body: { message64: 'testMessage' },
} as any;
const res = createResponse();
await collaborationPollPostMessageHandler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Room name not provided' });
});
it('should return 404 if document is not found', async () => {
mockInitHocuspocusDocument.mockResolvedValue(null);
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'True' },
body: { message64: 'testMessage' },
} as any;
const res = createResponse();
res.headersSent = false;
await collaborationPollPostMessageHandler(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Document not found' });
});
it('should process message and return updated true when successful', async () => {
// Reset headerSent to false to simulate a proper response
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'True' },
body: { message64: 'testMessage' },
} as any;
const res = createResponse();
res.headersSent = false;
await collaborationPollPostMessageHandler(req, res);
expect(mockSendClientsMessages).toHaveBeenCalledWith('testMessage');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ updated: true });
});
});
describe('collaborationPollSyncDocHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
mockInitHocuspocusDocument.mockResolvedValue({ doc: 'exists' });
mockSync.mockReturnValue('syncDocEncoded');
});
it('should return 400 if room is not provided', async () => {
const req = {
query: {},
headers: { 'x-can-edit': 'True' },
body: { localDoc64: 'localDocEncoded' },
} as any;
const res = createResponse();
await collaborationPollSyncDocHandler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Room name not provided' });
});
it('should return 404 if document is not found', async () => {
mockInitHocuspocusDocument.mockResolvedValue(null);
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'True' },
body: { localDoc64: 'localDocEncoded' },
} as any;
const res = createResponse();
await collaborationPollSyncDocHandler(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Document not found' });
});
it('should sync document and return syncDoc64 when successful', async () => {
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'True' },
body: { localDoc64: 'localDocEncoded' },
} as any;
const res = createResponse();
res.headersSent = false;
await collaborationPollSyncDocHandler(req, res);
expect(mockSync).toHaveBeenCalledWith('localDocEncoded');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ syncDoc64: 'syncDocEncoded' });
});
});
describe('collaborationPollSSEMessageHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
mockInitHocuspocusDocument.mockResolvedValue({ doc: 'exists' });
});
it('should return 400 if room is not provided', async () => {
const req = {
query: {},
headers: { 'x-can-edit': 'True' },
} as any;
const res = createResponse();
await collaborationPollSSEMessageHandler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Room name not provided' });
});
it('should return 404 if document is not found', async () => {
mockInitHocuspocusDocument.mockResolvedValue(null);
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'True' },
} as any;
const res = createResponse();
await collaborationPollSSEMessageHandler(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Document not found' });
});
it('should set SSE headers and send connected message when successful', async () => {
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'True' },
} as any;
const res = createResponse();
res.headersSent = false;
await collaborationPollSSEMessageHandler(req, res);
expect(res.setHeader).toHaveBeenCalledWith(
'Content-Type',
'text/event-stream',
);
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
expect(res.setHeader).toHaveBeenCalledWith('Connection', 'keep-alive');
expect(res.write).toHaveBeenCalledWith(': connected\n\n');
expect(mockPullClientsMessages).toHaveBeenCalledWith(res);
});
});
@@ -7,7 +7,6 @@ var config = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/../src/$1',
'^@blocknote/server-util$': '<rootDir>/../__mocks__/mock.js',
'^y-protocols/awareness.js$': '<rootDir>/../__mocks__/mock.js',
},
};
export default config;
@@ -23,7 +23,6 @@
"cors": "2.8.5",
"express": "4.21.2",
"express-ws": "5.0.2",
"uuid": "11.0.5",
"y-protocols": "1.0.6",
"yjs": "13.6.23"
},
+2 -3
View File
@@ -4,8 +4,7 @@ export const COLLABORATION_SERVER_ORIGIN =
process.env.COLLABORATION_SERVER_ORIGIN || 'http://localhost:3000';
export const COLLABORATION_SERVER_SECRET =
process.env.COLLABORATION_SERVER_SECRET || 'secret-api-key';
export const PORT = Number(process.env.PORT || 4444);
export const SENTRY_DSN = process.env.SENTRY_DSN || '';
export const SENTRY_ENV = process.env.SENTRY_ENV || 'Development';
export const Y_PROVIDER_API_KEY =
process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key';
export const PORT = Number(process.env.PORT || 4444);
export const SENTRY_DSN = process.env.SENTRY_DSN || '';
@@ -1,132 +0,0 @@
import { Response } from 'express';
import { PollSync, PollSyncRequest } from '@/libs/PollSync';
import { hocusPocusServer } from '@/servers/hocusPocusServer';
interface CollaborationPollPostMessagePayload {
message64: string;
}
interface CollaborationPollPostMessageResponse {
updated?: boolean;
error?: string;
}
export const collaborationPollPostMessageHandler = async (
req: PollSyncRequest<CollaborationPollPostMessagePayload>,
res: Response<CollaborationPollPostMessageResponse>,
) => {
const room = req.query.room;
const canEdit = req.headers['x-can-edit'] === 'True';
// Only editors can send messages
if (!canEdit) {
res.status(403).json({ error: 'Forbidden' });
return;
}
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
const pollSynch = new PollSync<CollaborationPollPostMessagePayload>(
req,
room,
canEdit,
);
const hpDoc = await pollSynch.initHocuspocusDocument(hocusPocusServer);
if (!res.headersSent && !hpDoc) {
res.status(404).json({ error: 'Document not found' });
return;
}
pollSynch.sendClientsMessages(req.body.message64);
if (!res.headersSent) {
res.status(200).json({ updated: true });
}
};
/**
* Polling way of handling collaboration
* @param req
* @param res
*/
interface CollaborationPollSyncDocResponse {
syncDoc64?: string;
error?: string;
}
interface CollaborationPollSyncDocBody {
localDoc64: string;
}
export const collaborationPollSyncDocHandler = async (
req: PollSyncRequest<CollaborationPollSyncDocBody>,
res: Response<CollaborationPollSyncDocResponse>,
) => {
const room = req.query.room;
const canEdit = req.headers['x-can-edit'] === 'True';
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
const pollSynch = new PollSync<CollaborationPollSyncDocBody>(
req,
room,
canEdit,
);
const hpDoc = await pollSynch.initHocuspocusDocument(hocusPocusServer);
if (!hpDoc) {
res.status(404).json({ error: 'Document not found' });
return;
}
const syncDoc64 = pollSynch.sync(req.body.localDoc64);
if (!res.headersSent) {
res.status(200).json({ syncDoc64 });
}
};
/**
* SSE message handling
* @param req
* @param res
*/
interface CollaborationPollSSEMessageResponse {
updatedDoc64?: string;
stateFingerprint?: string;
awareness64?: string;
error?: string;
}
export const collaborationPollSSEMessageHandler = async (
req: PollSyncRequest<void>,
res: Response<CollaborationPollSSEMessageResponse>,
) => {
const room = req.query.room;
const canEdit = req.headers['x-can-edit'] === 'True';
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
const pollSynch = new PollSync<void>(req, room, canEdit);
const hpDoc = await pollSynch.initHocuspocusDocument(hocusPocusServer);
if (!hpDoc) {
res.status(404).json({ error: 'Document not found' });
return;
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(': connected\n\n');
pollSynch.pullClientsMessages(res);
};
@@ -1,4 +1,3 @@
export * from './collaborationResetConnectionsHandler';
export * from './collaborationPollHandler';
export * from './convertMarkdownHandler';
export * from './collaborationWSHandler';
export * from './convertMarkdownHandler';
@@ -1,6 +1,6 @@
export const promiseDone = <T = void>() => {
let done: (value: T | PromiseLike<T>) => void = () => {};
const promise = new Promise<T>((resolve) => {
export const promiseDone = () => {
let done: (value: void | PromiseLike<void>) => void = () => {};
const promise = new Promise<void>((resolve) => {
done = resolve;
});
@@ -1,227 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import crypto from 'crypto';
import {
AwarenessUpdate,
Document,
Hocuspocus,
IncomingMessage,
MessageType,
OutgoingMessage,
} from '@hocuspocus/server';
import { Request, Response } from 'express';
import { v4 as uuid } from 'uuid';
import { applyAwarenessUpdate } from 'y-protocols/awareness.js';
import { readSyncMessage } from 'y-protocols/sync';
import * as Y from 'yjs';
import { base64ToYDoc, logger, toBase64 } from '@/utils';
export type PollSyncRequestQuery = {
room?: string;
};
export type PollSyncRequest<T> = Request<
object,
object,
T,
PollSyncRequestQuery
>;
export class PollSync<T> {
public readonly canEdit: boolean;
public readonly req: PollSyncRequest<T>;
public readonly room: string;
private _hpDocument?: Document;
constructor(req: PollSyncRequest<T>, room: string, canEdit: boolean) {
this.room = room;
this.canEdit = canEdit;
this.req = req;
}
public get hpDocument() {
return this._hpDocument;
}
public async initHocuspocusDocument(hocusPocusServer: Hocuspocus) {
const { req, room, canEdit } = this;
this._hpDocument = await hocusPocusServer.loadingDocuments.get(room);
if (this._hpDocument) {
return this._hpDocument;
}
this._hpDocument = hocusPocusServer.documents.get(room);
if (this._hpDocument || (!this._hpDocument && !canEdit)) {
return this._hpDocument;
}
/**
* createDocument is used to create a new document if it does not exist.
* If the document exists, it will return the existing document.
*/
this._hpDocument = await hocusPocusServer.createDocument(
room,
req,
uuid(),
{
readOnly: false,
requiresAuthentication: false,
isAuthenticated: false,
},
);
return this._hpDocument;
}
/**
* Sync the document with the latest changes
*
* @param localDoc64
* @returns
*/
public sync(localDoc64: string): string | undefined {
const hpDoc = this.getHpDocument();
let syncYDoc = hpDoc;
// Merge the coming document with the latest changes (only if the user can edit)
if (this.canEdit) {
const localDoc = base64ToYDoc(localDoc64);
syncYDoc = hpDoc.merge(localDoc);
}
return toBase64(Y.encodeStateAsUpdate(syncYDoc));
}
/**
* Create a hash SHA-256 of the state vector of the document.
* Usefull to compare the state of the document.
* @param doc
* @returns
*/
protected getStateFingerprint(doc: Y.Doc): string {
const stateVector = Y.encodeStateVector(doc);
return crypto.createHash('sha256').update(stateVector).digest('base64'); // or 'hex'
}
/**
* Send messages to other clients
*/
public sendClientsMessages(message64: string) {
const hpDoc = this.getHpDocument();
const messageBuffer = Buffer.from(message64, 'base64');
const message = new IncomingMessage(messageBuffer);
const room = message.readVarString();
if (hpDoc.name !== room) {
logger('Send messages problem, room different', room, hpDoc.name);
return;
}
// We write the sync to the current doc - it will propagate to others by itself
const type = message.readVarUint() as MessageType;
if (type === MessageType.Sync) {
message.writeVarUint(MessageType.Sync);
readSyncMessage(message.decoder, message.encoder, hpDoc, null);
} else if (type === MessageType.Awareness) {
const awarenessUpdate = message.readVarUint8Array();
applyAwarenessUpdate(
hpDoc.awareness,
awarenessUpdate,
hpDoc.awareness.clientID,
);
} else {
hpDoc.getConnections().forEach((connection) => {
connection.handleMessage(messageBuffer);
});
}
}
/**
* Pull messages from other clients
*
* We listen 2 kind of messages:
* - Document updates (change in the document)
* - Awareness messages (cursor, selection, etc.)
*
* @param res
*/
public pullClientsMessages(res: Response) {
const hpDoc = this.getHpDocument();
hpDoc.addDirectConnection();
const updateMessagesFn = (
update: Uint8Array,
_origin: string,
updatedDoc: Y.Doc,
_transaction: Y.Transaction,
) => {
res.write(
`data: ${JSON.stringify({
time: new Date(),
updatedDoc64: toBase64(update),
stateFingerprint: this.getStateFingerprint(updatedDoc),
})}\n\n`,
);
};
const destroyFn = (updatedDoc: Y.Doc) => {
res.write(
`data: ${JSON.stringify({
time: new Date(),
updatedDoc64: undefined,
stateFingerprint: this.getStateFingerprint(updatedDoc),
})}\n\n`,
);
hpDoc.off('destroy', destroyFn);
hpDoc.off('update', updateMessagesFn);
// Close the connection
res.end();
};
const updateAwarenessFn = ({
added,
updated,
removed,
}: AwarenessUpdate) => {
const changedClients = added.concat(updated, removed);
const awarenessMessage = new OutgoingMessage(
this.room,
).createAwarenessUpdateMessage(hpDoc.awareness, changedClients);
res.write(
`data: ${JSON.stringify({
time: new Date(),
awareness64: toBase64(awarenessMessage.toUint8Array()),
stateFingerprint: this.getStateFingerprint(hpDoc),
})}\n\n`,
);
};
hpDoc.awareness.off('update', updateAwarenessFn);
hpDoc.awareness.on('update', updateAwarenessFn);
hpDoc.off('update', updateMessagesFn);
hpDoc.off('destroy', destroyFn);
hpDoc.on('update', updateMessagesFn);
hpDoc.on('destroy', destroyFn);
this.req.on('close', () => {
hpDoc.off('update', updateMessagesFn);
hpDoc.off('destroy', destroyFn);
hpDoc.awareness.off('update', updateAwarenessFn);
hpDoc.removeDirectConnection();
});
}
protected getHpDocument() {
if (!this.hpDocument) {
throw new Error('HocusPocus document not initialized');
}
return this.hpDocument;
}
}
@@ -1,7 +1,5 @@
export const routes = {
COLLABORATION_WS: '/collaboration/ws/',
COLLABORATION_POLL_MESSAGE: '/collaboration/ws/poll/message/',
COLLABORATION_POLL_SYNC: '/collaboration/ws/poll/sync/',
COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/',
CONVERT_MARKDOWN: '/api/convert-markdown/',
};
@@ -6,9 +6,6 @@ import expressWebsockets from 'express-ws';
import { PORT } from '../env';
import {
collaborationPollPostMessageHandler,
collaborationPollSSEMessageHandler,
collaborationPollSyncDocHandler,
collaborationResetConnectionsHandler,
collaborationWSHandler,
convertMarkdownHandler,
@@ -30,24 +27,9 @@ export const initServer = () => {
app.use(corsMiddleware);
/**
* Routes to handle collaboration connections
* Route to handle WebSocket connections
*/
app.ws(routes.COLLABORATION_WS, wsSecurity, collaborationWSHandler);
app.get(
routes.COLLABORATION_POLL_MESSAGE,
httpSecurity,
collaborationPollSSEMessageHandler,
);
app.post(
routes.COLLABORATION_POLL_MESSAGE,
httpSecurity,
collaborationPollPostMessageHandler,
);
app.post(
routes.COLLABORATION_POLL_SYNC,
httpSecurity,
collaborationPollSyncDocHandler,
);
/**
* Route to reset connections in a room:
@@ -1,12 +1,11 @@
import * as Sentry from '@sentry/node';
import { nodeProfilingIntegration } from '@sentry/profiling-node';
import { SENTRY_DSN, SENTRY_ENV } from '../env';
import { SENTRY_DSN } from '../env';
Sentry.init({
dsn: SENTRY_DSN,
integrations: [nodeProfilingIntegration()],
tracesSampleRate: 0.1,
profilesSampleRate: 1.0,
environment: SENTRY_ENV,
});
+2 -11
View File
@@ -1,5 +1,3 @@
import * as Y from 'yjs';
import { COLLABORATION_LOGGING } from './env';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -10,13 +8,6 @@ export function logger(...args: any[]) {
}
}
export const toBase64 = function (uInt8Array: Uint8Array) {
return Buffer.from(uInt8Array).toString('base64');
};
export const base64ToYDoc = (base64: string) => {
const uint8Array = Buffer.from(base64, 'base64');
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, uint8Array);
return ydoc;
export const toBase64 = function (str: Uint8Array) {
return Buffer.from(str).toString('base64');
};
+1 -8
View File
@@ -5281,13 +5281,6 @@
dependencies:
"@types/node" "*"
"@types/ws@8.5.13":
version "8.5.13"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.13.tgz#6414c280875e2691d0d1e080b05addbf5cb91e20"
integrity sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==
dependencies:
"@types/node" "*"
"@types/yargs-parser@*":
version "21.0.3"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15"
@@ -13941,7 +13934,7 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
uuid@11.0.5, uuid@^11.0.3:
uuid@^11.0.3:
version "11.0.5"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.5.tgz#07b46bdfa6310c92c3fb3953a8720f170427fc62"
integrity sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==
+1 -1
View File
@@ -1,5 +1,5 @@
apiVersion: v2
type: application
name: docs
version: 2.2.0-beta.2
version: 2.2.0-beta.1
appVersion: latest
-4
View File
@@ -82,11 +82,7 @@ ingressCollaborationWS:
## @param ingressCollaborationWS.annotations.nginx.ingress.kubernetes.io/proxy-send-timeout
## @param ingressCollaborationWS.annotations.nginx.ingress.kubernetes.io/upstream-hash-by
annotations:
nginx.ingress.kubernetes.io/auth-cache-key: "$http_authorization-$arg_room"
nginx.ingress.kubernetes.io/auth-cache-duration: 200 30s
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Can-Edit, X-User-Id"
nginx.ingress.kubernetes.io/auth-snippet: |
proxy_set_header Accept "application/json";
nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/collaboration-auth/
nginx.ingress.kubernetes.io/enable-websocket: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "86400"