Compare commits

...
Author SHA1 Message Date
Ludy87 93d816750f Sync provider settings and fix default initialization
Added useEffect to ProviderCard to keep local settings in sync with incoming settings when collapsed. Improved default settings initialization to preserve values from settings.yml. Passed settings prop to ProviderCard in AdminConnectionsSection to ensure correct settings are used.
2025-12-21 00:00:42 +01:00
Anthony StirlingandGitHub a5dcdd5bd9 Revert "Sync provider settings with settings.yml in config UI" (#5271)
Reverts Stirling-Tools/Stirling-PDF#5270
2025-12-20 22:21:45 +00:00
LudyandGitHub 5eafbf9e51 Sync provider settings with settings.yml in config UI (#5270)
# Description of Changes

This pull request improves the handling of provider settings in the
`ProviderCard` component to ensure settings are always in sync with the
latest values from `settings.yml`, and enhances how default values are
initialized for provider fields. It also updates the usage of
`ProviderCard` in the admin connections section to explicitly pass
provider settings.

**Provider settings synchronization and initialization:**

* Added a `useEffect` in `ProviderCard` to keep `localSettings` in sync
with incoming `settings` prop, ensuring that changes from `settings.yml`
are reflected in the UI.
* Improved default value initialization in `ProviderCard`: when opening
an unconfigured provider, it now merges defaults with any existing
values from `settings.yml`, rather than overwriting them.

**Integration with AdminConnectionsSection:**

* Updated `AdminConnectionsSection` to explicitly pass provider settings
to `ProviderCard` via the `settings` prop, ensuring the component always
receives the correct configuration.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-12-20 22:08:35 +00:00
Dario Ghunney WareandGitHub a1317f0e01 Bumping version (#5268) 2025-12-18 20:43:10 +00:00
Reece BrowneandGitHub 3529849bca Feature/annotations (#5260) 2025-12-18 15:47:54 +00:00
Stephan PaternotteandGitHub 49bea34576 Update translation.toml (#5254)
Updated translations of latest additions,
Saintized lots of accented letters,
Brought consistency in formal addressing of the user
2025-12-17 13:37:47 +00:00
f9a44c4da4 Saml fixes (#5256)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2025-12-17 10:52:48 +00:00
Anthony StirlingandGitHub 4ec75d4d8c allow static overrides (#5258)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-12-17 10:07:51 +00:00
34 changed files with 4080 additions and 423 deletions
@@ -37,10 +37,6 @@ public class AppConfig {
private final ApplicationProperties applicationProperties;
@Getter
@Value("${baseUrl:http://localhost}")
private String baseUrl;
@Getter
@Value("${server.servlet.context-path:/}")
private String contextPath;
@@ -49,6 +45,17 @@ public class AppConfig {
@Value("${server.port:8080}")
private String serverPort;
/**
* Get the backend URL from system configuration. Falls back to http://localhost if not
* configured.
*
* @return The backend base URL for SAML/OAuth/API callbacks
*/
public String getBackendUrl() {
String backendUrl = applicationProperties.getSystem().getBackendUrl();
return (backendUrl != null && !backendUrl.isBlank()) ? backendUrl : "http://localhost";
}
@Value("${v2}")
public boolean v2Enabled;
@@ -138,13 +138,13 @@ public class SPDFApplication {
@PostConstruct
public void init() {
String baseUrl = appConfig.getBaseUrl();
String backendUrl = appConfig.getBackendUrl();
String contextPath = appConfig.getContextPath();
String serverPort = appConfig.getServerPort();
baseUrlStatic = baseUrl;
baseUrlStatic = backendUrl;
contextPathStatic = contextPath;
serverPortStatic = serverPort;
String url = baseUrl + ":" + getStaticPort() + contextPath;
String url = backendUrl + ":" + getStaticPort() + contextPath;
// Log Tauri mode information
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
@@ -33,14 +33,35 @@ public class WebMvcConfig implements WebMvcConfigurer {
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Cache hashed assets (JS/CSS with content hashes) for 1 year
// These files have names like index-ChAS4tCC.js that change when content changes
// Check customFiles/static first, then fall back to classpath
registry.addResourceHandler("/assets/**")
.addResourceLocations("classpath:/static/assets/")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath()
+ "assets/",
"classpath:/static/assets/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
// Note: index.html is handled by ReactRoutingController for dynamic processing
registry.addResourceHandler("/index.html")
.addResourceLocations("classpath:/static/")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath(),
"classpath:/static/")
.setCacheControl(CacheControl.noCache().mustRevalidate());
// Handle all other static resources (js, css, images, fonts, etc.)
// Check customFiles/static first for user overrides
registry.addResourceHandler("/**")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath(),
"classpath:/static/")
.setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS));
}
@Override
@@ -66,7 +66,8 @@ public class ConfigController {
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
// Extract key configuration values from AppConfig
configData.put("baseUrl", appConfig.getBaseUrl());
// Note: Frontend expects "baseUrl" field name for compatibility
configData.put("baseUrl", appConfig.getBackendUrl());
configData.put("contextPath", appConfig.getContextPath());
configData.put("serverPort", appConfig.getServerPort());
@@ -3,9 +3,14 @@ package stirling.software.SPDF.controller.web;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
@@ -14,6 +19,11 @@ import org.springframework.web.bind.annotation.GetMapping;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
@Slf4j
@Controller
public class ReactRoutingController {
@@ -22,24 +32,44 @@ public class ReactRoutingController {
private String cachedIndexHtml;
private boolean indexHtmlExists = false;
private boolean useExternalIndexHtml = false;
@PostConstruct
public void init() {
// Only cache if index.html exists (production builds)
log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath());
// Check for external index.html first (customFiles/static/)
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
log.debug("Checking for custom index.html at: {}", externalIndexPath);
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
log.info("Using custom index.html from: {}", externalIndexPath);
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = true;
return;
} catch (IOException e) {
log.warn("Failed to load custom index.html, falling back to classpath", e);
}
}
// Fall back to classpath index.html
ClassPathResource resource = new ClassPathResource("static/index.html");
if (resource.exists()) {
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = false;
} catch (IOException e) {
// Failed to cache, will process on each request
log.warn("Failed to cache index.html", e);
this.indexHtmlExists = false;
}
}
}
private String processIndexHtml() throws IOException {
ClassPathResource resource = new ClassPathResource("static/index.html");
Resource resource = getIndexHtmlResource();
try (InputStream inputStream = resource.getInputStream()) {
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
@@ -62,6 +92,17 @@ public class ReactRoutingController {
}
}
private Resource getIndexHtmlResource() throws IOException {
// Check external location first
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
return new FileSystemResource(externalIndexPath.toFile());
}
// Fall back to classpath
return new ClassPathResource("static/index.html");
}
@GetMapping(
value = {"/", "/index.html"},
produces = MediaType.TEXT_HTML_VALUE)
@@ -198,7 +198,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
private SamlClient getSamlClient(
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
throws SamlException {
String serverUrl = appConfig.getBaseUrl() + ":" + appConfig.getServerPort();
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
String relyingPartyIdentifier =
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
@@ -344,7 +344,8 @@ public class SecurityConfiguration {
log.error("Error configuring SAML 2 login", e);
throw new RuntimeException(e);
}
});
})
.saml2Metadata(metadata -> {});
}
} else {
log.debug("Login is not enabled.");
@@ -102,16 +102,31 @@ public class Saml2Configuration {
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
}
// Get backend URL from configuration (for SAML endpoints)
String backendUrl = applicationProperties.getSystem().getBackendUrl();
if (backendUrl == null || backendUrl.isBlank()) {
backendUrl = "{baseUrl}"; // Fallback to Spring's auto-resolution
log.warn(
"system.backendUrl not configured - SAML metadata will use request-based URLs. Set system.backendUrl for production use.");
} else {
log.info("Using configured backend URL for SAML: {}", backendUrl);
}
String entityId =
backendUrl + "/saml2/service-provider-metadata/" + samlConf.getRegistrationId();
String acsLocation = backendUrl + "/login/saml2/sso/{registrationId}";
String sloResponseLocation = backendUrl + "/login";
RelyingPartyRegistration rp =
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
.signingX509Credentials(c -> c.add(signingCredential))
.entityId(samlConf.getIdpIssuer())
.entityId(entityId)
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation("{baseUrl}/login")
.singleLogoutServiceResponseLocation(sloResponseLocation)
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
.assertionConsumerServiceLocation(
"{baseUrl}/login/saml2/sso/{registrationId}")
.assertionConsumerServiceLocation(acsLocation)
.authnRequestsSigned(true)
.assertingPartyMetadata(
metadata ->
@@ -127,7 +142,7 @@ public class Saml2Configuration {
.singleLogoutServiceLocation(
samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation(
"{baseUrl}/login")
sloResponseLocation)
.wantAuthnRequestsSigned(true))
.build();
@@ -51,7 +51,8 @@ class UserLicenseSettingsServiceTest {
when(applicationProperties.getPremium()).thenReturn(premium);
when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated);
when(automaticallyGenerated.getIsNewServer()).thenReturn(false); // Default: not a new server
when(automaticallyGenerated.getIsNewServer())
.thenReturn(false); // Default: not a new server
when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
when(userService.getTotalUsersCount()).thenReturn(80L);
when(settingsRepository.save(any(UserLicenseSettings.class)))
+1 -1
View File
@@ -59,7 +59,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.1.4'
version = '2.1.5'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
+83 -9
View File
@@ -736,6 +736,11 @@ tags = "signature,autograph"
title = "Sign"
desc = "Adds signature to PDF by drawing, text or image"
[home.annotate]
tags = "annotate,highlight,draw"
title = "Annotate"
desc = "Highlight, draw, add notes and shapes in the viewer"
[home.flatten]
tags = "simplify,remove,interactive"
title = "Flatten"
@@ -4013,23 +4018,92 @@ deleteSelected = "Delete Selected Pages"
closePdf = "Close PDF"
exportAll = "Export PDF"
downloadSelected = "Download Selected Files"
downloadAll = "Download All"
saveAll = "Save All"
annotations = "Annotations"
exportSelected = "Export Selected Pages"
saveChanges = "Save Changes"
toggleTheme = "Toggle Theme"
toggleBookmarks = "Toggle Bookmarks"
language = "Language"
toggleAnnotations = "Toggle Annotations Visibility"
search = "Search PDF"
panMode = "Pan Mode"
rotateLeft = "Rotate Left"
rotateRight = "Rotate Right"
toggleSidebar = "Toggle Sidebar"
exportSelected = "Export Selected Pages"
toggleAnnotations = "Toggle Annotations Visibility"
annotationMode = "Toggle Annotation Mode"
toggleBookmarks = "Toggle Bookmarks"
print = "Print PDF"
draw = "Draw"
save = "Save"
saveChanges = "Save Changes"
downloadAll = "Download All"
saveAll = "Save All"
[textAlign]
left = "Left"
center = "Center"
right = "Right"
[annotation]
title = "Annotate"
desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required."
highlight = "Highlight"
pen = "Pen"
text = "Text box"
note = "Note"
rectangle = "Rectangle"
ellipse = "Ellipse"
select = "Select"
exit = "Exit annotation mode"
strokeWidth = "Width"
opacity = "Opacity"
strokeOpacity = "Stroke Opacity"
fillOpacity = "Fill Opacity"
fontSize = "Font size"
chooseColor = "Choose colour"
color = "Colour"
strokeColor = "Stroke Colour"
fillColor = "Fill Colour"
underline = "Underline"
strikeout = "Strikeout"
squiggly = "Squiggly"
inkHighlighter = "Freehand Highlighter"
freehandHighlighter = "Freehand Highlighter"
square = "Square"
circle = "Circle"
polygon = "Polygon"
line = "Line"
stamp = "Add Image"
textMarkup = "Text Markup"
drawing = "Drawing"
shapes = "Shapes"
notesStamps = "Notes & Stamps"
settings = "Settings"
borderOn = "Border: On"
borderOff = "Border: Off"
editInk = "Edit Pen"
editLine = "Edit Line"
editNote = "Edit Note"
editText = "Edit Text Box"
editTextMarkup = "Edit Text Markup"
editSelected = "Edit Annotation"
editSquare = "Edit Square"
editCircle = "Edit Circle"
editPolygon = "Edit Polygon"
unsupportedType = "This annotation type is not fully supported for editing."
textAlignment = "Text Alignment"
noteIcon = "Note Icon"
imagePreview = "Preview"
contents = "Text"
backgroundColor = "Background colour"
clearBackground = "Remove background"
noBackground = "No background"
stampSettings = "Stamp Settings"
savingCopy = "Preparing download..."
saveFailed = "Unable to save copy"
saveReady = "Download ready"
selectAndMove = "Select and Edit"
editSelectDescription = "Click an existing annotation to edit its colour, opacity, text, or size."
editStampHint = "To change the image, delete this stamp and add a new one."
editSwitchToSelect = "Switch to Select & Edit to edit this annotation."
undo = "Undo"
redo = "Redo"
applyChanges = "Apply Changes"
[search]
title = "Search PDF"
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -12,6 +12,7 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from
import { RightRailProvider } from "@app/contexts/RightRailContext";
import { ViewerProvider } from "@app/contexts/ViewerContext";
import { SignatureProvider } from "@app/contexts/SignatureContext";
import { AnnotationProvider } from "@app/contexts/AnnotationContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
@@ -95,13 +96,15 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
<AnnotationProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</AnnotationProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
@@ -1,5 +1,5 @@
import React from 'react';
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface ColorPickerProps {
@@ -8,6 +8,10 @@ interface ColorPickerProps {
selectedColor: string;
onColorChange: (color: string) => void;
title?: string;
opacity?: number;
onOpacityChange?: (opacity: number) => void;
showOpacity?: boolean;
opacityLabel?: string;
}
export const ColorPicker: React.FC<ColorPickerProps> = ({
@@ -15,10 +19,15 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
onClose,
selectedColor,
onColorChange,
title
title,
opacity,
onOpacityChange,
showOpacity = false,
opacityLabel,
}) => {
const { t } = useTranslation();
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
const resolvedOpacityLabel = opacityLabel ?? t('annotation.opacity', 'Opacity');
return (
<Modal
@@ -38,6 +47,23 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
size="lg"
fullWidth
/>
{showOpacity && onOpacityChange && opacity !== undefined && (
<Stack gap="xs">
<Text size="sm" fw={500}>{resolvedOpacityLabel}</Text>
<Slider
min={10}
max={100}
value={opacity}
onChange={onOpacityChange}
marks={[
{ value: 25, label: '25%' },
{ value: 50, label: '50%' },
{ value: 75, label: '75%' },
{ value: 100, label: '100%' },
]}
/>
</Stack>
)}
<Group justify="flex-end">
<Button onClick={onClose}>
{t('common.done', 'Done')}
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
interface TextInputWithFontProps {
@@ -11,6 +12,8 @@ interface TextInputWithFontProps {
onFontFamilyChange: (family: string) => void;
textColor?: string;
onTextColorChange?: (color: string) => void;
textAlign?: 'left' | 'center' | 'right';
onTextAlignChange?: (align: 'left' | 'center' | 'right') => void;
disabled?: boolean;
label: string;
placeholder: string;
@@ -30,6 +33,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
onFontFamilyChange,
textColor = '#000000',
onTextColorChange,
textAlign = 'left',
onTextAlignChange,
disabled = false,
label,
placeholder,
@@ -39,6 +44,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
colorLabel,
onAnyChange
}) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
@@ -212,6 +218,23 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
}}
/>
)}
{/* Text Alignment */}
{onTextAlignChange && (
<SegmentedControl
value={textAlign}
onChange={(value: string) => {
onTextAlignChange(value as 'left' | 'center' | 'right');
onAnyChange?.();
}}
disabled={disabled}
data={[
{ label: t('textAlign.left', 'Left'), value: 'left' },
{ label: t('textAlign.center', 'Center'), value: 'center' },
{ label: t('textAlign.right', 'Right'), value: 'right' },
]}
/>
)}
</Stack>
);
};
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Paper, Group, Text, Button, Collapse, Stack, TextInput, Textarea, Switch, PasswordInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
@@ -25,14 +25,22 @@ export default function ProviderCard({
const [expanded, setExpanded] = useState(false);
const [localSettings, setLocalSettings] = useState<Record<string, any>>(settings);
// Keep local settings in sync with incoming settings (values loaded from settings.yml)
useEffect(() => {
if (!expanded) {
setLocalSettings(settings);
}
}, [settings, expanded]);
// Initialize local settings with defaults when opening an unconfigured provider
const handleConnectToggle = () => {
if (!isConfigured && !expanded) {
// First time opening an unconfigured provider - initialize with defaults
const defaultSettings: Record<string, any> = {};
// while preserving any values already present (from settings.yml)
const defaultSettings: Record<string, any> = { ...settings };
provider.fields.forEach((field) => {
if (field.defaultValue !== undefined) {
defaultSettings[field.key] = field.defaultValue;
defaultSettings[field.key] = defaultSettings[field.key] ?? field.defaultValue;
}
});
setLocalSettings(defaultSettings);
@@ -25,6 +25,12 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
const { selectedTool } = useNavigationState();
const isSignMode = selectedTool === 'sign';
// Check if we're in any annotation tool that should disable the toggle
const isInAnnotationTool = selectedTool === 'annotate' || selectedTool === 'sign' || selectedTool === 'addImage' || selectedTool === 'addText';
// Check if we're on annotate tool to highlight the button
const isAnnotateActive = selectedTool === 'annotate';
// Don't show any annotation controls in sign mode
if (isSignMode) {
return null;
@@ -35,13 +41,14 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
{/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
variant={isAnnotateActive ? "filled" : "subtle"}
color="blue"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={disabled || currentView !== 'viewer'}
disabled={disabled || currentView !== 'viewer' || isInAnnotationTool}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
@@ -0,0 +1,352 @@
import { useImperativeHandle, forwardRef, useCallback } from 'react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype, PdfAnnotationIcon } from '@embedpdf/models';
import type {
AnnotationToolId,
AnnotationToolOptions,
AnnotationAPI,
AnnotationEvent,
AnnotationPatch,
} from '@app/components/viewer/viewerTypes';
type NoteIcon = NonNullable<AnnotationToolOptions['icon']>;
type AnnotationDefaults =
| {
type:
| PdfAnnotationSubtype.HIGHLIGHT
| PdfAnnotationSubtype.UNDERLINE
| PdfAnnotationSubtype.STRIKEOUT
| PdfAnnotationSubtype.SQUIGGLY;
color: string;
opacity: number;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.INK;
color: string;
opacity?: number;
borderWidth?: number;
strokeWidth?: number;
lineWidth?: number;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.FREETEXT;
fontColor?: string;
fontSize?: number;
fontFamily?: string;
textAlign?: number;
opacity?: number;
backgroundColor?: string;
borderWidth?: number;
contents?: string;
icon?: PdfAnnotationIcon;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.SQUARE | PdfAnnotationSubtype.CIRCLE | PdfAnnotationSubtype.POLYGON;
color: string;
strokeColor: string;
opacity: number;
fillOpacity: number;
strokeOpacity: number;
borderWidth: number;
strokeWidth: number;
lineWidth: number;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.LINE | PdfAnnotationSubtype.POLYLINE;
color: string;
strokeColor?: string;
opacity: number;
borderWidth?: number;
strokeWidth?: number;
lineWidth?: number;
startStyle?: string;
endStyle?: string;
lineEndingStyles?: { start: string; end: string };
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.STAMP;
imageSrc?: string;
imageSize?: { width: number; height: number };
customData?: Record<string, unknown>;
}
| null;
type AnnotationApiSurface = {
setActiveTool: (toolId: AnnotationToolId | null) => void;
getActiveTool?: () => { id: AnnotationToolId } | null;
setToolDefaults?: (toolId: AnnotationToolId, defaults: AnnotationDefaults) => void;
getSelectedAnnotation?: () => unknown | null;
deselectAnnotation?: () => void;
updateAnnotation?: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
};
type ToolDefaultsBuilder = (options?: AnnotationToolOptions) => AnnotationDefaults;
const NOTE_ICON_MAP: Record<NoteIcon, PdfAnnotationIcon> = {
Comment: PdfAnnotationIcon.Comment,
Key: PdfAnnotationIcon.Key,
Note: PdfAnnotationIcon.Note,
Help: PdfAnnotationIcon.Help,
NewParagraph: PdfAnnotationIcon.NewParagraph,
Paragraph: PdfAnnotationIcon.Paragraph,
Insert: PdfAnnotationIcon.Insert,
};
const DEFAULTS = {
highlight: '#ffd54f',
underline: '#ffb300',
strikeout: '#e53935',
squiggly: '#00acc1',
ink: '#1f2933',
inkHighlighter: '#ffd54f',
text: '#111111',
note: '#ffd54f', // match highlight color
shapeFill: '#0000ff',
shapeStroke: '#cf5b5b',
shapeOpacity: 0.5,
};
const withCustomData = (options?: AnnotationToolOptions) =>
options?.customData ? { customData: options.customData } : {};
const getIconEnum = (icon?: NoteIcon) => NOTE_ICON_MAP[icon ?? 'Comment'] ?? PdfAnnotationIcon.Comment;
const buildStampDefaults: ToolDefaultsBuilder = (options) => ({
type: PdfAnnotationSubtype.STAMP,
...(options?.imageSrc ? { imageSrc: options.imageSrc } : {}),
...(options?.imageSize ? { imageSize: options.imageSize } : {}),
...withCustomData(options),
});
const buildInkDefaults = (options?: AnnotationToolOptions, opacityOverride?: number): AnnotationDefaults => ({
type: PdfAnnotationSubtype.INK,
color: options?.color ?? (opacityOverride ? DEFAULTS.inkHighlighter : DEFAULTS.ink),
opacity: options?.opacity ?? opacityOverride ?? 1,
borderWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
strokeWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
lineWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
...withCustomData(options),
});
const TOOL_DEFAULT_BUILDERS: Record<AnnotationToolId, ToolDefaultsBuilder> = {
select: () => null,
highlight: (options) => ({
type: PdfAnnotationSubtype.HIGHLIGHT,
color: options?.color ?? DEFAULTS.highlight,
opacity: options?.opacity ?? 0.6,
...withCustomData(options),
}),
underline: (options) => ({
type: PdfAnnotationSubtype.UNDERLINE,
color: options?.color ?? DEFAULTS.underline,
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
strikeout: (options) => ({
type: PdfAnnotationSubtype.STRIKEOUT,
color: options?.color ?? DEFAULTS.strikeout,
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
squiggly: (options) => ({
type: PdfAnnotationSubtype.SQUIGGLY,
color: options?.color ?? DEFAULTS.squiggly,
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
ink: (options) => buildInkDefaults(options),
inkHighlighter: (options) => buildInkDefaults(options, options?.opacity ?? 0.6),
text: (options) => ({
type: PdfAnnotationSubtype.FREETEXT,
fontColor: options?.color ?? DEFAULTS.text,
fontSize: options?.fontSize ?? 14,
fontFamily: options?.fontFamily ?? 'Helvetica',
textAlign: options?.textAlign ?? 0,
opacity: options?.opacity ?? 1,
borderWidth: options?.thickness ?? 1,
...(options?.fillColor ? { backgroundColor: options.fillColor } : {}),
...withCustomData(options),
}),
note: (options) => {
const backgroundColor = options?.fillColor ?? DEFAULTS.note;
const fontColor = options?.color ?? DEFAULTS.text;
return {
type: PdfAnnotationSubtype.FREETEXT,
fontColor,
color: fontColor,
fontFamily: options?.fontFamily ?? 'Helvetica',
textAlign: options?.textAlign ?? 0,
fontSize: options?.fontSize ?? 12,
opacity: options?.opacity ?? 1,
backgroundColor,
borderWidth: options?.thickness ?? 0,
contents: options?.contents ?? 'Note',
icon: getIconEnum(options?.icon),
...withCustomData(options),
};
},
square: (options) => ({
type: PdfAnnotationSubtype.SQUARE,
color: options?.color ?? DEFAULTS.shapeFill,
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
borderWidth: options?.borderWidth ?? 1,
strokeWidth: options?.borderWidth ?? 1,
lineWidth: options?.borderWidth ?? 1,
...withCustomData(options),
}),
circle: (options) => ({
type: PdfAnnotationSubtype.CIRCLE,
color: options?.color ?? DEFAULTS.shapeFill,
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
borderWidth: options?.borderWidth ?? 1,
strokeWidth: options?.borderWidth ?? 1,
lineWidth: options?.borderWidth ?? 1,
...withCustomData(options),
}),
line: (options) => ({
type: PdfAnnotationSubtype.LINE,
color: options?.color ?? '#1565c0',
strokeColor: options?.color ?? '#1565c0',
opacity: options?.opacity ?? 1,
borderWidth: options?.borderWidth ?? 2,
strokeWidth: options?.borderWidth ?? 2,
lineWidth: options?.borderWidth ?? 2,
...withCustomData(options),
}),
lineArrow: (options) => ({
type: PdfAnnotationSubtype.LINE,
color: options?.color ?? '#1565c0',
strokeColor: options?.color ?? '#1565c0',
opacity: options?.opacity ?? 1,
borderWidth: options?.borderWidth ?? 2,
strokeWidth: options?.borderWidth ?? 2,
lineWidth: options?.borderWidth ?? 2,
startStyle: 'None',
endStyle: 'ClosedArrow',
lineEndingStyles: { start: 'None', end: 'ClosedArrow' },
...withCustomData(options),
}),
polyline: (options) => ({
type: PdfAnnotationSubtype.POLYLINE,
color: options?.color ?? '#1565c0',
opacity: options?.opacity ?? 1,
borderWidth: options?.borderWidth ?? 2,
...withCustomData(options),
}),
polygon: (options) => ({
type: PdfAnnotationSubtype.POLYGON,
color: options?.color ?? DEFAULTS.shapeFill,
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
borderWidth: options?.borderWidth ?? 1,
strokeWidth: options?.borderWidth ?? 1,
lineWidth: options?.borderWidth ?? 1,
...withCustomData(options),
}),
stamp: buildStampDefaults,
signatureStamp: buildStampDefaults,
signatureInk: (options) => buildInkDefaults(options),
};
export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function AnnotationAPIBridge(_props, ref) {
// Use the provided annotation API just like SignatureAPIBridge/HistoryAPIBridge
const { provides: annotationApi } = useAnnotationCapability();
const buildAnnotationDefaults = useCallback(
(toolId: AnnotationToolId, options?: AnnotationToolOptions) =>
TOOL_DEFAULT_BUILDERS[toolId]?.(options) ?? null,
[]
);
const configureAnnotationTool = useCallback(
(toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
const api = annotationApi as AnnotationApiSurface | undefined;
if (!api?.setActiveTool) return;
const defaults = buildAnnotationDefaults(toolId, options);
// Reset tool first, then activate (like SignatureAPIBridge does)
api.setActiveTool(null);
api.setActiveTool(toolId === 'select' ? null : toolId);
// Verify tool was activated before setting defaults (like SignatureAPIBridge does)
const activeTool = api.getActiveTool?.();
if (activeTool && activeTool.id === toolId && defaults) {
api.setToolDefaults?.(toolId, defaults);
}
},
[annotationApi, buildAnnotationDefaults]
);
useImperativeHandle(
ref,
() => ({
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
configureAnnotationTool(toolId, options);
},
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
const defaults = buildAnnotationDefaults(toolId, options);
const api = annotationApi as AnnotationApiSurface | undefined;
if (defaults && api?.setToolDefaults) {
api.setToolDefaults(toolId, defaults);
}
},
getSelectedAnnotation: () => {
const api = annotationApi as AnnotationApiSurface | undefined;
if (!api?.getSelectedAnnotation) {
return null;
}
try {
return api.getSelectedAnnotation();
} catch (error) {
// Some EmbedPDF builds expose getSelectedAnnotation with an internal
// `this`/state dependency (e.g. reading `selectedUid` from undefined).
// If that happens, fail gracefully and treat it as "no selection"
// instead of crashing the entire annotations tool.
console.error('[AnnotationAPIBridge] getSelectedAnnotation failed:', error);
return null;
}
},
deselectAnnotation: () => {
const api = annotationApi as AnnotationApiSurface | undefined;
api?.deselectAnnotation?.();
},
updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => {
const api = annotationApi as AnnotationApiSurface | undefined;
api?.updateAnnotation?.(pageIndex, annotationId, patch);
},
deactivateTools: () => {
const api = annotationApi as AnnotationApiSurface | undefined;
api?.setActiveTool?.(null);
},
onAnnotationEvent: (listener: (event: AnnotationEvent) => void) => {
const api = annotationApi as AnnotationApiSurface | undefined;
if (api?.onAnnotationEvent) {
return api.onAnnotationEvent(listener);
}
return undefined;
},
getActiveTool: () => {
const api = annotationApi as AnnotationApiSurface | undefined;
return api?.getActiveTool?.() ?? null;
},
}),
[annotationApi, configureAnnotationTool, buildAnnotationDefaults]
);
return null;
});
@@ -56,9 +56,6 @@ const EmbedPdfViewerContent = ({
exportActions,
} = useViewer();
// Register viewer right-rail buttons
useViewerRightRailButtons();
const scrollState = getScrollState();
const rotationState = getRotationState();
@@ -70,8 +67,13 @@ const EmbedPdfViewerContent = ({
}
}, [rotationState.rotation]);
// Get signature context
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
// Get signature and annotation contexts
const { signatureApiRef, annotationApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
// Track whether there are unsaved annotation changes in this viewer session.
// This is our source of truth for navigation guards; it is set when the
// annotation history changes, and cleared after we successfully apply changes.
const hasAnnotationChangesRef = useRef(false);
// Get current file from FileContext
const { selectors, state } = useFileState();
@@ -85,8 +87,8 @@ const EmbedPdfViewerContent = ({
// Check if we're in an annotation tool
const { selectedTool } = useNavigationState();
// Tools that require the annotation layer (Sign, Add Text, Add Image)
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
// Tools that require the annotation layer (Sign, Add Text, Add Image, Annotate)
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate';
// Sync isAnnotationMode in ViewerContext with current tool
useEffect(() => {
@@ -225,6 +227,31 @@ const EmbedPdfViewerContent = ({
};
}, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]);
// Watch the annotation history API to detect when the document becomes "dirty".
// We treat any change that makes the history undoable as unsaved changes until
// the user explicitly applies them via applyChanges.
useEffect(() => {
const historyApi = historyApiRef.current;
if (!historyApi || !historyApi.subscribe) {
return;
}
const updateHasChanges = () => {
const canUndo = historyApi.canUndo?.() ?? false;
if (!hasAnnotationChangesRef.current && canUndo) {
hasAnnotationChangesRef.current = true;
setHasUnsavedChanges(true);
}
};
const unsubscribe = historyApi.subscribe(updateHasChanges);
return () => {
if (typeof unsubscribe === 'function') {
unsubscribe();
}
};
}, [historyApiRef.current, setHasUnsavedChanges]);
// Register checker for unsaved changes (annotations only for now)
useEffect(() => {
if (previewFile) {
@@ -232,39 +259,28 @@ const EmbedPdfViewerContent = ({
}
const checkForChanges = () => {
// Check for annotation changes via history
const hasAnnotationChanges = historyApiRef.current?.canUndo() || false;
console.log('[Viewer] Checking for unsaved changes:', {
hasAnnotationChanges
});
const hasAnnotationChanges = hasAnnotationChangesRef.current;
return hasAnnotationChanges;
};
console.log('[Viewer] Registering unsaved changes checker');
registerUnsavedChangesChecker(checkForChanges);
return () => {
console.log('[Viewer] Unregistering unsaved changes checker');
unregisterUnsavedChangesChecker();
};
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
}, [previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
// Apply changes - save annotations to new file version
const applyChanges = useCallback(async () => {
if (!currentFile || activeFileIds.length === 0) return;
try {
console.log('[Viewer] Applying changes - exporting PDF with annotations');
// Step 1: Export PDF with annotations using EmbedPDF
const arrayBuffer = await exportActions.saveAsCopy();
if (!arrayBuffer) {
throw new Error('Failed to export PDF');
}
console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength);
// Step 2: Convert ArrayBuffer to File
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
const filename = currentFile.name || 'document.pdf';
@@ -279,12 +295,29 @@ const EmbedPdfViewerContent = ({
// Step 4: Consume files (replace in context)
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
// Mark annotations as saved so navigation away from the viewer is allowed.
hasAnnotationChangesRef.current = false;
setHasUnsavedChanges(false);
} catch (error) {
console.error('Apply changes failed:', error);
}
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
// Expose annotation apply via a global event so tools (like Annotate) can
// trigger saves from the left sidebar without tight coupling.
useEffect(() => {
const handler = () => {
void applyChanges();
};
window.addEventListener('stirling-annotations-apply', handler);
return () => {
window.removeEventListener('stirling-annotations-apply', handler);
};
}, [applyChanges]);
// Register viewer right-rail buttons
useViewerRightRailButtons();
const sidebarWidthRem = 15;
const totalRightMargin =
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
@@ -340,6 +373,7 @@ const EmbedPdfViewerContent = ({
enableAnnotations={isAnnotationMode}
showBakedAnnotations={isAnnotationsVisible}
signatureApiRef={signatureApiRef as React.RefObject<any>}
annotationApiRef={annotationApiRef as React.RefObject<any>}
historyApiRef={historyApiRef as React.RefObject<any>}
onSignatureAdded={() => {
// Handle signature added - for debugging, enable console logs as needed
@@ -38,8 +38,9 @@ import { SearchAPIBridge } from '@app/components/viewer/SearchAPIBridge';
import { ThumbnailAPIBridge } from '@app/components/viewer/ThumbnailAPIBridge';
import { RotateAPIBridge } from '@app/components/viewer/RotateAPIBridge';
import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge';
import { AnnotationAPIBridge } from '@app/components/viewer/AnnotationAPIBridge';
import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
import type { SignatureAPI, AnnotationAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
@@ -55,10 +56,11 @@ interface LocalEmbedPDFProps {
showBakedAnnotations?: boolean;
onSignatureAdded?: (annotation: any) => void;
signatureApiRef?: React.RefObject<SignatureAPI>;
annotationApiRef?: React.RefObject<AnnotationAPI>;
historyApiRef?: React.RefObject<HistoryAPI>;
}
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef }: LocalEmbedPDFProps) {
const { t } = useTranslation();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
@@ -123,10 +125,8 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
selectAfterCreate: true,
}),
// Register pan plugin (depends on Viewport, InteractionManager)
createPluginRegistration(PanPluginPackage, {
defaultMode: 'mobile', // Try mobile mode which might be more permissive
}),
// Register pan plugin (depends on Viewport, InteractionManager) - keep disabled to prevent drag panning
createPluginRegistration(PanPluginPackage, {}),
// Register zoom plugin with configuration
createPluginRegistration(ZoomPluginPackage, {
@@ -252,7 +252,315 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
if (!annotationApi) return;
if (enableAnnotations) {
annotationApi.addTool({
const ensureTool = (tool: any) => {
const existing = annotationApi.getTool?.(tool.id);
if (!existing) {
annotationApi.addTool(tool);
}
};
ensureTool({
id: 'highlight',
name: 'Highlight',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.HIGHLIGHT,
color: '#ffd54f',
opacity: 0.6,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'underline',
name: 'Underline',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.UNDERLINE,
color: '#ffb300',
opacity: 1,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'strikeout',
name: 'Strikeout',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.STRIKEOUT,
color: '#e53935',
opacity: 1,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'squiggly',
name: 'Squiggly',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.SQUIGGLY,
color: '#00acc1',
opacity: 1,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'ink',
name: 'Pen',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.INK,
color: '#1f2933',
opacity: 1,
borderWidth: 2,
lineWidth: 2,
strokeWidth: 2,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'inkHighlighter',
name: 'Ink Highlighter',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK && annotation.color === '#ffd54f' ? 8 : 0),
defaults: {
type: PdfAnnotationSubtype.INK,
color: '#ffd54f',
opacity: 0.5,
borderWidth: 6,
lineWidth: 6,
strokeWidth: 6,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'square',
name: 'Square',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.SQUARE,
color: '#0000ff', // fill color (blue)
strokeColor: '#cf5b5b', // border color (reddish pink)
opacity: 0.5,
borderWidth: 1,
strokeWidth: 1,
lineWidth: 1,
},
clickBehavior: {
enabled: true,
defaultSize: { width: 120, height: 90 },
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'circle',
name: 'Circle',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.CIRCLE,
color: '#0000ff', // fill color (blue)
strokeColor: '#cf5b5b', // border color (reddish pink)
opacity: 0.5,
borderWidth: 1,
strokeWidth: 1,
lineWidth: 1,
},
clickBehavior: {
enabled: true,
defaultSize: { width: 100, height: 100 },
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'line',
name: 'Line',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.LINE,
color: '#1565c0',
opacity: 1,
borderWidth: 2,
strokeWidth: 2,
lineWidth: 2,
},
clickBehavior: {
enabled: true,
defaultLength: 120,
defaultAngle: 0,
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'lineArrow',
name: 'Arrow',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE && (annotation.endStyle === 'ClosedArrow' || annotation.lineEndingStyles?.end === 'ClosedArrow') ? 9 : 0),
defaults: {
type: PdfAnnotationSubtype.LINE,
color: '#1565c0',
opacity: 1,
borderWidth: 2,
startStyle: 'None',
endStyle: 'ClosedArrow',
lineEndingStyles: { start: 'None', end: 'ClosedArrow' },
},
clickBehavior: {
enabled: true,
defaultLength: 120,
defaultAngle: 0,
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'polyline',
name: 'Polyline',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.POLYLINE,
color: '#1565c0',
opacity: 1,
borderWidth: 2,
},
clickBehavior: {
enabled: true,
finishOnDoubleClick: true,
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'polygon',
name: 'Polygon',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.POLYGON,
color: '#0000ff', // fill color (blue)
strokeColor: '#cf5b5b', // border color (reddish pink)
opacity: 0.5,
borderWidth: 1,
},
clickBehavior: {
enabled: true,
finishOnDoubleClick: true,
defaultSize: { width: 140, height: 100 },
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'text',
name: 'Text',
interaction: { exclusive: true, cursor: 'text' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.FREETEXT,
textColor: '#111111',
fontSize: 14,
fontFamily: 'Helvetica',
opacity: 1,
interiorColor: '#fffef7',
contents: 'Text',
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'note',
name: 'Note',
interaction: { exclusive: true, cursor: 'pointer' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0),
defaults: {
type: PdfAnnotationSubtype.FREETEXT,
textColor: '#1b1b1b',
color: '#ffa000',
interiorColor: '#fff8e1',
opacity: 1,
contents: 'Note',
fontSize: 12,
},
clickBehavior: {
enabled: true,
defaultSize: { width: 160, height: 100 },
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'stamp',
name: 'Image Stamp',
interaction: { exclusive: false, cursor: 'copy' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0),
defaults: {
type: PdfAnnotationSubtype.STAMP,
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'signatureStamp',
name: 'Digital Signature',
interaction: { exclusive: false, cursor: 'copy' },
@@ -262,7 +570,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
},
});
annotationApi.addTool({
ensureTool({
id: 'signatureInk',
name: 'Signature Draw',
interaction: { exclusive: true, cursor: 'crosshair' },
@@ -310,6 +618,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
<ThumbnailAPIBridge />
<RotateAPIBridge />
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
<ExportAPIBridge />
<BookmarkAPIBridge />
@@ -104,12 +104,20 @@ const createTextStampImage = (
ctx.fillStyle = textColor;
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textAlign = 'left';
ctx.textAlign = config.textAlign || 'left';
ctx.textBaseline = 'middle';
const horizontalPadding = paddingX;
const verticalCenter = naturalHeight / 2;
ctx.fillText(text, horizontalPadding, verticalCenter);
let xPosition = horizontalPadding;
if (config.textAlign === 'center') {
xPosition = naturalWidth / 2;
} else if (config.textAlign === 'right') {
xPosition = naturalWidth - horizontalPadding;
}
ctx.fillText(text, xPosition, verticalCenter);
return {
dataUrl: canvas.toDataURL('image/png'),
@@ -199,12 +207,21 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
}
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
// Enable keyboard deletion of selected annotations
useEffect(() => {
// Always enable delete key when we have annotation API and are in sign mode
if (!annotationApi || (isPlacementMode === undefined)) return;
const handleKeyDown = (event: KeyboardEvent) => {
// Skip delete/backspace while a text input/textarea is focused (e.g., editing textbox)
const target = event.target as HTMLElement | null;
const tag = target?.tagName?.toLowerCase();
const editable = target?.getAttribute?.('contenteditable');
if (tag === 'input' || tag === 'textarea' || editable === 'true') {
return;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo, useState, useEffect, useCallback } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useViewer } from '@app/contexts/ViewerContext';
@@ -9,6 +9,9 @@ import { SearchInterface } from '@app/components/viewer/SearchInterface';
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { BASE_PATH, withBasePath } from '@app/constants/app';
export function useViewerRightRailButtons() {
const { t, i18n } = useTranslation();
@@ -16,6 +19,32 @@ export function useViewerRightRailButtons() {
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
const { handleToolSelect } = useToolWorkflow();
const { selectedTool } = useNavigationState();
const stripBasePath = useCallback((path: string) => {
if (BASE_PATH && path.startsWith(BASE_PATH)) {
return path.slice(BASE_PATH.length) || '/';
}
return path;
}, []);
const isAnnotationsPath = useCallback(() => {
const cleanPath = stripBasePath(window.location.pathname).toLowerCase();
return cleanPath === '/annotations' || cleanPath.endsWith('/annotations');
}, [stripBasePath]);
const [isAnnotationsActive, setIsAnnotationsActive] = useState<boolean>(() => isAnnotationsPath());
useEffect(() => {
setIsAnnotationsActive(isAnnotationsPath());
}, [selectedTool, isAnnotationsPath]);
useEffect(() => {
const handlePopState = () => setIsAnnotationsActive(isAnnotationsPath());
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [isAnnotationsPath]);
// Lift i18n labels out of memo for clarity
const searchLabel = t('rightRail.search', 'Search PDF');
@@ -25,9 +54,11 @@ export function useViewerRightRailButtons() {
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
const printLabel = t('rightRail.print', 'Print PDF');
const annotationsLabel = t('rightRail.annotations', 'Annotations');
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
return [
const buttons: RightRailButtonWithAction[] = [
{
id: 'viewer-search',
tooltip: searchLabel,
@@ -147,6 +178,36 @@ export function useViewerRightRailButtons() {
viewer.printActions.print();
}
},
{
id: 'viewer-annotations',
tooltip: annotationsLabel,
ariaLabel: annotationsLabel,
section: 'top' as const,
order: 58,
render: ({ disabled }) => (
<Tooltip content={annotationsLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isAnnotationsActive ? 'default' : 'subtle'}
radius="md"
className="right-rail-icon"
onClick={() => {
if (disabled || isAnnotationsActive) return;
const targetPath = withBasePath('/annotations');
if (window.location.pathname !== targetPath) {
window.history.pushState(null, '', targetPath);
}
setIsAnnotationsActive(true);
handleToolSelect('annotate');
}}
disabled={disabled || isAnnotationsActive}
aria-pressed={isAnnotationsActive}
style={isAnnotationsActive ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)
},
{
id: 'viewer-annotation-controls',
section: 'top' as const,
@@ -154,9 +215,30 @@ export function useViewerRightRailButtons() {
render: ({ disabled }) => (
<ViewerAnnotationControls currentView="viewer" disabled={disabled} />
)
}
},
];
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, printLabel, tooltipPosition]);
// Optional: Save button for annotations (always registered when this hook is used
// with a save handler; uses a ref to avoid infinite re-registration loops).
return buttons;
}, [
t,
i18n.language,
viewer,
isPanning,
searchLabel,
panLabel,
rotateLeftLabel,
rotateRightLabel,
sidebarLabel,
bookmarkLabel,
printLabel,
tooltipPosition,
annotationsLabel,
saveChangesLabel,
isAnnotationsActive,
handleToolSelect,
]);
useRightRailButtons(viewerButtons);
}
@@ -16,6 +16,17 @@ export interface SignatureAPI {
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
}
export interface AnnotationAPI {
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
getSelectedAnnotation: () => AnnotationSelection | null;
deselectAnnotation: () => void;
updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
deactivateTools: () => void;
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
getActiveTool?: () => { id: AnnotationToolId } | null;
}
export interface HistoryAPI {
undo: () => void;
redo: () => void;
@@ -23,3 +34,50 @@ export interface HistoryAPI {
canRedo: () => boolean;
subscribe?: (listener: () => void) => () => void;
}
export type AnnotationToolId =
| 'select'
| 'highlight'
| 'underline'
| 'strikeout'
| 'squiggly'
| 'ink'
| 'inkHighlighter'
| 'text'
| 'note'
| 'square'
| 'circle'
| 'line'
| 'lineArrow'
| 'polyline'
| 'polygon'
| 'stamp'
| 'signatureStamp'
| 'signatureInk';
export interface AnnotationEvent {
type: string;
[key: string]: unknown;
}
export type AnnotationPatch = Record<string, unknown>;
export type AnnotationSelection = unknown;
export interface AnnotationToolOptions {
color?: string;
fillColor?: string;
strokeColor?: string;
opacity?: number;
strokeOpacity?: number;
fillOpacity?: number;
thickness?: number;
borderWidth?: number;
fontSize?: number;
fontFamily?: string;
textAlign?: number; // 0 = Left, 1 = Center, 2 = Right
imageSrc?: string;
imageSize?: { width: number; height: number };
icon?: 'Comment' | 'Key' | 'Note' | 'Help' | 'NewParagraph' | 'Paragraph' | 'Insert';
contents?: string;
customData?: Record<string, unknown>;
}
@@ -0,0 +1,26 @@
import React, { createContext, useContext, ReactNode, useRef } from 'react';
import type { AnnotationAPI } from '@app/components/viewer/viewerTypes';
interface AnnotationContextValue {
annotationApiRef: React.RefObject<AnnotationAPI | null>;
}
const AnnotationContext = createContext<AnnotationContextValue | undefined>(undefined);
export const AnnotationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const annotationApiRef = useRef<AnnotationAPI>(null);
const value: AnnotationContextValue = {
annotationApiRef,
};
return <AnnotationContext.Provider value={value}>{children}</AnnotationContext.Provider>;
};
export const useAnnotation = (): AnnotationContextValue => {
const context = useContext(AnnotationContext);
if (!context) {
throw new Error('useAnnotation must be used within an AnnotationProvider');
}
return context;
};
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react';
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
import type { SignatureAPI, HistoryAPI, AnnotationAPI } from '@app/components/viewer/viewerTypes';
// Signature state interface
interface SignatureState {
@@ -34,6 +34,7 @@ interface SignatureActions {
// Combined context interface
interface SignatureContextValue extends SignatureState, SignatureActions {
signatureApiRef: React.RefObject<SignatureAPI | null>;
annotationApiRef: React.RefObject<AnnotationAPI | null>;
historyApiRef: React.RefObject<HistoryAPI | null>;
}
@@ -52,6 +53,7 @@ const initialState: SignatureState = {
export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [state, setState] = useState<SignatureState>(initialState);
const signatureApiRef = useRef<SignatureAPI>(null);
const annotationApiRef = useRef<AnnotationAPI>(null);
const historyApiRef = useRef<HistoryAPI>(null);
const imageDataStore = useRef<Map<string, string>>(new Map());
@@ -157,6 +159,7 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
const contextValue: SignatureContextValue = {
...state,
signatureApiRef,
annotationApiRef,
historyApiRef,
setSignatureConfig,
setPlacementMode,
@@ -51,6 +51,7 @@ import Crop from "@app/tools/Crop";
import Sign from "@app/tools/Sign";
import AddText from "@app/tools/AddText";
import AddImage from "@app/tools/AddImage";
import Annotate from "@app/tools/Annotate";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
@@ -246,6 +247,19 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, 'addImage'),
supportsAutomate: false,
},
annotate: {
icon: <LocalIcon icon="edit" width="1.5rem" height="1.5rem" />,
name: t('home.annotate.title', 'Annotate'),
component: Annotate,
description: t('home.annotate.desc', 'Highlight, draw, add notes, and shapes directly in the viewer'),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
workbench: 'viewer',
operationConfig: signOperationConfig,
automationSettings: null,
synonyms: getSynonyms(t, 'annotate'),
supportsAutomate: false,
},
// Document Security
@@ -18,6 +18,7 @@ export interface SignParameters {
fontFamily?: string;
fontSize?: number;
textColor?: string;
textAlign?: 'left' | 'center' | 'right';
}
export const DEFAULT_PARAMETERS: SignParameters = {
@@ -28,6 +29,7 @@ export const DEFAULT_PARAMETERS: SignParameters = {
fontFamily: 'Helvetica',
fontSize: 16,
textColor: '#000000',
textAlign: 'left',
};
const validateSignParameters = (parameters: SignParameters): boolean => {
+416
View File
@@ -0,0 +1,416 @@
import { useEffect, useState, useContext, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
import { useNavigation } from '@app/contexts/NavigationContext';
import { useFileSelection } from '@app/contexts/FileContext';
import { BaseToolProps } from '@app/types/tool';
import { useSignature } from '@app/contexts/SignatureContext';
import { ViewerContext, useViewer } from '@app/contexts/ViewerContext';
import type { AnnotationToolId } from '@app/components/viewer/viewerTypes';
import { useAnnotationStyleState } from '@app/tools/annotate/useAnnotationStyleState';
import { useAnnotationSelection } from '@app/tools/annotate/useAnnotationSelection';
import { AnnotationPanel } from '@app/tools/annotate/AnnotationPanel';
const KNOWN_ANNOTATION_TOOLS: AnnotationToolId[] = [
'select',
'highlight',
'underline',
'strikeout',
'squiggly',
'ink',
'inkHighlighter',
'text',
'note',
'square',
'circle',
'line',
'lineArrow',
'polyline',
'polygon',
'stamp',
'signatureStamp',
'signatureInk',
];
const isKnownAnnotationTool = (toolId: string | undefined | null): toolId is AnnotationToolId =>
!!toolId && (KNOWN_ANNOTATION_TOOLS as string[]).includes(toolId);
const Annotate = (_props: BaseToolProps) => {
const { t } = useTranslation();
const { selectedTool, workbench, hasUnsavedChanges } = useNavigation();
const { selectedFiles } = useFileSelection();
const {
signatureApiRef,
annotationApiRef,
historyApiRef,
undo,
redo,
setSignatureConfig,
setPlacementMode,
placementPreviewSize,
setPlacementPreviewSize,
} = useSignature();
const viewerContext = useContext(ViewerContext);
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
const [activeTool, setActiveTool] = useState<AnnotationToolId>('select');
const activeToolRef = useRef<AnnotationToolId>('select');
const wasAnnotateActiveRef = useRef<boolean>(false);
const [selectedTextDraft, setSelectedTextDraft] = useState<string>('');
const [selectedFontSize, setSelectedFontSize] = useState<number>(14);
const [stampImageData, setStampImageData] = useState<string | undefined>();
const [stampImageSize, setStampImageSize] = useState<{ width: number; height: number } | null>(null);
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
const manualToolSwitch = useRef<boolean>(false);
// Zoom tracking for stamp size conversion
const [currentZoom, setCurrentZoom] = useState(() => {
const zoomState = getZoomState();
if (!zoomState) return 1;
if (typeof zoomState.zoomPercent === 'number') {
return Math.max(zoomState.zoomPercent / 100, 0.01);
}
return Math.max(zoomState.currentZoom ?? 1, 0.01);
});
useEffect(() => {
return registerImmediateZoomUpdate((newZoomPercent) => {
setCurrentZoom(Math.max(newZoomPercent / 100, 0.01));
});
}, [registerImmediateZoomUpdate]);
useEffect(() => {
activeToolRef.current = activeTool;
}, [activeTool]);
// CSS to PDF size conversion accounting for zoom
const cssToPdfSize = useCallback(
(size: { width: number; height: number }) => {
const zoom = currentZoom || 1;
const factor = 1 / zoom;
return {
width: size.width * factor,
height: size.height * factor,
};
},
[currentZoom]
);
const computeStampDisplaySize = useCallback((natural: { width: number; height: number } | null) => {
if (!natural) {
return { width: 180, height: 120 };
}
const maxSide = 260;
const minSide = 24;
const { width, height } = natural;
const largest = Math.max(width || maxSide, height || maxSide, 1);
const scale = Math.min(1, maxSide / largest);
return {
width: Math.max(minSide, Math.round(width * scale)),
height: Math.max(minSide, Math.round(height * scale)),
};
}, []);
const {
styleState,
styleActions,
buildToolOptions,
getActiveColor,
} = useAnnotationStyleState(cssToPdfSize);
const {
setInkWidth,
setShapeThickness,
setTextColor,
setTextBackgroundColor,
setNoteBackgroundColor,
setInkColor,
setHighlightColor,
setHighlightOpacity,
setFreehandHighlighterWidth,
setUnderlineColor,
setUnderlineOpacity,
setStrikeoutColor,
setStrikeoutOpacity,
setSquigglyColor,
setSquigglyOpacity,
setShapeStrokeColor,
setShapeFillColor,
setShapeOpacity,
setShapeStrokeOpacity,
setShapeFillOpacity,
setTextAlignment,
} = styleActions;
const handleApplyChanges = useCallback(() => {
window.dispatchEvent(new CustomEvent('stirling-annotations-apply'));
}, []);
useEffect(() => {
const isAnnotateActive = workbench === 'viewer' && selectedTool === 'annotate';
if (wasAnnotateActiveRef.current && !isAnnotateActive) {
annotationApiRef?.current?.deactivateTools?.();
signatureApiRef?.current?.deactivateTools?.();
setPlacementMode(false);
} else if (!wasAnnotateActiveRef.current && isAnnotateActive) {
// When entering annotate mode, activate the select tool by default
const toolOptions = buildToolOptions('select');
annotationApiRef?.current?.activateAnnotationTool?.('select', toolOptions);
}
wasAnnotateActiveRef.current = isAnnotateActive;
}, [workbench, selectedTool, annotationApiRef, signatureApiRef, setPlacementMode, buildToolOptions]);
// Monitor history state for undo/redo availability
useEffect(() => {
const historyApi = historyApiRef?.current;
if (!historyApi) return;
const updateAvailability = () =>
setHistoryAvailability({
canUndo: historyApi.canUndo?.() ?? false,
canRedo: historyApi.canRedo?.() ?? false,
});
updateAvailability();
let interval: ReturnType<typeof setInterval> | undefined;
if (!historyApi.subscribe) {
// Fallback polling in case the history API doesn't support subscriptions
interval = setInterval(updateAvailability, 350);
} else {
const unsubscribe = historyApi.subscribe(updateAvailability);
return () => {
if (typeof unsubscribe === 'function') {
unsubscribe();
}
if (interval) clearInterval(interval);
};
}
return () => {
if (interval) clearInterval(interval);
};
}, [historyApiRef?.current]);
useEffect(() => {
if (!viewerContext) return;
if (viewerContext.isAnnotationMode) return;
viewerContext.setAnnotationMode(true);
const toolOptions =
activeTool === 'stamp'
? buildToolOptions('stamp', { stampImageData, stampImageSize })
: buildToolOptions(activeTool);
annotationApiRef?.current?.activateAnnotationTool?.(activeTool, toolOptions);
}, [viewerContext?.isAnnotationMode, signatureApiRef, activeTool, buildToolOptions, stampImageData, stampImageSize]);
const activateAnnotationTool = (toolId: AnnotationToolId) => {
// If leaving stamp tool, clean up placement mode
if (activeTool === 'stamp' && toolId !== 'stamp') {
setPlacementMode(false);
setSignatureConfig(null);
}
viewerContext?.setAnnotationMode(true);
// Mark as manual tool switch to prevent auto-switch back
manualToolSwitch.current = true;
// Deselect annotation in the viewer first
annotationApiRef?.current?.deselectAnnotation?.();
// Clear selection state to show default controls
setSelectedAnn(null);
setSelectedAnnId(null);
// Change the tool
setActiveTool(toolId);
const options =
toolId === 'stamp'
? buildToolOptions('stamp', { stampImageData, stampImageSize })
: buildToolOptions(toolId);
// For stamp, apply the image if we have one
annotationApiRef?.current?.setAnnotationStyle?.(toolId, options);
annotationApiRef?.current?.activateAnnotationTool?.(toolId === 'stamp' ? 'stamp' : toolId, options);
// Reset flag after a short delay
setTimeout(() => {
manualToolSwitch.current = false;
}, 300);
};
useEffect(() => {
// push style updates to EmbedPDF when sliders/colors change
if (activeTool === 'stamp') {
const options = buildToolOptions('stamp', { stampImageData, stampImageSize });
annotationApiRef?.current?.setAnnotationStyle?.('stamp', options);
} else {
annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
}
}, [activeTool, buildToolOptions, signatureApiRef, stampImageData, stampImageSize]);
// Sync preview size from overlay to annotation engine
useEffect(() => {
// When preview size changes, update stamp annotation sizing
// The SignatureAPIBridge will use placementPreviewSize from SignatureContext
// and apply the converted size to the stamp tool automatically
if (activeTool === 'stamp' && stampImageData) {
const size = placementPreviewSize ?? stampImageSize;
const stampOptions = buildToolOptions('stamp', { stampImageData, stampImageSize: size ?? null });
annotationApiRef?.current?.setAnnotationStyle?.('stamp', stampOptions);
}
}, [placementPreviewSize, activeTool, stampImageData, signatureApiRef, stampImageSize, cssToPdfSize, buildToolOptions]);
// Allow exiting multi-point tools with Escape (e.g., polyline)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (['polyline', 'polygon'].includes(activeTool)) {
annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
annotationApiRef?.current?.activateAnnotationTool?.(null as any);
setTimeout(() => {
annotationApiRef?.current?.activateAnnotationTool?.(activeTool, buildToolOptions(activeTool));
}, 50);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [activeTool, buildToolOptions, signatureApiRef]);
const deriveToolFromAnnotation = useCallback((annotation: any): AnnotationToolId | undefined => {
if (!annotation) return undefined;
const customToolId = annotation.customData?.toolId || annotation.customData?.annotationToolId;
if (isKnownAnnotationTool(customToolId)) {
return customToolId;
}
const type = annotation.type ?? annotation.object?.type;
switch (type) {
case 3: return 'text'; // FREETEXT
case 4: return 'line'; // LINE
case 5: return 'square'; // SQUARE
case 6: return 'circle'; // CIRCLE
case 7: return 'polygon'; // POLYGON
case 8: return 'polyline'; // POLYLINE
case 9: return 'highlight'; // HIGHLIGHT
case 10: return 'underline'; // UNDERLINE
case 11: return 'squiggly'; // SQUIGGLY
case 12: return 'strikeout'; // STRIKEOUT
case 13: return 'stamp'; // STAMP
case 15: return 'ink'; // INK
default: return undefined;
}
}, []);
const {
selectedAnn,
setSelectedAnn,
setSelectedAnnId,
} = useAnnotationSelection({
annotationApiRef,
deriveToolFromAnnotation,
activeToolRef,
manualToolSwitch,
setActiveTool,
setSelectedTextDraft,
setSelectedFontSize,
setInkWidth,
setShapeThickness,
setTextColor,
setTextBackgroundColor,
setNoteBackgroundColor,
setInkColor,
setHighlightColor,
setHighlightOpacity,
setFreehandHighlighterWidth,
setUnderlineColor,
setUnderlineOpacity,
setStrikeoutColor,
setStrikeoutOpacity,
setSquigglyColor,
setSquigglyOpacity,
setShapeStrokeColor,
setShapeFillColor,
setShapeOpacity,
setShapeStrokeOpacity,
setShapeFillOpacity,
setTextAlignment,
});
const steps =
selectedFiles.length === 0
? []
: [
{
title: t('annotation.title', 'Annotate'),
isCollapsed: false,
onCollapsedClick: undefined,
content: (
<AnnotationPanel
activeTool={activeTool}
activateAnnotationTool={activateAnnotationTool}
styleState={styleState}
styleActions={styleActions}
getActiveColor={getActiveColor}
buildToolOptions={buildToolOptions}
deriveToolFromAnnotation={deriveToolFromAnnotation}
selectedAnn={selectedAnn}
selectedTextDraft={selectedTextDraft}
setSelectedTextDraft={setSelectedTextDraft}
selectedFontSize={selectedFontSize}
setSelectedFontSize={setSelectedFontSize}
annotationApiRef={annotationApiRef}
signatureApiRef={signatureApiRef}
viewerContext={viewerContext}
setPlacementMode={setPlacementMode}
setSignatureConfig={setSignatureConfig}
computeStampDisplaySize={computeStampDisplaySize}
stampImageData={stampImageData}
setStampImageData={setStampImageData}
stampImageSize={stampImageSize}
setStampImageSize={setStampImageSize}
setPlacementPreviewSize={setPlacementPreviewSize}
undo={undo}
redo={redo}
historyAvailability={historyAvailability}
onApplyChanges={handleApplyChanges}
applyDisabled={!hasUnsavedChanges}
/>
),
},
];
return createToolFlow({
files: {
selectedFiles,
isCollapsed: false,
},
steps,
review: {
isVisible: false,
operation: {
files: [],
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl: null,
downloadFilename: '',
isLoading: false,
status: '',
errorMessage: null,
progress: null,
executeOperation: async () => {},
resetResults: () => {},
clearError: () => {},
cancelOperation: () => {},
undoOperation: async () => {},
},
title: '',
onFileClick: () => {},
onUndo: () => {},
},
forceStepNumbers: true,
});
};
export default Annotate;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,383 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { AnnotationAPI, AnnotationToolId } from '@app/components/viewer/viewerTypes';
interface UseAnnotationSelectionParams {
annotationApiRef: React.RefObject<AnnotationAPI | null>;
deriveToolFromAnnotation: (annotation: any) => AnnotationToolId | undefined;
activeToolRef: React.MutableRefObject<AnnotationToolId>;
manualToolSwitch: React.MutableRefObject<boolean>;
setActiveTool: (toolId: AnnotationToolId) => void;
setSelectedTextDraft: (text: string) => void;
setSelectedFontSize: (size: number) => void;
setInkWidth: (value: number) => void;
setFreehandHighlighterWidth?: (value: number) => void;
setShapeThickness: (value: number) => void;
setTextColor: (value: string) => void;
setTextBackgroundColor: (value: string) => void;
setNoteBackgroundColor: (value: string) => void;
setInkColor: (value: string) => void;
setHighlightColor: (value: string) => void;
setHighlightOpacity: (value: number) => void;
setUnderlineColor: (value: string) => void;
setUnderlineOpacity: (value: number) => void;
setStrikeoutColor: (value: string) => void;
setStrikeoutOpacity: (value: number) => void;
setSquigglyColor: (value: string) => void;
setSquigglyOpacity: (value: number) => void;
setShapeStrokeColor: (value: string) => void;
setShapeFillColor: (value: string) => void;
setShapeOpacity: (value: number) => void;
setShapeStrokeOpacity: (value: number) => void;
setShapeFillOpacity: (value: number) => void;
setTextAlignment: (value: 'left' | 'center' | 'right') => void;
}
const MARKUP_TOOL_IDS = ['highlight', 'underline', 'strikeout', 'squiggly'] as const;
const DRAWING_TOOL_IDS = ['ink', 'inkHighlighter'] as const;
const isTextMarkupAnnotation = (annotation: any): boolean => {
const toolId =
annotation?.customData?.annotationToolId ||
annotation?.customData?.toolId ||
annotation?.object?.customData?.annotationToolId ||
annotation?.object?.customData?.toolId;
if (toolId && MARKUP_TOOL_IDS.includes(toolId)) return true;
const type = annotation?.type ?? annotation?.object?.type;
if (typeof type === 'number' && [9, 10, 11, 12].includes(type)) return true;
const subtype = annotation?.subtype ?? annotation?.object?.subtype;
if (typeof subtype === 'string') {
const lower = subtype.toLowerCase();
if (MARKUP_TOOL_IDS.some((t) => lower.includes(t))) return true;
}
return false;
};
const shouldStayOnPlacementTool = (annotation: any, derivedTool?: string | null | undefined): boolean => {
const toolId =
derivedTool ||
annotation?.customData?.annotationToolId ||
annotation?.customData?.toolId ||
annotation?.object?.customData?.annotationToolId ||
annotation?.object?.customData?.toolId;
if (toolId && (MARKUP_TOOL_IDS.includes(toolId as any) || DRAWING_TOOL_IDS.includes(toolId as any))) {
return true;
}
const type = annotation?.type ?? annotation?.object?.type;
if (typeof type === 'number' && type === 15) return true; // ink family
if (isTextMarkupAnnotation(annotation)) return true;
return false;
};
export function useAnnotationSelection({
annotationApiRef,
deriveToolFromAnnotation,
activeToolRef,
manualToolSwitch,
setActiveTool,
setSelectedTextDraft,
setSelectedFontSize,
setInkWidth,
setShapeThickness,
setTextColor,
setTextBackgroundColor,
setNoteBackgroundColor,
setInkColor,
setHighlightColor,
setHighlightOpacity,
setUnderlineColor,
setUnderlineOpacity,
setStrikeoutColor,
setStrikeoutOpacity,
setSquigglyColor,
setSquigglyOpacity,
setShapeStrokeColor,
setShapeFillColor,
setShapeOpacity,
setShapeStrokeOpacity,
setShapeFillOpacity,
setTextAlignment,
setFreehandHighlighterWidth,
}: UseAnnotationSelectionParams) {
const [selectedAnn, setSelectedAnn] = useState<any | null>(null);
const [selectedAnnId, setSelectedAnnId] = useState<string | null>(null);
const selectedAnnIdRef = useRef<string | null>(null);
const applySelectionFromAnnotation = useCallback(
(ann: any | null) => {
const annObject = ann?.object ?? ann ?? null;
const annId = annObject?.id ?? null;
const type = annObject?.type;
const derivedTool = annObject ? deriveToolFromAnnotation(annObject) : undefined;
selectedAnnIdRef.current = annId;
setSelectedAnnId(annId);
// Normalize selected annotation to always expose .object for edit panels
const normalizedSelection = ann?.object ? ann : annObject ? { object: annObject } : null;
setSelectedAnn(normalizedSelection);
if (annObject?.contents !== undefined) {
setSelectedTextDraft(annObject.contents ?? '');
}
if (annObject?.fontSize !== undefined) {
setSelectedFontSize(annObject.fontSize ?? 14);
}
if (annObject?.textAlign !== undefined) {
const align = annObject.textAlign;
if (typeof align === 'string') {
const normalized = align === 'center' ? 'center' : align === 'right' ? 'right' : 'left';
setTextAlignment(normalized);
} else if (typeof align === 'number') {
const normalized = align === 1 ? 'center' : align === 2 ? 'right' : 'left';
setTextAlignment(normalized);
}
}
if (type === 3) {
const background =
(annObject?.backgroundColor as string | undefined) ||
(annObject?.fillColor as string | undefined) ||
undefined;
const textColor = (annObject?.textColor as string | undefined) || (annObject?.color as string | undefined);
if (textColor) {
setTextColor(textColor);
}
if (derivedTool === 'note') {
setNoteBackgroundColor(background || '');
} else {
setTextBackgroundColor(background || '');
}
}
if (type === 15) {
const width =
annObject?.strokeWidth ?? annObject?.borderWidth ?? annObject?.lineWidth ?? annObject?.thickness;
if (derivedTool === 'inkHighlighter') {
if (annObject?.color) setHighlightColor(annObject.color);
if (annObject?.opacity !== undefined) {
setHighlightOpacity(Math.round((annObject.opacity ?? 1) * 100));
}
if (width !== undefined && setFreehandHighlighterWidth) {
setFreehandHighlighterWidth(width);
}
} else {
if (width !== undefined) setInkWidth(width ?? 2);
if (annObject?.color) {
setInkColor(annObject.color);
}
}
} else if (type >= 4 && type <= 8) {
const width = annObject?.strokeWidth ?? annObject?.borderWidth ?? annObject?.lineWidth;
if (width !== undefined) {
setShapeThickness(width ?? 1);
}
}
if (type === 9) {
if (annObject?.color) setHighlightColor(annObject.color);
if (annObject?.opacity !== undefined) setHighlightOpacity(Math.round((annObject.opacity ?? 1) * 100));
} else if (type === 10) {
if (annObject?.color) setUnderlineColor(annObject.color);
if (annObject?.opacity !== undefined) setUnderlineOpacity(Math.round((annObject.opacity ?? 1) * 100));
} else if (type === 12) {
if (annObject?.color) setStrikeoutColor(annObject.color);
if (annObject?.opacity !== undefined) setStrikeoutOpacity(Math.round((annObject.opacity ?? 1) * 100));
} else if (type === 11) {
if (annObject?.color) setSquigglyColor(annObject.color);
if (annObject?.opacity !== undefined) setSquigglyOpacity(Math.round((annObject.opacity ?? 1) * 100));
}
if ([4, 5, 6, 7, 8].includes(type)) {
const stroke = (annObject?.strokeColor as string | undefined) ?? (annObject?.color as string | undefined);
if (stroke) setShapeStrokeColor(stroke);
if ([5, 6, 7].includes(type)) {
const fill = (annObject?.color as string | undefined) ?? (annObject?.fillColor as string | undefined);
if (fill) setShapeFillColor(fill);
}
const opacity =
annObject?.opacity !== undefined ? Math.round((annObject.opacity ?? 1) * 100) : undefined;
const strokeOpacityValue =
annObject?.strokeOpacity !== undefined
? Math.round((annObject.strokeOpacity ?? 1) * 100)
: undefined;
const fillOpacityValue =
annObject?.fillOpacity !== undefined ? Math.round((annObject.fillOpacity ?? 1) * 100) : undefined;
if (opacity !== undefined) {
setShapeOpacity(opacity);
setShapeStrokeOpacity(strokeOpacityValue ?? opacity);
setShapeFillOpacity(fillOpacityValue ?? opacity);
} else {
if (strokeOpacityValue !== undefined) setShapeStrokeOpacity(strokeOpacityValue);
if (fillOpacityValue !== undefined) setShapeFillOpacity(fillOpacityValue);
}
}
const matchingTool = derivedTool;
const stayOnPlacement = shouldStayOnPlacementTool(annObject, matchingTool);
if (matchingTool && activeToolRef.current !== 'select' && !stayOnPlacement) {
activeToolRef.current = 'select';
setActiveTool('select');
// Immediately enable select tool to avoid re-entering placement after creation.
annotationApiRef.current?.activateAnnotationTool?.('select');
} else if (activeToolRef.current === 'select') {
// Keep the viewer in Select mode so clicking existing annotations does not re-enable placement.
annotationApiRef.current?.activateAnnotationTool?.('select');
}
},
[
activeToolRef,
deriveToolFromAnnotation,
manualToolSwitch,
setActiveTool,
setInkWidth,
setNoteBackgroundColor,
setSelectedFontSize,
setSelectedTextDraft,
setShapeThickness,
setTextBackgroundColor,
setTextColor,
setInkColor,
setHighlightColor,
setHighlightOpacity,
setUnderlineColor,
setUnderlineOpacity,
setStrikeoutColor,
setStrikeoutOpacity,
setSquigglyColor,
setSquigglyOpacity,
setShapeStrokeColor,
setShapeFillColor,
setShapeOpacity,
setShapeStrokeOpacity,
setShapeFillOpacity,
setTextAlignment,
setFreehandHighlighterWidth,
shouldStayOnPlacementTool,
]
);
useEffect(() => {
const api = annotationApiRef.current as any;
if (!api) return;
const checkSelection = () => {
let ann: any = null;
if (typeof api.getSelectedAnnotation === 'function') {
try {
ann = api.getSelectedAnnotation();
} catch (error) {
// Some builds of the annotation plugin can throw when reading
// internal selection state (e.g., accessing `selectedUid` on
// an undefined object). Treat this as "no current selection"
// instead of crashing the annotations tool.
console.error('[useAnnotationSelection] getSelectedAnnotation failed:', error);
ann = null;
}
}
const currentId = ann?.object?.id ?? ann?.id ?? null;
if (currentId !== selectedAnnIdRef.current) {
applySelectionFromAnnotation(ann ?? null);
}
};
let interval: ReturnType<typeof setInterval> | null = null;
if (typeof api.onAnnotationEvent === 'function') {
const handler = (event: any) => {
const ann = event?.annotation ?? event?.selectedAnnotation ?? null;
const eventType = event?.type;
switch (eventType) {
case 'create':
case 'add':
case 'added':
case 'created':
case 'annotationCreated':
case 'annotationAdded':
case 'complete': {
const eventAnn = ann ?? api.getSelectedAnnotation?.();
applySelectionFromAnnotation(eventAnn);
const currentTool = activeToolRef.current;
const tool =
deriveToolFromAnnotation((eventAnn as any)?.object ?? eventAnn ?? api.getSelectedAnnotation?.()) ||
currentTool;
const stayOnPlacement =
shouldStayOnPlacementTool(eventAnn, tool) ||
(tool ? DRAWING_TOOL_IDS.includes(tool as any) : false);
if (activeToolRef.current !== 'select' && !stayOnPlacement) {
activeToolRef.current = 'select';
setActiveTool('select');
annotationApiRef.current?.activateAnnotationTool?.('select');
}
// Re-read selection after the viewer updates to ensure we have the full annotation object for the edit panel.
setTimeout(() => {
const selected = api.getSelectedAnnotation?.();
applySelectionFromAnnotation(selected ?? eventAnn ?? null);
const derivedAfter =
deriveToolFromAnnotation((selected as any)?.object ?? selected ?? eventAnn ?? null) || activeToolRef.current;
const stayOnPlacementAfter =
shouldStayOnPlacementTool(selected ?? eventAnn ?? null, derivedAfter) ||
(derivedAfter ? DRAWING_TOOL_IDS.includes(derivedAfter as any) : false);
if (activeToolRef.current !== 'select' && !stayOnPlacementAfter) {
activeToolRef.current = 'select';
setActiveTool('select');
annotationApiRef.current?.activateAnnotationTool?.('select');
}
}, 50);
break;
}
case 'select':
case 'selected':
case 'annotationSelected':
case 'annotationClicked':
case 'annotationTapped':
applySelectionFromAnnotation(ann ?? api.getSelectedAnnotation?.());
break;
case 'deselect':
case 'clearSelection':
applySelectionFromAnnotation(null);
break;
case 'delete':
case 'remove':
if (ann?.id && ann.id === selectedAnnIdRef.current) {
applySelectionFromAnnotation(null);
}
break;
case 'update':
case 'change':
if (selectedAnnIdRef.current) {
const current = api.getSelectedAnnotation?.();
if (current) {
applySelectionFromAnnotation(current);
}
}
break;
default:
break;
}
};
const unsubscribe = api.onAnnotationEvent(handler);
interval = setInterval(checkSelection, 450);
return () => {
if (typeof unsubscribe === 'function') {
unsubscribe();
}
if (interval) clearInterval(interval);
};
}
interval = setInterval(checkSelection, 350);
return () => {
if (interval) clearInterval(interval);
};
}, [annotationApiRef, applySelectionFromAnnotation]);
return {
selectedAnn,
selectedAnnId,
selectedAnnIdRef,
setSelectedAnn,
setSelectedAnnId,
applySelectionFromAnnotation,
};
}
@@ -0,0 +1,325 @@
import { useCallback, useMemo, useState } from 'react';
import type { AnnotationToolId } from '@app/components/viewer/viewerTypes';
type Size = { width: number; height: number };
export type BuildToolOptionsExtras = {
includeMetadata?: boolean;
stampImageData?: string;
stampImageSize?: Size | null;
};
interface StyleState {
inkColor: string;
inkWidth: number;
highlightColor: string;
highlightOpacity: number;
freehandHighlighterWidth: number;
underlineColor: string;
underlineOpacity: number;
strikeoutColor: string;
strikeoutOpacity: number;
squigglyColor: string;
squigglyOpacity: number;
textColor: string;
textSize: number;
textAlignment: 'left' | 'center' | 'right';
textBackgroundColor: string;
noteBackgroundColor: string;
shapeStrokeColor: string;
shapeFillColor: string;
shapeOpacity: number;
shapeStrokeOpacity: number;
shapeFillOpacity: number;
shapeThickness: number;
}
interface StyleActions {
setInkColor: (value: string) => void;
setInkWidth: (value: number) => void;
setHighlightColor: (value: string) => void;
setHighlightOpacity: (value: number) => void;
setFreehandHighlighterWidth: (value: number) => void;
setUnderlineColor: (value: string) => void;
setUnderlineOpacity: (value: number) => void;
setStrikeoutColor: (value: string) => void;
setStrikeoutOpacity: (value: number) => void;
setSquigglyColor: (value: string) => void;
setSquigglyOpacity: (value: number) => void;
setTextColor: (value: string) => void;
setTextSize: (value: number) => void;
setTextAlignment: (value: 'left' | 'center' | 'right') => void;
setTextBackgroundColor: (value: string) => void;
setNoteBackgroundColor: (value: string) => void;
setShapeStrokeColor: (value: string) => void;
setShapeFillColor: (value: string) => void;
setShapeOpacity: (value: number) => void;
setShapeStrokeOpacity: (value: number) => void;
setShapeFillOpacity: (value: number) => void;
setShapeThickness: (value: number) => void;
}
export type BuildToolOptionsFn = (
toolId: AnnotationToolId,
extras?: BuildToolOptionsExtras
) => Record<string, unknown>;
export interface AnnotationStyleStateReturn {
styleState: StyleState;
styleActions: StyleActions;
buildToolOptions: BuildToolOptionsFn;
getActiveColor: (target: string | null) => string;
}
export const useAnnotationStyleState = (
cssToPdfSize?: (size: Size) => Size
): AnnotationStyleStateReturn => {
const [inkColor, setInkColor] = useState('#1f2933');
const [inkWidth, setInkWidth] = useState(2);
const [highlightColor, setHighlightColor] = useState('#ffd54f');
const [highlightOpacity, setHighlightOpacity] = useState(60);
const [freehandHighlighterWidth, setFreehandHighlighterWidth] = useState(6);
const [underlineColor, setUnderlineColor] = useState('#ffb300');
const [underlineOpacity, setUnderlineOpacity] = useState(100);
const [strikeoutColor, setStrikeoutColor] = useState('#e53935');
const [strikeoutOpacity, setStrikeoutOpacity] = useState(100);
const [squigglyColor, setSquigglyColor] = useState('#00acc1');
const [squigglyOpacity, setSquigglyOpacity] = useState(100);
const [textColor, setTextColor] = useState('#111111');
const [textSize, setTextSize] = useState(14);
const [textAlignment, setTextAlignment] = useState<'left' | 'center' | 'right'>('left');
const [textBackgroundColor, setTextBackgroundColor] = useState<string>('');
const [noteBackgroundColor, setNoteBackgroundColor] = useState('#ffd54f');
const [shapeStrokeColor, setShapeStrokeColor] = useState('#cf5b5b');
const [shapeFillColor, setShapeFillColor] = useState('#0000ff');
const [shapeOpacity, setShapeOpacity] = useState(50);
const [shapeStrokeOpacity, setShapeStrokeOpacity] = useState(50);
const [shapeFillOpacity, setShapeFillOpacity] = useState(50);
const [shapeThickness, setShapeThickness] = useState(2);
const buildToolOptions = useCallback<BuildToolOptionsFn>(
(toolId, extras) => {
const includeMetadata = extras?.includeMetadata ?? true;
const metadata = includeMetadata
? {
customData: {
toolId,
annotationToolId: toolId,
source: 'annotate',
author: 'User',
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
},
}
: {};
switch (toolId) {
case 'ink':
return { color: inkColor, thickness: inkWidth, ...metadata };
case 'inkHighlighter':
return {
color: highlightColor,
opacity: highlightOpacity / 100,
thickness: freehandHighlighterWidth,
...metadata,
};
case 'highlight':
return { color: highlightColor, opacity: highlightOpacity / 100, ...metadata };
case 'underline':
return { color: underlineColor, opacity: underlineOpacity / 100, ...metadata };
case 'strikeout':
return { color: strikeoutColor, opacity: strikeoutOpacity / 100, ...metadata };
case 'squiggly':
return { color: squigglyColor, opacity: squigglyOpacity / 100, ...metadata };
case 'text': {
const textAlignNumber = textAlignment === 'left' ? 0 : textAlignment === 'center' ? 1 : 2;
return {
color: textColor,
textColor: textColor,
fontSize: textSize,
textAlign: textAlignNumber,
...(textBackgroundColor ? { fillColor: textBackgroundColor } : {}),
...metadata,
};
}
case 'note': {
const noteFillColor = noteBackgroundColor || 'transparent';
return {
color: textColor,
fillColor: noteFillColor,
opacity: 1,
...metadata,
};
}
case 'square':
case 'circle':
case 'polygon':
return {
color: shapeFillColor,
strokeColor: shapeStrokeColor,
opacity: shapeOpacity / 100,
strokeOpacity: shapeStrokeOpacity / 100,
fillOpacity: shapeFillOpacity / 100,
borderWidth: shapeThickness,
...metadata,
};
case 'line':
case 'polyline':
case 'lineArrow':
return {
color: shapeStrokeColor,
strokeColor: shapeStrokeColor,
opacity: shapeStrokeOpacity / 100,
borderWidth: shapeThickness,
...metadata,
};
case 'stamp': {
const pdfSize =
extras?.stampImageSize && cssToPdfSize ? cssToPdfSize(extras.stampImageSize) : undefined;
return {
imageSrc: extras?.stampImageData,
...(pdfSize ? { imageSize: pdfSize } : {}),
...metadata,
};
}
default:
return { ...metadata };
}
},
[
cssToPdfSize,
freehandHighlighterWidth,
highlightColor,
highlightOpacity,
inkColor,
inkWidth,
noteBackgroundColor,
shapeFillColor,
shapeFillOpacity,
shapeOpacity,
shapeStrokeColor,
shapeStrokeOpacity,
shapeThickness,
squigglyColor,
squigglyOpacity,
strikeoutColor,
strikeoutOpacity,
textAlignment,
textBackgroundColor,
textColor,
textSize,
underlineColor,
underlineOpacity,
]
);
const getActiveColor = useCallback(
(target: string | null) => {
if (target === 'ink') return inkColor;
if (target === 'highlight' || target === 'inkHighlighter') return highlightColor;
if (target === 'underline') return underlineColor;
if (target === 'strikeout') return strikeoutColor;
if (target === 'squiggly') return squigglyColor;
if (target === 'shapeStroke') return shapeStrokeColor;
if (target === 'shapeFill') return shapeFillColor;
if (target === 'textBackground') return textBackgroundColor || '#ffffff';
if (target === 'noteBackground') return noteBackgroundColor || '#ffffff';
return textColor;
},
[
highlightColor,
inkColor,
noteBackgroundColor,
shapeFillColor,
shapeStrokeColor,
squigglyColor,
strikeoutColor,
textBackgroundColor,
textColor,
underlineColor,
]
);
const styleState: StyleState = useMemo(
() => ({
inkColor,
inkWidth,
highlightColor,
highlightOpacity,
freehandHighlighterWidth,
underlineColor,
underlineOpacity,
strikeoutColor,
strikeoutOpacity,
squigglyColor,
squigglyOpacity,
textColor,
textSize,
textAlignment,
textBackgroundColor,
noteBackgroundColor,
shapeStrokeColor,
shapeFillColor,
shapeOpacity,
shapeStrokeOpacity,
shapeFillOpacity,
shapeThickness,
}),
[
freehandHighlighterWidth,
highlightColor,
highlightOpacity,
inkColor,
inkWidth,
noteBackgroundColor,
shapeFillColor,
shapeFillOpacity,
shapeOpacity,
shapeStrokeColor,
shapeStrokeOpacity,
shapeThickness,
squigglyColor,
squigglyOpacity,
strikeoutColor,
strikeoutOpacity,
textAlignment,
textBackgroundColor,
textColor,
textSize,
underlineColor,
underlineOpacity,
]
);
const styleActions: StyleActions = {
setInkColor,
setInkWidth,
setHighlightColor,
setHighlightOpacity,
setFreehandHighlighterWidth,
setUnderlineColor,
setUnderlineOpacity,
setStrikeoutColor,
setStrikeoutOpacity,
setSquigglyColor,
setSquigglyOpacity,
setTextColor,
setTextSize,
setTextAlignment,
setTextBackgroundColor,
setNoteBackgroundColor,
setShapeStrokeColor,
setShapeFillColor,
setShapeOpacity,
setShapeStrokeOpacity,
setShapeFillOpacity,
setShapeThickness,
};
return {
styleState,
styleActions,
buildToolOptions,
getActiveColor,
};
};
+1
View File
@@ -25,6 +25,7 @@ export const CORE_REGULAR_TOOL_IDS = [
'ocr',
'addImage',
'rotate',
'annotate',
'scannerImageSplit',
'editTableOfContents',
'scannerEffect',
+2
View File
@@ -70,6 +70,8 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
'/scanner-image-split': 'scannerImageSplit',
// Annotation and content removal
'/annotations': 'annotate',
'/annotate': 'annotate',
'/remove-annotations': 'removeAnnotations',
'/remove-image': 'removeImage',
@@ -422,6 +422,7 @@ export default function AdminConnectionsSection() {
key={provider.id}
provider={provider}
isConfigured={false}
settings={getProviderSettings(provider)}
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
disabled={!loginEnabled}
/>