mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
Compare commits
No commits in common. "main" and "v2.1.4" have entirely different histories.
@ -37,6 +37,10 @@ public class AppConfig {
|
|||||||
|
|
||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Value("${baseUrl:http://localhost}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@Value("${server.servlet.context-path:/}")
|
@Value("${server.servlet.context-path:/}")
|
||||||
private String contextPath;
|
private String contextPath;
|
||||||
@ -45,17 +49,6 @@ public class AppConfig {
|
|||||||
@Value("${server.port:8080}")
|
@Value("${server.port:8080}")
|
||||||
private String serverPort;
|
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}")
|
@Value("${v2}")
|
||||||
public boolean v2Enabled;
|
public boolean v2Enabled;
|
||||||
|
|
||||||
|
|||||||
@ -138,13 +138,13 @@ public class SPDFApplication {
|
|||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
String backendUrl = appConfig.getBackendUrl();
|
String baseUrl = appConfig.getBaseUrl();
|
||||||
String contextPath = appConfig.getContextPath();
|
String contextPath = appConfig.getContextPath();
|
||||||
String serverPort = appConfig.getServerPort();
|
String serverPort = appConfig.getServerPort();
|
||||||
baseUrlStatic = backendUrl;
|
baseUrlStatic = baseUrl;
|
||||||
contextPathStatic = contextPath;
|
contextPathStatic = contextPath;
|
||||||
serverPortStatic = serverPort;
|
serverPortStatic = serverPort;
|
||||||
String url = backendUrl + ":" + getStaticPort() + contextPath;
|
String url = baseUrl + ":" + getStaticPort() + contextPath;
|
||||||
|
|
||||||
// Log Tauri mode information
|
// Log Tauri mode information
|
||||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
|
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
|
||||||
|
|||||||
@ -33,35 +33,14 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
|||||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||||
// Cache hashed assets (JS/CSS with content hashes) for 1 year
|
// Cache hashed assets (JS/CSS with content hashes) for 1 year
|
||||||
// These files have names like index-ChAS4tCC.js that change when content changes
|
// 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/**")
|
registry.addResourceHandler("/assets/**")
|
||||||
.addResourceLocations(
|
.addResourceLocations("classpath:/static/assets/")
|
||||||
"file:"
|
|
||||||
+ stirling.software.common.configuration.InstallationPathConfig
|
|
||||||
.getStaticPath()
|
|
||||||
+ "assets/",
|
|
||||||
"classpath:/static/assets/")
|
|
||||||
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
|
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
|
||||||
|
|
||||||
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
|
// 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")
|
registry.addResourceHandler("/index.html")
|
||||||
.addResourceLocations(
|
.addResourceLocations("classpath:/static/")
|
||||||
"file:"
|
|
||||||
+ stirling.software.common.configuration.InstallationPathConfig
|
|
||||||
.getStaticPath(),
|
|
||||||
"classpath:/static/")
|
|
||||||
.setCacheControl(CacheControl.noCache().mustRevalidate());
|
.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
|
@Override
|
||||||
|
|||||||
@ -66,8 +66,7 @@ public class ConfigController {
|
|||||||
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
|
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
|
||||||
|
|
||||||
// Extract key configuration values from AppConfig
|
// Extract key configuration values from AppConfig
|
||||||
// Note: Frontend expects "baseUrl" field name for compatibility
|
configData.put("baseUrl", appConfig.getBaseUrl());
|
||||||
configData.put("baseUrl", appConfig.getBackendUrl());
|
|
||||||
configData.put("contextPath", appConfig.getContextPath());
|
configData.put("contextPath", appConfig.getContextPath());
|
||||||
configData.put("serverPort", appConfig.getServerPort());
|
configData.put("serverPort", appConfig.getServerPort());
|
||||||
|
|
||||||
|
|||||||
@ -3,14 +3,9 @@ package stirling.software.SPDF.controller.web;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
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.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
@ -19,11 +14,6 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.common.configuration.InstallationPathConfig;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Controller
|
@Controller
|
||||||
public class ReactRoutingController {
|
public class ReactRoutingController {
|
||||||
|
|
||||||
@ -32,44 +22,24 @@ public class ReactRoutingController {
|
|||||||
|
|
||||||
private String cachedIndexHtml;
|
private String cachedIndexHtml;
|
||||||
private boolean indexHtmlExists = false;
|
private boolean indexHtmlExists = false;
|
||||||
private boolean useExternalIndexHtml = false;
|
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath());
|
// Only cache if index.html exists (production builds)
|
||||||
|
|
||||||
// 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
|
|
||||||
ClassPathResource resource = new ClassPathResource("static/index.html");
|
ClassPathResource resource = new ClassPathResource("static/index.html");
|
||||||
if (resource.exists()) {
|
if (resource.exists()) {
|
||||||
try {
|
try {
|
||||||
this.cachedIndexHtml = processIndexHtml();
|
this.cachedIndexHtml = processIndexHtml();
|
||||||
this.indexHtmlExists = true;
|
this.indexHtmlExists = true;
|
||||||
this.useExternalIndexHtml = false;
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
// Failed to cache, will process on each request
|
// Failed to cache, will process on each request
|
||||||
log.warn("Failed to cache index.html", e);
|
|
||||||
this.indexHtmlExists = false;
|
this.indexHtmlExists = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String processIndexHtml() throws IOException {
|
private String processIndexHtml() throws IOException {
|
||||||
Resource resource = getIndexHtmlResource();
|
ClassPathResource resource = new ClassPathResource("static/index.html");
|
||||||
|
|
||||||
try (InputStream inputStream = resource.getInputStream()) {
|
try (InputStream inputStream = resource.getInputStream()) {
|
||||||
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
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(
|
@GetMapping(
|
||||||
value = {"/", "/index.html"},
|
value = {"/", "/index.html"},
|
||||||
produces = MediaType.TEXT_HTML_VALUE)
|
produces = MediaType.TEXT_HTML_VALUE)
|
||||||
|
|||||||
@ -198,7 +198,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
|||||||
private SamlClient getSamlClient(
|
private SamlClient getSamlClient(
|
||||||
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
|
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
|
||||||
throws SamlException {
|
throws SamlException {
|
||||||
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
|
String serverUrl = appConfig.getBaseUrl() + ":" + appConfig.getServerPort();
|
||||||
|
|
||||||
String relyingPartyIdentifier =
|
String relyingPartyIdentifier =
|
||||||
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
|
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
|
||||||
|
|||||||
@ -344,8 +344,7 @@ public class SecurityConfiguration {
|
|||||||
log.error("Error configuring SAML 2 login", e);
|
log.error("Error configuring SAML 2 login", e);
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.saml2Metadata(metadata -> {});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.debug("Login is not enabled.");
|
log.debug("Login is not enabled.");
|
||||||
|
|||||||
@ -102,31 +102,16 @@ public class Saml2Configuration {
|
|||||||
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
|
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
|
||||||
throw new IllegalStateException("Failed to load SAML2 SP credentials", 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 rp =
|
||||||
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
|
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
|
||||||
.signingX509Credentials(c -> c.add(signingCredential))
|
.signingX509Credentials(c -> c.add(signingCredential))
|
||||||
.entityId(entityId)
|
.entityId(samlConf.getIdpIssuer())
|
||||||
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
|
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
|
||||||
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
|
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
|
||||||
.singleLogoutServiceResponseLocation(sloResponseLocation)
|
.singleLogoutServiceResponseLocation("{baseUrl}/login")
|
||||||
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
|
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
|
||||||
.assertionConsumerServiceLocation(acsLocation)
|
.assertionConsumerServiceLocation(
|
||||||
|
"{baseUrl}/login/saml2/sso/{registrationId}")
|
||||||
.authnRequestsSigned(true)
|
.authnRequestsSigned(true)
|
||||||
.assertingPartyMetadata(
|
.assertingPartyMetadata(
|
||||||
metadata ->
|
metadata ->
|
||||||
@ -142,7 +127,7 @@ public class Saml2Configuration {
|
|||||||
.singleLogoutServiceLocation(
|
.singleLogoutServiceLocation(
|
||||||
samlConf.getIdpSingleLogoutUrl())
|
samlConf.getIdpSingleLogoutUrl())
|
||||||
.singleLogoutServiceResponseLocation(
|
.singleLogoutServiceResponseLocation(
|
||||||
sloResponseLocation)
|
"{baseUrl}/login")
|
||||||
.wantAuthnRequestsSigned(true))
|
.wantAuthnRequestsSigned(true))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
@ -51,8 +51,7 @@ class UserLicenseSettingsServiceTest {
|
|||||||
|
|
||||||
when(applicationProperties.getPremium()).thenReturn(premium);
|
when(applicationProperties.getPremium()).thenReturn(premium);
|
||||||
when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated);
|
when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated);
|
||||||
when(automaticallyGenerated.getIsNewServer())
|
when(automaticallyGenerated.getIsNewServer()).thenReturn(false); // Default: not a new server
|
||||||
.thenReturn(false); // Default: not a new server
|
|
||||||
when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
|
when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
|
||||||
when(userService.getTotalUsersCount()).thenReturn(80L);
|
when(userService.getTotalUsersCount()).thenReturn(80L);
|
||||||
when(settingsRepository.save(any(UserLicenseSettings.class)))
|
when(settingsRepository.save(any(UserLicenseSettings.class)))
|
||||||
|
|||||||
@ -736,11 +736,6 @@ tags = "signature,autograph"
|
|||||||
title = "Sign"
|
title = "Sign"
|
||||||
desc = "Adds signature to PDF by drawing, text or image"
|
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]
|
[home.flatten]
|
||||||
tags = "simplify,remove,interactive"
|
tags = "simplify,remove,interactive"
|
||||||
title = "Flatten"
|
title = "Flatten"
|
||||||
@ -4018,92 +4013,23 @@ deleteSelected = "Delete Selected Pages"
|
|||||||
closePdf = "Close PDF"
|
closePdf = "Close PDF"
|
||||||
exportAll = "Export PDF"
|
exportAll = "Export PDF"
|
||||||
downloadSelected = "Download Selected Files"
|
downloadSelected = "Download Selected Files"
|
||||||
annotations = "Annotations"
|
downloadAll = "Download All"
|
||||||
exportSelected = "Export Selected Pages"
|
saveAll = "Save All"
|
||||||
saveChanges = "Save Changes"
|
|
||||||
toggleTheme = "Toggle Theme"
|
toggleTheme = "Toggle Theme"
|
||||||
|
toggleBookmarks = "Toggle Bookmarks"
|
||||||
language = "Language"
|
language = "Language"
|
||||||
toggleAnnotations = "Toggle Annotations Visibility"
|
|
||||||
search = "Search PDF"
|
search = "Search PDF"
|
||||||
panMode = "Pan Mode"
|
panMode = "Pan Mode"
|
||||||
rotateLeft = "Rotate Left"
|
rotateLeft = "Rotate Left"
|
||||||
rotateRight = "Rotate Right"
|
rotateRight = "Rotate Right"
|
||||||
toggleSidebar = "Toggle Sidebar"
|
toggleSidebar = "Toggle Sidebar"
|
||||||
toggleBookmarks = "Toggle Bookmarks"
|
exportSelected = "Export Selected Pages"
|
||||||
|
toggleAnnotations = "Toggle Annotations Visibility"
|
||||||
|
annotationMode = "Toggle Annotation Mode"
|
||||||
print = "Print PDF"
|
print = "Print PDF"
|
||||||
downloadAll = "Download All"
|
draw = "Draw"
|
||||||
saveAll = "Save All"
|
save = "Save"
|
||||||
|
saveChanges = "Save Changes"
|
||||||
[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"
|
|
||||||
|
|
||||||
[search]
|
[search]
|
||||||
title = "Search PDF"
|
title = "Search PDF"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
93
frontend/src-tauri/Cargo.lock
generated
93
frontend/src-tauri/Cargo.lock
generated
@ -589,26 +589,6 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"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]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@ -736,12 +716,6 @@ version = "0.8.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crunchy"
|
|
||||||
version = "0.2.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@ -934,15 +908,6 @@ dependencies = [
|
|||||||
"syn 2.0.108",
|
"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]]
|
[[package]]
|
||||||
name = "document-features"
|
name = "document-features"
|
||||||
version = "0.2.12"
|
version = "0.2.12"
|
||||||
@ -1661,12 +1626,6 @@ dependencies = [
|
|||||||
"ahash",
|
"ahash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.14.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@ -2861,16 +2820,6 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
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]]
|
[[package]]
|
||||||
name = "ordered-stream"
|
name = "ordered-stream"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@ -3702,16 +3651,6 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"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]]
|
[[package]]
|
||||||
name = "rust_decimal"
|
name = "rust_decimal"
|
||||||
version = "1.39.0"
|
version = "1.39.0"
|
||||||
@ -4317,7 +4256,6 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-deep-link",
|
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-http",
|
"tauri-plugin-http",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
@ -4677,27 +4615,6 @@ dependencies = [
|
|||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-fs"
|
name = "tauri-plugin-fs"
|
||||||
version = "2.4.4"
|
version = "2.4.4"
|
||||||
@ -4818,7 +4735,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin-deep-link",
|
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@ -5038,15 +4954,6 @@ dependencies = [
|
|||||||
"time-core",
|
"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]]
|
[[package]]
|
||||||
name = "tiny_http"
|
name = "tiny_http"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
|
|||||||
@ -29,10 +29,9 @@ tauri-plugin-log = "2.0.0-rc"
|
|||||||
tauri-plugin-shell = "2.1.0"
|
tauri-plugin-shell = "2.1.0"
|
||||||
tauri-plugin-fs = "2.4.4"
|
tauri-plugin-fs = "2.4.4"
|
||||||
tauri-plugin-http = "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-store = "2.1.0"
|
||||||
tauri-plugin-opener = "2.0.0"
|
tauri-plugin-opener = "2.0.0"
|
||||||
tauri-plugin-deep-link = "2.4.5"
|
|
||||||
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
|
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
|
||||||
tokio = { version = "1.0", features = ["time", "sync"] }
|
tokio = { version = "1.0", features = ["time", "sync"] }
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
|||||||
@ -19,8 +19,6 @@
|
|||||||
{
|
{
|
||||||
"identifier": "fs:allow-read-file",
|
"identifier": "fs:allow-read-file",
|
||||||
"allow": [{ "path": "**" }]
|
"allow": [{ "path": "**" }]
|
||||||
},
|
}
|
||||||
"opener:default",
|
|
||||||
"shell:allow-open"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent};
|
use tauri::{Manager, RunEvent, WindowEvent, Emitter};
|
||||||
|
|
||||||
mod utils;
|
mod utils;
|
||||||
mod commands;
|
mod commands;
|
||||||
@ -28,17 +28,6 @@ use commands::{
|
|||||||
};
|
};
|
||||||
use state::connection_state::AppConnectionState;
|
use state::connection_state::AppConnectionState;
|
||||||
use utils::{add_log, get_tauri_logs};
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@ -53,7 +42,6 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_deep_link::init())
|
|
||||||
.manage(AppConnectionState::default())
|
.manage(AppConnectionState::default())
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
// This callback runs when a second instance tries to start
|
// 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
|
// Start backend immediately, non-blocking
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
|
|
||||||
|
|||||||
@ -77,13 +77,6 @@
|
|||||||
},
|
},
|
||||||
"fs": {
|
"fs": {
|
||||||
"requireLiteralLeadingDot": false
|
"requireLiteralLeadingDot": false
|
||||||
},
|
|
||||||
"deep-link": {
|
|
||||||
"desktop": {
|
|
||||||
"schemes": [
|
|
||||||
"stirlingpdf"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from
|
|||||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||||
import { AnnotationProvider } from "@app/contexts/AnnotationContext";
|
|
||||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||||
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
||||||
@ -96,15 +95,13 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
|||||||
<ViewerProvider>
|
<ViewerProvider>
|
||||||
<PageEditorProvider>
|
<PageEditorProvider>
|
||||||
<SignatureProvider>
|
<SignatureProvider>
|
||||||
<AnnotationProvider>
|
<RightRailProvider>
|
||||||
<RightRailProvider>
|
<TourOrchestrationProvider>
|
||||||
<TourOrchestrationProvider>
|
<AdminTourOrchestrationProvider>
|
||||||
<AdminTourOrchestrationProvider>
|
{children}
|
||||||
{children}
|
</AdminTourOrchestrationProvider>
|
||||||
</AdminTourOrchestrationProvider>
|
</TourOrchestrationProvider>
|
||||||
</TourOrchestrationProvider>
|
</RightRailProvider>
|
||||||
</RightRailProvider>
|
|
||||||
</AnnotationProvider>
|
|
||||||
</SignatureProvider>
|
</SignatureProvider>
|
||||||
</PageEditorProvider>
|
</PageEditorProvider>
|
||||||
</ViewerProvider>
|
</ViewerProvider>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
@ -8,10 +8,6 @@ interface ColorPickerProps {
|
|||||||
selectedColor: string;
|
selectedColor: string;
|
||||||
onColorChange: (color: string) => void;
|
onColorChange: (color: string) => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
opacity?: number;
|
|
||||||
onOpacityChange?: (opacity: number) => void;
|
|
||||||
showOpacity?: boolean;
|
|
||||||
opacityLabel?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||||
@ -19,15 +15,10 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
selectedColor,
|
selectedColor,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
title,
|
title
|
||||||
opacity,
|
|
||||||
onOpacityChange,
|
|
||||||
showOpacity = false,
|
|
||||||
opacityLabel,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
|
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
|
||||||
const resolvedOpacityLabel = opacityLabel ?? t('annotation.opacity', 'Opacity');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@ -47,23 +38,6 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|||||||
size="lg"
|
size="lg"
|
||||||
fullWidth
|
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">
|
<Group justify="flex-end">
|
||||||
<Button onClick={onClose}>
|
<Button onClick={onClose}>
|
||||||
{t('common.done', 'Done')}
|
{t('common.done', 'Done')}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from '@mantine/core';
|
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||||
|
|
||||||
interface TextInputWithFontProps {
|
interface TextInputWithFontProps {
|
||||||
@ -12,8 +11,6 @@ interface TextInputWithFontProps {
|
|||||||
onFontFamilyChange: (family: string) => void;
|
onFontFamilyChange: (family: string) => void;
|
||||||
textColor?: string;
|
textColor?: string;
|
||||||
onTextColorChange?: (color: string) => void;
|
onTextColorChange?: (color: string) => void;
|
||||||
textAlign?: 'left' | 'center' | 'right';
|
|
||||||
onTextAlignChange?: (align: 'left' | 'center' | 'right') => void;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
@ -33,8 +30,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
onFontFamilyChange,
|
onFontFamilyChange,
|
||||||
textColor = '#000000',
|
textColor = '#000000',
|
||||||
onTextColorChange,
|
onTextColorChange,
|
||||||
textAlign = 'left',
|
|
||||||
onTextAlignChange,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
@ -44,7 +39,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
colorLabel,
|
colorLabel,
|
||||||
onAnyChange
|
onAnyChange
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||||
const fontSizeCombobox = useCombobox();
|
const fontSizeCombobox = useCombobox();
|
||||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
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>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||||
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
|
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
|
||||||
@ -10,11 +11,13 @@ import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccess
|
|||||||
interface AllToolsNavButtonProps {
|
interface AllToolsNavButtonProps {
|
||||||
activeButton: string;
|
activeButton: string;
|
||||||
setActiveButton: (id: string) => void;
|
setActiveButton: (id: string) => void;
|
||||||
|
tooltipPosition?: 'left' | 'right' | 'top' | 'bottom';
|
||||||
}
|
}
|
||||||
|
|
||||||
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
|
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
|
||||||
activeButton,
|
activeButton,
|
||||||
setActiveButton,
|
setActiveButton,
|
||||||
|
tooltipPosition = 'right'
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
||||||
@ -52,18 +55,26 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 mb-2">
|
<Tooltip
|
||||||
<QuickAccessButton
|
content={t("quickAccess.allTools", "Tools")}
|
||||||
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
|
position={tooltipPosition}
|
||||||
label={t("quickAccess.allTools", "Tools")}
|
arrow
|
||||||
isActive={isActive}
|
containerStyle={{ marginTop: "-1rem" }}
|
||||||
onClick={handleNavClick}
|
maxWidth={200}
|
||||||
href={navProps.href}
|
>
|
||||||
ariaLabel={t("quickAccess.allTools", "Tools")}
|
<div className="mt-4 mb-2">
|
||||||
textClassName="all-tools-text"
|
<QuickAccessButton
|
||||||
component="a"
|
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
|
||||||
/>
|
label={t("quickAccess.allTools", "Tools")}
|
||||||
</div>
|
isActive={isActive}
|
||||||
|
onClick={handleNavClick}
|
||||||
|
href={navProps.href}
|
||||||
|
ariaLabel={t("quickAccess.allTools", "Tools")}
|
||||||
|
textClassName="all-tools-text"
|
||||||
|
component="a"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ActionIcon } from '@mantine/core';
|
import { ActionIcon, Popover } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||||
import { ViewerContext } from '@app/contexts/ViewerContext';
|
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 { useNavigationState } from '@app/contexts/NavigationContext';
|
||||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||||
@ -17,19 +23,31 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { sidebarRefs } = useSidebarContext();
|
const { sidebarRefs } = useSidebarContext();
|
||||||
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
|
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
|
// Viewer context for PDF controls - safely handle when not available
|
||||||
const viewerContext = React.useContext(ViewerContext);
|
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
|
// Check if we're in sign mode
|
||||||
const { selectedTool } = useNavigationState();
|
const { selectedTool } = useNavigationState();
|
||||||
const isSignMode = selectedTool === 'sign';
|
const isSignMode = selectedTool === 'sign';
|
||||||
|
|
||||||
// Check if we're in any annotation tool that should disable the toggle
|
// Turn off annotation mode when switching away from viewer
|
||||||
const isInAnnotationTool = selectedTool === 'annotate' || selectedTool === 'sign' || selectedTool === 'addImage' || selectedTool === 'addText';
|
useEffect(() => {
|
||||||
|
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
|
||||||
// Check if we're on annotate tool to highlight the button
|
viewerContext.setAnnotationMode(false);
|
||||||
const isAnnotateActive = selectedTool === 'annotate';
|
}
|
||||||
|
}, [currentView, viewerContext]);
|
||||||
|
|
||||||
// Don't show any annotation controls in sign mode
|
// Don't show any annotation controls in sign mode
|
||||||
if (isSignMode) {
|
if (isSignMode) {
|
||||||
@ -41,14 +59,13 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
|||||||
{/* Annotation Visibility Toggle */}
|
{/* Annotation Visibility Toggle */}
|
||||||
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isAnnotateActive ? "filled" : "subtle"}
|
variant="subtle"
|
||||||
color="blue"
|
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
className="right-rail-icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
viewerContext?.toggleAnnotationsVisibility();
|
viewerContext?.toggleAnnotationsVisibility();
|
||||||
}}
|
}}
|
||||||
disabled={disabled || currentView !== 'viewer' || isInAnnotationTool}
|
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
|
||||||
>
|
>
|
||||||
<LocalIcon
|
<LocalIcon
|
||||||
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
|
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
|
||||||
@ -57,6 +74,164 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
|||||||
/>
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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;
|
|
||||||
});
|
|
||||||
@ -51,11 +51,13 @@ const EmbedPdfViewerContent = ({
|
|||||||
getScrollState,
|
getScrollState,
|
||||||
getRotationState,
|
getRotationState,
|
||||||
isAnnotationMode,
|
isAnnotationMode,
|
||||||
setAnnotationMode,
|
|
||||||
isAnnotationsVisible,
|
isAnnotationsVisible,
|
||||||
exportActions,
|
exportActions,
|
||||||
} = useViewer();
|
} = useViewer();
|
||||||
|
|
||||||
|
// Register viewer right-rail buttons
|
||||||
|
useViewerRightRailButtons();
|
||||||
|
|
||||||
const scrollState = getScrollState();
|
const scrollState = getScrollState();
|
||||||
const rotationState = getRotationState();
|
const rotationState = getRotationState();
|
||||||
|
|
||||||
@ -67,13 +69,8 @@ const EmbedPdfViewerContent = ({
|
|||||||
}
|
}
|
||||||
}, [rotationState.rotation]);
|
}, [rotationState.rotation]);
|
||||||
|
|
||||||
// Get signature and annotation contexts
|
// Get signature context
|
||||||
const { signatureApiRef, annotationApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
|
const { signatureApiRef, 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 current file from FileContext
|
// Get current file from FileContext
|
||||||
const { selectors, state } = useFileState();
|
const { selectors, state } = useFileState();
|
||||||
@ -85,18 +82,15 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Navigation guard for unsaved changes
|
// Navigation guard for unsaved changes
|
||||||
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
|
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();
|
const { selectedTool } = useNavigationState();
|
||||||
// Tools that require the annotation layer (Sign, Add Text, Add Image, Annotate)
|
// Tools that use the stamp/signature placement system with hover preview
|
||||||
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate';
|
const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
|
||||||
|
|
||||||
// Sync isAnnotationMode in ViewerContext with current tool
|
|
||||||
useEffect(() => {
|
|
||||||
setAnnotationMode(isInAnnotationTool);
|
|
||||||
}, [isInAnnotationTool, setAnnotationMode]);
|
|
||||||
|
|
||||||
|
// 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(
|
const isPlacementOverlayActive = Boolean(
|
||||||
isInAnnotationTool && isPlacementMode && signatureConfig
|
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track which file tab is active
|
// Track which file tab is active
|
||||||
@ -227,31 +221,6 @@ const EmbedPdfViewerContent = ({
|
|||||||
};
|
};
|
||||||
}, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]);
|
}, [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)
|
// Register checker for unsaved changes (annotations only for now)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previewFile) {
|
if (previewFile) {
|
||||||
@ -259,28 +228,39 @@ const EmbedPdfViewerContent = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checkForChanges = () => {
|
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;
|
return hasAnnotationChanges;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('[Viewer] Registering unsaved changes checker');
|
||||||
registerUnsavedChangesChecker(checkForChanges);
|
registerUnsavedChangesChecker(checkForChanges);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log('[Viewer] Unregistering unsaved changes checker');
|
||||||
unregisterUnsavedChangesChecker();
|
unregisterUnsavedChangesChecker();
|
||||||
};
|
};
|
||||||
}, [previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
|
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
|
||||||
|
|
||||||
// Apply changes - save annotations to new file version
|
// Apply changes - save annotations to new file version
|
||||||
const applyChanges = useCallback(async () => {
|
const applyChanges = useCallback(async () => {
|
||||||
if (!currentFile || activeFileIds.length === 0) return;
|
if (!currentFile || activeFileIds.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[Viewer] Applying changes - exporting PDF with annotations');
|
||||||
|
|
||||||
// Step 1: Export PDF with annotations using EmbedPDF
|
// Step 1: Export PDF with annotations using EmbedPDF
|
||||||
const arrayBuffer = await exportActions.saveAsCopy();
|
const arrayBuffer = await exportActions.saveAsCopy();
|
||||||
if (!arrayBuffer) {
|
if (!arrayBuffer) {
|
||||||
throw new Error('Failed to export PDF');
|
throw new Error('Failed to export PDF');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength);
|
||||||
|
|
||||||
// Step 2: Convert ArrayBuffer to File
|
// Step 2: Convert ArrayBuffer to File
|
||||||
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
|
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
|
||||||
const filename = currentFile.name || 'document.pdf';
|
const filename = currentFile.name || 'document.pdf';
|
||||||
@ -295,29 +275,12 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Step 4: Consume files (replace in context)
|
// Step 4: Consume files (replace in context)
|
||||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||||
|
|
||||||
// Mark annotations as saved so navigation away from the viewer is allowed.
|
|
||||||
hasAnnotationChangesRef.current = false;
|
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Apply changes failed:', error);
|
console.error('Apply changes failed:', error);
|
||||||
}
|
}
|
||||||
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
|
}, [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 sidebarWidthRem = 15;
|
||||||
const totalRightMargin =
|
const totalRightMargin =
|
||||||
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
|
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
|
||||||
@ -370,10 +333,8 @@ const EmbedPdfViewerContent = ({
|
|||||||
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
|
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
|
||||||
file={effectiveFile.file}
|
file={effectiveFile.file}
|
||||||
url={effectiveFile.url}
|
url={effectiveFile.url}
|
||||||
enableAnnotations={isAnnotationMode}
|
enableAnnotations={shouldEnableAnnotations}
|
||||||
showBakedAnnotations={isAnnotationsVisible}
|
|
||||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||||
annotationApiRef={annotationApiRef as React.RefObject<any>}
|
|
||||||
historyApiRef={historyApiRef as React.RefObject<any>}
|
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||||
onSignatureAdded={() => {
|
onSignatureAdded={() => {
|
||||||
// Handle signature added - for debugging, enable console logs as needed
|
// 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 { ThumbnailAPIBridge } from '@app/components/viewer/ThumbnailAPIBridge';
|
||||||
import { RotateAPIBridge } from '@app/components/viewer/RotateAPIBridge';
|
import { RotateAPIBridge } from '@app/components/viewer/RotateAPIBridge';
|
||||||
import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge';
|
import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge';
|
||||||
import { AnnotationAPIBridge } from '@app/components/viewer/AnnotationAPIBridge';
|
|
||||||
import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
|
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 { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
|
||||||
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
|
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
|
||||||
import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
|
import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
|
||||||
@ -53,14 +52,12 @@ interface LocalEmbedPDFProps {
|
|||||||
file?: File | Blob;
|
file?: File | Blob;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
enableAnnotations?: boolean;
|
enableAnnotations?: boolean;
|
||||||
showBakedAnnotations?: boolean;
|
|
||||||
onSignatureAdded?: (annotation: any) => void;
|
onSignatureAdded?: (annotation: any) => void;
|
||||||
signatureApiRef?: React.RefObject<SignatureAPI>;
|
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||||
annotationApiRef?: React.RefObject<AnnotationAPI>;
|
|
||||||
historyApiRef?: React.RefObject<HistoryAPI>;
|
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 { t } = useTranslation();
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||||
@ -103,7 +100,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
|||||||
}),
|
}),
|
||||||
createPluginRegistration(RenderPluginPackage, {
|
createPluginRegistration(RenderPluginPackage, {
|
||||||
withForms: true,
|
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)
|
// Register interaction manager (required for zoom and selection features)
|
||||||
@ -125,8 +122,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
|||||||
selectAfterCreate: true,
|
selectAfterCreate: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Register pan plugin (depends on Viewport, InteractionManager) - keep disabled to prevent drag panning
|
// Register pan plugin (depends on Viewport, InteractionManager)
|
||||||
createPluginRegistration(PanPluginPackage, {}),
|
createPluginRegistration(PanPluginPackage, {
|
||||||
|
defaultMode: 'mobile', // Try mobile mode which might be more permissive
|
||||||
|
}),
|
||||||
|
|
||||||
// Register zoom plugin with configuration
|
// Register zoom plugin with configuration
|
||||||
createPluginRegistration(ZoomPluginPackage, {
|
createPluginRegistration(ZoomPluginPackage, {
|
||||||
@ -167,7 +166,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
|||||||
// Register print plugin for printing PDFs
|
// Register print plugin for printing PDFs
|
||||||
createPluginRegistration(PrintPluginPackage),
|
createPluginRegistration(PrintPluginPackage),
|
||||||
];
|
];
|
||||||
}, [pdfUrl, enableAnnotations, showBakedAnnotations]);
|
}, [pdfUrl]);
|
||||||
|
|
||||||
// Initialize the engine with the React hook - use local WASM for offline support
|
// Initialize the engine with the React hook - use local WASM for offline support
|
||||||
const { engine, isLoading, error } = usePdfiumEngine({
|
const { engine, isLoading, error } = usePdfiumEngine({
|
||||||
@ -252,315 +251,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
|||||||
if (!annotationApi) return;
|
if (!annotationApi) return;
|
||||||
|
|
||||||
if (enableAnnotations) {
|
if (enableAnnotations) {
|
||||||
const ensureTool = (tool: any) => {
|
annotationApi.addTool({
|
||||||
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({
|
|
||||||
id: 'signatureStamp',
|
id: 'signatureStamp',
|
||||||
name: 'Digital Signature',
|
name: 'Digital Signature',
|
||||||
interaction: { exclusive: false, cursor: 'copy' },
|
interaction: { exclusive: false, cursor: 'copy' },
|
||||||
@ -570,7 +261,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
ensureTool({
|
annotationApi.addTool({
|
||||||
id: 'signatureInk',
|
id: 'signatureInk',
|
||||||
name: 'Signature Draw',
|
name: 'Signature Draw',
|
||||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||||
@ -618,7 +309,6 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
|||||||
<ThumbnailAPIBridge />
|
<ThumbnailAPIBridge />
|
||||||
<RotateAPIBridge />
|
<RotateAPIBridge />
|
||||||
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
|
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
|
||||||
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
|
|
||||||
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
|
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
|
||||||
<ExportAPIBridge />
|
<ExportAPIBridge />
|
||||||
<BookmarkAPIBridge />
|
<BookmarkAPIBridge />
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Button, Paper, Group, NumberInput } from '@mantine/core';
|
import { Button, Paper, Group, NumberInput } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useViewer } from '@app/contexts/ViewerContext';
|
import { useViewer } from '@app/contexts/ViewerContext';
|
||||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
|
||||||
import FirstPageIcon from '@mui/icons-material/FirstPage';
|
import FirstPageIcon from '@mui/icons-material/FirstPage';
|
||||||
import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
|
import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
|
||||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||||
@ -210,27 +209,21 @@ export function PdfViewerToolbar({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Dual Page Toggle */}
|
{/* Dual Page Toggle */}
|
||||||
<Tooltip
|
<Button
|
||||||
content={
|
variant={isDualPageActive ? "filled" : "light"}
|
||||||
|
color="blue"
|
||||||
|
size="md"
|
||||||
|
radius="xl"
|
||||||
|
onClick={handleDualPageToggle}
|
||||||
|
style={{ minWidth: '2.5rem' }}
|
||||||
|
title={
|
||||||
isDualPageActive
|
isDualPageActive
|
||||||
? t("viewer.singlePageView", "Single Page View")
|
? t("viewer.singlePageView", "Single Page View")
|
||||||
: t("viewer.dualPageView", "Dual Page View")
|
: t("viewer.dualPageView", "Dual Page View")
|
||||||
}
|
}
|
||||||
position="top"
|
|
||||||
arrow
|
|
||||||
>
|
>
|
||||||
<Button
|
{isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
|
||||||
variant={isDualPageActive ? "filled" : "light"}
|
</Button>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Zoom Controls */}
|
{/* Zoom Controls */}
|
||||||
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
|
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
|
||||||
|
|||||||
@ -104,20 +104,12 @@ const createTextStampImage = (
|
|||||||
|
|
||||||
ctx.fillStyle = textColor;
|
ctx.fillStyle = textColor;
|
||||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||||
ctx.textAlign = config.textAlign || 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
const horizontalPadding = paddingX;
|
const horizontalPadding = paddingX;
|
||||||
const verticalCenter = naturalHeight / 2;
|
const verticalCenter = naturalHeight / 2;
|
||||||
|
ctx.fillText(text, horizontalPadding, verticalCenter);
|
||||||
let xPosition = horizontalPadding;
|
|
||||||
if (config.textAlign === 'center') {
|
|
||||||
xPosition = naturalWidth / 2;
|
|
||||||
} else if (config.textAlign === 'right') {
|
|
||||||
xPosition = naturalWidth - horizontalPadding;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.fillText(text, xPosition, verticalCenter);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dataUrl: canvas.toDataURL('image/png'),
|
dataUrl: canvas.toDataURL('image/png'),
|
||||||
@ -207,21 +199,12 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
|||||||
}
|
}
|
||||||
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
|
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
|
||||||
|
|
||||||
|
|
||||||
// Enable keyboard deletion of selected annotations
|
// Enable keyboard deletion of selected annotations
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Always enable delete key when we have annotation API and are in sign mode
|
// Always enable delete key when we have annotation API and are in sign mode
|
||||||
if (!annotationApi || (isPlacementMode === undefined)) return;
|
if (!annotationApi || (isPlacementMode === undefined)) return;
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
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') {
|
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||||
const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
|
const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState, useEffect, useCallback } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { ActionIcon, Popover } from '@mantine/core';
|
import { ActionIcon, Popover } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useViewer } from '@app/contexts/ViewerContext';
|
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 ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
|
||||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
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() {
|
export function useViewerRightRailButtons() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@ -19,32 +16,6 @@ export function useViewerRightRailButtons() {
|
|||||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||||
const { sidebarRefs } = useSidebarContext();
|
const { sidebarRefs } = useSidebarContext();
|
||||||
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
|
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
|
// Lift i18n labels out of memo for clarity
|
||||||
const searchLabel = t('rightRail.search', 'Search PDF');
|
const searchLabel = t('rightRail.search', 'Search PDF');
|
||||||
@ -54,11 +25,9 @@ export function useViewerRightRailButtons() {
|
|||||||
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
||||||
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
|
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
|
||||||
const printLabel = t('rightRail.print', 'Print PDF');
|
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 viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||||
const buttons: RightRailButtonWithAction[] = [
|
return [
|
||||||
{
|
{
|
||||||
id: 'viewer-search',
|
id: 'viewer-search',
|
||||||
tooltip: searchLabel,
|
tooltip: searchLabel,
|
||||||
@ -178,36 +147,6 @@ export function useViewerRightRailButtons() {
|
|||||||
viewer.printActions.print();
|
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',
|
id: 'viewer-annotation-controls',
|
||||||
section: 'top' as const,
|
section: 'top' as const,
|
||||||
@ -215,30 +154,9 @@ export function useViewerRightRailButtons() {
|
|||||||
render: ({ disabled }) => (
|
render: ({ disabled }) => (
|
||||||
<ViewerAnnotationControls currentView="viewer" disabled={disabled} />
|
<ViewerAnnotationControls currentView="viewer" disabled={disabled} />
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
];
|
];
|
||||||
|
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, printLabel, tooltipPosition]);
|
||||||
// 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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useRightRailButtons(viewerButtons);
|
useRightRailButtons(viewerButtons);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,17 +16,6 @@ export interface SignatureAPI {
|
|||||||
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
|
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 {
|
export interface HistoryAPI {
|
||||||
undo: () => void;
|
undo: () => void;
|
||||||
redo: () => void;
|
redo: () => void;
|
||||||
@ -34,50 +23,3 @@ export interface HistoryAPI {
|
|||||||
canRedo: () => boolean;
|
canRedo: () => boolean;
|
||||||
subscribe?: (listener: () => void) => () => void;
|
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 React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react';
|
||||||
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
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
|
// Signature state interface
|
||||||
interface SignatureState {
|
interface SignatureState {
|
||||||
@ -34,7 +34,6 @@ interface SignatureActions {
|
|||||||
// Combined context interface
|
// Combined context interface
|
||||||
interface SignatureContextValue extends SignatureState, SignatureActions {
|
interface SignatureContextValue extends SignatureState, SignatureActions {
|
||||||
signatureApiRef: React.RefObject<SignatureAPI | null>;
|
signatureApiRef: React.RefObject<SignatureAPI | null>;
|
||||||
annotationApiRef: React.RefObject<AnnotationAPI | null>;
|
|
||||||
historyApiRef: React.RefObject<HistoryAPI | null>;
|
historyApiRef: React.RefObject<HistoryAPI | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +52,6 @@ const initialState: SignatureState = {
|
|||||||
export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const [state, setState] = useState<SignatureState>(initialState);
|
const [state, setState] = useState<SignatureState>(initialState);
|
||||||
const signatureApiRef = useRef<SignatureAPI>(null);
|
const signatureApiRef = useRef<SignatureAPI>(null);
|
||||||
const annotationApiRef = useRef<AnnotationAPI>(null);
|
|
||||||
const historyApiRef = useRef<HistoryAPI>(null);
|
const historyApiRef = useRef<HistoryAPI>(null);
|
||||||
const imageDataStore = useRef<Map<string, string>>(new Map());
|
const imageDataStore = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
@ -159,7 +157,6 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
|
|||||||
const contextValue: SignatureContextValue = {
|
const contextValue: SignatureContextValue = {
|
||||||
...state,
|
...state,
|
||||||
signatureApiRef,
|
signatureApiRef,
|
||||||
annotationApiRef,
|
|
||||||
historyApiRef,
|
historyApiRef,
|
||||||
setSignatureConfig,
|
setSignatureConfig,
|
||||||
setPlacementMode,
|
setPlacementMode,
|
||||||
|
|||||||
@ -95,6 +95,7 @@ interface ViewerContextType {
|
|||||||
// Annotation/drawing mode for viewer
|
// Annotation/drawing mode for viewer
|
||||||
isAnnotationMode: boolean;
|
isAnnotationMode: boolean;
|
||||||
setAnnotationMode: (enabled: boolean) => void;
|
setAnnotationMode: (enabled: boolean) => void;
|
||||||
|
toggleAnnotationMode: () => void;
|
||||||
|
|
||||||
// Active file index for multi-file viewing
|
// Active file index for multi-file viewing
|
||||||
activeFileIndex: number;
|
activeFileIndex: number;
|
||||||
@ -229,6 +230,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
setIsAnnotationModeState(enabled);
|
setIsAnnotationModeState(enabled);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleAnnotationMode = () => {
|
||||||
|
setIsAnnotationModeState(prev => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
// State getters - read from bridge refs
|
// State getters - read from bridge refs
|
||||||
const getScrollState = (): ScrollState => {
|
const getScrollState = (): ScrollState => {
|
||||||
return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 };
|
return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 };
|
||||||
@ -313,6 +318,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
toggleAnnotationsVisibility,
|
toggleAnnotationsVisibility,
|
||||||
isAnnotationMode,
|
isAnnotationMode,
|
||||||
setAnnotationMode,
|
setAnnotationMode,
|
||||||
|
toggleAnnotationMode,
|
||||||
|
|
||||||
// Active file index
|
// Active file index
|
||||||
activeFileIndex,
|
activeFileIndex,
|
||||||
|
|||||||
@ -51,7 +51,6 @@ import Crop from "@app/tools/Crop";
|
|||||||
import Sign from "@app/tools/Sign";
|
import Sign from "@app/tools/Sign";
|
||||||
import AddText from "@app/tools/AddText";
|
import AddText from "@app/tools/AddText";
|
||||||
import AddImage from "@app/tools/AddImage";
|
import AddImage from "@app/tools/AddImage";
|
||||||
import Annotate from "@app/tools/Annotate";
|
|
||||||
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
|
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
|
||||||
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
|
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
|
||||||
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
|
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
|
||||||
@ -247,19 +246,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
|||||||
synonyms: getSynonyms(t, 'addImage'),
|
synonyms: getSynonyms(t, 'addImage'),
|
||||||
supportsAutomate: false,
|
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
|
// Document Security
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,6 @@ export interface SignParameters {
|
|||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
textColor?: string;
|
textColor?: string;
|
||||||
textAlign?: 'left' | 'center' | 'right';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_PARAMETERS: SignParameters = {
|
export const DEFAULT_PARAMETERS: SignParameters = {
|
||||||
@ -29,7 +28,6 @@ export const DEFAULT_PARAMETERS: SignParameters = {
|
|||||||
fontFamily: 'Helvetica',
|
fontFamily: 'Helvetica',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
textColor: '#000000',
|
textColor: '#000000',
|
||||||
textAlign: 'left',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateSignParameters = (parameters: SignParameters): boolean => {
|
const validateSignParameters = (parameters: SignParameters): boolean => {
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -25,7 +25,6 @@ export const CORE_REGULAR_TOOL_IDS = [
|
|||||||
'ocr',
|
'ocr',
|
||||||
'addImage',
|
'addImage',
|
||||||
'rotate',
|
'rotate',
|
||||||
'annotate',
|
|
||||||
'scannerImageSplit',
|
'scannerImageSplit',
|
||||||
'editTableOfContents',
|
'editTableOfContents',
|
||||||
'scannerEffect',
|
'scannerEffect',
|
||||||
|
|||||||
@ -70,8 +70,6 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
|||||||
'/scanner-image-split': 'scannerImageSplit',
|
'/scanner-image-split': 'scannerImageSplit',
|
||||||
|
|
||||||
// Annotation and content removal
|
// Annotation and content removal
|
||||||
'/annotations': 'annotate',
|
|
||||||
'/annotate': 'annotate',
|
|
||||||
'/remove-annotations': 'removeAnnotations',
|
'/remove-annotations': 'removeAnnotations',
|
||||||
'/remove-image': 'removeImage',
|
'/remove-image': 'removeImage',
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,6 @@ interface SaaSLoginScreenProps {
|
|||||||
onLogin: (username: string, password: string) => Promise<void>;
|
onLogin: (username: string, password: string) => Promise<void>;
|
||||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||||
onSelfHostedClick: () => void;
|
onSelfHostedClick: () => void;
|
||||||
onSwitchToSignup: () => void;
|
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@ -24,7 +23,6 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
|||||||
onLogin,
|
onLogin,
|
||||||
onOAuthSuccess,
|
onOAuthSuccess,
|
||||||
onSelfHostedClick,
|
onSelfHostedClick,
|
||||||
onSwitchToSignup,
|
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
}) => {
|
}) => {
|
||||||
@ -91,20 +89,6 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
|||||||
submitButtonText={t('setup.login.submit', 'Login')}
|
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} />
|
<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 { useTranslation } from 'react-i18next';
|
||||||
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
|
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
|
||||||
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
|
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
|
||||||
import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen';
|
|
||||||
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
|
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
|
||||||
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
|
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
|
||||||
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService';
|
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService';
|
||||||
import { authService, UserInfo } from '@app/services/authService';
|
import { authService, UserInfo } from '@app/services/authService';
|
||||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||||
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
|
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';
|
import '@app/routes/authShared/auth.css';
|
||||||
|
|
||||||
enum SetupStep {
|
enum SetupStep {
|
||||||
SaaSLogin,
|
SaaSLogin,
|
||||||
SaaSSignup,
|
|
||||||
ServerSelection,
|
ServerSelection,
|
||||||
SelfHostedLogin,
|
SelfHostedLogin,
|
||||||
}
|
}
|
||||||
@ -84,16 +80,6 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
|||||||
setActiveStep(SetupStep.ServerSelection);
|
setActiveStep(SetupStep.ServerSelection);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSwitchToSignup = () => {
|
|
||||||
setError(null);
|
|
||||||
setActiveStep(SetupStep.SaaSSignup);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSwitchToLogin = () => {
|
|
||||||
setError(null);
|
|
||||||
setActiveStep(SetupStep.SaaSLogin);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleServerSelection = (config: ServerConfig) => {
|
const handleServerSelection = (config: ServerConfig) => {
|
||||||
setServerConfig(config);
|
setServerConfig(config);
|
||||||
setError(null);
|
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 = () => {
|
const handleBack = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (activeStep === SetupStep.SelfHostedLogin) {
|
if (activeStep === SetupStep.SelfHostedLogin) {
|
||||||
@ -191,8 +135,6 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
|||||||
} else if (activeStep === SetupStep.ServerSelection) {
|
} else if (activeStep === SetupStep.ServerSelection) {
|
||||||
setActiveStep(SetupStep.SaaSLogin);
|
setActiveStep(SetupStep.SaaSLogin);
|
||||||
setServerConfig({ url: STIRLING_SAAS_URL });
|
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}
|
onLogin={handleSaaSLogin}
|
||||||
onOAuthSuccess={handleSaaSLoginOAuth}
|
onOAuthSuccess={handleSaaSLoginOAuth}
|
||||||
onSelfHostedClick={handleSelfHostedClick}
|
onSelfHostedClick={handleSelfHostedClick}
|
||||||
onSwitchToSignup={handleSwitchToSignup}
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStep === SetupStep.SaaSSignup && (
|
|
||||||
<SaaSSignupScreen
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
onLogin={handleSaaSLogin}
|
|
||||||
onSwitchToLogin={handleSwitchToLogin}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeStep === SetupStep.ServerSelection && (
|
{activeStep === SetupStep.ServerSelection && (
|
||||||
<ServerSelectionScreen
|
<ServerSelectionScreen
|
||||||
onSelect={handleServerSelection}
|
onSelect={handleServerSelection}
|
||||||
|
|||||||
@ -6,12 +6,6 @@
|
|||||||
// The SaaS authentication server
|
// The SaaS authentication server
|
||||||
export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL || '';
|
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
|
// Supabase publishable key from environment variable
|
||||||
// Used for SaaS authentication
|
// Used for SaaS authentication
|
||||||
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb';
|
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,6 +1,6 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import axios from 'axios';
|
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 {
|
export interface UserInfo {
|
||||||
username: string;
|
username: string;
|
||||||
@ -131,67 +131,6 @@ export class AuthService {
|
|||||||
this.notifyListeners();
|
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> {
|
async login(serverUrl: string, username: string, password: string): Promise<UserInfo> {
|
||||||
try {
|
try {
|
||||||
console.log('Logging in to:', serverUrl);
|
console.log('Logging in to:', serverUrl);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user