mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
1
Commits
API_UI
..
animations
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5b0c9c0ec |
+3
-15
@@ -268,18 +268,6 @@ tasks.register('cleanFrontendAssets', Delete) {
|
||||
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
|
||||
}
|
||||
|
||||
tasks.register('copyApiLandingPage', Copy) {
|
||||
group = 'frontend'
|
||||
description = 'Copy API landing page to index.html for backend-only mode'
|
||||
from(new File(resourcesStaticDir, 'api-landing.html'))
|
||||
into(resourcesStaticDir)
|
||||
rename('api-landing.html', 'index.html')
|
||||
dependsOn cleanFrontendAssets
|
||||
doFirst {
|
||||
println "Copying API landing page to index.html for backend-only mode..."
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure copyFrontendAssets runs after spotless tasks
|
||||
tasks.named('copyFrontendAssets').configure {
|
||||
mustRunAfter tasks.matching { it.name.startsWith('spotless') }
|
||||
@@ -289,9 +277,9 @@ if (buildWithFrontend) {
|
||||
println "Frontend build enabled - JAR will include React frontend"
|
||||
processResources.dependsOn copyFrontendAssets
|
||||
} else {
|
||||
println "Frontend build disabled - JAR will be backend-only with API landing page"
|
||||
// When not building the UI, ensure any stale frontend assets are removed and use API landing page
|
||||
processResources.dependsOn copyApiLandingPage
|
||||
println "Frontend build disabled - JAR will be backend-only"
|
||||
// When not building the UI, ensure any stale frontend assets are removed
|
||||
processResources.dependsOn cleanFrontendAssets
|
||||
}
|
||||
|
||||
bootJar.dependsOn ':common:jar'
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="Stirling-PDF API Server">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Stirling-PDF - API Server</title>
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1.5rem 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(45rem, 96vw);
|
||||
background-color: #ffffff;
|
||||
border-radius: 1.25rem;
|
||||
box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
max-width: 26.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header-logos {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.login-logo-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.login-logo-text {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: #111827;
|
||||
margin: 0 0 0.375rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-text {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-list {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
margin-left: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-list a,
|
||||
.section-text a {
|
||||
color: #AF3434;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-list a:hover,
|
||||
.section-text a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.code-inline {
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #AF3434;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
transition: background-color 0.2s;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background-color: #9a2e2e;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 1.5rem 0;
|
||||
border: 0;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: #374151;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
background-color: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #AF3434;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="auth-card">
|
||||
<div class="auth-content">
|
||||
<div class="login-header">
|
||||
<div class="login-header-logos">
|
||||
<img src="favicon.svg" alt="Stirling PDF" class="login-logo-icon">
|
||||
<img src="api-wordmark.svg" alt="Stirling PDF" class="login-logo-text">
|
||||
</div>
|
||||
<h1 class="login-title">API Server</h1>
|
||||
<p class="login-subtitle">Backend-only mode without web UI</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<p class="section-text">
|
||||
This Stirling-PDF instance is running in <strong>API-only mode</strong>. The web interface has not been included in this build.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="swagger-ui/index.html" class="cta-button">Open API Documentation</a>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Looking for the Web UI?</h2>
|
||||
|
||||
<p class="section-text"><strong>If you're using Docker:</strong></p>
|
||||
<ul class="section-list">
|
||||
<li>You may have pulled an <strong>API-only image</strong> or there was a build configuration issue</li>
|
||||
<li>Use the standard Docker image: <code class="code-inline">stirlingtools/stirling-pdf:latest</code></li>
|
||||
</ul>
|
||||
|
||||
<p class="section-text"><strong>If you're using a JAR file:</strong></p>
|
||||
<ul class="section-list">
|
||||
<li>You downloaded <code class="code-inline">Stirling-PDF-server.jar</code> which is the <strong>API-only version without UI</strong></li>
|
||||
<li>Download the full version from <a href="https://github.com/Stirling-Tools/Stirling-PDF/releases" target="_blank">GitHub Releases</a>:</li>
|
||||
<li style="margin-left: 1.5rem;"><code class="code-inline">Stirling-PDF.jar</code> - Standard version with UI</li>
|
||||
<li style="margin-left: 1.5rem;"><code class="code-inline">Stirling-PDF-with-login.jar</code> - Version with authentication features</li>
|
||||
</ul>
|
||||
|
||||
<p class="section-text"><strong>If you built from source:</strong></p>
|
||||
<ul class="section-list">
|
||||
<li>Rebuild with: <code class="code-inline">./gradlew build -PbuildWithFrontend=true</code></li>
|
||||
<li>Or deploy the frontend separately from the <code class="code-inline">/frontend</code> directory</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Need Help?</h2>
|
||||
<p class="section-text">Join our community for support:</p>
|
||||
<ul class="section-list">
|
||||
<li><a href="https://discord.gg/Cn8pWhQRxZ" target="_blank">Discord Community</a> - Get help from the community</li>
|
||||
<li><a href="https://github.com/Stirling-Tools/Stirling-PDF/issues" target="_blank">GitHub Issues</a> - Report bugs or request features</li>
|
||||
<li><a href="https://github.com/Stirling-Tools/Stirling-PDF" target="_blank">GitHub Repository</a> - View documentation and source code</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span>Powered by </span>
|
||||
<a href="https://stirlingpdf.com">Stirling PDF</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.4 KiB |
Generated
+61
-41
@@ -51,6 +51,7 @@
|
||||
"@tauri-apps/plugin-http": "^2.5.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"framer-motion": "^12.23.26",
|
||||
"globals": "^16.4.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -457,7 +458,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -501,7 +501,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -582,7 +581,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz",
|
||||
"integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "1.5.0",
|
||||
"@embedpdf/models": "1.5.0"
|
||||
@@ -682,7 +680,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz",
|
||||
"integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@@ -699,7 +696,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz",
|
||||
"integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@@ -717,7 +713,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz",
|
||||
"integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@@ -771,7 +766,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz",
|
||||
"integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@@ -806,7 +800,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz",
|
||||
"integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@@ -843,7 +836,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz",
|
||||
"integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@@ -919,7 +911,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz",
|
||||
"integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
@@ -1075,7 +1066,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -1119,7 +1109,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
||||
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -2150,7 +2139,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
|
||||
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -2201,7 +2189,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
|
||||
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
@@ -2269,7 +2256,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
|
||||
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/core-downloads-tracker": "^7.3.5",
|
||||
@@ -3202,7 +3188,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
|
||||
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
@@ -3321,6 +3306,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
|
||||
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.9.0"
|
||||
}
|
||||
@@ -4097,7 +4083,6 @@
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -4426,7 +4411,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4437,7 +4421,6 @@
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -4507,7 +4490,6 @@
|
||||
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.3",
|
||||
"@typescript-eslint/types": "8.46.3",
|
||||
@@ -5221,6 +5203,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
|
||||
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
@@ -5230,6 +5213,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
|
||||
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
@@ -5240,6 +5224,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
|
||||
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/runtime-core": "3.5.24",
|
||||
@@ -5252,6 +5237,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
|
||||
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
@@ -5278,7 +5264,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5686,6 +5671,7 @@
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -5962,7 +5948,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.19",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
@@ -7010,8 +6995,7 @@
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
|
||||
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
@@ -7406,7 +7390,6 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -7577,7 +7560,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -7744,7 +7726,8 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
@@ -7809,6 +7792,7 @@
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
|
||||
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
}
|
||||
@@ -8272,6 +8256,37 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.23.26",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
|
||||
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.23.23",
|
||||
"motion-utils": "^12.23.6",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion/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=="
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "11.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz",
|
||||
@@ -8899,7 +8914,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -9376,6 +9390,7 @@
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
@@ -9696,7 +9711,6 @@
|
||||
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.19",
|
||||
"@asamuzakjp/dom-selector": "^6.7.3",
|
||||
@@ -10283,7 +10297,8 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
@@ -10678,6 +10693,19 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.23.23",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.23.6",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -11442,7 +11470,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -11722,7 +11749,6 @@
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
||||
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
@@ -12105,7 +12131,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -12115,7 +12140,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -13627,6 +13651,7 @@
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -13835,7 +13860,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14137,7 +14161,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14219,7 +14242,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.3.0"
|
||||
},
|
||||
@@ -14424,7 +14446,6 @@
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -14595,7 +14616,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14609,7 +14629,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -15221,7 +15240,8 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"@tauri-apps/plugin-http": "^2.5.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"framer-motion": "^12.23.26",
|
||||
"globals": "^16.4.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useMorphElement } from '@app/hooks/useMorphElement';
|
||||
|
||||
interface FileCardMorphProps {
|
||||
fileId: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for file cards that registers them for morphing animations
|
||||
*/
|
||||
export function FileCardMorph({ fileId, children, className }: FileCardMorphProps) {
|
||||
const morphRef = useMorphElement<HTMLDivElement>(
|
||||
`file-${fileId}`,
|
||||
{ fileId, type: 'file' },
|
||||
{ onlyIn: ['fileEditor'] }
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={morphRef}
|
||||
className={className}
|
||||
data-morph-id={`file-${fileId}`}
|
||||
data-morph-file-id={fileId}
|
||||
data-morph-type="file"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -399,3 +399,20 @@
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Grid Animation Styles
|
||||
========================= */
|
||||
|
||||
.gridContainer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
row-gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fileCardItem {
|
||||
/* Target for stagger animations */
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@@ -5,16 +5,19 @@ import {
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { useMorphElement } from '@app/hooks/useMorphElement';
|
||||
import { zipFileService } from '@app/services/zipFileService';
|
||||
import { detectFileExtension } from '@app/utils/fileUtils';
|
||||
import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail';
|
||||
import AddFileCard from '@app/components/fileEditor/AddFileCard';
|
||||
import FilePickerModal from '@app/components/shared/FilePickerModal';
|
||||
import { FileCardMorph } from '@app/components/fileEditor/FileCardMorph';
|
||||
import { FileId, StirlingFile } from '@app/types/fileContext';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { downloadBlob } from '@app/utils/downloadUtils';
|
||||
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import styles from '@app/components/fileEditor/FileEditor.module.css';
|
||||
|
||||
|
||||
interface FileEditorProps {
|
||||
@@ -379,42 +382,37 @@ const FileEditor = ({
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
rowGap: '1.5rem',
|
||||
padding: '1rem',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
<div className={styles.gridContainer}>
|
||||
{/* Add File Card - only show when files exist */}
|
||||
{activeStirlingFileStubs.length > 0 && (
|
||||
<AddFileCard
|
||||
key="add-file-card"
|
||||
onFileSelect={handleFileUpload}
|
||||
/>
|
||||
<div className={styles.fileCardItem}>
|
||||
<AddFileCard
|
||||
key="add-file-card"
|
||||
onFileSelect={handleFileUpload}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
return (
|
||||
<FileEditorThumbnail
|
||||
key={record.id}
|
||||
file={record}
|
||||
index={index}
|
||||
totalFiles={activeStirlingFileStubs.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
onCloseFile={handleCloseFile}
|
||||
onViewFile={handleViewFile}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
onUnzipFile={handleUnzipFile}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
/>
|
||||
<FileCardMorph key={record.id} fileId={record.id} className={styles.fileCardItem}>
|
||||
<FileEditorThumbnail
|
||||
file={record}
|
||||
index={index}
|
||||
totalFiles={activeStirlingFileStubs.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
onCloseFile={handleCloseFile}
|
||||
onViewFile={handleViewFile}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
onUnzipFile={handleUnzipFile}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
/>
|
||||
</FileCardMorph>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Box } from '@mantine/core';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { isBaseWorkbench } from '@app/types/workbench';
|
||||
import { isBaseWorkbench, type BaseWorkbenchType } from '@app/types/workbench';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useWorkbenchMorphTransition } from '@app/hooks/useWorkbenchMorphTransition';
|
||||
import { useWorkbenchTransition } from '@app/contexts/WorkbenchTransitionContext';
|
||||
import styles from '@app/components/layout/Workbench.module.css';
|
||||
|
||||
import TopControls from '@app/components/shared/TopControls';
|
||||
@@ -28,6 +32,14 @@ export default function Workbench() {
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
|
||||
// Track the displayed workbench (for transitions)
|
||||
const [displayedView, setDisplayedView] = useState(currentView);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
// Workbench morphing transition hook
|
||||
const { transition } = useWorkbenchMorphTransition();
|
||||
const { transitionState } = useWorkbenchTransition();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const {
|
||||
previewFile,
|
||||
@@ -52,6 +64,33 @@ export default function Workbench() {
|
||||
// Get active file index from ViewerContext
|
||||
const { activeFileIndex, setActiveFileIndex } = useViewer();
|
||||
|
||||
// Trigger morphing transition when workbench changes
|
||||
useEffect(() => {
|
||||
if (currentView === displayedView || isTransitioning) return;
|
||||
|
||||
const performTransition = async () => {
|
||||
if (isBaseWorkbench(currentView) && isBaseWorkbench(displayedView)) {
|
||||
// Both are base workbenches - perform morphing transition
|
||||
setIsTransitioning(true);
|
||||
|
||||
await transition(displayedView as BaseWorkbenchType, currentView as BaseWorkbenchType, () => {
|
||||
// Swap content after source snapshots are captured
|
||||
setDisplayedView(currentView);
|
||||
});
|
||||
|
||||
// Transition completes automatically after animation
|
||||
setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
}, 550);
|
||||
} else {
|
||||
// One is custom view - just swap without morph
|
||||
setDisplayedView(currentView);
|
||||
}
|
||||
};
|
||||
|
||||
performTransition();
|
||||
}, [currentView, displayedView, isTransitioning, transition]);
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
setPreviewFile(null);
|
||||
const previousMode = sessionStorage.getItem('previousMode');
|
||||
@@ -71,12 +110,15 @@ export default function Workbench() {
|
||||
};
|
||||
|
||||
const renderMainContent = () => {
|
||||
// Use displayedView for rendering (sync with transition)
|
||||
const viewToRender = displayedView;
|
||||
|
||||
// Check for custom workbench views first
|
||||
if (!isBaseWorkbench(currentView)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||
if (!isBaseWorkbench(viewToRender)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === viewToRender && view.data != null);
|
||||
if (customView) {
|
||||
// PDF text editor handles its own empty state (shows dropzone when no document)
|
||||
const handlesOwnEmptyState = currentView === 'custom:pdfTextEditor';
|
||||
const handlesOwnEmptyState = viewToRender === 'custom:pdfTextEditor';
|
||||
if (handlesOwnEmptyState || activeFiles.length > 0) {
|
||||
const CustomComponent = customView.component;
|
||||
return <CustomComponent data={customView.data} />;
|
||||
@@ -92,7 +134,7 @@ export default function Workbench() {
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentView) {
|
||||
switch (viewToRender) {
|
||||
case "fileEditor":
|
||||
|
||||
return (
|
||||
@@ -189,15 +231,20 @@ export default function Workbench() {
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
</Box>
|
||||
{/* Main content area with workbench transitions */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={displayedView}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: transitionState.isTransitioning ? 0 : 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
|
||||
style={{ display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{renderMainContent()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<Footer
|
||||
analyticsEnabled={config?.enableAnalytics === true}
|
||||
|
||||
@@ -469,6 +469,7 @@ const PageEditor = ({
|
||||
index={index}
|
||||
totalPages={displayDocument.pages.length}
|
||||
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
|
||||
fileId={page.originalFileId || displayDocument.id}
|
||||
fileColorIndex={fileColorIndex}
|
||||
selectedPageIds={selectedPageIds}
|
||||
selectionMode={selectionMode}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Text, Checkbox } from '@mantine/core';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import { useMorphElement } from '@app/hooks/useMorphElement';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||
@@ -23,6 +24,7 @@ interface PageThumbnailProps {
|
||||
index: number;
|
||||
totalPages: number;
|
||||
originalFile?: File;
|
||||
fileId: string;
|
||||
fileColorIndex: number;
|
||||
selectedPageIds: string[];
|
||||
selectionMode: boolean;
|
||||
@@ -56,6 +58,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
index: _index,
|
||||
totalPages,
|
||||
originalFile,
|
||||
fileId,
|
||||
fileColorIndex,
|
||||
selectedPageIds,
|
||||
selectionMode,
|
||||
@@ -82,6 +85,24 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
}: PageThumbnailProps) => {
|
||||
const pageIndex = page.pageNumber - 1;
|
||||
|
||||
// Register for morphing - ALL pages register for split/merge animations
|
||||
const morphId = page.originalFileId
|
||||
? `page-${page.originalFileId}-${page.pageNumber}`
|
||||
: `page-${page.id}`;
|
||||
|
||||
const morphRef = useMorphElement<HTMLDivElement>(
|
||||
morphId,
|
||||
{
|
||||
pageId: page.id,
|
||||
fileId: fileId || page.originalFileId, // Used for grouping in morph pairs
|
||||
pageNumber: page.pageNumber,
|
||||
type: 'page',
|
||||
},
|
||||
{
|
||||
onlyIn: ['pageEditor'],
|
||||
}
|
||||
);
|
||||
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -162,11 +183,16 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
pageRefs.current.delete(page.id);
|
||||
}
|
||||
|
||||
// Assign to morph ref for animation system
|
||||
if (morphRef) {
|
||||
(morphRef as React.MutableRefObject<HTMLDivElement | null>).current = element;
|
||||
}
|
||||
|
||||
// Call dnd-kit's ref if provided
|
||||
if (dragHandleProps?.ref) {
|
||||
dragHandleProps.ref(element);
|
||||
}
|
||||
}, [page.id, pageRefs, dragHandleProps]);
|
||||
}, [page.id, pageRefs, dragHandleProps, morphRef]);
|
||||
|
||||
|
||||
// DOM command handlers
|
||||
@@ -352,6 +378,10 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
{...restDragProps}
|
||||
data-page-id={page.id}
|
||||
data-page-number={page.pageNumber}
|
||||
data-morph-id={morphId}
|
||||
data-morph-file-id={fileId || page.originalFileId}
|
||||
data-morph-page-number={page.pageNumber}
|
||||
data-morph-type="page"
|
||||
className={`
|
||||
${styles.pageContainer}
|
||||
!rounded-lg
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { useWorkbenchTransition } from '@app/contexts/WorkbenchTransitionContext';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* MorphOverlay renders animated clones of elements during workbench transitions
|
||||
*
|
||||
* Uses Framer Motion to smoothly morph elements from source to target positions,
|
||||
* creating the illusion that pages/files are transforming between view modes.
|
||||
*/
|
||||
export function MorphOverlay() {
|
||||
const { transitionState, getMorphPairs, completeTransition } = useWorkbenchTransition();
|
||||
|
||||
const morphPairs = getMorphPairs();
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
if (transitionState.isTransitioning) {
|
||||
console.log('[MorphOverlay] Transition state:', {
|
||||
from: transitionState.fromWorkbench,
|
||||
to: transitionState.toWorkbench,
|
||||
sources: transitionState.sourceSnapshots.length,
|
||||
targets: transitionState.targetSnapshots.length,
|
||||
pairs: morphPairs.length,
|
||||
});
|
||||
|
||||
if (morphPairs.length > 0) {
|
||||
const pairsBySource = new Map<string, number>();
|
||||
morphPairs.forEach(({ source }) => {
|
||||
pairsBySource.set(source.id, (pairsBySource.get(source.id) || 0) + 1);
|
||||
});
|
||||
const hasSplit = Array.from(pairsBySource.values()).some(count => count > 1);
|
||||
const type = hasSplit ? 'SPLIT' : 'MERGE';
|
||||
|
||||
console.log(`[MorphOverlay] ${type} animation with ${morphPairs.length} pairs`);
|
||||
|
||||
// Log first pair details
|
||||
const first = morphPairs[0];
|
||||
console.log('[MorphOverlay] First pair:', {
|
||||
source: first.source.id,
|
||||
target: first.target.id,
|
||||
sourcePos: { x: Math.round(first.source.rect.left), y: Math.round(first.source.rect.top) },
|
||||
targetPos: { x: Math.round(first.target.rect.left), y: Math.round(first.target.rect.top) },
|
||||
});
|
||||
} else {
|
||||
console.log('[MorphOverlay] No morph pairs created!');
|
||||
}
|
||||
}
|
||||
}, [transitionState, morphPairs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!transitionState.isTransitioning) return;
|
||||
|
||||
// Auto-complete transition after animation duration.
|
||||
// Give extra time when pairs are not ready so targets can register.
|
||||
const timeout = setTimeout(() => {
|
||||
completeTransition();
|
||||
}, morphPairs.length > 0 ? 1400 : 3500); // allow time for targets to register
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [transitionState.isTransitioning, morphPairs.length, completeTransition]);
|
||||
|
||||
const isSplit = morphPairs.some(({ source }) => {
|
||||
const sourceFile = source.metadata?.fileId;
|
||||
const counts = morphPairs.filter(p => p.source.metadata?.fileId === sourceFile);
|
||||
return counts.length > 1;
|
||||
});
|
||||
|
||||
// For split animations, we want all outgoing clones to originate near the first page spot
|
||||
const firstTargetRectByFile = useMemo(() => {
|
||||
const map = new Map<string, DOMRect>();
|
||||
morphPairs.forEach(({ source, target }) => {
|
||||
const fileId = source.metadata?.fileId || target.metadata?.fileId;
|
||||
if (fileId && !map.has(fileId)) {
|
||||
map.set(fileId, target.rect);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [morphPairs]);
|
||||
|
||||
const preferSplitTargetOrigin = transitionState.toWorkbench === 'pageEditor';
|
||||
|
||||
if (!transitionState.isTransitioning || morphPairs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10000,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{morphPairs.map(({ source, target }, index) => {
|
||||
const sourceRect = source.rect;
|
||||
const targetRect = target.rect;
|
||||
const fileId = source.metadata?.fileId || target.metadata?.fileId;
|
||||
|
||||
// If splitting (file -> pages), start all clones from the first target rect for that file
|
||||
const initialRect =
|
||||
source.metadata?.type === 'file' &&
|
||||
isSplit &&
|
||||
preferSplitTargetOrigin &&
|
||||
fileId &&
|
||||
firstTargetRectByFile.get(fileId)
|
||||
? firstTargetRectByFile.get(fileId)!
|
||||
: sourceRect;
|
||||
|
||||
// Use combination of source and target IDs for unique keys (supports split/merge)
|
||||
const key = `${source.id}-to-${target.id}`;
|
||||
|
||||
// Use page thumbnail when flying pages -> file, and target when file -> pages
|
||||
const thumbnail =
|
||||
source.metadata?.type === 'page'
|
||||
? source.thumbnail || target.thumbnail
|
||||
: target.thumbnail || source.thumbnail;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={key}
|
||||
initial={{
|
||||
position: 'absolute',
|
||||
left: initialRect.left,
|
||||
top: initialRect.top,
|
||||
width: initialRect.width,
|
||||
height: initialRect.height,
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
opacity: 0.9,
|
||||
scale: 0.9,
|
||||
}}
|
||||
animate={{
|
||||
left: targetRect.left,
|
||||
top: targetRect.top,
|
||||
width: targetRect.width,
|
||||
height: targetRect.height,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 220,
|
||||
damping: 26,
|
||||
mass: 1.05,
|
||||
bounce: 0.3,
|
||||
restDelta: 0.1,
|
||||
restSpeed: 0.1,
|
||||
delay: isSplit ? index * 0.05 : 0,
|
||||
}}
|
||||
style={{ willChange: 'transform, width, height, opacity' }}
|
||||
>
|
||||
{/* Render thumbnail if available */}
|
||||
{thumbnail && (
|
||||
<motion.img
|
||||
src={thumbnail}
|
||||
alt=""
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fallback: render a placeholder */}
|
||||
{!source.thumbnail && (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--file-card-bg, #f5f5f5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '24px', opacity: 0.5 }}>📄</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import { isStirlingFile } from '@app/types/fileContext';
|
||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
|
||||
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||
import { ViewerMorphAnchor } from '@app/components/viewer/ViewerMorphAnchor';
|
||||
import { ViewerPageMorphs } from '@app/components/viewer/ViewerPageMorphs';
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
sidebarsVisible: boolean;
|
||||
@@ -134,6 +136,13 @@ const EmbedPdfViewerContent = ({
|
||||
return null;
|
||||
}, [previewFile, activeFiles, activeFileIndex]);
|
||||
|
||||
const currentFileId = React.useMemo(() => {
|
||||
if (currentFile && isStirlingFile(currentFile)) {
|
||||
return currentFile.fileId;
|
||||
}
|
||||
return undefined;
|
||||
}, [currentFile]);
|
||||
|
||||
// Get file with URL for rendering
|
||||
const fileWithUrl = useFileWithUrl(currentFile);
|
||||
|
||||
@@ -150,6 +159,13 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
}, [previewFile, fileWithUrl]);
|
||||
|
||||
const fileForThumbnails = React.useMemo(() => {
|
||||
if (effectiveFile?.file instanceof File) {
|
||||
return effectiveFile.file;
|
||||
}
|
||||
return null;
|
||||
}, [effectiveFile]);
|
||||
|
||||
const bookmarkCacheKey = React.useMemo(() => {
|
||||
if (currentFile && isStirlingFile(currentFile)) {
|
||||
return currentFile.fileId;
|
||||
@@ -335,6 +351,8 @@ const EmbedPdfViewerContent = ({
|
||||
overflow: 'hidden',
|
||||
contain: 'layout style paint'
|
||||
}}>
|
||||
<ViewerMorphAnchor fileId={currentFileId} />
|
||||
<ViewerPageMorphs fileId={currentFileId} file={fileForThumbnails} />
|
||||
{/* Close Button - Only show in preview mode */}
|
||||
{onClose && previewFile && (
|
||||
<ActionIcon
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMorphElement } from '@app/hooks/useMorphElement';
|
||||
|
||||
interface ViewerMorphAnchorProps {
|
||||
fileId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invisible anchor that registers the current viewer file for morph animations.
|
||||
* Gives the transition system a source/target element when coming from the viewer.
|
||||
*/
|
||||
export function ViewerMorphAnchor({ fileId }: ViewerMorphAnchorProps) {
|
||||
const morphId = fileId ? `file-${fileId}` : undefined;
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
if (!fileId) return undefined;
|
||||
return { fileId, type: 'file' };
|
||||
}, [fileId]);
|
||||
|
||||
const ref = useMorphElement<HTMLDivElement>(
|
||||
morphId ?? 'viewer-file-anchor',
|
||||
metadata,
|
||||
{ onlyIn: ['viewer'], disabled: !fileId }
|
||||
);
|
||||
|
||||
if (!fileId) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-morph-id={morphId}
|
||||
data-morph-file-id={fileId}
|
||||
data-morph-type="file"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
top: '45%',
|
||||
width: '180px',
|
||||
height: '220px',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
opacity: 0,
|
||||
borderRadius: '6px',
|
||||
background: 'transparent',
|
||||
zIndex: 5,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMorphElement } from '@app/hooks/useMorphElement';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useThumbnailGeneration } from '@app/hooks/useThumbnailGeneration';
|
||||
|
||||
interface ViewerPageMorphsProps {
|
||||
fileId?: string;
|
||||
file?: File | null;
|
||||
}
|
||||
|
||||
interface PageMorphProps {
|
||||
fileId: string;
|
||||
pageNumber: number;
|
||||
indexOffset: number;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
function ViewerPageMorph({ fileId, pageNumber, indexOffset, thumbnail }: PageMorphProps) {
|
||||
const morphId = `page-${fileId}-${pageNumber}`;
|
||||
const ref = useMorphElement<HTMLDivElement>(
|
||||
morphId,
|
||||
{ fileId, type: 'page', pageNumber, thumbnail },
|
||||
{ onlyIn: ['viewer'] }
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={morphId}
|
||||
ref={ref}
|
||||
data-morph-id={morphId}
|
||||
data-morph-file-id={fileId}
|
||||
data-morph-page-number={pageNumber}
|
||||
data-morph-type="page"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
top: `${40 + indexOffset * 26}%`,
|
||||
width: '140px',
|
||||
height: '180px',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
opacity: 0,
|
||||
borderRadius: '4px',
|
||||
background: 'transparent',
|
||||
zIndex: 5,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the currently visible pages in the viewer for morph animations.
|
||||
* Uses limited slots to avoid excessive clones on long documents.
|
||||
*/
|
||||
export function ViewerPageMorphs({ fileId, file }: ViewerPageMorphsProps) {
|
||||
const { getScrollState, getThumbnailAPI } = useViewer();
|
||||
const scrollState = getScrollState();
|
||||
const thumbnailAPI = getThumbnailAPI();
|
||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||
const [thumbnailsByPage, setThumbnailsByPage] = useState<Record<number, string>>({});
|
||||
const pendingRequestsRef = useRef(new Map<number, Promise<string | null>>());
|
||||
const objectUrlsRef = useRef(new Map<number, string>());
|
||||
|
||||
// Derive a small window of visible pages around the current page
|
||||
const visiblePages = useMemo(() => {
|
||||
const { currentPage, totalPages } = scrollState;
|
||||
if (!currentPage || totalPages === 0) return [];
|
||||
const pages: number[] = [];
|
||||
for (let i = currentPage - 1; i <= currentPage + 2 && i <= totalPages; i++) {
|
||||
if (i >= 1) pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
}, [scrollState.currentPage, scrollState.totalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
objectUrlsRef.current.clear();
|
||||
pendingRequestsRef.current.clear();
|
||||
setThumbnailsByPage({});
|
||||
};
|
||||
}, [fileId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileId || visiblePages.length === 0) return;
|
||||
|
||||
visiblePages.forEach((pageNumber) => {
|
||||
if (thumbnailsByPage[pageNumber] || pendingRequestsRef.current.has(pageNumber)) return;
|
||||
|
||||
const cacheKey = `${fileId}-page-${pageNumber}`;
|
||||
const cached = getThumbnailFromCache(cacheKey);
|
||||
if (cached) {
|
||||
setThumbnailsByPage((prev) => ({ ...prev, [pageNumber]: cached }));
|
||||
return;
|
||||
}
|
||||
|
||||
let requestPromise: Promise<string | null> | null = null;
|
||||
|
||||
if (thumbnailAPI?.renderThumb) {
|
||||
requestPromise = thumbnailAPI
|
||||
.renderThumb(pageNumber - 1, 0.18)
|
||||
.toPromise()
|
||||
.then((blob) => {
|
||||
if (!blob) return null;
|
||||
const url = URL.createObjectURL(blob);
|
||||
objectUrlsRef.current.set(pageNumber, url);
|
||||
return url;
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
if (!requestPromise && file instanceof File) {
|
||||
requestPromise = requestThumbnail(cacheKey, file, pageNumber).catch(() => null);
|
||||
}
|
||||
|
||||
if (!requestPromise) return;
|
||||
|
||||
pendingRequestsRef.current.set(pageNumber, requestPromise);
|
||||
|
||||
requestPromise.then((thumbnail) => {
|
||||
pendingRequestsRef.current.delete(pageNumber);
|
||||
if (!thumbnail) return;
|
||||
setThumbnailsByPage((prev) => ({ ...prev, [pageNumber]: thumbnail }));
|
||||
});
|
||||
});
|
||||
}, [
|
||||
file,
|
||||
fileId,
|
||||
getThumbnailFromCache,
|
||||
requestThumbnail,
|
||||
thumbnailAPI,
|
||||
thumbnailsByPage,
|
||||
visiblePages,
|
||||
]);
|
||||
|
||||
if (!fileId) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{visiblePages.map((pageNumber, idx) => {
|
||||
const thumbSrc = thumbnailsByPage[pageNumber];
|
||||
|
||||
return (
|
||||
<ViewerPageMorph
|
||||
key={`morph-${fileId}-${pageNumber}`}
|
||||
fileId={fileId}
|
||||
pageNumber={pageNumber}
|
||||
indexOffset={idx}
|
||||
thumbnail={thumbSrc}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
|
||||
import type { BaseWorkbenchType } from '../types/workbench';
|
||||
|
||||
/**
|
||||
* Element snapshot for morphing animations
|
||||
*/
|
||||
export interface ElementSnapshot {
|
||||
id: string;
|
||||
rect: DOMRect;
|
||||
element: HTMLElement;
|
||||
thumbnail?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition state tracking
|
||||
*/
|
||||
interface TransitionState {
|
||||
isTransitioning: boolean;
|
||||
fromWorkbench: BaseWorkbenchType | null;
|
||||
toWorkbench: BaseWorkbenchType | null;
|
||||
sourceSnapshots: ElementSnapshot[];
|
||||
targetSnapshots: ElementSnapshot[];
|
||||
}
|
||||
|
||||
interface WorkbenchTransitionContextValue {
|
||||
// Transition state
|
||||
transitionState: TransitionState;
|
||||
|
||||
// Register elements for morphing
|
||||
registerSourceElement: (id: string, element: HTMLElement, metadata?: Record<string, any>) => void;
|
||||
registerTargetElement: (id: string, element: HTMLElement, metadata?: Record<string, any>) => void;
|
||||
|
||||
// Initiate transition
|
||||
startTransition: (from: BaseWorkbenchType, to: BaseWorkbenchType, onViewChange?: () => void) => Promise<void>;
|
||||
completeTransition: () => void;
|
||||
|
||||
// Get morph pairs
|
||||
getMorphPairs: () => Array<{ source: ElementSnapshot; target: ElementSnapshot }>;
|
||||
}
|
||||
|
||||
const WorkbenchTransitionContext = createContext<WorkbenchTransitionContextValue | null>(null);
|
||||
|
||||
export function useWorkbenchTransition() {
|
||||
const context = useContext(WorkbenchTransitionContext);
|
||||
if (!context) {
|
||||
throw new Error('useWorkbenchTransition must be used within WorkbenchTransitionProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface WorkbenchTransitionProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const MAX_MORPH_ELEMENTS = 40; // cap clones to what can reasonably be on screen
|
||||
|
||||
export function WorkbenchTransitionProvider({ children }: WorkbenchTransitionProviderProps) {
|
||||
const [transitionState, setTransitionState] = useState<TransitionState>({
|
||||
isTransitioning: false,
|
||||
fromWorkbench: null,
|
||||
toWorkbench: null,
|
||||
sourceSnapshots: [],
|
||||
targetSnapshots: [],
|
||||
});
|
||||
|
||||
// Use refs to store element registrations
|
||||
const sourceElementsRef = useRef<Map<string, { element: HTMLElement; metadata?: Record<string, any> }>>(new Map());
|
||||
const targetElementsRef = useRef<Map<string, { element: HTMLElement; metadata?: Record<string, any> }>>(new Map());
|
||||
|
||||
const registerSourceElement = useCallback((id: string, element: HTMLElement, metadata?: Record<string, any>) => {
|
||||
sourceElementsRef.current.set(id, { element, metadata });
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[WorkbenchTransition] Source registered', { id, fileId: metadata?.fileId, type: metadata?.type });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const registerTargetElement = useCallback((id: string, element: HTMLElement, metadata?: Record<string, any>) => {
|
||||
targetElementsRef.current.set(id, { element, metadata });
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[WorkbenchTransition] Target registered', { id, fileId: metadata?.fileId, type: metadata?.type, page: metadata?.pageNumber });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const captureSnapshot = useCallback((id: string, element: HTMLElement, metadata?: Record<string, any>): ElementSnapshot => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
// Try to capture thumbnail if it's an image or provided metadata
|
||||
let thumbnail: string | undefined;
|
||||
const img = element.querySelector('img');
|
||||
if (img && img.src) {
|
||||
thumbnail = img.src;
|
||||
} else if (metadata?.thumbnail) {
|
||||
thumbnail = metadata.thumbnail;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
rect,
|
||||
element,
|
||||
thumbnail,
|
||||
metadata,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startTransition = useCallback(async (from: BaseWorkbenchType, to: BaseWorkbenchType, onViewChange?: () => void) => {
|
||||
const waitForNextPaint = () => new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
|
||||
const hasTargetsOfType = (type: string) => {
|
||||
for (const [, data] of targetElementsRef.current) {
|
||||
if (data.metadata?.type === type) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Capture source snapshots
|
||||
const sourceSnapshots: ElementSnapshot[] = [];
|
||||
sourceElementsRef.current.forEach((data, id) => {
|
||||
if (sourceSnapshots.length >= MAX_MORPH_ELEMENTS) return;
|
||||
if (data.element.offsetParent !== null) { // Only capture visible elements
|
||||
sourceSnapshots.push(captureSnapshot(id, data.element, data.metadata));
|
||||
}
|
||||
});
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[WorkbenchTransition] Captured sources', sourceSnapshots.length);
|
||||
}
|
||||
|
||||
setTransitionState({
|
||||
isTransitioning: true,
|
||||
fromWorkbench: from,
|
||||
toWorkbench: to,
|
||||
sourceSnapshots,
|
||||
targetSnapshots: [],
|
||||
});
|
||||
|
||||
// Call the view change callback to mount the new view
|
||||
onViewChange?.();
|
||||
|
||||
// Give the new view a tick to mount before we look for targets
|
||||
await waitForNextPaint();
|
||||
|
||||
// Helper to scan DOM as a fallback in case targets haven't registered yet
|
||||
const scanForTargets = () => {
|
||||
let added = 0;
|
||||
const elements = document.querySelectorAll('[data-morph-id]');
|
||||
elements.forEach((el) => {
|
||||
const htmlEl = el as HTMLElement;
|
||||
const morphId = htmlEl.dataset.morphId;
|
||||
const fileId = htmlEl.dataset.morphFileId;
|
||||
const pageNumber = htmlEl.dataset.morphPageNumber;
|
||||
const type = htmlEl.dataset.morphType;
|
||||
|
||||
if (morphId && fileId) {
|
||||
const metadata: Record<string, any> = { fileId, type };
|
||||
if (pageNumber) {
|
||||
metadata.pageNumber = parseInt(pageNumber);
|
||||
}
|
||||
|
||||
// Preserve any explicit registration, otherwise add from scan
|
||||
if (!targetElementsRef.current.has(morphId)) {
|
||||
targetElementsRef.current.set(morphId, { element: htmlEl, metadata });
|
||||
added++;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[WorkbenchTransition] DOM scan found', elements.length, 'morphables; added', added);
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for target registrations coming from useMorphElement (and fall back to DOM scan)
|
||||
const startTime = performance.now();
|
||||
const maxWaitMs = 1200; // shorter wait so animation can start quicker
|
||||
while (performance.now() - startTime < maxWaitMs) {
|
||||
if (targetElementsRef.current.size > 0) break;
|
||||
scanForTargets();
|
||||
await waitForNextPaint();
|
||||
}
|
||||
|
||||
// If we are transitioning to pageEditor, prefer to wait for page targets
|
||||
if (to === 'pageEditor') {
|
||||
const typeWaitStart = performance.now();
|
||||
const typeWaitMs = 400; // brief grace period to pick up page targets
|
||||
while (!hasTargetsOfType('page') && performance.now() - typeWaitStart < typeWaitMs) {
|
||||
scanForTargets();
|
||||
await waitForNextPaint();
|
||||
}
|
||||
}
|
||||
|
||||
// If we are transitioning to fileEditor, prefer to wait for file targets
|
||||
if (to === 'fileEditor') {
|
||||
const typeWaitStart = performance.now();
|
||||
const typeWaitMs = 400; // brief grace period to pick up file targets
|
||||
while (!hasTargetsOfType('file') && performance.now() - typeWaitStart < typeWaitMs) {
|
||||
scanForTargets();
|
||||
await waitForNextPaint();
|
||||
}
|
||||
}
|
||||
|
||||
// If we are transitioning to viewer, also wait briefly for file targets (viewer anchor)
|
||||
if (to === 'viewer') {
|
||||
const typeWaitStart = performance.now();
|
||||
const typeWaitMs = 400;
|
||||
while (!hasTargetsOfType('file') && performance.now() - typeWaitStart < typeWaitMs) {
|
||||
scanForTargets();
|
||||
await waitForNextPaint();
|
||||
}
|
||||
}
|
||||
|
||||
// One final scan after wait to pick up any late mounts
|
||||
scanForTargets();
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[WorkbenchTransition] Targets registered after wait:', targetElementsRef.current.size);
|
||||
}
|
||||
|
||||
// Give targets one more paint so layout stabilizes before measuring
|
||||
await waitForNextPaint();
|
||||
|
||||
// Allow layout to settle so we capture accurate positions
|
||||
await waitForNextPaint();
|
||||
|
||||
// Capture target snapshots
|
||||
console.log('[WorkbenchTransition] Capturing targets. Found:', targetElementsRef.current.size);
|
||||
const targetSnapshots: ElementSnapshot[] = [];
|
||||
targetElementsRef.current.forEach((data, id) => {
|
||||
if (targetSnapshots.length >= MAX_MORPH_ELEMENTS) return;
|
||||
if (data.element.offsetParent !== null) {
|
||||
targetSnapshots.push(captureSnapshot(id, data.element, data.metadata));
|
||||
}
|
||||
});
|
||||
|
||||
// If nothing was visible, fall back to first few elements regardless of viewport
|
||||
if (targetSnapshots.length === 0 && targetElementsRef.current.size > 0) {
|
||||
let count = 0;
|
||||
targetElementsRef.current.forEach((data, id) => {
|
||||
if (count >= Math.min(MAX_MORPH_ELEMENTS, 12)) return;
|
||||
const snap = captureSnapshot(id, data.element, data.metadata);
|
||||
targetSnapshots.push(snap);
|
||||
count++;
|
||||
});
|
||||
}
|
||||
console.log('[WorkbenchTransition] Captured', targetSnapshots.length, 'visible targets');
|
||||
|
||||
setTransitionState(prev => ({
|
||||
...prev,
|
||||
targetSnapshots,
|
||||
}));
|
||||
}, [captureSnapshot]);
|
||||
|
||||
const completeTransition = useCallback(() => {
|
||||
setTransitionState({
|
||||
isTransitioning: false,
|
||||
fromWorkbench: null,
|
||||
toWorkbench: null,
|
||||
sourceSnapshots: [],
|
||||
targetSnapshots: [],
|
||||
});
|
||||
|
||||
// Clear registrations
|
||||
sourceElementsRef.current.clear();
|
||||
targetElementsRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const getMorphPairs = useCallback(() => {
|
||||
const pairs: Array<{ source: ElementSnapshot; target: ElementSnapshot }> = [];
|
||||
|
||||
// Group snapshots by fileId for split/merge animations
|
||||
const sourcesByFile = new Map<string, ElementSnapshot[]>();
|
||||
const targetsByFile = new Map<string, ElementSnapshot[]>();
|
||||
|
||||
// Group sources by fileId
|
||||
transitionState.sourceSnapshots.forEach(source => {
|
||||
const fileId = source.metadata?.fileId;
|
||||
if (fileId) {
|
||||
if (!sourcesByFile.has(fileId)) {
|
||||
sourcesByFile.set(fileId, []);
|
||||
}
|
||||
sourcesByFile.get(fileId)!.push(source);
|
||||
}
|
||||
});
|
||||
|
||||
// Group targets by fileId
|
||||
transitionState.targetSnapshots.forEach(target => {
|
||||
const fileId = target.metadata?.fileId;
|
||||
if (fileId) {
|
||||
if (!targetsByFile.has(fileId)) {
|
||||
targetsByFile.set(fileId, []);
|
||||
}
|
||||
targetsByFile.get(fileId)!.push(target);
|
||||
}
|
||||
});
|
||||
|
||||
// Create morph pairs for each file group
|
||||
const allFileIds = new Set([...sourcesByFile.keys(), ...targetsByFile.keys()]);
|
||||
|
||||
allFileIds.forEach(fileId => {
|
||||
const sources = sourcesByFile.get(fileId) || [];
|
||||
const targets = targetsByFile.get(fileId) || [];
|
||||
|
||||
if (sources.length === 0 || targets.length === 0) return;
|
||||
|
||||
// Sort targets by page number for consistent animation
|
||||
targets.sort((a, b) => (a.metadata?.pageNumber || 0) - (b.metadata?.pageNumber || 0));
|
||||
|
||||
if (sources.length === 1 && targets.length > 1) {
|
||||
// SPLIT: 1 file card → N page thumbnails
|
||||
// All targets morph from the same source (file card)
|
||||
const source = sources[0];
|
||||
targets.forEach(target => {
|
||||
pairs.push({ source, target });
|
||||
});
|
||||
} else if (sources.length > 1 && targets.length === 1) {
|
||||
// MERGE: N page thumbnails → 1 file card
|
||||
// All sources morph to the same target (file card)
|
||||
const target = targets[0];
|
||||
sources.forEach(source => {
|
||||
pairs.push({ source, target });
|
||||
});
|
||||
} else if (sources.length === targets.length) {
|
||||
// 1:1 mapping (e.g., page editor to page editor, or same view)
|
||||
sources.sort((a, b) => (a.metadata?.pageNumber || 0) - (b.metadata?.pageNumber || 0));
|
||||
sources.forEach((source, i) => {
|
||||
pairs.push({ source, target: targets[i] });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return pairs;
|
||||
}, [transitionState.sourceSnapshots, transitionState.targetSnapshots]);
|
||||
|
||||
const value: WorkbenchTransitionContextValue = {
|
||||
transitionState,
|
||||
registerSourceElement,
|
||||
registerTargetElement,
|
||||
startTransition,
|
||||
completeTransition,
|
||||
getMorphPairs,
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkbenchTransitionContext.Provider value={value}>
|
||||
{children}
|
||||
</WorkbenchTransitionContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { useWorkbenchTransition } from '@app/contexts/WorkbenchTransitionContext';
|
||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { isBaseWorkbench } from '@app/types/workbench';
|
||||
|
||||
/**
|
||||
* Hook to register an element for morphing animations during workbench transitions
|
||||
*
|
||||
* @param morphId - Unique identifier for this element (should be consistent across views)
|
||||
* @param metadata - Optional metadata (file ID, page number, etc.)
|
||||
* @param options - Configuration options
|
||||
* @returns Ref to attach to the element that should morph
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In FileEditor
|
||||
* const ref = useMorphElement(`file-${fileId}`, { fileId, type: 'file' });
|
||||
* return <div ref={ref}>File Card</div>;
|
||||
*
|
||||
* // In PageEditor (same fileId)
|
||||
* const ref = useMorphElement(`file-${fileId}`, { fileId, type: 'page' });
|
||||
* return <div ref={ref}>Page Thumbnail</div>;
|
||||
* ```
|
||||
*/
|
||||
export function useMorphElement<T extends HTMLElement = HTMLDivElement>(
|
||||
morphId: string,
|
||||
metadata?: Record<string, any>,
|
||||
options?: {
|
||||
/**
|
||||
* Only register in specific workbench types
|
||||
*/
|
||||
onlyIn?: ('viewer' | 'pageEditor' | 'fileEditor')[];
|
||||
|
||||
/**
|
||||
* Disable morphing for this element
|
||||
*/
|
||||
disabled?: boolean;
|
||||
}
|
||||
) {
|
||||
const elementRef = useRef<T>(null);
|
||||
const { registerSourceElement, registerTargetElement, transitionState } = useWorkbenchTransition();
|
||||
const { workbench } = useNavigationState();
|
||||
|
||||
const currentWorkbench = isBaseWorkbench(workbench) ? workbench : null;
|
||||
|
||||
// Stabilize metadata object to prevent unnecessary re-registrations
|
||||
const stableMetadata = useMemo(() => metadata, [JSON.stringify(metadata)]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!elementRef.current || !currentWorkbench || options?.disabled) return;
|
||||
|
||||
// Check if we should register in this workbench
|
||||
if (options?.onlyIn && !options.onlyIn.includes(currentWorkbench)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = elementRef.current;
|
||||
|
||||
// ALWAYS register as source when mounted in current workbench
|
||||
// This ensures elements are available as sources before transition starts
|
||||
registerSourceElement(morphId, element, stableMetadata);
|
||||
|
||||
// Cleanup is handled by the context when transition completes
|
||||
}, [
|
||||
morphId,
|
||||
currentWorkbench,
|
||||
registerSourceElement,
|
||||
stableMetadata,
|
||||
options?.disabled,
|
||||
options?.onlyIn,
|
||||
]);
|
||||
|
||||
// Separate effect for target registration during transitions
|
||||
useEffect(() => {
|
||||
if (!elementRef.current || !currentWorkbench || options?.disabled) return;
|
||||
|
||||
// Check if we should register in this workbench
|
||||
if (options?.onlyIn && !options.onlyIn.includes(currentWorkbench)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only register as target if we're transitioning TO this workbench
|
||||
if (transitionState.isTransitioning && transitionState.toWorkbench === currentWorkbench) {
|
||||
const element = elementRef.current;
|
||||
registerTargetElement(morphId, element, stableMetadata);
|
||||
}
|
||||
}, [
|
||||
morphId,
|
||||
currentWorkbench,
|
||||
transitionState.isTransitioning,
|
||||
transitionState.toWorkbench,
|
||||
registerTargetElement,
|
||||
stableMetadata,
|
||||
options?.disabled,
|
||||
options?.onlyIn,
|
||||
]);
|
||||
|
||||
return elementRef;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useWorkbenchTransition as useMorphContext } from '@app/contexts/WorkbenchTransitionContext';
|
||||
import type { BaseWorkbenchType } from '../types/workbench';
|
||||
|
||||
/**
|
||||
* Hook for initiating morphing transitions between workbenches
|
||||
*
|
||||
* Coordinates the animation sequence:
|
||||
* 1. Register source elements (current view)
|
||||
* 2. Start transition (capture snapshots)
|
||||
* 3. Swap view content
|
||||
* 4. Register target elements (new view)
|
||||
* 5. Morph animation plays
|
||||
* 6. Complete transition
|
||||
*/
|
||||
export function useWorkbenchMorphTransition() {
|
||||
const { startTransition } = useMorphContext();
|
||||
|
||||
const transition = useCallback(
|
||||
async (from: BaseWorkbenchType, to: BaseWorkbenchType, onViewChange?: () => void) => {
|
||||
// Start transition and pass view change callback
|
||||
// The callback will be called after source snapshots are captured
|
||||
// and before target snapshots are captured
|
||||
await startTransition(from, to, onViewChange);
|
||||
},
|
||||
[startTransition]
|
||||
);
|
||||
|
||||
return { transition };
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import FileManager from "@app/components/FileManager";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import AppConfigModal from "@app/components/shared/AppConfigModal";
|
||||
import { WorkbenchTransitionProvider } from "@app/contexts/WorkbenchTransitionContext";
|
||||
import { MorphOverlay } from "@app/components/shared/MorphOverlay";
|
||||
|
||||
import "@app/pages/HomePage.css";
|
||||
|
||||
@@ -175,7 +177,9 @@ export default function HomePage() {
|
||||
// Note: File selection limits are now handled directly by individual tools
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden">
|
||||
<WorkbenchTransitionProvider>
|
||||
<MorphOverlay />
|
||||
<div className="h-screen overflow-hidden">
|
||||
{isMobile ? (
|
||||
<div className="mobile-layout">
|
||||
<div className="mobile-toggle">
|
||||
@@ -288,6 +292,7 @@ export default function HomePage() {
|
||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WorkbenchTransitionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user