Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f716c498e5 | |||
| 29a4147b5e | |||
| 15389156d3 | |||
| 168904728b | |||
| 3ca07e0f4c | |||
| 6098fe1e14 | |||
| a2f1e32f21 | |||
| f27e968c15 | |||
| 971b7e099d | |||
| 6d0ccb15ea |
@@ -6,6 +6,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'feature/collab-long-polling'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
|
||||
+2
-2
@@ -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;
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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 user’s 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.
|
||||
Generated
-745
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "^2.0.1"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
+7
-2
@@ -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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
|
||||
+67
@@ -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>;
|
||||
};
|
||||
+2
-8
@@ -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>
|
||||
}
|
||||
|
||||
+16
-3
@@ -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,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
+333
@@ -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');
|
||||
}
|
||||
}
|
||||
+179
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+6
-2
@@ -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();
|
||||
|
||||
+3
-1
@@ -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>}
|
||||
>
|
||||
|
||||
+4
-8
@@ -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`
|
||||
|
||||
+1
-1
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: docs
|
||||
version: 2.2.0-beta.1
|
||||
version: 2.2.0-beta.2
|
||||
appVersion: latest
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user