mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db8e46c451 | ||
|
|
d57c3ddeb7 | ||
|
|
031477b7b5 | ||
|
|
8725ba66bb | ||
|
|
115a24b16d | ||
|
|
9f26dc4112 | ||
|
|
a23c252af5 | ||
|
|
f25b308e46 | ||
|
|
d3e13967e9 | ||
|
|
0e94ea156f | ||
|
|
46049a0a4a | ||
|
|
3d3c5f79a5 | ||
|
|
330a987faf | ||
|
|
5806dfecf6 | ||
|
|
b653e09c16 | ||
|
|
61f3000cea |
+6
@@ -14,6 +14,7 @@ public class InstallationPathConfig {
|
||||
private static final String CUSTOM_FILES_PATH;
|
||||
private static final String CLIENT_WEBUI_PATH;
|
||||
private static final String PIPELINE_PATH;
|
||||
private static final String PLUGINS_PATH;
|
||||
|
||||
// Config paths
|
||||
private static final String SETTINGS_PATH;
|
||||
@@ -40,6 +41,7 @@ public class InstallationPathConfig {
|
||||
CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator;
|
||||
CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator;
|
||||
PIPELINE_PATH = BASE_PATH + "pipeline" + File.separator;
|
||||
PLUGINS_PATH = CUSTOM_FILES_PATH + "plugins" + File.separator;
|
||||
|
||||
// Initialize config paths
|
||||
SETTINGS_PATH = CONFIG_PATH + "settings.yml";
|
||||
@@ -110,6 +112,10 @@ public class InstallationPathConfig {
|
||||
return SIGNATURES_PATH;
|
||||
}
|
||||
|
||||
public static String getPluginsPath() {
|
||||
return PLUGINS_PATH;
|
||||
}
|
||||
|
||||
public static String getPrivateKeyPath() {
|
||||
return BACKUP_PRIVATE_KEY_PATH;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
/** Immutable descriptor that represents a loaded plugin. */
|
||||
@Value
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
public class PluginDescriptor {
|
||||
|
||||
String id;
|
||||
String icon;
|
||||
String name;
|
||||
String description;
|
||||
String version;
|
||||
String author;
|
||||
String frontendLabel;
|
||||
String frontendPath;
|
||||
String iconPath;
|
||||
String minHostVersion;
|
||||
String jarCreatedAt;
|
||||
|
||||
@Builder.Default boolean hasFrontend = false;
|
||||
|
||||
@Builder.Default List<String> backendEndpoints = Collections.emptyList();
|
||||
@Builder.Default Map<String, String> metadata = Collections.emptyMap();
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
@Builder
|
||||
/**
|
||||
* API-facing representation of a plugin descriptor with a fully resolved frontend URL.
|
||||
*
|
||||
* <p>This DTO is returned to clients so they can render plugin metadata and open plugin UIs.
|
||||
*/
|
||||
public class PluginDescriptorResponse {
|
||||
String id;
|
||||
String icon;
|
||||
String name;
|
||||
String description;
|
||||
String version;
|
||||
String author;
|
||||
String frontendUrl;
|
||||
String frontendLabel;
|
||||
String iconPath;
|
||||
String minHostVersion;
|
||||
String jarCreatedAt;
|
||||
boolean hasFrontend;
|
||||
List<String> backendEndpoints;
|
||||
Map<String, String> metadata;
|
||||
|
||||
/**
|
||||
* Creates a response object from an internal {@link PluginDescriptor}.
|
||||
*
|
||||
* @param descriptor loaded plugin descriptor
|
||||
* @param baseUrl optional API base URL used to build an absolute frontend URL
|
||||
* @return normalized response payload for API clients
|
||||
*/
|
||||
public static PluginDescriptorResponse from(PluginDescriptor descriptor, String baseUrl) {
|
||||
String frontendPath = descriptor.getFrontendPath();
|
||||
String normalizedBase = baseUrl != null ? baseUrl.replaceAll("/+$", "") : "";
|
||||
String normalizedPath = frontendPath != null ? frontendPath.replaceAll("^/+", "/") : "";
|
||||
String frontendUrl =
|
||||
(normalizedBase.isEmpty() || normalizedPath.isEmpty())
|
||||
? (normalizedPath.isEmpty() ? null : normalizedPath)
|
||||
: normalizedBase + normalizedPath;
|
||||
|
||||
return PluginDescriptorResponse.builder()
|
||||
.id(descriptor.getId())
|
||||
.icon(descriptor.getIcon())
|
||||
.name(descriptor.getName())
|
||||
.description(descriptor.getDescription())
|
||||
.version(descriptor.getVersion())
|
||||
.author(descriptor.getAuthor())
|
||||
.frontendUrl(frontendUrl)
|
||||
.frontendLabel(descriptor.getFrontendLabel())
|
||||
.iconPath(descriptor.getIconPath())
|
||||
.hasFrontend(descriptor.isHasFrontend())
|
||||
.backendEndpoints(descriptor.getBackendEndpoints())
|
||||
.metadata(descriptor.getMetadata())
|
||||
.minHostVersion(descriptor.getMinHostVersion())
|
||||
.jarCreatedAt(descriptor.getJarCreatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
/**
|
||||
* Utility responsible for discovering plugin jars, parsing their metadata, and integrating them
|
||||
* into the Stirling-PDF runtime.
|
||||
*/
|
||||
@Slf4j
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public final class PluginLoader {
|
||||
private static final String JAR_EXTENSION = ".jar";
|
||||
private static final String JAR_MIME_TYPE = "application/java-archive";
|
||||
private static final String METADATA_RESOURCE = "META-INF/stirling-plugin.json";
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Discovers plugin jar files in the configured plugins directory.
|
||||
*
|
||||
* @return sorted list of valid plugin jar paths
|
||||
*/
|
||||
public static List<Path> listPluginJars() {
|
||||
Path pluginDir = ensurePluginDirectory();
|
||||
if (!Files.isDirectory(pluginDir)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(pluginDir)) {
|
||||
return stream.filter(Files::isRegularFile)
|
||||
.filter(PluginLoader::looksLikeJarFile)
|
||||
.filter(PluginLoader::isReadableJarArchive)
|
||||
.sorted(
|
||||
Comparator.comparing(
|
||||
path -> path.getFileName().toString().toLowerCase()))
|
||||
.collect(Collectors.toList());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to list plugin directory {}: {}", pluginDir, e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts discovered plugin jar paths into URL entries suitable for class/resource loading.
|
||||
*
|
||||
* @return immutable-style list of valid jar URLs
|
||||
*/
|
||||
public static List<URL> pluginJarUrls() {
|
||||
List<Path> jars = listPluginJars();
|
||||
if (jars.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<URL> urls = new ArrayList<>(jars.size());
|
||||
for (Path jar : jars) {
|
||||
try {
|
||||
urls.add(jar.toUri().toURL());
|
||||
} catch (MalformedURLException e) {
|
||||
log.warn("Skipping plugin jar with invalid URL {}: {}", jar, e.getMessage());
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a class loader that can load classes/resources from installed plugins.
|
||||
*
|
||||
* @param parent parent class loader
|
||||
* @return plugin-aware class loader or parent when no plugin jars exist
|
||||
*/
|
||||
public static ClassLoader buildPluginClassLoader(ClassLoader parent) {
|
||||
List<URL> urls = pluginJarUrls();
|
||||
if (urls.isEmpty()) {
|
||||
return parent;
|
||||
}
|
||||
log.info(
|
||||
"Scanning {} plugin jars in {}",
|
||||
urls.size(),
|
||||
InstallationPathConfig.getPluginsPath());
|
||||
return new URLClassLoader(urls.toArray(URL[]::new), parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads descriptors for all discovered plugin jars.
|
||||
*
|
||||
* @return immutable list of successfully parsed descriptors
|
||||
*/
|
||||
public static List<PluginDescriptor> loadDescriptors() {
|
||||
List<Path> jars = listPluginJars();
|
||||
if (jars.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<PluginDescriptor> descriptors = new ArrayList<>();
|
||||
for (Path jar : jars) {
|
||||
PluginDescriptor descriptor = readDescriptorFromJar(jar);
|
||||
if (descriptor != null) {
|
||||
descriptors.add(descriptor);
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableList(descriptors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads metadata for one plugin jar and maps it to a descriptor.
|
||||
*
|
||||
* @param jarPath plugin jar path
|
||||
* @return descriptor when valid metadata exists, otherwise {@code null}
|
||||
*/
|
||||
public static PluginDescriptor loadDescriptor(Path jarPath) {
|
||||
return readDescriptorFromJar(jarPath);
|
||||
}
|
||||
|
||||
private static Path ensurePluginDirectory() {
|
||||
Path pluginDir = Path.of(InstallationPathConfig.getPluginsPath());
|
||||
try {
|
||||
return Files.createDirectories(pluginDir);
|
||||
} catch (IOException e) {
|
||||
log.error("Unable to create plugin directory {}", pluginDir, e);
|
||||
return pluginDir;
|
||||
}
|
||||
}
|
||||
|
||||
private static PluginDescriptor readDescriptorFromJar(Path jarPath) {
|
||||
if (!Files.isRegularFile(jarPath)) {
|
||||
log.warn("Plugin jar {} is not a regular file, skipping", jarPath);
|
||||
return null;
|
||||
}
|
||||
try (JarFile jarFile = new JarFile(jarPath.toFile())) {
|
||||
JarEntry entry = jarFile.getJarEntry(METADATA_RESOURCE);
|
||||
if (entry == null) {
|
||||
log.info("Plugin jar {} does not include {}, skipping", jarPath, METADATA_RESOURCE);
|
||||
return null;
|
||||
}
|
||||
PluginMetadata metadata;
|
||||
try (InputStream inputStream = jarFile.getInputStream(entry)) {
|
||||
metadata = OBJECT_MAPPER.readValue(inputStream, PluginMetadata.class);
|
||||
}
|
||||
|
||||
String createdAt = resolveJarTimestamp(jarPath);
|
||||
|
||||
if (metadata.getId() == null || metadata.getId().isBlank()) {
|
||||
log.warn("Plugin metadata in {} is missing required id, ignoring", jarPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
String pluginId = metadata.getId();
|
||||
log.info(
|
||||
"Loaded metadata for plugin '{}': name='{}' version='{}'",
|
||||
pluginId,
|
||||
metadata.getName(),
|
||||
metadata.getVersion());
|
||||
|
||||
return buildDescriptor(metadata, createdAt);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to inspect plugin jar {}: {}", jarPath, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static PluginDescriptor buildDescriptor(PluginMetadata metadata, String jarCreatedAt) {
|
||||
PluginMetadata.PluginFrontend frontend = metadata.getFrontend();
|
||||
String id = metadata.getId();
|
||||
String icon = metadata.getIcon();
|
||||
|
||||
String frontendPath =
|
||||
(frontend != null
|
||||
&& frontend.getEntrypoint() != null
|
||||
&& !frontend.getEntrypoint().isBlank())
|
||||
? ensureLeadingSlash(frontend.getEntrypoint())
|
||||
: "/plugins/" + id + "/index.html";
|
||||
|
||||
return PluginDescriptor.builder()
|
||||
.id(id)
|
||||
.icon(defaultIfEmpty(icon, null))
|
||||
.name(defaultIfEmpty(metadata.getName(), id))
|
||||
.description(defaultIfEmpty(metadata.getDescription(), ""))
|
||||
.version(defaultIfEmpty(metadata.getVersion(), "0.0.0"))
|
||||
.author(metadata.getAuthor())
|
||||
.frontendLabel(frontend != null ? frontend.getLabel() : null)
|
||||
.frontendPath(frontendPath)
|
||||
.iconPath(frontend != null ? frontend.getIconPath() : null)
|
||||
.hasFrontend(frontend != null)
|
||||
.backendEndpoints(
|
||||
metadata.getBackendEndpoints() == null
|
||||
? Collections.emptyList()
|
||||
: metadata.getBackendEndpoints())
|
||||
.metadata(
|
||||
metadata.getMetadata() == null
|
||||
? Collections.emptyMap()
|
||||
: metadata.getMetadata())
|
||||
.minHostVersion(defaultIfEmpty(metadata.getMinHostVersion(), null))
|
||||
.jarCreatedAt(jarCreatedAt)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String defaultIfEmpty(String value, String fallback) {
|
||||
return (value == null || value.isBlank()) ? fallback : value;
|
||||
}
|
||||
|
||||
private static String ensureLeadingSlash(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "/";
|
||||
}
|
||||
return path.startsWith("/") ? path : "/" + path;
|
||||
}
|
||||
|
||||
private static boolean looksLikeJarFile(Path path) {
|
||||
return path.getFileName().toString().toLowerCase().endsWith(JAR_EXTENSION);
|
||||
}
|
||||
|
||||
private static boolean isReadableJarArchive(Path path) {
|
||||
try {
|
||||
String mimeType = Files.probeContentType(path);
|
||||
if (mimeType != null && !JAR_MIME_TYPE.equals(mimeType)) {
|
||||
log.debug("Ignoring non-jar mime type {} for {}", mimeType, path);
|
||||
return false;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("Unable to probe mime type for {}: {}", path, e.getMessage());
|
||||
}
|
||||
|
||||
try (JarFile ignored = new JarFile(path.toFile())) {
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
log.warn("Skipping invalid jar archive {}: {}", path, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveJarTimestamp(Path jarPath) throws IOException {
|
||||
BasicFileAttributes attrs = Files.readAttributes(jarPath, BasicFileAttributes.class);
|
||||
FileTime creationTime = attrs.creationTime();
|
||||
FileTime lastModifiedTime = attrs.lastModifiedTime();
|
||||
FileTime preferredTime =
|
||||
creationTime == null || creationTime.toMillis() <= 0
|
||||
? lastModifiedTime
|
||||
: creationTime;
|
||||
return preferredTime.toInstant().toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
/** Model for deserializing {@code META-INF/stirling-plugin.json} from a plugin jar. */
|
||||
public class PluginMetadata {
|
||||
private String id;
|
||||
private String icon;
|
||||
private String name;
|
||||
private String description;
|
||||
private String version;
|
||||
private String author;
|
||||
private String minHostVersion;
|
||||
private PluginFrontend frontend;
|
||||
private List<String> backendEndpoints;
|
||||
private Map<String, String> metadata;
|
||||
|
||||
/** Frontend-specific metadata block declared inside plugin metadata JSON. */
|
||||
@Getter
|
||||
@Setter
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class PluginFrontend {
|
||||
private String entrypoint;
|
||||
private String label;
|
||||
private String iconPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
/** Registers MVC resource handlers that expose static assets from plugin jars. */
|
||||
public class PluginResourceConfig implements WebMvcConfigurer {
|
||||
|
||||
/** Adds {@code /plugins/**} static resource mappings for every discovered plugin jar. */
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
List<String> locations =
|
||||
PluginLoader.pluginJarUrls().stream()
|
||||
.map(url -> "jar:" + url + "!/META-INF/resources/plugins/")
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!locations.isEmpty()) {
|
||||
registry.addResourceHandler("/plugins/**")
|
||||
.addResourceLocations(locations.toArray(String[]::new))
|
||||
.setCachePeriod(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.web.context.WebServerInitializedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.env.Environment;
|
||||
@@ -28,6 +29,7 @@ import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.configuration.ConfigInitializer;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.plugins.PluginLoader;
|
||||
|
||||
@Slf4j
|
||||
@EnableScheduling
|
||||
@@ -59,11 +61,11 @@ public class SPDFApplication {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws IOException, InterruptedException {
|
||||
SpringApplication app = new SpringApplication(SPDFApplication.class);
|
||||
SpringApplicationBuilder builder = new SpringApplicationBuilder(SPDFApplication.class);
|
||||
|
||||
Properties props = new Properties();
|
||||
|
||||
app.setAdditionalProfiles(getActiveProfile(args));
|
||||
builder.profiles(getActiveProfile(args));
|
||||
|
||||
ConfigInitializer initializer = new ConfigInitializer();
|
||||
try {
|
||||
@@ -111,8 +113,13 @@ public class SPDFApplication {
|
||||
if (!props.isEmpty()) {
|
||||
finalProps.putAll(props);
|
||||
}
|
||||
ClassLoader pluginClassLoader =
|
||||
PluginLoader.buildPluginClassLoader(SPDFApplication.class.getClassLoader());
|
||||
if (pluginClassLoader != SPDFApplication.class.getClassLoader()) {
|
||||
Thread.currentThread().setContextClassLoader(pluginClassLoader);
|
||||
}
|
||||
SpringApplication app = builder.build();
|
||||
app.setDefaultProperties(finalProps);
|
||||
|
||||
app.run(args);
|
||||
|
||||
// Ensure directories are created
|
||||
|
||||
+31
-1
@@ -9,17 +9,23 @@ import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||
import stirling.software.SPDF.config.InitialSetup;
|
||||
import stirling.software.SPDF.service.plugin.PluginService;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.plugins.PluginDescriptorResponse;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
@@ -35,6 +41,7 @@ public class ConfigController {
|
||||
private final UserServiceInterface userService;
|
||||
private final stirling.software.common.service.LicenseServiceInterface licenseService;
|
||||
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
|
||||
private final PluginService pluginService;
|
||||
|
||||
public ConfigController(
|
||||
ApplicationProperties applicationProperties,
|
||||
@@ -46,7 +53,8 @@ public class ConfigController {
|
||||
UserServiceInterface userService,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
stirling.software.common.service.LicenseServiceInterface licenseService,
|
||||
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
|
||||
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig,
|
||||
PluginService pluginService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
@@ -54,6 +62,7 @@ public class ConfigController {
|
||||
this.userService = userService;
|
||||
this.licenseService = licenseService;
|
||||
this.externalAppDepConfig = externalAppDepConfig;
|
||||
this.pluginService = pluginService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,6 +297,10 @@ public class ConfigController {
|
||||
// Version/machine info not available
|
||||
}
|
||||
|
||||
// config directory path
|
||||
configData.put("basePath", InstallationPathConfig.getPath());
|
||||
configData.put("pluginsPath", InstallationPathConfig.getPluginsPath());
|
||||
|
||||
return ResponseEntity.ok(configData);
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -297,6 +310,23 @@ public class ConfigController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/plugins")
|
||||
public ResponseEntity<List<PluginDescriptorResponse>> getPlugins(HttpServletRequest request) {
|
||||
String baseUrl =
|
||||
ServletUriComponentsBuilder.fromRequestUri(request)
|
||||
.replacePath(null)
|
||||
.replaceQuery(null)
|
||||
.build()
|
||||
.toUriString();
|
||||
|
||||
List<PluginDescriptorResponse> mapped =
|
||||
pluginService.getPlugins().stream()
|
||||
.map(descriptor -> PluginDescriptorResponse.from(descriptor, baseUrl))
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(mapped);
|
||||
}
|
||||
|
||||
@GetMapping("/endpoint-enabled")
|
||||
public ResponseEntity<Boolean> isEndpointEnabled(
|
||||
@RequestParam(name = "endpoint") String endpoint) {
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.plugin.PluginService;
|
||||
|
||||
@Controller
|
||||
@Slf4j
|
||||
/**
|
||||
* Serves static frontend assets embedded in plugin jars under {@code META-INF/resources/plugins}.
|
||||
*/
|
||||
public class PluginFrontendController {
|
||||
private static final String PLUGIN_RESOURCE_ROOT = "META-INF/resources/plugins/";
|
||||
|
||||
private final PluginService pluginService;
|
||||
|
||||
/**
|
||||
* @param pluginService service used to resolve plugin jar locations
|
||||
*/
|
||||
public PluginFrontendController(PluginService pluginService) {
|
||||
this.pluginService = pluginService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects plugin root requests to the conventional {@code index.html} entrypoint.
|
||||
*
|
||||
* @param pluginId requested plugin identifier
|
||||
* @return permanent redirect to plugin index page
|
||||
*/
|
||||
@GetMapping("/plugins/{pluginId}")
|
||||
public ResponseEntity<Void> redirectToIndex(@PathVariable String pluginId) {
|
||||
return ResponseEntity.status(301)
|
||||
.location(URI.create("/plugins/" + pluginId + "/index.html"))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams an asset from the requested plugin jar, while validating path boundaries to avoid
|
||||
* traversal outside the plugin resource root.
|
||||
*
|
||||
* @param request incoming servlet request used to extract suffix path
|
||||
* @param pluginId requested plugin identifier
|
||||
* @return asset content when found; suitable HTTP error otherwise
|
||||
*/
|
||||
@GetMapping("/plugins/{pluginId}/**")
|
||||
public ResponseEntity<ByteArrayResource> servePluginAsset(
|
||||
HttpServletRequest request, @PathVariable String pluginId) {
|
||||
try {
|
||||
String suffix = resolveSuffix(request, pluginId);
|
||||
if (suffix == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (suffix.contains("..")) {
|
||||
log.warn(
|
||||
"[PluginFrontend] Blocked path traversal attempt for {}: {}",
|
||||
pluginId,
|
||||
suffix);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
Optional<Path> jarPath = pluginService.getPluginJarPath(pluginId);
|
||||
if (jarPath.isEmpty() || !Files.isRegularFile(jarPath.get())) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String resourcePath = PLUGIN_RESOURCE_ROOT + pluginId + suffix;
|
||||
return serveResourceFromJar(jarPath.get(), resourcePath);
|
||||
} catch (IOException e) {
|
||||
log.error("[PluginFrontend] Failed to stream plugin asset for {}", pluginId, e);
|
||||
return ResponseEntity.status(500).build();
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveSuffix(HttpServletRequest request, String pluginId) {
|
||||
String contextPath = Optional.ofNullable(request.getContextPath()).orElse("");
|
||||
String requestUri = Optional.ofNullable(request.getRequestURI()).orElse("");
|
||||
String prefix = contextPath + "/plugins/" + pluginId;
|
||||
if (!requestUri.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String suffix = requestUri.substring(prefix.length());
|
||||
if (suffix.isEmpty() || "/".equals(suffix)) {
|
||||
return "/index.html";
|
||||
}
|
||||
return suffix;
|
||||
}
|
||||
|
||||
private static ResponseEntity<ByteArrayResource> serveResourceFromJar(
|
||||
Path jarPath, String resourcePath) throws IOException {
|
||||
try (JarFile jarFile = new JarFile(jarPath.toFile())) {
|
||||
JarEntry entry = jarFile.getJarEntry(resourcePath);
|
||||
if (entry == null || entry.isDirectory()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
byte[] content;
|
||||
try (InputStream stream = jarFile.getInputStream(entry)) {
|
||||
content = stream.readAllBytes();
|
||||
}
|
||||
|
||||
MediaType mediaType =
|
||||
MediaTypeFactory.getMediaType(entry.getName())
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(mediaType)
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.body(new ByteArrayResource(content));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package stirling.software.SPDF.service.plugin;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.plugins.PluginDescriptor;
|
||||
import stirling.software.common.plugins.PluginLoader;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class PluginService {
|
||||
private final List<PluginDescriptor> plugins;
|
||||
private final Map<String, Path> pluginJarPaths;
|
||||
|
||||
public PluginService() {
|
||||
List<Path> jars = PluginLoader.listPluginJars();
|
||||
Map<String, Path> jarMap = new LinkedHashMap<>();
|
||||
List<PluginDescriptor> descriptors = new java.util.ArrayList<>();
|
||||
|
||||
for (Path jar : jars) {
|
||||
PluginDescriptor descriptor = PluginLoader.loadDescriptor(jar);
|
||||
if (descriptor == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String pluginId = descriptor.getId();
|
||||
if (jarMap.containsKey(pluginId)) {
|
||||
log.warn(
|
||||
"Duplicate plugin id '{}' detected in {}. Keeping first jar at {}",
|
||||
pluginId,
|
||||
jar,
|
||||
jarMap.get(pluginId));
|
||||
continue;
|
||||
}
|
||||
|
||||
descriptors.add(descriptor);
|
||||
jarMap.put(pluginId, jar);
|
||||
}
|
||||
|
||||
this.plugins = Collections.unmodifiableList(descriptors);
|
||||
this.pluginJarPaths = Collections.unmodifiableMap(jarMap);
|
||||
}
|
||||
|
||||
public List<PluginDescriptor> getPlugins() {
|
||||
return plugins;
|
||||
}
|
||||
|
||||
public Optional<Path> getPluginJarPath(String pluginId) {
|
||||
return Optional.ofNullable(pluginJarPaths.get(pluginId));
|
||||
}
|
||||
}
|
||||
@@ -4273,6 +4273,7 @@ rotateRight = "Rotate Right"
|
||||
toggleSidebar = "Toggle Sidebar"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
print = "Print PDF"
|
||||
ruler = "Ruler / Measure"
|
||||
draw = "Draw"
|
||||
redact = "Redact"
|
||||
exitRedaction = "Exit Redaction Mode"
|
||||
@@ -4911,6 +4912,7 @@ account = "Account"
|
||||
activity = "Activity"
|
||||
adminSettings = "Admin Settings"
|
||||
allTools = "Tools"
|
||||
plugins = "Plugins"
|
||||
automate = "Automate"
|
||||
config = "Config"
|
||||
files = "Files"
|
||||
@@ -5624,6 +5626,21 @@ title = "Policies & Privacy"
|
||||
[settings.preferences]
|
||||
title = "Preferences"
|
||||
|
||||
[settings.plugins]
|
||||
author = "Author: {{author}}"
|
||||
count = "Installed plugins {{count}}"
|
||||
createdAt = "Created on {{date}}"
|
||||
description = "Browse, install, and configure extensions."
|
||||
empty = "No plugins found. Drop a plugin JAR in {{path}}."
|
||||
error = "Failed to load plugins"
|
||||
label = "Plugins"
|
||||
loading = "Loading plugins..."
|
||||
minHost = "min. v{{version}}"
|
||||
noDescription = "No description"
|
||||
sectionTitle = "Extensions"
|
||||
title = "Plugins"
|
||||
unknownAuthor = "unknown"
|
||||
|
||||
[settings.security]
|
||||
description = "Update your password to keep your account secure."
|
||||
title = "Security"
|
||||
@@ -6267,6 +6284,15 @@ verification = "Verification"
|
||||
noSearchResults = "No tools found"
|
||||
noTools = "No tools available"
|
||||
|
||||
[plugins]
|
||||
closeViewer = "Close plugin viewer"
|
||||
noDescription = "No description provided."
|
||||
open = "Open UI"
|
||||
refresh = "Refresh"
|
||||
shortTitle = "Plugins"
|
||||
title = "Installed plugins"
|
||||
version = "v{{version}}"
|
||||
|
||||
[unlockPDFForms]
|
||||
description = "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable."
|
||||
filenamePrefix = "unlocked_forms"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Stirling-PDF needs access to your local network to connect to self-hosted servers.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"publisher": "Stirling PDF Inc.",
|
||||
"targets": [
|
||||
"deb",
|
||||
"rpm",
|
||||
@@ -76,7 +77,8 @@
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": null,
|
||||
"entitlements": null,
|
||||
"providerShortName": null
|
||||
"providerShortName": null,
|
||||
"infoPlist": "Info.plist"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvide
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||
import PluginPage from "@app/pages/PluginPage";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
|
||||
// Import global styles
|
||||
@@ -43,6 +44,15 @@ export default function App() {
|
||||
/>
|
||||
|
||||
{/* All other routes need AppProviders for backend integration */}
|
||||
<Route
|
||||
path="/plugins/:id"
|
||||
element={
|
||||
<AppProviders>
|
||||
<PluginPage />
|
||||
</AppProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactNode, useEffect } from "react";
|
||||
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import { NavigationProvider } from "@app/contexts/NavigationContext";
|
||||
import { PluginRegistryProvider } from "@app/contexts/PluginRegistryContext";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import { FilesModalProvider } from "@app/contexts/FilesModalContext";
|
||||
import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
|
||||
@@ -108,7 +109,8 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<BrandingAssetManager />
|
||||
<ToolRegistryProvider>
|
||||
<PluginRegistryProvider>
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
@@ -139,6 +141,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</PluginRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</BannerProvider>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { PluginInfo } from "@app/contexts/PluginRegistryContext";
|
||||
|
||||
interface PluginViewerOverlayProps {
|
||||
plugin: PluginInfo;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PluginViewerOverlay({
|
||||
plugin,
|
||||
onClose,
|
||||
}: PluginViewerOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const iframeSrc = useMemo(() => plugin.frontendUrl ?? "", [plugin.frontendUrl]);
|
||||
|
||||
if (!iframeSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(6, 8, 12, 0.92)",
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
zIndex: 5000,
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "1rem",
|
||||
color: "var(--text-on-dark, white)",
|
||||
};
|
||||
|
||||
const titleStyle: React.CSSProperties = {
|
||||
fontWeight: 600,
|
||||
fontSize: "1.25rem",
|
||||
};
|
||||
|
||||
const subtitleStyle: React.CSSProperties = {
|
||||
opacity: 0.85,
|
||||
fontSize: "0.9rem",
|
||||
};
|
||||
|
||||
const iframeStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
border: "none",
|
||||
borderRadius: "0.75rem",
|
||||
background: "#05070a",
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div style={overlayStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div>
|
||||
<div style={titleStyle}>{plugin.name}</div>
|
||||
<div style={subtitleStyle}>
|
||||
{plugin.description || t("plugins.noDescription", "No description provided.")}
|
||||
</div>
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="lg"
|
||||
onClick={onClose}
|
||||
aria-label={t("plugins.closeViewer", "Close plugin viewer")}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
title={plugin.name}
|
||||
style={iframeStyle}
|
||||
allowFullScreen
|
||||
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { NavKey } from '@app/components/shared/config/types';
|
||||
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
|
||||
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
|
||||
import PluginSection from '@app/components/shared/config/configSections/PluginSection';
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
@@ -53,6 +54,17 @@ export const useConfigNavSections = (
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('settings.plugins.sectionTitle', 'Extensions'),
|
||||
items: [
|
||||
{
|
||||
key: 'plugins',
|
||||
label: t('settings.plugins.label', 'Plugins'),
|
||||
icon: 'extension-rounded',
|
||||
component: <PluginSection />
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return sections;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Paper, Text, Group, Stack, Badge, Divider, Avatar, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePluginRegistry } from "@app/contexts/PluginRegistryContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
const PluginSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { plugins, loading, error } = usePluginRegistry();
|
||||
const { config } = useAppConfig();
|
||||
const pluginPath =
|
||||
config?.pluginsPath ?? (config?.basePath ? `${config.basePath}/customFiles/plugins/` : "customFiles/plugins/");
|
||||
const [iconStatus, setIconStatus] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const toCheck = plugins.filter((plugin) => plugin.iconUrl && iconStatus[plugin.id] === undefined);
|
||||
|
||||
toCheck.forEach((plugin) => {
|
||||
const iconUrl = plugin.iconUrl!;
|
||||
apiClient
|
||||
.get(iconUrl, { responseType: "blob", suppressErrorToast: true })
|
||||
.then(() => {
|
||||
if (!active) return;
|
||||
setIconStatus((prev) => ({ ...prev, [plugin.id]: true }));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setIconStatus((prev) => ({ ...prev, [plugin.id]: false }));
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [plugins, iconStatus]);
|
||||
|
||||
const renderIcon = (plugin: ReturnType<typeof usePluginRegistry> extends { plugins: (infer T)[] } ? T : never) => {
|
||||
const isValid = iconStatus[plugin.id];
|
||||
if (plugin.iconUrl && isValid) {
|
||||
return <Avatar radius="md" w="36px" h="36px" src={plugin.iconUrl} alt="Plugin Icon" />;
|
||||
}
|
||||
return <LocalIcon icon="extension-outline" width="1.5rem" height="1.5rem" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper radius="md" p="md" withBorder style={{ background: "var(--modal-content-bg)" }}>
|
||||
<Group justify="space-between">
|
||||
<Text size="lg" fw={600}>
|
||||
{t("settings.plugins.title", "Plugins")}
|
||||
</Text>
|
||||
<Badge variant="outline" color="gray">
|
||||
{t("settings.plugins.count", "Installed plugins {{count}}", { count: plugins.length })}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{t("settings.plugins.description", "Browse, install, and configure extensions.")}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
{loading && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("settings.plugins.loading", "Loading plugins...")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{error && plugins.length > 0 && (
|
||||
<Text size="sm" c="red">
|
||||
{t("settings.plugins.error", "Failed to load plugins")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!loading && plugins.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("settings.plugins.empty", "No plugins found. Drop a plugin JAR in {{path}}.", { path: pluginPath })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{plugins.map((plugin) => (
|
||||
<Paper key={plugin.id} radius="md" p="md" withBorder style={{ background: "var(--modal-content-bg)" }}>
|
||||
<Stack gap="sm">
|
||||
<Group align="center" gap="sm">
|
||||
{renderIcon(plugin)}
|
||||
<Stack gap="0">
|
||||
<Group gap="xs">
|
||||
{plugin.frontendLabel && (
|
||||
<Badge color="teal" variant="light">
|
||||
{plugin.frontendLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{plugin.minHostVersion && (
|
||||
<Badge color="blue" variant="light">
|
||||
{t("settings.plugins.minHost", { defaultValue: "min. v{{version}}", version: plugin.minHostVersion })}
|
||||
</Badge>
|
||||
)}
|
||||
{plugin.version && (
|
||||
<Badge variant="outline" color="gray">
|
||||
{t("plugins.version", { defaultValue: "v{{version}}", version: plugin.version })}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fw={600} c={plugin.hasFrontend ? "var(--mantine-color-blue-3)" : undefined}>
|
||||
{plugin.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{plugin.description || t("settings.plugins.noDescription", "No description")}
|
||||
</Text>
|
||||
{plugin.backendEndpoints.length > 0 && (
|
||||
<Group gap="xs">
|
||||
{plugin.backendEndpoints.map((endpoint) => (
|
||||
<Tooltip key={endpoint} label={endpoint} position="bottom" withArrow>
|
||||
<Badge variant="outline" color="cyan" style={{ fontSize: "0.7rem", letterSpacing: 0.4 }}>
|
||||
{endpoint.replace(/^\//, "").toUpperCase()}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
<Divider />
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("settings.plugins.author", "Author: {{author}}", {
|
||||
author: plugin.author || t("settings.plugins.unknownAuthor", "unknown"),
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
{plugin.jarCreatedAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("settings.plugins.createdAt", "Created on {{date}}", {
|
||||
date: new Date(plugin.jarCreatedAt).toLocaleString(),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginSection;
|
||||
@@ -29,6 +29,7 @@ export const VALID_NAV_KEYS = [
|
||||
'adminAudit',
|
||||
'adminUsage',
|
||||
'adminEndpoints',
|
||||
'plugins',
|
||||
] as const;
|
||||
|
||||
// Derive the type from the array
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { Box, Button, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import "@app/components/tools/toolPicker/ToolPicker.css";
|
||||
@@ -12,6 +12,11 @@ import ToolButton from "@app/components/tools/toolPicker/ToolButton";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
|
||||
import { usePluginRegistry } from "@app/contexts/PluginRegistryContext";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import FitText from "@app/components/shared/FitText";
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
@@ -26,7 +31,10 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { sections: visibleSections } = useToolSections(filteredTools);
|
||||
const { favoriteTools, toolRegistry } = useToolWorkflow();
|
||||
const {
|
||||
favoriteTools,
|
||||
toolRegistry,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
|
||||
|
||||
@@ -47,6 +55,19 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
[visibleSections]
|
||||
);
|
||||
|
||||
const { plugins } = usePluginRegistry();
|
||||
const navigate = useNavigate();
|
||||
const pluginItems = useMemo(
|
||||
() => plugins.filter((plugin) => plugin.hasFrontend && plugin.frontendUrl),
|
||||
[plugins],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
pluginItems.forEach((plugin) => {
|
||||
console.debug(`[ToolPicker] Rendering icon for plugin ${plugin.id}:`, plugin.icon);
|
||||
});
|
||||
}, [pluginItems]);
|
||||
|
||||
// Build flat list by subcategory for search mode
|
||||
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
|
||||
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
|
||||
@@ -133,6 +154,68 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{pluginItems.length > 0 && (
|
||||
<Box w="100%">
|
||||
<div style={headerTextStyle}>{t("plugins.shortTitle", "Plugins")}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
|
||||
{pluginItems.map((plugin) => (
|
||||
<div key={`plugin-${plugin.id}`} className="tool-button-container">
|
||||
<Tooltip content={plugin.description} position="right" arrow={true} delay={500}>
|
||||
<Button
|
||||
component="a"
|
||||
key={`plugin-${plugin.id}`}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
className="tool-button"
|
||||
justify="flex-start"
|
||||
onClick={() => {
|
||||
console.debug(`[ToolPicker] Navigating to plugin ${plugin.id}`);
|
||||
navigate(`/plugins/${plugin.id}`, { state: { plugin } });
|
||||
}}
|
||||
data-tour={`plugin-button-${plugin.id}`}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
overflow: 'visible'
|
||||
},
|
||||
label: { overflow: 'visible' }
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<div
|
||||
className="tool-button-icon"
|
||||
style={{
|
||||
transform: "scale(0.8)",
|
||||
transformOrigin: "center",
|
||||
opacity: 1,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
marginRight: "0.5rem"
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon={typeof plugin.icon === 'string' ? plugin.icon : 'extension'} width="24" height="24" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', width: '100%' }}>
|
||||
<FitText
|
||||
text={plugin.name}
|
||||
lines={1}
|
||||
minimumFontScale={0.8}
|
||||
as="span"
|
||||
style={{ display: 'inline-block', maxWidth: '100%', opacity: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{allSection && allSection.subcategories.map((sc: SubcategoryGroup) => (
|
||||
<Box key={sc.subcategoryId} w="100%">
|
||||
<div style={headerTextStyle}>
|
||||
|
||||
@@ -19,10 +19,87 @@ import NavigationWarningModal from '@app/components/shared/NavigationWarningModa
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
|
||||
import { RulerOverlay, type PageMeasureScales, type PageScaleInfo, type ViewportScale } from '@app/components/viewer/RulerOverlay';
|
||||
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||
import { useFormFill } from '@app/tools/formFill/FormFillContext';
|
||||
import { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
|
||||
|
||||
import type { PDFDict, PDFNumber } from '@cantoo/pdf-lib';
|
||||
|
||||
// ─── Measure dictionary extraction ────────────────────────────────────────────
|
||||
|
||||
async function extractPageMeasureScales(file: Blob): Promise<PageMeasureScales | null> {
|
||||
try {
|
||||
const { PDFDocument, PDFDict, PDFName, PDFArray, PDFNumber, PDFString, PDFHexString } = await import('@cantoo/pdf-lib');
|
||||
const pdfDoc = await PDFDocument.load(await file.arrayBuffer(), { ignoreEncryption: true });
|
||||
|
||||
// Parse a Measure dict into a MeasureScale, or return null if malformed.
|
||||
const parseScale = (measureObj: unknown) => {
|
||||
if (!(measureObj instanceof PDFDict)) return null;
|
||||
const rObj = measureObj.lookup(PDFName.of('R'));
|
||||
const ratioLabel = (rObj instanceof PDFString || rObj instanceof PDFHexString)
|
||||
? rObj.decodeText() : '';
|
||||
// D = distance array, X = x-axis fallback
|
||||
let fmtArray = measureObj.lookup(PDFName.of('D'));
|
||||
if (!(fmtArray instanceof PDFArray)) fmtArray = measureObj.lookup(PDFName.of('X'));
|
||||
if (!(fmtArray instanceof PDFArray)) return null;
|
||||
const firstFmt = fmtArray.lookup(0);
|
||||
if (!(firstFmt instanceof PDFDict)) return null;
|
||||
const cObj = firstFmt.lookup(PDFName.of('C'));
|
||||
const uObj = firstFmt.lookup(PDFName.of('U'));
|
||||
if (!(cObj instanceof PDFNumber) || cObj.asNumber() <= 0) return null;
|
||||
const unit = (uObj instanceof PDFString || uObj instanceof PDFHexString)
|
||||
? uObj.decodeText() : 'units';
|
||||
return { factor: cObj.asNumber(), unit, ratioLabel };
|
||||
};
|
||||
|
||||
const result: PageMeasureScales = new Map();
|
||||
|
||||
for (let i = 0; i < pdfDoc.getPageCount(); i++) {
|
||||
const page = pdfDoc.getPage(i);
|
||||
const pageHeight = page.getHeight();
|
||||
const pageNode = page.node as unknown as PDFDict;
|
||||
const viewports: ViewportScale[] = [];
|
||||
|
||||
// Spec-conformant: /VP array — each viewport can have its own scale and BBox
|
||||
const vpObj = pageNode.lookup(PDFName.of('VP'));
|
||||
if (vpObj instanceof PDFArray) {
|
||||
for (let j = 0; j < vpObj.size(); j++) {
|
||||
const vpEntry = vpObj.lookup(j);
|
||||
if (!(vpEntry instanceof PDFDict)) continue;
|
||||
const scale = parseScale(vpEntry.lookup(PDFName.of('Measure')));
|
||||
if (!scale) continue;
|
||||
let bbox: ViewportScale['bbox'] = null;
|
||||
const bboxObj = vpEntry.lookup(PDFName.of('BBox'));
|
||||
if (bboxObj instanceof PDFArray && bboxObj.size() >= 4) {
|
||||
bbox = [
|
||||
(bboxObj.lookup(0) as PDFNumber).asNumber(),
|
||||
(bboxObj.lookup(1) as PDFNumber).asNumber(),
|
||||
(bboxObj.lookup(2) as PDFNumber).asNumber(),
|
||||
(bboxObj.lookup(3) as PDFNumber).asNumber(),
|
||||
];
|
||||
}
|
||||
viewports.push({ bbox, scale });
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: /Measure directly on page (non-conforming but seen in the wild)
|
||||
if (viewports.length === 0) {
|
||||
const scale = parseScale(pageNode.lookup(PDFName.of('Measure')));
|
||||
if (scale) viewports.push({ bbox: null, scale });
|
||||
}
|
||||
|
||||
if (viewports.length > 0) result.set(i, { viewports, pageHeight } satisfies PageScaleInfo);
|
||||
}
|
||||
|
||||
return result.size > 0 ? result : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
sidebarsVisible: boolean;
|
||||
setSidebarsVisible: (v: boolean) => void;
|
||||
@@ -688,8 +765,20 @@ const EmbedPdfViewerContent = ({
|
||||
};
|
||||
}, [applyChanges, setApplyChanges]);
|
||||
|
||||
// Ruler / measurement tool state
|
||||
const [isRulerActive, setIsRulerActive] = useState(false);
|
||||
const [pageMeasureScales, setPageMeasureScales] = useState<PageMeasureScales | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const file = effectiveFile?.file;
|
||||
if (!file) { setPageMeasureScales(null); return; }
|
||||
let cancelled = false;
|
||||
extractPageMeasureScales(file).then(scales => { if (!cancelled) setPageMeasureScales(scales); });
|
||||
return () => { cancelled = true; };
|
||||
}, [effectiveFile]);
|
||||
|
||||
// Register viewer right-rail buttons
|
||||
useViewerRightRailButtons();
|
||||
useViewerRightRailButtons(isRulerActive, setIsRulerActive);
|
||||
|
||||
// Auto-fetch form fields when a PDF is loaded in the viewer.
|
||||
// In normal viewer mode, this uses pdf-lib (frontend-only).
|
||||
@@ -819,6 +908,11 @@ const EmbedPdfViewerContent = ({
|
||||
isActive={isPlacementOverlayActive}
|
||||
signatureConfig={signatureConfig}
|
||||
/>
|
||||
<RulerOverlay
|
||||
containerRef={pdfContainerRef}
|
||||
isActive={isRulerActive}
|
||||
pageMeasureScales={pageMeasureScales}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A point anchored to a specific PDF page in PDF-unit space.
|
||||
* x and y are in PDF points (1/72 inch) relative to the page's top-left corner.
|
||||
*
|
||||
* This is the only truly zoom-invariant representation. Screen positions are
|
||||
* recovered at render time via getBoundingClientRect on the page element, so
|
||||
* scroll, zoom, and fixed page margins are all handled by the browser — we never
|
||||
* have to track them ourselves.
|
||||
*/
|
||||
interface PagePoint {
|
||||
pageIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Measurement {
|
||||
id: string;
|
||||
start: PagePoint;
|
||||
end: PagePoint;
|
||||
}
|
||||
|
||||
export interface RulerOverlayHandle {
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
interface RulerOverlayProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
isActive: boolean;
|
||||
pageMeasureScales?: PageMeasureScales | null;
|
||||
}
|
||||
|
||||
// ─── Math ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function dist(a: Point, b: Point): number {
|
||||
return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
|
||||
}
|
||||
|
||||
function midpoint(a: Point, b: Point): Point {
|
||||
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
}
|
||||
|
||||
function perpUnit(a: Point, b: Point): { nx: number; ny: number } {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
return { nx: -dy / len, ny: dx / len };
|
||||
}
|
||||
|
||||
/** Angle from horizontal 0°–90°. Computed from screen-space points (same angle as PDF space). */
|
||||
function angleDeg(a: Point, b: Point): number {
|
||||
return Math.atan2(Math.abs(b.y - a.y), Math.abs(b.x - a.x)) * (180 / Math.PI);
|
||||
}
|
||||
|
||||
function formatDist(pts: number): string {
|
||||
const mm = (pts / 72) * 25.4;
|
||||
if (mm < 100) return `${mm.toFixed(1)} mm`;
|
||||
if (mm < 1000) return `${(mm / 10).toFixed(1)} cm`;
|
||||
return `${(mm / 1000).toFixed(2)} m`;
|
||||
}
|
||||
|
||||
function formatInches(pts: number): string {
|
||||
const inches = pts / 72;
|
||||
if (inches < 12) return `${inches.toFixed(2)} in`;
|
||||
return `${(inches / 12).toFixed(2)} ft`;
|
||||
}
|
||||
|
||||
export interface MeasureScale {
|
||||
/** real_world_value = pdf_points * factor */
|
||||
factor: number;
|
||||
/** e.g. "ft", "m" */
|
||||
unit: string;
|
||||
/** Human-readable ratio from PDF, e.g. "1 in = 10 ft" */
|
||||
ratioLabel: string;
|
||||
}
|
||||
|
||||
export interface ViewportScale {
|
||||
/** BBox in PDF user space (bottom-left origin). null = entire page. */
|
||||
bbox: [number, number, number, number] | null;
|
||||
scale: MeasureScale;
|
||||
}
|
||||
|
||||
export interface PageScaleInfo {
|
||||
viewports: ViewportScale[];
|
||||
/** Page height in PDF points — used to flip screen-y (top=0) to PDF-y (bottom=0). */
|
||||
pageHeight: number;
|
||||
}
|
||||
|
||||
export type PageMeasureScales = Map<number, PageScaleInfo>;
|
||||
|
||||
/**
|
||||
* Given the start/end PagePoints of a measurement, find the scale from the
|
||||
* viewport whose BBox contains the midpoint. Falls back to the first viewport
|
||||
* if none contains it (handles whole-page viewports with bbox=null).
|
||||
*/
|
||||
function pickScale(
|
||||
start: PagePoint,
|
||||
end: PagePoint,
|
||||
pageMeasureScales: PageMeasureScales,
|
||||
): MeasureScale | null {
|
||||
if (start.pageIndex !== end.pageIndex) return null;
|
||||
const info = pageMeasureScales.get(start.pageIndex);
|
||||
if (!info?.viewports.length) return null;
|
||||
|
||||
// Midpoint in screen-space page coords (x left→right, y top→bottom, PDF points)
|
||||
const mx = (start.x + end.x) / 2;
|
||||
// Flip y: screen y=0 is page top; PDF user space y=0 is page bottom
|
||||
const my = info.pageHeight - (start.y + end.y) / 2;
|
||||
|
||||
for (const { bbox, scale } of info.viewports) {
|
||||
if (!bbox) return scale; // whole-page viewport
|
||||
const [x0, y0, x1, y1] = bbox;
|
||||
if (mx >= Math.min(x0, x1) && mx <= Math.max(x0, x1) &&
|
||||
my >= Math.min(y0, y1) && my <= Math.max(y0, y1)) {
|
||||
return scale;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatScaled(pts: number, scale: MeasureScale): string {
|
||||
const val = pts * scale.factor;
|
||||
if (val >= 1000) return `${val.toFixed(0)} ${scale.unit}`;
|
||||
if (val >= 100) return `${val.toFixed(1)} ${scale.unit}`;
|
||||
if (val >= 10) return `${val.toFixed(2)} ${scale.unit}`;
|
||||
return `${val.toFixed(3)} ${scale.unit}`;
|
||||
}
|
||||
|
||||
// Conversion factors to metres for known units
|
||||
const TO_METRES: Record<string, number> = {
|
||||
m: 1, cm: 0.01, mm: 0.001, km: 1000,
|
||||
ft: 0.3048, in: 0.0254, yd: 0.9144, mi: 1609.344,
|
||||
};
|
||||
|
||||
function isImperialUnit(unit: string): boolean {
|
||||
return ['ft', 'in', 'yd', 'mi'].includes(unit.toLowerCase().trim());
|
||||
}
|
||||
|
||||
function formatMetricFromMetres(m: number): string {
|
||||
if (m >= 1000) return `${(m / 1000).toFixed(2)} km`;
|
||||
if (m >= 1) return `${m.toFixed(1)} m`;
|
||||
if (m >= 0.1) return `${(m * 100).toFixed(1)} cm`;
|
||||
return `${(m * 1000).toFixed(1)} mm`;
|
||||
}
|
||||
|
||||
function formatImperialFromFeet(ft: number): string {
|
||||
if (ft >= 1) return `${ft.toFixed(2)} ft`;
|
||||
return `${(ft * 12).toFixed(2)} in`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scaled real-world value in the *other* unit system, or null if
|
||||
* the unit is not a recognised metric/imperial unit.
|
||||
* e.g. 72 pts, scale {factor:0.138889, unit:"ft"} → "3.048 m"
|
||||
* 72 pts, scale {factor:0.352778, unit:"m"} → "1.157 ft" (approx)
|
||||
*/
|
||||
function scaledCross(pts: number, scale: MeasureScale): string | null {
|
||||
const toM = TO_METRES[scale.unit.toLowerCase().trim()];
|
||||
if (!toM) return null;
|
||||
const metres = pts * scale.factor * toM;
|
||||
return isImperialUnit(scale.unit)
|
||||
? formatMetricFromMetres(metres)
|
||||
: formatImperialFromFeet(metres / 0.3048);
|
||||
}
|
||||
|
||||
// ─── DOM helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function findScrollEl(root: HTMLElement): HTMLElement | null {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
const el = node as HTMLElement;
|
||||
if (el === root) continue;
|
||||
const { overflow, overflowY, overflowX } = window.getComputedStyle(el);
|
||||
if ([overflow, overflowY, overflowX].some(v => v === 'auto' || v === 'scroll')) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isOverPage(e: MouseEvent): boolean {
|
||||
return !!(e.target as Element).closest?.('[data-page-index]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest point on any page boundary and return it as both
|
||||
* an SVG screen coordinate and a PagePoint (page-relative PDF units).
|
||||
* Used to clamp the live line when the cursor drifts off the page.
|
||||
*/
|
||||
function nearestPageDocPt(
|
||||
cursor: Point,
|
||||
container: HTMLElement,
|
||||
zoom: number,
|
||||
): { screenPt: Point; docPt: PagePoint } | null {
|
||||
const pages = container.querySelectorAll('[data-page-index]');
|
||||
if (!pages.length) return null;
|
||||
|
||||
const cr = container.getBoundingClientRect();
|
||||
let bestDist = Infinity;
|
||||
let best: { screenPt: Point; docPt: PagePoint } | null = null;
|
||||
|
||||
pages.forEach(pageNode => {
|
||||
const pageEl = pageNode as HTMLElement;
|
||||
const r = pageEl.getBoundingClientRect();
|
||||
const pageIndex = parseInt(pageEl.dataset.pageIndex ?? '0', 10);
|
||||
|
||||
// Page bounds in SVG (container-relative) space
|
||||
const left = r.left - cr.left;
|
||||
const top = r.top - cr.top;
|
||||
const right = r.right - cr.left;
|
||||
const bottom = r.bottom - cr.top;
|
||||
|
||||
// Nearest point on this rect to the cursor (SVG space)
|
||||
const cx = Math.max(left, Math.min(right, cursor.x));
|
||||
const cy = Math.max(top, Math.min(bottom, cursor.y));
|
||||
const d = Math.sqrt((cursor.x - cx) ** 2 + (cursor.y - cy) ** 2);
|
||||
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
// Convert SVG-space point (cx, cy) → page-relative viewport → PDF points:
|
||||
// viewport position of cx = cr.left + cx
|
||||
// page-relative position = (cr.left + cx) - r.left
|
||||
// PDF units = page-relative / zoom
|
||||
best = {
|
||||
screenPt: { x: cx, y: cy },
|
||||
docPt: {
|
||||
pageIndex,
|
||||
x: (cr.left + cx - r.left) / zoom,
|
||||
y: (cr.top + cy - r.top ) / zoom,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
const TICK = 10;
|
||||
const DOT_R = 5;
|
||||
const LH = 26; // label height (normal — 1 line)
|
||||
const LH2 = 44; // label height (hovered, no scale — 2 lines)
|
||||
const LH3 = 62; // label height (hovered, with scale — 3 lines)
|
||||
const LP = 10; // label horizontal padding
|
||||
const DEL_R = 8;
|
||||
|
||||
interface MeasurementLineProps {
|
||||
id: string;
|
||||
startS: Point;
|
||||
endS: Point;
|
||||
/** Physical distance in PDF points (= screen pixel distance / zoom). */
|
||||
distPts: number;
|
||||
hovered: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
onHover: (id: string | null) => void;
|
||||
measureScale?: MeasureScale | null;
|
||||
}
|
||||
|
||||
function MeasurementLine({ id, startS, endS, distPts, hovered, onDelete, onHover, measureScale }: MeasurementLineProps) {
|
||||
const mid = midpoint(startS, endS);
|
||||
const { nx, ny } = perpUnit(startS, endS);
|
||||
const ang = angleDeg(startS, endS);
|
||||
const angLabel = `∠ ${ang.toFixed(1)}°`;
|
||||
|
||||
// Whether the PDF's unit is imperial — determines display order (imperial-first vs metric-first)
|
||||
const imperialFirst = !!measureScale && isImperialUnit(measureScale.unit);
|
||||
|
||||
// Idle: scaled primary if scale present, else physical metric
|
||||
const distLabel = measureScale ? formatScaled(distPts, measureScale) : formatDist(distPts);
|
||||
|
||||
// Hover line 1 — both real-world values ordered by PDF unit system:
|
||||
// imperial PDF: "10.000 ft / 3.048 m"
|
||||
// metric PDF: "142.5 m / 467.5 ft"
|
||||
// no scale: "25.4 mm / 1.00 in" (metric first, default)
|
||||
const hoverLine1 = measureScale
|
||||
? (() => {
|
||||
const primary = formatScaled(distPts, measureScale);
|
||||
const cross = scaledCross(distPts, measureScale);
|
||||
return cross ? `${primary} / ${cross}` : primary;
|
||||
})()
|
||||
: `${formatDist(distPts)} / ${formatInches(distPts)}`;
|
||||
|
||||
// Hover line 2 — both physical paper values, same order as line 1:
|
||||
// imperial PDF: "1.00 in / 25.4 mm"
|
||||
// metric PDF or no scale: "25.4 mm / 1.00 in"
|
||||
const hoverLine2 = measureScale
|
||||
? (imperialFirst
|
||||
? `${formatInches(distPts)} / ${formatDist(distPts)}`
|
||||
: `${formatDist(distPts)} / ${formatInches(distPts)}`)
|
||||
: null;
|
||||
|
||||
// Hover line 3 (scaled) / line 2 (no scale) — ratio label + angle
|
||||
const contextLabel = measureScale?.ratioLabel
|
||||
? `${measureScale.ratioLabel} ${angLabel}`
|
||||
: angLabel;
|
||||
|
||||
const maxHoverLh = measureScale ? LH3 : LH2;
|
||||
const lh = hovered ? maxHoverLh : LH;
|
||||
|
||||
const lwNormal = Math.max(distLabel.length * 8 + LP * 2, 80);
|
||||
const lwHover = Math.max(
|
||||
hoverLine1.length * 8 + LP * 2,
|
||||
(hoverLine2?.length ?? 0) * 8 + LP * 2,
|
||||
contextLabel.length * 8 + LP * 2,
|
||||
80,
|
||||
);
|
||||
const lw = hovered ? lwHover : lwNormal;
|
||||
const sw = hovered ? 3 : 2;
|
||||
|
||||
const delX = mid.x + lwHover / 2 + DEL_R + 4;
|
||||
const delY = mid.y;
|
||||
|
||||
const hitLeft = mid.x - lwHover / 2 - 4;
|
||||
const hitTop = mid.y - maxHoverLh / 2 - 4;
|
||||
const hitWidth = (delX + DEL_R + 4) - hitLeft;
|
||||
const hitHeight = maxHoverLh + 8;
|
||||
|
||||
const mono = "'Roboto Mono','Consolas',monospace";
|
||||
|
||||
return (
|
||||
<g
|
||||
onMouseEnter={() => onHover(id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
style={{ pointerEvents: 'all' }}
|
||||
>
|
||||
<rect x={hitLeft} y={hitTop} width={hitWidth} height={hitHeight}
|
||||
fill="transparent" stroke="none" style={{ pointerEvents: 'all' }} />
|
||||
|
||||
<line x1={startS.x} y1={startS.y} x2={endS.x} y2={endS.y}
|
||||
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
|
||||
<line x1={startS.x + nx * TICK / 2} y1={startS.y + ny * TICK / 2}
|
||||
x2={startS.x - nx * TICK / 2} y2={startS.y - ny * TICK / 2}
|
||||
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
|
||||
<line x1={endS.x + nx * TICK / 2} y1={endS.y + ny * TICK / 2}
|
||||
x2={endS.x - nx * TICK / 2} y2={endS.y - ny * TICK / 2}
|
||||
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
|
||||
<circle cx={startS.x} cy={startS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
|
||||
<circle cx={endS.x} cy={endS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
|
||||
|
||||
<g style={{ pointerEvents: 'all', cursor: 'default' }}>
|
||||
<rect x={mid.x - lw / 2} y={mid.y - lh / 2} width={lw} height={lh}
|
||||
rx={5} fill="white" stroke="#1e88e5" strokeWidth={1.5} filter="url(#ruler-shadow)" />
|
||||
|
||||
{hovered && measureScale ? (
|
||||
// 3-line scaled hover
|
||||
<>
|
||||
<text x={mid.x} y={mid.y - 17} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
|
||||
style={{ userSelect: 'none' }}>{hoverLine1}</text>
|
||||
<text x={mid.x} y={mid.y} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="#546e7a" fontSize={11} fontFamily={mono} fontWeight={500}
|
||||
style={{ userSelect: 'none' }}>{hoverLine2}</text>
|
||||
<text x={mid.x} y={mid.y + 17} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="#5c6bc0" fontSize={10} fontFamily={mono} fontWeight={500}
|
||||
style={{ userSelect: 'none' }}>{contextLabel}</text>
|
||||
</>
|
||||
) : hovered ? (
|
||||
// 2-line no-scale hover
|
||||
<>
|
||||
<text x={mid.x} y={mid.y - 6} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
|
||||
style={{ userSelect: 'none' }}>{hoverLine1}</text>
|
||||
<text x={mid.x} y={mid.y + 13} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="#5c6bc0" fontSize={11} fontFamily={mono} fontWeight={500}
|
||||
style={{ userSelect: 'none' }}>{contextLabel}</text>
|
||||
</>
|
||||
) : (
|
||||
// Idle — single line
|
||||
<text x={mid.x} y={mid.y + 1} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
|
||||
style={{ userSelect: 'none' }}>{distLabel}</text>
|
||||
)}
|
||||
|
||||
<g style={{ cursor: 'pointer' }} onClick={(e) => { e.stopPropagation(); onDelete(id); }}>
|
||||
<circle cx={delX} cy={delY} r={DEL_R} fill="#ef5350" stroke="white" strokeWidth={1.5} />
|
||||
<text x={delX} y={delY} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="white" fontSize={12} fontWeight={700} style={{ userSelect: 'none' }}>×</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
interface LiveLineProps {
|
||||
startS: Point;
|
||||
endS: Point;
|
||||
zoom: number;
|
||||
measureScale?: MeasureScale | null;
|
||||
}
|
||||
|
||||
function LiveLine({ startS, endS, zoom, measureScale }: LiveLineProps) {
|
||||
const d = dist(startS, endS) / zoom; // PDF points from screen distance
|
||||
const mid = midpoint(startS, endS);
|
||||
const { nx, ny } = perpUnit(startS, endS);
|
||||
const ang = angleDeg(startS, endS);
|
||||
const distLabel = measureScale ? formatScaled(d, measureScale) : formatDist(d);
|
||||
const lw = Math.max(distLabel.length * 8 + LP * 2, 80);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<line x1={startS.x} y1={startS.y} x2={endS.x} y2={endS.y}
|
||||
stroke="#1e88e5" strokeWidth={2} strokeDasharray="7 4"
|
||||
strokeLinecap="round" opacity={0.85} />
|
||||
<line x1={startS.x + nx * TICK / 2} y1={startS.y + ny * TICK / 2}
|
||||
x2={startS.x - nx * TICK / 2} y2={startS.y - ny * TICK / 2}
|
||||
stroke="#1e88e5" strokeWidth={2} strokeLinecap="round" />
|
||||
{d > 4 && (
|
||||
<g>
|
||||
<rect x={mid.x - lw / 2} y={mid.y - LH2 / 2} width={lw} height={LH2}
|
||||
rx={5} fill="#1e88e5" stroke="white" strokeWidth={1} />
|
||||
<text x={mid.x} y={mid.y - 6}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
fill="white" fontSize={12}
|
||||
fontFamily="'Roboto Mono','Consolas',monospace" fontWeight={600}
|
||||
style={{ userSelect: 'none' }}>
|
||||
{distLabel}
|
||||
</text>
|
||||
<text x={mid.x} y={mid.y + 13}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
fill="rgba(255,255,255,0.85)" fontSize={11}
|
||||
fontFamily="'Roboto Mono','Consolas',monospace" fontWeight={500}
|
||||
style={{ userSelect: 'none' }}>
|
||||
{`∠ ${ang.toFixed(1)}°`}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const RulerOverlay = React.forwardRef<RulerOverlayHandle, RulerOverlayProps>(
|
||||
({ containerRef, isActive, pageMeasureScales }, ref) => {
|
||||
const [measurements, setMeasurements] = useState<Measurement[]>([]);
|
||||
const [firstPt, setFirstPt] = useState<PagePoint | null>(null);
|
||||
/** Current cursor in SVG screen-space — for live crosshair and live line rendering. */
|
||||
const [cursorS, setCursorS] = useState<Point | null>(null);
|
||||
/** Current cursor in page-relative PDF units — for finalising off-page clicks. */
|
||||
const [cursorDoc, setCursorDoc] = useState<PagePoint | null>(null);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Incremented on scroll to trigger re-renders.
|
||||
* We no longer store the scroll value — getBoundingClientRect handles that
|
||||
* automatically and is always accurate regardless of scroll position.
|
||||
*/
|
||||
const [, setScrollVersion] = useState(0);
|
||||
|
||||
const scrollElRef = useRef<HTMLElement | null>(null);
|
||||
const scrollCleanupRef = useRef<(() => void) | null>(null);
|
||||
const idCounter = useRef(0);
|
||||
|
||||
const firstPtRef = useRef<PagePoint | null>(null);
|
||||
useEffect(() => { firstPtRef.current = firstPt; }, [firstPt]);
|
||||
|
||||
const cursorDocRef = useRef<PagePoint | null>(null);
|
||||
|
||||
// ── Zoom ──────────────────────────────────────────────────────────────────
|
||||
const viewer = useViewer();
|
||||
const { registerImmediateZoomUpdate } = viewer;
|
||||
|
||||
const [zoom, setZoom] = useState<number>(() => {
|
||||
try { return ((viewer.getZoomState() as any)?.zoomPercent ?? 140) / 100; }
|
||||
catch { return 1.4; }
|
||||
});
|
||||
|
||||
const zoomRef = useRef(zoom);
|
||||
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
|
||||
|
||||
useEffect(() => {
|
||||
return registerImmediateZoomUpdate((pct) => {
|
||||
const newZoom = pct / 100;
|
||||
zoomRef.current = newZoom; // immediate for event-listener closures
|
||||
setZoom(newZoom); // re-render #1: zoom updated, but PDF.js DOM may not be yet
|
||||
// re-render #2: after PDF.js has updated page element dimensions in the DOM,
|
||||
// so getBoundingClientRect returns the correct positions for the new zoom level.
|
||||
requestAnimationFrame(() => setScrollVersion(n => n + 1));
|
||||
});
|
||||
}, [registerImmediateZoomUpdate]);
|
||||
|
||||
// ── Scroll tracking ────────────────────────────────────────────────────────
|
||||
// We only need re-renders on scroll; getBoundingClientRect gives us accurate
|
||||
// positions without needing to know the scroll offset ourselves.
|
||||
|
||||
const attachScrollEl = useCallback((el: HTMLElement) => {
|
||||
scrollCleanupRef.current?.();
|
||||
scrollElRef.current = el;
|
||||
const handler = () => setScrollVersion(n => n + 1);
|
||||
el.addEventListener('scroll', handler, { passive: true });
|
||||
scrollCleanupRef.current = () => el.removeEventListener('scroll', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const tryAttach = () => {
|
||||
const el = findScrollEl(container);
|
||||
if (el) { attachScrollEl(el); return true; }
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!tryAttach()) {
|
||||
const timer = setTimeout(() => tryAttach(), 600);
|
||||
return () => { clearTimeout(timer); scrollCleanupRef.current?.(); };
|
||||
}
|
||||
return () => scrollCleanupRef.current?.();
|
||||
}, [containerRef, attachScrollEl]);
|
||||
|
||||
// Re-find scroll element when zoom changes (PDF.js may recreate the scroll DOM).
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const el = findScrollEl(container);
|
||||
if (el && el !== scrollElRef.current) attachScrollEl(el);
|
||||
}, [zoom, containerRef, attachScrollEl]);
|
||||
|
||||
// ── Imperative handle ──────────────────────────────────────────────────────
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
clearAll: () => { setMeasurements([]); setFirstPt(null); setCursorS(null); setCursorDoc(null); },
|
||||
}));
|
||||
|
||||
// ── Reset when deactivated ─────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!isActive) { setFirstPt(null); setCursorS(null); setCursorDoc(null); }
|
||||
}, [isActive]);
|
||||
|
||||
// ── Mouse events ───────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!isActive || !el) return;
|
||||
|
||||
const toScreenPt = (e: MouseEvent): Point => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a mouse event to a page-relative PagePoint.
|
||||
* Returns null if the cursor is not directly over a page element.
|
||||
*/
|
||||
const toDocPagePt = (e: MouseEvent): PagePoint | null => {
|
||||
const pageEl = (e.target as Element).closest?.('[data-page-index]') as HTMLElement | null;
|
||||
if (!pageEl) return null;
|
||||
const pageIndex = parseInt(pageEl.dataset.pageIndex ?? '0', 10);
|
||||
const r = pageEl.getBoundingClientRect();
|
||||
const z = zoomRef.current;
|
||||
return { pageIndex, x: (e.clientX - r.left) / z, y: (e.clientY - r.top) / z };
|
||||
};
|
||||
|
||||
const clearCursor = () => {
|
||||
setCursorS(null);
|
||||
setCursorDoc(null);
|
||||
cursorDocRef.current = null;
|
||||
};
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const screenPt = toScreenPt(e);
|
||||
|
||||
if (isOverPage(e)) {
|
||||
el.style.cursor = 'crosshair';
|
||||
const docPt = toDocPagePt(e);
|
||||
setCursorS(screenPt);
|
||||
setCursorDoc(docPt);
|
||||
cursorDocRef.current = docPt;
|
||||
} else if (firstPtRef.current !== null) {
|
||||
// First point placed, cursor wandered off page — clamp to nearest edge
|
||||
el.style.cursor = 'crosshair';
|
||||
const result = nearestPageDocPt(screenPt, el, zoomRef.current);
|
||||
if (result) {
|
||||
setCursorS(result.screenPt);
|
||||
setCursorDoc(result.docPt);
|
||||
cursorDocRef.current = result.docPt;
|
||||
}
|
||||
} else {
|
||||
el.style.cursor = 'default';
|
||||
clearCursor();
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as Element).closest?.('[data-ruler-interactive]')) return;
|
||||
|
||||
const overPage = isOverPage(e);
|
||||
if (!overPage && firstPtRef.current === null) return;
|
||||
e.preventDefault();
|
||||
|
||||
const dp = overPage ? toDocPagePt(e) : cursorDocRef.current;
|
||||
if (!dp) return;
|
||||
|
||||
setFirstPt(prev => {
|
||||
if (!prev) { firstPtRef.current = dp; return dp; }
|
||||
firstPtRef.current = null;
|
||||
const id = `ruler-${++idCounter.current}`;
|
||||
setMeasurements(m => [...m, { id, start: prev, end: dp }]);
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
const onLeave = () => {
|
||||
el.style.cursor = '';
|
||||
if (firstPtRef.current === null) clearCursor();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { setFirstPt(null); setCursorS(null); setCursorDoc(null); }
|
||||
};
|
||||
|
||||
el.addEventListener('mousemove', onMove);
|
||||
el.addEventListener('click', onClick);
|
||||
el.addEventListener('mouseleave', onLeave);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
el.removeEventListener('mousemove', onMove);
|
||||
el.removeEventListener('click', onClick);
|
||||
el.removeEventListener('mouseleave', onLeave);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
el.style.cursor = '';
|
||||
};
|
||||
}, [containerRef, isActive]);
|
||||
|
||||
const deleteMeasurement = useCallback((id: string) => {
|
||||
setMeasurements(prev => prev.filter(m => m.id !== id));
|
||||
}, []);
|
||||
|
||||
if (!isActive && measurements.length === 0) return null;
|
||||
|
||||
// ── PagePoint → SVG screen coordinates ────────────────────────────────────
|
||||
/**
|
||||
* Convert a page-anchored point to SVG screen coordinates.
|
||||
*
|
||||
* Uses getBoundingClientRect so the browser computes the exact screen position
|
||||
* accounting for scroll, zoom, page margins, centering — everything. This is
|
||||
* why we no longer need to track scroll offsets.
|
||||
*
|
||||
* Returns null if the page element isn't in the DOM (shouldn't happen with
|
||||
* PDF.js placeholder divs, but guard anyway).
|
||||
*/
|
||||
const pagePointToScreen = (pt: PagePoint): Point | null => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return null;
|
||||
const pageEl = container.querySelector(`[data-page-index="${pt.pageIndex}"]`) as HTMLElement | null;
|
||||
if (!pageEl) return null;
|
||||
const pageRect = pageEl.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
return {
|
||||
x: pageRect.left - containerRect.left + pt.x * zoom,
|
||||
y: pageRect.top - containerRect.top + pt.y * zoom,
|
||||
};
|
||||
};
|
||||
|
||||
const firstPtS = firstPt ? pagePointToScreen(firstPt) : null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
overflow: 'visible',
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<filter id="ruler-shadow" x="-20%" y="-50%" width="140%" height="200%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" floodColor="rgba(0,0,0,0.22)" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Completed measurements */}
|
||||
{measurements.map(m => {
|
||||
const startS = pagePointToScreen(m.start);
|
||||
const endS = pagePointToScreen(m.end);
|
||||
if (!startS || !endS) return null;
|
||||
const mScale = pageMeasureScales ? pickScale(m.start, m.end, pageMeasureScales) : null;
|
||||
return (
|
||||
<MeasurementLine
|
||||
key={m.id}
|
||||
id={m.id}
|
||||
startS={startS}
|
||||
endS={endS}
|
||||
distPts={dist(startS, endS) / zoom}
|
||||
hovered={hoveredId === m.id}
|
||||
onDelete={deleteMeasurement}
|
||||
onHover={setHoveredId}
|
||||
measureScale={mScale}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Live line while drawing */}
|
||||
{isActive && firstPtS && cursorS && (
|
||||
<LiveLine
|
||||
startS={firstPtS} endS={cursorS} zoom={zoom}
|
||||
measureScale={pageMeasureScales && firstPt && cursorDoc
|
||||
? pickScale(firstPt, cursorDoc, pageMeasureScales)
|
||||
: null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* First-point anchor dot */}
|
||||
{isActive && firstPtS && (
|
||||
<circle cx={firstPtS.x} cy={firstPtS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
|
||||
)}
|
||||
|
||||
{/* Crosshair */}
|
||||
{isActive && cursorS && (
|
||||
<g opacity={0.75}>
|
||||
<line x1={cursorS.x - 12} y1={cursorS.y} x2={cursorS.x + 12} y2={cursorS.y} stroke="#1e88e5" strokeWidth={1.5} />
|
||||
<line x1={cursorS.x} y1={cursorS.y - 12} x2={cursorS.x} y2={cursorS.y + 12} stroke="#1e88e5" strokeWidth={1.5} />
|
||||
<circle cx={cursorS.x} cy={cursorS.y} r={2} fill="#1e88e5" />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Clear all */}
|
||||
{measurements.length > 0 && (
|
||||
<g data-ruler-interactive="true" style={{ pointerEvents: 'all', cursor: 'pointer' }}
|
||||
onClick={(e) => { e.stopPropagation(); setMeasurements([]); }}>
|
||||
<rect x={8} y={8} width={88} height={26} rx={5}
|
||||
fill="rgba(239,83,80,0.9)" stroke="white" strokeWidth={1} />
|
||||
<text x={52} y={25} textAnchor="middle" fill="white" fontSize={12}
|
||||
fontFamily="sans-serif" fontWeight={600} style={{ userSelect: 'none' }}>
|
||||
Clear all
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
RulerOverlay.displayName = 'RulerOverlay';
|
||||
@@ -14,8 +14,12 @@ import { useNavigationState, useNavigationGuard } from '@app/contexts/Navigation
|
||||
import { BASE_PATH, withBasePath } from '@app/constants/app';
|
||||
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import StraightenIcon from '@mui/icons-material/Straighten';
|
||||
|
||||
export function useViewerRightRailButtons() {
|
||||
export function useViewerRightRailButtons(
|
||||
isRulerActive?: boolean,
|
||||
setIsRulerActive?: (v: boolean) => void,
|
||||
) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const viewer = useViewer();
|
||||
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
|
||||
@@ -82,6 +86,8 @@ export function useViewerRightRailButtons() {
|
||||
|
||||
const isFormFillActive = (selectedTool as string) === 'formFill';
|
||||
|
||||
const rulerLabel = t('rightRail.ruler', 'Ruler / Measure');
|
||||
|
||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
const buttons: RightRailButtonWithAction[] = [
|
||||
{
|
||||
@@ -137,6 +143,24 @@ export function useViewerRightRailButtons() {
|
||||
setIsPanning(prev => !prev);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'viewer-ruler',
|
||||
icon: <StraightenIcon sx={{ fontSize: '1.5rem' }} />,
|
||||
tooltip: rulerLabel,
|
||||
ariaLabel: rulerLabel,
|
||||
section: 'top' as const,
|
||||
order: 25,
|
||||
active: Boolean(isRulerActive),
|
||||
onClick: () => {
|
||||
const next = !isRulerActive;
|
||||
setIsRulerActive?.(next);
|
||||
// Disable pan when activating ruler — they conflict
|
||||
if (next && viewer.getPanState()?.isPanning) {
|
||||
viewer.panActions.togglePan();
|
||||
setIsPanning(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'viewer-rotate-left',
|
||||
icon: <LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />,
|
||||
@@ -317,6 +341,9 @@ export function useViewerRightRailButtons() {
|
||||
redactionActiveType,
|
||||
formFillLabel,
|
||||
isFormFillActive,
|
||||
rulerLabel,
|
||||
isRulerActive,
|
||||
setIsRulerActive,
|
||||
]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface AppConfig {
|
||||
isNewUser?: boolean;
|
||||
defaultHideUnavailableTools?: boolean;
|
||||
defaultHideUnavailableConversions?: boolean;
|
||||
pluginsPath?: string;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
interface PluginResponse {
|
||||
id: string;
|
||||
icon: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
frontendUrl?: string;
|
||||
frontendLabel?: string;
|
||||
iconPath?: string;
|
||||
hasFrontend?: boolean;
|
||||
backendEndpoints?: string[];
|
||||
minHostVersion: string;
|
||||
iconUrl?: string;
|
||||
jarCreatedAt?: string;
|
||||
}
|
||||
|
||||
export interface PluginInfo {
|
||||
icon: ReactNode;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
hasFrontend: boolean;
|
||||
frontendUrl?: string;
|
||||
frontendLabel?: string;
|
||||
iconPath?: string;
|
||||
backendEndpoints: string[];
|
||||
minHostVersion: string;
|
||||
iconUrl?: string;
|
||||
jarCreatedAt?: string;
|
||||
}
|
||||
|
||||
interface PluginRegistryState {
|
||||
plugins: PluginInfo[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PluginRegistryContext = createContext<PluginRegistryState | undefined>(undefined);
|
||||
|
||||
const buildFrontendUrl = (response: PluginResponse): string | undefined => {
|
||||
if (!response.frontendUrl) {
|
||||
return undefined;
|
||||
}
|
||||
return response.frontendUrl;
|
||||
};
|
||||
|
||||
const buildIconUrl = (path?: string, baseUrl?: string): string | undefined => {
|
||||
if (!path) return undefined;
|
||||
if (path.startsWith("http")) return path;
|
||||
const normalizedBase =
|
||||
baseUrl?.replace(/\/+$/, "") || (typeof window !== "undefined" ? window.location.origin.replace(/\/+$/, "") : "");
|
||||
if (!normalizedBase) return path;
|
||||
return `${normalizedBase}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
};
|
||||
|
||||
const buildPluginInfo = (response: PluginResponse, baseUrl?: string): PluginInfo => {
|
||||
const frontendBase = response.frontendUrl
|
||||
? new URL(response.frontendUrl).origin
|
||||
: baseUrl;
|
||||
|
||||
return {
|
||||
id: response.id,
|
||||
icon: response.icon,
|
||||
name: response.name,
|
||||
description: response.description || "",
|
||||
version: response.version,
|
||||
author: response.author,
|
||||
hasFrontend: Boolean(response.hasFrontend),
|
||||
frontendUrl: buildFrontendUrl(response),
|
||||
frontendLabel: response.frontendLabel,
|
||||
iconPath: response.iconPath,
|
||||
backendEndpoints: response.backendEndpoints ?? [],
|
||||
minHostVersion: response.minHostVersion,
|
||||
iconUrl: buildIconUrl(response.iconPath, frontendBase),
|
||||
jarCreatedAt: response.jarCreatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const PluginRegistryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { loading: configLoading, config } = useAppConfig();
|
||||
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchPlugins = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.get<PluginResponse[]>("/api/v1/config/plugins");
|
||||
const normalized = response.data
|
||||
.map((plugin) => buildPluginInfo(plugin, config?.baseUrl))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
setPlugins(normalized);
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 401) {
|
||||
setPlugins([]);
|
||||
setError(null);
|
||||
} else {
|
||||
const message = err?.response?.data?.message || err?.message || "Unable to load plugins";
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!configLoading) {
|
||||
if (typeof window === "undefined") {
|
||||
void fetchPlugins();
|
||||
return;
|
||||
}
|
||||
|
||||
const path = window.location.pathname;
|
||||
const isAuthPage =
|
||||
path.includes("/login") || path.includes("/signup") || path.includes("/auth/callback") || path.includes("/invite/");
|
||||
|
||||
if (!isAuthPage) {
|
||||
void fetchPlugins();
|
||||
}
|
||||
}
|
||||
}, [configLoading, fetchPlugins]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
plugins,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchPlugins,
|
||||
}),
|
||||
[plugins, loading, error, fetchPlugins],
|
||||
);
|
||||
|
||||
return <PluginRegistryContext.Provider value={value}>{children}</PluginRegistryContext.Provider>;
|
||||
};
|
||||
|
||||
export const usePluginRegistry = () => {
|
||||
const context = useContext(PluginRegistryContext);
|
||||
|
||||
if (context) {
|
||||
return context;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.warn("[PluginRegistryContext] usePluginRegistry called outside PluginRegistryProvider - returning fallback state");
|
||||
}
|
||||
|
||||
return {
|
||||
plugins: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: async () => {},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
import { Box, Button, Group, Text } from "@mantine/core";
|
||||
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PluginInfo, usePluginRegistry } from "@app/contexts/PluginRegistryContext";
|
||||
|
||||
const URL_ATTRIBUTES_TO_NORMALIZE = new Set([
|
||||
"src",
|
||||
"href",
|
||||
"action",
|
||||
"poster",
|
||||
"formaction",
|
||||
"data-src",
|
||||
"data-href",
|
||||
"data-url",
|
||||
"data-background",
|
||||
"xlink:href",
|
||||
"srcset",
|
||||
]);
|
||||
|
||||
const ABSOLUTE_URL_PATTERN = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|\/\/|#)/;
|
||||
|
||||
const resolveRelativeUrl = (value: string, base: string | null): string => {
|
||||
if (!value || !base) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || ABSOLUTE_URL_PATTERN.test(trimmed)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(trimmed, base).toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeSrcset = (value: string, base: string | null): string => {
|
||||
if (!base) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value
|
||||
.split(",")
|
||||
.map((segment) => {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [url, descriptor] = trimmed.split(/\s+/, 2);
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const resolved = resolveRelativeUrl(url, base);
|
||||
return descriptor ? `${resolved} ${descriptor}` : resolved;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
const normalizeStyleUrls = (value: string, base: string | null): string => {
|
||||
if (!base) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.replace(/url\(([^)]+)\)/g, (_, rawUrl) => {
|
||||
const trimmed = rawUrl.trim();
|
||||
const cleaned = trimmed.replace(/^["']|["']$/g, "");
|
||||
const resolved = resolveRelativeUrl(cleaned, base);
|
||||
if (!resolved) {
|
||||
return `url(${rawUrl})`;
|
||||
}
|
||||
return `url("${resolved}")`;
|
||||
});
|
||||
};
|
||||
|
||||
const copyAttributes = (source: Element, target: Element, base: string | null) => {
|
||||
Array.from(source.attributes).forEach((attribute) => {
|
||||
if (attribute.name === "style") {
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerName = attribute.name.toLowerCase();
|
||||
if (URL_ATTRIBUTES_TO_NORMALIZE.has(lowerName)) {
|
||||
if (lowerName === "srcset") {
|
||||
target.setAttribute(attribute.name, normalizeSrcset(attribute.value, base));
|
||||
} else {
|
||||
target.setAttribute(attribute.name, resolveRelativeUrl(attribute.value, base));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
target.setAttribute(attribute.name, attribute.value);
|
||||
});
|
||||
|
||||
const styleValue = source.getAttribute("style");
|
||||
if (styleValue) {
|
||||
target.setAttribute("style", normalizeStyleUrls(styleValue, base));
|
||||
}
|
||||
};
|
||||
|
||||
const DOMTextConstructor = typeof globalThis.Text === "function" ? globalThis.Text : null;
|
||||
const DOMCommentConstructor = typeof globalThis.Comment === "function" ? globalThis.Comment : null;
|
||||
|
||||
const cloneNodeWithResolvedUrls = (node: ChildNode, base: string | null): Node | null => {
|
||||
if (DOMTextConstructor ? node instanceof DOMTextConstructor : node.nodeType === Node.TEXT_NODE) {
|
||||
return document.createTextNode(node.textContent ?? "");
|
||||
}
|
||||
|
||||
if (DOMCommentConstructor ? node instanceof DOMCommentConstructor : node.nodeType === Node.COMMENT_NODE) {
|
||||
return document.createComment(node.textContent ?? "");
|
||||
}
|
||||
|
||||
if (!(node instanceof Element)) {
|
||||
return node.cloneNode(true);
|
||||
}
|
||||
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
if (tagName === "script") {
|
||||
const script = document.createElement("script");
|
||||
copyAttributes(node, script, base);
|
||||
script.text = node.textContent ?? "";
|
||||
return script;
|
||||
}
|
||||
|
||||
const clone = node.cloneNode(false) as Element;
|
||||
copyAttributes(node, clone, base);
|
||||
node.childNodes.forEach((child) => {
|
||||
const normalizedChild = cloneNodeWithResolvedUrls(child, base);
|
||||
if (normalizedChild) {
|
||||
clone.appendChild(normalizedChild);
|
||||
}
|
||||
});
|
||||
return clone;
|
||||
};
|
||||
|
||||
export default function PluginPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const pluginRegistry = usePluginRegistry();
|
||||
const plugins = pluginRegistry?.plugins ?? [];
|
||||
const loading = pluginRegistry?.loading ?? false;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const location = useLocation();
|
||||
const navigationPlugin = location.state?.plugin as PluginInfo | undefined;
|
||||
const plugin = useMemo(() => navigationPlugin ?? plugins.find((p: { id: any; }) => p.id === id), [navigationPlugin, plugins, id]);
|
||||
const pluginBaseUrl = useMemo(() => {
|
||||
if (!plugin?.frontendUrl) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const url = new URL(plugin.frontendUrl);
|
||||
url.hash = "";
|
||||
url.search = "";
|
||||
url.pathname = url.pathname.replace(/\/[^/]*$/, "/");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [plugin?.frontendUrl]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingHtml, setLoadingHtml] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!plugin?.frontendUrl) {
|
||||
console.log("[PluginPage] Plugin missing frontendUrl, skipping fetch");
|
||||
setLoadingHtml(false);
|
||||
containerRef.current?.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const fetchHtml = async () => {
|
||||
try {
|
||||
setLoadingHtml(true);
|
||||
setError(null);
|
||||
console.log(`[PluginPage] Fetching plugin HTML from ${plugin.frontendUrl}`);
|
||||
const response = await fetch(plugin.frontendUrl!, { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText || "Failed to load plugin");
|
||||
}
|
||||
const html = await response.text();
|
||||
if (cancelled) return;
|
||||
console.debug(`[PluginPage] Successfully loaded plugin HTML (${html.length} chars)`);
|
||||
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = "";
|
||||
|
||||
const dom = new DOMParser().parseFromString(html, "text/html");
|
||||
const nodes = [...Array.from(dom.head.childNodes), ...Array.from(dom.body.childNodes)];
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const normalizedNode = cloneNodeWithResolvedUrls(node, pluginBaseUrl);
|
||||
if (normalizedNode) {
|
||||
fragment.appendChild(normalizedNode);
|
||||
}
|
||||
});
|
||||
|
||||
if (pluginBaseUrl) {
|
||||
const backendPrefix = pluginBaseUrl
|
||||
.replace(/\/$/, "")
|
||||
.replace(/\/plugins\/[^/]+\/?$/, "");
|
||||
const bridgeScript = document.createElement("script");
|
||||
bridgeScript.textContent = `
|
||||
window.STIRLING_PDF_PLUGIN_API_BASE = ${JSON.stringify(backendPrefix)};
|
||||
window.STIRLING_PDF_PLUGIN_AUTH_TOKEN =
|
||||
localStorage.getItem("stirling_jwt") ||
|
||||
sessionStorage.getItem("stirling_jwt") ||
|
||||
"";
|
||||
`;
|
||||
containerRef.current.appendChild(bridgeScript);
|
||||
}
|
||||
|
||||
containerRef.current.appendChild(fragment);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
console.error("[PluginPage] Failed to load plugin HTML", err);
|
||||
setError(err?.message || "Unable to load plugin HTML");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoadingHtml(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchHtml();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [plugin, pluginBaseUrl]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Loading plugin…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!plugin) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
height: "100vh",
|
||||
background: "var(--bg-app, #05070a)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700}>Plugin not found</Text>
|
||||
<Button variant="outline" size="xs" onClick={() => navigate("/")}>
|
||||
Back home
|
||||
</Button>
|
||||
</Group>
|
||||
<Text>The requested plugin cannot be loaded right now.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="main"
|
||||
style={{
|
||||
height: "100vh",
|
||||
background: "#05070a",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" style={{ padding: "1rem 1.5rem", borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{plugin.name}
|
||||
</Text>
|
||||
<Text size="sm" color="dimmed">
|
||||
{plugin.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Button size="xs" variant="outline" onClick={() => navigate("/")}>
|
||||
Back to Stirling PDF
|
||||
</Button>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{loadingHtml && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(5, 7, 10, 0.9)",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Text>Loading plugin UI…</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "tomato",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<Text>{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
ref={containerRef}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflow: "auto",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import Signup from "@app/routes/Signup";
|
||||
import AuthCallback from "@app/routes/AuthCallback";
|
||||
import InviteAccept from "@app/routes/InviteAccept";
|
||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||
import PluginPage from "@app/pages/PluginPage";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
|
||||
// Import global styles
|
||||
@@ -60,6 +61,8 @@ export default function App() {
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/invite/:token" element={<InviteAccept />} />
|
||||
|
||||
{/* Plugin route must win before the catch-all Landing */}
|
||||
<Route path="/plugins/:id" element={<PluginPage />} />
|
||||
{/* Main app routes - Landing handles auth logic */}
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
|
||||
Reference in New Issue
Block a user