Merge branch 'V2' into feature/v2/add-text

This commit is contained in:
Reece Browne
2025-11-21 15:57:30 +00:00
committed by GitHub
30 changed files with 1531 additions and 185 deletions
@@ -18,11 +18,37 @@ import stirling.software.common.model.ApplicationProperties;
@Slf4j
public class EndpointConfiguration {
public enum DisableReason {
CONFIG,
DEPENDENCY,
UNKNOWN
}
public static class EndpointAvailability {
private final boolean enabled;
private final DisableReason reason;
public EndpointAvailability(boolean enabled, DisableReason reason) {
this.enabled = enabled;
this.reason = reason;
}
public boolean isEnabled() {
return enabled;
}
public DisableReason getReason() {
return reason;
}
}
private static final String REMOVE_BLANKS = "remove-blanks";
private final ApplicationProperties applicationProperties;
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
private Set<String> disabledGroups = new HashSet<>();
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
private final boolean runningProOrHigher;
@@ -35,16 +61,31 @@ public class EndpointConfiguration {
processEnvironmentConfigs();
}
private String normalizeEndpoint(String endpoint) {
if (endpoint == null) {
return null;
}
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
}
public void enableEndpoint(String endpoint) {
endpointStatuses.put(endpoint, true);
log.debug("Enabled endpoint: {}", endpoint);
String normalized = normalizeEndpoint(endpoint);
endpointStatuses.put(normalized, true);
endpointDisableReasons.remove(normalized);
log.debug("Enabled endpoint: {}", normalized);
}
public void disableEndpoint(String endpoint) {
if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) {
log.debug("Disabling endpoint: {}", endpoint);
disableEndpoint(endpoint, DisableReason.CONFIG);
}
public void disableEndpoint(String endpoint, DisableReason reason) {
String normalized = normalizeEndpoint(endpoint);
if (!Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
log.debug("Disabling endpoint: {}", normalized);
}
endpointStatuses.put(endpoint, false);
endpointStatuses.put(normalized, false);
endpointDisableReasons.put(normalized, reason);
}
public boolean isEndpointEnabled(String endpoint) {
@@ -150,6 +191,10 @@ public class EndpointConfiguration {
}
public void disableGroup(String group) {
disableGroup(group, DisableReason.CONFIG);
}
public void disableGroup(String group, DisableReason reason) {
if (disabledGroups.add(group)) {
if (isToolGroup(group)) {
log.debug(
@@ -161,11 +206,12 @@ public class EndpointConfiguration {
group);
}
}
groupDisableReasons.put(group, reason);
// Only cascade to endpoints for *functional* groups
if (!isToolGroup(group)) {
Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) {
endpoints.forEach(this::disableEndpoint);
endpoints.forEach(endpoint -> disableEndpoint(endpoint, reason));
}
}
}
@@ -174,12 +220,39 @@ public class EndpointConfiguration {
if (disabledGroups.remove(group)) {
log.debug("Enabling group: {}", group);
}
groupDisableReasons.remove(group);
Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) {
endpoints.forEach(this::enableEndpoint);
}
}
public EndpointAvailability getEndpointAvailability(String endpoint) {
boolean enabled = isEndpointEnabled(endpoint);
DisableReason reason = enabled ? null : determineDisableReason(endpoint);
return new EndpointAvailability(enabled, reason);
}
private DisableReason determineDisableReason(String endpoint) {
String normalized = normalizeEndpoint(endpoint);
if (Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
return endpointDisableReasons.getOrDefault(normalized, DisableReason.CONFIG);
}
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
String group = entry.getKey();
Set<String> endpoints = entry.getValue();
if (!disabledGroups.contains(group) || endpoints == null) {
continue;
}
if (endpoints.contains(normalized)) {
return groupDisableReasons.getOrDefault(group, DisableReason.CONFIG);
}
}
return DisableReason.UNKNOWN;
}
public Set<String> getDisabledGroups() {
return new HashSet<>(disabledGroups);
}
@@ -261,6 +334,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "pdf-to-csv");
addEndpointToGroup("Convert", "pdf-to-markdown");
addEndpointToGroup("Convert", "eml-to-pdf");
addEndpointToGroup("Convert", "cbz-to-pdf");
addEndpointToGroup("Convert", "pdf-to-cbz");
// Adding endpoints to "Security" group
addEndpointToGroup("Security", "add-password");
@@ -394,6 +469,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "pdf-to-markdown");
addEndpointToGroup("Java", "add-attachments");
addEndpointToGroup("Java", "compress-pdf");
addEndpointToGroup("Java", "cbz-to-pdf");
addEndpointToGroup("Java", "pdf-to-cbz");
addEndpointToGroup("rar", "pdf-to-cbr");
// Javascript
@@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.RegexPatternUtils;
@@ -97,7 +98,7 @@ public class ExternalAppDepConfig {
if (affectedGroups != null) {
for (String group : affectedGroups) {
List<String> affectedFeatures = getAffectedFeatures(group);
endpointConfiguration.disableGroup(group);
endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY);
log.warn(
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
command,
@@ -127,8 +128,8 @@ public class ExternalAppDepConfig {
if (!pythonAvailable) {
List<String> pythonFeatures = getAffectedFeatures("Python");
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("Python");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY);
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}",
String.join(", ", pythonFeatures),
@@ -146,14 +147,14 @@ public class ExternalAppDepConfig {
int exitCode = process.waitFor();
if (exitCode != 0) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"OpenCV not available in Python - Disabling OpenCV features: {}",
String.join(", ", openCVFeatures));
}
} catch (Exception e) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"Error checking OpenCV: {} - Disabling OpenCV features: {}",
e.getMessage(),
@@ -1,6 +1,7 @@
package stirling.software.SPDF.controller.api.misc;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.context.ApplicationContext;
@@ -10,9 +11,13 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.SPDF.config.InitialSetup;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
@@ -200,4 +205,19 @@ public class ConfigController {
}
return ResponseEntity.ok(result);
}
@GetMapping("/endpoints-availability")
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
@RequestParam(name = "endpoints")
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
List<@NotBlank String> endpoints) {
Map<String, EndpointAvailability> result = new HashMap<>();
for (String endpoint : endpoints) {
String trimmedEndpoint = endpoint.trim();
result.put(
trimmedEndpoint,
endpointConfiguration.getEndpointAvailability(trimmedEndpoint));
}
return ResponseEntity.ok(result);
}
}
+17
View File
@@ -14,6 +14,7 @@
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-bookmark": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
@@ -633,6 +634,22 @@
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-bookmark": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.4.1.tgz",
"integrity": "sha512-WnfBJdv+Eq5zsMfwDZ5RlXZMGpvKm/ccL6jlTVwtELBhu3wvhjjbBmZdheEOzHMC3VXMNYDMjCeaXkUG4nWoDA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-export": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz",
+1
View File
@@ -10,6 +10,7 @@
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-bookmark": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
@@ -17,6 +17,8 @@
"comingSoon": "Coming soon:",
"favorite": "Add to favourites",
"favorites": "Favourites",
"unavailable": "Disabled by server administrator:",
"unavailableDependency": "Unavailable - required tool missing on server:",
"heading": "All tools (fullscreen view)",
"noResults": "Try adjusting your search or toggle descriptions to find what you need.",
"recommended": "Recommended",
@@ -5730,7 +5732,11 @@
"latestVersion": "Latest Version",
"checkForUpdates": "Check for Updates",
"viewDetails": "View Details"
}
},
"hideUnavailableTools": "Hide unavailable tools",
"hideUnavailableToolsDescription": "Remove tools that have been disabled by your server instead of showing them greyed out.",
"hideUnavailableConversions": "Hide unavailable conversions",
"hideUnavailableConversionsDescription": "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
},
"hotkeys": {
"errorConflict": "Shortcut already used by {{tool}}.",
@@ -53,11 +53,17 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
};
const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo);
if (summary) {
if (summary && summary.latest_version) {
const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0;
if (isNewerVersion) {
setUpdateSummary(summary);
} else {
// Clear any existing update summary if user is on latest version
setUpdateSummary(null);
}
} else {
// No update available (latest_version is null) - clear any existing update summary
setUpdateSummary(null);
}
setCheckingUpdate(false);
};
@@ -128,83 +134,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
</Paper>
)}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
</Text>
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
data={[
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
]}
/>
</div>
<Tooltip
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
</Text>
</div>
<Switch
checked={preferences.autoUnzip}
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
/>
</div>
</Tooltip>
<Tooltip
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
</Text>
</div>
<NumberInput
value={fileLimitInput}
onChange={setFileLimitInput}
onBlur={() => {
const numValue = Number(fileLimitInput);
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
setFileLimitInput(finalValue);
updatePreference('autoUnzipFileLimit', finalValue);
}}
min={1}
max={100}
step={1}
disabled={!preferences.autoUnzip}
style={{ width: 90 }}
/>
</div>
</Tooltip>
</Stack>
</Paper>
{/* Update Check Section */}
{config?.appVersion && (
<Paper withBorder p="md" radius="md">
@@ -292,6 +221,111 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
</Paper>
)}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
</Text>
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
data={[
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
]}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.hideUnavailableTools', 'Hide unavailable tools')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.hideUnavailableToolsDescription', 'Remove tools that have been disabled by your server instead of showing them greyed out.')}
</Text>
</div>
<Switch
checked={preferences.hideUnavailableTools}
onChange={(event) => updatePreference('hideUnavailableTools', event.currentTarget.checked)}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.hideUnavailableConversions', 'Hide unavailable conversions')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.hideUnavailableConversionsDescription', 'Remove disabled conversion options in the Convert tool instead of showing them greyed out.')}
</Text>
</div>
<Switch
checked={preferences.hideUnavailableConversions}
onChange={(event) => updatePreference('hideUnavailableConversions', event.currentTarget.checked)}
/>
</div>
<Tooltip
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
</Text>
</div>
<Switch
checked={preferences.autoUnzip}
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
/>
</div>
</Tooltip>
<Tooltip
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
</Text>
</div>
<NumberInput
value={fileLimitInput}
onChange={setFileLimitInput}
onBlur={() => {
const numValue = Number(fileLimitInput);
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
setFileLimitInput(finalValue);
updatePreference('autoUnzipFileLimit', finalValue);
}}
min={1}
max={100}
step={1}
disabled={!preferences.autoUnzip}
style={{ width: 90 }}
/>
</div>
</Tooltip>
</Stack>
</Paper>
{/* Update Modal */}
{updateSummary && config?.appVersion && config?.machineType && (
<UpdateModal
@@ -8,6 +8,7 @@ import { getConversionEndpoints } from "@app/data/toolsTaxonomy";
import { useFileSelection } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/FileContext";
import { detectFileExtension } from "@app/utils/fileUtils";
import { usePreferences } from "@app/contexts/PreferencesContext";
import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown";
import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings";
import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings";
@@ -47,8 +48,12 @@ const ConvertSettings = ({
const { setSelectedFiles } = useFileSelection();
const { state, selectors } = useFileState();
const activeFiles = state.files.ids;
const { preferences } = usePreferences();
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []);
const allEndpoints = useMemo(() => {
const endpoints = getConversionEndpoints(EXTENSION_TO_ENDPOINT);
return endpoints;
}, []);
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
@@ -56,7 +61,8 @@ const ConvertSettings = ({
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
if (!endpointKey) return false;
return endpointStatus[endpointKey] === true;
const isAvailable = endpointStatus[endpointKey] === true;
return isAvailable;
};
// Enhanced FROM options with endpoint availability
@@ -74,6 +80,12 @@ const ConvertSettings = ({
};
});
// Filter out unavailable source formats if preference is enabled
let filteredOptions = baseOptions;
if (preferences.hideUnavailableConversions) {
filteredOptions = baseOptions.filter(opt => opt.enabled !== false);
}
// Add dynamic format option if current selection is a file-<extension> format
if (parameters.fromExtension && parameters.fromExtension.startsWith('file-')) {
const extension = parameters.fromExtension.replace('file-', '');
@@ -85,22 +97,32 @@ const ConvertSettings = ({
};
// Add the dynamic option at the beginning
return [dynamicOption, ...baseOptions];
return [dynamicOption, ...filteredOptions];
}
return baseOptions;
}, [parameters.fromExtension, endpointStatus]);
return filteredOptions;
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
// Enhanced TO options with endpoint availability
const enhancedToOptions = useMemo(() => {
if (!parameters.fromExtension) return [];
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
return availableOptions.map(option => ({
...option,
enabled: isConversionAvailable(parameters.fromExtension, option.value)
}));
}, [parameters.fromExtension, endpointStatus]);
const enhanced = availableOptions.map(option => {
const enabled = isConversionAvailable(parameters.fromExtension, option.value);
return {
...option,
enabled
};
});
// Filter out unavailable conversions if preference is enabled
if (preferences.hideUnavailableConversions) {
return enhanced.filter(opt => opt.enabled !== false);
}
return enhanced;
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
const resetParametersToDefaults = () => {
onParameterChange('imageOptions', {
@@ -5,7 +5,7 @@ import { Tooltip } from '@app/components/shared/Tooltip';
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared';
interface CompactToolItemProps {
id: string;
@@ -17,7 +17,7 @@ interface CompactToolItemProps {
const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => {
const { t } = useTranslation();
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool);
const categoryColor = getSubcategoryColor(tool.subcategoryId);
const iconBg = getIconBackground(categoryColor, false);
const iconClasses = 'tool-panel__fullscreen-list-icon';
@@ -73,9 +73,12 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected,
</button>
);
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
const disabledMessage = t(disabledKey, disabledFallback);
const tooltipContent = disabled
? (
<span><strong>{t('toolPanel.fullscreen.comingSoon', 'Coming soon:')}</strong> {tool.description}</span>
<span><strong>{disabledMessage}</strong> {tool.description}</span>
)
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared';
interface DetailedToolItemProps {
id: string;
@@ -15,7 +15,7 @@ interface DetailedToolItemProps {
const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelected, onClick }) => {
const { t } = useTranslation();
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool);
const categoryColor = getSubcategoryColor(tool.subcategoryId);
const iconBg = getIconBackground(categoryColor, true);
@@ -34,6 +34,9 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
iconNode = tool.icon;
}
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
const disabledMessage = t(disabledKey, disabledFallback);
return (
<button
type="button"
@@ -60,7 +63,12 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
{tool.name}
</Text>
<Text size="sm" c="dimmed" className="tool-panel__fullscreen-description">
{tool.description}
{disabled ? (
<>
<strong>{disabledMessage} </strong>
{tool.description}
</>
) : tool.description}
</Text>
{binding && (
<div className="tool-panel__fullscreen-shortcut">
@@ -2,6 +2,7 @@ import { useHotkeys } from '@app/contexts/HotkeyContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
import { ToolId } from '@app/types/toolId';
import type { ToolAvailabilityMap } from '@app/hooks/useToolManagement';
export const getItemClasses = (isDetailed: boolean): string => {
return isDetailed ? 'tool-panel__fullscreen-item--detailed' : '';
@@ -22,23 +23,67 @@ export const getIconStyle = (): Record<string, string> => {
return {};
};
export const isToolDisabled = (id: string, tool: ToolRegistryEntry): boolean => {
return !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | null;
export const getToolDisabledReason = (
id: string,
tool: ToolRegistryEntry,
toolAvailability?: ToolAvailabilityMap
): ToolDisabledReason => {
if (!tool.component && !tool.link && id !== 'read' && id !== 'multiTool') {
return 'comingSoon';
}
const availabilityInfo = toolAvailability?.[id as ToolId];
if (availabilityInfo && availabilityInfo.available === false) {
if (availabilityInfo.reason === 'missingDependency') {
return 'missingDependency';
}
if (availabilityInfo.reason === 'disabledByAdmin') {
return 'disabledByAdmin';
}
return 'unknownUnavailable';
}
return null;
};
export const getDisabledLabel = (
disabledReason: ToolDisabledReason
): { key: string; fallback: string } => {
if (disabledReason === 'missingDependency') {
return {
key: 'toolPanel.fullscreen.unavailableDependency',
fallback: 'Unavailable - required tool missing on server:'
};
}
if (disabledReason === 'disabledByAdmin' || disabledReason === 'unknownUnavailable') {
return {
key: 'toolPanel.fullscreen.unavailable',
fallback: 'Disabled by server administrator:'
};
}
return {
key: 'toolPanel.fullscreen.comingSoon',
fallback: 'Coming soon:'
};
};
export function useToolMeta(id: string, tool: ToolRegistryEntry) {
const { hotkeys } = useHotkeys();
const { isFavorite, toggleFavorite } = useToolWorkflow();
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
const isFav = isFavorite(id as ToolId);
const binding = hotkeys[id as ToolId];
const disabled = isToolDisabled(id, tool);
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
const disabled = disabledReason !== null;
return {
binding,
isFav,
toggleFavorite: () => toggleFavorite(id as ToolId),
disabled,
disabledReason,
};
}
@@ -12,6 +12,7 @@ import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId";
import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
interface ToolButtonProps {
id: ToolId;
@@ -26,12 +27,12 @@ interface ToolButtonProps {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => {
const { t } = useTranslation();
// Special case: read and multiTool are navigational tools that are always available
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
const isUnavailable = disabledReason !== null;
const { hotkeys } = useHotkeys();
const binding = hotkeys[id];
const { getToolNavigation } = useToolNavigation();
const { isFavorite, toggleFavorite } = useToolWorkflow();
const fav = isFavorite(id as ToolId);
const handleClick = (id: ToolId) => {
@@ -48,8 +49,11 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
// Get navigation props for URL support (only if navigation is not disabled)
const navProps = !isUnavailable && !tool.link && !disableNavigation ? getToolNavigation(id, tool) : null;
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
const disabledMessage = t(disabledKey, disabledFallback);
const tooltipContent = isUnavailable
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
? (<span><strong>{disabledMessage}</strong> {tool.description}</span>)
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span>{tool.description}</span>
@@ -0,0 +1,73 @@
import { useEffect, useMemo, useState, useCallback } from 'react';
import { useBookmarkCapability } from '@embedpdf/plugin-bookmark/react';
import { BookmarkCapability } from '@embedpdf/plugin-bookmark';
import { useViewer } from '@app/contexts/ViewerContext';
import { BookmarkState, BookmarkAPIWrapper } from '@app/contexts/viewer/viewerBridges';
export function BookmarkAPIBridge() {
const { provides: bookmarkCapability } = useBookmarkCapability();
const { registerBridge } = useViewer();
const [state, setState] = useState<BookmarkState>({
bookmarks: null,
isLoading: false,
error: null,
});
const fetchBookmarks = useCallback(
async (capability: BookmarkCapability) => {
setState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const task = capability.getBookmarks();
const result = await task.toPromise();
setState({
bookmarks: result.bookmarks ?? [],
isLoading: false,
error: null,
});
return result.bookmarks ?? [];
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load bookmarks';
setState({
bookmarks: null,
isLoading: false,
error: message,
});
throw error;
}
},
[]
);
const api = useMemo<BookmarkAPIWrapper | null>(() => {
if (!bookmarkCapability) return null;
return {
fetchBookmarks: () => fetchBookmarks(bookmarkCapability),
clearBookmarks: () => {
setState({
bookmarks: null,
isLoading: false,
error: null,
});
},
setLocalBookmarks: (bookmarks, error = null) => {
setState({
bookmarks,
isLoading: false,
error,
});
},
};
}, [bookmarkCapability, fetchBookmarks]);
useEffect(() => {
if (!api) return;
registerBridge('bookmark', {
state,
api,
});
}, [api, state, registerBridge]);
return null;
}
@@ -0,0 +1,196 @@
/* Bookmark Sidebar - Modern styling to match AllTools section */
.bookmark-sidebar {
display: flex;
flex-direction: column;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--bg-toolbar) 96%, transparent),
color-mix(in srgb, var(--bg-background) 90%, transparent)
);
border-left: 1px solid color-mix(in srgb, var(--border-subtle) 75%, transparent);
box-shadow: -2px 0 16px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 20%, transparent);
backdrop-filter: blur(12px);
}
/* Header Section */
.bookmark-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0.875rem;
background: var(--bg-toolbar);
border-bottom: 1px solid var(--border-subtle);
}
.bookmark-sidebar__header-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.bookmark-sidebar__header-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--mantine-color-blue-6);
font-size: 1.1rem;
}
/* Search Section */
.bookmark-sidebar__search {
background: var(--tool-panel-search-bg, var(--bg-toolbar));
border-bottom: 1px solid var(--tool-panel-search-border-bottom, var(--border-subtle));
padding-top: 0.75rem !important;
}
/* Content Section */
.bookmark-sidebar__content {
padding-top: 0.5rem !important;
}
.bookmark-sidebar__empty-state,
.bookmark-sidebar__error {
padding: 2rem 1rem;
text-align: center;
}
.bookmark-sidebar__loading {
padding: 2rem 1rem !important;
}
/* Bookmark List */
.bookmark-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Bookmark Items */
.bookmark-item-wrapper {
display: flex;
flex-direction: column;
}
.bookmark-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
border-radius: 0.65rem;
cursor: default;
transition: all 0.2s ease;
background: transparent;
border: 1px solid transparent;
width: 100%;
box-sizing: border-box;
position: relative;
text-align: left;
outline: none;
}
.bookmark-item--clickable {
cursor: pointer;
background: color-mix(in srgb, var(--bg-toolbar) 86%, transparent);
}
.bookmark-item--clickable:hover {
background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar));
border-color: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle));
transform: translateX(2px);
}
.bookmark-item--clickable:active {
transform: translateX(3px);
}
.bookmark-item--clickable:focus-visible {
outline: 2px solid color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle));
outline-offset: 2px;
}
/* Expand Icon */
.bookmark-item__expand-icon {
flex-shrink: 0;
transition: transform 0.2s ease;
width: 2rem;
height: 2rem;
}
.bookmark-item:hover .bookmark-item__expand-icon {
transform: scale(1.05);
}
/* Dash for items without children */
.bookmark-item__dash {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
flex-shrink: 0;
color: var(--text-muted);
font-size: 1.25rem;
opacity: 0.5;
font-weight: 300;
}
/* Content */
.bookmark-item__content {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1;
min-width: 0;
}
.bookmark-item__title {
line-height: 1.35;
overflow-wrap: anywhere;
word-break: break-word;
color: var(--text-primary);
}
.bookmark-item--clickable:hover .bookmark-item__title {
color: var(--text-primary);
}
.bookmark-item__page {
line-height: 1.3;
font-size: 0.75rem;
}
/* Children Container */
.bookmark-item__children {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.25rem;
}
/* Smooth expand/collapse animation */
@keyframes bookmark-fade-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.bookmark-item__children {
animation: bookmark-fade-in 0.2s ease;
}
/* Accessibility & Reduced Motion */
@media (prefers-reduced-motion: reduce) {
.bookmark-item,
.bookmark-item__expand-icon,
.bookmark-item__children {
transition: none;
animation: none;
}
}
@@ -0,0 +1,551 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Box, ScrollArea, Text, ActionIcon, Loader, Stack, TextInput, Button } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useViewer } from '@app/contexts/ViewerContext';
import { PdfBookmarkObject, PdfActionType } from '@embedpdf/models';
import BookmarksIcon from '@mui/icons-material/BookmarksRounded';
import '@app/components/viewer/BookmarkSidebar.css';
interface BookmarkSidebarProps {
visible: boolean;
thumbnailVisible: boolean;
documentCacheKey?: string;
preloadCacheKeys?: string[];
}
const SIDEBAR_WIDTH = '15rem';
type BookmarkNode = PdfBookmarkObject & { id: string };
type BookmarkCacheStatus = 'idle' | 'loading' | 'success' | 'error';
interface BookmarkCacheEntry {
status: BookmarkCacheStatus;
bookmarks: PdfBookmarkObject[] | null;
error: string | null;
lastFetched: number | null;
}
const createEntry = (overrides: Partial<BookmarkCacheEntry> = {}): BookmarkCacheEntry => ({
status: 'idle',
bookmarks: null,
error: null,
lastFetched: null,
...overrides,
});
const resolvePageNumber = (bookmark: PdfBookmarkObject): number | null => {
const target = bookmark.target;
if (!target) return null;
if (target.type === 'destination') {
return target.destination.pageIndex + 1;
}
if (target.type === 'action') {
const action = target.action;
if (
action.type === PdfActionType.Goto ||
action.type === PdfActionType.RemoteGoto
) {
return action.destination?.pageIndex !== undefined
? action.destination.pageIndex + 1
: null;
}
}
return null;
};
export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, preloadCacheKeys = [] }: BookmarkSidebarProps) => {
const { bookmarkActions, scrollActions, hasBookmarkSupport } = useViewer();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [searchTerm, setSearchTerm] = useState('');
const [bookmarkSupport, setBookmarkSupport] = useState(() => hasBookmarkSupport());
const [activeEntry, setActiveEntry] = useState<BookmarkCacheEntry>(() => createEntry());
const cacheRef = useRef<Map<string, BookmarkCacheEntry>>(new Map());
const [fetchNonce, setFetchNonce] = useState(0);
const currentKeyRef = useRef<string | null>(documentCacheKey ?? null);
useEffect(() => {
currentKeyRef.current = documentCacheKey ?? null;
}, [documentCacheKey]);
// Poll once until the bookmark bridge registers
useEffect(() => {
if (bookmarkSupport) return;
let cancelled = false;
const id = setInterval(() => {
if (!cancelled && hasBookmarkSupport()) {
setBookmarkSupport(true);
clearInterval(id);
}
}, 250);
return () => {
cancelled = true;
clearInterval(id);
};
}, [bookmarkSupport, hasBookmarkSupport]);
// Reset UI and load cached entry (if any) when switching documents
useEffect(() => {
setExpanded({});
setSearchTerm('');
if (!documentCacheKey) {
setActiveEntry(createEntry());
bookmarkActions.clearBookmarks();
return;
}
const cached = cacheRef.current.get(documentCacheKey);
if (cached) {
setActiveEntry(cached);
if (cached.status === 'success') {
bookmarkActions.setLocalBookmarks(cached.bookmarks ?? [], null);
} else if (cached.status === 'error') {
bookmarkActions.setLocalBookmarks(cached.bookmarks ?? null, cached.error);
} else {
bookmarkActions.clearBookmarks();
}
} else {
setActiveEntry(createEntry());
bookmarkActions.clearBookmarks();
}
}, [documentCacheKey, bookmarkActions]);
// Keep cache bounded to the currently relevant keys
useEffect(() => {
const allowed = new Set<string>();
if (documentCacheKey) {
allowed.add(documentCacheKey);
}
preloadCacheKeys.forEach(key => {
if (key) {
allowed.add(key);
}
});
cacheRef.current.forEach((_entry, key) => {
if (!allowed.has(key)) {
cacheRef.current.delete(key);
}
});
}, [documentCacheKey, preloadCacheKeys]);
// Fetch bookmarks for the active document when needed
useEffect(() => {
if (!bookmarkSupport || !documentCacheKey) return;
const key = documentCacheKey;
const cached = cacheRef.current.get(key);
if (cached && (cached.status === 'loading' || cached.status === 'success')) {
return;
}
let cancelled = false;
const updateEntry = (entry: BookmarkCacheEntry) => {
cacheRef.current.set(key, entry);
if (!cancelled && currentKeyRef.current === key) {
setActiveEntry(entry);
}
};
updateEntry(createEntry({
status: 'loading',
bookmarks: cached?.bookmarks ?? null,
lastFetched: cached?.lastFetched ?? null,
}));
const fetchWithRetry = async () => {
const maxAttempts = 10;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const result = await bookmarkActions.fetchBookmarks();
return Array.isArray(result) ? result : [];
} catch (error: any) {
const message = typeof error?.message === 'string' ? error.message.toLowerCase() : '';
const notReady =
message.includes('document') &&
message.includes('not') &&
message.includes('open');
if (!notReady || attempt === maxAttempts - 1) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, 50));
}
}
return [];
};
fetchWithRetry()
.then(bookmarks => {
if (cancelled) return;
const entry = createEntry({
status: 'success',
bookmarks,
lastFetched: Date.now(),
});
updateEntry(entry);
if (currentKeyRef.current === key) {
bookmarkActions.setLocalBookmarks(bookmarks, null);
}
})
.catch(error => {
if (cancelled) return;
const message = error instanceof Error ? error.message : 'Failed to load bookmarks';
const fallback = cacheRef.current.get(key);
const entry = createEntry({
status: 'error',
bookmarks: fallback?.bookmarks ?? null,
error: message,
lastFetched: fallback?.lastFetched ?? null,
});
updateEntry(entry);
if (currentKeyRef.current === key) {
bookmarkActions.setLocalBookmarks(null, message);
}
});
return () => {
cancelled = true;
};
}, [bookmarkSupport, documentCacheKey, fetchNonce, bookmarkActions]);
const requestReload = useCallback(() => {
if (!documentCacheKey) return;
cacheRef.current.delete(documentCacheKey);
setActiveEntry(createEntry());
bookmarkActions.clearBookmarks();
setFetchNonce(value => value + 1);
}, [documentCacheKey, bookmarkActions]);
const bookmarksWithIds = useMemo(() => {
const assignIds = (nodes: PdfBookmarkObject[], prefix = 'root'): BookmarkNode[] => {
if (!Array.isArray(nodes)) {
return [];
}
return nodes.map((node, index) => {
const id = `${prefix}-${index}`;
return {
...node,
id,
children: node.children ? assignIds(node.children, id) : undefined,
};
});
};
const bookmarks = Array.isArray(activeEntry.bookmarks) ? activeEntry.bookmarks : [];
return assignIds(bookmarks);
}, [activeEntry.bookmarks]);
const currentStatus = activeEntry.status;
const isLocalLoading = bookmarkSupport && currentStatus === 'loading';
const currentError = bookmarkSupport && currentStatus === 'error' ? activeEntry.error : null;
const toggleNode = (nodeId: string) => {
setExpanded(prev => ({
...prev,
[nodeId]: !(prev[nodeId] ?? true),
}));
};
const expandAll = useCallback(() => {
const allExpanded: Record<string, boolean> = {};
const expandRecursive = (nodes: BookmarkNode[]) => {
nodes.forEach(node => {
if (node.children && node.children.length > 0) {
allExpanded[node.id] = true;
expandRecursive(node.children as BookmarkNode[]);
}
});
};
expandRecursive(bookmarksWithIds);
setExpanded(allExpanded);
}, [bookmarksWithIds]);
const collapseAll = useCallback(() => {
const allCollapsed: Record<string, boolean> = {};
const collapseRecursive = (nodes: BookmarkNode[]) => {
nodes.forEach(node => {
if (node.children && node.children.length > 0) {
allCollapsed[node.id] = false;
collapseRecursive(node.children as BookmarkNode[]);
}
});
};
collapseRecursive(bookmarksWithIds);
setExpanded(allCollapsed);
}, [bookmarksWithIds]);
const handleBookmarkClick = (bookmark: PdfBookmarkObject, event: React.MouseEvent) => {
const target = bookmark.target;
if (target?.type === 'action') {
const action = target.action;
if (action.type === PdfActionType.URI && action.uri) {
event.preventDefault();
window.open(action.uri, '_blank', 'noopener');
return;
}
if (action.type === PdfActionType.LaunchAppOrOpenFile && action.path) {
event.preventDefault();
window.open(action.path, '_blank', 'noopener');
return;
}
}
const pageNumber = resolvePageNumber(bookmark);
if (pageNumber) {
scrollActions.scrollToPage(pageNumber);
}
};
const filteredBookmarks = useMemo(() => {
if (!searchTerm.trim()) return bookmarksWithIds;
const term = searchTerm.trim().toLowerCase();
const applyFilter = (nodeList: BookmarkNode[]): BookmarkNode[] => {
const results: BookmarkNode[] = [];
for (const node of nodeList) {
const childMatches = node.children ? applyFilter(node.children as BookmarkNode[]) : [];
const matchesSelf = node.title?.toLowerCase().includes(term) ?? false;
if (matchesSelf || childMatches.length > 0) {
results.push({ ...node, children: childMatches.length > 0 ? childMatches : node.children });
}
}
return results;
};
return applyFilter(bookmarksWithIds);
}, [bookmarksWithIds, searchTerm]);
const renderBookmarks = (nodes: BookmarkNode[], depth = 0) => {
if (!nodes || !Array.isArray(nodes)) {
return null;
}
return nodes.map((node, _index) => {
if (!node || !node.id) {
return null;
}
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
const isNodeExpanded = expanded[node.id] ?? true;
const pageNumber = resolvePageNumber(node);
return (
<div
key={node.id}
className="bookmark-item-wrapper"
style={{
marginLeft: depth > 0 ? `${depth * 0.75}rem` : '0',
}}
>
<div
className={`bookmark-item ${pageNumber ? 'bookmark-item--clickable' : ''}`}
onClick={(event) => handleBookmarkClick(node, event)}
role={pageNumber ? "button" : undefined}
tabIndex={pageNumber ? 0 : undefined}
onKeyDown={pageNumber ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleBookmarkClick(node, event as any);
}
} : undefined}
>
{hasChildren ? (
<ActionIcon
variant="subtle"
size="sm"
className="bookmark-item__expand-icon"
onClick={(event) => {
event.stopPropagation();
toggleNode(node.id);
}}
>
<LocalIcon
icon={isNodeExpanded ? 'keyboard-arrow-up' : 'keyboard-arrow-down'}
width="1rem"
height="1rem"
/>
</ActionIcon>
) : (
<span className="bookmark-item__dash">-</span>
)}
<div className="bookmark-item__content">
<Text
size="sm"
fw={500}
className="bookmark-item__title"
>
{node.title || 'Untitled'}
</Text>
{pageNumber && (
<Text size="xs" c="dimmed" className="bookmark-item__page">
Page {pageNumber}
</Text>
)}
</div>
</div>
{hasChildren && isNodeExpanded && (
<div className="bookmark-item__children">
{renderBookmarks(node.children as BookmarkNode[], depth + 1)}
</div>
)}
</div>
);
});
};
const isSearchActive = searchTerm.trim().length > 0;
const hasBookmarks = bookmarksWithIds.length > 0;
const showBookmarkList = bookmarkSupport && documentCacheKey && filteredBookmarks.length > 0;
const showEmptyState =
bookmarkSupport &&
documentCacheKey &&
!isLocalLoading &&
!currentError &&
currentStatus === 'success' &&
!hasBookmarks;
const showSearchEmpty =
bookmarkSupport &&
documentCacheKey &&
isSearchActive &&
hasBookmarks &&
filteredBookmarks.length === 0;
const showNoDocument = bookmarkSupport && !documentCacheKey;
if (!visible) {
return null;
}
return (
<Box
className="bookmark-sidebar"
style={{
position: 'fixed',
right: thumbnailVisible ? SIDEBAR_WIDTH : 0,
top: 0,
bottom: 0,
width: SIDEBAR_WIDTH,
zIndex: 998,
}}
>
<div className="bookmark-sidebar__header">
<div className="bookmark-sidebar__header-title">
<span className="bookmark-sidebar__header-icon">
<BookmarksIcon />
</span>
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
Bookmarks
</Text>
</div>
{bookmarkSupport && bookmarksWithIds.length > 0 && (
<>
{Object.values(expanded).some(val => val === false) ? (
<ActionIcon
variant="subtle"
size="sm"
onClick={expandAll}
aria-label="Expand all bookmarks"
title="Expand all"
>
<LocalIcon icon="unfold-more" width="1.1rem" height="1.1rem" />
</ActionIcon>
) : (
<ActionIcon
variant="subtle"
size="sm"
onClick={collapseAll}
aria-label="Collapse all bookmarks"
title="Collapse all"
>
<LocalIcon icon="unfold-less" width="1.1rem" height="1.1rem" />
</ActionIcon>
)}
</>
)}
</div>
<Box px="sm" pb="sm" className="bookmark-sidebar__search">
<TextInput
value={searchTerm}
placeholder="Search bookmarks"
onChange={(event) => setSearchTerm(event.currentTarget.value)}
leftSection={<LocalIcon icon="search" width="1.1rem" height="1.1rem" />}
size="xs"
/>
</Box>
<ScrollArea style={{ flex: 1 }}>
<Box p="sm" className="bookmark-sidebar__content">
{!bookmarkSupport && (
<div className="bookmark-sidebar__empty-state">
<Text size="sm" c="dimmed" ta="center">
Bookmark support is unavailable for this viewer.
</Text>
</div>
)}
{bookmarkSupport && showNoDocument && (
<div className="bookmark-sidebar__empty-state">
<Text size="sm" c="dimmed" ta="center">
Open a PDF to view its bookmarks.
</Text>
</div>
)}
{bookmarkSupport && documentCacheKey && currentError && (
<Stack gap="xs" align="center" className="bookmark-sidebar__error">
<Text size="sm" c="red" ta="center">
{currentError}
</Text>
<Button size="xs" variant="light" onClick={requestReload}>
Retry
</Button>
</Stack>
)}
{bookmarkSupport && documentCacheKey && isLocalLoading && (
<Stack gap="md" align="center" c="dimmed" py="xl" className="bookmark-sidebar__loading">
<Loader size="md" type="dots" />
<Text size="sm" ta="center">
Loading bookmarks...
</Text>
</Stack>
)}
{showEmptyState && (
<div className="bookmark-sidebar__empty-state">
<Text size="sm" c="dimmed" ta="center">
No bookmarks in this document
</Text>
</div>
)}
{showBookmarkList && (
<div className="bookmark-list">
{renderBookmarks(filteredBookmarks)}
</div>
)}
{showSearchEmpty && (
<div className="bookmark-sidebar__empty-state">
<Text size="sm" c="dimmed" ta="center">
No bookmarks match your search
</Text>
</div>
)}
</Box>
</ScrollArea>
</Box>
);
};
@@ -8,6 +8,7 @@ import { useViewer } from "@app/contexts/ViewerContext";
import { LocalEmbedPDF } from '@app/components/viewer/LocalEmbedPDF';
import { PdfViewerToolbar } from '@app/components/viewer/PdfViewerToolbar';
import { ThumbnailSidebar } from '@app/components/viewer/ThumbnailSidebar';
import { BookmarkSidebar } from '@app/components/viewer/BookmarkSidebar';
import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext';
import { useSignature } from '@app/contexts/SignatureContext';
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
@@ -38,7 +39,19 @@ const EmbedPdfViewerContent = ({
const pdfContainerRef = useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
const {
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
isBookmarkSidebarVisible,
zoomActions,
panActions: _panActions,
rotationActions: _rotationActions,
getScrollState,
getRotationState,
isAnnotationMode,
isAnnotationsVisible,
exportActions,
} = useViewer();
// Register viewer right-rail buttons
useViewerRightRailButtons();
@@ -129,6 +142,42 @@ const EmbedPdfViewerContent = ({
}
}, [previewFile, fileWithUrl]);
const bookmarkCacheKey = React.useMemo(() => {
if (currentFile && isStirlingFile(currentFile)) {
return currentFile.fileId;
}
if (previewFile) {
const uniquePreviewId = `${previewFile.name}-${previewFile.size}-${previewFile.lastModified ?? 'na'}`;
return `preview-${uniquePreviewId}`;
}
if (effectiveFile?.url) {
return effectiveFile.url;
}
if (effectiveFile?.file instanceof File) {
const fileObj = effectiveFile.file;
return `file-${fileObj.name}-${fileObj.size}-${fileObj.lastModified ?? 'na'}`;
}
return undefined;
}, [currentFile, effectiveFile, previewFile]);
// Generate cache keys for all active files to enable preloading
const allBookmarkCacheKeys = React.useMemo(() => {
if (previewFile) {
return [bookmarkCacheKey].filter(Boolean) as string[];
}
return activeFiles.map(file => {
if (isStirlingFile(file)) {
return file.fileId;
}
return undefined;
}).filter(Boolean) as string[];
}, [activeFiles, previewFile, bookmarkCacheKey]);
useWheelZoom({
ref: viewerRef,
onZoomIn: zoomActions.zoomIn,
@@ -220,6 +269,10 @@ const EmbedPdfViewerContent = ({
}
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
const sidebarWidthRem = 15;
const totalRightMargin =
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
return (
<Box
ref={viewerRef}
@@ -261,7 +314,7 @@ const EmbedPdfViewerContent = ({
overflow: 'hidden',
minHeight: 0,
minWidth: 0,
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
marginRight: `${totalRightMargin}rem`,
transition: 'margin-right 0.3s ease'
}}>
<LocalEmbedPDF
@@ -316,6 +369,12 @@ const EmbedPdfViewerContent = ({
onToggle={toggleThumbnailSidebar}
activeFileIndex={activeFileIndex}
/>
<BookmarkSidebar
visible={isBookmarkSidebarVisible}
thumbnailVisible={isThumbnailSidebarVisible}
documentCacheKey={bookmarkCacheKey}
preloadCacheKeys={allBookmarkCacheKeys}
/>
{/* Navigation Warning Modal */}
{!previewFile && (
@@ -19,6 +19,7 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark';
// Import annotation plugins
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
@@ -39,6 +40,7 @@ import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge';
import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
interface LocalEmbedPDFProps {
file?: File | Blob;
@@ -138,6 +140,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
// Register thumbnail plugin for page thumbnails
createPluginRegistration(ThumbnailPluginPackage),
// Register bookmark plugin for PDF outline support
createPluginRegistration(BookmarkPluginPackage),
// Register rotate plugin
createPluginRegistration(RotatePluginPackage),
@@ -272,6 +277,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
<ExportAPIBridge />
<BookmarkAPIBridge />
<GlobalPointerProvider>
<Viewport
style={{
@@ -19,6 +19,7 @@ export function useViewerRightRailButtons() {
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
return [
@@ -111,6 +112,17 @@ export function useViewerRightRailButtons() {
viewer.toggleThumbnailSidebar();
}
},
{
id: 'viewer-toggle-bookmarks',
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
tooltip: bookmarkLabel,
ariaLabel: bookmarkLabel,
section: 'top' as const,
order: 55,
onClick: () => {
viewer.toggleBookmarkSidebar();
}
},
{
id: 'viewer-annotation-controls',
section: 'top' as const,
@@ -120,7 +132,7 @@ export function useViewerRightRailButtons() {
)
}
];
}, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]);
}, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel]);
useRightRailButtons(viewerButtons);
}
@@ -4,7 +4,7 @@
*/
import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react';
import { useToolManagement } from '@app/hooks/useToolManagement';
import { useToolManagement, type ToolAvailabilityMap } from '@app/hooks/useToolManagement';
import { PageEditorFunctions } from '@app/types/pageEditor';
import { ToolRegistryEntry, ToolRegistry } from '@app/data/toolsTaxonomy';
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
@@ -44,6 +44,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
selectedTool: ToolRegistryEntry | null;
toolRegistry: Partial<ToolRegistry>;
getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null;
toolAvailability: ToolAvailabilityMap;
// UI Actions
setSidebarsVisible: (visible: boolean) => void;
@@ -112,7 +113,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const navigationState = useNavigationState();
// Tool management hook
const { toolRegistry, getSelectedTool } = useToolManagement();
const { toolRegistry, getSelectedTool, toolAvailability } = useToolManagement();
const { allTools } = useToolRegistry();
// Tool history hook
@@ -258,6 +259,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Workflow actions (compound actions that coordinate multiple state changes)
const handleToolSelect = useCallback((toolId: ToolId) => {
const availabilityInfo = toolAvailability[toolId];
const isExplicitlyDisabled = availabilityInfo ? availabilityInfo.available === false : false;
if (toolId !== 'read' && toolId !== 'multiTool' && isExplicitlyDisabled) {
return;
}
// If we're currently on a custom workbench (e.g., Validate Signature report),
// selecting any tool should take the user back to the default file manager view.
const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench);
@@ -299,7 +305,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSearchQuery('');
setLeftPanelView('toolContent');
setReaderMode(false); // Disable read mode when selecting tools
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]);
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery, toolAvailability]);
const handleBackToTools = useCallback(() => {
setLeftPanelView('toolPicker');
@@ -354,6 +360,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
toolResetFunctions,
registerToolReset,
resetTool,
toolAvailability,
// Workflow Actions
handleToolSelect,
@@ -381,6 +388,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
selectedTool,
toolRegistry,
getSelectedTool,
toolAvailability,
setSidebarsVisible,
setLeftPanelView,
setReaderMode,
@@ -17,6 +17,7 @@ import {
RotationActions,
SearchActions,
ExportActions,
BookmarkActions,
} from '@app/contexts/viewer/viewerActions';
import {
BridgeRef,
@@ -35,6 +36,7 @@ import {
SearchState,
ExportState,
ThumbnailAPIWrapper,
BookmarkState,
} from '@app/contexts/viewer/viewerBridges';
import { SpreadMode } from '@embedpdf/plugin-spread/react';
@@ -74,6 +76,8 @@ interface ViewerContextType {
// UI state managed by this context
isThumbnailSidebarVisible: boolean;
toggleThumbnailSidebar: () => void;
isBookmarkSidebarVisible: boolean;
toggleBookmarkSidebar: () => void;
// Annotation visibility toggle
isAnnotationsVisible: boolean;
@@ -98,6 +102,8 @@ interface ViewerContextType {
getSearchState: () => SearchState;
getThumbnailAPI: () => ThumbnailAPIWrapper | null;
getExportState: () => ExportState;
getBookmarkState: () => BookmarkState;
hasBookmarkSupport: () => boolean;
// Immediate update callbacks
registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void;
@@ -118,6 +124,7 @@ interface ViewerContextType {
rotationActions: RotationActions;
searchActions: SearchActions;
exportActions: ExportActions;
bookmarkActions: BookmarkActions;
// Bridge registration - internal use by bridges
registerBridge: <K extends BridgeKey>(
@@ -135,6 +142,7 @@ interface ViewerProviderProps {
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
const [activeFileIndex, setActiveFileIndex] = useState(0);
@@ -193,6 +201,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setIsThumbnailSidebarVisible(prev => !prev);
};
const toggleBookmarkSidebar = () => {
setIsBookmarkSidebarVisible(prev => !prev);
};
const toggleAnnotationsVisibility = () => {
setIsAnnotationsVisible(prev => !prev);
};
@@ -242,6 +254,18 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
return bridgeRefs.current.export?.state || { canExport: false };
};
const getBookmarkState = (): BookmarkState => {
return (
bridgeRefs.current.bookmark?.state || {
bookmarks: null,
isLoading: false,
error: null,
}
);
};
const hasBookmarkSupport = () => Boolean(bridgeRefs.current.bookmark);
// Action handlers - call APIs directly
const {
scrollActions,
@@ -252,6 +276,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
rotationActions,
searchActions,
exportActions,
bookmarkActions,
} = createViewerActions({
registry: bridgeRefs,
getScrollState,
@@ -263,6 +288,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
isBookmarkSidebarVisible,
toggleBookmarkSidebar,
// Annotation controls
isAnnotationsVisible,
@@ -285,6 +312,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
getSearchState,
getThumbnailAPI,
getExportState,
getBookmarkState,
hasBookmarkSupport,
// Immediate updates
registerImmediateZoomUpdate,
@@ -303,6 +332,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
rotationActions,
searchActions,
exportActions,
bookmarkActions,
// Bridge registration
registerBridge,
@@ -5,6 +5,7 @@ import {
ScrollState,
ZoomState,
} from '@app/contexts/viewer/viewerBridges';
import { PdfBookmarkObject } from '@embedpdf/models';
export interface ScrollActions {
scrollToPage: (page: number) => void;
@@ -58,6 +59,12 @@ export interface ExportActions {
saveAsCopy: () => Promise<ArrayBuffer | null>;
}
export interface BookmarkActions {
fetchBookmarks: () => Promise<PdfBookmarkObject[] | null>;
clearBookmarks: () => void;
setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void;
}
export interface ViewerActionsBundle {
scrollActions: ScrollActions;
zoomActions: ZoomActions;
@@ -67,6 +74,7 @@ export interface ViewerActionsBundle {
rotationActions: RotationActions;
searchActions: SearchActions;
exportActions: ExportActions;
bookmarkActions: BookmarkActions;
}
interface ViewerActionDependencies {
@@ -307,5 +315,22 @@ export function createViewerActions({
rotationActions,
searchActions,
exportActions,
bookmarkActions: {
fetchBookmarks: async () => {
const api = registry.current.bookmark?.api;
if (!api?.fetchBookmarks) {
return null;
}
return api.fetchBookmarks();
},
clearBookmarks: () => {
const api = registry.current.bookmark?.api;
api?.clearBookmarks?.();
},
setLocalBookmarks: (bookmarks, error = null) => {
const api = registry.current.bookmark?.api;
api?.setLocalBookmarks?.(bookmarks ?? null, error);
},
},
};
}
@@ -1,4 +1,5 @@
import { SpreadMode } from '@embedpdf/plugin-spread/react';
import { PdfBookmarkObject } from '@embedpdf/models';
export interface ScrollAPIWrapper {
scrollToPage: (params: { pageNumber: number }) => void;
@@ -59,6 +60,12 @@ export interface ExportAPIWrapper {
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
}
export interface BookmarkAPIWrapper {
fetchBookmarks: () => Promise<PdfBookmarkObject[]>;
clearBookmarks: () => void;
setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void;
}
export interface ScrollState {
currentPage: number;
totalPages: number;
@@ -103,6 +110,12 @@ export interface ExportState {
canExport: boolean;
}
export interface BookmarkState {
bookmarks: PdfBookmarkObject[] | null;
isLoading: boolean;
error: string | null;
}
export interface BridgeRef<TState = unknown, TApi = unknown> {
state: TState;
api: TApi;
@@ -118,6 +131,7 @@ export interface BridgeStateMap {
search: SearchState;
thumbnail: unknown;
export: ExportState;
bookmark: BookmarkState;
}
export interface BridgeApiMap {
@@ -130,6 +144,7 @@ export interface BridgeApiMap {
search: SearchAPIWrapper;
thumbnail: ThumbnailAPIWrapper;
export: ExportAPIWrapper;
bookmark: BookmarkAPIWrapper;
}
export type BridgeKey = keyof BridgeStateMap;
@@ -148,6 +163,7 @@ export const createBridgeRegistry = (): ViewerBridgeRegistry => ({
search: null,
thumbnail: null,
export: null,
bookmark: null,
});
export function registerBridge<K extends BridgeKey>(
@@ -83,6 +83,7 @@ import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale
import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation";
import { extractPagesOperationConfig } from "@app/hooks/tools/extractPages/useExtractPagesOperation";
import { ENDPOINTS as SPLIT_ENDPOINT_NAMES } from '@app/constants/splitConstants';
import CompressSettings from "@app/components/tools/compress/CompressSettings";
import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings";
import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings";
@@ -313,6 +314,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION,
endpoints: ["get-info-on-pdf"],
synonyms: getSynonyms(t, "getPdfInfo"),
supportsAutomate: false,
automationSettings: null
@@ -411,6 +413,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.split.desc", "Split PDFs into multiple documents"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINT_NAMES))),
operationConfig: splitOperationConfig,
automationSettings: SplitAutomationSettings,
synonyms: getSynonyms(t, "split")
@@ -478,6 +481,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
endpoints: ["booklet-imposition"],
},
pdfToSinglePage: {
@@ -572,6 +576,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1,
endpoints: ["remove-annotations"],
operationConfig: removeAnnotationsOperationConfig,
automationSettings: null,
synonyms: getSynonyms(t, "removeAnnotations")
@@ -610,7 +615,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1,
endpoints: ["remove-certificate-sign"],
endpoints: ["remove-cert-sign"],
operationConfig: removeCertificateSignOperationConfig,
synonyms: getSynonyms(t, "removeCertSign"),
automationSettings: null,
@@ -639,7 +644,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
name: t("home.autoRename.title", "Auto Rename PDF File"),
component: AutoRename,
maxFiles: -1,
endpoints: ["remove-certificate-sign"],
endpoints: ["auto-rename"],
operationConfig: autoRenameOperationConfig,
description: t("home.autoRename.desc", "Automatically rename PDF files based on their content"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
@@ -694,6 +699,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
endpoints: ["overlay-pdf"],
operationConfig: overlayPdfsOperationConfig,
synonyms: getSynonyms(t, "overlay-pdfs"),
automationSettings: OverlayPdfsSettings
@@ -718,6 +724,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.addImage.desc", "Add images to PDF documents"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
endpoints: ["add-image"],
synonyms: getSynonyms(t, "addImage"),
automationSettings: null
},
@@ -728,6 +735,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.scannerEffect.desc", "Create a PDF that looks like it was scanned"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
endpoints: ["scanner-effect"],
synonyms: getSynonyms(t, "scannerEffect"),
automationSettings: null
},
@@ -818,6 +826,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["compress-pdf"],
operationConfig: compressOperationConfig,
automationSettings: CompressSettings,
synonyms: getSynonyms(t, "compress")
@@ -861,6 +870,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["ocr-pdf"],
operationConfig: ocrOperationConfig,
automationSettings: OCRSettings,
synonyms: getSynonyms(t, "ocr")
@@ -14,6 +14,6 @@ export type RemoveCertificateSignParametersHook = BaseParametersHook<RemoveCerti
export const useRemoveCertificateSignParameters = (): RemoveCertificateSignParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'remove-certificate-sign',
endpointName: 'remove-cert-sign',
});
};
+77 -44
View File
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react';
import apiClient from '@app/services/apiClient';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
// Track globally fetched endpoint sets to prevent duplicate fetches across components
const globalFetchedSets = new Set<string>();
const globalEndpointCache: Record<string, boolean> = {};
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
/**
* Hook to check if a specific endpoint is enabled
@@ -59,11 +60,13 @@ export function useEndpointEnabled(endpoint: string): {
*/
export function useMultipleEndpointsEnabled(endpoints: string[]): {
endpointStatus: Record<string, boolean>;
endpointDetails: Record<string, EndpointAvailabilityDetails>;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
} {
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -73,31 +76,25 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
// Skip if we already fetched these exact endpoints globally
if (!force && globalFetchedSets.has(endpointsKey)) {
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
const cachedStatus = endpoints.reduce((acc, endpoint) => {
if (endpoint in globalEndpointCache) {
acc[endpoint] = globalEndpointCache[endpoint];
}
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(cachedStatus);
const cached = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
if (cachedDetails) {
acc.status[endpoint] = cachedDetails.enabled;
acc.details[endpoint] = cachedDetails;
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(cached.status);
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
setLoading(false);
return;
}
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setLoading(false);
return;
}
// Check if JWT exists - if not, optimistically enable all endpoints
const hasJwt = !!localStorage.getItem('stirling_jwt');
if (!hasJwt) {
console.debug('[useEndpointConfig] No JWT found - optimistically enabling all endpoints');
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = true;
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(optimisticStatus);
setEndpointDetails({});
setLoading(false);
return;
}
@@ -110,11 +107,19 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
if (newEndpoints.length === 0) {
console.debug('[useEndpointConfig] All endpoints already in global cache');
const cachedStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = globalEndpointCache[endpoint];
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(cachedStatus);
const cached = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
if (cachedDetails) {
acc.status[endpoint] = cachedDetails.enabled;
acc.details[endpoint] = cachedDetails;
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(cached.status);
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
globalFetchedSets.add(endpointsKey);
setLoading(false);
return;
@@ -123,30 +128,51 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
// Use batch API for efficiency - only fetch new endpoints
const endpointsParam = newEndpoints.join(',');
const response = await apiClient.get<Record<string, boolean>>(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`);
const statusMap = response.data;
// Update global cache with new results
Object.assign(globalEndpointCache, statusMap);
Object.entries(statusMap).forEach(([endpoint, details]) => {
globalEndpointCache[endpoint] = {
enabled: details?.enabled ?? true,
reason: details?.reason ?? null,
};
});
// Get all requested endpoints from cache (including previously cached ones)
const fullStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache
return acc;
}, {} as Record<string, boolean>);
const fullStatus = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
if (cachedDetails) {
acc.status[endpoint] = cachedDetails.enabled;
acc.details[endpoint] = cachedDetails;
} else {
acc.status[endpoint] = true;
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(fullStatus);
setEndpointStatus(fullStatus.status);
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
globalFetchedSets.add(endpointsKey);
} catch (err: any) {
// On 401 (auth error), use optimistic fallback instead of disabling
if (err.response?.status === 401) {
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = true;
globalEndpointCache[endpoint] = true; // Cache the optimistic value
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(optimisticStatus);
const optimisticStatus = endpoints.reduce(
(acc, endpoint) => {
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
acc.status[endpoint] = true;
acc.details[endpoint] = optimisticDetails;
globalEndpointCache[endpoint] = optimisticDetails;
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(optimisticStatus.status);
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
setLoading(false);
return;
}
@@ -156,11 +182,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
// Fallback: assume all endpoints are enabled on error (optimistic)
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = true;
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(optimisticStatus);
const optimisticStatus = endpoints.reduce(
(acc, endpoint) => {
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
acc.status[endpoint] = true;
acc.details[endpoint] = optimisticDetails;
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(optimisticStatus.status);
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
} finally {
setLoading(false);
}
@@ -186,6 +218,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
return {
endpointStatus,
endpointDetails,
loading,
error,
refetch: () => fetchAllEndpointStatuses(true),
+67 -11
View File
@@ -1,9 +1,20 @@
import { useState, useCallback, useMemo } from 'react';
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { usePreferences } from '@app/contexts/PreferencesContext';
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
import { FileId } from '@app/types/file';
import { ToolId } from "@app/types/toolId";
import type { EndpointDisableReason } from '@app/types/endpointAvailability';
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown';
export interface ToolAvailabilityInfo {
available: boolean;
reason?: ToolDisableCause;
}
export type ToolAvailabilityMap = Partial<Record<ToolId, ToolAvailabilityInfo>>;
interface ToolManagementResult {
selectedTool: ToolRegistryEntry | null;
@@ -11,6 +22,7 @@ interface ToolManagementResult {
toolRegistry: Partial<ToolRegistry>;
setToolSelectedFileIds: (fileIds: FileId[]) => void;
getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null;
toolAvailability: ToolAvailabilityMap;
}
export const useToolManagement = (): ToolManagementResult => {
@@ -19,9 +31,10 @@ export const useToolManagement = (): ToolManagementResult => {
// Build endpoints list from registry entries with fallback to legacy mapping
const { allTools } = useToolRegistry();
const baseRegistry = allTools;
const { preferences } = usePreferences();
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const isToolAvailable = useCallback((toolKey: string): boolean => {
// Keep tools enabled during loading (optimistic UX)
@@ -38,22 +51,64 @@ export const useToolManagement = (): ToolManagementResult => {
return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
}, [endpointsLoading, endpointStatus, baseRegistry]);
const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => {
const tool = baseRegistry[toolKey];
if (!tool) {
return 'unknown';
}
const endpoints = tool.endpoints || [];
const disabledReasons: EndpointDisableReason[] = endpoints
.filter(endpoint => endpointStatus[endpoint] === false)
.map(endpoint => endpointDetails[endpoint]?.reason ?? 'CONFIG');
if (disabledReasons.some(reason => reason === 'DEPENDENCY')) {
return 'missingDependency';
}
if (disabledReasons.some(reason => reason === 'CONFIG')) {
return 'disabledByAdmin';
}
if (disabledReasons.length > 0) {
return 'unknown';
}
return 'unknown';
}, [baseRegistry, endpointDetails, endpointStatus]);
const toolAvailability = useMemo(() => {
if (endpointsLoading) {
return {};
}
const availability: ToolAvailabilityMap = {};
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
const available = isToolAvailable(toolKey);
availability[toolKey] = available
? { available: true }
: { available: false, reason: deriveToolDisableReason(toolKey) };
});
return availability;
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]);
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
const availableToolRegistry: Partial<ToolRegistry> = {};
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
if (isToolAvailable(toolKey)) {
const baseTool = baseRegistry[toolKey];
if (baseTool) {
availableToolRegistry[toolKey] = {
...baseTool,
name: baseTool.name,
description: baseTool.description,
};
}
const baseTool = baseRegistry[toolKey];
if (!baseTool) return;
const availabilityInfo = toolAvailability[toolKey];
const isAvailable = availabilityInfo ? availabilityInfo.available !== false : true;
// Check if tool is "coming soon" (has no component and no link)
const isComingSoon = !baseTool.component && !baseTool.link && toolKey !== 'read' && toolKey !== 'multiTool';
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
return;
}
availableToolRegistry[toolKey] = {
...baseTool,
name: baseTool.name,
description: baseTool.description,
};
});
return availableToolRegistry;
}, [isToolAvailable, baseRegistry]);
}, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]);
const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => {
return toolKey ? toolRegistry[toolKey] || null : null;
@@ -65,5 +120,6 @@ export const useToolManagement = (): ToolManagementResult => {
toolRegistry,
setToolSelectedFileIds,
getSelectedTool,
toolAvailability,
};
};
@@ -9,6 +9,8 @@ export interface UserPreferences {
toolPanelModePromptSeen: boolean;
showLegacyToolDescriptions: boolean;
hasCompletedOnboarding: boolean;
hideUnavailableTools: boolean;
hideUnavailableConversions: boolean;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
@@ -19,6 +21,8 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
toolPanelModePromptSeen: false,
showLegacyToolDescriptions: false,
hasCompletedOnboarding: false,
hideUnavailableTools: false,
hideUnavailableConversions: false,
};
const STORAGE_KEY = 'stirlingpdf_preferences';
+2 -2
View File
@@ -1,6 +1,6 @@
export interface UpdateSummary {
latest_version: string;
latest_stable_version?: string;
latest_version: string | null;
latest_stable_version?: string | null;
max_priority: 'urgent' | 'normal' | 'minor' | 'low';
recommended_action?: string;
any_breaking: boolean;
@@ -0,0 +1,6 @@
export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | null;
export interface EndpointAvailabilityDetails {
enabled: boolean;
reason?: EndpointDisableReason;
}
@@ -4,8 +4,10 @@ import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { isBackendNotReadyError } from '@app/constants/backendErrors';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
import { connectionModeService } from '@desktop/services/connectionModeService';
interface EndpointConfig {
backendUrl: string;
}
@@ -128,6 +130,7 @@ export function useEndpointEnabled(endpoint: string): {
export function useMultipleEndpointsEnabled(endpoints: string[]): {
endpointStatus: Record<string, boolean>;
endpointDetails: Record<string, EndpointAvailabilityDetails>;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
@@ -140,6 +143,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
return acc;
}, {} as Record<string, boolean>);
});
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
@@ -174,13 +178,27 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const endpointsParam = endpoints.join(',');
const response = await apiClient.get<Record<string, boolean>>('/api/v1/config/endpoints-enabled', {
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>('/api/v1/config/endpoints-availability', {
params: { endpoints: endpointsParam },
suppressErrorToast: true,
});
if (!isMountedRef.current) return;
setEndpointStatus(response.data);
const details = Object.entries(response.data).reduce((acc, [endpointName, detail]) => {
acc[endpointName] = {
enabled: detail?.enabled ?? true,
reason: detail?.reason ?? null,
};
return acc;
}, {} as Record<string, EndpointAvailabilityDetails>);
const statusMap = Object.keys(details).reduce((acc, key) => {
acc[key] = details[key].enabled;
return acc;
}, {} as Record<string, boolean>);
setEndpointDetails(prev => ({ ...prev, ...details }));
setEndpointStatus(statusMap);
} catch (err: unknown) {
const isBackendStarting = isBackendNotReadyError(err);
const message = getErrorMessage(err);
@@ -188,10 +206,13 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message);
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
acc[endpointName] = true;
const fallbackDetail: EndpointAvailabilityDetails = { enabled: true, reason: null };
acc.status[endpointName] = true;
acc.details[endpointName] = fallbackDetail;
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(fallbackStatus);
}, { status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> });
setEndpointStatus(fallbackStatus.status);
setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details }));
if (!retryTimeoutRef.current) {
retryTimeoutRef.current = setTimeout(() => {
@@ -209,6 +230,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
useEffect(() => {
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setEndpointDetails({});
setLoading(false);
return;
}
@@ -230,6 +252,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
return {
endpointStatus,
endpointDetails,
loading,
error,
refetch: fetchAllEndpointStatuses,