Compare commits

..
14 Commits
Author SHA1 Message Date
Anthony Stirling 43137cde83 tests 2025-12-15 23:34:28 +00:00
Anthony Stirling 9ea2c94912 tests 2025-12-15 23:27:59 +00:00
Anthony Stirling ad33679c2b fix grandfather logs 2025-12-15 23:06:13 +00:00
Anthony Stirling c390f21114 minor stuff 2025-12-15 22:29:19 +00:00
Anthony Stirling b03ecef121 version bump 2025-12-15 22:15:32 +00:00
Anthony Stirling 3d9d2621fa fixes 2025-12-15 22:10:38 +00:00
Anthony Stirling 9c84e1eed4 Merge remote-tracking branch 'origin/main' into pdfCache 2025-12-15 21:58:35 +00:00
Anthony Stirling e424af1a31 several SAML 2025-12-15 21:57:59 +00:00
Anthony Stirling 71d416ce90 Merge branch 'main' into pdfCache 2025-12-15 15:35:40 +00:00
Anthony Stirling 91cdd20b3f remove stuff 2025-12-13 16:48:35 +00:00
Anthony Stirling 235e68fa2b lint :) 2025-12-13 16:28:27 +00:00
Anthony Stirling 3279b28a76 fixes and cleanups 2025-12-13 13:56:24 +00:00
Anthony Stirling d95abcea95 missed commit 2025-12-10 23:26:46 +00:00
Anthony Stirling a73636a597 Cache fix issues 2025-12-10 23:26:35 +00:00
60 changed files with 741 additions and 5636 deletions
@@ -37,6 +37,10 @@ public class AppConfig {
private final ApplicationProperties applicationProperties;
@Getter
@Value("${baseUrl:http://localhost}")
private String baseUrl;
@Getter
@Value("${server.servlet.context-path:/}")
private String contextPath;
@@ -45,17 +49,6 @@ public class AppConfig {
@Value("${server.port:8080}")
private String serverPort;
/**
* Get the backend URL from system configuration. Falls back to http://localhost if not
* configured.
*
* @return The backend base URL for SAML/OAuth/API callbacks
*/
public String getBackendUrl() {
String backendUrl = applicationProperties.getSystem().getBackendUrl();
return (backendUrl != null && !backendUrl.isBlank()) ? backendUrl : "http://localhost";
}
@Value("${v2}")
public boolean v2Enabled;
@@ -138,13 +138,13 @@ public class SPDFApplication {
@PostConstruct
public void init() {
String backendUrl = appConfig.getBackendUrl();
String baseUrl = appConfig.getBaseUrl();
String contextPath = appConfig.getContextPath();
String serverPort = appConfig.getServerPort();
baseUrlStatic = backendUrl;
baseUrlStatic = baseUrl;
contextPathStatic = contextPath;
serverPortStatic = serverPort;
String url = backendUrl + ":" + getStaticPort() + contextPath;
String url = baseUrl + ":" + getStaticPort() + contextPath;
// Log Tauri mode information
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
@@ -33,35 +33,14 @@ public class WebMvcConfig implements WebMvcConfigurer {
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Cache hashed assets (JS/CSS with content hashes) for 1 year
// These files have names like index-ChAS4tCC.js that change when content changes
// Check customFiles/static first, then fall back to classpath
registry.addResourceHandler("/assets/**")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath()
+ "assets/",
"classpath:/static/assets/")
.addResourceLocations("classpath:/static/assets/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
// Note: index.html is handled by ReactRoutingController for dynamic processing
registry.addResourceHandler("/index.html")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath(),
"classpath:/static/")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.noCache().mustRevalidate());
// Handle all other static resources (js, css, images, fonts, etc.)
// Check customFiles/static first for user overrides
registry.addResourceHandler("/**")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath(),
"classpath:/static/")
.setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS));
}
@Override
@@ -66,8 +66,7 @@ public class ConfigController {
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
// Extract key configuration values from AppConfig
// Note: Frontend expects "baseUrl" field name for compatibility
configData.put("baseUrl", appConfig.getBackendUrl());
configData.put("baseUrl", appConfig.getBaseUrl());
configData.put("contextPath", appConfig.getContextPath());
configData.put("serverPort", appConfig.getServerPort());
@@ -3,14 +3,9 @@ package stirling.software.SPDF.controller.web;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
@@ -19,11 +14,6 @@ import org.springframework.web.bind.annotation.GetMapping;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
@Slf4j
@Controller
public class ReactRoutingController {
@@ -32,44 +22,24 @@ public class ReactRoutingController {
private String cachedIndexHtml;
private boolean indexHtmlExists = false;
private boolean useExternalIndexHtml = false;
@PostConstruct
public void init() {
log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath());
// Check for external index.html first (customFiles/static/)
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
log.debug("Checking for custom index.html at: {}", externalIndexPath);
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
log.info("Using custom index.html from: {}", externalIndexPath);
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = true;
return;
} catch (IOException e) {
log.warn("Failed to load custom index.html, falling back to classpath", e);
}
}
// Fall back to classpath index.html
// Only cache if index.html exists (production builds)
ClassPathResource resource = new ClassPathResource("static/index.html");
if (resource.exists()) {
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = false;
} catch (IOException e) {
// Failed to cache, will process on each request
log.warn("Failed to cache index.html", e);
this.indexHtmlExists = false;
}
}
}
private String processIndexHtml() throws IOException {
Resource resource = getIndexHtmlResource();
ClassPathResource resource = new ClassPathResource("static/index.html");
try (InputStream inputStream = resource.getInputStream()) {
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
@@ -92,17 +62,6 @@ public class ReactRoutingController {
}
}
private Resource getIndexHtmlResource() throws IOException {
// Check external location first
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
return new FileSystemResource(externalIndexPath.toFile());
}
// Fall back to classpath
return new ClassPathResource("static/index.html");
}
@GetMapping(
value = {"/", "/index.html"},
produces = MediaType.TEXT_HTML_VALUE)
@@ -198,7 +198,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
private SamlClient getSamlClient(
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
throws SamlException {
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
String serverUrl = appConfig.getBaseUrl() + ":" + appConfig.getServerPort();
String relyingPartyIdentifier =
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
@@ -344,8 +344,7 @@ public class SecurityConfiguration {
log.error("Error configuring SAML 2 login", e);
throw new RuntimeException(e);
}
})
.saml2Metadata(metadata -> {});
});
}
} else {
log.debug("Login is not enabled.");
@@ -102,31 +102,16 @@ public class Saml2Configuration {
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
}
// Get backend URL from configuration (for SAML endpoints)
String backendUrl = applicationProperties.getSystem().getBackendUrl();
if (backendUrl == null || backendUrl.isBlank()) {
backendUrl = "{baseUrl}"; // Fallback to Spring's auto-resolution
log.warn(
"system.backendUrl not configured - SAML metadata will use request-based URLs. Set system.backendUrl for production use.");
} else {
log.info("Using configured backend URL for SAML: {}", backendUrl);
}
String entityId =
backendUrl + "/saml2/service-provider-metadata/" + samlConf.getRegistrationId();
String acsLocation = backendUrl + "/login/saml2/sso/{registrationId}";
String sloResponseLocation = backendUrl + "/login";
RelyingPartyRegistration rp =
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
.signingX509Credentials(c -> c.add(signingCredential))
.entityId(entityId)
.entityId(samlConf.getIdpIssuer())
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation(sloResponseLocation)
.singleLogoutServiceResponseLocation("{baseUrl}/login")
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
.assertionConsumerServiceLocation(acsLocation)
.assertionConsumerServiceLocation(
"{baseUrl}/login/saml2/sso/{registrationId}")
.authnRequestsSigned(true)
.assertingPartyMetadata(
metadata ->
@@ -142,7 +127,7 @@ public class Saml2Configuration {
.singleLogoutServiceLocation(
samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation(
sloResponseLocation)
"{baseUrl}/login")
.wantAuthnRequestsSigned(true))
.build();
@@ -51,8 +51,7 @@ class UserLicenseSettingsServiceTest {
when(applicationProperties.getPremium()).thenReturn(premium);
when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated);
when(automaticallyGenerated.getIsNewServer())
.thenReturn(false); // Default: not a new server
when(automaticallyGenerated.getIsNewServer()).thenReturn(false); // Default: not a new server
when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
when(userService.getTotalUsersCount()).thenReturn(80L);
when(settingsRepository.save(any(UserLicenseSettings.class)))
+1 -1
View File
@@ -59,7 +59,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.1.5'
version = '2.1.4'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
+41 -61
View File
@@ -51,7 +51,6 @@
"@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",
@@ -458,6 +457,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -501,6 +501,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -581,6 +582,7 @@
"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"
@@ -680,6 +682,7 @@
"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"
},
@@ -696,6 +699,7 @@
"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"
},
@@ -713,6 +717,7 @@
"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"
},
@@ -766,6 +771,7 @@
"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"
},
@@ -800,6 +806,7 @@
"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"
},
@@ -836,6 +843,7 @@
"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"
},
@@ -911,6 +919,7 @@
"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"
},
@@ -1066,6 +1075,7 @@
"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",
@@ -1109,6 +1119,7 @@
"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",
@@ -2139,6 +2150,7 @@
"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",
@@ -2189,6 +2201,7 @@
"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"
}
@@ -2256,6 +2269,7 @@
"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",
@@ -3188,6 +3202,7 @@
"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"
}
@@ -3306,7 +3321,6 @@
"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"
}
@@ -4083,6 +4097,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4411,6 +4426,7 @@
"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"
}
@@ -4421,6 +4437,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4490,6 +4507,7 @@
"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",
@@ -5203,7 +5221,6 @@
"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"
}
@@ -5213,7 +5230,6 @@
"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"
@@ -5224,7 +5240,6 @@
"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",
@@ -5237,7 +5252,6 @@
"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"
@@ -5264,6 +5278,7 @@
"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"
},
@@ -5671,7 +5686,6 @@
"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"
}
@@ -5948,6 +5962,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6995,7 +7010,8 @@
"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"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7390,6 +7406,7 @@
"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",
@@ -7560,6 +7577,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7726,8 +7744,7 @@
"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",
"peer": true
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
@@ -7792,7 +7809,6 @@
"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"
}
@@ -8256,37 +8272,6 @@
"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",
@@ -8914,6 +8899,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -9390,7 +9376,6 @@
"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"
}
@@ -9711,6 +9696,7 @@
"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",
@@ -10297,8 +10283,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
@@ -10693,19 +10678,6 @@
"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",
@@ -11470,6 +11442,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11749,6 +11722,7 @@
"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"
@@ -12131,6 +12105,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -12140,6 +12115,7 @@
"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"
},
@@ -13651,7 +13627,6 @@
"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"
}
@@ -13860,6 +13835,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14161,6 +14137,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14242,6 +14219,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -14446,6 +14424,7 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14616,6 +14595,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14629,6 +14609,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -15240,8 +15221,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
-1
View File
@@ -47,7 +47,6 @@
"@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",
+9 -83
View File
@@ -736,11 +736,6 @@ tags = "signature,autograph"
title = "Sign"
desc = "Adds signature to PDF by drawing, text or image"
[home.annotate]
tags = "annotate,highlight,draw"
title = "Annotate"
desc = "Highlight, draw, add notes and shapes in the viewer"
[home.flatten]
tags = "simplify,remove,interactive"
title = "Flatten"
@@ -4018,92 +4013,23 @@ deleteSelected = "Delete Selected Pages"
closePdf = "Close PDF"
exportAll = "Export PDF"
downloadSelected = "Download Selected Files"
annotations = "Annotations"
exportSelected = "Export Selected Pages"
saveChanges = "Save Changes"
downloadAll = "Download All"
saveAll = "Save All"
toggleTheme = "Toggle Theme"
toggleBookmarks = "Toggle Bookmarks"
language = "Language"
toggleAnnotations = "Toggle Annotations Visibility"
search = "Search PDF"
panMode = "Pan Mode"
rotateLeft = "Rotate Left"
rotateRight = "Rotate Right"
toggleSidebar = "Toggle Sidebar"
toggleBookmarks = "Toggle Bookmarks"
exportSelected = "Export Selected Pages"
toggleAnnotations = "Toggle Annotations Visibility"
annotationMode = "Toggle Annotation Mode"
print = "Print PDF"
downloadAll = "Download All"
saveAll = "Save All"
[textAlign]
left = "Left"
center = "Center"
right = "Right"
[annotation]
title = "Annotate"
desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required."
highlight = "Highlight"
pen = "Pen"
text = "Text box"
note = "Note"
rectangle = "Rectangle"
ellipse = "Ellipse"
select = "Select"
exit = "Exit annotation mode"
strokeWidth = "Width"
opacity = "Opacity"
strokeOpacity = "Stroke Opacity"
fillOpacity = "Fill Opacity"
fontSize = "Font size"
chooseColor = "Choose colour"
color = "Colour"
strokeColor = "Stroke Colour"
fillColor = "Fill Colour"
underline = "Underline"
strikeout = "Strikeout"
squiggly = "Squiggly"
inkHighlighter = "Freehand Highlighter"
freehandHighlighter = "Freehand Highlighter"
square = "Square"
circle = "Circle"
polygon = "Polygon"
line = "Line"
stamp = "Add Image"
textMarkup = "Text Markup"
drawing = "Drawing"
shapes = "Shapes"
notesStamps = "Notes & Stamps"
settings = "Settings"
borderOn = "Border: On"
borderOff = "Border: Off"
editInk = "Edit Pen"
editLine = "Edit Line"
editNote = "Edit Note"
editText = "Edit Text Box"
editTextMarkup = "Edit Text Markup"
editSelected = "Edit Annotation"
editSquare = "Edit Square"
editCircle = "Edit Circle"
editPolygon = "Edit Polygon"
unsupportedType = "This annotation type is not fully supported for editing."
textAlignment = "Text Alignment"
noteIcon = "Note Icon"
imagePreview = "Preview"
contents = "Text"
backgroundColor = "Background colour"
clearBackground = "Remove background"
noBackground = "No background"
stampSettings = "Stamp Settings"
savingCopy = "Preparing download..."
saveFailed = "Unable to save copy"
saveReady = "Download ready"
selectAndMove = "Select and Edit"
editSelectDescription = "Click an existing annotation to edit its colour, opacity, text, or size."
editStampHint = "To change the image, delete this stamp and add a new one."
editSwitchToSelect = "Switch to Select & Edit to edit this annotation."
undo = "Undo"
redo = "Redo"
applyChanges = "Apply Changes"
draw = "Draw"
save = "Save"
saveChanges = "Save Changes"
[search]
title = "Search PDF"
File diff suppressed because it is too large Load Diff
-93
View File
@@ -589,26 +589,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.16",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -736,12 +716,6 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -934,15 +908,6 @@ dependencies = [
"syn 2.0.108",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "document-features"
version = "0.2.12"
@@ -1661,12 +1626,6 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.16.0"
@@ -2861,16 +2820,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -3702,16 +3651,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rust_decimal"
version = "1.39.0"
@@ -4317,7 +4256,6 @@ dependencies = [
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-log",
@@ -4677,27 +4615,6 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.17",
"tracing",
"url",
"windows-registry",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.4"
@@ -4818,7 +4735,6 @@ dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.17",
"tracing",
"windows-sys 0.60.2",
@@ -5038,15 +4954,6 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
+1 -2
View File
@@ -29,10 +29,9 @@ tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.1.0"
tauri-plugin-fs = "2.4.4"
tauri-plugin-http = "2.4.4"
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
tauri-plugin-single-instance = "2.0.1"
tauri-plugin-store = "2.1.0"
tauri-plugin-opener = "2.0.0"
tauri-plugin-deep-link = "2.4.5"
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
tokio = { version = "1.0", features = ["time", "sync"] }
reqwest = { version = "0.11", features = ["json"] }
+1 -3
View File
@@ -19,8 +19,6 @@
{
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
},
"opener:default",
"shell:allow-open"
}
]
}
+1 -36
View File
@@ -1,4 +1,4 @@
use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent};
use tauri::{Manager, RunEvent, WindowEvent, Emitter};
mod utils;
mod commands;
@@ -28,17 +28,6 @@ use commands::{
};
use state::connection_state::AppConnectionState;
use utils::{add_log, get_tauri_logs};
use tauri_plugin_deep_link::DeepLinkExt;
fn dispatch_deep_link(app: &AppHandle, url: &str) {
add_log(format!("🔗 Dispatching deep link: {}", url));
let _ = app.emit("deep-link", url.to_string());
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
let _ = window.unminimize();
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -53,7 +42,6 @@ pub fn run() {
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_deep_link::init())
.manage(AppConnectionState::default())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// This callback runs when a second instance tries to start
@@ -90,29 +78,6 @@ pub fn run() {
}
}
{
let app_handle = app.handle();
// On macOS the plugin registers schemes via bundle metadata, so runtime registration is required only on Windows/Linux
#[cfg(any(target_os = "linux", target_os = "windows"))]
if let Err(err) = app_handle.deep_link().register_all() {
add_log(format!("⚠️ Failed to register deep link handler: {}", err));
}
if let Ok(Some(urls)) = app_handle.deep_link().get_current() {
let initial_handle = app_handle.clone();
for url in urls {
dispatch_deep_link(&initial_handle, url.as_str());
}
}
let event_app_handle = app_handle.clone();
app_handle.deep_link().on_open_url(move |event| {
for url in event.urls() {
dispatch_deep_link(&event_app_handle, url.as_str());
}
});
}
// Start backend immediately, non-blocking
let app_handle = app.handle().clone();
-7
View File
@@ -77,13 +77,6 @@
},
"fs": {
"requireLiteralLeadingDot": false
},
"deep-link": {
"desktop": {
"schemes": [
"stirlingpdf"
]
}
}
}
}
+7 -10
View File
@@ -12,7 +12,6 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from
import { RightRailProvider } from "@app/contexts/RightRailContext";
import { ViewerProvider } from "@app/contexts/ViewerContext";
import { SignatureProvider } from "@app/contexts/SignatureContext";
import { AnnotationProvider } from "@app/contexts/AnnotationContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
@@ -96,15 +95,13 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<AnnotationProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</AnnotationProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
@@ -1,5 +1,5 @@
import React from 'react';
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from '@mantine/core';
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface ColorPickerProps {
@@ -8,10 +8,6 @@ interface ColorPickerProps {
selectedColor: string;
onColorChange: (color: string) => void;
title?: string;
opacity?: number;
onOpacityChange?: (opacity: number) => void;
showOpacity?: boolean;
opacityLabel?: string;
}
export const ColorPicker: React.FC<ColorPickerProps> = ({
@@ -19,15 +15,10 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
onClose,
selectedColor,
onColorChange,
title,
opacity,
onOpacityChange,
showOpacity = false,
opacityLabel,
title
}) => {
const { t } = useTranslation();
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
const resolvedOpacityLabel = opacityLabel ?? t('annotation.opacity', 'Opacity');
return (
<Modal
@@ -47,23 +38,6 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
size="lg"
fullWidth
/>
{showOpacity && onOpacityChange && opacity !== undefined && (
<Stack gap="xs">
<Text size="sm" fw={500}>{resolvedOpacityLabel}</Text>
<Slider
min={10}
max={100}
value={opacity}
onChange={onOpacityChange}
marks={[
{ value: 25, label: '25%' },
{ value: 50, label: '50%' },
{ value: 75, label: '75%' },
{ value: 100, label: '100%' },
]}
/>
</Stack>
)}
<Group justify="flex-end">
<Button onClick={onClose}>
{t('common.done', 'Done')}
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
interface TextInputWithFontProps {
@@ -12,8 +11,6 @@ interface TextInputWithFontProps {
onFontFamilyChange: (family: string) => void;
textColor?: string;
onTextColorChange?: (color: string) => void;
textAlign?: 'left' | 'center' | 'right';
onTextAlignChange?: (align: 'left' | 'center' | 'right') => void;
disabled?: boolean;
label: string;
placeholder: string;
@@ -33,8 +30,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
onFontFamilyChange,
textColor = '#000000',
onTextColorChange,
textAlign = 'left',
onTextAlignChange,
disabled = false,
label,
placeholder,
@@ -44,7 +39,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
colorLabel,
onAnyChange
}) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
@@ -218,23 +212,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
}}
/>
)}
{/* Text Alignment */}
{onTextAlignChange && (
<SegmentedControl
value={textAlign}
onChange={(value: string) => {
onTextAlignChange(value as 'left' | 'center' | 'right');
onAnyChange?.();
}}
disabled={disabled}
data={[
{ label: t('textAlign.left', 'Left'), value: 'left' },
{ label: t('textAlign.center', 'Center'), value: 'center' },
{ label: t('textAlign.right', 'Right'), value: 'right' },
]}
/>
)}
</Stack>
);
};
@@ -1,30 +0,0 @@
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,20 +399,3 @@
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,19 +5,16 @@ 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 {
@@ -382,37 +379,42 @@ const FileEditor = ({
</Stack>
</Center>
) : (
<div className={styles.gridContainer}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
rowGap: '1.5rem',
padding: '1rem',
pointerEvents: 'auto'
}}
>
{/* Add File Card - only show when files exist */}
{activeStirlingFileStubs.length > 0 && (
<div className={styles.fileCardItem}>
<AddFileCard
key="add-file-card"
onFileSelect={handleFileUpload}
/>
</div>
<AddFileCard
key="add-file-card"
onFileSelect={handleFileUpload}
/>
)}
{activeStirlingFileStubs.map((record, index) => {
return (
<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>
<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)}
/>
);
})}
</div>
@@ -1,16 +1,12 @@
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, type BaseWorkbenchType } from '@app/types/workbench';
import { isBaseWorkbench } 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';
@@ -32,14 +28,6 @@ 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,
@@ -64,33 +52,6 @@ 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');
@@ -110,15 +71,12 @@ export default function Workbench() {
};
const renderMainContent = () => {
// Use displayedView for rendering (sync with transition)
const viewToRender = displayedView;
// Check for custom workbench views first
if (!isBaseWorkbench(viewToRender)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === viewToRender && view.data != null);
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (customView) {
// PDF text editor handles its own empty state (shows dropzone when no document)
const handlesOwnEmptyState = viewToRender === 'custom:pdfTextEditor';
const handlesOwnEmptyState = currentView === 'custom:pdfTextEditor';
if (handlesOwnEmptyState || activeFiles.length > 0) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
@@ -134,7 +92,7 @@ export default function Workbench() {
);
}
switch (viewToRender) {
switch (currentView) {
case "fileEditor":
return (
@@ -231,20 +189,15 @@ export default function Workbench() {
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
{/* 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>
{/* 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>
<Footer
analyticsEnabled={config?.enableAnalytics === true}
@@ -469,7 +469,6 @@ 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,7 +1,6 @@
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';
@@ -24,7 +23,6 @@ interface PageThumbnailProps {
index: number;
totalPages: number;
originalFile?: File;
fileId: string;
fileColorIndex: number;
selectedPageIds: string[];
selectionMode: boolean;
@@ -58,7 +56,6 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
index: _index,
totalPages,
originalFile,
fileId,
fileColorIndex,
selectedPageIds,
selectionMode,
@@ -85,24 +82,6 @@ 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);
@@ -183,16 +162,11 @@ 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, morphRef]);
}, [page.id, pageRefs, dragHandleProps]);
// DOM command handlers
@@ -378,10 +352,6 @@ 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
@@ -1,5 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
@@ -10,11 +11,13 @@ import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccess
interface AllToolsNavButtonProps {
activeButton: string;
setActiveButton: (id: string) => void;
tooltipPosition?: 'left' | 'right' | 'top' | 'bottom';
}
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
activeButton,
setActiveButton,
tooltipPosition = 'right'
}) => {
const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
@@ -52,18 +55,26 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
};
return (
<div className="mt-4 mb-2">
<QuickAccessButton
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
label={t("quickAccess.allTools", "Tools")}
isActive={isActive}
onClick={handleNavClick}
href={navProps.href}
ariaLabel={t("quickAccess.allTools", "Tools")}
textClassName="all-tools-text"
component="a"
/>
</div>
<Tooltip
content={t("quickAccess.allTools", "Tools")}
position={tooltipPosition}
arrow
containerStyle={{ marginTop: "-1rem" }}
maxWidth={200}
>
<div className="mt-4 mb-2">
<QuickAccessButton
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
label={t("quickAccess.allTools", "Tools")}
isActive={isActive}
onClick={handleNavClick}
href={navProps.href}
ariaLabel={t("quickAccess.allTools", "Tools")}
textClassName="all-tools-text"
component="a"
/>
</div>
</Tooltip>
);
};
@@ -1,196 +0,0 @@
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>
);
}
@@ -1,9 +1,15 @@
import React from 'react';
import { ActionIcon } from '@mantine/core';
import React, { useState, useEffect } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip';
import { ViewerContext } from '@app/contexts/ViewerContext';
import { useSignature } from '@app/contexts/SignatureContext';
import { ColorSwatchButton, ColorPicker } from '@app/components/annotation/shared/ColorPicker';
import { useFileState, useFileContext } from '@app/contexts/FileContext';
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
import { createProcessedFile } from '@app/contexts/file/fileActions';
import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
@@ -17,19 +23,31 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
const { t } = useTranslation();
const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
// Signature context for accessing drawing API
const { signatureApiRef, isPlacementMode } = useSignature();
// File state for save functionality
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const activeFiles = selectors.getFiles();
// Check if we're in sign mode
const { selectedTool } = useNavigationState();
const isSignMode = selectedTool === 'sign';
// Check if we're in any annotation tool that should disable the toggle
const isInAnnotationTool = selectedTool === 'annotate' || selectedTool === 'sign' || selectedTool === 'addImage' || selectedTool === 'addText';
// Check if we're on annotate tool to highlight the button
const isAnnotateActive = selectedTool === 'annotate';
// Turn off annotation mode when switching away from viewer
useEffect(() => {
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
viewerContext.setAnnotationMode(false);
}
}, [currentView, viewerContext]);
// Don't show any annotation controls in sign mode
if (isSignMode) {
@@ -41,14 +59,13 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
{/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant={isAnnotateActive ? "filled" : "subtle"}
color="blue"
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={disabled || currentView !== 'viewer' || isInAnnotationTool}
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
@@ -57,6 +74,164 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
/>
</ActionIcon>
</Tooltip>
{/* Annotation Mode Toggle with Drawing Controls */}
{viewerContext?.isAnnotationMode ? (
// When active: Show color picker on hover
<div
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
style={{ display: 'inline-flex' }}
>
<Popover
opened={isHoverColorPickerOpen}
onClose={() => setIsHoverColorPickerOpen(false)}
position="left"
withArrow
shadow="md"
offset={8}
>
<Popover.Target>
<ActionIcon
variant="filled"
color="blue"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
// Deactivate drawing tool when exiting annotation mode
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={disabled}
aria-label="Drawing mode active"
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '8rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
<ColorSwatchButton
color={selectedColor}
size={32}
onClick={() => {
setIsHoverColorPickerOpen(false); // Close hover picker
setIsColorPickerOpen(true); // Open main color picker modal
}}
/>
</div>
</div>
</Popover.Dropdown>
</Popover>
</div>
) : (
// When inactive: Show "Draw" tooltip
<Tooltip content={t('rightRail.draw', 'Draw')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
// Activate ink drawing tool when entering annotation mode
if (signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.activateDrawMode();
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={disabled}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)}
{/* Save PDF with Annotations */}
<Tooltip content={t('rightRail.save', 'Save')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={async () => {
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
try {
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (pdfArrayBuffer) {
// Create new File object with flattened annotations
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
// Get the original file name or use a default
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
// Replace the current file in context with the saved version (exact same logic as Sign tool)
if (activeFiles.length > 0) {
// Generate thumbnail and metadata for the saved file
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
// Get current file info
const currentFileIds = state.files.ids;
if (currentFileIds.length > 0) {
const currentFileId = currentFileIds[0];
const currentRecord = selectors.getStirlingFileStub(currentFileId);
if (!currentRecord) {
console.error('No file record found for:', currentFileId);
return;
}
// Create output stub and file (exact same as Sign tool)
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
// Replace the original file with the saved version
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
}
}
}
} catch (error) {
console.error('Error saving PDF:', error);
}
}
}}
disabled={disabled}
>
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={(color) => {
setSelectedColor(color);
// Update drawing tool color if annotation mode is active
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.updateDrawSettings(color, 2);
} catch (error) {
console.log('Unable to update drawing settings:', error);
}
}
}}
title="Choose Drawing Color"
/>
</>
);
}
@@ -1,352 +0,0 @@
import { useImperativeHandle, forwardRef, useCallback } from 'react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype, PdfAnnotationIcon } from '@embedpdf/models';
import type {
AnnotationToolId,
AnnotationToolOptions,
AnnotationAPI,
AnnotationEvent,
AnnotationPatch,
} from '@app/components/viewer/viewerTypes';
type NoteIcon = NonNullable<AnnotationToolOptions['icon']>;
type AnnotationDefaults =
| {
type:
| PdfAnnotationSubtype.HIGHLIGHT
| PdfAnnotationSubtype.UNDERLINE
| PdfAnnotationSubtype.STRIKEOUT
| PdfAnnotationSubtype.SQUIGGLY;
color: string;
opacity: number;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.INK;
color: string;
opacity?: number;
borderWidth?: number;
strokeWidth?: number;
lineWidth?: number;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.FREETEXT;
fontColor?: string;
fontSize?: number;
fontFamily?: string;
textAlign?: number;
opacity?: number;
backgroundColor?: string;
borderWidth?: number;
contents?: string;
icon?: PdfAnnotationIcon;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.SQUARE | PdfAnnotationSubtype.CIRCLE | PdfAnnotationSubtype.POLYGON;
color: string;
strokeColor: string;
opacity: number;
fillOpacity: number;
strokeOpacity: number;
borderWidth: number;
strokeWidth: number;
lineWidth: number;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.LINE | PdfAnnotationSubtype.POLYLINE;
color: string;
strokeColor?: string;
opacity: number;
borderWidth?: number;
strokeWidth?: number;
lineWidth?: number;
startStyle?: string;
endStyle?: string;
lineEndingStyles?: { start: string; end: string };
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.STAMP;
imageSrc?: string;
imageSize?: { width: number; height: number };
customData?: Record<string, unknown>;
}
| null;
type AnnotationApiSurface = {
setActiveTool: (toolId: AnnotationToolId | null) => void;
getActiveTool?: () => { id: AnnotationToolId } | null;
setToolDefaults?: (toolId: AnnotationToolId, defaults: AnnotationDefaults) => void;
getSelectedAnnotation?: () => unknown | null;
deselectAnnotation?: () => void;
updateAnnotation?: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
};
type ToolDefaultsBuilder = (options?: AnnotationToolOptions) => AnnotationDefaults;
const NOTE_ICON_MAP: Record<NoteIcon, PdfAnnotationIcon> = {
Comment: PdfAnnotationIcon.Comment,
Key: PdfAnnotationIcon.Key,
Note: PdfAnnotationIcon.Note,
Help: PdfAnnotationIcon.Help,
NewParagraph: PdfAnnotationIcon.NewParagraph,
Paragraph: PdfAnnotationIcon.Paragraph,
Insert: PdfAnnotationIcon.Insert,
};
const DEFAULTS = {
highlight: '#ffd54f',
underline: '#ffb300',
strikeout: '#e53935',
squiggly: '#00acc1',
ink: '#1f2933',
inkHighlighter: '#ffd54f',
text: '#111111',
note: '#ffd54f', // match highlight color
shapeFill: '#0000ff',
shapeStroke: '#cf5b5b',
shapeOpacity: 0.5,
};
const withCustomData = (options?: AnnotationToolOptions) =>
options?.customData ? { customData: options.customData } : {};
const getIconEnum = (icon?: NoteIcon) => NOTE_ICON_MAP[icon ?? 'Comment'] ?? PdfAnnotationIcon.Comment;
const buildStampDefaults: ToolDefaultsBuilder = (options) => ({
type: PdfAnnotationSubtype.STAMP,
...(options?.imageSrc ? { imageSrc: options.imageSrc } : {}),
...(options?.imageSize ? { imageSize: options.imageSize } : {}),
...withCustomData(options),
});
const buildInkDefaults = (options?: AnnotationToolOptions, opacityOverride?: number): AnnotationDefaults => ({
type: PdfAnnotationSubtype.INK,
color: options?.color ?? (opacityOverride ? DEFAULTS.inkHighlighter : DEFAULTS.ink),
opacity: options?.opacity ?? opacityOverride ?? 1,
borderWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
strokeWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
lineWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
...withCustomData(options),
});
const TOOL_DEFAULT_BUILDERS: Record<AnnotationToolId, ToolDefaultsBuilder> = {
select: () => null,
highlight: (options) => ({
type: PdfAnnotationSubtype.HIGHLIGHT,
color: options?.color ?? DEFAULTS.highlight,
opacity: options?.opacity ?? 0.6,
...withCustomData(options),
}),
underline: (options) => ({
type: PdfAnnotationSubtype.UNDERLINE,
color: options?.color ?? DEFAULTS.underline,
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
strikeout: (options) => ({
type: PdfAnnotationSubtype.STRIKEOUT,
color: options?.color ?? DEFAULTS.strikeout,
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
squiggly: (options) => ({
type: PdfAnnotationSubtype.SQUIGGLY,
color: options?.color ?? DEFAULTS.squiggly,
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
ink: (options) => buildInkDefaults(options),
inkHighlighter: (options) => buildInkDefaults(options, options?.opacity ?? 0.6),
text: (options) => ({
type: PdfAnnotationSubtype.FREETEXT,
fontColor: options?.color ?? DEFAULTS.text,
fontSize: options?.fontSize ?? 14,
fontFamily: options?.fontFamily ?? 'Helvetica',
textAlign: options?.textAlign ?? 0,
opacity: options?.opacity ?? 1,
borderWidth: options?.thickness ?? 1,
...(options?.fillColor ? { backgroundColor: options.fillColor } : {}),
...withCustomData(options),
}),
note: (options) => {
const backgroundColor = options?.fillColor ?? DEFAULTS.note;
const fontColor = options?.color ?? DEFAULTS.text;
return {
type: PdfAnnotationSubtype.FREETEXT,
fontColor,
color: fontColor,
fontFamily: options?.fontFamily ?? 'Helvetica',
textAlign: options?.textAlign ?? 0,
fontSize: options?.fontSize ?? 12,
opacity: options?.opacity ?? 1,
backgroundColor,
borderWidth: options?.thickness ?? 0,
contents: options?.contents ?? 'Note',
icon: getIconEnum(options?.icon),
...withCustomData(options),
};
},
square: (options) => ({
type: PdfAnnotationSubtype.SQUARE,
color: options?.color ?? DEFAULTS.shapeFill,
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
borderWidth: options?.borderWidth ?? 1,
strokeWidth: options?.borderWidth ?? 1,
lineWidth: options?.borderWidth ?? 1,
...withCustomData(options),
}),
circle: (options) => ({
type: PdfAnnotationSubtype.CIRCLE,
color: options?.color ?? DEFAULTS.shapeFill,
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
borderWidth: options?.borderWidth ?? 1,
strokeWidth: options?.borderWidth ?? 1,
lineWidth: options?.borderWidth ?? 1,
...withCustomData(options),
}),
line: (options) => ({
type: PdfAnnotationSubtype.LINE,
color: options?.color ?? '#1565c0',
strokeColor: options?.color ?? '#1565c0',
opacity: options?.opacity ?? 1,
borderWidth: options?.borderWidth ?? 2,
strokeWidth: options?.borderWidth ?? 2,
lineWidth: options?.borderWidth ?? 2,
...withCustomData(options),
}),
lineArrow: (options) => ({
type: PdfAnnotationSubtype.LINE,
color: options?.color ?? '#1565c0',
strokeColor: options?.color ?? '#1565c0',
opacity: options?.opacity ?? 1,
borderWidth: options?.borderWidth ?? 2,
strokeWidth: options?.borderWidth ?? 2,
lineWidth: options?.borderWidth ?? 2,
startStyle: 'None',
endStyle: 'ClosedArrow',
lineEndingStyles: { start: 'None', end: 'ClosedArrow' },
...withCustomData(options),
}),
polyline: (options) => ({
type: PdfAnnotationSubtype.POLYLINE,
color: options?.color ?? '#1565c0',
opacity: options?.opacity ?? 1,
borderWidth: options?.borderWidth ?? 2,
...withCustomData(options),
}),
polygon: (options) => ({
type: PdfAnnotationSubtype.POLYGON,
color: options?.color ?? DEFAULTS.shapeFill,
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
borderWidth: options?.borderWidth ?? 1,
strokeWidth: options?.borderWidth ?? 1,
lineWidth: options?.borderWidth ?? 1,
...withCustomData(options),
}),
stamp: buildStampDefaults,
signatureStamp: buildStampDefaults,
signatureInk: (options) => buildInkDefaults(options),
};
export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function AnnotationAPIBridge(_props, ref) {
// Use the provided annotation API just like SignatureAPIBridge/HistoryAPIBridge
const { provides: annotationApi } = useAnnotationCapability();
const buildAnnotationDefaults = useCallback(
(toolId: AnnotationToolId, options?: AnnotationToolOptions) =>
TOOL_DEFAULT_BUILDERS[toolId]?.(options) ?? null,
[]
);
const configureAnnotationTool = useCallback(
(toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
const api = annotationApi as AnnotationApiSurface | undefined;
if (!api?.setActiveTool) return;
const defaults = buildAnnotationDefaults(toolId, options);
// Reset tool first, then activate (like SignatureAPIBridge does)
api.setActiveTool(null);
api.setActiveTool(toolId === 'select' ? null : toolId);
// Verify tool was activated before setting defaults (like SignatureAPIBridge does)
const activeTool = api.getActiveTool?.();
if (activeTool && activeTool.id === toolId && defaults) {
api.setToolDefaults?.(toolId, defaults);
}
},
[annotationApi, buildAnnotationDefaults]
);
useImperativeHandle(
ref,
() => ({
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
configureAnnotationTool(toolId, options);
},
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
const defaults = buildAnnotationDefaults(toolId, options);
const api = annotationApi as AnnotationApiSurface | undefined;
if (defaults && api?.setToolDefaults) {
api.setToolDefaults(toolId, defaults);
}
},
getSelectedAnnotation: () => {
const api = annotationApi as AnnotationApiSurface | undefined;
if (!api?.getSelectedAnnotation) {
return null;
}
try {
return api.getSelectedAnnotation();
} catch (error) {
// Some EmbedPDF builds expose getSelectedAnnotation with an internal
// `this`/state dependency (e.g. reading `selectedUid` from undefined).
// If that happens, fail gracefully and treat it as "no selection"
// instead of crashing the entire annotations tool.
console.error('[AnnotationAPIBridge] getSelectedAnnotation failed:', error);
return null;
}
},
deselectAnnotation: () => {
const api = annotationApi as AnnotationApiSurface | undefined;
api?.deselectAnnotation?.();
},
updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => {
const api = annotationApi as AnnotationApiSurface | undefined;
api?.updateAnnotation?.(pageIndex, annotationId, patch);
},
deactivateTools: () => {
const api = annotationApi as AnnotationApiSurface | undefined;
api?.setActiveTool?.(null);
},
onAnnotationEvent: (listener: (event: AnnotationEvent) => void) => {
const api = annotationApi as AnnotationApiSurface | undefined;
if (api?.onAnnotationEvent) {
return api.onAnnotationEvent(listener);
}
return undefined;
},
getActiveTool: () => {
const api = annotationApi as AnnotationApiSurface | undefined;
return api?.getActiveTool?.() ?? null;
},
}),
[annotationApi, configureAnnotationTool, buildAnnotationDefaults]
);
return null;
});
@@ -17,8 +17,6 @@ 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;
@@ -53,11 +51,13 @@ const EmbedPdfViewerContent = ({
getScrollState,
getRotationState,
isAnnotationMode,
setAnnotationMode,
isAnnotationsVisible,
exportActions,
} = useViewer();
// Register viewer right-rail buttons
useViewerRightRailButtons();
const scrollState = getScrollState();
const rotationState = getRotationState();
@@ -69,13 +69,8 @@ const EmbedPdfViewerContent = ({
}
}, [rotationState.rotation]);
// Get signature and annotation contexts
const { signatureApiRef, annotationApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
// Track whether there are unsaved annotation changes in this viewer session.
// This is our source of truth for navigation guards; it is set when the
// annotation history changes, and cleared after we successfully apply changes.
const hasAnnotationChangesRef = useRef(false);
// Get signature context
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
// Get current file from FileContext
const { selectors, state } = useFileState();
@@ -87,18 +82,15 @@ const EmbedPdfViewerContent = ({
// Navigation guard for unsaved changes
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
// Check if we're in an annotation tool
// Check if we're in signature mode OR viewer annotation mode
const { selectedTool } = useNavigationState();
// Tools that require the annotation layer (Sign, Add Text, Add Image, Annotate)
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate';
// Sync isAnnotationMode in ViewerContext with current tool
useEffect(() => {
setAnnotationMode(isInAnnotationTool);
}, [isInAnnotationTool, setAnnotationMode]);
// Tools that use the stamp/signature placement system with hover preview
const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
const isPlacementOverlayActive = Boolean(
isInAnnotationTool && isPlacementMode && signatureConfig
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
);
// Track which file tab is active
@@ -136,13 +128,6 @@ 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);
@@ -159,13 +144,6 @@ 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;
@@ -243,31 +221,6 @@ const EmbedPdfViewerContent = ({
};
}, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]);
// Watch the annotation history API to detect when the document becomes "dirty".
// We treat any change that makes the history undoable as unsaved changes until
// the user explicitly applies them via applyChanges.
useEffect(() => {
const historyApi = historyApiRef.current;
if (!historyApi || !historyApi.subscribe) {
return;
}
const updateHasChanges = () => {
const canUndo = historyApi.canUndo?.() ?? false;
if (!hasAnnotationChangesRef.current && canUndo) {
hasAnnotationChangesRef.current = true;
setHasUnsavedChanges(true);
}
};
const unsubscribe = historyApi.subscribe(updateHasChanges);
return () => {
if (typeof unsubscribe === 'function') {
unsubscribe();
}
};
}, [historyApiRef.current, setHasUnsavedChanges]);
// Register checker for unsaved changes (annotations only for now)
useEffect(() => {
if (previewFile) {
@@ -275,28 +228,39 @@ const EmbedPdfViewerContent = ({
}
const checkForChanges = () => {
const hasAnnotationChanges = hasAnnotationChangesRef.current;
// Check for annotation changes via history
const hasAnnotationChanges = historyApiRef.current?.canUndo() || false;
console.log('[Viewer] Checking for unsaved changes:', {
hasAnnotationChanges
});
return hasAnnotationChanges;
};
console.log('[Viewer] Registering unsaved changes checker');
registerUnsavedChangesChecker(checkForChanges);
return () => {
console.log('[Viewer] Unregistering unsaved changes checker');
unregisterUnsavedChangesChecker();
};
}, [previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
// Apply changes - save annotations to new file version
const applyChanges = useCallback(async () => {
if (!currentFile || activeFileIds.length === 0) return;
try {
console.log('[Viewer] Applying changes - exporting PDF with annotations');
// Step 1: Export PDF with annotations using EmbedPDF
const arrayBuffer = await exportActions.saveAsCopy();
if (!arrayBuffer) {
throw new Error('Failed to export PDF');
}
console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength);
// Step 2: Convert ArrayBuffer to File
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
const filename = currentFile.name || 'document.pdf';
@@ -311,29 +275,12 @@ const EmbedPdfViewerContent = ({
// Step 4: Consume files (replace in context)
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
// Mark annotations as saved so navigation away from the viewer is allowed.
hasAnnotationChangesRef.current = false;
setHasUnsavedChanges(false);
} catch (error) {
console.error('Apply changes failed:', error);
}
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
// Expose annotation apply via a global event so tools (like Annotate) can
// trigger saves from the left sidebar without tight coupling.
useEffect(() => {
const handler = () => {
void applyChanges();
};
window.addEventListener('stirling-annotations-apply', handler);
return () => {
window.removeEventListener('stirling-annotations-apply', handler);
};
}, [applyChanges]);
// Register viewer right-rail buttons
useViewerRightRailButtons();
const sidebarWidthRem = 15;
const totalRightMargin =
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
@@ -351,8 +298,6 @@ 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
@@ -388,10 +333,8 @@ const EmbedPdfViewerContent = ({
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
file={effectiveFile.file}
url={effectiveFile.url}
enableAnnotations={isAnnotationMode}
showBakedAnnotations={isAnnotationsVisible}
enableAnnotations={shouldEnableAnnotations}
signatureApiRef={signatureApiRef as React.RefObject<any>}
annotationApiRef={annotationApiRef as React.RefObject<any>}
historyApiRef={historyApiRef as React.RefObject<any>}
onSignatureAdded={() => {
// Handle signature added - for debugging, enable console logs as needed
@@ -38,9 +38,8 @@ import { SearchAPIBridge } from '@app/components/viewer/SearchAPIBridge';
import { ThumbnailAPIBridge } from '@app/components/viewer/ThumbnailAPIBridge';
import { RotateAPIBridge } from '@app/components/viewer/RotateAPIBridge';
import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge';
import { AnnotationAPIBridge } from '@app/components/viewer/AnnotationAPIBridge';
import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
import type { SignatureAPI, AnnotationAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
@@ -53,14 +52,12 @@ interface LocalEmbedPDFProps {
file?: File | Blob;
url?: string | null;
enableAnnotations?: boolean;
showBakedAnnotations?: boolean;
onSignatureAdded?: (annotation: any) => void;
signatureApiRef?: React.RefObject<SignatureAPI>;
annotationApiRef?: React.RefObject<AnnotationAPI>;
historyApiRef?: React.RefObject<HistoryAPI>;
}
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef }: LocalEmbedPDFProps) {
export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
const { t } = useTranslation();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
@@ -103,7 +100,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
}),
createPluginRegistration(RenderPluginPackage, {
withForms: true,
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF
withAnnotations: true,
}),
// Register interaction manager (required for zoom and selection features)
@@ -125,8 +122,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
selectAfterCreate: true,
}),
// Register pan plugin (depends on Viewport, InteractionManager) - keep disabled to prevent drag panning
createPluginRegistration(PanPluginPackage, {}),
// Register pan plugin (depends on Viewport, InteractionManager)
createPluginRegistration(PanPluginPackage, {
defaultMode: 'mobile', // Try mobile mode which might be more permissive
}),
// Register zoom plugin with configuration
createPluginRegistration(ZoomPluginPackage, {
@@ -167,7 +166,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
// Register print plugin for printing PDFs
createPluginRegistration(PrintPluginPackage),
];
}, [pdfUrl, enableAnnotations, showBakedAnnotations]);
}, [pdfUrl]);
// Initialize the engine with the React hook - use local WASM for offline support
const { engine, isLoading, error } = usePdfiumEngine({
@@ -252,315 +251,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
if (!annotationApi) return;
if (enableAnnotations) {
const ensureTool = (tool: any) => {
const existing = annotationApi.getTool?.(tool.id);
if (!existing) {
annotationApi.addTool(tool);
}
};
ensureTool({
id: 'highlight',
name: 'Highlight',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.HIGHLIGHT,
color: '#ffd54f',
opacity: 0.6,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'underline',
name: 'Underline',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.UNDERLINE,
color: '#ffb300',
opacity: 1,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'strikeout',
name: 'Strikeout',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.STRIKEOUT,
color: '#e53935',
opacity: 1,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'squiggly',
name: 'Squiggly',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.SQUIGGLY,
color: '#00acc1',
opacity: 1,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'ink',
name: 'Pen',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.INK,
color: '#1f2933',
opacity: 1,
borderWidth: 2,
lineWidth: 2,
strokeWidth: 2,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'inkHighlighter',
name: 'Ink Highlighter',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK && annotation.color === '#ffd54f' ? 8 : 0),
defaults: {
type: PdfAnnotationSubtype.INK,
color: '#ffd54f',
opacity: 0.5,
borderWidth: 6,
lineWidth: 6,
strokeWidth: 6,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'square',
name: 'Square',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.SQUARE,
color: '#0000ff', // fill color (blue)
strokeColor: '#cf5b5b', // border color (reddish pink)
opacity: 0.5,
borderWidth: 1,
strokeWidth: 1,
lineWidth: 1,
},
clickBehavior: {
enabled: true,
defaultSize: { width: 120, height: 90 },
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'circle',
name: 'Circle',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.CIRCLE,
color: '#0000ff', // fill color (blue)
strokeColor: '#cf5b5b', // border color (reddish pink)
opacity: 0.5,
borderWidth: 1,
strokeWidth: 1,
lineWidth: 1,
},
clickBehavior: {
enabled: true,
defaultSize: { width: 100, height: 100 },
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'line',
name: 'Line',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.LINE,
color: '#1565c0',
opacity: 1,
borderWidth: 2,
strokeWidth: 2,
lineWidth: 2,
},
clickBehavior: {
enabled: true,
defaultLength: 120,
defaultAngle: 0,
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'lineArrow',
name: 'Arrow',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE && (annotation.endStyle === 'ClosedArrow' || annotation.lineEndingStyles?.end === 'ClosedArrow') ? 9 : 0),
defaults: {
type: PdfAnnotationSubtype.LINE,
color: '#1565c0',
opacity: 1,
borderWidth: 2,
startStyle: 'None',
endStyle: 'ClosedArrow',
lineEndingStyles: { start: 'None', end: 'ClosedArrow' },
},
clickBehavior: {
enabled: true,
defaultLength: 120,
defaultAngle: 0,
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'polyline',
name: 'Polyline',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.POLYLINE,
color: '#1565c0',
opacity: 1,
borderWidth: 2,
},
clickBehavior: {
enabled: true,
finishOnDoubleClick: true,
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'polygon',
name: 'Polygon',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.POLYGON,
color: '#0000ff', // fill color (blue)
strokeColor: '#cf5b5b', // border color (reddish pink)
opacity: 0.5,
borderWidth: 1,
},
clickBehavior: {
enabled: true,
finishOnDoubleClick: true,
defaultSize: { width: 140, height: 100 },
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'text',
name: 'Text',
interaction: { exclusive: true, cursor: 'text' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.FREETEXT,
textColor: '#111111',
fontSize: 14,
fontFamily: 'Helvetica',
opacity: 1,
interiorColor: '#fffef7',
contents: 'Text',
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'note',
name: 'Note',
interaction: { exclusive: true, cursor: 'pointer' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0),
defaults: {
type: PdfAnnotationSubtype.FREETEXT,
textColor: '#1b1b1b',
color: '#ffa000',
interiorColor: '#fff8e1',
opacity: 1,
contents: 'Note',
fontSize: 12,
},
clickBehavior: {
enabled: true,
defaultSize: { width: 160, height: 100 },
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'stamp',
name: 'Image Stamp',
interaction: { exclusive: false, cursor: 'copy' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0),
defaults: {
type: PdfAnnotationSubtype.STAMP,
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
annotationApi.addTool({
id: 'signatureStamp',
name: 'Digital Signature',
interaction: { exclusive: false, cursor: 'copy' },
@@ -570,7 +261,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
},
});
ensureTool({
annotationApi.addTool({
id: 'signatureInk',
name: 'Signature Draw',
interaction: { exclusive: true, cursor: 'crosshair' },
@@ -618,7 +309,6 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
<ThumbnailAPIBridge />
<RotateAPIBridge />
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
<ExportAPIBridge />
<BookmarkAPIBridge />
@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
import { Button, Paper, Group, NumberInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useViewer } from '@app/contexts/ViewerContext';
import { Tooltip } from '@app/components/shared/Tooltip';
import FirstPageIcon from '@mui/icons-material/FirstPage';
import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
@@ -210,27 +209,21 @@ export function PdfViewerToolbar({
</Button>
{/* Dual Page Toggle */}
<Tooltip
content={
<Button
variant={isDualPageActive ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={handleDualPageToggle}
style={{ minWidth: '2.5rem' }}
title={
isDualPageActive
? t("viewer.singlePageView", "Single Page View")
: t("viewer.dualPageView", "Dual Page View")
}
position="top"
arrow
>
<Button
variant={isDualPageActive ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={handleDualPageToggle}
disabled={scrollState.totalPages <= 1}
style={{ minWidth: '2.5rem' }}
>
{isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
</Tooltip>
{isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
{/* Zoom Controls */}
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
@@ -104,20 +104,12 @@ const createTextStampImage = (
ctx.fillStyle = textColor;
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textAlign = config.textAlign || 'left';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const horizontalPadding = paddingX;
const verticalCenter = naturalHeight / 2;
let xPosition = horizontalPadding;
if (config.textAlign === 'center') {
xPosition = naturalWidth / 2;
} else if (config.textAlign === 'right') {
xPosition = naturalWidth - horizontalPadding;
}
ctx.fillText(text, xPosition, verticalCenter);
ctx.fillText(text, horizontalPadding, verticalCenter);
return {
dataUrl: canvas.toDataURL('image/png'),
@@ -207,21 +199,12 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
}
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
// Enable keyboard deletion of selected annotations
useEffect(() => {
// Always enable delete key when we have annotation API and are in sign mode
if (!annotationApi || (isPlacementMode === undefined)) return;
const handleKeyDown = (event: KeyboardEvent) => {
// Skip delete/backspace while a text input/textarea is focused (e.g., editing textbox)
const target = event.target as HTMLElement | null;
const tag = target?.tagName?.toLowerCase();
const editable = target?.getAttribute?.('contenteditable');
if (tag === 'input' || tag === 'textarea' || editable === 'true') {
return;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
@@ -1,49 +0,0 @@
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,
}}
/>
);
}
@@ -1,155 +0,0 @@
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}
/>
);
})}
</>
);
}
@@ -1,4 +1,4 @@
import { useMemo, useState, useEffect, useCallback } from 'react';
import { useMemo, useState } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useViewer } from '@app/contexts/ViewerContext';
@@ -9,9 +9,6 @@ import { SearchInterface } from '@app/components/viewer/SearchInterface';
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { BASE_PATH, withBasePath } from '@app/constants/app';
export function useViewerRightRailButtons() {
const { t, i18n } = useTranslation();
@@ -19,32 +16,6 @@ export function useViewerRightRailButtons() {
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
const { handleToolSelect } = useToolWorkflow();
const { selectedTool } = useNavigationState();
const stripBasePath = useCallback((path: string) => {
if (BASE_PATH && path.startsWith(BASE_PATH)) {
return path.slice(BASE_PATH.length) || '/';
}
return path;
}, []);
const isAnnotationsPath = useCallback(() => {
const cleanPath = stripBasePath(window.location.pathname).toLowerCase();
return cleanPath === '/annotations' || cleanPath.endsWith('/annotations');
}, [stripBasePath]);
const [isAnnotationsActive, setIsAnnotationsActive] = useState<boolean>(() => isAnnotationsPath());
useEffect(() => {
setIsAnnotationsActive(isAnnotationsPath());
}, [selectedTool, isAnnotationsPath]);
useEffect(() => {
const handlePopState = () => setIsAnnotationsActive(isAnnotationsPath());
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [isAnnotationsPath]);
// Lift i18n labels out of memo for clarity
const searchLabel = t('rightRail.search', 'Search PDF');
@@ -54,11 +25,9 @@ export function useViewerRightRailButtons() {
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
const printLabel = t('rightRail.print', 'Print PDF');
const annotationsLabel = t('rightRail.annotations', 'Annotations');
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
const buttons: RightRailButtonWithAction[] = [
return [
{
id: 'viewer-search',
tooltip: searchLabel,
@@ -178,36 +147,6 @@ export function useViewerRightRailButtons() {
viewer.printActions.print();
}
},
{
id: 'viewer-annotations',
tooltip: annotationsLabel,
ariaLabel: annotationsLabel,
section: 'top' as const,
order: 58,
render: ({ disabled }) => (
<Tooltip content={annotationsLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isAnnotationsActive ? 'default' : 'subtle'}
radius="md"
className="right-rail-icon"
onClick={() => {
if (disabled || isAnnotationsActive) return;
const targetPath = withBasePath('/annotations');
if (window.location.pathname !== targetPath) {
window.history.pushState(null, '', targetPath);
}
setIsAnnotationsActive(true);
handleToolSelect('annotate');
}}
disabled={disabled || isAnnotationsActive}
aria-pressed={isAnnotationsActive}
style={isAnnotationsActive ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)
},
{
id: 'viewer-annotation-controls',
section: 'top' as const,
@@ -215,30 +154,9 @@ export function useViewerRightRailButtons() {
render: ({ disabled }) => (
<ViewerAnnotationControls currentView="viewer" disabled={disabled} />
)
},
}
];
// Optional: Save button for annotations (always registered when this hook is used
// with a save handler; uses a ref to avoid infinite re-registration loops).
return buttons;
}, [
t,
i18n.language,
viewer,
isPanning,
searchLabel,
panLabel,
rotateLeftLabel,
rotateRightLabel,
sidebarLabel,
bookmarkLabel,
printLabel,
tooltipPosition,
annotationsLabel,
saveChangesLabel,
isAnnotationsActive,
handleToolSelect,
]);
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, printLabel, tooltipPosition]);
useRightRailButtons(viewerButtons);
}
@@ -16,17 +16,6 @@ export interface SignatureAPI {
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
}
export interface AnnotationAPI {
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
getSelectedAnnotation: () => AnnotationSelection | null;
deselectAnnotation: () => void;
updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
deactivateTools: () => void;
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
getActiveTool?: () => { id: AnnotationToolId } | null;
}
export interface HistoryAPI {
undo: () => void;
redo: () => void;
@@ -34,50 +23,3 @@ export interface HistoryAPI {
canRedo: () => boolean;
subscribe?: (listener: () => void) => () => void;
}
export type AnnotationToolId =
| 'select'
| 'highlight'
| 'underline'
| 'strikeout'
| 'squiggly'
| 'ink'
| 'inkHighlighter'
| 'text'
| 'note'
| 'square'
| 'circle'
| 'line'
| 'lineArrow'
| 'polyline'
| 'polygon'
| 'stamp'
| 'signatureStamp'
| 'signatureInk';
export interface AnnotationEvent {
type: string;
[key: string]: unknown;
}
export type AnnotationPatch = Record<string, unknown>;
export type AnnotationSelection = unknown;
export interface AnnotationToolOptions {
color?: string;
fillColor?: string;
strokeColor?: string;
opacity?: number;
strokeOpacity?: number;
fillOpacity?: number;
thickness?: number;
borderWidth?: number;
fontSize?: number;
fontFamily?: string;
textAlign?: number; // 0 = Left, 1 = Center, 2 = Right
imageSrc?: string;
imageSize?: { width: number; height: number };
icon?: 'Comment' | 'Key' | 'Note' | 'Help' | 'NewParagraph' | 'Paragraph' | 'Insert';
contents?: string;
customData?: Record<string, unknown>;
}
@@ -1,26 +0,0 @@
import React, { createContext, useContext, ReactNode, useRef } from 'react';
import type { AnnotationAPI } from '@app/components/viewer/viewerTypes';
interface AnnotationContextValue {
annotationApiRef: React.RefObject<AnnotationAPI | null>;
}
const AnnotationContext = createContext<AnnotationContextValue | undefined>(undefined);
export const AnnotationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const annotationApiRef = useRef<AnnotationAPI>(null);
const value: AnnotationContextValue = {
annotationApiRef,
};
return <AnnotationContext.Provider value={value}>{children}</AnnotationContext.Provider>;
};
export const useAnnotation = (): AnnotationContextValue => {
const context = useContext(AnnotationContext);
if (!context) {
throw new Error('useAnnotation must be used within an AnnotationProvider');
}
return context;
};
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react';
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import type { SignatureAPI, HistoryAPI, AnnotationAPI } from '@app/components/viewer/viewerTypes';
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
// Signature state interface
interface SignatureState {
@@ -34,7 +34,6 @@ interface SignatureActions {
// Combined context interface
interface SignatureContextValue extends SignatureState, SignatureActions {
signatureApiRef: React.RefObject<SignatureAPI | null>;
annotationApiRef: React.RefObject<AnnotationAPI | null>;
historyApiRef: React.RefObject<HistoryAPI | null>;
}
@@ -53,7 +52,6 @@ const initialState: SignatureState = {
export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [state, setState] = useState<SignatureState>(initialState);
const signatureApiRef = useRef<SignatureAPI>(null);
const annotationApiRef = useRef<AnnotationAPI>(null);
const historyApiRef = useRef<HistoryAPI>(null);
const imageDataStore = useRef<Map<string, string>>(new Map());
@@ -159,7 +157,6 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
const contextValue: SignatureContextValue = {
...state,
signatureApiRef,
annotationApiRef,
historyApiRef,
setSignatureConfig,
setPlacementMode,
@@ -95,6 +95,7 @@ interface ViewerContextType {
// Annotation/drawing mode for viewer
isAnnotationMode: boolean;
setAnnotationMode: (enabled: boolean) => void;
toggleAnnotationMode: () => void;
// Active file index for multi-file viewing
activeFileIndex: number;
@@ -229,6 +230,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setIsAnnotationModeState(enabled);
};
const toggleAnnotationMode = () => {
setIsAnnotationModeState(prev => !prev);
};
// State getters - read from bridge refs
const getScrollState = (): ScrollState => {
return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 };
@@ -313,6 +318,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
toggleAnnotationsVisibility,
isAnnotationMode,
setAnnotationMode,
toggleAnnotationMode,
// Active file index
activeFileIndex,
@@ -1,344 +0,0 @@
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>
);
}
@@ -51,7 +51,6 @@ import Crop from "@app/tools/Crop";
import Sign from "@app/tools/Sign";
import AddText from "@app/tools/AddText";
import AddImage from "@app/tools/AddImage";
import Annotate from "@app/tools/Annotate";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
@@ -247,19 +246,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, 'addImage'),
supportsAutomate: false,
},
annotate: {
icon: <LocalIcon icon="edit" width="1.5rem" height="1.5rem" />,
name: t('home.annotate.title', 'Annotate'),
component: Annotate,
description: t('home.annotate.desc', 'Highlight, draw, add notes, and shapes directly in the viewer'),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
workbench: 'viewer',
operationConfig: signOperationConfig,
automationSettings: null,
synonyms: getSynonyms(t, 'annotate'),
supportsAutomate: false,
},
// Document Security
@@ -18,7 +18,6 @@ export interface SignParameters {
fontFamily?: string;
fontSize?: number;
textColor?: string;
textAlign?: 'left' | 'center' | 'right';
}
export const DEFAULT_PARAMETERS: SignParameters = {
@@ -29,7 +28,6 @@ export const DEFAULT_PARAMETERS: SignParameters = {
fontFamily: 'Helvetica',
fontSize: 16,
textColor: '#000000',
textAlign: 'left',
};
const validateSignParameters = (parameters: SignParameters): boolean => {
@@ -1,99 +0,0 @@
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;
}
@@ -1,30 +0,0 @@
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 };
}
+2 -7
View File
@@ -22,8 +22,6 @@ 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";
@@ -177,9 +175,7 @@ export default function HomePage() {
// Note: File selection limits are now handled directly by individual tools
return (
<WorkbenchTransitionProvider>
<MorphOverlay />
<div className="h-screen overflow-hidden">
<div className="h-screen overflow-hidden">
{isMobile ? (
<div className="mobile-layout">
<div className="mobile-toggle">
@@ -292,7 +288,6 @@ export default function HomePage() {
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
</Group>
)}
</div>
</WorkbenchTransitionProvider>
</div>
);
}
-416
View File
@@ -1,416 +0,0 @@
import { useEffect, useState, useContext, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
import { useNavigation } from '@app/contexts/NavigationContext';
import { useFileSelection } from '@app/contexts/FileContext';
import { BaseToolProps } from '@app/types/tool';
import { useSignature } from '@app/contexts/SignatureContext';
import { ViewerContext, useViewer } from '@app/contexts/ViewerContext';
import type { AnnotationToolId } from '@app/components/viewer/viewerTypes';
import { useAnnotationStyleState } from '@app/tools/annotate/useAnnotationStyleState';
import { useAnnotationSelection } from '@app/tools/annotate/useAnnotationSelection';
import { AnnotationPanel } from '@app/tools/annotate/AnnotationPanel';
const KNOWN_ANNOTATION_TOOLS: AnnotationToolId[] = [
'select',
'highlight',
'underline',
'strikeout',
'squiggly',
'ink',
'inkHighlighter',
'text',
'note',
'square',
'circle',
'line',
'lineArrow',
'polyline',
'polygon',
'stamp',
'signatureStamp',
'signatureInk',
];
const isKnownAnnotationTool = (toolId: string | undefined | null): toolId is AnnotationToolId =>
!!toolId && (KNOWN_ANNOTATION_TOOLS as string[]).includes(toolId);
const Annotate = (_props: BaseToolProps) => {
const { t } = useTranslation();
const { selectedTool, workbench, hasUnsavedChanges } = useNavigation();
const { selectedFiles } = useFileSelection();
const {
signatureApiRef,
annotationApiRef,
historyApiRef,
undo,
redo,
setSignatureConfig,
setPlacementMode,
placementPreviewSize,
setPlacementPreviewSize,
} = useSignature();
const viewerContext = useContext(ViewerContext);
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
const [activeTool, setActiveTool] = useState<AnnotationToolId>('select');
const activeToolRef = useRef<AnnotationToolId>('select');
const wasAnnotateActiveRef = useRef<boolean>(false);
const [selectedTextDraft, setSelectedTextDraft] = useState<string>('');
const [selectedFontSize, setSelectedFontSize] = useState<number>(14);
const [stampImageData, setStampImageData] = useState<string | undefined>();
const [stampImageSize, setStampImageSize] = useState<{ width: number; height: number } | null>(null);
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
const manualToolSwitch = useRef<boolean>(false);
// Zoom tracking for stamp size conversion
const [currentZoom, setCurrentZoom] = useState(() => {
const zoomState = getZoomState();
if (!zoomState) return 1;
if (typeof zoomState.zoomPercent === 'number') {
return Math.max(zoomState.zoomPercent / 100, 0.01);
}
return Math.max(zoomState.currentZoom ?? 1, 0.01);
});
useEffect(() => {
return registerImmediateZoomUpdate((newZoomPercent) => {
setCurrentZoom(Math.max(newZoomPercent / 100, 0.01));
});
}, [registerImmediateZoomUpdate]);
useEffect(() => {
activeToolRef.current = activeTool;
}, [activeTool]);
// CSS to PDF size conversion accounting for zoom
const cssToPdfSize = useCallback(
(size: { width: number; height: number }) => {
const zoom = currentZoom || 1;
const factor = 1 / zoom;
return {
width: size.width * factor,
height: size.height * factor,
};
},
[currentZoom]
);
const computeStampDisplaySize = useCallback((natural: { width: number; height: number } | null) => {
if (!natural) {
return { width: 180, height: 120 };
}
const maxSide = 260;
const minSide = 24;
const { width, height } = natural;
const largest = Math.max(width || maxSide, height || maxSide, 1);
const scale = Math.min(1, maxSide / largest);
return {
width: Math.max(minSide, Math.round(width * scale)),
height: Math.max(minSide, Math.round(height * scale)),
};
}, []);
const {
styleState,
styleActions,
buildToolOptions,
getActiveColor,
} = useAnnotationStyleState(cssToPdfSize);
const {
setInkWidth,
setShapeThickness,
setTextColor,
setTextBackgroundColor,
setNoteBackgroundColor,
setInkColor,
setHighlightColor,
setHighlightOpacity,
setFreehandHighlighterWidth,
setUnderlineColor,
setUnderlineOpacity,
setStrikeoutColor,
setStrikeoutOpacity,
setSquigglyColor,
setSquigglyOpacity,
setShapeStrokeColor,
setShapeFillColor,
setShapeOpacity,
setShapeStrokeOpacity,
setShapeFillOpacity,
setTextAlignment,
} = styleActions;
const handleApplyChanges = useCallback(() => {
window.dispatchEvent(new CustomEvent('stirling-annotations-apply'));
}, []);
useEffect(() => {
const isAnnotateActive = workbench === 'viewer' && selectedTool === 'annotate';
if (wasAnnotateActiveRef.current && !isAnnotateActive) {
annotationApiRef?.current?.deactivateTools?.();
signatureApiRef?.current?.deactivateTools?.();
setPlacementMode(false);
} else if (!wasAnnotateActiveRef.current && isAnnotateActive) {
// When entering annotate mode, activate the select tool by default
const toolOptions = buildToolOptions('select');
annotationApiRef?.current?.activateAnnotationTool?.('select', toolOptions);
}
wasAnnotateActiveRef.current = isAnnotateActive;
}, [workbench, selectedTool, annotationApiRef, signatureApiRef, setPlacementMode, buildToolOptions]);
// Monitor history state for undo/redo availability
useEffect(() => {
const historyApi = historyApiRef?.current;
if (!historyApi) return;
const updateAvailability = () =>
setHistoryAvailability({
canUndo: historyApi.canUndo?.() ?? false,
canRedo: historyApi.canRedo?.() ?? false,
});
updateAvailability();
let interval: ReturnType<typeof setInterval> | undefined;
if (!historyApi.subscribe) {
// Fallback polling in case the history API doesn't support subscriptions
interval = setInterval(updateAvailability, 350);
} else {
const unsubscribe = historyApi.subscribe(updateAvailability);
return () => {
if (typeof unsubscribe === 'function') {
unsubscribe();
}
if (interval) clearInterval(interval);
};
}
return () => {
if (interval) clearInterval(interval);
};
}, [historyApiRef?.current]);
useEffect(() => {
if (!viewerContext) return;
if (viewerContext.isAnnotationMode) return;
viewerContext.setAnnotationMode(true);
const toolOptions =
activeTool === 'stamp'
? buildToolOptions('stamp', { stampImageData, stampImageSize })
: buildToolOptions(activeTool);
annotationApiRef?.current?.activateAnnotationTool?.(activeTool, toolOptions);
}, [viewerContext?.isAnnotationMode, signatureApiRef, activeTool, buildToolOptions, stampImageData, stampImageSize]);
const activateAnnotationTool = (toolId: AnnotationToolId) => {
// If leaving stamp tool, clean up placement mode
if (activeTool === 'stamp' && toolId !== 'stamp') {
setPlacementMode(false);
setSignatureConfig(null);
}
viewerContext?.setAnnotationMode(true);
// Mark as manual tool switch to prevent auto-switch back
manualToolSwitch.current = true;
// Deselect annotation in the viewer first
annotationApiRef?.current?.deselectAnnotation?.();
// Clear selection state to show default controls
setSelectedAnn(null);
setSelectedAnnId(null);
// Change the tool
setActiveTool(toolId);
const options =
toolId === 'stamp'
? buildToolOptions('stamp', { stampImageData, stampImageSize })
: buildToolOptions(toolId);
// For stamp, apply the image if we have one
annotationApiRef?.current?.setAnnotationStyle?.(toolId, options);
annotationApiRef?.current?.activateAnnotationTool?.(toolId === 'stamp' ? 'stamp' : toolId, options);
// Reset flag after a short delay
setTimeout(() => {
manualToolSwitch.current = false;
}, 300);
};
useEffect(() => {
// push style updates to EmbedPDF when sliders/colors change
if (activeTool === 'stamp') {
const options = buildToolOptions('stamp', { stampImageData, stampImageSize });
annotationApiRef?.current?.setAnnotationStyle?.('stamp', options);
} else {
annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
}
}, [activeTool, buildToolOptions, signatureApiRef, stampImageData, stampImageSize]);
// Sync preview size from overlay to annotation engine
useEffect(() => {
// When preview size changes, update stamp annotation sizing
// The SignatureAPIBridge will use placementPreviewSize from SignatureContext
// and apply the converted size to the stamp tool automatically
if (activeTool === 'stamp' && stampImageData) {
const size = placementPreviewSize ?? stampImageSize;
const stampOptions = buildToolOptions('stamp', { stampImageData, stampImageSize: size ?? null });
annotationApiRef?.current?.setAnnotationStyle?.('stamp', stampOptions);
}
}, [placementPreviewSize, activeTool, stampImageData, signatureApiRef, stampImageSize, cssToPdfSize, buildToolOptions]);
// Allow exiting multi-point tools with Escape (e.g., polyline)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (['polyline', 'polygon'].includes(activeTool)) {
annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
annotationApiRef?.current?.activateAnnotationTool?.(null as any);
setTimeout(() => {
annotationApiRef?.current?.activateAnnotationTool?.(activeTool, buildToolOptions(activeTool));
}, 50);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [activeTool, buildToolOptions, signatureApiRef]);
const deriveToolFromAnnotation = useCallback((annotation: any): AnnotationToolId | undefined => {
if (!annotation) return undefined;
const customToolId = annotation.customData?.toolId || annotation.customData?.annotationToolId;
if (isKnownAnnotationTool(customToolId)) {
return customToolId;
}
const type = annotation.type ?? annotation.object?.type;
switch (type) {
case 3: return 'text'; // FREETEXT
case 4: return 'line'; // LINE
case 5: return 'square'; // SQUARE
case 6: return 'circle'; // CIRCLE
case 7: return 'polygon'; // POLYGON
case 8: return 'polyline'; // POLYLINE
case 9: return 'highlight'; // HIGHLIGHT
case 10: return 'underline'; // UNDERLINE
case 11: return 'squiggly'; // SQUIGGLY
case 12: return 'strikeout'; // STRIKEOUT
case 13: return 'stamp'; // STAMP
case 15: return 'ink'; // INK
default: return undefined;
}
}, []);
const {
selectedAnn,
setSelectedAnn,
setSelectedAnnId,
} = useAnnotationSelection({
annotationApiRef,
deriveToolFromAnnotation,
activeToolRef,
manualToolSwitch,
setActiveTool,
setSelectedTextDraft,
setSelectedFontSize,
setInkWidth,
setShapeThickness,
setTextColor,
setTextBackgroundColor,
setNoteBackgroundColor,
setInkColor,
setHighlightColor,
setHighlightOpacity,
setFreehandHighlighterWidth,
setUnderlineColor,
setUnderlineOpacity,
setStrikeoutColor,
setStrikeoutOpacity,
setSquigglyColor,
setSquigglyOpacity,
setShapeStrokeColor,
setShapeFillColor,
setShapeOpacity,
setShapeStrokeOpacity,
setShapeFillOpacity,
setTextAlignment,
});
const steps =
selectedFiles.length === 0
? []
: [
{
title: t('annotation.title', 'Annotate'),
isCollapsed: false,
onCollapsedClick: undefined,
content: (
<AnnotationPanel
activeTool={activeTool}
activateAnnotationTool={activateAnnotationTool}
styleState={styleState}
styleActions={styleActions}
getActiveColor={getActiveColor}
buildToolOptions={buildToolOptions}
deriveToolFromAnnotation={deriveToolFromAnnotation}
selectedAnn={selectedAnn}
selectedTextDraft={selectedTextDraft}
setSelectedTextDraft={setSelectedTextDraft}
selectedFontSize={selectedFontSize}
setSelectedFontSize={setSelectedFontSize}
annotationApiRef={annotationApiRef}
signatureApiRef={signatureApiRef}
viewerContext={viewerContext}
setPlacementMode={setPlacementMode}
setSignatureConfig={setSignatureConfig}
computeStampDisplaySize={computeStampDisplaySize}
stampImageData={stampImageData}
setStampImageData={setStampImageData}
stampImageSize={stampImageSize}
setStampImageSize={setStampImageSize}
setPlacementPreviewSize={setPlacementPreviewSize}
undo={undo}
redo={redo}
historyAvailability={historyAvailability}
onApplyChanges={handleApplyChanges}
applyDisabled={!hasUnsavedChanges}
/>
),
},
];
return createToolFlow({
files: {
selectedFiles,
isCollapsed: false,
},
steps,
review: {
isVisible: false,
operation: {
files: [],
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl: null,
downloadFilename: '',
isLoading: false,
status: '',
errorMessage: null,
progress: null,
executeOperation: async () => {},
resetResults: () => {},
clearError: () => {},
cancelOperation: () => {},
undoOperation: async () => {},
},
title: '',
onFileClick: () => {},
onUndo: () => {},
},
forceStepNumbers: true,
});
};
export default Annotate;
File diff suppressed because it is too large Load Diff
@@ -1,383 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { AnnotationAPI, AnnotationToolId } from '@app/components/viewer/viewerTypes';
interface UseAnnotationSelectionParams {
annotationApiRef: React.RefObject<AnnotationAPI | null>;
deriveToolFromAnnotation: (annotation: any) => AnnotationToolId | undefined;
activeToolRef: React.MutableRefObject<AnnotationToolId>;
manualToolSwitch: React.MutableRefObject<boolean>;
setActiveTool: (toolId: AnnotationToolId) => void;
setSelectedTextDraft: (text: string) => void;
setSelectedFontSize: (size: number) => void;
setInkWidth: (value: number) => void;
setFreehandHighlighterWidth?: (value: number) => void;
setShapeThickness: (value: number) => void;
setTextColor: (value: string) => void;
setTextBackgroundColor: (value: string) => void;
setNoteBackgroundColor: (value: string) => void;
setInkColor: (value: string) => void;
setHighlightColor: (value: string) => void;
setHighlightOpacity: (value: number) => void;
setUnderlineColor: (value: string) => void;
setUnderlineOpacity: (value: number) => void;
setStrikeoutColor: (value: string) => void;
setStrikeoutOpacity: (value: number) => void;
setSquigglyColor: (value: string) => void;
setSquigglyOpacity: (value: number) => void;
setShapeStrokeColor: (value: string) => void;
setShapeFillColor: (value: string) => void;
setShapeOpacity: (value: number) => void;
setShapeStrokeOpacity: (value: number) => void;
setShapeFillOpacity: (value: number) => void;
setTextAlignment: (value: 'left' | 'center' | 'right') => void;
}
const MARKUP_TOOL_IDS = ['highlight', 'underline', 'strikeout', 'squiggly'] as const;
const DRAWING_TOOL_IDS = ['ink', 'inkHighlighter'] as const;
const isTextMarkupAnnotation = (annotation: any): boolean => {
const toolId =
annotation?.customData?.annotationToolId ||
annotation?.customData?.toolId ||
annotation?.object?.customData?.annotationToolId ||
annotation?.object?.customData?.toolId;
if (toolId && MARKUP_TOOL_IDS.includes(toolId)) return true;
const type = annotation?.type ?? annotation?.object?.type;
if (typeof type === 'number' && [9, 10, 11, 12].includes(type)) return true;
const subtype = annotation?.subtype ?? annotation?.object?.subtype;
if (typeof subtype === 'string') {
const lower = subtype.toLowerCase();
if (MARKUP_TOOL_IDS.some((t) => lower.includes(t))) return true;
}
return false;
};
const shouldStayOnPlacementTool = (annotation: any, derivedTool?: string | null | undefined): boolean => {
const toolId =
derivedTool ||
annotation?.customData?.annotationToolId ||
annotation?.customData?.toolId ||
annotation?.object?.customData?.annotationToolId ||
annotation?.object?.customData?.toolId;
if (toolId && (MARKUP_TOOL_IDS.includes(toolId as any) || DRAWING_TOOL_IDS.includes(toolId as any))) {
return true;
}
const type = annotation?.type ?? annotation?.object?.type;
if (typeof type === 'number' && type === 15) return true; // ink family
if (isTextMarkupAnnotation(annotation)) return true;
return false;
};
export function useAnnotationSelection({
annotationApiRef,
deriveToolFromAnnotation,
activeToolRef,
manualToolSwitch,
setActiveTool,
setSelectedTextDraft,
setSelectedFontSize,
setInkWidth,
setShapeThickness,
setTextColor,
setTextBackgroundColor,
setNoteBackgroundColor,
setInkColor,
setHighlightColor,
setHighlightOpacity,
setUnderlineColor,
setUnderlineOpacity,
setStrikeoutColor,
setStrikeoutOpacity,
setSquigglyColor,
setSquigglyOpacity,
setShapeStrokeColor,
setShapeFillColor,
setShapeOpacity,
setShapeStrokeOpacity,
setShapeFillOpacity,
setTextAlignment,
setFreehandHighlighterWidth,
}: UseAnnotationSelectionParams) {
const [selectedAnn, setSelectedAnn] = useState<any | null>(null);
const [selectedAnnId, setSelectedAnnId] = useState<string | null>(null);
const selectedAnnIdRef = useRef<string | null>(null);
const applySelectionFromAnnotation = useCallback(
(ann: any | null) => {
const annObject = ann?.object ?? ann ?? null;
const annId = annObject?.id ?? null;
const type = annObject?.type;
const derivedTool = annObject ? deriveToolFromAnnotation(annObject) : undefined;
selectedAnnIdRef.current = annId;
setSelectedAnnId(annId);
// Normalize selected annotation to always expose .object for edit panels
const normalizedSelection = ann?.object ? ann : annObject ? { object: annObject } : null;
setSelectedAnn(normalizedSelection);
if (annObject?.contents !== undefined) {
setSelectedTextDraft(annObject.contents ?? '');
}
if (annObject?.fontSize !== undefined) {
setSelectedFontSize(annObject.fontSize ?? 14);
}
if (annObject?.textAlign !== undefined) {
const align = annObject.textAlign;
if (typeof align === 'string') {
const normalized = align === 'center' ? 'center' : align === 'right' ? 'right' : 'left';
setTextAlignment(normalized);
} else if (typeof align === 'number') {
const normalized = align === 1 ? 'center' : align === 2 ? 'right' : 'left';
setTextAlignment(normalized);
}
}
if (type === 3) {
const background =
(annObject?.backgroundColor as string | undefined) ||
(annObject?.fillColor as string | undefined) ||
undefined;
const textColor = (annObject?.textColor as string | undefined) || (annObject?.color as string | undefined);
if (textColor) {
setTextColor(textColor);
}
if (derivedTool === 'note') {
setNoteBackgroundColor(background || '');
} else {
setTextBackgroundColor(background || '');
}
}
if (type === 15) {
const width =
annObject?.strokeWidth ?? annObject?.borderWidth ?? annObject?.lineWidth ?? annObject?.thickness;
if (derivedTool === 'inkHighlighter') {
if (annObject?.color) setHighlightColor(annObject.color);
if (annObject?.opacity !== undefined) {
setHighlightOpacity(Math.round((annObject.opacity ?? 1) * 100));
}
if (width !== undefined && setFreehandHighlighterWidth) {
setFreehandHighlighterWidth(width);
}
} else {
if (width !== undefined) setInkWidth(width ?? 2);
if (annObject?.color) {
setInkColor(annObject.color);
}
}
} else if (type >= 4 && type <= 8) {
const width = annObject?.strokeWidth ?? annObject?.borderWidth ?? annObject?.lineWidth;
if (width !== undefined) {
setShapeThickness(width ?? 1);
}
}
if (type === 9) {
if (annObject?.color) setHighlightColor(annObject.color);
if (annObject?.opacity !== undefined) setHighlightOpacity(Math.round((annObject.opacity ?? 1) * 100));
} else if (type === 10) {
if (annObject?.color) setUnderlineColor(annObject.color);
if (annObject?.opacity !== undefined) setUnderlineOpacity(Math.round((annObject.opacity ?? 1) * 100));
} else if (type === 12) {
if (annObject?.color) setStrikeoutColor(annObject.color);
if (annObject?.opacity !== undefined) setStrikeoutOpacity(Math.round((annObject.opacity ?? 1) * 100));
} else if (type === 11) {
if (annObject?.color) setSquigglyColor(annObject.color);
if (annObject?.opacity !== undefined) setSquigglyOpacity(Math.round((annObject.opacity ?? 1) * 100));
}
if ([4, 5, 6, 7, 8].includes(type)) {
const stroke = (annObject?.strokeColor as string | undefined) ?? (annObject?.color as string | undefined);
if (stroke) setShapeStrokeColor(stroke);
if ([5, 6, 7].includes(type)) {
const fill = (annObject?.color as string | undefined) ?? (annObject?.fillColor as string | undefined);
if (fill) setShapeFillColor(fill);
}
const opacity =
annObject?.opacity !== undefined ? Math.round((annObject.opacity ?? 1) * 100) : undefined;
const strokeOpacityValue =
annObject?.strokeOpacity !== undefined
? Math.round((annObject.strokeOpacity ?? 1) * 100)
: undefined;
const fillOpacityValue =
annObject?.fillOpacity !== undefined ? Math.round((annObject.fillOpacity ?? 1) * 100) : undefined;
if (opacity !== undefined) {
setShapeOpacity(opacity);
setShapeStrokeOpacity(strokeOpacityValue ?? opacity);
setShapeFillOpacity(fillOpacityValue ?? opacity);
} else {
if (strokeOpacityValue !== undefined) setShapeStrokeOpacity(strokeOpacityValue);
if (fillOpacityValue !== undefined) setShapeFillOpacity(fillOpacityValue);
}
}
const matchingTool = derivedTool;
const stayOnPlacement = shouldStayOnPlacementTool(annObject, matchingTool);
if (matchingTool && activeToolRef.current !== 'select' && !stayOnPlacement) {
activeToolRef.current = 'select';
setActiveTool('select');
// Immediately enable select tool to avoid re-entering placement after creation.
annotationApiRef.current?.activateAnnotationTool?.('select');
} else if (activeToolRef.current === 'select') {
// Keep the viewer in Select mode so clicking existing annotations does not re-enable placement.
annotationApiRef.current?.activateAnnotationTool?.('select');
}
},
[
activeToolRef,
deriveToolFromAnnotation,
manualToolSwitch,
setActiveTool,
setInkWidth,
setNoteBackgroundColor,
setSelectedFontSize,
setSelectedTextDraft,
setShapeThickness,
setTextBackgroundColor,
setTextColor,
setInkColor,
setHighlightColor,
setHighlightOpacity,
setUnderlineColor,
setUnderlineOpacity,
setStrikeoutColor,
setStrikeoutOpacity,
setSquigglyColor,
setSquigglyOpacity,
setShapeStrokeColor,
setShapeFillColor,
setShapeOpacity,
setShapeStrokeOpacity,
setShapeFillOpacity,
setTextAlignment,
setFreehandHighlighterWidth,
shouldStayOnPlacementTool,
]
);
useEffect(() => {
const api = annotationApiRef.current as any;
if (!api) return;
const checkSelection = () => {
let ann: any = null;
if (typeof api.getSelectedAnnotation === 'function') {
try {
ann = api.getSelectedAnnotation();
} catch (error) {
// Some builds of the annotation plugin can throw when reading
// internal selection state (e.g., accessing `selectedUid` on
// an undefined object). Treat this as "no current selection"
// instead of crashing the annotations tool.
console.error('[useAnnotationSelection] getSelectedAnnotation failed:', error);
ann = null;
}
}
const currentId = ann?.object?.id ?? ann?.id ?? null;
if (currentId !== selectedAnnIdRef.current) {
applySelectionFromAnnotation(ann ?? null);
}
};
let interval: ReturnType<typeof setInterval> | null = null;
if (typeof api.onAnnotationEvent === 'function') {
const handler = (event: any) => {
const ann = event?.annotation ?? event?.selectedAnnotation ?? null;
const eventType = event?.type;
switch (eventType) {
case 'create':
case 'add':
case 'added':
case 'created':
case 'annotationCreated':
case 'annotationAdded':
case 'complete': {
const eventAnn = ann ?? api.getSelectedAnnotation?.();
applySelectionFromAnnotation(eventAnn);
const currentTool = activeToolRef.current;
const tool =
deriveToolFromAnnotation((eventAnn as any)?.object ?? eventAnn ?? api.getSelectedAnnotation?.()) ||
currentTool;
const stayOnPlacement =
shouldStayOnPlacementTool(eventAnn, tool) ||
(tool ? DRAWING_TOOL_IDS.includes(tool as any) : false);
if (activeToolRef.current !== 'select' && !stayOnPlacement) {
activeToolRef.current = 'select';
setActiveTool('select');
annotationApiRef.current?.activateAnnotationTool?.('select');
}
// Re-read selection after the viewer updates to ensure we have the full annotation object for the edit panel.
setTimeout(() => {
const selected = api.getSelectedAnnotation?.();
applySelectionFromAnnotation(selected ?? eventAnn ?? null);
const derivedAfter =
deriveToolFromAnnotation((selected as any)?.object ?? selected ?? eventAnn ?? null) || activeToolRef.current;
const stayOnPlacementAfter =
shouldStayOnPlacementTool(selected ?? eventAnn ?? null, derivedAfter) ||
(derivedAfter ? DRAWING_TOOL_IDS.includes(derivedAfter as any) : false);
if (activeToolRef.current !== 'select' && !stayOnPlacementAfter) {
activeToolRef.current = 'select';
setActiveTool('select');
annotationApiRef.current?.activateAnnotationTool?.('select');
}
}, 50);
break;
}
case 'select':
case 'selected':
case 'annotationSelected':
case 'annotationClicked':
case 'annotationTapped':
applySelectionFromAnnotation(ann ?? api.getSelectedAnnotation?.());
break;
case 'deselect':
case 'clearSelection':
applySelectionFromAnnotation(null);
break;
case 'delete':
case 'remove':
if (ann?.id && ann.id === selectedAnnIdRef.current) {
applySelectionFromAnnotation(null);
}
break;
case 'update':
case 'change':
if (selectedAnnIdRef.current) {
const current = api.getSelectedAnnotation?.();
if (current) {
applySelectionFromAnnotation(current);
}
}
break;
default:
break;
}
};
const unsubscribe = api.onAnnotationEvent(handler);
interval = setInterval(checkSelection, 450);
return () => {
if (typeof unsubscribe === 'function') {
unsubscribe();
}
if (interval) clearInterval(interval);
};
}
interval = setInterval(checkSelection, 350);
return () => {
if (interval) clearInterval(interval);
};
}, [annotationApiRef, applySelectionFromAnnotation]);
return {
selectedAnn,
selectedAnnId,
selectedAnnIdRef,
setSelectedAnn,
setSelectedAnnId,
applySelectionFromAnnotation,
};
}
@@ -1,325 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import type { AnnotationToolId } from '@app/components/viewer/viewerTypes';
type Size = { width: number; height: number };
export type BuildToolOptionsExtras = {
includeMetadata?: boolean;
stampImageData?: string;
stampImageSize?: Size | null;
};
interface StyleState {
inkColor: string;
inkWidth: number;
highlightColor: string;
highlightOpacity: number;
freehandHighlighterWidth: number;
underlineColor: string;
underlineOpacity: number;
strikeoutColor: string;
strikeoutOpacity: number;
squigglyColor: string;
squigglyOpacity: number;
textColor: string;
textSize: number;
textAlignment: 'left' | 'center' | 'right';
textBackgroundColor: string;
noteBackgroundColor: string;
shapeStrokeColor: string;
shapeFillColor: string;
shapeOpacity: number;
shapeStrokeOpacity: number;
shapeFillOpacity: number;
shapeThickness: number;
}
interface StyleActions {
setInkColor: (value: string) => void;
setInkWidth: (value: number) => void;
setHighlightColor: (value: string) => void;
setHighlightOpacity: (value: number) => void;
setFreehandHighlighterWidth: (value: number) => void;
setUnderlineColor: (value: string) => void;
setUnderlineOpacity: (value: number) => void;
setStrikeoutColor: (value: string) => void;
setStrikeoutOpacity: (value: number) => void;
setSquigglyColor: (value: string) => void;
setSquigglyOpacity: (value: number) => void;
setTextColor: (value: string) => void;
setTextSize: (value: number) => void;
setTextAlignment: (value: 'left' | 'center' | 'right') => void;
setTextBackgroundColor: (value: string) => void;
setNoteBackgroundColor: (value: string) => void;
setShapeStrokeColor: (value: string) => void;
setShapeFillColor: (value: string) => void;
setShapeOpacity: (value: number) => void;
setShapeStrokeOpacity: (value: number) => void;
setShapeFillOpacity: (value: number) => void;
setShapeThickness: (value: number) => void;
}
export type BuildToolOptionsFn = (
toolId: AnnotationToolId,
extras?: BuildToolOptionsExtras
) => Record<string, unknown>;
export interface AnnotationStyleStateReturn {
styleState: StyleState;
styleActions: StyleActions;
buildToolOptions: BuildToolOptionsFn;
getActiveColor: (target: string | null) => string;
}
export const useAnnotationStyleState = (
cssToPdfSize?: (size: Size) => Size
): AnnotationStyleStateReturn => {
const [inkColor, setInkColor] = useState('#1f2933');
const [inkWidth, setInkWidth] = useState(2);
const [highlightColor, setHighlightColor] = useState('#ffd54f');
const [highlightOpacity, setHighlightOpacity] = useState(60);
const [freehandHighlighterWidth, setFreehandHighlighterWidth] = useState(6);
const [underlineColor, setUnderlineColor] = useState('#ffb300');
const [underlineOpacity, setUnderlineOpacity] = useState(100);
const [strikeoutColor, setStrikeoutColor] = useState('#e53935');
const [strikeoutOpacity, setStrikeoutOpacity] = useState(100);
const [squigglyColor, setSquigglyColor] = useState('#00acc1');
const [squigglyOpacity, setSquigglyOpacity] = useState(100);
const [textColor, setTextColor] = useState('#111111');
const [textSize, setTextSize] = useState(14);
const [textAlignment, setTextAlignment] = useState<'left' | 'center' | 'right'>('left');
const [textBackgroundColor, setTextBackgroundColor] = useState<string>('');
const [noteBackgroundColor, setNoteBackgroundColor] = useState('#ffd54f');
const [shapeStrokeColor, setShapeStrokeColor] = useState('#cf5b5b');
const [shapeFillColor, setShapeFillColor] = useState('#0000ff');
const [shapeOpacity, setShapeOpacity] = useState(50);
const [shapeStrokeOpacity, setShapeStrokeOpacity] = useState(50);
const [shapeFillOpacity, setShapeFillOpacity] = useState(50);
const [shapeThickness, setShapeThickness] = useState(2);
const buildToolOptions = useCallback<BuildToolOptionsFn>(
(toolId, extras) => {
const includeMetadata = extras?.includeMetadata ?? true;
const metadata = includeMetadata
? {
customData: {
toolId,
annotationToolId: toolId,
source: 'annotate',
author: 'User',
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
},
}
: {};
switch (toolId) {
case 'ink':
return { color: inkColor, thickness: inkWidth, ...metadata };
case 'inkHighlighter':
return {
color: highlightColor,
opacity: highlightOpacity / 100,
thickness: freehandHighlighterWidth,
...metadata,
};
case 'highlight':
return { color: highlightColor, opacity: highlightOpacity / 100, ...metadata };
case 'underline':
return { color: underlineColor, opacity: underlineOpacity / 100, ...metadata };
case 'strikeout':
return { color: strikeoutColor, opacity: strikeoutOpacity / 100, ...metadata };
case 'squiggly':
return { color: squigglyColor, opacity: squigglyOpacity / 100, ...metadata };
case 'text': {
const textAlignNumber = textAlignment === 'left' ? 0 : textAlignment === 'center' ? 1 : 2;
return {
color: textColor,
textColor: textColor,
fontSize: textSize,
textAlign: textAlignNumber,
...(textBackgroundColor ? { fillColor: textBackgroundColor } : {}),
...metadata,
};
}
case 'note': {
const noteFillColor = noteBackgroundColor || 'transparent';
return {
color: textColor,
fillColor: noteFillColor,
opacity: 1,
...metadata,
};
}
case 'square':
case 'circle':
case 'polygon':
return {
color: shapeFillColor,
strokeColor: shapeStrokeColor,
opacity: shapeOpacity / 100,
strokeOpacity: shapeStrokeOpacity / 100,
fillOpacity: shapeFillOpacity / 100,
borderWidth: shapeThickness,
...metadata,
};
case 'line':
case 'polyline':
case 'lineArrow':
return {
color: shapeStrokeColor,
strokeColor: shapeStrokeColor,
opacity: shapeStrokeOpacity / 100,
borderWidth: shapeThickness,
...metadata,
};
case 'stamp': {
const pdfSize =
extras?.stampImageSize && cssToPdfSize ? cssToPdfSize(extras.stampImageSize) : undefined;
return {
imageSrc: extras?.stampImageData,
...(pdfSize ? { imageSize: pdfSize } : {}),
...metadata,
};
}
default:
return { ...metadata };
}
},
[
cssToPdfSize,
freehandHighlighterWidth,
highlightColor,
highlightOpacity,
inkColor,
inkWidth,
noteBackgroundColor,
shapeFillColor,
shapeFillOpacity,
shapeOpacity,
shapeStrokeColor,
shapeStrokeOpacity,
shapeThickness,
squigglyColor,
squigglyOpacity,
strikeoutColor,
strikeoutOpacity,
textAlignment,
textBackgroundColor,
textColor,
textSize,
underlineColor,
underlineOpacity,
]
);
const getActiveColor = useCallback(
(target: string | null) => {
if (target === 'ink') return inkColor;
if (target === 'highlight' || target === 'inkHighlighter') return highlightColor;
if (target === 'underline') return underlineColor;
if (target === 'strikeout') return strikeoutColor;
if (target === 'squiggly') return squigglyColor;
if (target === 'shapeStroke') return shapeStrokeColor;
if (target === 'shapeFill') return shapeFillColor;
if (target === 'textBackground') return textBackgroundColor || '#ffffff';
if (target === 'noteBackground') return noteBackgroundColor || '#ffffff';
return textColor;
},
[
highlightColor,
inkColor,
noteBackgroundColor,
shapeFillColor,
shapeStrokeColor,
squigglyColor,
strikeoutColor,
textBackgroundColor,
textColor,
underlineColor,
]
);
const styleState: StyleState = useMemo(
() => ({
inkColor,
inkWidth,
highlightColor,
highlightOpacity,
freehandHighlighterWidth,
underlineColor,
underlineOpacity,
strikeoutColor,
strikeoutOpacity,
squigglyColor,
squigglyOpacity,
textColor,
textSize,
textAlignment,
textBackgroundColor,
noteBackgroundColor,
shapeStrokeColor,
shapeFillColor,
shapeOpacity,
shapeStrokeOpacity,
shapeFillOpacity,
shapeThickness,
}),
[
freehandHighlighterWidth,
highlightColor,
highlightOpacity,
inkColor,
inkWidth,
noteBackgroundColor,
shapeFillColor,
shapeFillOpacity,
shapeOpacity,
shapeStrokeColor,
shapeStrokeOpacity,
shapeThickness,
squigglyColor,
squigglyOpacity,
strikeoutColor,
strikeoutOpacity,
textAlignment,
textBackgroundColor,
textColor,
textSize,
underlineColor,
underlineOpacity,
]
);
const styleActions: StyleActions = {
setInkColor,
setInkWidth,
setHighlightColor,
setHighlightOpacity,
setFreehandHighlighterWidth,
setUnderlineColor,
setUnderlineOpacity,
setStrikeoutColor,
setStrikeoutOpacity,
setSquigglyColor,
setSquigglyOpacity,
setTextColor,
setTextSize,
setTextAlignment,
setTextBackgroundColor,
setNoteBackgroundColor,
setShapeStrokeColor,
setShapeFillColor,
setShapeOpacity,
setShapeStrokeOpacity,
setShapeFillOpacity,
setShapeThickness,
};
return {
styleState,
styleActions,
buildToolOptions,
getActiveColor,
};
};
-1
View File
@@ -25,7 +25,6 @@ export const CORE_REGULAR_TOOL_IDS = [
'ocr',
'addImage',
'rotate',
'annotate',
'scannerImageSplit',
'editTableOfContents',
'scannerEffect',
-2
View File
@@ -70,8 +70,6 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
'/scanner-image-split': 'scannerImageSplit',
// Annotation and content removal
'/annotations': 'annotate',
'/annotate': 'annotate',
'/remove-annotations': 'removeAnnotations',
'/remove-image': 'removeImage',
@@ -14,7 +14,6 @@ interface SaaSLoginScreenProps {
onLogin: (username: string, password: string) => Promise<void>;
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
onSelfHostedClick: () => void;
onSwitchToSignup: () => void;
loading: boolean;
error: string | null;
}
@@ -24,7 +23,6 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
onLogin,
onOAuthSuccess,
onSelfHostedClick,
onSwitchToSignup,
loading,
error,
}) => {
@@ -91,20 +89,6 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
submitButtonText={t('setup.login.submit', 'Login')}
/>
<div className="navigation-link-container" style={{ marginTop: '0.5rem', textAlign: 'right' }}>
<button
type="button"
onClick={() => {
setValidationError(null);
onSwitchToSignup();
}}
className="navigation-link-button"
disabled={loading}
>
{t('signup.signUp', 'Sign Up')}
</button>
</div>
<SelfHostedLink onClick={onSelfHostedClick} disabled={loading} />
</>
);
@@ -1,104 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage';
import SignupForm from '@app/routes/signup/SignupForm';
import { useSignupFormValidation, SignupFieldErrors } from '@app/routes/signup/SignupFormValidation';
import { authService } from '@app/services/authService';
import '@app/routes/authShared/auth.css';
interface SaaSSignupScreenProps {
loading: boolean;
error: string | null;
onLogin: (username: string, password: string) => Promise<void>;
onSwitchToLogin: () => void;
}
export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
loading,
error,
onLogin: _onLogin,
onSwitchToLogin: _onSwitchToLogin,
}) => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [validationError, setValidationError] = useState<string | null>(null);
const [signupFieldErrors, setSignupFieldErrors] = useState<SignupFieldErrors>({});
const [signupSuccessMessage, setSignupSuccessMessage] = useState<string | null>(null);
const [isSignupSubmitting, setIsSignupSubmitting] = useState(false);
const { validateSignupForm } = useSignupFormValidation();
const displayError = error || validationError;
const handleSignupSubmit = async () => {
setValidationError(null);
setSignupSuccessMessage(null);
setSignupFieldErrors({});
const validation = validateSignupForm(email, password, confirmPassword);
if (!validation.isValid) {
setValidationError(validation.error);
setSignupFieldErrors(validation.fieldErrors || {});
return;
}
try {
setIsSignupSubmitting(true);
await authService.signUpSaas(email.trim(), password);
setSignupSuccessMessage(t('signup.checkEmailConfirmation', 'Check your email for a confirmation link to complete your registration.'));
setSignupFieldErrors({});
setValidationError(null);
} catch (err) {
setSignupSuccessMessage(null);
const message = err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' });
setValidationError(message);
} finally {
setIsSignupSubmitting(false);
}
};
return (
<>
<LoginHeader
title={t('signup.title', 'Create an account')}
subtitle={t('signup.subtitle', 'Join Stirling PDF')}
/>
<ErrorMessage error={displayError} />
{signupSuccessMessage && (
<div className="success-message">
<p className="success-message-text">{signupSuccessMessage}</p>
</div>
)}
<SignupForm
email={email}
password={password}
confirmPassword={confirmPassword}
setEmail={(value) => {
setEmail(value);
setValidationError(null);
setSignupFieldErrors({});
}}
setPassword={(value) => {
setPassword(value);
setValidationError(null);
setSignupFieldErrors({});
}}
setConfirmPassword={(value) => {
setConfirmPassword(value);
setValidationError(null);
setSignupFieldErrors({});
}}
onSubmit={handleSignupSubmit}
isSubmitting={loading || isSignupSubmitting}
fieldErrors={signupFieldErrors}
showName={false}
showTerms={false}
/>
</>
);
};
@@ -2,20 +2,16 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen';
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService';
import { authService, UserInfo } from '@app/services/authService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
import { listen } from '@tauri-apps/api/event';
import { useEffect } from 'react';
import '@app/routes/authShared/auth.css';
enum SetupStep {
SaaSLogin,
SaaSSignup,
ServerSelection,
SelfHostedLogin,
}
@@ -84,16 +80,6 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
setActiveStep(SetupStep.ServerSelection);
};
const handleSwitchToSignup = () => {
setError(null);
setActiveStep(SetupStep.SaaSSignup);
};
const handleSwitchToLogin = () => {
setError(null);
setActiveStep(SetupStep.SaaSLogin);
};
const handleServerSelection = (config: ServerConfig) => {
setServerConfig(config);
setError(null);
@@ -142,48 +128,6 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
}
};
useEffect(() => {
const unsubscribePromise = listen<string>('deep-link', async (event) => {
const url = event.payload;
if (!url) return;
try {
const parsed = new URL(url);
// Supabase sends tokens in the URL hash
const hash = parsed.hash.replace(/^#/, '');
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const type = params.get('type') || parsed.searchParams.get('type');
if (!type || (type !== 'signup' && type !== 'recovery' && type !== 'magiclink')) {
return;
}
if (!accessToken) {
console.error('[SetupWizard] Deep link missing access_token');
return;
}
setLoading(true);
setError(null);
await authService.completeSupabaseSession(accessToken, serverConfig?.url || STIRLING_SAAS_URL);
await connectionModeService.switchToSaaS(serverConfig?.url || STIRLING_SAAS_URL);
tauriBackendService.startBackend().catch(console.error);
onComplete();
} catch (err) {
console.error('[SetupWizard] Failed to handle deep link', err);
setError(err instanceof Error ? err.message : 'Failed to complete signup');
setLoading(false);
}
});
return () => {
void unsubscribePromise.then((unsub) => unsub());
};
}, [onComplete, serverConfig?.url]);
const handleBack = () => {
setError(null);
if (activeStep === SetupStep.SelfHostedLogin) {
@@ -191,8 +135,6 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
} else if (activeStep === SetupStep.ServerSelection) {
setActiveStep(SetupStep.SaaSLogin);
setServerConfig({ url: STIRLING_SAAS_URL });
} else if (activeStep === SetupStep.SaaSSignup) {
setActiveStep(SetupStep.SaaSLogin);
}
};
@@ -205,21 +147,11 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
onLogin={handleSaaSLogin}
onOAuthSuccess={handleSaaSLoginOAuth}
onSelfHostedClick={handleSelfHostedClick}
onSwitchToSignup={handleSwitchToSignup}
loading={loading}
error={error}
/>
)}
{activeStep === SetupStep.SaaSSignup && (
<SaaSSignupScreen
loading={loading}
error={error}
onLogin={handleSaaSLogin}
onSwitchToLogin={handleSwitchToLogin}
/>
)}
{activeStep === SetupStep.ServerSelection && (
<ServerSelectionScreen
onSelect={handleServerSelection}
@@ -6,12 +6,6 @@
// The SaaS authentication server
export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL || '';
// SaaS signup URL for creating new cloud accounts
export const STIRLING_SAAS_SIGNUP_URL: string = import.meta.env.VITE_SAAS_SIGNUP_URL || '';
// Supabase publishable key from environment variable
// Used for SaaS authentication
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb';
// Desktop deep link callback for Supabase email confirmations
export const DESKTOP_DEEP_LINK_CALLBACK = 'stirlingpdf://auth/callback';
+1 -62
View File
@@ -1,6 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import axios from 'axios';
import { DESKTOP_DEEP_LINK_CALLBACK, STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
import { STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
export interface UserInfo {
username: string;
@@ -131,67 +131,6 @@ export class AuthService {
this.notifyListeners();
}
async completeSupabaseSession(accessToken: string, serverUrl: string): Promise<UserInfo> {
if (!accessToken || !accessToken.trim()) {
throw new Error('Invalid access token');
}
if (!SUPABASE_KEY) {
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
}
await this.saveTokenEverywhere(accessToken);
const userInfo = await this.fetchSupabaseUserInfo(serverUrl, accessToken);
await invoke('save_user_info', {
username: userInfo.username,
email: userInfo.email || null,
});
this.setAuthStatus('authenticated', userInfo);
return userInfo;
}
async signUpSaas(email: string, password: string): Promise<void> {
if (!STIRLING_SAAS_URL) {
throw new Error('VITE_SAAS_SERVER_URL is not configured');
}
if (!SUPABASE_KEY) {
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
}
const redirectParam = encodeURIComponent(DESKTOP_DEEP_LINK_CALLBACK);
const signupUrl = `${STIRLING_SAAS_URL.replace(/\/$/, '')}/auth/v1/signup?redirect_to=${redirectParam}`;
try {
const response = await axios.post(
signupUrl,
{ email, password, email_redirect_to: DESKTOP_DEEP_LINK_CALLBACK },
{
headers: {
'Content-Type': 'application/json;charset=UTF-8',
apikey: SUPABASE_KEY,
Authorization: `Bearer ${SUPABASE_KEY}`,
},
}
);
if (response.status >= 400) {
throw new Error('Sign up failed');
}
} catch (error) {
if (axios.isAxiosError(error)) {
const message =
error.response?.data?.error_description ||
error.response?.data?.msg ||
error.response?.data?.message ||
error.message;
throw new Error(message || 'Sign up failed');
}
throw error instanceof Error ? error : new Error('Sign up failed');
}
}
async login(serverUrl: string, username: string, password: string): Promise<UserInfo> {
try {
console.log('Logging in to:', serverUrl);