Compare commits

..

10 Commits

Author SHA1 Message Date
Anthony LC f716c498e5 📝(documentation) add collaboration architecture doc
Documentation to describe the collaboration
architecture in the project.
2025-02-20 11:20:50 +01:00
Anthony LC 29a4147b5e fixup! 🔧(helm) adapt helm nginx 2025-02-20 11:20:50 +01:00
Anthony LC 15389156d3 fixup! 🔧(ngnix) adapt nginx development 2025-02-20 11:20:50 +01:00
Anthony LC 168904728b for-testing
Firefox with websocket
Other without
2025-02-20 11:20:50 +01:00
Anthony LC 3ca07e0f4c 🔧(helm) adapt helm nginx
We adapt the nginx configuration to works
with http requests and on the collaboration routes.
Requests are light but quite network intensive,
so we add a cache system above "collaboration-auth".
It means the backend will be called only once
every 30 seconds after a 200 response.
2025-02-20 11:20:50 +01:00
Anthony LC 6098fe1e14 🔧(ngnix) adapt nginx development
We adapt the nginx configuration to works
with http requests and on the collaboration routes.
Requests are light but quite network intensive,
so we add a cache system above "collaboration-auth".
It means the backend will be called only once
every 30 seconds after a 200 response.
2025-02-20 11:20:50 +01:00
Anthony LC a2f1e32f21 (frontend) create class CollaborationProvider
Create the CollaborationProvider class.
This class is inherited from HocuspocusProvider class.
This class integrate a fallback mechanism to handle the
case where the user cannot connect with websockets.
It will use post request to send the data to the
collaboration server.
It will use an EventSource to receive the data from the
collaboration server.
2025-02-20 11:20:50 +01:00
Anthony LC f27e968c15 🚚(frontend) move toBase64
We will need toBase64 in different features,
better to move it to "doc-management".
2025-02-20 11:20:49 +01:00
Anthony LC 971b7e099d (y-provider) collaboration polling
We can now interact with the collaboration server
using http requests.
It will be used as a fallback when the websocket
is not working.
2 kind of requests:
 - to send messages to the server we use POST requests
 - to get messages from the server we use a GET
 request using SSE (Server Sent Events)
2025-02-20 11:20:49 +01:00
Anthony LC 6d0ccb15ea 🔧(y-provider) add missing Sentry environment
The environment was missing in the Sentry
configuration.
This commit adds the environment to the
Sentry configuration.
2025-02-20 11:20:49 +01:00
84 changed files with 1844 additions and 1673 deletions
+1
View File
@@ -6,6 +6,7 @@ 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
- 💄(frontend) add error pages #643
- ✨Collaboration long polling fallback #517
## 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
@@ -0,0 +1 @@
proxy_cache_path /tmp/auth_cache levels=1:2 keys_zone=auth_cache:10m inactive=60s max_size=100m;
+15 -3
View File
@@ -1,4 +1,3 @@
server {
listen 8083;
server_name localhost;
@@ -6,6 +5,14 @@ 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;
@@ -34,6 +41,10 @@ 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;
@@ -43,10 +54,11 @@ 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;
@@ -76,7 +88,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
@@ -0,0 +1,84 @@
# 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
@@ -1,745 +0,0 @@
{
"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
@@ -1,5 +0,0 @@
{
"dependencies": {
"@ag-media/react-pdf-table": "^2.0.1"
}
}
-314
View File
@@ -1,314 +0,0 @@
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('Home')).toBeVisible();
await expect(page.getByText('Back to home page')).toBeVisible();
});
test('checks go back to home page redirects to home page', async ({
page,
}) => {
await page.getByText('Home').click();
await page.getByText('Back to home page').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('menuitem', { name: role }).click();
await page.getByRole('button', { name: role }).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
@@ -0,0 +1,97 @@
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('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('button').getByText('Français'),
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
// Trigger slash menu to show french menu
@@ -88,70 +88,6 @@ 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,9 +7,17 @@ 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();
@@ -86,10 +94,6 @@ 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);
@@ -208,8 +212,6 @@ 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) =>
@@ -280,9 +282,11 @@ test.describe('Documents filters', () => {
});
test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
@@ -310,12 +314,11 @@ test.describe('Documents Grid', () => {
test('checks the infinite scroll', async ({ page }) => {
let docs: SmallDoc[] = [];
const responsePromisePage1 = page.waitForResponse((response) => {
return (
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().endsWith(`/documents/?page=1`) &&
response.status() === 200
);
});
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
@@ -323,8 +326,6 @@ 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('menuitem', {
.getByRole('button', {
name: 'Delete document',
})
.click();
@@ -153,7 +153,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
page.getByRole('button', { 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('menuitem', {
page.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
@@ -195,7 +195,7 @@ test.describe('Doc Header', () => {
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('menuitem', {
page.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
@@ -233,7 +233,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
page.getByRole('button', { 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('menuitem', { name: 'Delete document' }),
page.getByRole('button', { 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('menuitem', { name: 'Copy as Markdown' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Copy as HTML' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Share' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Share' }).click();
await page.getByRole('button', { 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('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: 'Reader' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
page.getByRole('button', { name: 'Administrator' }),
).toBeVisible();
// Validate
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Owner' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Owner' }).click();
await page.getByRole('button', { 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('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'translate 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('menuitem', { name: 'Administrateur' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Reader' }).click();
await page.getByRole('button', { name: 'Reader' }).click();
const moreActions = userInvitation.getByRole('button', {
name: 'more_horiz',
});
await moreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Administrator' }).click();
await list.click();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(userReader).toBeHidden();
await mySelfMoreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(
page.getByText('You do not have permission to view this document.'),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
});
@@ -19,7 +19,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Version history',
})
.click();
@@ -59,7 +59,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Version history',
})
.click();
@@ -91,7 +91,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
page.getByRole('button', { name: 'Version history' }),
).toBeDisabled();
});
@@ -120,7 +120,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Version history',
})
.click();
@@ -50,7 +50,7 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -60,7 +60,7 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -100,9 +100,7 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('A doc is not accessible when authentified but not member.', async ({
@@ -135,7 +133,7 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await expect(
page.getByText('You do not have permission to view this document.'),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
@@ -162,7 +160,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('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
@@ -215,7 +213,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -227,7 +225,7 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Reading',
})
.click();
@@ -289,7 +287,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -357,7 +355,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -381,10 +379,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeHidden();
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('It checks a authenticated doc in read only mode', async ({
@@ -407,7 +402,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -416,14 +411,6 @@ 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();
@@ -469,7 +456,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
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('button', { name: /Language/ }),
header.getByRole('combobox', { name: 'Language' }),
).toBeVisible();
await expect(
header.getByRole('button', { name: 'Les services de La Suite numé' }),
@@ -9,20 +9,19 @@ test.describe('Language', () => {
await expect(page.getByLabel('Logout')).toBeVisible();
const header = page.locator('header').first();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('button').getByText('Français'),
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).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 header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
await expect(
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
});
@@ -54,11 +53,8 @@ test.describe('Language', () => {
// Switch language to French
const header = page.locator('header').first();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { 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('button', { name: /Language/ });
const languageButton = page.getByRole('combobox', { name: 'Language' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport();
-1
View File
@@ -381,7 +381,6 @@ 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,6 +62,7 @@
"@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.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

@@ -8,20 +8,17 @@ 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;
`;
interface StyledButtonProps {
$css?: BoxProps['$css'];
}
const StyledButton = styled(Button)<StyledButtonProps>`
const StyledButton = styled(Button)`
cursor: pointer;
border: none;
background: none;
@@ -32,12 +29,10 @@ const StyledButton = styled(Button)<StyledButtonProps>`
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;
@@ -45,7 +40,6 @@ export interface DropButtonProps {
export const DropButton = ({
button,
buttonCss,
isOpen = false,
onOpenChange,
children,
@@ -53,14 +47,11 @@ export const DropButton = ({
}: PropsWithChildren<DropButtonProps>) => {
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
const triggerRef = useRef<HTMLButtonElement>(null);
const firstFocusableRef = useRef<HTMLButtonElement>(null);
const triggerRef = useRef(null);
useEffect(() => {
if (isLocalOpen && firstFocusableRef.current) {
firstFocusableRef.current.focus();
}
}, [isLocalOpen]);
setIsLocalOpen(isOpen);
}, [isOpen]);
const onOpenChangeHandler = (isOpen: boolean) => {
setIsLocalOpen(isOpen);
@@ -73,7 +64,6 @@ export const DropButton = ({
ref={triggerRef}
onPress={() => onOpenChangeHandler(true)}
aria-label={label}
$css={buttonCss}
>
{button}
</StyledButton>
@@ -1,4 +1,4 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { PropsWithChildren, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
@@ -20,7 +20,6 @@ export type DropdownMenuProps = {
showArrow?: boolean;
label?: string;
arrowCss?: BoxProps['$css'];
buttonCss?: BoxProps['$css'];
disabled?: boolean;
topMessage?: string;
};
@@ -31,7 +30,6 @@ export const DropdownMenu = ({
disabled = false,
showArrow = false,
arrowCss,
buttonCss,
label,
topMessage,
}: PropsWithChildren<DropdownMenuProps>) => {
@@ -39,7 +37,6 @@ 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);
@@ -54,17 +51,10 @@ export const DropdownMenu = ({
isOpen={isOpen}
onOpenChange={onOpenChange}
label={label}
buttonCss={buttonCss}
button={
showArrow ? (
<Box
ref={blockButtonRef}
$direction="row"
$align="center"
$position="relative"
aria-controls="menu"
>
<Box>{children}</Box>
<Box $direction="row" $align="center">
<div>{children}</div>
<Icon
$variation="600"
$css={
@@ -77,17 +67,11 @@ export const DropdownMenu = ({
/>
</Box>
) : (
<Box ref={blockButtonRef} aria-controls="menu">
{children}
</Box>
children
)
}
>
<Box
$maxWidth="320px"
$minWidth={`${blockButtonRef.current?.clientWidth}px`}
role="menu"
>
<Box $maxWidth="320px">
{topMessage && (
<Text
$variation="700"
@@ -106,7 +90,6 @@ 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,9 +25,7 @@ export interface TextProps extends BoxProps {
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
| 'primary-text'
| 'secondary'
| 'secondary-text'
| 'info'
| 'success'
| 'warning'
@@ -406,10 +406,6 @@ 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(
@@ -609,20 +605,3 @@ 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,9 +505,6 @@
--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,7 +505,6 @@ 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,11 +14,7 @@ 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>
);
@@ -36,7 +32,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,11 +16,8 @@ export const setAuthUrl = () => {
}
};
export const gotoLogin = (withRedirect = true) => {
if (withRedirect) {
setAuthUrl();
}
export const gotoLogin = () => {
setAuthUrl();
window.location.replace(LOGIN_URL);
};
@@ -1,4 +1,9 @@
import { BlockNoteSchema, Dictionary, locales } from '@blocknote/core';
import {
BlockNoteSchema,
Dictionary,
locales,
withPageBreak,
} from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
@@ -22,7 +27,7 @@ import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolbar';
export const blockNoteSchema = BlockNoteSchema.create();
export const blockNoteSchema = withPageBreak(BlockNoteSchema.create());
interface BlockNoteEditorProps {
doc: Doc;
@@ -2,12 +2,10 @@ import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Y from 'yjs';
import { useUpdateDoc } from '@/features/docs/doc-management/';
import { toBase64, 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) {
margin-top: 32px;
padding-top: 32px;
}
&:has(h2) {
margin-top: 24px;
padding-top: 24px;
}
&:has(h3) {
margin-top: 16px;
padding-top: 16px;
}
}
@@ -92,16 +92,9 @@ 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,6 +22,3 @@ 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,7 +27,6 @@ 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();
@@ -39,7 +38,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$gap={spacings['base']}
aria-label={t('It is the card information about the document.')}
>
{(docIsPublic || docIsAuth) && (
{docIsPublic && (
<Box
aria-label={t('Public document')}
$color={colors['primary-800']}
@@ -58,12 +57,10 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$theme="primary"
$variation="800"
data-testid="public-icon"
iconName={docIsPublic ? 'public' : 'vpn_lock'}
iconName="public"
/>
<Text $theme="primary" $variation="800">
{docIsPublic
? t('Public document')
: t('Document accessible to any connected person')}
{t('Public document')}
</Text>
</Box>
)}
@@ -79,9 +76,8 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$css="flex:1;"
$gap="0.5rem 1rem"
$align="center"
$maxWidth="100%"
>
<Box $gap={spacings['3xs']} $overflow="auto">
<Box $gap={spacings['3xs']}>
<DocTitle doc={doc} />
<Box $direction="row">
@@ -101,35 +101,39 @@ 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 || '')
}
$color={colorsTokens()['greyscale-1000']}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
<>
<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 || '')
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
`}
>
{titleDisplay}
</Box>
</Tooltip>
$color={colorsTokens()['greyscale-1000']}
$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>
</>
);
};
@@ -18,11 +18,7 @@ import {
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/features/docs/doc-editor/';
import {
Doc,
ModalRemoveDoc,
useCopyDocLink,
} from '@/features/docs/doc-management';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
import { DocShareModal } from '@/features/docs/doc-share';
import {
KEY_LIST_DOC_VERSIONS,
@@ -38,7 +34,7 @@ interface DocToolBoxProps {
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const hasAccesses = doc.nb_accesses > 1 && doc.abilities.accesses_view;
const hasAccesses = doc.nb_accesses > 1;
const queryClient = useQueryClient();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
@@ -54,7 +50,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const copyDocLink = useCopyDocLink(doc.id);
const options: DropdownMenuOption[] = [
...(isSmallMobile
@@ -71,11 +66,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
setIsModalExportOpen(true);
},
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),
@@ -0,0 +1,67 @@
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,15 +71,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
</Button>
</>
}
size={ModalSize.SMALL}
size={ModalSize.MEDIUM}
title={
<Text
$size="h6"
as="h6"
$margin={{ all: '0' }}
$align="flex-start"
$variation="1000"
>
<Text $size="h6" as="h6" $margin={{ all: '0' }} $align="flex-start">
{t('Delete a doc')}
</Text>
}
@@ -6,17 +6,29 @@ import { useBroadcastStore } from '@/stores';
import { useProviderStore } from '../stores/useProviderStore';
import { Base64 } from '../types';
export const useCollaboration = (room?: string, initialContent?: Base64) => {
export const useCollaboration = (
room?: string,
initialContent?: Base64,
canEdit?: boolean,
) => {
const collaborationUrl = useCollaborationUrl(room);
const { setBroadcastProvider } = useBroadcastStore();
const { provider, createProvider, destroyProvider } = useProviderStore();
/**
* Initialize the provider
*/
useEffect(() => {
if (!room || !collaborationUrl || provider) {
if (!room || !collaborationUrl || provider || canEdit === undefined) {
return;
}
const newProvider = createProvider(collaborationUrl, room, initialContent);
const newProvider = createProvider(
collaborationUrl,
room,
canEdit,
initialContent,
);
setBroadcastProvider(newProvider);
}, [
provider,
@@ -25,6 +37,7 @@ export const useCollaboration = (room?: string, initialContent?: Base64) => {
initialContent,
createProvider,
setBroadcastProvider,
canEdit,
]);
/**
@@ -0,0 +1,333 @@
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');
}
}
@@ -0,0 +1,179 @@
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,10 +4,13 @@ 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;
@@ -20,7 +23,7 @@ const defaultValues = {
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
...defaultValues,
createProvider: (wsUrl, storeId, initialDoc) => {
createProvider: (wsUrl, storeId, canEdit, initialDoc) => {
const doc = new Y.Doc({
guid: storeId,
});
@@ -29,10 +32,11 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
}
const provider = new HocuspocusProvider({
const provider = new CollaborationProvider({
url: wsUrl,
name: storeId,
document: doc,
canEdit,
});
set({
@@ -12,6 +12,9 @@ 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,7 +33,9 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
>
<Button
fullWidth={false}
onClick={copyDocLink}
onClick={() => {
copyDocLink();
}}
color="tertiary"
icon={<span className="material-icons">add_link</span>}
>
@@ -100,21 +100,17 @@ export const ModalConfirmationVersion = ({
</Button>
</>
}
size={ModalSize.SMALL}
size={ModalSize.MEDIUM}
title={
<Text $size="h6" $align="flex-start" $variation="1000">
<Text $size="h6" $align="flex-start">
{t('Warning')}
</Text>
}
>
<Box aria-label={t('Modal confirmation to restore the version')}>
<Box>
<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>
<Text>{t('Your current document will revert to this version.')}</Text>
<Text>{t('If a member is editing, his works can be lost.')}</Text>
</Box>
</Box>
</Modal>
@@ -61,8 +61,12 @@ export const DocsGrid = ({
$position="relative"
$width="100%"
$maxWidth="960px"
$maxHeight="calc(100vh - 52px - 2rem)"
$maxHeight="calc(100vh - 52px - 1rem)"
$align="center"
$css={css`
overflow-x: hidden;
overflow-y: auto;
`}
>
<DocsGridLoader isLoading={isRefetching || loading} />
<Card
@@ -71,7 +75,8 @@ export const DocsGrid = ({
$height="100%"
$width="100%"
$css={css`
${!isDesktop ? 'border: none;' : ''}
overflow-x: hidden;
overflow-y: auto;
`}
$padding={{
top: 'base',
@@ -96,7 +101,7 @@ export const DocsGrid = ({
</Box>
)}
{hasDocs && (
<Box $gap="6px" $overflow="auto">
<Box $gap="6px">
<Box
$direction="row"
$padding={{ horizontal: 'xs' }}
@@ -117,30 +122,28 @@ export const DocsGrid = ({
)}
</Box>
{/* Body */}
{data?.pages.map((currentPage) => {
return currentPage.results.map((doc) => (
<DocsGridItem doc={doc} key={doc.id} />
));
})}
{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>
)}
</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>
)}
</InView>
)}
</Card>
</Box>
);
@@ -54,10 +54,6 @@ 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}`}
>
@@ -68,7 +64,6 @@ 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} $overflow="auto">
<Box $direction="row" $gap={spacings.sm}>
<Box
$direction="row"
$align="center"
@@ -50,10 +50,10 @@ export const SimpleDocItem = ({
{isPinned ? (
<PinnedDocumentIcon aria-label={t('Pin document icon')} />
) : (
<SimpleFileIcon aria-hidden="true" />
<SimpleFileIcon aria-label={t('Simple document icon')} />
)}
</Box>
<Box $justify="center" $overflow="auto">
<Box $justify="center">
<Text
aria-describedby="doc-title"
aria-label={doc.title}
@@ -7,11 +7,9 @@ import { createGlobalStyle } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
const GaufreStyle = createGlobalStyle`
&:focus {
outline: 2px solidrgb(33, 34, 82);
.lasuite-gaufre-btn{
box-shadow: inset 0 0 0 0 !important;
}
`;
export const LaGaufre = () => {
@@ -55,7 +55,7 @@ export default function HomeBanner() {
$textAlign="center"
$margin="none"
$css={css`
line-height: ${!isMobile ? '56px' : '45px'};
line-height: 56px;
`}
>
{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' : isSmallMobile ? 'h6' : 'h4'}
$textAlign="left"
$size={!isSmallDevice ? 'xs-alt' : 'h4'}
$textAlign={isSmallMobile ? 'center' : 'left'}
$margin="none"
>
{title}
@@ -1,16 +1,41 @@
import { Select } from '@openfun/cunningham-react';
import { Settings } from 'luxon';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import styled from 'styled-components';
import { DropdownMenu, Text } from '@/components/';
import { Box, 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;
const language = i18n.language;
Settings.defaultLocale = language;
Settings.defaultLocale = i18n.language;
const optionsPicker = useMemo(() => {
return (languages || []).map((lang) => ({
@@ -32,53 +57,28 @@ export const LanguagePicker = () => {
>
translate
</Text>
<Text $theme="primary" $weight="500" $variation="800" lang={lang}>
<Text $theme="primary" $weight="500" $variation="800">
{LANGUAGES_ALLOWED[lang]}
</Text>
</Box>
),
label: LANGUAGES_ALLOWED[lang],
isSelected: language === lang,
callback: () => {
i18n.changeLanguage(lang).catch((err) => {
console.error('Error changing language', err);
});
},
}));
}, [i18n, language, languages]);
}, [languages]);
return (
<DropdownMenu
<SelectStyled
label={t('Language')}
showLabelWhenSelected={false}
clearable={false}
hideLabel
defaultValue={i18n.language}
className="c_select__no_bg"
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>
onChange={(e) => {
i18n.changeLanguage(e.target.value as string).catch((err) => {
console.error('Error changing language', err);
});
}}
/>
);
};
@@ -50,7 +50,6 @@ 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}`} $css="overflow: auto;">
<StyledLink href={`/docs/${doc.id}`}>
<SimpleDocItem showAccesses doc={doc} />
</StyledLink>
<div className="pinned-actions">
@@ -50,11 +50,8 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
onClick={goToHome}
size="medium"
color="tertiary-text"
aria-label={t('Back to home page')}
icon={
<span aria-hidden="true">
<Icon $variation="800" $theme="primary" iconName="house" />
</span>
<Icon $variation="800" $theme="primary" iconName="house" />
}
/>
{authenticated && (
@@ -62,27 +59,14 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
onClick={searchModal.open}
size="medium"
color="tertiary-text"
aria-label={t('Search')}
icon={
<span aria-hidden="true">
<Icon
$variation="800"
$theme="primary"
iconName="search"
/>
</span>
<Icon $variation="800" $theme="primary" iconName="search" />
}
/>
)}
</Box>
{authenticated && (
<Button
onClick={createNewDoc}
className="new-doc-button"
aria-label={t('New document')}
>
{t('New doc')}
</Button>
<Button onClick={createNewDoc}>{t('New doc')}</Button>
)}
</Box>
</SeparatedSection>
@@ -270,7 +270,6 @@
"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' : '0',
all: isDesktop ? 'base' : '2xs',
}}
$background={
currentBackgroundColor === 'white'
backgroundColor === 'white'
? colors['greyscale-000']
: colors['greyscale-050']
}
@@ -6,27 +6,17 @@ import { HEADER_HEIGHT, Header } from '@/features/header';
import { LeftPanel } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
interface PageLayoutProps {
withFooter?: boolean;
}
export function PageLayout({
children,
withFooter = true,
}: PropsWithChildren<PageLayoutProps>) {
export function PageLayout({ children }: PropsWithChildren) {
const { isDesktop } = useResponsiveStore();
return (
<Box
$minHeight={`calc(100vh - ${HEADER_HEIGHT}px)`}
$margin={{ top: `${HEADER_HEIGHT}px` }}
>
<Box $minHeight="100vh" $margin={{ top: `${HEADER_HEIGHT}px` }}>
<Header />
<Box as="main" $width="100%" $css="flex-grow:1;">
{!isDesktop && <LeftPanel />}
{children}
</Box>
{withFooter && <Footer />}
<Footer />
</Box>
);
}
@@ -1,57 +0,0 @@
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;
@@ -1,60 +0,0 @@
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;
+3 -11
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 { PageLayout } from '@/layouts';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const StyledButton = styled(Button)`
@@ -33,15 +33,7 @@ const Page: NextPageWithLayout = () => {
<Box $margin={{ top: 'large' }}>
<StyledLink href="/">
<StyledButton
icon={
<Text $isMaterialIcon $color="white">
house
</Text>
}
>
{t('Home')}
</StyledButton>
<StyledButton>{t('Back to home page')}</StyledButton>
</StyledLink>
</Box>
</Box>
@@ -49,7 +41,7 @@ const Page: NextPageWithLayout = () => {
};
Page.getLayout = function getLayout(page: ReactElement) {
return <PageLayout withFooter={false}>{page}</PageLayout>;
return <MainLayout>{page}</MainLayout>;
};
export default Page;
+1 -5
View File
@@ -16,11 +16,7 @@ type AppPropsWithLayout = AppProps & {
export default function App({ Component, pageProps }: AppPropsWithLayout) {
useSWRegister();
const getLayout = Component.getLayout ?? ((page) => page);
const { t, i18n } = useTranslation();
useEffect(() => {
document.documentElement.lang = i18n.language;
}, [i18n.language]);
const { t } = useTranslation();
useEffect(() => {
console.log(
@@ -5,7 +5,7 @@ import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Box, Text, TextErrors } from '@/components';
import { setAuthUrl } from '@/features/auth';
import { gotoLogin } 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);
useCollaboration(doc?.id, doc?.content, doc?.abilities.partial_update);
useEffect(() => {
if (doc?.title) {
@@ -99,19 +99,13 @@ 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) {
setAuthUrl();
void replace(`/401`);
gotoLogin();
return null;
}
@@ -31,15 +31,7 @@ const Page: NextPageWithLayout = () => {
<Box $margin={{ top: 'large' }}>
<StyledLink href="/">
<StyledButton
icon={
<Text $isMaterialIcon $color="white">
house
</Text>
}
>
{t('Home')}
</StyledButton>
<StyledButton>{t('Back to home page')}</StyledButton>
</StyledLink>
</Box>
</Box>
@@ -0,0 +1,185 @@
/* 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();
});
});
});
@@ -0,0 +1,193 @@
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,6 +7,7 @@ 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,6 +23,7 @@
"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"
},
+3 -2
View File
@@ -4,7 +4,8 @@ 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 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 || '';
export const SENTRY_ENV = process.env.SENTRY_ENV || 'Development';
export const Y_PROVIDER_API_KEY =
process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key';
@@ -0,0 +1,132 @@
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,3 +1,4 @@
export * from './collaborationResetConnectionsHandler';
export * from './collaborationWSHandler';
export * from './collaborationPollHandler';
export * from './convertMarkdownHandler';
export * from './collaborationWSHandler';
@@ -1,6 +1,6 @@
export const promiseDone = () => {
let done: (value: void | PromiseLike<void>) => void = () => {};
const promise = new Promise<void>((resolve) => {
export const promiseDone = <T = void>() => {
let done: (value: T | PromiseLike<T>) => void = () => {};
const promise = new Promise<T>((resolve) => {
done = resolve;
});
@@ -0,0 +1,227 @@
/* 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,5 +1,7 @@
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,6 +6,9 @@ import expressWebsockets from 'express-ws';
import { PORT } from '../env';
import {
collaborationPollPostMessageHandler,
collaborationPollSSEMessageHandler,
collaborationPollSyncDocHandler,
collaborationResetConnectionsHandler,
collaborationWSHandler,
convertMarkdownHandler,
@@ -27,9 +30,24 @@ export const initServer = () => {
app.use(corsMiddleware);
/**
* Route to handle WebSocket connections
* Routes to handle collaboration 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,11 +1,12 @@
import * as Sentry from '@sentry/node';
import { nodeProfilingIntegration } from '@sentry/profiling-node';
import { SENTRY_DSN } from '../env';
import { SENTRY_DSN, SENTRY_ENV } from '../env';
Sentry.init({
dsn: SENTRY_DSN,
integrations: [nodeProfilingIntegration()],
tracesSampleRate: 0.1,
profilesSampleRate: 1.0,
environment: SENTRY_ENV,
});
+11 -2
View File
@@ -1,3 +1,5 @@
import * as Y from 'yjs';
import { COLLABORATION_LOGGING } from './env';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -8,6 +10,13 @@ export function logger(...args: any[]) {
}
}
export const toBase64 = function (str: Uint8Array) {
return Buffer.from(str).toString('base64');
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;
};
+8 -1
View File
@@ -5281,6 +5281,13 @@
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"
@@ -13934,7 +13941,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.3:
uuid@11.0.5, 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.1
version: 2.2.0-beta.2
appVersion: latest
+4
View File
@@ -82,7 +82,11 @@ 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"