mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ef63566f1 | ||
|
|
00efc8802c | ||
|
|
9973c6b1d3 | ||
|
|
5d828040a7 | ||
|
|
d6a3774e9b | ||
|
|
ce2065aec4 | ||
|
|
e7109bb4e9 |
@@ -1,345 +0,0 @@
|
||||
"""
|
||||
Author: Ludy87
|
||||
Description: This script processes JSON translation files for localization checks. It compares translation files in a branch with
|
||||
a reference file to ensure consistency. The script performs two main checks:
|
||||
1. Verifies that the number of translation keys in the translation files matches the reference file.
|
||||
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
|
||||
|
||||
The script also provides functionality to update the translation files to match the reference file by adding missing keys and
|
||||
adjusting the format.
|
||||
|
||||
Usage:
|
||||
python check_language_json.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
|
||||
"""
|
||||
# Sample for Windows:
|
||||
# python .github/scripts/check_language_json.py --reference-file frontend/public/locales/en-GB/translation.json --branch "" --files frontend/public/locales/de-DE/translation.json frontend/public/locales/fr-FR/translation.json
|
||||
|
||||
import copy
|
||||
import glob
|
||||
import os
|
||||
import argparse
|
||||
import re
|
||||
import json
|
||||
|
||||
|
||||
def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
"""
|
||||
Identifies duplicate keys in a JSON file (including nested keys).
|
||||
:param file_path: Path to the JSON file.
|
||||
:param keys: Dictionary to track keys (used for recursion).
|
||||
:param prefix: Prefix for nested keys.
|
||||
:return: List of tuples (key, first_occurrence_path, duplicate_path).
|
||||
"""
|
||||
if keys is None:
|
||||
keys = {}
|
||||
|
||||
duplicates = []
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
|
||||
def process_dict(obj, current_prefix=""):
|
||||
for key, value in obj.items():
|
||||
full_key = f"{current_prefix}.{key}" if current_prefix else key
|
||||
|
||||
if isinstance(value, dict):
|
||||
process_dict(value, full_key)
|
||||
else:
|
||||
if full_key in keys:
|
||||
duplicates.append((full_key, keys[full_key], full_key))
|
||||
else:
|
||||
keys[full_key] = full_key
|
||||
|
||||
process_dict(data, prefix)
|
||||
return duplicates
|
||||
|
||||
|
||||
# Maximum size for JSON files (e.g., 500 KB)
|
||||
MAX_FILE_SIZE = 500 * 1024
|
||||
|
||||
|
||||
def parse_json_file(file_path):
|
||||
"""
|
||||
Parses a JSON translation file and returns a flat dictionary of all keys.
|
||||
:param file_path: Path to the JSON file.
|
||||
:return: Dictionary with flattened keys.
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="."):
|
||||
items = {}
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
||||
if isinstance(v, dict):
|
||||
items.update(flatten_dict(v, new_key, sep=sep))
|
||||
else:
|
||||
items[new_key] = v
|
||||
return items
|
||||
|
||||
return flatten_dict(data)
|
||||
|
||||
|
||||
def unflatten_dict(d, sep="."):
|
||||
"""
|
||||
Converts a flat dictionary with dot notation keys back to nested dict.
|
||||
:param d: Flattened dictionary.
|
||||
:param sep: Separator used in keys.
|
||||
:return: Nested dictionary.
|
||||
"""
|
||||
result = {}
|
||||
for key, value in d.items():
|
||||
parts = key.split(sep)
|
||||
current = result
|
||||
for part in parts[:-1]:
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[parts[-1]] = value
|
||||
return result
|
||||
|
||||
|
||||
def write_json_file(file_path, updated_properties):
|
||||
"""
|
||||
Writes updated properties back to the JSON file.
|
||||
:param file_path: Path to the JSON file.
|
||||
:param updated_properties: Dictionary of updated properties to write.
|
||||
"""
|
||||
nested_data = unflatten_dict(updated_properties)
|
||||
|
||||
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
|
||||
json.dump(nested_data, file, ensure_ascii=False, indent=2)
|
||||
file.write("\n") # Add trailing newline
|
||||
|
||||
|
||||
def update_missing_keys(reference_file, file_list, branch=""):
|
||||
"""
|
||||
Updates missing keys in the translation files based on the reference file.
|
||||
:param reference_file: Path to the reference JSON file.
|
||||
:param file_list: List of translation files to update.
|
||||
:param branch: Branch where the files are located.
|
||||
"""
|
||||
reference_properties = parse_json_file(reference_file)
|
||||
|
||||
for file_path in file_list:
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_path))
|
||||
if (
|
||||
basename_current_file == os.path.basename(reference_file)
|
||||
or not file_path.endswith(".json")
|
||||
or not os.path.dirname(file_path).endswith("locales")
|
||||
):
|
||||
continue
|
||||
|
||||
current_properties = parse_json_file(os.path.join(branch, file_path))
|
||||
updated_properties = {}
|
||||
|
||||
for ref_key, ref_value in reference_properties.items():
|
||||
if ref_key in current_properties:
|
||||
# Keep the current translation
|
||||
updated_properties[ref_key] = current_properties[ref_key]
|
||||
else:
|
||||
# Add missing key with reference value
|
||||
updated_properties[ref_key] = ref_value
|
||||
|
||||
write_json_file(os.path.join(branch, file_path), updated_properties)
|
||||
|
||||
|
||||
def check_for_missing_keys(reference_file, file_list, branch):
|
||||
update_missing_keys(reference_file, file_list, branch)
|
||||
|
||||
|
||||
def read_json_keys(file_path):
|
||||
if os.path.isfile(file_path) and os.path.exists(file_path):
|
||||
return parse_json_file(file_path)
|
||||
return {}
|
||||
|
||||
|
||||
def check_for_differences(reference_file, file_list, branch, actor):
|
||||
reference_branch = branch
|
||||
basename_reference_file = os.path.basename(reference_file)
|
||||
|
||||
report = []
|
||||
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
|
||||
reference_keys = read_json_keys(reference_file)
|
||||
has_differences = False
|
||||
|
||||
only_reference_file = True
|
||||
|
||||
file_arr = file_list
|
||||
|
||||
if len(file_list) == 1:
|
||||
file_arr = file_list[0].split()
|
||||
|
||||
base_dir = os.path.abspath(
|
||||
os.path.join(os.getcwd(), "frontend", "public", "locales")
|
||||
)
|
||||
|
||||
for file_path in file_arr:
|
||||
file_normpath = os.path.normpath(file_path)
|
||||
absolute_path = os.path.abspath(file_normpath)
|
||||
|
||||
# Verify that file is within the expected directory
|
||||
if not absolute_path.startswith(base_dir):
|
||||
raise ValueError(f"Unsafe file found: {file_normpath}")
|
||||
|
||||
# Verify file size before processing
|
||||
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
|
||||
raise ValueError(
|
||||
f"The file {file_normpath} is too large and could pose a security risk."
|
||||
)
|
||||
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
|
||||
locale_dir = os.path.basename(os.path.dirname(file_normpath))
|
||||
|
||||
if (
|
||||
basename_current_file == basename_reference_file
|
||||
and locale_dir == "en-GB"
|
||||
):
|
||||
continue
|
||||
|
||||
if not file_normpath.endswith(".json") or basename_current_file != "translation.json":
|
||||
continue
|
||||
|
||||
only_reference_file = False
|
||||
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
|
||||
current_keys = read_json_keys(os.path.join(branch, file_path))
|
||||
reference_key_count = len(reference_keys)
|
||||
current_key_count = len(current_keys)
|
||||
|
||||
if reference_key_count != current_key_count:
|
||||
report.append("")
|
||||
report.append("1. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
has_differences = True
|
||||
if reference_key_count > current_key_count:
|
||||
report.append(
|
||||
f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing."
|
||||
)
|
||||
elif reference_key_count < current_key_count:
|
||||
report.append(
|
||||
f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed."
|
||||
)
|
||||
else:
|
||||
report.append("1. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
# Check for missing or extra keys
|
||||
current_keys_set = set(current_keys.keys())
|
||||
reference_keys_set = set(reference_keys.keys())
|
||||
missing_keys = current_keys_set.difference(reference_keys_set)
|
||||
extra_keys = reference_keys_set.difference(current_keys_set)
|
||||
missing_keys_list = list(missing_keys)
|
||||
extra_keys_list = list(extra_keys)
|
||||
|
||||
if missing_keys_list or extra_keys_list:
|
||||
has_differences = True
|
||||
missing_keys_str = "`, `".join(missing_keys_list)
|
||||
extra_keys_str = "`, `".join(extra_keys_list)
|
||||
report.append("2. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
if missing_keys_list:
|
||||
report.append(
|
||||
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
|
||||
)
|
||||
if extra_keys_list:
|
||||
report.append(
|
||||
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**."
|
||||
)
|
||||
else:
|
||||
report.append("2. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
if find_duplicate_keys(os.path.join(branch, file_normpath)):
|
||||
has_differences = True
|
||||
output = "\n".join(
|
||||
[
|
||||
f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
|
||||
for key, first, duplicate in find_duplicate_keys(
|
||||
os.path.join(branch, file_normpath)
|
||||
)
|
||||
]
|
||||
)
|
||||
report.append("3. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
report.append(" - duplicate entries were found:")
|
||||
report.append(output)
|
||||
else:
|
||||
report.append("3. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
report.append("")
|
||||
report.append("---")
|
||||
report.append("")
|
||||
|
||||
if has_differences:
|
||||
report.append("## ❌ Overall Check Status: **_Failed_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
|
||||
)
|
||||
else:
|
||||
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"Thanks @{actor} for your help in keeping the translations up to date."
|
||||
)
|
||||
|
||||
if not only_reference_file:
|
||||
print("\n".join(report))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Find missing keys")
|
||||
parser.add_argument(
|
||||
"--actor",
|
||||
required=False,
|
||||
help="Actor from PR.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reference-file",
|
||||
required=True,
|
||||
help="Path to the reference file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--branch",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Branch name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-file",
|
||||
type=str,
|
||||
required=False,
|
||||
help="List of changed files, separated by spaces.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--files",
|
||||
nargs="+",
|
||||
required=False,
|
||||
help="List of changed files, separated by spaces.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Sanitize --actor input to avoid injection attacks
|
||||
if args.actor:
|
||||
args.actor = re.sub(r"[^a-zA-Z0-9_\\-]", "", args.actor)
|
||||
|
||||
# Sanitize --branch input to avoid injection attacks
|
||||
if args.branch:
|
||||
args.branch = re.sub(r"[^a-zA-Z0-9\\-]", "", args.branch)
|
||||
|
||||
file_list = args.files
|
||||
if file_list is None:
|
||||
if args.check_file:
|
||||
file_list = [args.check_file]
|
||||
else:
|
||||
file_list = glob.glob(
|
||||
os.path.join(
|
||||
os.getcwd(),
|
||||
"frontend",
|
||||
"public",
|
||||
"locales",
|
||||
"*",
|
||||
"translation.json",
|
||||
)
|
||||
)
|
||||
update_missing_keys(args.reference_file, file_list)
|
||||
else:
|
||||
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
|
||||
@@ -12,7 +12,6 @@ on:
|
||||
branches:
|
||||
- V2
|
||||
paths:
|
||||
- ".github/workflows/frontend-licenses-update.yml"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
@@ -29,12 +28,12 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR head (default)
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -49,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Checkout BASE branch (safe script)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
@@ -57,9 +56,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
@@ -115,7 +114,7 @@ jobs:
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -168,7 +167,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
name: Sync Files V2
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- V2
|
||||
- syncLangTest
|
||||
paths:
|
||||
- "build.gradle"
|
||||
- "README.md"
|
||||
- "frontend/public/locales/*/translation.json"
|
||||
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
|
||||
- "scripts/ignore_translation.toml"
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-files:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
- name: Sync translation JSON files
|
||||
run: |
|
||||
python .github/scripts/check_language_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
git add frontend/public/locales/*/translation.json
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Sync README.md
|
||||
run: |
|
||||
python scripts/counter_translation_v2.py
|
||||
|
||||
- name: Run git add
|
||||
run: |
|
||||
git add README.md scripts/ignore_translation.toml
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
|
||||
|
||||
- name: Create Pull Request
|
||||
if: always()
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: Update files
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: sync_readme_v2
|
||||
base: V2
|
||||
title: ":globe_with_meridians: [V2] Sync Translations + Update README Progress Table"
|
||||
body: |
|
||||
### Description of Changes
|
||||
|
||||
This Pull Request was automatically generated to synchronize updates to translation files and documentation for the **V2 branch**. Below are the details of the changes made:
|
||||
|
||||
#### **1. Synchronization of Translation Files**
|
||||
- Updated translation files (`frontend/public/locales/*/translation.json`) to reflect changes in the reference file `en-GB/translation.json`.
|
||||
- Ensured consistency and synchronization across all supported language files.
|
||||
- Highlighted any missing or incomplete translations.
|
||||
|
||||
#### **2. Update README.md**
|
||||
- Generated the translation progress table in `README.md`.
|
||||
- Added a summary of the current translation status for all supported languages.
|
||||
- Included up-to-date statistics on translation coverage.
|
||||
|
||||
#### **Why these changes are necessary**
|
||||
- Keeps translation files aligned with the latest reference updates.
|
||||
- Ensures the documentation reflects the current translation progress.
|
||||
|
||||
---
|
||||
|
||||
Auto-generated by [create-pull-request][1].
|
||||
|
||||
[1]: https://github.com/peter-evans/create-pull-request
|
||||
draft: false
|
||||
delete-branch: true
|
||||
labels: github-actions
|
||||
sign-commits: true
|
||||
add-paths: |
|
||||
README.md
|
||||
frontend/public/locales/*/translation.json
|
||||
@@ -192,11 +192,6 @@ return useToolOperation({
|
||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
|
||||
- Translation files are located in `frontend/public/locales/`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
# Frontend TODO: Revocation Status Migration
|
||||
|
||||
## Background
|
||||
The backend has removed the deprecated `notRevoked` boolean field in favor of `revocationStatus` string field.
|
||||
|
||||
**revocationStatus values**:
|
||||
- `"not-checked"` - revocation checking was disabled
|
||||
- `"good"` - certificate was checked and is not revoked
|
||||
- `"revoked"` - certificate is revoked
|
||||
- `"soft-fail"` - revocation status couldn't be determined (network error, etc.)
|
||||
- `"unknown"` - other failure scenarios
|
||||
|
||||
## Files That Need Changes
|
||||
|
||||
### 1. `/frontend/src/hooks/tools/validateSignature/utils/signatureUtils.ts`
|
||||
|
||||
Add mappings for new backend fields in `normalizeBackendResult()`:
|
||||
```typescript
|
||||
export const normalizeBackendResult = (
|
||||
item: SignatureValidationBackendResult,
|
||||
stirlingFile: StirlingFile,
|
||||
index: number
|
||||
): SignatureValidationSignature => ({
|
||||
id: `${stirlingFile.fileId}-${index}`,
|
||||
valid: Boolean(item.valid),
|
||||
chainValid: Boolean(item.chainValid),
|
||||
trustValid: Boolean(item.trustValid),
|
||||
chainValidationError: item.chainValidationError ?? null, // ADD THIS
|
||||
certPathLength: item.certPathLength ?? null, // ADD THIS
|
||||
notExpired: Boolean(item.notExpired),
|
||||
revocationChecked: item.revocationChecked ?? null, // ADD THIS
|
||||
revocationStatus: item.revocationStatus ?? null, // ADD THIS
|
||||
validationTimeSource: item.validationTimeSource ?? null, // ADD THIS
|
||||
signerName: coerceString(item.signerName),
|
||||
// ... rest of fields
|
||||
})
|
||||
```
|
||||
|
||||
### 2. `/frontend/src/hooks/tools/validateSignature/utils/signatureStatus.ts`
|
||||
|
||||
**Current code** (lines 42-43):
|
||||
```typescript
|
||||
// Use new revocationStatus field if available, fallback to notRevoked for backward compatibility
|
||||
const revStatus = signature.revocationStatus || (signature.notRevoked ? 'good' : 'unknown');
|
||||
```
|
||||
|
||||
**Change to**:
|
||||
```typescript
|
||||
const revStatus = signature.revocationStatus || 'unknown';
|
||||
```
|
||||
|
||||
### 3. `/frontend/src/hooks/tools/validateSignature/utils/signatureCsv.ts`
|
||||
|
||||
**Current code** (lines 12, 42):
|
||||
```typescript
|
||||
'notRevoked', // line 12 in CSV header
|
||||
booleanToString(signature.notRevoked), // line 42 in data row
|
||||
```
|
||||
|
||||
**Recommended change** - replace with detailed status:
|
||||
```typescript
|
||||
// Header:
|
||||
'revocationStatus',
|
||||
|
||||
// Data:
|
||||
signature.revocationStatus || 'unknown',
|
||||
```
|
||||
|
||||
### 4. `/frontend/src/hooks/tools/validateSignature/utils/reportStatus.ts`
|
||||
|
||||
**Current code** (line 24):
|
||||
```typescript
|
||||
(sig) => sig.valid && sig.chainValid && sig.trustValid && sig.notExpired && sig.notRevoked
|
||||
```
|
||||
|
||||
**Change to**:
|
||||
```typescript
|
||||
(sig) => sig.valid && sig.chainValid && sig.trustValid && sig.notExpired && sig.revocationStatus === 'good'
|
||||
```
|
||||
|
||||
### 5. `/frontend/src/components/tools/validateSignature/ValidateSignatureResults.tsx`
|
||||
|
||||
**Current code** (line 33):
|
||||
```typescript
|
||||
signature.notRevoked;
|
||||
```
|
||||
|
||||
**Change to**:
|
||||
```typescript
|
||||
signature.revocationStatus === 'good'
|
||||
```
|
||||
|
||||
## Migration Pattern
|
||||
|
||||
**For boolean contexts** (if statements, filters):
|
||||
```typescript
|
||||
// Old: signature.notRevoked
|
||||
// New: signature.revocationStatus === 'good'
|
||||
```
|
||||
|
||||
**For display/logging**:
|
||||
```typescript
|
||||
signature.revocationStatus // "good" | "revoked" | "soft-fail" | "not-checked" | "unknown"
|
||||
```
|
||||
@@ -97,6 +97,7 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
|
||||
|
||||
|
||||
|
||||
|
||||
# 📖 Get Started
|
||||
|
||||
Visit our comprehensive documentation at [docs.stirlingpdf.com](https://docs.stirlingpdf.com) for:
|
||||
@@ -115,46 +116,46 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
@@ -258,6 +258,12 @@ public class AppConfig {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "GoogleDriveEnabled")
|
||||
@Profile("default")
|
||||
public boolean googleDriveEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "license")
|
||||
@Profile("default")
|
||||
public String licenseType() {
|
||||
|
||||
+21
-36
@@ -120,7 +120,6 @@ public class ApplicationProperties {
|
||||
private String loginMethod = "all";
|
||||
private String customGlobalAPIKey;
|
||||
private Jwt jwt = new Jwt();
|
||||
private Validation validation = new Validation();
|
||||
|
||||
public Boolean isAltLogin() {
|
||||
return saml2.getEnabled() || oauth2.getEnabled();
|
||||
@@ -309,41 +308,6 @@ public class ApplicationProperties {
|
||||
private int keyRetentionDays = 7;
|
||||
private boolean secureCookie;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Validation {
|
||||
private Trust trust = new Trust();
|
||||
private boolean allowAIA = false;
|
||||
private Aatl aatl = new Aatl();
|
||||
private Eutl eutl = new Eutl();
|
||||
private Revocation revocation = new Revocation();
|
||||
|
||||
@Data
|
||||
public static class Trust {
|
||||
private boolean serverAsAnchor = true;
|
||||
private boolean useSystemTrust = false;
|
||||
private boolean useMozillaBundle = false;
|
||||
private boolean useAATL = false;
|
||||
private boolean useEUTL = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Aatl {
|
||||
private String url = "https://trustlist.adobe.com/tl.pdf";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Eutl {
|
||||
private String lotlUrl = "https://ec.europa.eu/tools/lotl/eu-lotl.xml";
|
||||
private boolean acceptTransitional = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Revocation {
|
||||
private String mode = "none";
|
||||
private boolean hardFail = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -566,6 +530,7 @@ public class ApplicationProperties {
|
||||
private boolean ssoAutoLogin;
|
||||
private boolean database;
|
||||
private CustomMetadata customMetadata = new CustomMetadata();
|
||||
private GoogleDrive googleDrive = new GoogleDrive();
|
||||
|
||||
@Data
|
||||
public static class CustomMetadata {
|
||||
@@ -584,6 +549,26 @@ public class ApplicationProperties {
|
||||
: producer;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class GoogleDrive {
|
||||
private boolean enabled;
|
||||
private String clientId;
|
||||
private String apiKey;
|
||||
private String appId;
|
||||
|
||||
public String getClientId() {
|
||||
return clientId == null || clientId.trim().isEmpty() ? "" : clientId;
|
||||
}
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey == null || apiKey.trim().isEmpty() ? "" : apiKey;
|
||||
}
|
||||
|
||||
public String getAppId() {
|
||||
return appId == null || appId.trim().isEmpty() ? "" : appId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+16
@@ -109,6 +109,22 @@ class ApplicationPropertiesLogicTest {
|
||||
assertTrue(ex.getMessage().toLowerCase().contains("not supported"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void premium_google_drive_getters_return_empty_string_on_null_or_blank() {
|
||||
Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive();
|
||||
|
||||
assertEquals("", gd.getClientId());
|
||||
assertEquals("", gd.getApiKey());
|
||||
assertEquals("", gd.getAppId());
|
||||
|
||||
gd.setClientId(" id ");
|
||||
gd.setApiKey(" key ");
|
||||
gd.setAppId(" app ");
|
||||
assertEquals(" id ", gd.getClientId());
|
||||
assertEquals(" key ", gd.getApiKey());
|
||||
assertEquals(" app ", gd.getAppId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ui_getters_return_null_for_blank() {
|
||||
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
|
||||
|
||||
+5
@@ -98,6 +98,11 @@ public class ConfigController {
|
||||
if (applicationContext.containsBean("license")) {
|
||||
configData.put("license", applicationContext.getBean("license", String.class));
|
||||
}
|
||||
if (applicationContext.containsBean("GoogleDriveEnabled")) {
|
||||
configData.put(
|
||||
"GoogleDriveEnabled",
|
||||
applicationContext.getBean("GoogleDriveEnabled", Boolean.class));
|
||||
}
|
||||
if (applicationContext.containsBean("SSOAutoLogin")) {
|
||||
configData.put(
|
||||
"SSOAutoLogin",
|
||||
|
||||
+43
-129
@@ -5,11 +5,9 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.PKIXCertPathBuilderResult;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@@ -34,7 +32,6 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.JsonDataResponse;
|
||||
import stirling.software.SPDF.model.api.security.SignatureValidationRequest;
|
||||
@@ -45,7 +42,6 @@ import stirling.software.common.annotations.api.SecurityApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
@Slf4j
|
||||
@SecurityApi
|
||||
@RequiredArgsConstructor
|
||||
public class ValidateSignatureController {
|
||||
@@ -69,9 +65,8 @@ public class ValidateSignatureController {
|
||||
@Operation(
|
||||
summary = "Validate PDF Digital Signature",
|
||||
description =
|
||||
"Validates the digital signatures in a PDF file using PKIX path building"
|
||||
+ " and time-of-signing semantics. Supports custom trust anchors."
|
||||
+ " Input:PDF Output:JSON Type:SISO")
|
||||
"Validates the digital signatures in a PDF file against default or custom"
|
||||
+ " certificates. Input:PDF Output:JSON Type:SISO")
|
||||
@AutoJobPostMapping(
|
||||
value = "/validate-signature",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -79,12 +74,12 @@ public class ValidateSignatureController {
|
||||
@ModelAttribute SignatureValidationRequest request) throws IOException {
|
||||
List<SignatureValidationResult> results = new ArrayList<>();
|
||||
MultipartFile file = request.getFileInput();
|
||||
MultipartFile certFile = request.getCertFile();
|
||||
|
||||
// Load custom certificate if provided
|
||||
X509Certificate customCert = null;
|
||||
if (request.getCertFile() != null && !request.getCertFile().isEmpty()) {
|
||||
try (ByteArrayInputStream certStream =
|
||||
new ByteArrayInputStream(request.getCertFile().getBytes())) {
|
||||
if (certFile != null && !certFile.isEmpty()) {
|
||||
try (ByteArrayInputStream certStream = new ByteArrayInputStream(certFile.getBytes())) {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
customCert = (X509Certificate) cf.generateCertificate(certStream);
|
||||
} catch (CertificateException e) {
|
||||
@@ -113,150 +108,67 @@ public class ValidateSignatureController {
|
||||
Store<X509CertificateHolder> certStore = signedData.getCertificates();
|
||||
SignerInformationStore signerStore = signedData.getSignerInfos();
|
||||
|
||||
for (SignerInformation signerInfo : signerStore.getSigners()) {
|
||||
for (SignerInformation signer : signerStore.getSigners()) {
|
||||
X509CertificateHolder certHolder =
|
||||
(X509CertificateHolder)
|
||||
certStore.getMatches(signerInfo.getSID()).iterator().next();
|
||||
X509Certificate signerCert =
|
||||
certStore.getMatches(signer.getSID()).iterator().next();
|
||||
X509Certificate cert =
|
||||
new JcaX509CertificateConverter().getCertificate(certHolder);
|
||||
|
||||
// Extract intermediate certificates from CMS
|
||||
Collection<X509Certificate> intermediates =
|
||||
certValidationService.extractIntermediateCertificates(
|
||||
certStore, signerCert);
|
||||
boolean isValid =
|
||||
signer.verify(new JcaSimpleSignerInfoVerifierBuilder().build(cert));
|
||||
result.setValid(isValid);
|
||||
|
||||
// Log what we found
|
||||
log.debug(
|
||||
"Found {} intermediate certificates in CMS signature",
|
||||
intermediates.size());
|
||||
for (X509Certificate inter : intermediates) {
|
||||
log.debug(
|
||||
" → Intermediate: {}",
|
||||
inter.getSubjectX500Principal().getName());
|
||||
log.debug(
|
||||
" Issuer DN: {}", inter.getIssuerX500Principal().getName());
|
||||
}
|
||||
// Additional validations
|
||||
result.setChainValid(
|
||||
customCert != null
|
||||
? certValidationService
|
||||
.validateCertificateChainWithCustomCert(
|
||||
cert, customCert)
|
||||
: certValidationService.validateCertificateChain(cert));
|
||||
|
||||
// Determine validation time (TSA timestamp or signingTime, or current)
|
||||
CertificateValidationService.ValidationTime validationTimeResult =
|
||||
certValidationService.extractValidationTime(signerInfo);
|
||||
Date validationTime;
|
||||
if (validationTimeResult == null) {
|
||||
validationTime = new Date();
|
||||
result.setValidationTimeSource("current");
|
||||
} else {
|
||||
validationTime = validationTimeResult.date;
|
||||
result.setValidationTimeSource(validationTimeResult.source);
|
||||
}
|
||||
result.setTrustValid(
|
||||
customCert != null
|
||||
? certValidationService.validateTrustWithCustomCert(
|
||||
cert, customCert)
|
||||
: certValidationService.validateTrustStore(cert));
|
||||
|
||||
// Verify cryptographic signature
|
||||
boolean cmsValid =
|
||||
signerInfo.verify(
|
||||
new JcaSimpleSignerInfoVerifierBuilder().build(signerCert));
|
||||
result.setValid(cmsValid);
|
||||
|
||||
// Build and validate certificate path
|
||||
boolean chainValid = false;
|
||||
boolean trustValid = false;
|
||||
try {
|
||||
PKIXCertPathBuilderResult pathResult =
|
||||
certValidationService.buildAndValidatePath(
|
||||
signerCert, intermediates, customCert, validationTime);
|
||||
chainValid = true;
|
||||
trustValid = true; // Path ends at trust anchor
|
||||
result.setCertPathLength(
|
||||
pathResult.getCertPath().getCertificates().size());
|
||||
} catch (Exception e) {
|
||||
String errorMsg = e.getMessage();
|
||||
result.setChainValidationError(errorMsg);
|
||||
chainValid = false;
|
||||
trustValid = false;
|
||||
// Log the full error for debugging
|
||||
log.warn(
|
||||
"Certificate path validation failed for {}: {}",
|
||||
signerCert.getSubjectX500Principal().getName(),
|
||||
errorMsg);
|
||||
log.debug("Full stack trace:", e);
|
||||
}
|
||||
result.setChainValid(chainValid);
|
||||
result.setTrustValid(trustValid);
|
||||
|
||||
// Check validity at validation time
|
||||
boolean outside =
|
||||
certValidationService.isOutsideValidityPeriod(
|
||||
signerCert, validationTime);
|
||||
result.setNotExpired(!outside);
|
||||
|
||||
// Revocation status determination
|
||||
boolean revocationEnabled = certValidationService.isRevocationEnabled();
|
||||
result.setRevocationChecked(revocationEnabled);
|
||||
|
||||
if (!revocationEnabled) {
|
||||
result.setRevocationStatus("not-checked");
|
||||
} else if (chainValid && trustValid) {
|
||||
// Path building succeeded with revocation enabled = no revocation found
|
||||
result.setRevocationStatus("good");
|
||||
} else if (result.getChainValidationError() != null
|
||||
&& result.getChainValidationError()
|
||||
.toLowerCase()
|
||||
.contains("revocation")) {
|
||||
// Check if failure was revocation-related
|
||||
if (result.getChainValidationError()
|
||||
.toLowerCase()
|
||||
.contains("unable to check")) {
|
||||
result.setRevocationStatus("soft-fail");
|
||||
} else {
|
||||
result.setRevocationStatus("revoked");
|
||||
}
|
||||
} else {
|
||||
result.setRevocationStatus("unknown");
|
||||
}
|
||||
result.setNotRevoked(!certValidationService.isRevoked(cert));
|
||||
result.setNotExpired(!cert.getNotAfter().before(new Date()));
|
||||
|
||||
// Set basic signature info
|
||||
result.setSignerName(sig.getName());
|
||||
result.setSignatureDate(
|
||||
sig.getSignDate() != null
|
||||
? sig.getSignDate().getTime().toString()
|
||||
: null);
|
||||
result.setSignatureDate(sig.getSignDate().getTime().toString());
|
||||
result.setReason(sig.getReason());
|
||||
result.setLocation(sig.getLocation());
|
||||
|
||||
// Set certificate details (from signer cert)
|
||||
result.setIssuerDN(signerCert.getIssuerX500Principal().getName());
|
||||
result.setSubjectDN(signerCert.getSubjectX500Principal().getName());
|
||||
result.setSerialNumber(
|
||||
signerCert.getSerialNumber().toString(16)); // Hex format
|
||||
result.setValidFrom(signerCert.getNotBefore().toString());
|
||||
result.setValidUntil(signerCert.getNotAfter().toString());
|
||||
result.setSignatureAlgorithm(signerCert.getSigAlgName());
|
||||
// Set new certificate details
|
||||
result.setIssuerDN(cert.getIssuerX500Principal().getName());
|
||||
result.setSubjectDN(cert.getSubjectX500Principal().getName());
|
||||
result.setSerialNumber(cert.getSerialNumber().toString(16)); // Hex format
|
||||
result.setValidFrom(cert.getNotBefore().toString());
|
||||
result.setValidUntil(cert.getNotAfter().toString());
|
||||
result.setSignatureAlgorithm(cert.getSigAlgName());
|
||||
|
||||
// Get key size (if possible)
|
||||
try {
|
||||
result.setKeySize(
|
||||
((RSAPublicKey) signerCert.getPublicKey())
|
||||
.getModulus()
|
||||
.bitLength());
|
||||
((RSAPublicKey) cert.getPublicKey()).getModulus().bitLength());
|
||||
} catch (Exception e) {
|
||||
// If not RSA or error, set to 0
|
||||
result.setKeySize(0);
|
||||
}
|
||||
|
||||
result.setVersion(String.valueOf(signerCert.getVersion()));
|
||||
result.setVersion(String.valueOf(cert.getVersion()));
|
||||
|
||||
// Set key usage
|
||||
List<String> keyUsages = new ArrayList<>();
|
||||
boolean[] keyUsageFlags = signerCert.getKeyUsage();
|
||||
boolean[] keyUsageFlags = cert.getKeyUsage();
|
||||
if (keyUsageFlags != null) {
|
||||
String[] keyUsageLabels = {
|
||||
"Digital Signature",
|
||||
"Non-Repudiation",
|
||||
"Key Encipherment",
|
||||
"Data Encipherment",
|
||||
"Key Agreement",
|
||||
"Certificate Signing",
|
||||
"CRL Signing",
|
||||
"Encipher Only",
|
||||
"Decipher Only"
|
||||
"Digital Signature", "Non-Repudiation", "Key Encipherment",
|
||||
"Data Encipherment", "Key Agreement", "Certificate Signing",
|
||||
"CRL Signing", "Encipher Only", "Decipher Only"
|
||||
};
|
||||
for (int i = 0; i < keyUsageFlags.length; i++) {
|
||||
if (keyUsageFlags[i]) {
|
||||
@@ -266,8 +178,10 @@ public class ValidateSignatureController {
|
||||
}
|
||||
result.setKeyUsages(keyUsages);
|
||||
|
||||
// Check if self-signed (properly)
|
||||
result.setSelfSigned(certValidationService.isSelfSigned(signerCert));
|
||||
// Check if self-signed
|
||||
result.setSelfSigned(
|
||||
cert.getSubjectX500Principal()
|
||||
.equals(cert.getIssuerX500Principal()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.setValid(false);
|
||||
|
||||
+4
-19
@@ -6,32 +6,17 @@ import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class SignatureValidationResult {
|
||||
// Cryptographic signature validation
|
||||
private boolean valid;
|
||||
|
||||
// Certificate chain validation
|
||||
private boolean chainValid;
|
||||
private boolean trustValid;
|
||||
private String chainValidationError;
|
||||
private int certPathLength;
|
||||
|
||||
// Time validation
|
||||
private boolean notExpired;
|
||||
|
||||
// Revocation validation
|
||||
private boolean revocationChecked; // true if PKIX revocation was enabled
|
||||
private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
|
||||
|
||||
private String validationTimeSource; // "current", "signing-time", or "timestamp"
|
||||
|
||||
// Signature metadata
|
||||
private String signerName;
|
||||
private String signatureDate;
|
||||
private String reason;
|
||||
private String location;
|
||||
private String errorMessage;
|
||||
private boolean chainValid;
|
||||
private boolean trustValid;
|
||||
private boolean notExpired;
|
||||
private boolean notRevoked;
|
||||
|
||||
// Certificate details
|
||||
private String issuerDN; // Certificate issuer's Distinguished Name
|
||||
private String subjectDN; // Certificate subject's Distinguished Name
|
||||
private String serialNumber; // Certificate serial number
|
||||
|
||||
+93
-813
@@ -1,863 +1,143 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.cert.*;
|
||||
import java.util.*;
|
||||
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
|
||||
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
|
||||
import org.bouncycastle.asn1.ASN1Encodable;
|
||||
import org.bouncycastle.asn1.ASN1GeneralizedTime;
|
||||
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
||||
import org.bouncycastle.asn1.ASN1UTCTime;
|
||||
import org.bouncycastle.asn1.cms.CMSAttributes;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cms.CMSSignedData;
|
||||
import org.bouncycastle.cms.SignerInformation;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.tsp.TimeStampToken;
|
||||
import org.bouncycastle.util.Store;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import io.github.pixee.security.BoundedLineReader;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CertificateValidationService {
|
||||
/**
|
||||
* Result container for validation time extraction Contains both the date and the source of the
|
||||
* time
|
||||
*/
|
||||
public static class ValidationTime {
|
||||
public final Date date;
|
||||
public final String source; // "timestamp" | "signing-time" | "current"
|
||||
|
||||
public ValidationTime(Date date, String source) {
|
||||
this.date = date;
|
||||
this.source = source;
|
||||
}
|
||||
}
|
||||
|
||||
// Separate trust stores: signing vs TLS
|
||||
private KeyStore signingTrustAnchors; // AATL/EUTL + server cert for PDF signing
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
// EUTL (EU Trusted List) constants
|
||||
private static final String NS_TSL = "http://uri.etsi.org/02231/v2#";
|
||||
|
||||
// Qualified CA service types to import as trust anchors (per ETSI TS 119 612)
|
||||
private static final Set<String> EUTL_SERVICE_TYPES =
|
||||
new HashSet<>(
|
||||
Arrays.asList(
|
||||
"http://uri.etsi.org/TrstSvc/Svctype/CA/QC",
|
||||
"http://uri.etsi.org/TrstSvc/Svctype/NationalRootCA-QC"));
|
||||
|
||||
// Active statuses to accept (per ETSI TS 119 612)
|
||||
private static final String STATUS_UNDER_SUPERVISION =
|
||||
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/undersupervision";
|
||||
private static final String STATUS_ACCREDITED =
|
||||
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/accredited";
|
||||
private static final String STATUS_SUPERVISION_IN_CESSATION =
|
||||
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/supervisionincessation";
|
||||
|
||||
static {
|
||||
if (java.security.Security.getProvider("BC") == null) {
|
||||
java.security.Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
}
|
||||
|
||||
public CertificateValidationService(
|
||||
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
private KeyStore trustStore;
|
||||
|
||||
@PostConstruct
|
||||
private void initializeTrustStore() throws Exception {
|
||||
signingTrustAnchors = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
signingTrustAnchors.load(null, null);
|
||||
|
||||
ApplicationProperties.Security.Validation validation =
|
||||
applicationProperties.getSecurity().getValidation();
|
||||
|
||||
// Enable JDK fetching of OCSP/CRLDP if allowed
|
||||
if (validation.isAllowAIA()) {
|
||||
java.security.Security.setProperty("ocsp.enable", "true");
|
||||
System.setProperty("com.sun.security.enableCRLDP", "true");
|
||||
System.setProperty("com.sun.security.enableAIAcaIssuers", "true");
|
||||
log.info("Enabled AIA certificate fetching and revocation checking");
|
||||
}
|
||||
|
||||
// Trust only what we explicitly opt into:
|
||||
if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor();
|
||||
if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore();
|
||||
if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts();
|
||||
if (validation.getTrust().isUseAATL()) loadAATLCertificates();
|
||||
if (validation.getTrust().isUseEUTL()) loadEUTLCertificates();
|
||||
trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
trustStore.load(null, null);
|
||||
loadMozillaCertificates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core entry-point: build a valid PKIX path from signerCert using provided intermediates
|
||||
*
|
||||
* @param signerCert The signer certificate
|
||||
* @param intermediates Collection of intermediate certificates from CMS
|
||||
* @param customTrustAnchor Optional custom root/intermediate certificate
|
||||
* @param validationTime Time to validate at (signing time or current)
|
||||
* @return PKIXCertPathBuilderResult containing validated path
|
||||
* @throws GeneralSecurityException if path building/validation fails
|
||||
*/
|
||||
public PKIXCertPathBuilderResult buildAndValidatePath(
|
||||
X509Certificate signerCert,
|
||||
Collection<X509Certificate> intermediates,
|
||||
X509Certificate customTrustAnchor,
|
||||
Date validationTime)
|
||||
throws GeneralSecurityException {
|
||||
private void loadMozillaCertificates() throws Exception {
|
||||
try (InputStream is = getClass().getResourceAsStream("/certdata.txt")) {
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
|
||||
String line;
|
||||
StringBuilder certData = new StringBuilder();
|
||||
boolean inCert = false;
|
||||
int certCount = 0;
|
||||
|
||||
// Build trust anchors
|
||||
Set<TrustAnchor> anchors = new HashSet<>();
|
||||
if (customTrustAnchor != null) {
|
||||
anchors.add(new TrustAnchor(customTrustAnchor, null));
|
||||
} else {
|
||||
Enumeration<String> aliases = signingTrustAnchors.aliases();
|
||||
while (aliases.hasMoreElements()) {
|
||||
Certificate c = signingTrustAnchors.getCertificate(aliases.nextElement());
|
||||
if (c instanceof X509Certificate x) {
|
||||
anchors.add(new TrustAnchor(x, null));
|
||||
while ((line = BoundedLineReader.readLine(reader, 5_000_000)) != null) {
|
||||
if (line.startsWith("CKA_VALUE MULTILINE_OCTAL")) {
|
||||
inCert = true;
|
||||
certData = new StringBuilder();
|
||||
continue;
|
||||
}
|
||||
if (inCert) {
|
||||
if ("END".equals(line)) {
|
||||
inCert = false;
|
||||
byte[] certBytes = parseOctalData(certData.toString());
|
||||
if (certBytes != null) {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
X509Certificate cert =
|
||||
(X509Certificate)
|
||||
cf.generateCertificate(
|
||||
new ByteArrayInputStream(certBytes));
|
||||
trustStore.setCertificateEntry("mozilla-cert-" + certCount++, cert);
|
||||
}
|
||||
} else {
|
||||
certData.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (anchors.isEmpty()) {
|
||||
throw new CertPathBuilderException("No trust anchors available");
|
||||
}
|
||||
|
||||
// Target certificate selector
|
||||
X509CertSelector target = new X509CertSelector();
|
||||
target.setCertificate(signerCert);
|
||||
|
||||
// Intermediate certificate store
|
||||
List<Certificate> allCerts = new ArrayList<>(intermediates);
|
||||
CertStore intermediateStore =
|
||||
CertStore.getInstance("Collection", new CollectionCertStoreParameters(allCerts));
|
||||
|
||||
// PKIX parameters
|
||||
PKIXBuilderParameters params = new PKIXBuilderParameters(anchors, target);
|
||||
params.addCertStore(intermediateStore);
|
||||
String revocationMode =
|
||||
applicationProperties.getSecurity().getValidation().getRevocation().getMode();
|
||||
params.setRevocationEnabled(!"none".equalsIgnoreCase(revocationMode));
|
||||
if (validationTime != null) {
|
||||
params.setDate(validationTime);
|
||||
}
|
||||
|
||||
// Revocation checking
|
||||
if (!"none".equalsIgnoreCase(revocationMode)) {
|
||||
try {
|
||||
PKIXRevocationChecker rc =
|
||||
(PKIXRevocationChecker)
|
||||
CertPathValidator.getInstance("PKIX").getRevocationChecker();
|
||||
|
||||
Set<PKIXRevocationChecker.Option> options =
|
||||
EnumSet.noneOf(PKIXRevocationChecker.Option.class);
|
||||
|
||||
// Soft-fail: allow validation to succeed if revocation status unavailable
|
||||
boolean revocationHardFail =
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getValidation()
|
||||
.getRevocation()
|
||||
.isHardFail();
|
||||
if (!revocationHardFail) {
|
||||
options.add(PKIXRevocationChecker.Option.SOFT_FAIL);
|
||||
}
|
||||
|
||||
// Revocation mode configuration
|
||||
if ("ocsp".equalsIgnoreCase(revocationMode)) {
|
||||
// OCSP-only: prefer OCSP (default), disable fallback to CRL
|
||||
options.add(PKIXRevocationChecker.Option.NO_FALLBACK);
|
||||
} else if ("crl".equalsIgnoreCase(revocationMode)) {
|
||||
// CRL-only: prefer CRLs, disable fallback to OCSP
|
||||
options.add(PKIXRevocationChecker.Option.PREFER_CRLS);
|
||||
options.add(PKIXRevocationChecker.Option.NO_FALLBACK);
|
||||
}
|
||||
// "ocsp+crl" or other: use defaults (try OCSP first, fallback to CRL)
|
||||
|
||||
rc.setOptions(options);
|
||||
params.addCertPathChecker(rc);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to configure revocation checker: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Build path
|
||||
CertPathBuilder builder = CertPathBuilder.getInstance("PKIX");
|
||||
return (PKIXCertPathBuilderResult) builder.build(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract validation time from signature (TSA timestamp or signingTime)
|
||||
*
|
||||
* @param signerInfo The CMS signer information
|
||||
* @return ValidationTime containing date and source, or null if not found
|
||||
*/
|
||||
public ValidationTime extractValidationTime(SignerInformation signerInfo) {
|
||||
private byte[] parseOctalData(String data) {
|
||||
try {
|
||||
// 1) Check for timestamp token (RFC 3161) - highest priority
|
||||
var unsignedAttrs = signerInfo.getUnsignedAttributes();
|
||||
if (unsignedAttrs != null) {
|
||||
var attr =
|
||||
unsignedAttrs.get(new ASN1ObjectIdentifier("1.2.840.113549.1.9.16.2.14"));
|
||||
if (attr != null) {
|
||||
try {
|
||||
TimeStampToken tst =
|
||||
new TimeStampToken(
|
||||
new CMSSignedData(
|
||||
attr.getAttributeValues()[0]
|
||||
.toASN1Primitive()
|
||||
.getEncoded()));
|
||||
Date tstTime = tst.getTimeStampInfo().getGenTime();
|
||||
log.debug("Using timestamp token time: {}", tstTime);
|
||||
return new ValidationTime(tstTime, "timestamp");
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to parse timestamp token: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Check for signingTime attribute - fallback
|
||||
var signedAttrs = signerInfo.getSignedAttributes();
|
||||
if (signedAttrs != null) {
|
||||
var st = signedAttrs.get(CMSAttributes.signingTime);
|
||||
if (st != null) {
|
||||
ASN1Encodable val = st.getAttributeValues()[0];
|
||||
Date signingTime = null;
|
||||
if (val instanceof ASN1UTCTime ut) {
|
||||
signingTime = ut.getDate();
|
||||
} else if (val instanceof ASN1GeneralizedTime gt) {
|
||||
signingTime = gt.getDate();
|
||||
}
|
||||
if (signingTime != null) {
|
||||
log.debug("Using signingTime attribute: {}", signingTime);
|
||||
return new ValidationTime(signingTime, "signing-time");
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
String[] tokens = data.split("\\\\");
|
||||
for (String token : tokens) {
|
||||
token = token.trim();
|
||||
if (!token.isEmpty()) {
|
||||
baos.write(Integer.parseInt(token, 8));
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
log.debug("Error extracting validation time: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if certificate is outside validity period at given time
|
||||
*
|
||||
* @param cert Certificate to check
|
||||
* @param at Time to check validity
|
||||
* @return true if certificate is expired or not yet valid
|
||||
*/
|
||||
public boolean isOutsideValidityPeriod(X509Certificate cert, Date at) {
|
||||
public boolean validateCertificateChain(X509Certificate cert) {
|
||||
try {
|
||||
cert.checkValidity(at);
|
||||
CertPathValidator validator = CertPathValidator.getInstance("PKIX");
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
List<X509Certificate> certList = Arrays.asList(cert);
|
||||
CertPath certPath = cf.generateCertPath(certList);
|
||||
|
||||
Set<TrustAnchor> anchors = new HashSet<>();
|
||||
Enumeration<String> aliases = trustStore.aliases();
|
||||
while (aliases.hasMoreElements()) {
|
||||
Object trustCert = trustStore.getCertificate(aliases.nextElement());
|
||||
if (trustCert instanceof X509Certificate x509Cert) {
|
||||
anchors.add(new TrustAnchor(x509Cert, null));
|
||||
}
|
||||
}
|
||||
|
||||
PKIXParameters params = new PKIXParameters(anchors);
|
||||
params.setRevocationEnabled(false);
|
||||
validator.validate(certPath, params);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean validateTrustStore(X509Certificate cert) {
|
||||
try {
|
||||
Enumeration<String> aliases = trustStore.aliases();
|
||||
while (aliases.hasMoreElements()) {
|
||||
Object trustCert = trustStore.getCertificate(aliases.nextElement());
|
||||
if (trustCert instanceof X509Certificate && cert.equals(trustCert)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (KeyStoreException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isRevoked(X509Certificate cert) {
|
||||
try {
|
||||
cert.checkValidity();
|
||||
return false;
|
||||
} catch (CertificateExpiredException | CertificateNotYetValidException e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if revocation checking is enabled
|
||||
*
|
||||
* @return true if revocation mode is not "none"
|
||||
*/
|
||||
public boolean isRevocationEnabled() {
|
||||
String revocationMode =
|
||||
applicationProperties.getSecurity().getValidation().getRevocation().getMode();
|
||||
return !"none".equalsIgnoreCase(revocationMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if certificate is a CA certificate
|
||||
*
|
||||
* @param cert Certificate to check
|
||||
* @return true if certificate has basicConstraints with CA=true
|
||||
*/
|
||||
public boolean isCA(X509Certificate cert) {
|
||||
return cert.getBasicConstraints() >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if certificate is self-signed by checking signature
|
||||
*
|
||||
* @param cert Certificate to check
|
||||
* @return true if certificate is self-signed and signature is valid
|
||||
*/
|
||||
public boolean isSelfSigned(X509Certificate cert) {
|
||||
public boolean validateCertificateChainWithCustomCert(
|
||||
X509Certificate cert, X509Certificate customCert) {
|
||||
try {
|
||||
if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) {
|
||||
return false;
|
||||
}
|
||||
cert.verify(cert.getPublicKey());
|
||||
cert.verify(customCert.getPublicKey());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SHA-256 fingerprint of certificate
|
||||
*
|
||||
* @param cert Certificate
|
||||
* @return Hex string of SHA-256 hash
|
||||
*/
|
||||
public String sha256Fingerprint(X509Certificate cert) {
|
||||
public boolean validateTrustWithCustomCert(X509Certificate cert, X509Certificate customCert) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = md.digest(cert.getEncoded());
|
||||
return bytesToHex(hash);
|
||||
// Compare the issuer of the signature certificate with the custom certificate
|
||||
return cert.getIssuerX500Principal().equals(customCert.getSubjectX500Principal());
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String bytesToHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02X", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all certificates from CMS signature store
|
||||
*
|
||||
* @param certStore BouncyCastle certificate store
|
||||
* @param signerCert The signer certificate
|
||||
* @return Collection of all certificates except signer
|
||||
*/
|
||||
public Collection<X509Certificate> extractIntermediateCertificates(
|
||||
Store<X509CertificateHolder> certStore, X509Certificate signerCert) {
|
||||
List<X509Certificate> intermediates = new ArrayList<>();
|
||||
try {
|
||||
JcaX509CertificateConverter converter = new JcaX509CertificateConverter();
|
||||
Collection<X509CertificateHolder> holders = certStore.getMatches(null);
|
||||
|
||||
for (X509CertificateHolder holder : holders) {
|
||||
X509Certificate cert = converter.getCertificate(holder);
|
||||
if (!cert.equals(signerCert)) {
|
||||
intermediates.add(cert);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error extracting intermediate certificates: {}", e.getMessage());
|
||||
}
|
||||
return intermediates;
|
||||
}
|
||||
|
||||
// ==================== Trust Store Loading ====================
|
||||
|
||||
/**
|
||||
* Load certificates from Java's system trust store (cacerts). On Windows, this includes
|
||||
* certificates from the Windows trust store. This provides maximum compatibility with what
|
||||
* browsers and OS trust.
|
||||
*/
|
||||
private void loadJavaSystemTrustStore() {
|
||||
try {
|
||||
log.info("Loading certificates from Java system trust store");
|
||||
|
||||
// Get default trust manager factory
|
||||
TrustManagerFactory tmf =
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
tmf.init((KeyStore) null); // null = use system default
|
||||
|
||||
// Extract certificates from trust managers
|
||||
int loadedCount = 0;
|
||||
for (TrustManager tm : tmf.getTrustManagers()) {
|
||||
if (tm instanceof X509TrustManager x509tm) {
|
||||
for (X509Certificate cert : x509tm.getAcceptedIssuers()) {
|
||||
if (isCA(cert)) {
|
||||
String fingerprint = sha256Fingerprint(cert);
|
||||
String alias = "system-" + fingerprint;
|
||||
signingTrustAnchors.setCertificateEntry(alias, cert);
|
||||
loadedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Loaded {} CA certificates from Java system trust store", loadedCount);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load Java system trust store: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load bundled Mozilla CA certificate bundle from resources. This bundle contains ~140 trusted
|
||||
* root CAs from Mozilla's CA Certificate Program, suitable for validating most commercial PDF
|
||||
* signatures.
|
||||
*/
|
||||
private void loadBundledMozillaCACerts() {
|
||||
try {
|
||||
log.info("Loading bundled Mozilla CA certificates from resources");
|
||||
InputStream certStream =
|
||||
getClass().getClassLoader().getResourceAsStream("certs/cacert.pem");
|
||||
if (certStream == null) {
|
||||
log.warn("Bundled Mozilla CA certificate file not found in resources");
|
||||
return;
|
||||
}
|
||||
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
Collection<? extends Certificate> certs = cf.generateCertificates(certStream);
|
||||
certStream.close();
|
||||
|
||||
int loadedCount = 0;
|
||||
int skippedCount = 0;
|
||||
|
||||
for (Certificate cert : certs) {
|
||||
if (cert instanceof X509Certificate x509) {
|
||||
// Only add CA certificates to trust anchors
|
||||
if (isCA(x509)) {
|
||||
String fingerprint = sha256Fingerprint(x509);
|
||||
String alias = "mozilla-" + fingerprint;
|
||||
signingTrustAnchors.setCertificateEntry(alias, x509);
|
||||
loadedCount++;
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Loaded {} Mozilla CA certificates as trust anchors (skipped {} non-CA certs)",
|
||||
loadedCount,
|
||||
skippedCount);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load bundled Mozilla CA certificates: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void loadServerCertAsAnchor() {
|
||||
try {
|
||||
if (serverCertificateService != null
|
||||
&& serverCertificateService.isEnabled()
|
||||
&& serverCertificateService.hasServerCertificate()) {
|
||||
X509Certificate serverCert = serverCertificateService.getServerCertificate();
|
||||
|
||||
// Self-signed certificates can be trust anchors regardless of CA flag
|
||||
// Non-self-signed certificates should only be trust anchors if they're CAs
|
||||
boolean selfSigned = isSelfSigned(serverCert);
|
||||
boolean ca = isCA(serverCert);
|
||||
|
||||
if (selfSigned || ca) {
|
||||
signingTrustAnchors.setCertificateEntry("server-anchor", serverCert);
|
||||
log.info(
|
||||
"Loaded server certificate as trust anchor (self-signed: {}, CA: {})",
|
||||
selfSigned,
|
||||
ca);
|
||||
} else {
|
||||
log.warn(
|
||||
"Server certificate is neither self-signed nor a CA; not adding as trust anchor");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed loading server certificate as anchor: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Download and parse Adobe Approved Trust List (AATL) and add CA certs as trust anchors. */
|
||||
private void loadAATLCertificates() {
|
||||
try {
|
||||
String aatlUrl = applicationProperties.getSecurity().getValidation().getAatl().getUrl();
|
||||
log.info("Loading Adobe Approved Trust List (AATL) from: {}", aatlUrl);
|
||||
byte[] pdfBytes = downloadTrustList(aatlUrl);
|
||||
if (pdfBytes == null) {
|
||||
log.warn("AATL download returned no data");
|
||||
return;
|
||||
}
|
||||
int added = parseAATLPdf(pdfBytes);
|
||||
log.info("Loaded {} AATL CA certificates into signing trust", added);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to load AATL: {}", e.getMessage());
|
||||
log.debug("AATL loading error", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simple HTTP(S) fetch with sane timeouts. */
|
||||
private byte[] downloadTrustList(String urlStr) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(urlStr);
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(10_000);
|
||||
conn.setReadTimeout(30_000);
|
||||
conn.setInstanceFollowRedirects(true);
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
if (code == HttpURLConnection.HTTP_OK) {
|
||||
try (InputStream in = conn.getInputStream();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
byte[] buf = new byte[8192];
|
||||
int r;
|
||||
while ((r = in.read(buf)) != -1) out.write(buf, 0, r);
|
||||
return out.toByteArray();
|
||||
}
|
||||
} else {
|
||||
log.warn("AATL download failed: HTTP {}", code);
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("AATL download error: {}", e.getMessage());
|
||||
return null;
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AATL PDF, extract the embedded "SecuritySettings.xml", and import CA certs. Returns the
|
||||
* number of newly-added CA certificates.
|
||||
*/
|
||||
private int parseAATLPdf(byte[] pdfBytes) throws Exception {
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
PDDocumentNameDictionary names = doc.getDocumentCatalog().getNames();
|
||||
if (names == null) {
|
||||
log.warn("AATL PDF has no name dictionary");
|
||||
return 0;
|
||||
}
|
||||
|
||||
PDEmbeddedFilesNameTreeNode efRoot = names.getEmbeddedFiles();
|
||||
if (efRoot == null) {
|
||||
log.warn("AATL PDF has no embedded files");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 1) Try names at root level
|
||||
Map<String, PDComplexFileSpecification> top = efRoot.getNames();
|
||||
if (top != null) {
|
||||
Integer count = tryParseSecuritySettingsXML(top);
|
||||
if (count != null) return count;
|
||||
}
|
||||
|
||||
// 2) Traverse kids (name-tree)
|
||||
@SuppressWarnings("unchecked")
|
||||
List<?> kids = efRoot.getKids();
|
||||
if (kids != null) {
|
||||
for (Object kidObj : kids) {
|
||||
if (kidObj instanceof PDEmbeddedFilesNameTreeNode) {
|
||||
PDEmbeddedFilesNameTreeNode kid = (PDEmbeddedFilesNameTreeNode) kidObj;
|
||||
Map<String, PDComplexFileSpecification> map = kid.getNames();
|
||||
if (map != null) {
|
||||
Integer count = tryParseSecuritySettingsXML(map);
|
||||
if (count != null) return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("AATL PDF did not contain SecuritySettings.xml");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to locate "SecuritySettings.xml" in the given name map. If found and parsed, returns the
|
||||
* number of certs added; otherwise returns null.
|
||||
*/
|
||||
private Integer tryParseSecuritySettingsXML(Map<String, PDComplexFileSpecification> nameMap) {
|
||||
PDComplexFileSpecification fileSpec = nameMap.get("SecuritySettings.xml");
|
||||
if (fileSpec == null) return null;
|
||||
|
||||
PDEmbeddedFile ef = fileSpec.getEmbeddedFile();
|
||||
if (ef == null) return null;
|
||||
|
||||
try (InputStream xmlStream = ef.createInputStream()) {
|
||||
return parseSecuritySettingsXML(xmlStream);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed parsing SecuritySettings.xml: {}", e.getMessage());
|
||||
log.debug("SecuritySettings.xml parse error", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the SecuritySettings.xml and load only CA certificates (basicConstraints >= 0). Returns
|
||||
* the number of newly-added CA certificates.
|
||||
*/
|
||||
private int parseSecuritySettingsXML(InputStream xmlStream) throws Exception {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
Document doc = builder.parse(xmlStream);
|
||||
|
||||
NodeList certNodes = doc.getElementsByTagName("Certificate");
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
|
||||
int added = 0;
|
||||
for (int i = 0; i < certNodes.getLength(); i++) {
|
||||
String base64 = certNodes.item(i).getTextContent().trim();
|
||||
if (base64.isEmpty()) continue;
|
||||
|
||||
try {
|
||||
byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64);
|
||||
X509Certificate cert =
|
||||
(X509Certificate)
|
||||
cf.generateCertificate(new ByteArrayInputStream(certBytes));
|
||||
|
||||
// Only add CA certs as anchors
|
||||
if (isCA(cert)) {
|
||||
String fingerprint = sha256Fingerprint(cert);
|
||||
String alias = "aatl-" + fingerprint;
|
||||
|
||||
// avoid duplicates
|
||||
if (signingTrustAnchors.getCertificate(alias) == null) {
|
||||
signingTrustAnchors.setCertificateEntry(alias, cert);
|
||||
added++;
|
||||
}
|
||||
} else {
|
||||
log.debug(
|
||||
"Skipping non-CA certificate from AATL: {}",
|
||||
cert.getSubjectX500Principal().getName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to parse an AATL certificate node: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download LOTL (List Of Trusted Lists), resolve national TSLs, and import qualified CA
|
||||
* certificates.
|
||||
*/
|
||||
private void loadEUTLCertificates() {
|
||||
try {
|
||||
String lotlUrl =
|
||||
applicationProperties.getSecurity().getValidation().getEutl().getLotlUrl();
|
||||
log.info("Loading EU Trusted List (LOTL) from: {}", lotlUrl);
|
||||
byte[] lotlBytes = downloadXml(lotlUrl);
|
||||
if (lotlBytes == null) {
|
||||
log.warn("LOTL download returned no data");
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> tslUrls = parseLotlForTslLocations(lotlBytes);
|
||||
log.info("Found {} national TSL locations in LOTL", tslUrls.size());
|
||||
|
||||
int totalAdded = 0;
|
||||
for (String tslUrl : tslUrls) {
|
||||
try {
|
||||
byte[] tslBytes = downloadXml(tslUrl);
|
||||
if (tslBytes == null) {
|
||||
log.warn("TSL download failed: {}", tslUrl);
|
||||
continue;
|
||||
}
|
||||
int added = parseTslAndAddCas(tslBytes, tslUrl);
|
||||
totalAdded += added;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse TSL {}: {}", tslUrl, e.getMessage());
|
||||
log.debug("TSL parse error", e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Imported {} qualified CA certificates from EUTL", totalAdded);
|
||||
} catch (Exception e) {
|
||||
log.warn("EUTL load failed: {}", e.getMessage());
|
||||
log.debug("EUTL load error", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** HTTP(S) GET for XML with sane timeouts. */
|
||||
private byte[] downloadXml(String urlStr) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(urlStr);
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(10_000);
|
||||
conn.setReadTimeout(30_000);
|
||||
conn.setInstanceFollowRedirects(true);
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
if (code == HttpURLConnection.HTTP_OK) {
|
||||
try (InputStream in = conn.getInputStream();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
byte[] buf = new byte[8192];
|
||||
int r;
|
||||
while ((r = in.read(buf)) != -1) out.write(buf, 0, r);
|
||||
return out.toByteArray();
|
||||
}
|
||||
} else {
|
||||
log.warn("XML download failed: HTTP {} for {}", code, urlStr);
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("XML download error for {}: {}", urlStr, e.getMessage());
|
||||
return null;
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse LOTL and return all TSL URLs from PointersToOtherTSL. */
|
||||
private List<String> parseLotlForTslLocations(byte[] lotlBytes) throws Exception {
|
||||
DocumentBuilderFactory dbf = secureDbfWithNamespaces();
|
||||
DocumentBuilder db = dbf.newDocumentBuilder();
|
||||
Document doc = db.parse(new ByteArrayInputStream(lotlBytes));
|
||||
|
||||
List<String> out = new ArrayList<>();
|
||||
NodeList ptrs = doc.getElementsByTagNameNS(NS_TSL, "PointersToOtherTSL");
|
||||
if (ptrs.getLength() == 0) return out;
|
||||
|
||||
org.w3c.dom.Element ptrRoot = (org.w3c.dom.Element) ptrs.item(0);
|
||||
NodeList locations = ptrRoot.getElementsByTagNameNS(NS_TSL, "TSLLocation");
|
||||
for (int i = 0; i < locations.getLength(); i++) {
|
||||
String url = locations.item(i).getTextContent().trim();
|
||||
if (!url.isEmpty()) out.add(url);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single national TSL, import CA certificates for qualified services in an active
|
||||
* status. Returns count of newly added CA certs.
|
||||
*/
|
||||
private int parseTslAndAddCas(byte[] tslBytes, String sourceUrl) throws Exception {
|
||||
DocumentBuilderFactory dbf = secureDbfWithNamespaces();
|
||||
DocumentBuilder db = dbf.newDocumentBuilder();
|
||||
Document doc = db.parse(new ByteArrayInputStream(tslBytes));
|
||||
|
||||
int added = 0;
|
||||
|
||||
NodeList services = doc.getElementsByTagNameNS(NS_TSL, "TSPService");
|
||||
for (int i = 0; i < services.getLength(); i++) {
|
||||
org.w3c.dom.Element svc = (org.w3c.dom.Element) services.item(i);
|
||||
org.w3c.dom.Element info = firstChildNS(svc, "ServiceInformation");
|
||||
if (info == null) continue;
|
||||
|
||||
String type = textOf(info, "ServiceTypeIdentifier");
|
||||
if (!EUTL_SERVICE_TYPES.contains(type)) continue;
|
||||
|
||||
String status = textOf(info, "ServiceStatus");
|
||||
if (!isActiveStatus(status)) continue;
|
||||
|
||||
org.w3c.dom.Element sdi = firstChildNS(info, "ServiceDigitalIdentity");
|
||||
if (sdi == null) continue;
|
||||
|
||||
NodeList digitalIds = sdi.getElementsByTagNameNS(NS_TSL, "DigitalId");
|
||||
for (int d = 0; d < digitalIds.getLength(); d++) {
|
||||
org.w3c.dom.Element did = (org.w3c.dom.Element) digitalIds.item(d);
|
||||
NodeList certNodes = did.getElementsByTagNameNS(NS_TSL, "X509Certificate");
|
||||
for (int c = 0; c < certNodes.getLength(); c++) {
|
||||
String base64 = certNodes.item(c).getTextContent().trim();
|
||||
if (base64.isEmpty()) continue;
|
||||
|
||||
try {
|
||||
byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64);
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
X509Certificate cert =
|
||||
(X509Certificate)
|
||||
cf.generateCertificate(new ByteArrayInputStream(certBytes));
|
||||
|
||||
if (!isCA(cert)) {
|
||||
log.debug(
|
||||
"Skipping non-CA in TSL {}: {}",
|
||||
sourceUrl,
|
||||
cert.getSubjectX500Principal().getName());
|
||||
continue;
|
||||
}
|
||||
|
||||
String fp = sha256Fingerprint(cert);
|
||||
String alias = "eutl-" + fp;
|
||||
|
||||
if (signingTrustAnchors.getCertificate(alias) == null) {
|
||||
signingTrustAnchors.setCertificateEntry(alias, cert);
|
||||
added++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to import a certificate from {}: {}",
|
||||
sourceUrl,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("TSL {} → imported {} CA certificates", sourceUrl, added);
|
||||
return added;
|
||||
}
|
||||
|
||||
/** Check if service status is active (per ETSI TS 119 612). */
|
||||
private boolean isActiveStatus(String statusUri) {
|
||||
if (STATUS_UNDER_SUPERVISION.equals(statusUri)) return true;
|
||||
if (STATUS_ACCREDITED.equals(statusUri)) return true;
|
||||
boolean acceptTransitional =
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getValidation()
|
||||
.getEutl()
|
||||
.isAcceptTransitional();
|
||||
if (acceptTransitional && STATUS_SUPERVISION_IN_CESSATION.equals(statusUri)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Create secure DocumentBuilderFactory with namespace awareness. */
|
||||
private DocumentBuilderFactory secureDbfWithNamespaces() throws Exception {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
// Secure processing hardening
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
return factory;
|
||||
}
|
||||
|
||||
/** Get first child element with given local name in TSL namespace. */
|
||||
private org.w3c.dom.Element firstChildNS(org.w3c.dom.Element parent, String localName) {
|
||||
NodeList nl = parent.getElementsByTagNameNS(NS_TSL, localName);
|
||||
return (nl.getLength() == 0) ? null : (org.w3c.dom.Element) nl.item(0);
|
||||
}
|
||||
|
||||
/** Get text content of first child with given local name. */
|
||||
private String textOf(org.w3c.dom.Element parent, String localName) {
|
||||
org.w3c.dom.Element e = firstChildNS(parent, localName);
|
||||
return (e == null) ? "" : e.getTextContent().trim();
|
||||
}
|
||||
|
||||
/** Get signing trust store */
|
||||
public KeyStore getSigningTrustStore() {
|
||||
return signingTrustAnchors;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1916,7 +1916,6 @@ fileManager.storageError=Storage error occurred
|
||||
fileManager.storageLow=Storage is running low. Consider removing old files.
|
||||
fileManager.uploadError=Failed to upload some files.
|
||||
fileManager.supportMessage=Powered by browser database storage for unlimited capacity
|
||||
fileManager.loadingFiles=Loading files...
|
||||
|
||||
# Page Editor
|
||||
pageEditor.title=Page Editor
|
||||
@@ -1946,25 +1945,6 @@ viewer.zoomIn=Zoom in
|
||||
# Tool Picker
|
||||
toolPicker.searchPlaceholder=Search tools...
|
||||
toolPicker.noToolsFound=No tools found
|
||||
toolPanel.toggle.legacy=Switch to legacy mode
|
||||
toolPanel.toggle.sidebar=Switch to sidebar mode
|
||||
toolPanel.placeholder=Choose a tool to get started
|
||||
toolPanel.legacy.heading=All tools (legacy view)
|
||||
toolPanel.legacy.tagline=Browse and launch tools while keeping the classic full-width gallery.
|
||||
toolPanel.legacy.descriptionsOn=Showing descriptions
|
||||
toolPanel.legacy.descriptionsOff=Descriptions hidden
|
||||
toolPanel.legacy.noResults=Try adjusting your search or toggle descriptions to find what you need.
|
||||
toolPanel.legacy.matchedSynonym=Matches "{{text}}"
|
||||
toolPanel.modePrompt.title=Choose how you browse tools
|
||||
toolPanel.modePrompt.description=Preview both layouts and decide how you want to explore Stirling PDF tools.
|
||||
toolPanel.modePrompt.sidebarTitle=Advanced sidebar
|
||||
toolPanel.modePrompt.sidebarDescription=Keep tools alongside your workspace for quick switching.
|
||||
toolPanel.modePrompt.recommended=Recommended
|
||||
toolPanel.modePrompt.chooseSidebar=Use advanced sidebar
|
||||
toolPanel.modePrompt.legacyTitle=Legacy fullscreen
|
||||
toolPanel.modePrompt.legacyDescription=Browse every tool in a catalogue that covers the workspace until you pick one.
|
||||
toolPanel.modePrompt.chooseLegacy=Use legacy fullscreen
|
||||
toolPanel.modePrompt.dismiss=Maybe later
|
||||
pageEditor.reset=Reset Changes
|
||||
pageEditor.zoomIn=Zoom In
|
||||
pageEditor.zoomOut=Zoom Out
|
||||
@@ -1977,39 +1957,3 @@ viewer.nextPage=Next Page
|
||||
viewer.pageNavigation=Page Navigation
|
||||
viewer.currentPage=Current Page
|
||||
viewer.totalPages=Total Pages
|
||||
toolPanel.legacy.favorites=Favourites
|
||||
toolPanel.legacy.recent=Recently used
|
||||
toolPanel.legacy.favorite=Add to favourites
|
||||
toolPanel.legacy.unfavorite=Remove from favourites
|
||||
toolPanel.legacy.settings.title=Customise appearance
|
||||
toolPanel.legacy.settings.iconBackground.label=Tool icon background
|
||||
toolPanel.legacy.settings.iconBackground.description=When to show coloured backgrounds behind tool icons
|
||||
toolPanel.legacy.settings.iconBackground.none=None
|
||||
toolPanel.legacy.settings.iconBackground.hover=On hover
|
||||
toolPanel.legacy.settings.iconBackground.always=Always
|
||||
toolPanel.legacy.settings.iconColor.label=Tool icon colour
|
||||
toolPanel.legacy.settings.iconColor.description=Colour scheme for tool icons
|
||||
toolPanel.legacy.settings.iconColor.colored=Coloured
|
||||
toolPanel.legacy.settings.iconColor.vibrant=Vibrant
|
||||
toolPanel.legacy.settings.iconColor.monochrome=Monochrome
|
||||
toolPanel.legacy.settings.sectionTitle.label=Section titles
|
||||
toolPanel.legacy.settings.sectionTitle.description=Colour for category section titles
|
||||
toolPanel.legacy.settings.sectionTitle.colored=Coloured
|
||||
toolPanel.legacy.settings.sectionTitle.neutral=Neutral
|
||||
toolPanel.legacy.settings.headerIcon.label=Section header icons
|
||||
toolPanel.legacy.settings.headerIcon.description=Colour for Favourites/Recent icons
|
||||
toolPanel.legacy.settings.headerIcon.colored=Coloured
|
||||
toolPanel.legacy.settings.headerIcon.monochrome=Monochrome
|
||||
toolPanel.legacy.settings.headerBadge.label=Section header badges
|
||||
toolPanel.legacy.settings.headerBadge.description=Colour for count badges in section headers
|
||||
toolPanel.legacy.settings.headerBadge.colored=Coloured
|
||||
toolPanel.legacy.settings.headerBadge.neutral=Neutral
|
||||
toolPanel.legacy.settings.border.label=Tool item borders
|
||||
toolPanel.legacy.settings.border.description=Show borders around tool items
|
||||
toolPanel.legacy.settings.border.visible=Visible
|
||||
toolPanel.legacy.settings.border.hidden=Hidden
|
||||
toolPanel.legacy.settings.hover.label=Hover effect intensity
|
||||
toolPanel.legacy.settings.hover.description=How prominent the hover effect should be
|
||||
toolPanel.legacy.settings.hover.subtle=Subtle
|
||||
toolPanel.legacy.settings.hover.moderate=Moderate
|
||||
toolPanel.legacy.settings.hover.prominent=Prominent
|
||||
|
||||
@@ -65,22 +65,6 @@ security:
|
||||
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
||||
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
|
||||
secureCookie: false # Set to 'true' to use secure cookies for JWTs
|
||||
validation: # PDF signature validation settings
|
||||
trust:
|
||||
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
||||
useSystemTrust: true # Trust Java/OS system trust store for PDF signature validation
|
||||
useMozillaBundle: true # Trust bundled Mozilla CA bundle (~140 CAs) for PDF signature validation
|
||||
useAATL: false # Trust Adobe Approved Trust List (AATL) for PDF signature validation - downloads from Adobe on startup if enabled
|
||||
useEUTL: false # Trust EU Trusted List (EUTL) for eIDAS qualified certificates - downloads LOTL and national TSLs on startup if enabled
|
||||
allowAIA: false # Allow JDK to fetch issuer certificates and revocation information from network (OCSP/CRL/AIA)
|
||||
aatl:
|
||||
url: https://trustlist.adobe.com/tl.pdf # Adobe Approved Trust List download URL
|
||||
eutl:
|
||||
lotlUrl: https://ec.europa.eu/tools/lotl/eu-lotl.xml # EU List Of Trusted Lists (LOTL) URL
|
||||
acceptTransitional: false # Accept certificates with 'supervisionincessation' status (transitional state)
|
||||
revocation:
|
||||
mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback)
|
||||
hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail)
|
||||
|
||||
premium:
|
||||
key: 00000000-0000-0000-0000-000000000000
|
||||
@@ -92,6 +76,11 @@ premium:
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
producer: Stirling-PDF
|
||||
googleDrive:
|
||||
enabled: false
|
||||
clientId: ''
|
||||
apiKey: ''
|
||||
appId: ''
|
||||
enterpriseFeatures:
|
||||
audit:
|
||||
enabled: true # Enable audit logging
|
||||
|
||||
@@ -422,6 +422,10 @@
|
||||
<span th:text="#{fileChooser.or}" style="margin: 0 5px;"></span>
|
||||
<span th:text="#{fileChooser.dragAndDrop}" id="dragAndDrop"></span>
|
||||
</div>
|
||||
<hr th:if="${@GoogleDriveEnabled == true}" class="horizontal-divider" >
|
||||
</div>
|
||||
<div th:if="${@GoogleDriveEnabled == true}" th:id="${name}+'-google-drive-button'" class="google-drive-button" th:attr="data-name=${name}, data-multiple=${!disableMultipleFiles}, data-accept=${accept}" >
|
||||
<img th:src="@{'/images/google-drive.svg'}" alt="google drive">
|
||||
</div>
|
||||
</div>
|
||||
<div class="selected-files flex-wrap"></div>
|
||||
@@ -439,4 +443,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<script th:src="@{'/js/fileInput.js'}" type="module"></script>
|
||||
|
||||
<div th:if="${@GoogleDriveEnabled == true}" >
|
||||
<script type="text/javascript" th:src="@{'/js/googleFilePicker.js'}"></script>
|
||||
<script async defer src="https://apis.google.com/js/api.js" onload="gapiLoaded()"></script>
|
||||
<script async defer src="https://accounts.google.com/gsi/client" onload="gisLoaded()"></script>
|
||||
|
||||
<script th:inline="javascript">
|
||||
window.stirlingPDF.GoogleDriveClientId = /*[[${@GoogleDriveConfig.getClientId()}]]*/ null;
|
||||
window.stirlingPDF.GoogleDriveApiKey = /*[[${@GoogleDriveConfig.getApiKey()}]]*/ null;
|
||||
window.stirlingPDF.GoogleDriveAppId = /*[[${@GoogleDriveConfig.getAppId()}]]*/ null;
|
||||
</script>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
+100
-61
@@ -2,20 +2,20 @@ package stirling.software.SPDF.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.security.PublicKey;
|
||||
import java.security.cert.CertificateExpiredException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
|
||||
import javax.security.auth.x500.X500Principal;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
/** Tests for the CertificateValidationService using mocked certificates. */
|
||||
class CertificateValidationServiceTest {
|
||||
@@ -26,82 +26,121 @@ class CertificateValidationServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Create mock ApplicationProperties with default validation settings
|
||||
ApplicationProperties applicationProperties = mock(ApplicationProperties.class);
|
||||
ApplicationProperties.Security security = mock(ApplicationProperties.Security.class);
|
||||
ApplicationProperties.Security.Validation validation =
|
||||
mock(ApplicationProperties.Security.Validation.class);
|
||||
ApplicationProperties.Security.Validation.Trust trust =
|
||||
mock(ApplicationProperties.Security.Validation.Trust.class);
|
||||
ApplicationProperties.Security.Validation.Revocation revocation =
|
||||
mock(ApplicationProperties.Security.Validation.Revocation.class);
|
||||
|
||||
when(applicationProperties.getSecurity()).thenReturn(security);
|
||||
when(security.getValidation()).thenReturn(validation);
|
||||
when(validation.getTrust()).thenReturn(trust);
|
||||
when(validation.getRevocation()).thenReturn(revocation);
|
||||
when(validation.isAllowAIA()).thenReturn(false);
|
||||
when(validation.isEnableEUTL()).thenReturn(false);
|
||||
when(trust.isServerAsAnchor()).thenReturn(false);
|
||||
when(trust.isUseSystemTrust()).thenReturn(false);
|
||||
when(trust.isUseMozillaBundle()).thenReturn(false);
|
||||
when(revocation.getMode()).thenReturn("none");
|
||||
when(revocation.isHardFail()).thenReturn(false);
|
||||
|
||||
validationService = new CertificateValidationService(null, applicationProperties);
|
||||
validationService = new CertificateValidationService();
|
||||
|
||||
// Create mock certificates
|
||||
validCertificate = mock(X509Certificate.class);
|
||||
expiredCertificate = mock(X509Certificate.class);
|
||||
|
||||
// Set up behaviors for valid certificate (both overloads)
|
||||
doNothing().when(validCertificate).checkValidity();
|
||||
doNothing().when(validCertificate).checkValidity(any(Date.class));
|
||||
// Set up behaviors for valid certificate
|
||||
doNothing().when(validCertificate).checkValidity(); // No exception means valid
|
||||
|
||||
// Set up behaviors for expired certificate (both overloads)
|
||||
// Set up behaviors for expired certificate
|
||||
doThrow(new CertificateExpiredException("Certificate expired"))
|
||||
.when(expiredCertificate)
|
||||
.checkValidity();
|
||||
doThrow(new CertificateExpiredException("Certificate expired"))
|
||||
.when(expiredCertificate)
|
||||
.checkValidity(any(Date.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsOutsideValidityPeriod_ValidCertificate() {
|
||||
void testIsRevoked_ValidCertificate() {
|
||||
// When certificate is valid (not expired)
|
||||
boolean result = validationService.isOutsideValidityPeriod(validCertificate, new Date());
|
||||
|
||||
// Then it should not be outside validity period
|
||||
assertFalse(result, "Valid certificate should not be outside validity period");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsOutsideValidityPeriod_ExpiredCertificate() {
|
||||
// When certificate is expired
|
||||
boolean result = validationService.isOutsideValidityPeriod(expiredCertificate, new Date());
|
||||
|
||||
// Then it should be outside validity period
|
||||
assertTrue(result, "Expired certificate should be outside validity period");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void testDeprecatedIsRevoked_ValidCertificate() {
|
||||
// Test deprecated method for backwards compatibility
|
||||
boolean result = validationService.isRevoked(validCertificate);
|
||||
|
||||
// Then it should not be considered revoked
|
||||
assertFalse(result, "Valid certificate should not be considered revoked");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void testDeprecatedIsRevoked_ExpiredCertificate() {
|
||||
// Test deprecated method for backwards compatibility
|
||||
void testIsRevoked_ExpiredCertificate() {
|
||||
// When certificate is expired
|
||||
boolean result = validationService.isRevoked(expiredCertificate);
|
||||
assertTrue(result, "Expired certificate should be considered revoked (legacy behavior)");
|
||||
|
||||
// Then it should be considered revoked
|
||||
assertTrue(result, "Expired certificate should be considered revoked");
|
||||
}
|
||||
|
||||
// Note: Full integration tests for buildAndValidatePath() would require
|
||||
// real certificate chains and trust anchors. These would be better as
|
||||
// integration tests using actual signed PDFs from the test-signed-pdfs directory.
|
||||
@Test
|
||||
void testValidateTrustWithCustomCert_Match() {
|
||||
// Create certificates with matching issuer and subject
|
||||
X509Certificate issuingCert = mock(X509Certificate.class);
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
|
||||
// Create X500Principal objects for issuer and subject
|
||||
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
|
||||
|
||||
// Mock the issuer of the signed certificate to match the subject of the issuing certificate
|
||||
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
|
||||
when(issuingCert.getSubjectX500Principal()).thenReturn(issuerPrincipal);
|
||||
|
||||
// When validating trust with custom cert
|
||||
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
|
||||
|
||||
// Then validation should succeed
|
||||
assertTrue(result, "Certificate with matching issuer and subject should validate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateTrustWithCustomCert_NoMatch() {
|
||||
// Create certificates with non-matching issuer and subject
|
||||
X509Certificate issuingCert = mock(X509Certificate.class);
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
|
||||
// Create X500Principal objects for issuer and subject
|
||||
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
|
||||
X500Principal differentPrincipal = new X500Principal("CN=Different Name");
|
||||
|
||||
// Mock the issuer of the signed certificate to NOT match the subject of the issuing
|
||||
// certificate
|
||||
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
|
||||
when(issuingCert.getSubjectX500Principal()).thenReturn(differentPrincipal);
|
||||
|
||||
// When validating trust with custom cert
|
||||
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
|
||||
|
||||
// Then validation should fail
|
||||
assertFalse(result, "Certificate with non-matching issuer and subject should not validate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateCertificateChainWithCustomCert_Success() throws Exception {
|
||||
// Setup mock certificates
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
X509Certificate signingCert = mock(X509Certificate.class);
|
||||
PublicKey publicKey = mock(PublicKey.class);
|
||||
|
||||
when(signingCert.getPublicKey()).thenReturn(publicKey);
|
||||
|
||||
// When verifying the certificate with the signing cert's public key, don't throw exception
|
||||
doNothing().when(signedCert).verify(Mockito.any());
|
||||
|
||||
// When validating certificate chain with custom cert
|
||||
boolean result =
|
||||
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
|
||||
|
||||
// Then validation should succeed
|
||||
assertTrue(result, "Certificate chain with proper signing should validate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateCertificateChainWithCustomCert_Failure() throws Exception {
|
||||
// Setup mock certificates
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
X509Certificate signingCert = mock(X509Certificate.class);
|
||||
PublicKey publicKey = mock(PublicKey.class);
|
||||
|
||||
when(signingCert.getPublicKey()).thenReturn(publicKey);
|
||||
|
||||
// When verifying the certificate with the signing cert's public key, throw exception
|
||||
// Need to use a specific exception that verify() can throw
|
||||
doThrow(new java.security.SignatureException("Verification failed"))
|
||||
.when(signedCert)
|
||||
.verify(Mockito.any());
|
||||
|
||||
// When validating certificate chain with custom cert
|
||||
boolean result =
|
||||
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
|
||||
|
||||
// Then validation should fail
|
||||
assertFalse(result, "Certificate chain with failed signing should not validate");
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -12,6 +12,7 @@ import org.springframework.core.annotation.Order;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.EnterpriseEdition;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.GoogleDrive;
|
||||
|
||||
@Configuration
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@@ -54,6 +55,19 @@ public class EEAppConfig {
|
||||
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
|
||||
}
|
||||
|
||||
@Profile("security")
|
||||
@Bean(name = "GoogleDriveEnabled")
|
||||
@Primary
|
||||
public boolean googleDriveEnabled() {
|
||||
return runningProOrHigher()
|
||||
&& applicationProperties.getPremium().getProFeatures().getGoogleDrive().isEnabled();
|
||||
}
|
||||
|
||||
@Bean(name = "GoogleDriveConfig")
|
||||
public GoogleDrive googleDriveConfig() {
|
||||
return applicationProperties.getPremium().getProFeatures().getGoogleDrive();
|
||||
}
|
||||
|
||||
// TODO: Remove post migration
|
||||
@SuppressWarnings("deprecation")
|
||||
public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) {
|
||||
|
||||
+1
-8
@@ -50,14 +50,7 @@ docker-compose -f docker/compose/docker-compose.fat.yml up --build
|
||||
- **Custom Ports**: Modify port mappings in docker-compose files
|
||||
- **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat)
|
||||
|
||||
### [Google Drive Integration](https://developers.google.com/workspace/drive/picker/guides/overview)
|
||||
|
||||
- **VITE_GOOGLE_DRIVE_CLIENT_ID**: [OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients/create)
|
||||
- **VITE_GOOGLE_DRIVE_API_KEY**: [Create New API](https://console.cloud.google.com/apis)
|
||||
- **VITE_GOOGLE_DRIVE_APP_ID**: This is your [project number](https://console.cloud.google.com/iam-admin/settings) in the GoogleCloud Settings
|
||||
|
||||
## Development vs Production
|
||||
|
||||
- **Development**: Keep backend port 8080 exposed for debugging
|
||||
- **Production**: Remove backend port exposure, use only frontend proxy
|
||||
|
||||
- **Production**: Remove backend port exposure, use only frontend proxy
|
||||
@@ -47,9 +47,6 @@ services:
|
||||
- "3000:80"
|
||||
environment:
|
||||
BACKEND_URL: http://backend:8080
|
||||
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
|
||||
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
|
||||
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@@ -44,9 +44,6 @@ services:
|
||||
- "3000:80"
|
||||
environment:
|
||||
BACKEND_URL: http://backend:8080
|
||||
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
|
||||
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
|
||||
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@@ -46,9 +46,6 @@ services:
|
||||
- "3000:80"
|
||||
environment:
|
||||
BACKEND_URL: http://backend:8080
|
||||
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
|
||||
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
|
||||
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
@@ -6,11 +6,6 @@ http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Add .mjs MIME type mapping
|
||||
types {
|
||||
text/javascript mjs;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -95,14 +90,6 @@ http {
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
# Serve .mjs files with correct MIME type (must come before general static assets)
|
||||
location ~* \.mjs$ {
|
||||
try_files $uri =404;
|
||||
add_header Content-Type "text/javascript; charset=utf-8" always;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import globals from "globals";
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const srcGlobs = [
|
||||
'src/**/*.{js,mjs,jsx,ts,tsx}',
|
||||
];
|
||||
const nodeGlobs = [
|
||||
'scripts/**/*.{js,ts,mjs}',
|
||||
'*.config.{js,ts,mjs}',
|
||||
];
|
||||
|
||||
export default defineConfig(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
@@ -24,6 +15,7 @@ export default defineConfig(
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
"no-undef": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
@@ -46,23 +38,5 @@ export default defineConfig(
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// Config for browser scripts
|
||||
{
|
||||
files: srcGlobs,
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
}
|
||||
}
|
||||
},
|
||||
// Config for node scripts
|
||||
{
|
||||
files: nodeGlobs,
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Generated
+115
-291
@@ -10,24 +10,21 @@
|
||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@embedpdf/core": "^1.3.14",
|
||||
"@embedpdf/engines": "^1.3.14",
|
||||
"@embedpdf/plugin-annotation": "^1.3.14",
|
||||
"@embedpdf/plugin-export": "^1.3.14",
|
||||
"@embedpdf/plugin-history": "^1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.14",
|
||||
"@embedpdf/plugin-loader": "^1.3.14",
|
||||
"@embedpdf/plugin-pan": "^1.3.14",
|
||||
"@embedpdf/plugin-render": "^1.3.14",
|
||||
"@embedpdf/plugin-rotate": "^1.3.14",
|
||||
"@embedpdf/plugin-scroll": "^1.3.14",
|
||||
"@embedpdf/plugin-search": "^1.3.14",
|
||||
"@embedpdf/plugin-selection": "^1.3.14",
|
||||
"@embedpdf/plugin-spread": "^1.3.14",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.14",
|
||||
"@embedpdf/plugin-tiling": "^1.3.14",
|
||||
"@embedpdf/plugin-viewport": "^1.3.14",
|
||||
"@embedpdf/plugin-zoom": "^1.3.14",
|
||||
"@embedpdf/core": "^1.2.1",
|
||||
"@embedpdf/engines": "^1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.2.1",
|
||||
"@embedpdf/plugin-loader": "^1.2.1",
|
||||
"@embedpdf/plugin-pan": "^1.2.1",
|
||||
"@embedpdf/plugin-render": "^1.2.1",
|
||||
"@embedpdf/plugin-rotate": "^1.2.1",
|
||||
"@embedpdf/plugin-scroll": "^1.2.1",
|
||||
"@embedpdf/plugin-search": "^1.2.1",
|
||||
"@embedpdf/plugin-selection": "^1.2.1",
|
||||
"@embedpdf/plugin-spread": "^1.2.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.2.1",
|
||||
"@embedpdf/plugin-tiling": "^1.2.1",
|
||||
"@embedpdf/plugin-viewport": "^1.2.1",
|
||||
"@embedpdf/plugin-zoom": "^1.2.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -41,7 +38,6 @@
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"globals": "^16.4.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
@@ -54,7 +50,6 @@
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"signature_pad": "^5.0.4",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"web-vitals": "^5.1.0"
|
||||
},
|
||||
@@ -67,10 +62,6 @@
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/gapi": "^0.0.47",
|
||||
"@types/gapi.client.drive-v3": "^0.0.5",
|
||||
"@types/google.accounts": "^0.0.18",
|
||||
"@types/google.picker": "^0.0.51",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
@@ -497,13 +488,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/core": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.14.tgz",
|
||||
"integrity": "sha512-lE/vfhA53CxamaCfGWEibrEPr+JeZT42QCF+cOELUwv4+Zt6b+IE6+4wsznx/8wjjJYwllXJ3GJ/un1UzTqARw==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.2.1.tgz",
|
||||
"integrity": "sha512-2VwRPsN3+LmaBrD8TCN1t1ni/Vc9CxAfl/SApDjZYwE7zOieQT4ZHt+nkgF0F4I3xSgvvyHDjmOonhjBIrT6xA==",
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "1.3.14",
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/engines": "1.2.1",
|
||||
"@embedpdf/models": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -513,13 +503,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/engines": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.3.14.tgz",
|
||||
"integrity": "sha512-+/FPW2gAzj2lQYvsMH/Oj9+MEXgkyEuyYDC+HFkltTuXvmiP2S/3BD0YslZDX9K4BzcmMxnWB+BiQpNJokbDVg==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.2.1.tgz",
|
||||
"integrity": "sha512-nhycZ7Buq2B34dcpo6n7RdFwdhwTvKzvnRy7QX+uU00Dz5vftkCG4OK+pBVzxE4y7vAu+Yb4wNpdc7HmIj3B6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14",
|
||||
"@embedpdf/pdfium": "1.3.14"
|
||||
"@embedpdf/models": "1.2.1",
|
||||
"@embedpdf/pdfium": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -529,79 +519,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/models": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.3.14.tgz",
|
||||
"integrity": "sha512-BujY4bmr8b2DQdoZkOge03SzoRVoWxzfIQATLSPPtp4WiFh1U4BPp6cADlGuCwGkp6zBcH/aM4h8PwwA75d/eg==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.2.1.tgz",
|
||||
"integrity": "sha512-FzJU51jsqihfgt50B00FEpgyym87/Dn2iGmMq4++Vu/oO6qBx/y69m4/cCAh4p4KkTJsvKNWC7T7dwSKa0FjHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/pdfium": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.3.14.tgz",
|
||||
"integrity": "sha512-TQMZabXzHmzvvfPwopubFcYgQuYV7POvMgjICYu3Pgfn3sgr+UdIUh3aNXR/COcl3q8sXPMFQ2GDuyOHR9QQnA==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.2.1.tgz",
|
||||
"integrity": "sha512-QWf1jg7EqUlku2q6KYhlXCNfk5IAykFerPuzKJepHTeAEaRcAfu84fJgEsoUTCK4D6dfzVNp2Iuxw6Kv7MpSeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-annotation": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.3.14.tgz",
|
||||
"integrity": "sha512-JJYqEWwUKCdBZsXCDq/CW96p3pVLn8N+XZ4W3OyL7djI2fvYC9x6ys9m82vwlSathAVOxk1D7xXiY8AzJQVF0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14",
|
||||
"@embedpdf/utils": "1.3.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-history": "1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.14",
|
||||
"@embedpdf/plugin-selection": "1.3.14",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-export": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.14.tgz",
|
||||
"integrity": "sha512-fMGp2YxvI4uTRIViUKxfnJts2Jw/vktEM45XUNGNSjT/kAW6znVNgdceYjpK++xU8CGs2grAQ1i5UvMd3aRNDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-history": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.14.tgz",
|
||||
"integrity": "sha512-77hnNLp0W0FHw8lT7SeqzCgp8bOClfeOAPZdcInu/jPDhVASUGYbtE/0fkLhiaqPH7kyMirNCLif4sF6n4b5vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-interaction-manager": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.14.tgz",
|
||||
"integrity": "sha512-nR0ZxNoTQtGqOHhweFh6QJ+nUJ4S4Ag1wWur6vAUAi8U95HUOfZhOEa0polZo0zR9WmmblGqRWjFM+mVSOoi1w==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.2.1.tgz",
|
||||
"integrity": "sha512-HhEBuDjDNMH6wu76Eo3yHwjG01U1lNZShkOsFoib/rtx8HByTgZS8iVpovaOprr6gfS04ZLqWcsN1nt5qAH90w==",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -609,15 +546,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-loader": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.14.tgz",
|
||||
"integrity": "sha512-KoJX1MacEWE2DrO1OeZeG/Ehz76//u+ida/xb4r9BfwqAp5TfYlksq09cOvcF8LMW5FY4pbAL+AHKI1Hjz+HNA==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.2.1.tgz",
|
||||
"integrity": "sha512-VblKErfEiHcVao18TfCmc0UJlKAkqxE29DaLJrXQHGUw/qc+pC9HlvMVpDz3+Eb13UafYS6ZUZuEng2/fQ+JJw==",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -625,17 +561,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-pan": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.3.14.tgz",
|
||||
"integrity": "sha512-7EG+I5nn8yDCV8pT4x/g5mv7zJli2t3wPrh6Kt8uIpUorPHNb6J0Z67gl0uc/8rEasNzuKOuT0er46Y6/UYLzQ==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.2.1.tgz",
|
||||
"integrity": "sha512-/BTOyRl31tvnCmoLs4qNPROMRLaG34jGYNyMQquB0uPUXZjwdMloikriwos91qCOLUrhvs4SaDpC3Ghv2BO5kA==",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.14",
|
||||
"@embedpdf/plugin-viewport": "1.3.14",
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -643,15 +578,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-render": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.14.tgz",
|
||||
"integrity": "sha512-IPj7GCQXJBsY++JaU+z7y+FwX5NaDBj4YYV6hsHNtSGf42Y1AdlwJzDYetivG2bA84xmk7KgD1X2Y3eIFBhjwA==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.2.1.tgz",
|
||||
"integrity": "sha512-iMfuVJqttJmm7Zb8oOaqNVNrC3NS57bDNNAc4MIc2f2TxIFSznvBPlwWN+PN45qNcTQiGzFc1ZMqIQDOG4qFnQ==",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -659,15 +593,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-rotate": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.3.14.tgz",
|
||||
"integrity": "sha512-OroEm11x/fPPXI9C0X+nm9LOjwaI0MvsToZRH+HpV60/FbQeOJvt6D8wThCDVLK95Na6A+JeYIMEu+Hiix7H+A==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.2.1.tgz",
|
||||
"integrity": "sha512-UhHds5donLDXm3i9nKrhSmo3yawVtjb6gID0MDrhj3+Lci/YQ3wDvGUhk7dNmgLcOt7G8pMa0wesnnpVWirUXA==",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -675,16 +608,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-scroll": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.14.tgz",
|
||||
"integrity": "sha512-fQbt7OlRMLQJMuZj/Bzh0qpRxMw1ld5Qe/OTw8N54b/plljnFA52joE7cITl3H03huWWyHS3NKOScbw7f34dog==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.2.1.tgz",
|
||||
"integrity": "sha512-I1haDXIOzs59uhOWEP6UvP5jzjcQHMLQuQbfRVJM0zdWU6t3jwSfcwPUI7iv4CAAepbuyJKL328yc8736r/FYw==",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-viewport": "1.3.14",
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -692,16 +624,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-search": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.3.14.tgz",
|
||||
"integrity": "sha512-tlZEgR2tG+GSNnh2u1SjCxhUHfTDgcr38sE/xRK1bRLDGPZWlr6Ln7qP7JSWqeYBGni75sGrj0iZqcZbPWyJag==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.2.1.tgz",
|
||||
"integrity": "sha512-sl9FBQzbOBtdmPpf6UI0bnWCTPWDkj47rTxyK07bpnGfGuFof4zhcxmMaFdyP7zqBh4Y9XqGu4A0uMTO2d/t7g==",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-loader": "1.3.14",
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-loader": "1.2.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -709,17 +640,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-selection": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.14.tgz",
|
||||
"integrity": "sha512-EXENuaAsse3rT6cjA1nYzyrNvoy62ojJl28wblCng6zcs3HSlGPemIQZAvaYKPUxoY608M+6nKlcMQ5neRnk/A==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.2.1.tgz",
|
||||
"integrity": "sha512-wgG1X1sl6sed3pv7WLIO74SX0x3389/ax+/OLMty/LFbDNYMRO+n8ZQss8aUM700HARIqkPJy7UoSQt91o4nwA==",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.14",
|
||||
"@embedpdf/plugin-viewport": "1.3.14",
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -727,16 +657,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-spread": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.3.14.tgz",
|
||||
"integrity": "sha512-DVlk6tDgUoDRkp2S4Jc3LrRTuf4DPMlph9vywJw5z6Qpbh0vgcMnObg896/S0Eu5FgACNAj0WGcXpLrcrn5b9Q==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.2.1.tgz",
|
||||
"integrity": "sha512-rpadnutT1wSdBQV7RQz40zYdKgCRgmJde/tamgB8oHQypcnZGQcAG6/ZfX5j12s9pG18hKwwL+KmMoBnXD9IjQ==",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-loader": "1.3.14",
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-loader": "1.2.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -744,35 +673,32 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-thumbnail": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.3.14.tgz",
|
||||
"integrity": "sha512-cnwb5dG8Jph8XSArys1WFCQ6kK2R5FKoO0B5mDrHFv9Fcm2pKszlmZC/NDoskX4pgNUgSnwhI1X3cP37ebF9Ng==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.2.1.tgz",
|
||||
"integrity": "sha512-TjHPkK8p3+FDMLcUdb3/4VREjm+liVooufLPVZ3FCXHbiC0PeUkqnwAxpCS2Jw1n+EtkY8pefRdRJeZhO6plOQ==",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-render": "1.3.14",
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-render": "1.2.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"vue": ">=3.2.0"
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-tiling": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.3.14.tgz",
|
||||
"integrity": "sha512-SaCTo2LdZwGeE6jCqkwJxvwt8YKbsI3QGxa9S7Ez+5OcBchlhHeTfLQswcErDQ3WH2p8WHtGuucAcOLrVVOm0A==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.2.1.tgz",
|
||||
"integrity": "sha512-C9uOGVIsoxUw+uQMXfJFZ8ibRLQeNOnaKC2izjx967iGu0ZoecAv+mKtH/Ge0vMEKYM1109AlF5T2EGwKQW2YA==",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-render": "1.3.14",
|
||||
"@embedpdf/plugin-scroll": "1.3.14",
|
||||
"@embedpdf/plugin-viewport": "1.3.14",
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-render": "1.2.1",
|
||||
"@embedpdf/plugin-scroll": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -780,15 +706,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-viewport": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.14.tgz",
|
||||
"integrity": "sha512-mfJ7EbbU68eKk6oFvQ4ozGJNpxUxWbjQ5Gm3uuB+Gj5/tWgBocBOX36k/9LgivEEeX7g2S0tOgyErljApmH8Vg==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.2.1.tgz",
|
||||
"integrity": "sha512-yvftOis7FLBjM3w2VYO5LXVKXoHkmFV/SPy7U6SbuLJTX126F4ohSij9euMHJjaqOgr5tBNvrf4xemVRglxM9w==",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -796,31 +721,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-zoom": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.3.14.tgz",
|
||||
"integrity": "sha512-/N5tyMk+8OzhObrS3O9yPkcmX8EPiuTo+WaT2QCVSmIUqKnOO4AnKpHJ6Vl0uVhcuXHCMwLucZKyhJ7tRqavwg==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.2.1.tgz",
|
||||
"integrity": "sha512-hsp/nM4C8q0FM9P6FkpQLbU8IYawUgmiYgD3HXqHWBVRk30OIaXs4N0KC9vsHwn8ZAiyLl7jhlAXpgoacH5xEQ==",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14",
|
||||
"@embedpdf/models": "1.2.1",
|
||||
"hammerjs": "^2.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.14",
|
||||
"@embedpdf/plugin-scroll": "1.3.14",
|
||||
"@embedpdf/plugin-viewport": "1.3.14",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/utils": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.3.14.tgz",
|
||||
"integrity": "sha512-gxEJD12nageCMqAjdbicNfDQolXU3nvnV0EX96OdZITRNj0Q1tisutVYoaxcCiJu3vvIEOzipjsAnQOubbFCEA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.2.1",
|
||||
"@embedpdf/plugin-scroll": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -1767,19 +1679,6 @@
|
||||
"mlly": "^1.7.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/utils/node_modules/globals": {
|
||||
"version": "15.15.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
|
||||
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -2015,28 +1914,6 @@
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@maxim_mazurok/gapi.client.discovery-v1": {
|
||||
"version": "0.4.20200806",
|
||||
"resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.4.20200806.tgz",
|
||||
"integrity": "sha512-Jeo/KZqK39DI6ExXHcJ4lqnn1O/wEqboQ6eQ8WnNpu5eJ7wUnX/C5KazOgs1aRhnIB/dVzDe8wm62nmtkMIoaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/gapi.client": "*",
|
||||
"@types/gapi.client.discovery-v1": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@maxim_mazurok/gapi.client.drive-v3": {
|
||||
"version": "0.1.20250930",
|
||||
"resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.drive-v3/-/gapi.client.drive-v3-0.1.20250930.tgz",
|
||||
"integrity": "sha512-zNR7HtaFl2Pvf8Ck2zP8cppUst7ouY2isKn7hrGf6hQ4/0ULsu19qMRSQgRb0HxBYcGjak7kGK4pZI4a2z4CWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/gapi.client": "*",
|
||||
"@types/gapi.client.discovery-v1": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/core-downloads-tracker": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz",
|
||||
@@ -3623,54 +3500,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/gapi": {
|
||||
"version": "0.0.47",
|
||||
"resolved": "https://registry.npmjs.org/@types/gapi/-/gapi-0.0.47.tgz",
|
||||
"integrity": "sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/gapi.client": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/gapi.client/-/gapi.client-1.0.8.tgz",
|
||||
"integrity": "sha512-qJQUmmumbYym3Amax0S8CVzuSngcXsC1fJdwRS2zeW5lM63zXkw4wJFP+bG0jzgi0R6EsJKoHnGNVTDbOyG1ng==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/gapi.client.discovery-v1": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.0.4.tgz",
|
||||
"integrity": "sha512-uevhRumNE65F5mf2gABLaReOmbFSXONuzFZjNR3dYv6BmkHg+wciubHrfBAsp3554zNo3Dcg6dUAlwMqQfpwjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@maxim_mazurok/gapi.client.discovery-v1": "latest"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/gapi.client.drive-v3": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/gapi.client.drive-v3/-/gapi.client.drive-v3-0.0.5.tgz",
|
||||
"integrity": "sha512-yYBxiqMqJVBg4bns4Q28+f2XdJnd3tVA9dxQX1lXMVmzT2B+pZdyCi1u9HLwGveVlookSsAXuqfLfS9KO6MF6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@maxim_mazurok/gapi.client.drive-v3": "latest"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/google.accounts": {
|
||||
"version": "0.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/google.accounts/-/google.accounts-0.0.18.tgz",
|
||||
"integrity": "sha512-yHaPznll97ZnMJlPABHyeiIlLn3u6gQaUjA5k/O9lrrpgFB9VT10CKPLuKM0qTHMl50uXpW5sIcG+utm8jMOHw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/google.picker": {
|
||||
"version": "0.0.51",
|
||||
"resolved": "https://registry.npmjs.org/@types/google.picker/-/google.picker-0.0.51.tgz",
|
||||
"integrity": "sha512-z6o2J4PQTcXvlW1rtgQx65d5uEF+rMI1hzrnazKQxBONdEuYAr4AeOSH2KZy12WHPmqMX+aWYyfcZ0uktBBhhA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-cache-semantics": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
|
||||
@@ -6505,9 +6334,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
|
||||
"integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
|
||||
"version": "15.15.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
|
||||
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -9994,12 +9824,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signature_pad": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-5.1.1.tgz",
|
||||
"integrity": "sha512-BT5JJygS5BS0oV+tffPRorIud6q17bM7v/1LdQwd0o6mTqGoI25yY1NjSL99OqkekWltS4uon6p52Y8j1Zqu7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
|
||||
|
||||
+15
-24
@@ -6,24 +6,21 @@
|
||||
"proxy": "http://localhost:8080",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@embedpdf/core": "^1.3.14",
|
||||
"@embedpdf/engines": "^1.3.14",
|
||||
"@embedpdf/plugin-annotation": "^1.3.14",
|
||||
"@embedpdf/plugin-export": "^1.3.14",
|
||||
"@embedpdf/plugin-history": "^1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.14",
|
||||
"@embedpdf/plugin-loader": "^1.3.14",
|
||||
"@embedpdf/plugin-pan": "^1.3.14",
|
||||
"@embedpdf/plugin-render": "^1.3.14",
|
||||
"@embedpdf/plugin-rotate": "^1.3.14",
|
||||
"@embedpdf/plugin-scroll": "^1.3.14",
|
||||
"@embedpdf/plugin-search": "^1.3.14",
|
||||
"@embedpdf/plugin-selection": "^1.3.14",
|
||||
"@embedpdf/plugin-spread": "^1.3.14",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.14",
|
||||
"@embedpdf/plugin-tiling": "^1.3.14",
|
||||
"@embedpdf/plugin-viewport": "^1.3.14",
|
||||
"@embedpdf/plugin-zoom": "^1.3.14",
|
||||
"@embedpdf/core": "^1.2.1",
|
||||
"@embedpdf/engines": "^1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.2.1",
|
||||
"@embedpdf/plugin-loader": "^1.2.1",
|
||||
"@embedpdf/plugin-pan": "^1.2.1",
|
||||
"@embedpdf/plugin-render": "^1.2.1",
|
||||
"@embedpdf/plugin-rotate": "^1.2.1",
|
||||
"@embedpdf/plugin-scroll": "^1.2.1",
|
||||
"@embedpdf/plugin-search": "^1.2.1",
|
||||
"@embedpdf/plugin-selection": "^1.2.1",
|
||||
"@embedpdf/plugin-spread": "^1.2.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.2.1",
|
||||
"@embedpdf/plugin-tiling": "^1.2.1",
|
||||
"@embedpdf/plugin-viewport": "^1.2.1",
|
||||
"@embedpdf/plugin-zoom": "^1.2.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -37,7 +34,6 @@
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"globals": "^16.4.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
@@ -50,7 +46,6 @@
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"signature_pad": "^5.0.4",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"web-vitals": "^5.1.0"
|
||||
},
|
||||
@@ -106,10 +101,6 @@
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/gapi": "^0.0.47",
|
||||
"@types/gapi.client.drive-v3": "^0.0.5",
|
||||
"@types/google.accounts": "^0.0.18",
|
||||
"@types/google.picker": "^0.0.51",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -87,10 +87,7 @@
|
||||
"showStack": "Stack-Trace anzeigen",
|
||||
"copyStack": "Stack-Trace kopieren",
|
||||
"githubSubmit": "GitHub - Ein Ticket einreichen",
|
||||
"discordSubmit": "Discord - Unterstützungsbeitrag einreichen",
|
||||
"dismissAllErrors": "Alle Fehler ausblenden",
|
||||
"encryptedPdfMustRemovePassword": "Diese PDF ist verschlüsselt oder passwortgeschützt. Bitte entsperren Sie sie, bevor Sie in PDF/A konvertieren.",
|
||||
"incorrectPasswordProvided": "Das PDF-Passwort ist falsch oder wurde nicht angegeben."
|
||||
"discordSubmit": "Discord - Unterstützungsbeitrag einreichen"
|
||||
},
|
||||
"warning": {
|
||||
"tooltipTitle": "Warnung"
|
||||
@@ -361,223 +358,179 @@
|
||||
"sortBy": "Sortieren nach:",
|
||||
"multiTool": {
|
||||
"title": "PDF-Multitool",
|
||||
"desc": "Seiten zusammenführen, drehen, neu anordnen und entfernen",
|
||||
"tags": "mehrere,werkzeuge"
|
||||
"desc": "Seiten zusammenführen, drehen, neu anordnen und entfernen"
|
||||
},
|
||||
"merge": {
|
||||
"title": "Zusammenführen",
|
||||
"desc": "Mehrere PDF-Dateien zu einer einzigen zusammenführen",
|
||||
"tags": "kombinieren,zusammenführen,vereinen"
|
||||
"desc": "Mehrere PDF-Dateien zu einer einzigen zusammenführen"
|
||||
},
|
||||
"split": {
|
||||
"title": "Aufteilen",
|
||||
"desc": "PDFs in mehrere Dokumente aufteilen",
|
||||
"tags": "teilen,trennen,aufteilen"
|
||||
"desc": "PDFs in mehrere Dokumente aufteilen"
|
||||
},
|
||||
"rotate": {
|
||||
"title": "Drehen",
|
||||
"desc": "Drehen Sie Ihre PDFs ganz einfach",
|
||||
"tags": "drehen,spiegeln,ausrichten"
|
||||
"desc": "Drehen Sie Ihre PDFs ganz einfach"
|
||||
},
|
||||
"convert": {
|
||||
"title": "Umwandeln",
|
||||
"desc": "Dateien zwischen verschiedenen Formaten konvertieren",
|
||||
"tags": "umwandeln,ändern"
|
||||
"desc": "Dateien zwischen verschiedenen Formaten konvertieren"
|
||||
},
|
||||
"pdfOrganiser": {
|
||||
"title": "Organisieren",
|
||||
"desc": "Seiten entfernen und Seitenreihenfolge ändern",
|
||||
"tags": "organisieren,umordnen,neu anordnen"
|
||||
"desc": "Seiten entfernen und Seitenreihenfolge ändern"
|
||||
},
|
||||
"addImage": {
|
||||
"title": "Bild einfügen",
|
||||
"desc": "Fügt ein Bild an eine bestimmte Stelle im PDF ein (in Arbeit)",
|
||||
"tags": "einfügen,einbetten,platzieren"
|
||||
"desc": "Fügt ein Bild an eine bestimmte Stelle im PDF ein (in Arbeit)"
|
||||
},
|
||||
"addAttachments": {
|
||||
"title": "Anhänge hinzufügen",
|
||||
"desc": "Eingebettete Dateien (Anhänge) zu einer PDF hinzufügen oder entfernen",
|
||||
"tags": "einbetten,anhängen,einfügen"
|
||||
"desc": "Eingebettete Dateien (Anhänge) zu einer PDF hinzufügen oder entfernen"
|
||||
},
|
||||
"watermark": {
|
||||
"title": "Wasserzeichen hinzufügen",
|
||||
"desc": "Fügen Sie ein eigenes Wasserzeichen zu Ihrem PDF hinzu",
|
||||
"tags": "stempel,markierung,überlagerung"
|
||||
"desc": "Fügen Sie ein eigenes Wasserzeichen zu Ihrem PDF hinzu"
|
||||
},
|
||||
"removePassword": {
|
||||
"title": "Passwort entfernen",
|
||||
"desc": "Den Passwortschutz eines PDFs entfernen",
|
||||
"tags": "entsperren"
|
||||
"desc": "Den Passwortschutz eines PDFs entfernen"
|
||||
},
|
||||
"compress": {
|
||||
"title": "Komprimieren",
|
||||
"desc": "PDF komprimieren um die Dateigröße zu reduzieren",
|
||||
"tags": "verkleinern,reduzieren,optimieren"
|
||||
"desc": "PDF komprimieren um die Dateigröße zu reduzieren"
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"title": "Schreibgeschützte PDF-Formfelder entfernen",
|
||||
"desc": "Entfernen Sie die schreibgeschützte Eigenschaft von Formularfeldern in einem PDF-Dokument.",
|
||||
"tags": "entsperren,aktivieren,bearbeiten"
|
||||
"desc": "Entfernen Sie die schreibgeschützte Eigenschaft von Formularfeldern in einem PDF-Dokument."
|
||||
},
|
||||
"changeMetadata": {
|
||||
"title": "Metadaten ändern",
|
||||
"desc": "Ändern/Entfernen/Hinzufügen von Metadaten aus einem PDF-Dokument",
|
||||
"tags": "bearbeiten,ändern,aktualisieren"
|
||||
"desc": "Ändern/Entfernen/Hinzufügen von Metadaten aus einem PDF-Dokument"
|
||||
},
|
||||
"ocr": {
|
||||
"title": "Führe OCR/Cleanup-Scans aus",
|
||||
"desc": "Cleanup scannt und erkennt Text aus Bildern in einer PDF-Datei und fügt ihn erneut als Text hinzu",
|
||||
"tags": "extrahieren,scannen"
|
||||
"desc": "Cleanup scannt und erkennt Text aus Bildern in einer PDF-Datei und fügt ihn erneut als Text hinzu"
|
||||
},
|
||||
"extractImages": {
|
||||
"title": "Bilder extrahieren",
|
||||
"desc": "Extrahiert alle Bilder aus einer PDF-Datei und speichert sie als Zip-Archiv",
|
||||
"tags": "extrahieren,speichern,exportieren"
|
||||
"desc": "Extrahiert alle Bilder aus einer PDF-Datei und speichert sie als Zip-Archiv"
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"title": "Gescannte Fotos erkennen/aufteilen",
|
||||
"desc": "Teilt mehrere Fotos aus einem Foto/PDF auf",
|
||||
"tags": "erkennen,teilen,fotos"
|
||||
"desc": "Teilt mehrere Fotos aus einem Foto/PDF auf"
|
||||
},
|
||||
"sign": {
|
||||
"title": "Signieren",
|
||||
"desc": "Fügt PDF-Signaturen durch Zeichnung, Text oder Bild hinzu",
|
||||
"tags": "unterschrift,autogramm"
|
||||
"desc": "Fügt PDF-Signaturen durch Zeichnung, Text oder Bild hinzu"
|
||||
},
|
||||
"flatten": {
|
||||
"title": "Abflachen",
|
||||
"desc": "Alle interaktiven Elemente und Formulare aus einem PDF entfernen",
|
||||
"tags": "vereinfachen,entfernen,interaktiv"
|
||||
"desc": "Alle interaktiven Elemente und Formulare aus einem PDF entfernen"
|
||||
},
|
||||
"certSign": {
|
||||
"title": "Mit Zertifikat signieren",
|
||||
"desc": "Ein PDF mit einem Zertifikat/Schlüssel (PEM/P12) signieren",
|
||||
"tags": "authentifizieren,PEM,P12,offiziell,verschlüsseln,signieren,zertifikat,PKCS12,JKS,server,manuell,auto"
|
||||
"desc": "Ein PDF mit einem Zertifikat/Schlüssel (PEM/P12) signieren"
|
||||
},
|
||||
"repair": {
|
||||
"title": "Reparatur",
|
||||
"desc": "Versucht, ein beschädigtes/kaputtes PDF zu reparieren",
|
||||
"tags": "reparieren,wiederherstellen"
|
||||
"desc": "Versucht, ein beschädigtes/kaputtes PDF zu reparieren"
|
||||
},
|
||||
"removeBlanks": {
|
||||
"title": "Leere Seiten entfernen",
|
||||
"desc": "Erkennt und entfernt leere Seiten aus einem Dokument",
|
||||
"tags": "löschen,bereinigen,leer"
|
||||
"desc": "Erkennt und entfernt leere Seiten aus einem Dokument"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"title": "Anmerkungen entfernen",
|
||||
"desc": "Entfernt alle Kommentare/Anmerkungen aus einem PDF",
|
||||
"tags": "löschen,bereinigen,entfernen"
|
||||
"desc": "Entfernt alle Kommentare/Anmerkungen aus einem PDF"
|
||||
},
|
||||
"compare": {
|
||||
"title": "Vergleichen",
|
||||
"desc": "Vergleicht und zeigt die Unterschiede zwischen zwei PDF-Dokumenten an",
|
||||
"tags": "unterschied"
|
||||
"desc": "Vergleicht und zeigt die Unterschiede zwischen zwei PDF-Dokumenten an"
|
||||
},
|
||||
"removeCertSign": {
|
||||
"title": "Zertifikatsignatur entfernen",
|
||||
"desc": "Zertifikatsignatur aus PDF entfernen",
|
||||
"tags": "entfernen,löschen,entsperren"
|
||||
"desc": "Zertifikatsignatur aus PDF entfernen"
|
||||
},
|
||||
"pageLayout": {
|
||||
"title": "Mehrseitiges Layout",
|
||||
"desc": "Mehrere Seiten eines PDF zu einer Seite zusammenführen",
|
||||
"tags": "layout,anordnen,kombinieren"
|
||||
"desc": "Mehrere Seiten eines PDF zu einer Seite zusammenführen"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"title": "Broschüren-Layout",
|
||||
"desc": "Broschüren mit korrekter Seitenreihenfolge und mehrseitigem Layout für Druck und Bindung erstellen",
|
||||
"tags": "broschüre,druck,bindung"
|
||||
"desc": "Broschüren mit korrekter Seitenreihenfolge und mehrseitigem Layout für Druck und Bindung erstellen"
|
||||
},
|
||||
"scalePages": {
|
||||
"title": "Seitengröße/Skalierung anpassen",
|
||||
"desc": "Größe/Skalierung der Seite und/oder des Inhalts ändern",
|
||||
"tags": "größe ändern,anpassen,skalieren"
|
||||
"desc": "Größe/Skalierung der Seite und/oder des Inhalts ändern"
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"title": "Seitenzahlen hinzufügen",
|
||||
"desc": "Hinzufügen von Seitenzahlen an einer bestimmten Stelle",
|
||||
"tags": "nummerieren,paginierung,zählen"
|
||||
"desc": "Hinzufügen von Seitenzahlen an einer bestimmten Stelle"
|
||||
},
|
||||
"autoRename": {
|
||||
"title": "PDF-Datei automatisch umbenennen",
|
||||
"desc": "Benennt eine PDF-Datei automatisch basierend auf der erkannten Überschrift um",
|
||||
"tags": "auto-erkennung,kopfzeilen-basiert,organisieren,umbenennen"
|
||||
"desc": "Benennt eine PDF-Datei automatisch basierend auf der erkannten Überschrift um"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Farben/Kontrast anpassen",
|
||||
"desc": "Kontrast, Sättigung und Helligkeit einer PDF anpassen",
|
||||
"tags": "kontrast,helligkeit,sättigung"
|
||||
"desc": "Kontrast, Sättigung und Helligkeit einer PDF anpassen"
|
||||
},
|
||||
"crop": {
|
||||
"title": "PDF zuschneiden",
|
||||
"desc": "PDF zuschneiden um die Größe zu verändern (Text bleibt erhalten!)",
|
||||
"tags": "zuschneiden,schneiden,größe ändern"
|
||||
"desc": "PDF zuschneiden um die Größe zu verändern (Text bleibt erhalten!)"
|
||||
},
|
||||
"autoSplitPDF": {
|
||||
"title": "PDF automatisch teilen",
|
||||
"desc": "Physisch gescannte PDF anhand von Splitter-Seiten und QR-Codes aufteilen",
|
||||
"tags": "auto,teilen,QR"
|
||||
"desc": "Physisch gescannte PDF anhand von Splitter-Seiten und QR-Codes aufteilen"
|
||||
},
|
||||
"sanitize": {
|
||||
"title": "Bereinigen",
|
||||
"desc": "Potentiell schädliche Elemente aus PDF-Dateien entfernen",
|
||||
"tags": "bereinigen,löschen,entfernen"
|
||||
"desc": "Potentiell schädliche Elemente aus PDF-Dateien entfernen"
|
||||
},
|
||||
"getPdfInfo": {
|
||||
"title": "Alle Informationen anzeigen",
|
||||
"desc": "Erfasst alle möglichen Informationen in einer PDF",
|
||||
"tags": "info,metadaten,details"
|
||||
"desc": "Erfasst alle möglichen Informationen in einer PDF"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"title": "PDF zu einer Seite zusammenfassen",
|
||||
"desc": "Fügt alle PDF-Seiten zu einer einzigen großen Seite zusammen",
|
||||
"tags": "kombinieren,zusammenführen,einzeln"
|
||||
"desc": "Fügt alle PDF-Seiten zu einer einzigen großen Seite zusammen"
|
||||
},
|
||||
"showJS": {
|
||||
"title": "Javascript anzeigen",
|
||||
"desc": "Alle Javascript Funktionen in einer PDF anzeigen",
|
||||
"tags": "javascript,code,skript"
|
||||
"desc": "Alle Javascript Funktionen in einer PDF anzeigen"
|
||||
},
|
||||
"redact": {
|
||||
"title": "Manuell zensieren/schwärzen",
|
||||
"desc": "Zensiere (Schwärze) eine PDF-Datei durch Auswählen von Text, gezeichneten Formen und/oder ausgewählten Seite(n)",
|
||||
"tags": "zensieren,schwärzen,verbergen"
|
||||
"desc": "Zensiere (Schwärze) eine PDF-Datei durch Auswählen von Text, gezeichneten Formen und/oder ausgewählten Seite(n)"
|
||||
},
|
||||
"overlayPdfs": {
|
||||
"title": "PDFs überlagern",
|
||||
"desc": "PDFs über eine andere PDF überlagern",
|
||||
"tags": "überlagern,kombinieren,stapeln"
|
||||
"desc": "PDFs über eine andere PDF überlagern"
|
||||
},
|
||||
"splitBySections": {
|
||||
"title": "PDF nach Abschnitten aufteilen",
|
||||
"desc": "Jede Seite einer PDF in kleinere horizontale und vertikale Abschnitte unterteilen",
|
||||
"tags": "teilen,abschnitte,aufteilen"
|
||||
"desc": "Jede Seite einer PDF in kleinere horizontale und vertikale Abschnitte unterteilen"
|
||||
},
|
||||
"addStamp": {
|
||||
"title": "Stempel zu PDF hinzufügen",
|
||||
"desc": "Text- oder Bildstempel an festgelegten Positionen hinzufügen",
|
||||
"tags": "stempel,markierung,siegel"
|
||||
"desc": "Text- oder Bildstempel an festgelegten Positionen hinzufügen"
|
||||
},
|
||||
"removeImage": {
|
||||
"title": "Bild entfernen",
|
||||
"desc": "Bild aus PDF entfernen, um die Dateigröße zu verringern",
|
||||
"tags": "entfernen,löschen,bereinigen"
|
||||
"desc": "Bild aus PDF entfernen, um die Dateigröße zu verringern"
|
||||
},
|
||||
"splitByChapters": {
|
||||
"title": "PDF-Datei nach Kapiteln aufteilen",
|
||||
"desc": "Aufteilung einer PDF-Datei in mehrere Dateien auf Basis der Kapitelstruktur.",
|
||||
"tags": "teilen,kapitel,struktur"
|
||||
"desc": "Aufteilung einer PDF-Datei in mehrere Dateien auf Basis der Kapitelstruktur."
|
||||
},
|
||||
"validateSignature": {
|
||||
"title": "PDF-Signatur überprüfen",
|
||||
"desc": "Digitale Signaturen und Zertifikate in PDF-Dokumenten überprüfen",
|
||||
"tags": "validieren,überprüfen,zertifikat"
|
||||
"desc": "Digitale Signaturen und Zertifikate in PDF-Dokumenten überprüfen"
|
||||
},
|
||||
"swagger": {
|
||||
"title": "API-Dokumentation",
|
||||
"desc": "API-Dokumentation anzeigen und Endpunkte testen",
|
||||
"tags": "API,dokumentation,test"
|
||||
"desc": "API-Dokumentation anzeigen und Endpunkte testen"
|
||||
},
|
||||
"fakeScan": {
|
||||
"title": "Scan simulieren",
|
||||
@@ -585,52 +538,42 @@
|
||||
},
|
||||
"editTableOfContents": {
|
||||
"title": "Inhaltsverzeichnis bearbeiten",
|
||||
"desc": "Hinzufügen oder Bearbeiten von Lesezeichen und Inhaltsverzeichnissen in PDF-Dokumenten",
|
||||
"tags": "lesezeichen,inhalt,bearbeiten"
|
||||
"desc": "Hinzufügen oder Bearbeiten von Lesezeichen und Inhaltsverzeichnissen in PDF-Dokumenten"
|
||||
},
|
||||
"manageCertificates": {
|
||||
"title": "Zertifikate verwalten",
|
||||
"desc": "Digitale Zertifikatsdateien für die PDF-Signierung importieren, exportieren oder löschen.",
|
||||
"tags": "zertifikate,importieren,exportieren"
|
||||
"desc": "Digitale Zertifikatsdateien für die PDF-Signierung importieren, exportieren oder löschen."
|
||||
},
|
||||
"read": {
|
||||
"title": "Lesen",
|
||||
"desc": "PDFs anzeigen und kommentieren. Text hervorheben, zeichnen oder Kommentare für Überprüfung und Zusammenarbeit einfügen.",
|
||||
"tags": "anzeigen,öffnen,anzeigen"
|
||||
"desc": "PDFs anzeigen und kommentieren. Text hervorheben, zeichnen oder Kommentare für Überprüfung und Zusammenarbeit einfügen."
|
||||
},
|
||||
"reorganizePages": {
|
||||
"title": "Seiten neu anordnen",
|
||||
"desc": "PDF-Seiten mit visueller Drag-and-Drop-Steuerung neu anordnen, duplizieren oder löschen.",
|
||||
"tags": "umordnen,neu anordnen,organisieren"
|
||||
"desc": "PDF-Seiten mit visueller Drag-and-Drop-Steuerung neu anordnen, duplizieren oder löschen."
|
||||
},
|
||||
"extractPages": {
|
||||
"title": "Seiten extrahieren",
|
||||
"desc": "Spezifische Seiten aus einem PDF-Dokument extrahieren",
|
||||
"tags": "extrahieren,auswählen,kopieren"
|
||||
"desc": "Spezifische Seiten aus einem PDF-Dokument extrahieren"
|
||||
},
|
||||
"removePages": {
|
||||
"title": "Entfernen",
|
||||
"desc": "Ungewollte Seiten aus dem PDF entfernen",
|
||||
"tags": "löschen,extrahieren,ausschließen"
|
||||
"desc": "Ungewollte Seiten aus dem PDF entfernen"
|
||||
},
|
||||
"autoSizeSplitPDF": {
|
||||
"title": "Teilen nach Größe/Anzahl",
|
||||
"desc": "Teilen Sie ein einzelnes PDF basierend auf Größe, Seitenanzahl oder Dokumentanzahl in mehrere Dokumente auf",
|
||||
"tags": "auto,teilen,größe"
|
||||
"desc": "Teilen Sie ein einzelnes PDF basierend auf Größe, Seitenanzahl oder Dokumentanzahl in mehrere Dokumente auf"
|
||||
},
|
||||
"replaceColorPdf": {
|
||||
"title": "Farbe ersetzen und invertieren",
|
||||
"desc": "Ersetzen Sie die Farbe des Texts und Hintergrund der PDF-Datei und invertieren Sie die komplette Farbe der PDF-Datei, um die Dateigröße zu reduzieren"
|
||||
},
|
||||
"devApi": {
|
||||
"desc": "Link zur API-Dokumentation",
|
||||
"tags": "API,entwicklung,dokumentation",
|
||||
"title": "API"
|
||||
"desc": "Link zur API-Dokumentation"
|
||||
},
|
||||
"devFolderScanning": {
|
||||
"title": "Automatische Ordnerüberwachung",
|
||||
"desc": "Link zum Leitfaden für automatisches Ordner-Scannen",
|
||||
"tags": "automatisierung,ordner,scannen"
|
||||
"desc": "Link zum Leitfaden für automatisches Ordner-Scannen"
|
||||
},
|
||||
"devSsoGuide": {
|
||||
"title": "SSO-Anleitung",
|
||||
@@ -650,17 +593,7 @@
|
||||
},
|
||||
"automate": {
|
||||
"title": "Automatisieren",
|
||||
"desc": "Mehrstufige Arbeitsabläufe durch Verkettung von PDF-Aktionen erstellen. Ideal für wiederkehrende Aufgaben.",
|
||||
"tags": "arbeitsablauf,sequenz,automatisierung"
|
||||
},
|
||||
"replaceColor": {
|
||||
"desc": "Farben in PDF-Dokumenten ersetzen oder invertieren",
|
||||
"title": "Farbe ersetzen & invertieren"
|
||||
},
|
||||
"scannerEffect": {
|
||||
"desc": "Erstellen Sie eine PDF, die aussieht, als wäre sie gescannt worden",
|
||||
"tags": "scannen,simulieren,erstellen",
|
||||
"title": "Scanner-Effekt"
|
||||
"desc": "Mehrstufige Arbeitsabläufe durch Verkettung von PDF-Aktionen erstellen. Ideal für wiederkehrende Aufgaben."
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
@@ -700,18 +633,8 @@
|
||||
"merge": {
|
||||
"tags": "zusammenführen,seitenvorgänge,back end,serverseitig",
|
||||
"title": "Zusammenführen",
|
||||
"removeDigitalSignature": {
|
||||
"tooltip": {
|
||||
"description": "Digitale Signaturen werden beim Zusammenführen von Dateien ungültig. Aktivieren Sie diese Option, um sie aus der endgültigen zusammengeführten PDF zu entfernen.",
|
||||
"title": "Digitale Signatur entfernen"
|
||||
}
|
||||
},
|
||||
"generateTableOfContents": {
|
||||
"tooltip": {
|
||||
"description": "Erstellt automatisch ein klickbares Inhaltsverzeichnis in der zusammengeführten PDF basierend auf den ursprünglichen Dateinamen und Seitenzahlen.",
|
||||
"title": "Inhaltsverzeichnis generieren"
|
||||
}
|
||||
},
|
||||
"removeDigitalSignature": "Digitale Signatur in der zusammengeführten Datei entfernen?",
|
||||
"generateTableOfContents": "Inhaltsverzeichnis in der zusammengeführten Datei erstellen?",
|
||||
"submit": "Zusammenführen",
|
||||
"sortBy": {
|
||||
"description": "Dateien werden in der Reihenfolge zusammengeführt, in der sie ausgewählt wurden. Ziehen Sie zum Neuordnen oder sortieren Sie unten.",
|
||||
@@ -937,13 +860,7 @@
|
||||
"images": "Bilder",
|
||||
"officeDocs": "Office-Dokumente (Word, Excel, PowerPoint)",
|
||||
"imagesExt": "Bilder (JPG, PNG, usw.)",
|
||||
"grayscale": "Graustufen",
|
||||
"dpi": "DPI",
|
||||
"markdown": "Markdown",
|
||||
"odtExt": "OpenDocument Text (.odt)",
|
||||
"pptExt": "PowerPoint (.pptx)",
|
||||
"rtfExt": "Rich Text Format (.rtf)",
|
||||
"textRtf": "Text/RTF"
|
||||
"grayscale": "Graustufen"
|
||||
},
|
||||
"imageToPdf": {
|
||||
"tags": "konvertierung,img,jpg,bild,foto"
|
||||
@@ -983,20 +900,7 @@
|
||||
"10": "Ungerade-Gerade-Zusammenführung",
|
||||
"11": "Alle Seiten duplizieren"
|
||||
},
|
||||
"placeholder": "(z.B. 1,3,2 oder 4-8,2,10-12 oder 2n-1)",
|
||||
"desc": {
|
||||
"BOOKLET_SORT": "Seiten für den Broschüren-Druck anordnen (letzte, erste, zweite, vorletzte, …).",
|
||||
"CUSTOM": "Verwenden Sie eine benutzerdefinierte Sequenz von Seitenzahlen oder Ausdrücken, um eine neue Reihenfolge zu definieren.",
|
||||
"DUPLEX_SORT": "Vorder- und Rückseiten verschachteln, als ob ein Duplex-Scanner alle Vorderseiten und dann alle Rückseiten gescannt hätte (1, n, 2, n-1, …).",
|
||||
"DUPLICATE": "Jede Seite entsprechend der benutzerdefinierten Anzahl duplizieren (z.B. 4 dupliziert jede Seite 4×).",
|
||||
"ODD_EVEN_MERGE": "Zwei PDFs durch abwechselnde Seiten zusammenführen: ungerade aus der ersten, gerade aus der zweiten.",
|
||||
"ODD_EVEN_SPLIT": "Das Dokument in zwei Ausgaben aufteilen: alle ungeraden Seiten und alle geraden Seiten.",
|
||||
"REMOVE_FIRST": "Die erste Seite aus dem Dokument entfernen.",
|
||||
"REMOVE_FIRST_AND_LAST": "Sowohl die erste als auch die letzte Seite aus dem Dokument entfernen.",
|
||||
"REMOVE_LAST": "Die letzte Seite aus dem Dokument entfernen.",
|
||||
"REVERSE_ORDER": "Das Dokument umkehren, sodass die letzte Seite zur ersten wird usw.",
|
||||
"SIDE_STITCH_BOOKLET_SORT": "Seiten für den Seitenheft-Broschüren-Druck anordnen (optimiert für die Bindung an der Seite)."
|
||||
}
|
||||
"placeholder": "(z.B. 1,3,2 oder 4-8,2,10-12 oder 2n-1)"
|
||||
},
|
||||
"addImage": {
|
||||
"tags": "img,jpg,bild,foto",
|
||||
@@ -1025,8 +929,7 @@
|
||||
"failed": "Ein Fehler ist beim Hinzufügen des Wasserzeichens zur PDF aufgetreten."
|
||||
},
|
||||
"watermarkType": {
|
||||
"image": "Bild",
|
||||
"text": "Text"
|
||||
"image": "Bild"
|
||||
},
|
||||
"settings": {
|
||||
"type": "Wasserzeichen-Typ",
|
||||
@@ -1430,9 +1333,7 @@
|
||||
},
|
||||
"trapped": {
|
||||
"label": "Trapped-Status",
|
||||
"unknown": "Unbekannt",
|
||||
"false": "Falsch",
|
||||
"true": "Wahr"
|
||||
"unknown": "Unbekannt"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Erweiterte Optionen"
|
||||
@@ -1621,13 +1522,7 @@
|
||||
"header": "Bilder extrahieren",
|
||||
"selectText": "Wählen Sie das Bildformat aus, in das extrahierte Bilder konvertiert werden sollen",
|
||||
"allowDuplicates": "Doppelte Bilder speichern",
|
||||
"submit": "Extrahieren",
|
||||
"error": {
|
||||
"failed": "Beim Extrahieren der Bilder aus der PDF ist ein Fehler aufgetreten."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen"
|
||||
}
|
||||
"submit": "Extrahieren"
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "archiv,langfristig,standard,konvertierung,speicherung,aufbewahrung",
|
||||
@@ -1704,14 +1599,8 @@
|
||||
"title": "Signieren",
|
||||
"header": "PDFs signieren",
|
||||
"upload": "Bild hochladen",
|
||||
"draw": {
|
||||
"clear": "Löschen",
|
||||
"title": "Zeichnen Sie Ihre Unterschrift"
|
||||
},
|
||||
"text": {
|
||||
"name": "Name des Unterzeichners",
|
||||
"placeholder": "Geben Sie Ihren vollständigen Namen ein"
|
||||
},
|
||||
"draw": "Signatur zeichnen",
|
||||
"text": "Texteingabe",
|
||||
"clear": "Leeren",
|
||||
"add": "Signieren",
|
||||
"saved": "Gespeicherte Signaturen",
|
||||
@@ -1727,35 +1616,7 @@
|
||||
"previous": "Vorherige Seite",
|
||||
"maintainRatio": "Seitenverhältnis beibehalten ein-/ausschalten",
|
||||
"undo": "Rückgängig",
|
||||
"redo": "Wiederherstellen",
|
||||
"activate": "Signatur-Platzierung aktivieren",
|
||||
"applySignatures": "Signaturen anwenden",
|
||||
"deactivate": "Signatur-Platzierung beenden",
|
||||
"error": {
|
||||
"failed": "Beim Signieren der PDF ist ein Fehler aufgetreten."
|
||||
},
|
||||
"image": {
|
||||
"hint": "Laden Sie ein PNG- oder JPG-Bild Ihrer Unterschrift hoch",
|
||||
"label": "Unterschriftsbild hochladen",
|
||||
"placeholder": "Bilddatei auswählen"
|
||||
},
|
||||
"instructions": {
|
||||
"title": "So fügen Sie eine Unterschrift hinzu"
|
||||
},
|
||||
"results": {
|
||||
"title": "Signatur-Ergebnisse"
|
||||
},
|
||||
"steps": {
|
||||
"configure": "Signatur konfigurieren"
|
||||
},
|
||||
"submit": "Dokument signieren",
|
||||
"type": {
|
||||
"canvas": "Canvas",
|
||||
"draw": "Zeichnen",
|
||||
"image": "Bild",
|
||||
"text": "Text",
|
||||
"title": "Signaturtyp"
|
||||
}
|
||||
"redo": "Wiederherstellen"
|
||||
},
|
||||
"flatten": {
|
||||
"tags": "statisch,deaktivieren,nicht interaktiv,optimieren",
|
||||
@@ -1774,8 +1635,7 @@
|
||||
"stepTitle": "Abflachungs-Optionen",
|
||||
"title": "Abflachungs-Optionen",
|
||||
"flattenOnlyForms.desc": "Nur Formularfelder vereinfachen, andere interaktive Elemente unverändert lassen",
|
||||
"note": "Das Abflachen entfernt interaktive Elemente aus der PDF und macht sie nicht mehr bearbeitbar.",
|
||||
"flattenOnlyForms": "Nur Formulare vereinfachen"
|
||||
"note": "Das Abflachen entfernt interaktive Elemente aus der PDF und macht sie nicht mehr bearbeitbar."
|
||||
},
|
||||
"results": {
|
||||
"title": "Reduzierungs-Ergebnisse"
|
||||
@@ -1833,8 +1693,7 @@
|
||||
"label": "Pixel-Weißheitsschwellwert"
|
||||
},
|
||||
"whitePercent": {
|
||||
"label": "Weiß-Prozentsatz-Schwellwert",
|
||||
"unit": "%"
|
||||
"label": "Weiß-Prozentsatz-Schwellwert"
|
||||
},
|
||||
"includeBlankPages": {
|
||||
"label": "Erkannte leere Seiten einschließen"
|
||||
@@ -1871,17 +1730,7 @@
|
||||
"tags": "kommentare,hervorheben,notizen,markieren,entfernen",
|
||||
"title": "Kommentare entfernen",
|
||||
"header": "Kommentare entfernen",
|
||||
"submit": "Entfernen",
|
||||
"error": {
|
||||
"failed": "Beim Entfernen der Anmerkungen aus der PDF ist ein Fehler aufgetreten."
|
||||
},
|
||||
"info": {
|
||||
"description": "Dieses Werkzeug entfernt alle Anmerkungen (Kommentare, Hervorhebungen, Notizen usw.) aus Ihren PDF-Dokumenten.",
|
||||
"title": "Über Anmerkungen entfernen"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen"
|
||||
}
|
||||
"submit": "Entfernen"
|
||||
},
|
||||
"compare": {
|
||||
"tags": "differenzieren,kontrastieren,verändern,analysieren",
|
||||
@@ -2166,9 +2015,7 @@
|
||||
},
|
||||
"pageSize": {
|
||||
"label": "Ziel-Seitengröße",
|
||||
"keep": "Ursprüngliche Größe beibehalten",
|
||||
"legal": "Legal",
|
||||
"letter": "Letter"
|
||||
"keep": "Ursprüngliche Größe beibehalten"
|
||||
},
|
||||
"submit": "Seitenskalierung anpassen",
|
||||
"error": {
|
||||
@@ -2459,8 +2306,7 @@
|
||||
"showLayers": "Ebenen anzeigen (Doppelklick, um alle Ebenen auf den Standardzustand zurückzusetzen)",
|
||||
"colourPicker": "Farbwähler",
|
||||
"findCurrentOutlineItem": "Aktuelles Gliederungselement finden",
|
||||
"applyChanges": "Änderungen anwenden",
|
||||
"zoom": "Zoom"
|
||||
"applyChanges": "Änderungen anwenden"
|
||||
}
|
||||
},
|
||||
"tableExtraxt": {
|
||||
@@ -2650,8 +2496,7 @@
|
||||
"magicLinkSent": "Magic Link wurde an {{email}} gesendet! Prüfen Sie Ihre E-Mails und klicken Sie auf den Link zur Anmeldung.",
|
||||
"passwordResetSent": "Passwort-Reset-Link wurde an {{email}} gesendet! Prüfen Sie Ihre E-Mails und folgen Sie den Anweisungen.",
|
||||
"failedToSignIn": "Anmeldung mit {{provider}} fehlgeschlagen: {{message}}",
|
||||
"unexpectedError": "Unerwarteter Fehler: {{message}}",
|
||||
"debug": "Debug"
|
||||
"unexpectedError": "Unerwarteter Fehler: {{message}}"
|
||||
},
|
||||
"signup": {
|
||||
"title": "Konto erstellen",
|
||||
@@ -2673,8 +2518,7 @@
|
||||
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"checkEmailConfirmation": "Prüfen Sie Ihre E-Mails auf einen Bestätigungslink, um die Registrierung abzuschließen.",
|
||||
"accountCreatedSuccessfully": "Konto erfolgreich erstellt! Sie können sich jetzt anmelden.",
|
||||
"unexpectedError": "Unerwarteter Fehler: {{message}}",
|
||||
"name": "Name"
|
||||
"unexpectedError": "Unerwarteter Fehler: {{message}}"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"title": "PDF zu einer Seite zusammenfassen",
|
||||
@@ -3117,12 +2961,7 @@
|
||||
"selectedCount": "{{count}} ausgewählt",
|
||||
"download": "Herunterladen",
|
||||
"delete": "Löschen",
|
||||
"unsupported": "Nicht unterstützt",
|
||||
"fileFormat": "Format",
|
||||
"fileName": "Name",
|
||||
"fileVersion": "Version",
|
||||
"googleDrive": "Google Drive",
|
||||
"googleDriveShort": "Drive"
|
||||
"unsupported": "Nicht unterstützt"
|
||||
},
|
||||
"storage": {
|
||||
"temporaryNotice": "Dateien werden temporär in Ihrem Browser gespeichert und können automatisch gelöscht werden",
|
||||
@@ -3153,24 +2992,12 @@
|
||||
"options": {
|
||||
"title": "Bereinigungs-Optionen",
|
||||
"note": "Wählen Sie die Elemente aus, die Sie aus der PDF entfernen möchten. Mindestens eine Option muss ausgewählt werden.",
|
||||
"removeJavaScript": {
|
||||
"desc": "JavaScript-Aktionen und Skripte aus der PDF entfernen"
|
||||
},
|
||||
"removeEmbeddedFiles": {
|
||||
"desc": "Alle in der PDF eingebetteten Dateien entfernen"
|
||||
},
|
||||
"removeXMPMetadata": {
|
||||
"desc": "XMP-Metadaten aus der PDF entfernen"
|
||||
},
|
||||
"removeMetadata": {
|
||||
"desc": "Dokumentinformations-Metadaten (Titel, Autor usw.) entfernen"
|
||||
},
|
||||
"removeLinks": {
|
||||
"desc": "Externe Links und Launch-Aktionen aus der PDF entfernen"
|
||||
},
|
||||
"removeFonts": {
|
||||
"desc": "Eingebettete Schriftarten aus der PDF entfernen"
|
||||
}
|
||||
"removeJavaScript": "JavaScript entfernen",
|
||||
"removeEmbeddedFiles": "Eingebettete Dateien entfernen",
|
||||
"removeXMPMetadata": "XMP-Metadaten entfernen",
|
||||
"removeMetadata": "Dokument-Metadaten entfernen",
|
||||
"removeLinks": "Links entfernen",
|
||||
"removeFonts": "Schriftarten entfernen"
|
||||
}
|
||||
},
|
||||
"addPassword": {
|
||||
@@ -3198,8 +3025,7 @@
|
||||
"keyLength": {
|
||||
"label": "Verschlüsselungsschlüssellänge",
|
||||
"40bit": "40-bit (Niedrig)",
|
||||
"256bit": "256-bit (Hoch)",
|
||||
"128bit": "128-bit (Standard)"
|
||||
"256bit": "256-bit (Hoch)"
|
||||
}
|
||||
},
|
||||
"results": {
|
||||
@@ -3438,58 +3264,5 @@
|
||||
},
|
||||
"generateError": "Wir konnten Ihren API-Schlüssel nicht generieren."
|
||||
}
|
||||
},
|
||||
"AddAttachmentsRequest": {
|
||||
"addMoreFiles": "Weitere Dateien hinzufügen...",
|
||||
"attachments": "Anhänge auswählen",
|
||||
"info": "Wählen Sie Dateien aus, die Sie Ihrer PDF anhängen möchten. Diese Dateien werden eingebettet und über das Anhangs-Panel der PDF zugänglich sein.",
|
||||
"placeholder": "Dateien auswählen...",
|
||||
"results": {
|
||||
"title": "Anhangs-Ergebnisse"
|
||||
},
|
||||
"selectFiles": "Dateien zum Anhängen auswählen",
|
||||
"selectedFiles": "Ausgewählte Dateien",
|
||||
"submit": "Anhänge hinzufügen"
|
||||
},
|
||||
"applyAndContinue": "Anwenden & Fortfahren",
|
||||
"discardChanges": "Änderungen verwerfen",
|
||||
"exportAndContinue": "Exportieren & Fortfahren",
|
||||
"keepWorking": "Weiterarbeiten",
|
||||
"logOut": "Abmelden",
|
||||
"replaceColor": {
|
||||
"tags": "Farbe ersetzen,Seitenoperationen,Backend,serverseitig"
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"error": {
|
||||
"failed": "Beim Extrahieren der Bild-Scans ist ein Fehler aufgetreten."
|
||||
},
|
||||
"submit": "Bild-Scans extrahieren",
|
||||
"title": "Extrahierte Bilder",
|
||||
"tooltip": {
|
||||
"headsUp": "Hinweis",
|
||||
"headsUpDesc": "Überlappende Fotos oder Hintergründe, die farblich sehr nah an den Fotos liegen, können die Genauigkeit verringern - versuchen Sie einen helleren oder dunkleren Hintergrund und lassen Sie mehr Platz.",
|
||||
"problem1": "Fotos nicht erkannt → Toleranz auf 30-50 erhöhen",
|
||||
"problem2": "Zu viele Falscherkennungen → Mindestfläche auf 15.000-20.000 erhöhen",
|
||||
"problem3": "Zuschnitte sind zu eng → Randgröße auf 5-10 erhöhen",
|
||||
"problem4": "Geneigte Fotos nicht begradigt → Winkelschwelle auf ~5° senken",
|
||||
"problem5": "Staub-/Rausch-Boxen → Mindest-Konturfläche auf 1000-2000 erhöhen",
|
||||
"quickFixes": "Schnelle Lösungen",
|
||||
"setupTips": "Einrichtungstipps",
|
||||
"tip1": "Verwenden Sie einen einfachen, hellen Hintergrund",
|
||||
"tip2": "Lassen Sie einen kleinen Abstand (≈1 cm) zwischen den Fotos",
|
||||
"tip3": "Scannen Sie mit 300-600 DPI",
|
||||
"tip4": "Reinigen Sie die Scanner-Glasplatte",
|
||||
"title": "Foto-Teiler",
|
||||
"useCase1": "Ganze Album-Seiten in einem Durchgang scannen",
|
||||
"useCase2": "Flachbett-Stapel in separate Dateien aufteilen",
|
||||
"useCase3": "Collagen in einzelne Fotos aufteilen",
|
||||
"useCase4": "Fotos aus Dokumenten extrahieren",
|
||||
"whatThisDoes": "Was dies tut",
|
||||
"whatThisDoesDesc": "Findet und extrahiert automatisch jedes Foto von einer gescannten Seite oder einem zusammengesetzten Bild - kein manuelles Zuschneiden erforderlich.",
|
||||
"whenToUse": "Wann zu verwenden"
|
||||
}
|
||||
},
|
||||
"termsAndConditions": "Allgemeine Geschäftsbedingungen",
|
||||
"unsavedChanges": "Sie haben ungespeicherte Änderungen an Ihrer PDF. Was möchten Sie tun?",
|
||||
"unsavedChangesTitle": "Ungespeicherte Änderungen"
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,4 @@
|
||||
{
|
||||
"toolPanel": {
|
||||
"modePrompt": {
|
||||
"title": "Choose how you browse tools",
|
||||
"description": "Preview both layouts and decide how you want to explore Stirling PDF tools.",
|
||||
"sidebarTitle": "Sidebar mode",
|
||||
"sidebarDescription": "Keep tools alongside your workspace for quick switching.",
|
||||
"recommended": "Recommended",
|
||||
"chooseSidebar": "Use sidebar mode",
|
||||
"fullscreenTitle": "Fullscreen mode - (legacy)",
|
||||
"fullscreenDescription": "Browse every tool in a catalogue that covers the workspace until you pick one.",
|
||||
"chooseFullscreen": "Use fullscreen mode",
|
||||
"dismiss": "Maybe later"
|
||||
},
|
||||
"fullscreen": {
|
||||
"showDetails": "Show Details"
|
||||
}
|
||||
},
|
||||
"unsavedChanges": "You have unsaved changes to your PDF.",
|
||||
"areYouSure": "Are you sure you want to leave?",
|
||||
"unsavedChangesTitle": "Unsaved Changes",
|
||||
"keepWorking": "Keep Working",
|
||||
"discardChanges": "Discard & Leave",
|
||||
"applyAndContinue": "Save & Leave",
|
||||
"exportAndContinue": "Export & Continue",
|
||||
"language": {
|
||||
"direction": "ltr"
|
||||
},
|
||||
@@ -34,34 +10,16 @@
|
||||
"selectText": {
|
||||
"1": "Select PDF file:",
|
||||
"2": "Margin Size",
|
||||
"3": "Position Selection",
|
||||
"3": "Position",
|
||||
"4": "Starting Number",
|
||||
"5": "Pages to Number",
|
||||
"6": "Custom Text Format"
|
||||
"6": "Custom Text"
|
||||
},
|
||||
"customTextDesc": "Custom Text",
|
||||
"numberPagesDesc": "e.g., 1,3,5-8 or leave blank for all pages",
|
||||
"customNumberDesc": "e.g., \"Page {n}\" or leave blank for just numbers",
|
||||
"submit": "Add Page Numbers",
|
||||
"configuration": "Configuration",
|
||||
"customize": "Customize Appearance",
|
||||
"pagesAndStarting": "Pages & Starting Number",
|
||||
"positionAndPages": "Position & Pages",
|
||||
"error": {
|
||||
"failed": "Add page numbers operation failed"
|
||||
},
|
||||
"results": {
|
||||
"title": "Page Number Results"
|
||||
},
|
||||
"preview": "Position Selection",
|
||||
"previewDisclaimer": "Preview is approximate. Final output may vary due to PDF font metrics."
|
||||
"numberPagesDesc": "Which pages to number, default 'all', also accepts 1-5 or 2,5,9 etc",
|
||||
"customNumberDesc": "Defaults to {n}, also accepts 'Page {n} of {total}', 'Text-{n}', '{filename}-{n}",
|
||||
"submit": "Add Page Numbers"
|
||||
},
|
||||
"pageSelectionPrompt": "Specify which pages to add numbers to. Examples: \"1,3,5\" for specific pages, \"1-5\" for ranges, \"2n\" for even pages, or leave blank for all pages.",
|
||||
"startingNumberTooltip": "The first number to display. Subsequent pages will increment from this number.",
|
||||
"marginTooltip": "Distance between the page number and the edge of the page.",
|
||||
"fontSizeTooltip": "Size of the page number text in points. Larger numbers create bigger text.",
|
||||
"fontTypeTooltip": "Font family for the page numbers. Choose based on your document style.",
|
||||
"customTextTooltip": "Optional custom format for page numbers. Use {n} as placeholder for the number. Example: \"Page {n}\" will show \"Page 1\", \"Page 2\", etc.",
|
||||
"pdfPrompt": "Select PDF(s)",
|
||||
"multiPdfPrompt": "Select PDFs (2+)",
|
||||
"multiPdfDropPrompt": "Select (or drag & drop) all PDFs you require",
|
||||
@@ -83,8 +41,6 @@
|
||||
"save": "Save",
|
||||
"saveToBrowser": "Save to Browser",
|
||||
"download": "Download",
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
|
||||
"undo": "Undo",
|
||||
"moreOptions": "More Options",
|
||||
@@ -275,33 +231,6 @@
|
||||
"cacheInputs": {
|
||||
"name": "Save form inputs",
|
||||
"help": "Enable to store previously used inputs for future runs"
|
||||
},
|
||||
"general": {
|
||||
"title": "General",
|
||||
"description": "Configure general application preferences.",
|
||||
"autoUnzip": "Auto-unzip API responses",
|
||||
"autoUnzipDescription": "Automatically extract files from ZIP responses",
|
||||
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
|
||||
"autoUnzipFileLimit": "Auto-unzip file limit",
|
||||
"autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP",
|
||||
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs."
|
||||
},
|
||||
"hotkeys": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
"description": "Hover a tool to see its shortcut or customise it below. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel.",
|
||||
"errorModifier": {
|
||||
"mac": "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.",
|
||||
"windows": "Include Ctrl, Alt, or another modifier in your shortcut."
|
||||
},
|
||||
"errorConflict": "Shortcut already used by {{tool}}.",
|
||||
"none": "Not assigned",
|
||||
"customBadge": "Custom",
|
||||
"defaultLabel": "Default: {{shortcut}}",
|
||||
"capturing": "Press keys… (Esc to cancel)",
|
||||
"change": "Change shortcut",
|
||||
"reset": "Reset",
|
||||
"shortcut": "Shortcut",
|
||||
"noShortcut": "No shortcut set"
|
||||
}
|
||||
},
|
||||
"changeCreds": {
|
||||
@@ -578,7 +507,7 @@
|
||||
"adjustContrast": {
|
||||
"tags": "contrast,brightness,saturation",
|
||||
"title": "Adjust Colours/Contrast",
|
||||
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
|
||||
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,cut,resize",
|
||||
@@ -615,6 +544,11 @@
|
||||
"title": "Redact",
|
||||
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
|
||||
},
|
||||
"overlayPdfs": {
|
||||
"tags": "overlay,combine,stack",
|
||||
"title": "Overlay PDFs",
|
||||
"desc": "Overlays PDFs on-top of another PDF"
|
||||
},
|
||||
"splitBySections": {
|
||||
"tags": "split,sections,divide",
|
||||
"title": "Split PDF by Sections",
|
||||
@@ -645,9 +579,9 @@
|
||||
"title": "API Documentation",
|
||||
"desc": "View API documentation and test endpoints"
|
||||
},
|
||||
"scannerEffect": {
|
||||
"fakeScan": {
|
||||
"tags": "scan,simulate,create",
|
||||
"title": "Scanner Effect",
|
||||
"title": "Fake Scan",
|
||||
"desc": "Create a PDF that looks like it was scanned"
|
||||
},
|
||||
"editTableOfContents": {
|
||||
@@ -685,7 +619,8 @@
|
||||
"title": "Auto Split by Size/Count",
|
||||
"desc": "Automatically split PDFs by file size or page count"
|
||||
},
|
||||
"replaceColor": {
|
||||
"replaceColorPdf": {
|
||||
"tags": "color,replace,invert",
|
||||
"title": "Replace & Invert Colour",
|
||||
"desc": "Replace or invert colours in PDF documents"
|
||||
},
|
||||
@@ -910,7 +845,6 @@
|
||||
"rotate": {
|
||||
"title": "Rotate PDF",
|
||||
"submit": "Apply Rotation",
|
||||
"selectRotation": "Select Rotation Angle (Clockwise)",
|
||||
"error": {
|
||||
"failed": "An error occurred while rotating the PDF."
|
||||
},
|
||||
@@ -1025,7 +959,7 @@
|
||||
"header": "PDF Page Organiser",
|
||||
"submit": "Rearrange Pages",
|
||||
"mode": {
|
||||
"_value": "Organization mode",
|
||||
"_value": "Mode",
|
||||
"1": "Custom Page Order",
|
||||
"2": "Reverse Order",
|
||||
"3": "Duplex Sort",
|
||||
@@ -1038,19 +972,6 @@
|
||||
"10": "Odd-Even Merge",
|
||||
"11": "Duplicate all pages"
|
||||
},
|
||||
"desc": {
|
||||
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
|
||||
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
|
||||
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
|
||||
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
|
||||
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimised for binding on the side).",
|
||||
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
|
||||
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
|
||||
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
|
||||
"REMOVE_FIRST": "Remove the first page from the document.",
|
||||
"REMOVE_LAST": "Remove the last page from the document.",
|
||||
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
|
||||
},
|
||||
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
|
||||
},
|
||||
"addImage": {
|
||||
@@ -1675,13 +1596,7 @@
|
||||
"header": "Extract Images",
|
||||
"selectText": "Select image format to convert extracted images to",
|
||||
"allowDuplicates": "Save duplicate images",
|
||||
"submit": "Extract",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while extracting images from the PDF."
|
||||
}
|
||||
"submit": "Extract"
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "archive,long-term,standard,conversion,storage,preservation",
|
||||
@@ -1793,7 +1708,6 @@
|
||||
"add": "Add",
|
||||
"saved": "Saved Signatures",
|
||||
"save": "Save Signature",
|
||||
"applySignatures": "Apply Signatures",
|
||||
"personalSigs": "Personal Signatures",
|
||||
"sharedSigs": "Shared Signatures",
|
||||
"noSavedSigs": "No saved signatures found",
|
||||
@@ -1805,50 +1719,7 @@
|
||||
"previous": "Previous page",
|
||||
"maintainRatio": "Toggle maintain aspect ratio",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"submit": "Sign Document",
|
||||
"steps": {
|
||||
"configure": "Configure Signature"
|
||||
},
|
||||
"type": {
|
||||
"title": "Signature Type",
|
||||
"draw": "Draw",
|
||||
"canvas": "Canvas",
|
||||
"image": "Image",
|
||||
"text": "Text"
|
||||
},
|
||||
"draw": {
|
||||
"title": "Draw your signature",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"image": {
|
||||
"label": "Upload signature image",
|
||||
"placeholder": "Select image file",
|
||||
"hint": "Upload a PNG or JPG image of your signature"
|
||||
},
|
||||
"text": {
|
||||
"name": "Signer Name",
|
||||
"placeholder": "Enter your full name"
|
||||
},
|
||||
"instructions": {
|
||||
"title": "How to add signature",
|
||||
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
|
||||
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
|
||||
"text": "After entering your name above, click anywhere on the PDF to place your signature."
|
||||
},
|
||||
"mode": {
|
||||
"move": "Move Signature",
|
||||
"place": "Place Signature"
|
||||
},
|
||||
"updateAndPlace": "Update and Place",
|
||||
"activate": "Activate Signature Placement",
|
||||
"deactivate": "Stop Placing Signatures",
|
||||
"results": {
|
||||
"title": "Signature Results"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while signing the PDF."
|
||||
}
|
||||
"redo": "Redo"
|
||||
},
|
||||
"flatten": {
|
||||
"title": "Flatten",
|
||||
@@ -1962,17 +1833,7 @@
|
||||
"tags": "comments,highlight,notes,markup,remove",
|
||||
"title": "Remove Annotations",
|
||||
"header": "Remove Annotations",
|
||||
"submit": "Remove",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"info": {
|
||||
"title": "About Remove Annotations",
|
||||
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while removing annotations from the PDF."
|
||||
}
|
||||
"submit": "Remove"
|
||||
},
|
||||
"compare": {
|
||||
"tags": "differentiate,contrast,changes,analysis",
|
||||
@@ -2562,15 +2423,11 @@
|
||||
"overlay-pdfs": {
|
||||
"tags": "Overlay",
|
||||
"header": "Overlay PDF Files",
|
||||
"title": "Overlay PDFs",
|
||||
"desc": "Overlay one PDF on top of another",
|
||||
"baseFile": {
|
||||
"label": "Select Base PDF File"
|
||||
},
|
||||
"overlayFiles": {
|
||||
"label": "Select Overlay PDF Files",
|
||||
"placeholder": "Choose PDF(s)...",
|
||||
"addMore": "Add more PDFs..."
|
||||
"label": "Select Overlay PDF Files"
|
||||
},
|
||||
"mode": {
|
||||
"label": "Select Overlay Mode",
|
||||
@@ -2580,49 +2437,14 @@
|
||||
},
|
||||
"counts": {
|
||||
"label": "Overlay Counts (for Fixed Repeat Mode)",
|
||||
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
|
||||
"item": "Count for file"
|
||||
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)"
|
||||
},
|
||||
"position": {
|
||||
"label": "Select Overlay Position",
|
||||
"foreground": "Foreground",
|
||||
"background": "Background"
|
||||
},
|
||||
"submit": "Submit",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"results": {
|
||||
"title": "Overlay Results"
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Overlay PDFs Overview"
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
"text": "Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background."
|
||||
},
|
||||
"mode": {
|
||||
"title": "Overlay Mode",
|
||||
"text": "Choose how to distribute overlay pages across the base PDF pages.",
|
||||
"sequential": "Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.",
|
||||
"interleaved": "Interleaved Overlay: Take one page from each overlay in turn.",
|
||||
"fixedRepeat": "Fixed Repeat Overlay: Take a set number of pages from each overlay before moving to the next. Use Counts to set the numbers."
|
||||
},
|
||||
"position": {
|
||||
"title": "Overlay Position",
|
||||
"text": "Foreground places the overlay on top of the page. Background places it behind."
|
||||
},
|
||||
"overlayFiles": {
|
||||
"title": "Overlay Files",
|
||||
"text": "Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes."
|
||||
},
|
||||
"counts": {
|
||||
"title": "Counts (Fixed Repeat only)",
|
||||
"text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat."
|
||||
}
|
||||
}
|
||||
"submit": "Submit"
|
||||
},
|
||||
"split-by-sections": {
|
||||
"tags": "Section Split, Divide, Customize,Customise",
|
||||
@@ -2711,48 +2533,25 @@
|
||||
},
|
||||
"selectCustomCert": "Custom Certificate File X.509 (Optional)"
|
||||
},
|
||||
"replaceColor": {
|
||||
"labels": {
|
||||
"settings": "Settings",
|
||||
"colourOperation": "Colour operation"
|
||||
"replace-color": {
|
||||
"title": "Advanced Colour options",
|
||||
"header": "Replace-Invert Colour PDF",
|
||||
"selectText": {
|
||||
"1": "Replace or Invert colour Options",
|
||||
"2": "Default(Default high contrast colours)",
|
||||
"3": "Custom(Customised colours)",
|
||||
"4": "Full-Invert(Invert all colours)",
|
||||
"5": "High contrast colour options",
|
||||
"6": "white text on black background",
|
||||
"7": "Black text on white background",
|
||||
"8": "Yellow text on black background",
|
||||
"9": "Green text on black background",
|
||||
"10": "Choose text Colour",
|
||||
"11": "Choose background Colour"
|
||||
},
|
||||
"options": {
|
||||
"highContrast": "High contrast",
|
||||
"invertAll": "Invert all colours",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Replace & Invert Colour Settings Overview"
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
"text": "Transform PDF colours to improve readability and accessibility. Choose from high contrast presets, invert all colours, or create custom colour schemes."
|
||||
},
|
||||
"highContrast": {
|
||||
"title": "High Contrast",
|
||||
"text": "Apply predefined high contrast colour combinations designed for better readability and accessibility compliance.",
|
||||
"bullet1": "White text on black background - Classic dark mode",
|
||||
"bullet2": "Black text on white background - Standard high contrast",
|
||||
"bullet3": "Yellow text on black background - High visibility option",
|
||||
"bullet4": "Green text on black background - Alternative high contrast"
|
||||
},
|
||||
"invertAll": {
|
||||
"title": "Invert All Colours",
|
||||
"text": "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
|
||||
},
|
||||
"custom": {
|
||||
"title": "Custom Colours",
|
||||
"text": "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements.",
|
||||
"bullet1": "Text colour - Choose the colour for text elements",
|
||||
"bullet2": "Background colour - Set the background colour for the document"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while processing the colour replacement."
|
||||
}
|
||||
"submit": "Replace"
|
||||
},
|
||||
"replaceColor": {
|
||||
"replaceColorPdf": {
|
||||
"tags": "Replace Colour,Page operations,Back end,server side"
|
||||
},
|
||||
"login": {
|
||||
@@ -2864,9 +2663,8 @@
|
||||
"submit": "Sanitize PDF"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"header": "Adjust Colors/Contrast",
|
||||
"basic": "Basic Adjustments",
|
||||
"title": "Adjust Contrast",
|
||||
"header": "Adjust Contrast",
|
||||
"contrast": "Contrast:",
|
||||
"brightness": "Brightness:",
|
||||
"saturation": "Saturation:",
|
||||
@@ -3153,12 +2951,7 @@
|
||||
"panMode": "Pan Mode",
|
||||
"rotateLeft": "Rotate Left",
|
||||
"rotateRight": "Rotate Right",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"exportSelected": "Export Selected Pages",
|
||||
"toggleAnnotations": "Toggle Annotations Visibility",
|
||||
"annotationMode": "Toggle Annotation Mode",
|
||||
"draw": "Draw",
|
||||
"save": "Save"
|
||||
"toggleSidebar": "Toggle Sidebar"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search PDF",
|
||||
@@ -3200,7 +2993,6 @@
|
||||
"automate": "Automate",
|
||||
"files": "Files",
|
||||
"activity": "Activity",
|
||||
"account": "Account",
|
||||
"config": "Config",
|
||||
"allTools": "All Tools"
|
||||
},
|
||||
@@ -3228,9 +3020,6 @@
|
||||
"addFiles": "Add Files",
|
||||
"dragFilesInOrClick": "Drag files in or click \"Add Files\" to browse"
|
||||
},
|
||||
"fileEditor": {
|
||||
"addFiles": "Add Files"
|
||||
},
|
||||
"fileManager": {
|
||||
"title": "Upload PDF Files",
|
||||
"subtitle": "Add files to your storage for easy access across tools",
|
||||
@@ -3259,7 +3048,6 @@
|
||||
"lastModified": "Last Modified",
|
||||
"toolChain": "Tools Applied",
|
||||
"restore": "Restore",
|
||||
"unzip": "Unzip",
|
||||
"searchFiles": "Search files...",
|
||||
"recent": "Recent",
|
||||
"localFiles": "Local Files",
|
||||
@@ -3267,6 +3055,7 @@
|
||||
"googleDriveShort": "Drive",
|
||||
"myFiles": "My Files",
|
||||
"noRecentFiles": "No recent files found",
|
||||
"dropFilesHint": "Drop files here to upload",
|
||||
"googleDriveNotAvailable": "Google Drive integration not available",
|
||||
"openFiles": "Open Files",
|
||||
"openFile": "Open File",
|
||||
@@ -3539,16 +3328,6 @@
|
||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||
}
|
||||
},
|
||||
"viewer": {
|
||||
"firstPage": "First Page",
|
||||
"lastPage": "Last Page",
|
||||
"previousPage": "Previous Page",
|
||||
"nextPage": "Next Page",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"singlePageView": "Single Page View",
|
||||
"dualPageView": "Dual Page View"
|
||||
},
|
||||
"common": {
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
@@ -3605,18 +3384,6 @@
|
||||
"generateError": "We couldn't generate your API key."
|
||||
}
|
||||
},
|
||||
"AddAttachmentsRequest": {
|
||||
"attachments": "Select Attachments",
|
||||
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
|
||||
"selectFiles": "Select Files to Attach",
|
||||
"placeholder": "Choose files...",
|
||||
"addMoreFiles": "Add more files...",
|
||||
"selectedFiles": "Selected Files",
|
||||
"submit": "Add Attachments",
|
||||
"results": {
|
||||
"title": "Attachment Results"
|
||||
}
|
||||
},
|
||||
"termsAndConditions": "Terms & Conditions",
|
||||
"logOut": "Log out"
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,6 @@
|
||||
"language": {
|
||||
"direction": "ltr"
|
||||
},
|
||||
"toolPanel": {
|
||||
"modePrompt": {
|
||||
"title": "Choose how you browse tools",
|
||||
"description": "Preview both layouts and decide how you want to explore Stirling PDF tools.",
|
||||
"sidebarTitle": "Sidebar mode",
|
||||
"sidebarDescription": "Keep tools alongside your workspace for quick switching.",
|
||||
"recommended": "Recommended",
|
||||
"chooseSidebar": "Use sidebar mode",
|
||||
"fullscreenTitle": "Fullscreen mode - (legacy)",
|
||||
"fullscreenDescription": "Browse every tool in a catalogue that covers the workspace until you pick one.",
|
||||
"chooseFullscreen": "Use fullscreen mode",
|
||||
"dismiss": "Maybe later"
|
||||
},
|
||||
"fullscreen": {
|
||||
"showDetails": "Show Details"
|
||||
}
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"fontSize": "Font Size",
|
||||
"fontName": "Font Name",
|
||||
@@ -579,7 +562,7 @@
|
||||
"adjustContrast": {
|
||||
"tags": "contrast,brightness,saturation",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
|
||||
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,cut,resize",
|
||||
@@ -786,7 +769,7 @@
|
||||
"header": "PDF Page Organizer",
|
||||
"submit": "Rearrange Pages",
|
||||
"mode": {
|
||||
"_value": "Organization mode",
|
||||
"_value": "Mode",
|
||||
"1": "Custom Page Order",
|
||||
"2": "Reverse Order",
|
||||
"3": "Duplex Sort",
|
||||
@@ -799,19 +782,6 @@
|
||||
"10": "Odd-Even Merge",
|
||||
"11": "Duplicate all pages"
|
||||
},
|
||||
"desc": {
|
||||
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
|
||||
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
|
||||
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
|
||||
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
|
||||
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimized for binding on the side).",
|
||||
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
|
||||
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
|
||||
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
|
||||
"REMOVE_FIRST": "Remove the first page from the document.",
|
||||
"REMOVE_LAST": "Remove the last page from the document.",
|
||||
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
|
||||
},
|
||||
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
|
||||
},
|
||||
"addImage": {
|
||||
@@ -1086,13 +1056,7 @@
|
||||
"header": "Extract Images",
|
||||
"selectText": "Select image format to convert extracted images to",
|
||||
"allowDuplicates": "Save duplicate images",
|
||||
"submit": "Extract",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while extracting images from the PDF."
|
||||
}
|
||||
"submit": "Extract"
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "archive,long-term,standard,conversion,storage,preservation",
|
||||
@@ -1260,17 +1224,7 @@
|
||||
"tags": "comments,highlight,notes,markup,remove",
|
||||
"title": "Remove Annotations",
|
||||
"header": "Remove Annotations",
|
||||
"submit": "Remove",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"info": {
|
||||
"title": "About Remove Annotations",
|
||||
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while removing annotations from the PDF."
|
||||
}
|
||||
"submit": "Remove"
|
||||
},
|
||||
"compare": {
|
||||
"tags": "differentiate,contrast,changes,analysis",
|
||||
@@ -1538,15 +1492,11 @@
|
||||
"overlay-pdfs": {
|
||||
"tags": "Overlay",
|
||||
"header": "Overlay PDF Files",
|
||||
"title": "Overlay PDFs",
|
||||
"desc": "Overlay one PDF on top of another",
|
||||
"baseFile": {
|
||||
"label": "Select Base PDF File"
|
||||
},
|
||||
"overlayFiles": {
|
||||
"label": "Select Overlay PDF Files",
|
||||
"placeholder": "Choose PDF(s)...",
|
||||
"addMore": "Add more PDFs..."
|
||||
"label": "Select Overlay PDF Files"
|
||||
},
|
||||
"mode": {
|
||||
"label": "Select Overlay Mode",
|
||||
@@ -1556,49 +1506,14 @@
|
||||
},
|
||||
"counts": {
|
||||
"label": "Overlay Counts (for Fixed Repeat Mode)",
|
||||
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
|
||||
"item": "Count for file"
|
||||
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)"
|
||||
},
|
||||
"position": {
|
||||
"label": "Select Overlay Position",
|
||||
"foreground": "Foreground",
|
||||
"background": "Background"
|
||||
},
|
||||
"submit": "Submit",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"results": {
|
||||
"title": "Overlay Results"
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Overlay PDFs Overview"
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
"text": "Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background."
|
||||
},
|
||||
"mode": {
|
||||
"title": "Overlay Mode",
|
||||
"text": "Choose how to distribute overlay pages across the base PDF pages.",
|
||||
"sequential": "Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.",
|
||||
"interleaved": "Interleaved Overlay: Take one page from each overlay in turn.",
|
||||
"fixedRepeat": "Fixed Repeat Overlay: Take a set number of pages from each overlay before moving to the next. Use Counts to set the numbers."
|
||||
},
|
||||
"position": {
|
||||
"title": "Overlay Position",
|
||||
"text": "Foreground places the overlay on top of the page. Background places it behind."
|
||||
},
|
||||
"overlayFiles": {
|
||||
"title": "Overlay Files",
|
||||
"text": "Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes."
|
||||
},
|
||||
"counts": {
|
||||
"title": "Counts (Fixed Repeat only)",
|
||||
"text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat."
|
||||
}
|
||||
}
|
||||
"submit": "Submit"
|
||||
},
|
||||
"split-by-sections": {
|
||||
"tags": "Section Split, Divide, Customize",
|
||||
@@ -1768,9 +1683,8 @@
|
||||
"submit": "Sanitize PDF"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"header": "Adjust Colors/Contrast",
|
||||
"basic": "Basic Adjustments",
|
||||
"title": "Adjust Contrast",
|
||||
"header": "Adjust Contrast",
|
||||
"contrast": "Contrast:",
|
||||
"brightness": "Brightness:",
|
||||
"saturation": "Saturation:",
|
||||
@@ -1890,16 +1804,10 @@
|
||||
}
|
||||
},
|
||||
"removeImage": {
|
||||
"title": "Remove Images",
|
||||
"header": "Remove Images",
|
||||
"removeImage": "Remove Images",
|
||||
"submit": "Remove Images",
|
||||
"results": {
|
||||
"title": "Remove Images Results"
|
||||
},
|
||||
"error": {
|
||||
"failed": "Failed to remove images from the PDF."
|
||||
}
|
||||
"title": "Remove image",
|
||||
"header": "Remove image",
|
||||
"removeImage": "Remove image",
|
||||
"submit": "Remove image"
|
||||
},
|
||||
"splitByChapters": {
|
||||
"title": "Split PDF by Chapters",
|
||||
@@ -1944,7 +1852,7 @@
|
||||
"title": "How we use Cookies",
|
||||
"description": {
|
||||
"1": "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.",
|
||||
"2": "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
|
||||
"2": "If you’d rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
|
||||
},
|
||||
"acceptAllBtn": "Okay",
|
||||
"acceptNecessaryBtn": "No Thanks",
|
||||
@@ -1968,7 +1876,7 @@
|
||||
"1": "Strictly Necessary Cookies",
|
||||
"2": "Always Enabled"
|
||||
},
|
||||
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off."
|
||||
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off."
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
@@ -2448,22 +2356,5 @@
|
||||
},
|
||||
"automate": {
|
||||
"copyToSaved": "Copy to Saved"
|
||||
},
|
||||
"AddAttachmentsRequest": {
|
||||
"attachments": "Select Attachments",
|
||||
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
|
||||
"selectFiles": "Select Files to Attach",
|
||||
"placeholder": "Choose files...",
|
||||
"addMoreFiles": "Add more files...",
|
||||
"selectedFiles": "Selected Files",
|
||||
"submit": "Add Attachments",
|
||||
"results": {
|
||||
"title": "Attachment Results"
|
||||
}
|
||||
},
|
||||
"addAttachments": {
|
||||
"error": {
|
||||
"failed": "An error occurred while adding attachments to the PDF."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -87,10 +87,7 @@
|
||||
"showStack": "Mostra traccia dello stack",
|
||||
"copyStack": "Copia traccia dello stack",
|
||||
"githubSubmit": "GitHub: apri un ticket",
|
||||
"discordSubmit": "Discord: invia post di supporto",
|
||||
"dismissAllErrors": "Chiudi tutti gli errori",
|
||||
"encryptedPdfMustRemovePassword": "Questo PDF è crittografato o protetto da password. Si prega di sbloccarlo prima di convertire in PDF/A.",
|
||||
"incorrectPasswordProvided": "La password del PDF è errata o non è stata fornita."
|
||||
"discordSubmit": "Discord: invia post di supporto"
|
||||
},
|
||||
"warning": {
|
||||
"tooltipTitle": "Avviso"
|
||||
@@ -361,223 +358,179 @@
|
||||
"sortBy": "Ordinamento:",
|
||||
"multiTool": {
|
||||
"title": "Multifunzione PDF",
|
||||
"desc": "Unisci, Ruota, Riordina, e Rimuovi pagine",
|
||||
"tags": "multipli,strumenti"
|
||||
"desc": "Unisci, Ruota, Riordina, e Rimuovi pagine"
|
||||
},
|
||||
"merge": {
|
||||
"title": "Unisci",
|
||||
"desc": "Unisci facilmente più PDF in uno.",
|
||||
"tags": "combina,unisci,unifica"
|
||||
"desc": "Unisci facilmente più PDF in uno."
|
||||
},
|
||||
"split": {
|
||||
"title": "Dividi",
|
||||
"desc": "Dividi un singolo PDF in più documenti.",
|
||||
"tags": "dividi,separa,spezza"
|
||||
"desc": "Dividi un singolo PDF in più documenti."
|
||||
},
|
||||
"rotate": {
|
||||
"title": "Ruota",
|
||||
"desc": "Ruota un PDF.",
|
||||
"tags": "ruota,capovolgi,orienta"
|
||||
"desc": "Ruota un PDF."
|
||||
},
|
||||
"convert": {
|
||||
"title": "Converti",
|
||||
"desc": "Converti file tra diversi formati",
|
||||
"tags": "trasforma,cambia"
|
||||
"desc": "Converti file tra diversi formati"
|
||||
},
|
||||
"pdfOrganiser": {
|
||||
"title": "Organizza",
|
||||
"desc": "Rimuovi/Riordina le pagine in qualsiasi ordine.",
|
||||
"tags": "organizza,riordina,riorganizza"
|
||||
"desc": "Rimuovi/Riordina le pagine in qualsiasi ordine."
|
||||
},
|
||||
"addImage": {
|
||||
"title": "Aggiungi Immagine",
|
||||
"desc": "Aggiungi un'immagine in un punto specifico del PDF (Lavori in corso)",
|
||||
"tags": "inserisci,incorpora,posiziona"
|
||||
"desc": "Aggiungi un'immagine in un punto specifico del PDF (Lavori in corso)"
|
||||
},
|
||||
"addAttachments": {
|
||||
"title": "Aggiungi allegati",
|
||||
"desc": "Aggiungi o rimuovi file incorporati (allegati) da/verso un PDF",
|
||||
"tags": "incorpora,allega,includi"
|
||||
"desc": "Aggiungi o rimuovi file incorporati (allegati) da/verso un PDF"
|
||||
},
|
||||
"watermark": {
|
||||
"title": "Aggiungi Filigrana",
|
||||
"desc": "Aggiungi una filigrana al tuo PDF.",
|
||||
"tags": "timbro,marca,sovrapponi"
|
||||
"desc": "Aggiungi una filigrana al tuo PDF."
|
||||
},
|
||||
"removePassword": {
|
||||
"title": "Rimuovi Password",
|
||||
"desc": "Rimuovi la password dal tuo PDF.",
|
||||
"tags": "sblocca"
|
||||
"desc": "Rimuovi la password dal tuo PDF."
|
||||
},
|
||||
"compress": {
|
||||
"title": "Comprimi",
|
||||
"desc": "Comprimi PDF per ridurne le dimensioni.",
|
||||
"tags": "riduci,comprimi,ottimizza"
|
||||
"desc": "Comprimi PDF per ridurne le dimensioni."
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"title": "Sblocca moduli PDF",
|
||||
"desc": "Rimuovi la proprietà di sola lettura dei campi del modulo in un documento PDF.",
|
||||
"tags": "sblocca,abilita,modifica"
|
||||
"desc": "Rimuovi la proprietà di sola lettura dei campi del modulo in un documento PDF."
|
||||
},
|
||||
"changeMetadata": {
|
||||
"title": "Modifica Proprietà",
|
||||
"desc": "Modifica/Aggiungi/Rimuovi le proprietà di un documento PDF.",
|
||||
"tags": "modifica,cambia,aggiorna"
|
||||
"desc": "Modifica/Aggiungi/Rimuovi le proprietà di un documento PDF."
|
||||
},
|
||||
"ocr": {
|
||||
"title": "OCR / Pulisci scansioni",
|
||||
"desc": "Pulisci scansioni ed estrai testo da immagini, convertendo le immagini in testo puro.",
|
||||
"tags": "estrai,scansiona"
|
||||
"desc": "Pulisci scansioni ed estrai testo da immagini, convertendo le immagini in testo puro."
|
||||
},
|
||||
"extractImages": {
|
||||
"title": "Estrai immagini",
|
||||
"desc": "Estrai tutte le immagini da un PDF e salvale come zip.",
|
||||
"tags": "estrai,salva,esporta"
|
||||
"desc": "Estrai tutte le immagini da un PDF e salvale come zip."
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"title": "Rileva/Dividi foto scansionate",
|
||||
"desc": "Divide più foto all’interno di una foto/PDF",
|
||||
"tags": "rileva,dividi,foto"
|
||||
"desc": "Divide più foto all’interno di una foto/PDF"
|
||||
},
|
||||
"sign": {
|
||||
"title": "Firma",
|
||||
"desc": "Aggiungi una firma al PDF da disegno, testo o immagine.",
|
||||
"tags": "firma,autografo"
|
||||
"desc": "Aggiungi una firma al PDF da disegno, testo o immagine."
|
||||
},
|
||||
"flatten": {
|
||||
"title": "Appiattisci",
|
||||
"desc": "Rimuovi tutti gli elementi interattivi e moduli da un PDF.",
|
||||
"tags": "semplifica,rimuovi,interattivo"
|
||||
"desc": "Rimuovi tutti gli elementi interattivi e moduli da un PDF."
|
||||
},
|
||||
"certSign": {
|
||||
"title": "Firma con certificato",
|
||||
"desc": "Firma un PDF con un certificato/chiave (PEM/P12)",
|
||||
"tags": "autentica,PEM,P12,ufficiale,cripta,firma,certificato,PKCS12,JKS,server,manuale,auto"
|
||||
"desc": "Firma un PDF con un certificato/chiave (PEM/P12)"
|
||||
},
|
||||
"repair": {
|
||||
"title": "Ripara",
|
||||
"desc": "Prova a riparare un PDF corrotto.",
|
||||
"tags": "ripara,ripristina"
|
||||
"desc": "Prova a riparare un PDF corrotto."
|
||||
},
|
||||
"removeBlanks": {
|
||||
"title": "Rimuovi pagine vuote",
|
||||
"desc": "Trova e rimuovi pagine vuote da un PDF.",
|
||||
"tags": "elimina,pulisci,vuote"
|
||||
"desc": "Trova e rimuovi pagine vuote da un PDF."
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"title": "Rimuovi annotazioni",
|
||||
"desc": "Rimuove tutti i commenti/annotazioni da un PDF",
|
||||
"tags": "elimina,pulisci,rimuovi"
|
||||
"desc": "Rimuove tutti i commenti/annotazioni da un PDF"
|
||||
},
|
||||
"compare": {
|
||||
"title": "Compara",
|
||||
"desc": "Vedi e compara le differenze tra due PDF.",
|
||||
"tags": "differenza"
|
||||
"desc": "Vedi e compara le differenze tra due PDF."
|
||||
},
|
||||
"removeCertSign": {
|
||||
"title": "Rimuovere firma dal certificato",
|
||||
"desc": "Rimuovi la firma del certificato dal PDF",
|
||||
"tags": "rimuovi,elimina,sblocca"
|
||||
"desc": "Rimuovi la firma del certificato dal PDF"
|
||||
},
|
||||
"pageLayout": {
|
||||
"title": "Layout multipagina",
|
||||
"desc": "Unisci più pagine di un documento PDF in un'unica pagina",
|
||||
"tags": "layout,disponi,combina"
|
||||
"desc": "Unisci più pagine di un documento PDF in un'unica pagina"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"title": "Imposizione a libretto",
|
||||
"desc": "Crea libretti con corretto ordinamento pagine e layout multipagina per stampa e rilegatura",
|
||||
"tags": "opuscolo,stampa,rilegatura"
|
||||
"desc": "Crea libretti con corretto ordinamento pagine e layout multipagina per stampa e rilegatura"
|
||||
},
|
||||
"scalePages": {
|
||||
"title": "Regola le dimensioni/scala della pagina",
|
||||
"desc": "Modificare le dimensioni/scala della pagina e/o dei suoi contenuti.",
|
||||
"tags": "ridimensiona,adatta,scala"
|
||||
"desc": "Modificare le dimensioni/scala della pagina e/o dei suoi contenuti."
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"title": "Aggiungi numeri di pagina",
|
||||
"desc": "Aggiungi numeri di pagina in tutto un documento in una posizione prestabilita",
|
||||
"tags": "numero,paginazione,conteggio"
|
||||
"desc": "Aggiungi numeri di pagina in tutto un documento in una posizione prestabilita"
|
||||
},
|
||||
"autoRename": {
|
||||
"title": "Rinomina automatica file PDF",
|
||||
"desc": "Rinomina automaticamente un file PDF in base all’intestazione rilevata",
|
||||
"tags": "auto-rilevamento,basato su intestazione,organizza,rinomina"
|
||||
"desc": "Rinomina automaticamente un file PDF in base all’intestazione rilevata"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Regola colori/contrasto",
|
||||
"desc": "Regola contrasto, saturazione e luminosità di un PDF",
|
||||
"tags": "contrasto,luminosità,saturazione"
|
||||
"desc": "Regola contrasto, saturazione e luminosità di un PDF"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Ritaglia PDF",
|
||||
"desc": "Ritaglia un PDF per ridurne le dimensioni (mantiene il testo!)",
|
||||
"tags": "ritaglia,taglia,ridimensiona"
|
||||
"desc": "Ritaglia un PDF per ridurne le dimensioni (mantiene il testo!)"
|
||||
},
|
||||
"autoSplitPDF": {
|
||||
"title": "Pagine divise automaticamente",
|
||||
"desc": "Dividi automaticamente il PDF scansionato con il codice QR dello divisore di pagina fisico scansionato",
|
||||
"tags": "auto,dividi,QR"
|
||||
"desc": "Dividi automaticamente il PDF scansionato con il codice QR dello divisore di pagina fisico scansionato"
|
||||
},
|
||||
"sanitize": {
|
||||
"title": "Sanitizza",
|
||||
"desc": "Rimuovi elementi potenzialmente dannosi dai PDF",
|
||||
"tags": "pulisci,elimina,rimuovi"
|
||||
"desc": "Rimuovi elementi potenzialmente dannosi dai PDF"
|
||||
},
|
||||
"getPdfInfo": {
|
||||
"title": "Ottieni TUTTE le informazioni in PDF",
|
||||
"desc": "Raccogli tutte le informazioni possibili sui PDF",
|
||||
"tags": "info,metadati,dettagli"
|
||||
"desc": "Raccogli tutte le informazioni possibili sui PDF"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"title": "PDF in un'unica pagina di grandi dimensioni",
|
||||
"desc": "Unisce tutte le pagine PDF in un'unica grande pagina",
|
||||
"tags": "combina,unisci,singola"
|
||||
"desc": "Unisce tutte le pagine PDF in un'unica grande pagina"
|
||||
},
|
||||
"showJS": {
|
||||
"title": "Mostra Javascript",
|
||||
"desc": "Cerca e visualizza qualsiasi JS inserito in un PDF",
|
||||
"tags": "javascript,codice,script"
|
||||
"desc": "Cerca e visualizza qualsiasi JS inserito in un PDF"
|
||||
},
|
||||
"redact": {
|
||||
"title": "Redazione manuale",
|
||||
"desc": "Redige un PDF in base al testo selezionato, alle forme disegnate e/o alle pagina selezionata(e)",
|
||||
"tags": "censura,oscura,nascondi"
|
||||
"desc": "Redige un PDF in base al testo selezionato, alle forme disegnate e/o alle pagina selezionata(e)"
|
||||
},
|
||||
"overlayPdfs": {
|
||||
"title": "Sovrapponi PDF",
|
||||
"desc": "Sovrapponi PDF sopra un altro PDF",
|
||||
"tags": "sovrapponi,combina,impila"
|
||||
"desc": "Sovrapponi PDF sopra un altro PDF"
|
||||
},
|
||||
"splitBySections": {
|
||||
"title": "Dividi PDF per sezioni",
|
||||
"desc": "Divide ogni pagina di un PDF in sezioni orizzontali e verticali più piccole",
|
||||
"tags": "dividi,sezioni,separa"
|
||||
"desc": "Divide ogni pagina di un PDF in sezioni orizzontali e verticali più piccole"
|
||||
},
|
||||
"addStamp": {
|
||||
"title": "Aggiungi timbro al PDF",
|
||||
"desc": "Aggiungi timbri di testo o immagine in posizioni specifiche",
|
||||
"tags": "timbro,marca,sigillo"
|
||||
"desc": "Aggiungi timbri di testo o immagine in posizioni specifiche"
|
||||
},
|
||||
"removeImage": {
|
||||
"title": "Rimuovi immagine",
|
||||
"desc": "Rimuovi le immagini dal PDF per ridurre la dimensione del file",
|
||||
"tags": "rimuovi,elimina,pulisci"
|
||||
"desc": "Rimuovi le immagini dal PDF per ridurre la dimensione del file"
|
||||
},
|
||||
"splitByChapters": {
|
||||
"title": "Dividi PDF per capitoli",
|
||||
"desc": "Dividi un PDF in più file in base alla struttura dei capitoli.",
|
||||
"tags": "dividi,capitoli,struttura"
|
||||
"desc": "Dividi un PDF in più file in base alla struttura dei capitoli."
|
||||
},
|
||||
"validateSignature": {
|
||||
"title": "Convalida la firma PDF",
|
||||
"desc": "Verificare le firme digitali e i certificati nei documenti PDF",
|
||||
"tags": "convalida,verifica,certificato"
|
||||
"desc": "Verificare le firme digitali e i certificati nei documenti PDF"
|
||||
},
|
||||
"swagger": {
|
||||
"title": "Documentazione API",
|
||||
"desc": "Visualizza documentazione API e testa gli endpoint",
|
||||
"tags": "API,documentazione,test"
|
||||
"desc": "Visualizza documentazione API e testa gli endpoint"
|
||||
},
|
||||
"fakeScan": {
|
||||
"title": "Finta scansione",
|
||||
@@ -585,38 +538,31 @@
|
||||
},
|
||||
"editTableOfContents": {
|
||||
"title": "Modifica indice",
|
||||
"desc": "Aggiungi o modifica segnalibri e sommario nei documenti PDF",
|
||||
"tags": "segnalibri,contenuti,modifica"
|
||||
"desc": "Aggiungi o modifica segnalibri e sommario nei documenti PDF"
|
||||
},
|
||||
"manageCertificates": {
|
||||
"title": "Gestisci certificati",
|
||||
"desc": "Importa, esporta o elimina i file certificato usati per firmare i PDF.",
|
||||
"tags": "certificati,importa,esporta"
|
||||
"desc": "Importa, esporta o elimina i file certificato usati per firmare i PDF."
|
||||
},
|
||||
"read": {
|
||||
"title": "Leggi",
|
||||
"desc": "Visualizza e annota PDF. Evidenzia testo, disegna o inserisci commenti per revisione e collaborazione.",
|
||||
"tags": "visualizza,apri,mostra"
|
||||
"desc": "Visualizza e annota PDF. Evidenzia testo, disegna o inserisci commenti per revisione e collaborazione."
|
||||
},
|
||||
"reorganizePages": {
|
||||
"title": "Riorganizza pagine",
|
||||
"desc": "Riorganizza, duplica o elimina pagine PDF con controllo visivo drag‑and‑drop.",
|
||||
"tags": "riordina,riorganizza,organizza"
|
||||
"desc": "Riorganizza, duplica o elimina pagine PDF con controllo visivo drag‑and‑drop."
|
||||
},
|
||||
"extractPages": {
|
||||
"title": "Estrai pagine",
|
||||
"desc": "Estrai pagine specifiche da un PDF",
|
||||
"tags": "estrai,seleziona,copia"
|
||||
"desc": "Estrai pagine specifiche da un PDF"
|
||||
},
|
||||
"removePages": {
|
||||
"title": "Rimuovi",
|
||||
"desc": "Elimina alcune pagine dal PDF.",
|
||||
"tags": "elimina,estrai,escludi"
|
||||
"desc": "Elimina alcune pagine dal PDF."
|
||||
},
|
||||
"autoSizeSplitPDF": {
|
||||
"title": "Divisione automatica per dimensione/numero",
|
||||
"desc": "Dividi un singolo PDF in più documenti in base alle dimensioni, al numero di pagine o al numero di documenti",
|
||||
"tags": "auto,dividi,dimensione"
|
||||
"desc": "Dividi un singolo PDF in più documenti in base alle dimensioni, al numero di pagine o al numero di documenti"
|
||||
},
|
||||
"replaceColorPdf": {
|
||||
"title": "Sostituisci e inverti il colore",
|
||||
@@ -624,13 +570,11 @@
|
||||
},
|
||||
"devApi": {
|
||||
"title": "API",
|
||||
"desc": "Link alla documentazione API",
|
||||
"tags": "API,sviluppo,documentazione"
|
||||
"desc": "Link alla documentazione API"
|
||||
},
|
||||
"devFolderScanning": {
|
||||
"title": "Scansione cartelle automatizzata",
|
||||
"desc": "Link alla guida per scansione cartelle automatizzata",
|
||||
"tags": "automazione,cartella,scansione"
|
||||
"desc": "Link alla guida per scansione cartelle automatizzata"
|
||||
},
|
||||
"devSsoGuide": {
|
||||
"title": "Guida SSO",
|
||||
@@ -650,17 +594,7 @@
|
||||
},
|
||||
"automate": {
|
||||
"title": "Automatizza",
|
||||
"desc": "Crea flussi multi‑step concatenando azioni PDF. Ideale per attività ricorrenti.",
|
||||
"tags": "flusso di lavoro,sequenza,automazione"
|
||||
},
|
||||
"replaceColor": {
|
||||
"desc": "Sostituisci o inverti i colori nei documenti PDF",
|
||||
"title": "Sostituisci e inverti colore"
|
||||
},
|
||||
"scannerEffect": {
|
||||
"desc": "Crea un PDF che sembra essere stato scansionato",
|
||||
"tags": "scansiona,simula,crea",
|
||||
"title": "Effetto scanner"
|
||||
"desc": "Crea flussi multi‑step concatenando azioni PDF. Ideale per attività ricorrenti."
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
@@ -720,9 +654,7 @@
|
||||
},
|
||||
"error": {
|
||||
"failed": "Si è verificato un errore durante l’unione dei PDF."
|
||||
},
|
||||
"generateTableOfContents": "Generare l'indice nel file unito?",
|
||||
"removeDigitalSignature": "Rimuovere la firma digitale nel file unito?"
|
||||
}
|
||||
},
|
||||
"split": {
|
||||
"tags": "Operazioni sulla pagina,divisione,multi pagina,taglio,lato server",
|
||||
@@ -981,20 +913,7 @@
|
||||
"10": "Unione pari-dispari",
|
||||
"11": "Duplica tutte le pagine"
|
||||
},
|
||||
"placeholder": "(ad es. 1,3,2 o 4-8,2,10-12 o 2n-1)",
|
||||
"desc": {
|
||||
"BOOKLET_SORT": "Disporre le pagine per la stampa a opuscolo (ultima, prima, seconda, penultima, …).",
|
||||
"CUSTOM": "Utilizzare una sequenza personalizzata di numeri di pagina o espressioni per definire un nuovo ordine.",
|
||||
"DUPLEX_SORT": "Alternare fronte e retro come se uno scanner duplex avesse scansionato tutti i fronti, poi tutti i retri (1, n, 2, n-1, …).",
|
||||
"DUPLICATE": "Duplicare ogni pagina secondo il conteggio dell'ordine personalizzato (ad es., 4 duplica ogni pagina 4×).",
|
||||
"ODD_EVEN_MERGE": "Unire due PDF alternando le pagine: dispari dal primo, pari dal secondo.",
|
||||
"ODD_EVEN_SPLIT": "Dividere il documento in due output: tutte le pagine dispari e tutte le pagine pari.",
|
||||
"REMOVE_FIRST": "Rimuovere la prima pagina dal documento.",
|
||||
"REMOVE_FIRST_AND_LAST": "Rimuovere sia la prima che l'ultima pagina dal documento.",
|
||||
"REMOVE_LAST": "Rimuovere l'ultima pagina dal documento.",
|
||||
"REVERSE_ORDER": "Capovolgere il documento in modo che l'ultima pagina diventi la prima e così via.",
|
||||
"SIDE_STITCH_BOOKLET_SORT": "Disporre le pagine per la stampa a opuscolo con cucitura laterale (ottimizzato per la rilegatura sul lato)."
|
||||
}
|
||||
"placeholder": "(ad es. 1,3,2 o 4-8,2,10-12 o 2n-1)"
|
||||
},
|
||||
"addImage": {
|
||||
"tags": "img,jpg,immagine,foto",
|
||||
@@ -1428,7 +1347,7 @@
|
||||
},
|
||||
"trapped": {
|
||||
"label": "Stato Trapped",
|
||||
"unknown": "Sconosciuto",
|
||||
"unknown": "Unknown",
|
||||
"true": "True",
|
||||
"false": "False"
|
||||
},
|
||||
@@ -1619,13 +1538,7 @@
|
||||
"header": "Estrai immagini",
|
||||
"selectText": "Seleziona il formato in cui salvare le immagini estratte",
|
||||
"allowDuplicates": "Salva le immagini duplicate",
|
||||
"submit": "Estrai",
|
||||
"error": {
|
||||
"failed": "Si è verificato un errore durante l'estrazione delle immagini dal PDF."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni"
|
||||
}
|
||||
"submit": "Estrai"
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "archivio,a lungo termine,standard,conversione,archiviazione,conservazione",
|
||||
@@ -1702,14 +1615,8 @@
|
||||
"title": "Firma",
|
||||
"header": "Firma PDF",
|
||||
"upload": "Carica immagine",
|
||||
"draw": {
|
||||
"clear": "Cancella",
|
||||
"title": "Disegna la tua firma"
|
||||
},
|
||||
"text": {
|
||||
"name": "Nome firmatario",
|
||||
"placeholder": "Inserisci il tuo nome completo"
|
||||
},
|
||||
"draw": "Disegna Firma",
|
||||
"text": "Testo",
|
||||
"clear": "Cancella",
|
||||
"add": "Aggiungi",
|
||||
"saved": "Firme salvate",
|
||||
@@ -1725,35 +1632,7 @@
|
||||
"previous": "Pagina precedente",
|
||||
"maintainRatio": "Attiva il mantenimento delle proporzioni",
|
||||
"undo": "Annulla",
|
||||
"redo": "Rifare",
|
||||
"activate": "Attiva posizionamento firma",
|
||||
"applySignatures": "Applica firme",
|
||||
"deactivate": "Interrompi posizionamento firme",
|
||||
"error": {
|
||||
"failed": "Si è verificato un errore durante la firma del PDF."
|
||||
},
|
||||
"image": {
|
||||
"hint": "Carica un'immagine PNG o JPG della tua firma",
|
||||
"label": "Carica immagine firma",
|
||||
"placeholder": "Seleziona file immagine"
|
||||
},
|
||||
"instructions": {
|
||||
"title": "Come aggiungere la firma"
|
||||
},
|
||||
"results": {
|
||||
"title": "Risultati firma"
|
||||
},
|
||||
"steps": {
|
||||
"configure": "Configura firma"
|
||||
},
|
||||
"submit": "Firma documento",
|
||||
"type": {
|
||||
"canvas": "Canvas",
|
||||
"draw": "Disegna",
|
||||
"image": "Immagine",
|
||||
"text": "Testo",
|
||||
"title": "Tipo di firma"
|
||||
}
|
||||
"redo": "Rifare"
|
||||
},
|
||||
"flatten": {
|
||||
"tags": "statico,disattivato,non interattivo,ottimizzato",
|
||||
@@ -1772,8 +1651,7 @@
|
||||
"stepTitle": "Opzioni di flattening",
|
||||
"title": "Opzioni di flattening",
|
||||
"flattenOnlyForms.desc": "Appiattisci solo i campi modulo, lasciando intatti gli altri elementi interattivi",
|
||||
"note": "Il flattening rimuove gli elementi interattivi dal PDF, rendendoli non modificabili.",
|
||||
"flattenOnlyForms": "Appiattisci solo i moduli"
|
||||
"note": "Il flattening rimuove gli elementi interattivi dal PDF, rendendoli non modificabili."
|
||||
},
|
||||
"results": {
|
||||
"title": "Risultati Flatten"
|
||||
@@ -1869,17 +1747,7 @@
|
||||
"tags": "commenti,evidenziazioni,note,markup,rimozione",
|
||||
"title": "Rimuovi Annotazioni",
|
||||
"header": "Rimuovi Annotazioni",
|
||||
"submit": "Rimuovi",
|
||||
"error": {
|
||||
"failed": "Si è verificato un errore durante la rimozione delle annotazioni dal PDF."
|
||||
},
|
||||
"info": {
|
||||
"description": "Questo strumento rimuoverà tutte le annotazioni (commenti, evidenziazioni, note, ecc.) dai tuoi documenti PDF.",
|
||||
"title": "Informazioni su Rimuovi annotazioni"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni"
|
||||
}
|
||||
"submit": "Rimuovi"
|
||||
},
|
||||
"compare": {
|
||||
"tags": "differenziare,contrastare,cambiare,analisi",
|
||||
@@ -3156,13 +3024,7 @@
|
||||
"removeXMPMetadata.desc": "Rimuovi i metadati XMP dal PDF",
|
||||
"removeMetadata.desc": "Rimuovi le informazioni (titolo, autore, ecc.)",
|
||||
"removeLinks.desc": "Rimuovi link esterni e azioni di avvio dal PDF",
|
||||
"removeFonts.desc": "Rimuovi i font incorporati dal PDF",
|
||||
"removeEmbeddedFiles": "Rimuovi file incorporati",
|
||||
"removeFonts": "Rimuovi caratteri",
|
||||
"removeJavaScript": "Rimuovi JavaScript",
|
||||
"removeLinks": "Rimuovi collegamenti",
|
||||
"removeMetadata": "Rimuovi metadati documento",
|
||||
"removeXMPMetadata": "Rimuovi metadati XMP"
|
||||
"removeFonts.desc": "Rimuovi i font incorporati dal PDF"
|
||||
}
|
||||
},
|
||||
"addPassword": {
|
||||
@@ -3432,56 +3294,5 @@
|
||||
}
|
||||
},
|
||||
"termsAndConditions": "Termini e condizioni",
|
||||
"logOut": "Esci",
|
||||
"AddAttachmentsRequest": {
|
||||
"addMoreFiles": "Aggiungi altri file...",
|
||||
"attachments": "Seleziona allegati",
|
||||
"info": "Seleziona i file da allegare al tuo PDF. Questi file saranno incorporati e accessibili tramite il pannello allegati del PDF.",
|
||||
"placeholder": "Scegli file...",
|
||||
"results": {
|
||||
"title": "Risultati allegati"
|
||||
},
|
||||
"selectFiles": "Seleziona file da allegare",
|
||||
"selectedFiles": "File selezionati",
|
||||
"submit": "Aggiungi allegati"
|
||||
},
|
||||
"applyAndContinue": "Applica e continua",
|
||||
"discardChanges": "Scarta modifiche",
|
||||
"exportAndContinue": "Esporta e continua",
|
||||
"keepWorking": "Continua a lavorare",
|
||||
"replaceColor": {
|
||||
"tags": "Sostituisci colore,Operazioni pagina,Back end,lato server"
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"error": {
|
||||
"failed": "Si è verificato un errore durante l'estrazione delle scansioni di immagini."
|
||||
},
|
||||
"submit": "Estrai scansioni di immagini",
|
||||
"title": "Immagini estratte",
|
||||
"tooltip": {
|
||||
"headsUp": "Attenzione",
|
||||
"headsUpDesc": "Foto sovrapposte o sfondi molto simili nel colore alle foto possono ridurre la precisione - prova uno sfondo più chiaro o più scuro e lascia più spazio.",
|
||||
"problem1": "Foto non rilevate → aumentare la tolleranza a 30-50",
|
||||
"problem2": "Troppe rilevazioni errate → aumentare l'area minima a 15.000-20.000",
|
||||
"problem3": "I ritagli sono troppo stretti → aumentare la dimensione del bordo a 5-10",
|
||||
"problem4": "Foto inclinate non raddrizzate → abbassare la soglia angolare a ~5°",
|
||||
"problem5": "Caselle di polvere/rumore → aumentare l'area minima del contorno a 1000-2000",
|
||||
"quickFixes": "Correzioni rapide",
|
||||
"setupTips": "Suggerimenti di configurazione",
|
||||
"tip1": "Usa uno sfondo semplice e chiaro",
|
||||
"tip2": "Lascia un piccolo spazio (≈1 cm) tra le foto",
|
||||
"tip3": "Scansiona a 300-600 DPI",
|
||||
"tip4": "Pulisci il vetro dello scanner",
|
||||
"title": "Divisore di foto",
|
||||
"useCase1": "Scansiona intere pagine di album in una volta",
|
||||
"useCase2": "Dividi i lotti flatbed in file separati",
|
||||
"useCase3": "Suddividi collage in singole foto",
|
||||
"useCase4": "Estrai foto dai documenti",
|
||||
"whatThisDoes": "Cosa fa",
|
||||
"whatThisDoesDesc": "Trova ed estrae automaticamente ogni foto da una pagina scansionata o da un'immagine composita - senza ritaglio manuale.",
|
||||
"whenToUse": "Quando usare"
|
||||
}
|
||||
},
|
||||
"unsavedChanges": "Hai modifiche non salvate al tuo PDF. Cosa vuoi fare?",
|
||||
"unsavedChangesTitle": "Modifiche non salvate"
|
||||
"logOut": "Esci"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -87,10 +87,7 @@
|
||||
"showStack": "显示堆栈跟踪",
|
||||
"copyStack": "复制堆栈跟踪",
|
||||
"githubSubmit": "GitHub - 提交工单",
|
||||
"discordSubmit": "Discord - 提交支持帖子",
|
||||
"dismissAllErrors": "关闭所有错误",
|
||||
"encryptedPdfMustRemovePassword": "此 PDF 已加密或受密码保护。请在转换为 PDF/A 之前将其解锁。",
|
||||
"incorrectPasswordProvided": "PDF 密码不正确或未提供。"
|
||||
"discordSubmit": "Discord - 提交支持帖子"
|
||||
},
|
||||
"warning": {
|
||||
"tooltipTitle": "警告"
|
||||
@@ -361,223 +358,179 @@
|
||||
"sortBy": "排序:",
|
||||
"multiTool": {
|
||||
"title": "PDF 多功能工具",
|
||||
"desc": "合并、旋转、重新排列和删除 PDF 页面",
|
||||
"tags": "多个,工具"
|
||||
"desc": "合并、旋转、重新排列和删除 PDF 页面"
|
||||
},
|
||||
"merge": {
|
||||
"title": "合并",
|
||||
"desc": "轻松将多个 PDF 合并成一个。",
|
||||
"tags": "组合,合并,联合"
|
||||
"desc": "轻松将多个 PDF 合并成一个。"
|
||||
},
|
||||
"split": {
|
||||
"title": "拆分",
|
||||
"desc": "将 PDF 拆分为多个文档。",
|
||||
"tags": "分割,分离,拆分"
|
||||
"desc": "将 PDF 拆分为多个文档。"
|
||||
},
|
||||
"rotate": {
|
||||
"title": "旋转",
|
||||
"desc": "旋转 PDF。",
|
||||
"tags": "旋转,翻转,定向"
|
||||
"desc": "旋转 PDF。"
|
||||
},
|
||||
"convert": {
|
||||
"title": "转换",
|
||||
"desc": "在不同格式之间转换文件",
|
||||
"tags": "转换,更改"
|
||||
"desc": "在不同格式之间转换文件"
|
||||
},
|
||||
"pdfOrganiser": {
|
||||
"title": "整理",
|
||||
"desc": "按任意顺序删除/重新排列页面。",
|
||||
"tags": "组织,重新排列,重新排序"
|
||||
"desc": "按任意顺序删除/重新排列页面。"
|
||||
},
|
||||
"addImage": {
|
||||
"title": "在 PDF 中添加图片",
|
||||
"desc": "将图像添加到 PDF 的指定位置。",
|
||||
"tags": "插入,嵌入,放置"
|
||||
"desc": "将图像添加到 PDF 的指定位置。"
|
||||
},
|
||||
"addAttachments": {
|
||||
"title": "添加附件",
|
||||
"desc": "向 PDF 添加或移除嵌入文件(附件)",
|
||||
"tags": "嵌入,附加,包含"
|
||||
"desc": "向 PDF 添加或移除嵌入文件(附件)"
|
||||
},
|
||||
"watermark": {
|
||||
"title": "添加水印",
|
||||
"desc": "在 PDF 中添加自定义水印。",
|
||||
"tags": "印章,标记,叠加"
|
||||
"desc": "在 PDF 中添加自定义水印。"
|
||||
},
|
||||
"removePassword": {
|
||||
"title": "删除密码",
|
||||
"desc": "从 PDF 文档中移除密码保护。",
|
||||
"tags": "解锁"
|
||||
"desc": "从 PDF 文档中移除密码保护。"
|
||||
},
|
||||
"compress": {
|
||||
"title": "压缩",
|
||||
"desc": "压缩 PDF 文件以减小文件大小。",
|
||||
"tags": "缩小,减少,优化"
|
||||
"desc": "压缩 PDF 文件以减小文件大小。"
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"title": "解锁PDF表单",
|
||||
"desc": "移除表单字段只读属性",
|
||||
"tags": "解锁,启用,编辑"
|
||||
"desc": "移除表单字段只读属性"
|
||||
},
|
||||
"changeMetadata": {
|
||||
"title": "更改元数据",
|
||||
"desc": "更改/删除/添加 PDF 文档的元数据。",
|
||||
"tags": "编辑,修改,更新"
|
||||
"desc": "更改/删除/添加 PDF 文档的元数据。"
|
||||
},
|
||||
"ocr": {
|
||||
"title": "运行 OCR /清理扫描",
|
||||
"desc": "清理和识别 PDF 中的图像文本,并将其转换为可编辑文本。",
|
||||
"tags": "提取,扫描"
|
||||
"desc": "清理和识别 PDF 中的图像文本,并将其转换为可编辑文本。"
|
||||
},
|
||||
"extractImages": {
|
||||
"title": "提取图像",
|
||||
"desc": "从 PDF 中提取所有图像并保存到压缩包中。",
|
||||
"tags": "提取,保存,导出"
|
||||
"desc": "从 PDF 中提取所有图像并保存到压缩包中。"
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"title": "检测/拆分扫描照片",
|
||||
"desc": "从照片/PDF 中拆分出多张照片",
|
||||
"tags": "检测,拆分,照片"
|
||||
"desc": "从照片/PDF 中拆分出多张照片"
|
||||
},
|
||||
"sign": {
|
||||
"title": "签名",
|
||||
"desc": "通过绘图、文字或图像向 PDF 添加签名",
|
||||
"tags": "签名,亲笔签名"
|
||||
"desc": "通过绘图、文字或图像向 PDF 添加签名"
|
||||
},
|
||||
"flatten": {
|
||||
"title": "展平",
|
||||
"desc": "从 PDF 中删除所有互动元素和表单",
|
||||
"tags": "简化,删除,交互式"
|
||||
"desc": "从 PDF 中删除所有互动元素和表单"
|
||||
},
|
||||
"certSign": {
|
||||
"title": "使用证书签名",
|
||||
"desc": "使用证书/密钥(PEM/P12)对PDF进行签名",
|
||||
"tags": "认证,PEM,P12,官方,加密,签名,证书,PKCS12,JKS,服务器,手动,自动"
|
||||
"desc": "使用证书/密钥(PEM/P12)对PDF进行签名"
|
||||
},
|
||||
"repair": {
|
||||
"title": "修复",
|
||||
"desc": "尝试修复损坏/损坏的 PDF",
|
||||
"tags": "修复,恢复"
|
||||
"desc": "尝试修复损坏/损坏的 PDF"
|
||||
},
|
||||
"removeBlanks": {
|
||||
"title": "删除空白页",
|
||||
"desc": "检测并删除文档中的空白页",
|
||||
"tags": "删除,清理,空白"
|
||||
"desc": "检测并删除文档中的空白页"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"title": "删除标注",
|
||||
"desc": "删除 PDF 中的所有标注/评论",
|
||||
"tags": "删除,清理,删除"
|
||||
"desc": "删除 PDF 中的所有标注/评论"
|
||||
},
|
||||
"compare": {
|
||||
"title": "比较",
|
||||
"desc": "比较并显示两个 PDF 文档之间的差异",
|
||||
"tags": "差异"
|
||||
"desc": "比较并显示两个 PDF 文档之间的差异"
|
||||
},
|
||||
"removeCertSign": {
|
||||
"title": "移除证书签名",
|
||||
"desc": "移除 PDF 的证书签名",
|
||||
"tags": "删除,删除,解锁"
|
||||
"desc": "移除 PDF 的证书签名"
|
||||
},
|
||||
"pageLayout": {
|
||||
"title": "多页布局",
|
||||
"desc": "将 PDF 文档的多个页面合并成一页",
|
||||
"tags": "布局,排列,组合"
|
||||
"desc": "将 PDF 文档的多个页面合并成一页"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"title": "小册子拼版",
|
||||
"desc": "创建具有正确页面顺序和多页布局的小册子,用于打印和装订",
|
||||
"tags": "小册子,打印,装订"
|
||||
"desc": "创建具有正确页面顺序和多页布局的小册子,用于打印和装订"
|
||||
},
|
||||
"scalePages": {
|
||||
"title": "调整页面尺寸/缩放",
|
||||
"desc": "调整页面及/或其内容的尺寸/缩放",
|
||||
"tags": "调整大小,调整,缩放"
|
||||
"desc": "调整页面及/或其内容的尺寸/缩放"
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"title": "添加页码",
|
||||
"desc": "在文档的指定位置添加页码",
|
||||
"tags": "编号,分页,计数"
|
||||
"desc": "在文档的指定位置添加页码"
|
||||
},
|
||||
"autoRename": {
|
||||
"title": "自动重命名 PDF 文件",
|
||||
"desc": "基于检测到的页眉自动重命名 PDF 文件",
|
||||
"tags": "自动检测,基于标题,组织,重新标记"
|
||||
"desc": "基于检测到的页眉自动重命名 PDF 文件"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "调整颜色/对比度",
|
||||
"desc": "调整 PDF 的对比度、饱和度和亮度",
|
||||
"tags": "对比度,亮度,饱和度"
|
||||
"desc": "调整 PDF 的对比度、饱和度和亮度"
|
||||
},
|
||||
"crop": {
|
||||
"title": "裁剪 PDF",
|
||||
"desc": "裁剪 PDF 以减小其文件大小(保留文本!)",
|
||||
"tags": "裁剪,剪切,调整大小"
|
||||
"desc": "裁剪 PDF 以减小其文件大小(保留文本!)"
|
||||
},
|
||||
"autoSplitPDF": {
|
||||
"title": "自动拆分页面",
|
||||
"desc": "使用物理扫描页面分割器 QR 代码自动拆分扫描的 PDF",
|
||||
"tags": "自动,拆分,QR"
|
||||
"desc": "使用物理扫描页面分割器 QR 代码自动拆分扫描的 PDF"
|
||||
},
|
||||
"sanitize": {
|
||||
"title": "安全清理",
|
||||
"desc": "移除 PDF 文件中的潜在有害元素",
|
||||
"tags": "清理,清除,删除"
|
||||
"desc": "移除 PDF 文件中的潜在有害元素"
|
||||
},
|
||||
"getPdfInfo": {
|
||||
"title": "获取 PDF 的所有信息",
|
||||
"desc": "获取 PDF 的所有可能的信息",
|
||||
"tags": "信息,元数据,详细信息"
|
||||
"desc": "获取 PDF 的所有可能的信息"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"title": "PDF 转单一大页",
|
||||
"desc": "将所有 PDF 页面合并为一个大的单页",
|
||||
"tags": "组合,合并,单页"
|
||||
"desc": "将所有 PDF 页面合并为一个大的单页"
|
||||
},
|
||||
"showJS": {
|
||||
"title": "显示 JavaScript",
|
||||
"desc": "搜索并显示嵌入到 PDF 中的任何 JavaScript 代码",
|
||||
"tags": "javascript,代码,脚本"
|
||||
"desc": "搜索并显示嵌入到 PDF 中的任何 JavaScript 代码"
|
||||
},
|
||||
"redact": {
|
||||
"title": "手动修订",
|
||||
"desc": "根据选定的文本、绘制的形状和/或选定的页面编辑PDF",
|
||||
"tags": "审查,涂黑,隐藏"
|
||||
"desc": "根据选定的文本、绘制的形状和/或选定的页面编辑PDF"
|
||||
},
|
||||
"overlayPdfs": {
|
||||
"title": "叠加 PDF",
|
||||
"desc": "将一个 PDF 叠加到另一个 PDF 之上",
|
||||
"tags": "叠加,组合,堆叠"
|
||||
"desc": "将一个 PDF 叠加到另一个 PDF 之上"
|
||||
},
|
||||
"splitBySections": {
|
||||
"title": "按区块拆分 PDF",
|
||||
"desc": "将 PDF 的每一页分割为更小的横向与纵向区块",
|
||||
"tags": "拆分,部分,分割"
|
||||
"desc": "将 PDF 的每一页分割为更小的横向与纵向区块"
|
||||
},
|
||||
"addStamp": {
|
||||
"title": "向 PDF 添加印章",
|
||||
"desc": "在指定位置添加文本或图像印章",
|
||||
"tags": "印章,标记,盖章"
|
||||
"desc": "在指定位置添加文本或图像印章"
|
||||
},
|
||||
"removeImage": {
|
||||
"title": "删除图像",
|
||||
"desc": "删除图像减少 PDF 大小",
|
||||
"tags": "删除,删除,清理"
|
||||
"desc": "删除图像减少 PDF 大小"
|
||||
},
|
||||
"splitByChapters": {
|
||||
"title": "按章节拆分 PDF",
|
||||
"desc": "根据其章节结构将 PDF 拆分为多个文件。",
|
||||
"tags": "拆分,章节,结构"
|
||||
"desc": "根据其章节结构将 PDF 拆分为多个文件。"
|
||||
},
|
||||
"validateSignature": {
|
||||
"title": "验证 PDF 签名",
|
||||
"desc": "验证 PDF 文档中的数字签名和证书",
|
||||
"tags": "验证,核实,证书"
|
||||
"desc": "验证 PDF 文档中的数字签名和证书"
|
||||
},
|
||||
"swagger": {
|
||||
"title": "API 文档",
|
||||
"desc": "查看 API 文档并测试端点",
|
||||
"tags": "API,文档,测试"
|
||||
"desc": "查看 API 文档并测试端点"
|
||||
},
|
||||
"fakeScan": {
|
||||
"title": "伪扫描",
|
||||
@@ -585,38 +538,31 @@
|
||||
},
|
||||
"editTableOfContents": {
|
||||
"title": "编辑目录",
|
||||
"desc": "为 PDF 文档添加或编辑目录和书签",
|
||||
"tags": "书签,目录,编辑"
|
||||
"desc": "为 PDF 文档添加或编辑目录和书签"
|
||||
},
|
||||
"manageCertificates": {
|
||||
"title": "管理证书",
|
||||
"desc": "导入、导出或删除用于签名 PDF 的数字证书文件。",
|
||||
"tags": "证书,导入,导出"
|
||||
"desc": "导入、导出或删除用于签名 PDF 的数字证书文件。"
|
||||
},
|
||||
"read": {
|
||||
"title": "阅读",
|
||||
"desc": "查看与批注 PDF。高亮、绘制或插入评论以便审阅协作。",
|
||||
"tags": "查看,打开,显示"
|
||||
"desc": "查看与批注 PDF。高亮、绘制或插入评论以便审阅协作。"
|
||||
},
|
||||
"reorganizePages": {
|
||||
"title": "重组页面",
|
||||
"desc": "通过可视化拖放控制重新排列、复制或删除 PDF 页面。",
|
||||
"tags": "重新排列,重新排序,组织"
|
||||
"desc": "通过可视化拖放控制重新排列、复制或删除 PDF 页面。"
|
||||
},
|
||||
"extractPages": {
|
||||
"title": "提取页面",
|
||||
"desc": "从 PDF 文档中提取特定页面",
|
||||
"tags": "提取,选择,复制"
|
||||
"desc": "从 PDF 文档中提取特定页面"
|
||||
},
|
||||
"removePages": {
|
||||
"title": "删除",
|
||||
"desc": "从 PDF 文档中删除不需要的页面。",
|
||||
"tags": "删除,提取,排除"
|
||||
"desc": "从 PDF 文档中删除不需要的页面。"
|
||||
},
|
||||
"autoSizeSplitPDF": {
|
||||
"title": "自动根据大小/数目拆分 PDF",
|
||||
"desc": "将单个 PDF 拆分为多个文档,基于大小、页数或文档数",
|
||||
"tags": "自动,拆分,大小"
|
||||
"desc": "将单个 PDF 拆分为多个文档,基于大小、页数或文档数"
|
||||
},
|
||||
"replaceColorPdf": {
|
||||
"title": "替换和反转颜色",
|
||||
@@ -624,13 +570,11 @@
|
||||
},
|
||||
"devApi": {
|
||||
"title": "API",
|
||||
"desc": "跳转至 API 文档",
|
||||
"tags": "API,开发,文档"
|
||||
"desc": "跳转至 API 文档"
|
||||
},
|
||||
"devFolderScanning": {
|
||||
"title": "自动文件夹扫描",
|
||||
"desc": "跳转至自动文件夹扫描指南",
|
||||
"tags": "自动化,文件夹,扫描"
|
||||
"desc": "跳转至自动文件夹扫描指南"
|
||||
},
|
||||
"devSsoGuide": {
|
||||
"title": "SSO 指南",
|
||||
@@ -650,17 +594,7 @@
|
||||
},
|
||||
"automate": {
|
||||
"title": "自动化",
|
||||
"desc": "通过串联 PDF 操作构建多步工作流。适合重复性任务。",
|
||||
"tags": "工作流,序列,自动化"
|
||||
},
|
||||
"replaceColor": {
|
||||
"desc": "替换或反转 PDF 文档中的颜色",
|
||||
"title": "替换和反转颜色"
|
||||
},
|
||||
"scannerEffect": {
|
||||
"desc": "创建看起来像扫描的 PDF",
|
||||
"tags": "扫描,模拟,创建",
|
||||
"title": "扫描仪效果"
|
||||
"desc": "通过串联 PDF 操作构建多步工作流。适合重复性任务。"
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
@@ -720,9 +654,7 @@
|
||||
},
|
||||
"error": {
|
||||
"failed": "合并 PDF 时发生错误。"
|
||||
},
|
||||
"generateTableOfContents": "在合并的文件中生成目录?",
|
||||
"removeDigitalSignature": "在合并的文件中删除数字签名?"
|
||||
}
|
||||
},
|
||||
"split": {
|
||||
"tags": "页面操作,划分,多页面,剪切,服务器端",
|
||||
@@ -883,7 +815,7 @@
|
||||
"settings": "设置",
|
||||
"conversionCompleted": "转换完成",
|
||||
"results": "结果",
|
||||
"defaultFilename": "已转换文件",
|
||||
"defaultFilename": "converted_file",
|
||||
"conversionResults": "转换结果",
|
||||
"convertFrom": "转换来源",
|
||||
"convertTo": "转换为",
|
||||
@@ -981,20 +913,7 @@
|
||||
"10": "奇偶合并",
|
||||
"11": "复制所有页面"
|
||||
},
|
||||
"placeholder": "(例如:1,3,2 或 4-8,2,10-12 或 2n-1)",
|
||||
"desc": {
|
||||
"BOOKLET_SORT": "排列页面以进行小册子打印(最后,第一,第二,倒数第二,...)。",
|
||||
"CUSTOM": "使用自定义的页码序列或表达式来定义新顺序。",
|
||||
"DUPLEX_SORT": "交错正面然后背面,就像双面扫描仪扫描了所有正面,然后所有背面(1, n, 2, n-1, ...)。",
|
||||
"DUPLICATE": "根据自定义顺序计数复制每页(例如,4 复制每页 4×)。",
|
||||
"ODD_EVEN_MERGE": "通过交替页面合并两个 PDF:第一个的奇数页,第二个的偶数页。",
|
||||
"ODD_EVEN_SPLIT": "将文档拆分为两个输出:所有奇数页和所有偶数页。",
|
||||
"REMOVE_FIRST": "从文档中删除第一页。",
|
||||
"REMOVE_FIRST_AND_LAST": "从文档中删除第一页和最后一页。",
|
||||
"REMOVE_LAST": "从文档中删除最后一页。",
|
||||
"REVERSE_ORDER": "翻转文档,使最后一页变为第一页,依此类推。",
|
||||
"SIDE_STITCH_BOOKLET_SORT": "排列页面以进行侧缝小册子打印(针对侧面装订进行了优化)。"
|
||||
}
|
||||
"placeholder": "(例如:1,3,2 或 4-8,2,10-12 或 2n-1)"
|
||||
},
|
||||
"addImage": {
|
||||
"tags": "图像、JPG、图片、照片",
|
||||
@@ -1018,7 +937,7 @@
|
||||
"desc": "向 PDF 添加文本或图像水印",
|
||||
"completed": "已添加水印",
|
||||
"submit": "添加水印",
|
||||
"filenamePrefix": "已加水印",
|
||||
"filenamePrefix": "watermarked",
|
||||
"error": {
|
||||
"failed": "向 PDF 添加水印时发生错误。"
|
||||
},
|
||||
@@ -1217,7 +1136,7 @@
|
||||
"placeholder": "例如:1,3,5-8,10",
|
||||
"error": "无效的页码格式。使用数字、范围(1-5)或数学表达式(2n+1)"
|
||||
},
|
||||
"filenamePrefix": "已删除页面",
|
||||
"filenamePrefix": "pages_removed",
|
||||
"files": {
|
||||
"placeholder": "在主视图中选择一个 PDF 文件以开始"
|
||||
},
|
||||
@@ -1365,7 +1284,7 @@
|
||||
"header": "解锁 PDF 表单",
|
||||
"submit": "Remove",
|
||||
"description": "该工具将移除 PDF 表单字段的只读限制,使其可编辑、可填写。",
|
||||
"filenamePrefix": "已解锁表单",
|
||||
"filenamePrefix": "unlocked_forms",
|
||||
"files": {
|
||||
"placeholder": "在主视图中选择一个 PDF 文件以开始"
|
||||
},
|
||||
@@ -1380,7 +1299,7 @@
|
||||
"tags": "标题、作者、日期、创建、时间、发布者、制作人、统计数据",
|
||||
"header": "更改元数据",
|
||||
"submit": "更改",
|
||||
"filenamePrefix": "元数据",
|
||||
"filenamePrefix": "metadata",
|
||||
"settings": {
|
||||
"title": "元数据设置"
|
||||
},
|
||||
@@ -1428,7 +1347,7 @@
|
||||
},
|
||||
"trapped": {
|
||||
"label": "陷印状态",
|
||||
"unknown": "未知",
|
||||
"unknown": "Unknown",
|
||||
"true": "True",
|
||||
"false": "False"
|
||||
},
|
||||
@@ -1619,13 +1538,7 @@
|
||||
"header": "提取图像",
|
||||
"selectText": "选择图像格式,将提取的图像转换为",
|
||||
"allowDuplicates": "保存重复图像",
|
||||
"submit": "提取",
|
||||
"error": {
|
||||
"failed": "从 PDF 提取图像时发生错误。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置"
|
||||
}
|
||||
"submit": "提取"
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "归档、长期、标准、转换、存储、保存",
|
||||
@@ -1702,14 +1615,8 @@
|
||||
"title": "签名",
|
||||
"header": "签署 PDF",
|
||||
"upload": "上传图片",
|
||||
"draw": {
|
||||
"clear": "清除",
|
||||
"title": "绘制您的签名"
|
||||
},
|
||||
"text": {
|
||||
"name": "签署人姓名",
|
||||
"placeholder": "输入您的全名"
|
||||
},
|
||||
"draw": "绘制签名",
|
||||
"text": "文本输入",
|
||||
"clear": "清除",
|
||||
"add": "添加",
|
||||
"saved": "已保存签名",
|
||||
@@ -1725,35 +1632,7 @@
|
||||
"previous": "上一页",
|
||||
"maintainRatio": "切换保持长宽比",
|
||||
"undo": "撤销",
|
||||
"redo": "重做",
|
||||
"activate": "激活签名放置",
|
||||
"applySignatures": "应用签名",
|
||||
"deactivate": "停止放置签名",
|
||||
"error": {
|
||||
"failed": "签署 PDF 时发生错误。"
|
||||
},
|
||||
"image": {
|
||||
"hint": "上传 PNG 或 JPG 格式的签名图像",
|
||||
"label": "上传签名图像",
|
||||
"placeholder": "选择图像文件"
|
||||
},
|
||||
"instructions": {
|
||||
"title": "如何添加签名"
|
||||
},
|
||||
"results": {
|
||||
"title": "签名结果"
|
||||
},
|
||||
"steps": {
|
||||
"configure": "配置签名"
|
||||
},
|
||||
"submit": "签署文档",
|
||||
"type": {
|
||||
"canvas": "画布",
|
||||
"draw": "绘制",
|
||||
"image": "图像",
|
||||
"text": "文本",
|
||||
"title": "签名类型"
|
||||
}
|
||||
"redo": "重做"
|
||||
},
|
||||
"flatten": {
|
||||
"tags": "静态、停用、非交互、简化",
|
||||
@@ -1761,7 +1640,7 @@
|
||||
"header": "展平 PDF",
|
||||
"flattenOnlyForms": "仅展平表格",
|
||||
"submit": "展平",
|
||||
"filenamePrefix": "已扁平化",
|
||||
"filenamePrefix": "flattened",
|
||||
"files": {
|
||||
"placeholder": "在主视图中选择一个 PDF 文件以开始"
|
||||
},
|
||||
@@ -1772,8 +1651,7 @@
|
||||
"stepTitle": "扁平化选项",
|
||||
"title": "扁平化选项",
|
||||
"flattenOnlyForms.desc": "仅扁平化表单字段,保留其他交互元素",
|
||||
"note": "扁平化会移除 PDF 的交互元素,使其不可编辑。",
|
||||
"flattenOnlyForms": "仅扁平化表单"
|
||||
"note": "扁平化会移除 PDF 的交互元素,使其不可编辑。"
|
||||
},
|
||||
"results": {
|
||||
"title": "扁平化结果"
|
||||
@@ -1809,7 +1687,7 @@
|
||||
"header": "修复 PDF",
|
||||
"submit": "修复",
|
||||
"description": "该工具将尝试修复损坏或受损的 PDF 文件。无需额外设置。",
|
||||
"filenamePrefix": "已修复",
|
||||
"filenamePrefix": "repaired",
|
||||
"files": {
|
||||
"placeholder": "在主视图中选择一个 PDF 文件以开始"
|
||||
},
|
||||
@@ -1869,17 +1747,7 @@
|
||||
"tags": "评论、高亮、笔记、标注、删除",
|
||||
"title": "删除标注",
|
||||
"header": "删除标注",
|
||||
"submit": "删除",
|
||||
"error": {
|
||||
"failed": "从 PDF 删除注释时发生错误。"
|
||||
},
|
||||
"info": {
|
||||
"description": "此工具将从您的 PDF 文档中删除所有注释(评论、高亮、笔记等)。",
|
||||
"title": "关于删除注释"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置"
|
||||
}
|
||||
"submit": "删除"
|
||||
},
|
||||
"compare": {
|
||||
"tags": "区分、对比、更改、分析",
|
||||
@@ -1911,7 +1779,7 @@
|
||||
"certSign": {
|
||||
"tags": "身份验证、PEM、P12、官方、加密",
|
||||
"title": "证书签名",
|
||||
"filenamePrefix": "已签名",
|
||||
"filenamePrefix": "signed",
|
||||
"signMode": {
|
||||
"stepTitle": "签名模式",
|
||||
"tooltip": {
|
||||
@@ -2035,7 +1903,7 @@
|
||||
"selectPDF": "选择 PDF 文件:",
|
||||
"submit": "移除签名",
|
||||
"description": "该工具将从您的 PDF 文档中移除数字证书签名。",
|
||||
"filenamePrefix": "未签名",
|
||||
"filenamePrefix": "unsigned",
|
||||
"files": {
|
||||
"placeholder": "在主视图中选择一个 PDF 文件以开始"
|
||||
},
|
||||
@@ -2055,7 +1923,7 @@
|
||||
"submit": "提交"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"tags": "小册子,拼版,打印,装订,折叠,签名",
|
||||
"tags": "booklet,imposition,printing,binding,folding,signature",
|
||||
"title": "小册子拼版",
|
||||
"header": "小册子拼版",
|
||||
"submit": "创建小册子",
|
||||
@@ -2156,7 +2024,7 @@
|
||||
"submit": "提交"
|
||||
},
|
||||
"adjustPageScale": {
|
||||
"tags": "调整大小,修改,尺寸,适应",
|
||||
"tags": "resize,modify,dimension,adapt",
|
||||
"title": "调整页面比例",
|
||||
"header": "调整页面比例",
|
||||
"scaleFactor": {
|
||||
@@ -2679,7 +2547,7 @@
|
||||
"header": "将 PDF 转换为单页",
|
||||
"submit": "转为单页",
|
||||
"description": "该工具会将 PDF 的所有页面合并为一张超长单页。宽度保持与原页面相同,高度为所有页面高度之和。",
|
||||
"filenamePrefix": "单页",
|
||||
"filenamePrefix": "single_page",
|
||||
"files": {
|
||||
"placeholder": "在主视图中选择一个 PDF 文件以开始"
|
||||
},
|
||||
@@ -2900,7 +2768,7 @@
|
||||
"title": "API 文档",
|
||||
"header": "API 文档",
|
||||
"desc": "查看并测试 Stirling PDF 的 API 端点",
|
||||
"tags": "api,文档,swagger,端点,开发"
|
||||
"tags": "api,documentation,swagger,endpoints,development"
|
||||
},
|
||||
"cookieBanner": {
|
||||
"popUp": {
|
||||
@@ -3138,7 +3006,7 @@
|
||||
"completed": "安全清理成功完成",
|
||||
"error.generic": "安全清理失败",
|
||||
"error.failed": "安全清理 PDF 时发生错误。",
|
||||
"filenamePrefix": "已清理",
|
||||
"filenamePrefix": "sanitised",
|
||||
"sanitizationResults": "安全清理结果",
|
||||
"steps": {
|
||||
"files": "文件",
|
||||
@@ -3156,13 +3024,7 @@
|
||||
"removeXMPMetadata.desc": "从 PDF 中移除 XMP 元数据",
|
||||
"removeMetadata.desc": "移除文档信息元数据(标题、作者等)",
|
||||
"removeLinks.desc": "移除外部链接与启动动作",
|
||||
"removeFonts.desc": "从 PDF 中移除嵌入字体",
|
||||
"removeEmbeddedFiles": "删除嵌入文件",
|
||||
"removeFonts": "删除字体",
|
||||
"removeJavaScript": "删除 JavaScript",
|
||||
"removeLinks": "删除链接",
|
||||
"removeMetadata": "删除文档元数据",
|
||||
"removeXMPMetadata": "删除 XMP 元数据"
|
||||
"removeFonts.desc": "从 PDF 中移除嵌入字体"
|
||||
}
|
||||
},
|
||||
"addPassword": {
|
||||
@@ -3170,7 +3032,7 @@
|
||||
"desc": "使用密码加密您的 PDF 文档。",
|
||||
"completed": "已应用密码保护",
|
||||
"submit": "加密",
|
||||
"filenamePrefix": "已加密",
|
||||
"filenamePrefix": "encrypted",
|
||||
"error": {
|
||||
"failed": "加密 PDF 时发生错误。"
|
||||
},
|
||||
@@ -3279,7 +3141,7 @@
|
||||
"placeholder": "输入当前密码",
|
||||
"completed": "密码已配置"
|
||||
},
|
||||
"filenamePrefix": "已解密",
|
||||
"filenamePrefix": "decrypted",
|
||||
"error": {
|
||||
"failed": "移除 PDF 密码时发生错误。"
|
||||
},
|
||||
@@ -3432,56 +3294,5 @@
|
||||
}
|
||||
},
|
||||
"termsAndConditions": "条款与条件",
|
||||
"logOut": "退出登录",
|
||||
"AddAttachmentsRequest": {
|
||||
"addMoreFiles": "添加更多文件...",
|
||||
"attachments": "选择附件",
|
||||
"info": "选择要附加到 PDF 的文件。这些文件将被嵌入并可通过 PDF 的附件面板访问。",
|
||||
"placeholder": "选择文件...",
|
||||
"results": {
|
||||
"title": "附件结果"
|
||||
},
|
||||
"selectFiles": "选择要附加的文件",
|
||||
"selectedFiles": "已选择的文件",
|
||||
"submit": "添加附件"
|
||||
},
|
||||
"applyAndContinue": "应用并继续",
|
||||
"discardChanges": "放弃更改",
|
||||
"exportAndContinue": "导出并继续",
|
||||
"keepWorking": "继续工作",
|
||||
"replaceColor": {
|
||||
"tags": "替换颜色,页面操作,后端,服务器端"
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"error": {
|
||||
"failed": "提取图像扫描时发生错误。"
|
||||
},
|
||||
"submit": "提取图像扫描",
|
||||
"title": "已提取的图像",
|
||||
"tooltip": {
|
||||
"headsUp": "注意",
|
||||
"headsUpDesc": "重叠的照片或颜色与照片非常接近的背景会降低准确性 - 尝试使用更浅或更深的背景并留出更多空间。",
|
||||
"problem1": "未检测到照片 → 将容差增加到 30-50",
|
||||
"problem2": "误检测太多 → 将最小面积增加到 15,000-20,000",
|
||||
"problem3": "裁剪太紧 → 将边框大小增加到 5-10",
|
||||
"problem4": "倾斜的照片未矫正 → 将角度阈值降低到 ~5°",
|
||||
"problem5": "灰尘/噪声框 → 将最小轮廓面积增加到 1000-2000",
|
||||
"quickFixes": "快速修复",
|
||||
"setupTips": "设置提示",
|
||||
"tip1": "使用简单的浅色背景",
|
||||
"tip2": "在照片之间留出小间隙(≈1 厘米)",
|
||||
"tip3": "以 300-600 DPI 扫描",
|
||||
"tip4": "清洁扫描仪玻璃",
|
||||
"title": "照片分割器",
|
||||
"useCase1": "一次扫描整个相册页面",
|
||||
"useCase2": "将平板批次拆分为单独的文件",
|
||||
"useCase3": "将拼贴画拆分为单独的照片",
|
||||
"useCase4": "从文档中提取照片",
|
||||
"whatThisDoes": "功能说明",
|
||||
"whatThisDoesDesc": "自动查找并从扫描页面或合成图像中提取每张照片 - 无需手动裁剪。",
|
||||
"whenToUse": "何时使用"
|
||||
}
|
||||
},
|
||||
"unsavedChanges": "您的 PDF 有未保存的更改。您想做什么?",
|
||||
"unsavedChangesTitle": "未保存的更改"
|
||||
"logOut": "退出登录"
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require('node:child_process');
|
||||
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('node:fs');
|
||||
const path = require('node:path');
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const { argv } = require('node:process');
|
||||
import { argv } from 'node:process';
|
||||
const inputIdx = argv.indexOf('--input');
|
||||
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
|
||||
const POSTPROCESS_ONLY = !!INPUT_FILE;
|
||||
|
||||
// __dirname is available in CommonJS by default
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/**
|
||||
* Generate 3rd party licenses for frontend dependencies
|
||||
|
||||
+16
-25
@@ -1,12 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import React, { Suspense } from "react";
|
||||
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
|
||||
import { FileContextProvider } from "./contexts/FileContext";
|
||||
import { NavigationProvider } from "./contexts/NavigationContext";
|
||||
import { FilesModalProvider } from "./contexts/FilesModalContext";
|
||||
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
|
||||
import { HotkeyProvider } from "./contexts/HotkeyContext";
|
||||
import { SidebarProvider } from "./contexts/SidebarContext";
|
||||
import { PreferencesProvider } from "./contexts/PreferencesContext";
|
||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||
import HomePage from "./pages/HomePage";
|
||||
|
||||
@@ -16,7 +14,6 @@ import "./styles/cookieconsent.css";
|
||||
import "./index.css";
|
||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||
import { ViewerProvider } from "./contexts/ViewerContext";
|
||||
import { SignatureProvider } from "./contexts/SignatureContext";
|
||||
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "./utils/fileIdSafety";
|
||||
@@ -42,27 +39,21 @@ export default function App() {
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</Suspense>
|
||||
|
||||
@@ -7,132 +7,6 @@
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/core",
|
||||
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/engines",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-annotation",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-export",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-history",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-interaction-manager",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-loader",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-pan",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-render",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-rotate",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-scroll",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-search",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-selection",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-spread",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-thumbnail",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-tiling",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-viewport",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-zoom",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@emotion/react",
|
||||
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
|
||||
@@ -161,13 +35,6 @@
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/dates",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/dropzone",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
@@ -276,7 +143,7 @@
|
||||
{
|
||||
"moduleName": "posthog-js",
|
||||
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
|
||||
"moduleVersion": "1.268.0",
|
||||
"moduleVersion": "1.266.0",
|
||||
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
|
||||
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
|
||||
},
|
||||
|
||||
@@ -9,9 +9,6 @@ import MobileLayout from './fileManager/MobileLayout';
|
||||
import DesktopLayout from './fileManager/DesktopLayout';
|
||||
import DragOverlay from './fileManager/DragOverlay';
|
||||
import { FileManagerProvider } from '../contexts/FileManagerContext';
|
||||
import { Z_INDEX_FILE_MANAGER_MODAL } from '../styles/zIndex';
|
||||
import { isGoogleDriveConfigured } from '../services/googleDrivePickerService';
|
||||
import { loadScript } from '../utils/scriptLoader';
|
||||
|
||||
interface FileManagerProps {
|
||||
selectedTool?: Tool | null;
|
||||
@@ -23,7 +20,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
||||
const { loadRecentFiles, handleRemoveFile } = useFileManager();
|
||||
|
||||
// File management handlers
|
||||
const isFileSupported = useCallback((fileName: string) => {
|
||||
@@ -87,30 +84,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Preload Google Drive scripts if configured
|
||||
|
||||
useEffect(() => {
|
||||
if (isGoogleDriveConfigured()) {
|
||||
// Load scripts in parallel without blocking
|
||||
Promise.all([
|
||||
loadScript({
|
||||
src: 'https://apis.google.com/js/api.js',
|
||||
id: 'gapi-script',
|
||||
async: true,
|
||||
defer: true,
|
||||
}),
|
||||
loadScript({
|
||||
src: 'https://accounts.google.com/gsi/client',
|
||||
id: 'gis-script',
|
||||
async: true,
|
||||
defer: true,
|
||||
}),
|
||||
]).catch((error) => {
|
||||
console.warn('Failed to preload Google Drive scripts:', error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Modal size constants for consistent scaling
|
||||
const modalHeight = '80vh';
|
||||
const modalWidth = isMobile ? '100%' : '80vw';
|
||||
@@ -127,7 +100,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
radius="md"
|
||||
className="overflow-hidden p-0"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_FILE_MANAGER_MODAL}
|
||||
styles={{
|
||||
content: {
|
||||
position: 'relative',
|
||||
@@ -151,6 +123,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onDrop={handleNewFileUpload}
|
||||
onDragEnter={() => setIsDragging(true)}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
accept={{}}
|
||||
multiple={true}
|
||||
activateOnClick={false}
|
||||
style={{
|
||||
@@ -174,7 +147,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
modalHeight={modalHeight}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
isLoading={loading}
|
||||
>
|
||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||
</FileManagerProvider>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
|
||||
interface PDFAnnotationContextValue {
|
||||
// Drawing mode management
|
||||
activateDrawMode: () => void;
|
||||
deactivateDrawMode: () => void;
|
||||
activateSignaturePlacementMode: () => void;
|
||||
activateDeleteMode: () => void;
|
||||
|
||||
// Drawing settings
|
||||
updateDrawSettings: (color: string, size: number) => void;
|
||||
|
||||
// History operations
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
|
||||
// Image data management
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
|
||||
// Placement state
|
||||
isPlacementMode: boolean;
|
||||
|
||||
// Signature configuration
|
||||
signatureConfig: any | null;
|
||||
setSignatureConfig: (config: any | null) => void;
|
||||
}
|
||||
|
||||
const PDFAnnotationContext = createContext<PDFAnnotationContextValue | undefined>(undefined);
|
||||
|
||||
interface PDFAnnotationProviderProps {
|
||||
children: ReactNode;
|
||||
// These would come from the signature context
|
||||
activateDrawMode: () => void;
|
||||
deactivateDrawMode: () => void;
|
||||
activateSignaturePlacementMode: () => void;
|
||||
activateDeleteMode: () => void;
|
||||
updateDrawSettings: (color: string, size: number) => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
isPlacementMode: boolean;
|
||||
signatureConfig: any | null;
|
||||
setSignatureConfig: (config: any | null) => void;
|
||||
}
|
||||
|
||||
export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
|
||||
children,
|
||||
activateDrawMode,
|
||||
deactivateDrawMode,
|
||||
activateSignaturePlacementMode,
|
||||
activateDeleteMode,
|
||||
updateDrawSettings,
|
||||
undo,
|
||||
redo,
|
||||
storeImageData,
|
||||
getImageData,
|
||||
isPlacementMode,
|
||||
signatureConfig,
|
||||
setSignatureConfig
|
||||
}) => {
|
||||
const contextValue: PDFAnnotationContextValue = {
|
||||
activateDrawMode,
|
||||
deactivateDrawMode,
|
||||
activateSignaturePlacementMode,
|
||||
activateDeleteMode,
|
||||
updateDrawSettings,
|
||||
undo,
|
||||
redo,
|
||||
storeImageData,
|
||||
getImageData,
|
||||
isPlacementMode,
|
||||
signatureConfig,
|
||||
setSignatureConfig
|
||||
};
|
||||
|
||||
return (
|
||||
<PDFAnnotationContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</PDFAnnotationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
|
||||
const context = useContext(PDFAnnotationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('usePDFAnnotation must be used within a PDFAnnotationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, Alert, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DrawingControls } from './DrawingControls';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
import { usePDFAnnotation } from '../providers/PDFAnnotationProvider';
|
||||
|
||||
export interface AnnotationToolConfig {
|
||||
enableDrawing?: boolean;
|
||||
enableImageUpload?: boolean;
|
||||
enableTextInput?: boolean;
|
||||
showPlaceButton?: boolean;
|
||||
placeButtonText?: string;
|
||||
}
|
||||
|
||||
interface BaseAnnotationToolProps {
|
||||
config: AnnotationToolConfig;
|
||||
children: React.ReactNode;
|
||||
onSignatureDataChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
config,
|
||||
children,
|
||||
onSignatureDataChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
activateSignaturePlacementMode,
|
||||
undo,
|
||||
redo
|
||||
} = usePDFAnnotation();
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
|
||||
const handleSignatureDataChange = (data: string | null) => {
|
||||
setSignatureData(data);
|
||||
onSignatureDataChange?.(data);
|
||||
};
|
||||
|
||||
const handlePlaceSignature = () => {
|
||||
if (activateSignaturePlacementMode) {
|
||||
activateSignaturePlacementMode();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Drawing Controls (Undo/Redo/Place) */}
|
||||
<DrawingControls
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
|
||||
hasSignatureData={!!signatureData}
|
||||
disabled={disabled}
|
||||
showPlaceButton={config.showPlaceButton}
|
||||
placeButtonText={config.placeButtonText}
|
||||
/>
|
||||
|
||||
{/* Tool Content */}
|
||||
{React.cloneElement(children as React.ReactElement<any>, {
|
||||
selectedColor,
|
||||
signatureData,
|
||||
onSignatureDataChange: handleSignatureDataChange,
|
||||
onColorSwatchClick: () => setIsColorPickerOpen(true),
|
||||
disabled
|
||||
})}
|
||||
|
||||
{/* Instructions for placing signature */}
|
||||
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
|
||||
<Text size="sm">
|
||||
Click anywhere on the PDF to place your annotation.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={setSelectedColor}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
|
||||
|
||||
interface ColorPickerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedColor: string;
|
||||
onColorChange: (color: string) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedColor,
|
||||
onColorChange,
|
||||
title = "Choose Color"
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<MantineColorPicker
|
||||
format="hex"
|
||||
value={selectedColor}
|
||||
onChange={onColorChange}
|
||||
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
|
||||
swatchesPerRow={6}
|
||||
size="lg"
|
||||
fullWidth
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface ColorSwatchButtonProps {
|
||||
color: string;
|
||||
onClick: () => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
|
||||
color,
|
||||
onClick,
|
||||
size = 24
|
||||
}) => {
|
||||
return (
|
||||
<ColorSwatch
|
||||
color={color}
|
||||
size={size}
|
||||
radius={0}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,281 +0,0 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
|
||||
import { ColorSwatchButton } from './ColorPicker';
|
||||
import PenSizeSelector from '../../tools/sign/PenSizeSelector';
|
||||
import SignaturePad from 'signature_pad';
|
||||
|
||||
interface DrawingCanvasProps {
|
||||
selectedColor: string;
|
||||
penSize: number;
|
||||
penSizeInput: string;
|
||||
onColorSwatchClick: () => void;
|
||||
onPenSizeChange: (size: number) => void;
|
||||
onPenSizeInputChange: (input: string) => void;
|
||||
onSignatureDataChange: (data: string | null) => void;
|
||||
onDrawingComplete?: () => void;
|
||||
disabled?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
modalWidth?: number;
|
||||
modalHeight?: number;
|
||||
additionalButtons?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
selectedColor,
|
||||
penSize,
|
||||
penSizeInput,
|
||||
onColorSwatchClick,
|
||||
onPenSizeChange,
|
||||
onPenSizeInputChange,
|
||||
onSignatureDataChange,
|
||||
onDrawingComplete,
|
||||
disabled = false,
|
||||
width = 400,
|
||||
height = 150,
|
||||
}) => {
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const padRef = useRef<SignaturePad | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
||||
|
||||
const initPad = (canvas: HTMLCanvasElement) => {
|
||||
if (!padRef.current) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
|
||||
padRef.current = new SignaturePad(canvas, {
|
||||
penColor: selectedColor,
|
||||
minWidth: penSize * 0.5,
|
||||
maxWidth: penSize * 2.5,
|
||||
throttle: 10,
|
||||
minDistance: 5,
|
||||
velocityFilterWeight: 0.7,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
// Clear pad ref so it reinitializes
|
||||
if (padRef.current) {
|
||||
padRef.current.off();
|
||||
padRef.current = null;
|
||||
}
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const trimCanvas = (canvas: HTMLCanvasElement): string => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return canvas.toDataURL('image/png');
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const pixels = imageData.data;
|
||||
|
||||
let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
|
||||
|
||||
// Find bounds of non-transparent pixels
|
||||
for (let y = 0; y < canvas.height; y++) {
|
||||
for (let x = 0; x < canvas.width; x++) {
|
||||
const alpha = pixels[(y * canvas.width + x) * 4 + 3];
|
||||
if (alpha > 0) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trimWidth = maxX - minX + 1;
|
||||
const trimHeight = maxY - minY + 1;
|
||||
|
||||
// Create trimmed canvas
|
||||
const trimmedCanvas = document.createElement('canvas');
|
||||
trimmedCanvas.width = trimWidth;
|
||||
trimmedCanvas.height = trimHeight;
|
||||
const trimmedCtx = trimmedCanvas.getContext('2d');
|
||||
if (trimmedCtx) {
|
||||
trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
|
||||
}
|
||||
|
||||
return trimmedCanvas.toDataURL('image/png');
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (padRef.current && !padRef.current.isEmpty()) {
|
||||
const canvas = modalCanvasRef.current;
|
||||
if (canvas) {
|
||||
const trimmedPng = trimCanvas(canvas);
|
||||
onSignatureDataChange(trimmedPng);
|
||||
|
||||
// Update preview canvas with proper aspect ratio
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (previewCanvasRef.current) {
|
||||
const ctx = previewCanvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
|
||||
// Calculate scaling to fit within preview canvas while maintaining aspect ratio
|
||||
const scale = Math.min(
|
||||
previewCanvasRef.current.width / img.width,
|
||||
previewCanvasRef.current.height / img.height
|
||||
);
|
||||
const scaledWidth = img.width * scale;
|
||||
const scaledHeight = img.height * scale;
|
||||
const x = (previewCanvasRef.current.width - scaledWidth) / 2;
|
||||
const y = (previewCanvasRef.current.height - scaledHeight) / 2;
|
||||
|
||||
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
img.src = trimmedPng;
|
||||
|
||||
if (onDrawingComplete) {
|
||||
onDrawingComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (padRef.current) {
|
||||
padRef.current.off();
|
||||
padRef.current = null;
|
||||
}
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
if (padRef.current) {
|
||||
padRef.current.clear();
|
||||
}
|
||||
if (previewCanvasRef.current) {
|
||||
const ctx = previewCanvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
}
|
||||
}
|
||||
onSignatureDataChange(null);
|
||||
};
|
||||
|
||||
const updatePenColor = (color: string) => {
|
||||
if (padRef.current) {
|
||||
padRef.current.penColor = color;
|
||||
}
|
||||
};
|
||||
|
||||
const updatePenSize = (size: number) => {
|
||||
if (padRef.current) {
|
||||
padRef.current.minWidth = size * 0.8;
|
||||
padRef.current.maxWidth = size * 1.2;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>Draw your signature</Text>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: '#ffffff',
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={disabled ? undefined : openModal}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Click to open drawing canvas
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
|
||||
<Stack gap="md">
|
||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Color</Text>
|
||||
<Popover
|
||||
opened={colorPickerOpen}
|
||||
onChange={setColorPickerOpen}
|
||||
position="bottom-start"
|
||||
withArrow
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={() => setColorPickerOpen(!colorPickerOpen)}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<MantineColorPicker
|
||||
format="hex"
|
||||
value={selectedColor}
|
||||
onChange={(color) => {
|
||||
onColorSwatchClick();
|
||||
updatePenColor(color);
|
||||
}}
|
||||
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
|
||||
<PenSizeSelector
|
||||
value={penSize}
|
||||
inputValue={penSizeInput}
|
||||
onValueChange={(size) => {
|
||||
onPenSizeChange(size);
|
||||
updatePenSize(size);
|
||||
}}
|
||||
onInputChange={onPenSizeInputChange}
|
||||
placeholder="Size"
|
||||
size="compact-sm"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas
|
||||
ref={(el) => {
|
||||
modalCanvasRef.current = el;
|
||||
if (el) initPad(el);
|
||||
}}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
display: 'block',
|
||||
touchAction: 'none',
|
||||
backgroundColor: 'white',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
height: '400px',
|
||||
cursor: 'crosshair',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button variant="subtle" color="red" onClick={clear}>
|
||||
Clear Canvas
|
||||
</Button>
|
||||
<Button onClick={closeModal}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawingCanvas;
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Group, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface DrawingControlsProps {
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
onPlaceSignature?: () => void;
|
||||
hasSignatureData?: boolean;
|
||||
disabled?: boolean;
|
||||
showPlaceButton?: boolean;
|
||||
placeButtonText?: string;
|
||||
}
|
||||
|
||||
export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPlaceSignature,
|
||||
hasSignatureData = false,
|
||||
disabled = false,
|
||||
showPlaceButton = true,
|
||||
placeButtonText = "Update and Place"
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group gap="sm">
|
||||
{/* Undo/Redo Controls */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUndo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.undo', 'Undo')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRedo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.redo', 'Redo')}
|
||||
</Button>
|
||||
|
||||
{/* Place Signature Button */}
|
||||
{showPlaceButton && onPlaceSignature && (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={onPlaceSignature}
|
||||
disabled={disabled || !hasSignatureData}
|
||||
flex={1}
|
||||
>
|
||||
{placeButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FileInput, Text, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ImageUploaderProps {
|
||||
onImageChange: (file: File | null) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
onImageChange,
|
||||
disabled = false,
|
||||
label,
|
||||
placeholder,
|
||||
hint
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleImageChange = async (file: File | null) => {
|
||||
if (file && !disabled) {
|
||||
try {
|
||||
// Validate that it's actually an image file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
console.error('Selected file is not an image');
|
||||
return;
|
||||
}
|
||||
|
||||
onImageChange(file);
|
||||
} catch (error) {
|
||||
console.error('Error processing image file:', error);
|
||||
}
|
||||
} else if (!file) {
|
||||
// Clear image data when no file is selected
|
||||
onImageChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<FileInput
|
||||
label={label || t('sign.image.label', 'Upload signature image')}
|
||||
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{hint || t('sign.image.hint', 'Upload an image of your signature')}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,172 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
|
||||
interface TextInputWithFontProps {
|
||||
text: string;
|
||||
onTextChange: (text: string) => void;
|
||||
fontSize: number;
|
||||
onFontSizeChange: (size: number) => void;
|
||||
fontFamily: string;
|
||||
onFontFamilyChange: (family: string) => void;
|
||||
textColor?: string;
|
||||
onTextColorChange?: (color: string) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
text,
|
||||
onTextChange,
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
fontFamily,
|
||||
onFontFamilyChange,
|
||||
textColor = '#000000',
|
||||
onTextColorChange,
|
||||
disabled = false,
|
||||
label,
|
||||
placeholder
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||
const fontSizeCombobox = useCombobox();
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
|
||||
// Sync font size input with prop changes
|
||||
useEffect(() => {
|
||||
setFontSizeInput(fontSize.toString());
|
||||
}, [fontSize]);
|
||||
|
||||
const fontOptions = [
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: 'Times-Roman', label: 'Times' },
|
||||
{ value: 'Courier', label: 'Courier' },
|
||||
{ value: 'Arial', label: 'Arial' },
|
||||
{ value: 'Georgia', label: 'Georgia' },
|
||||
];
|
||||
|
||||
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={label || t('sign.text.name', 'Signer Name')}
|
||||
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
|
||||
value={text}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Font Selection */}
|
||||
<Select
|
||||
label="Font"
|
||||
value={fontFamily}
|
||||
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
|
||||
data={fontOptions}
|
||||
disabled={disabled}
|
||||
searchable
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
{/* Font Size and Color */}
|
||||
<Group grow>
|
||||
<Combobox
|
||||
onOptionSubmit={(optionValue) => {
|
||||
setFontSizeInput(optionValue);
|
||||
const size = parseInt(optionValue);
|
||||
if (!isNaN(size)) {
|
||||
onFontSizeChange(size);
|
||||
}
|
||||
fontSizeCombobox.closeDropdown();
|
||||
}}
|
||||
store={fontSizeCombobox}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<TextInput
|
||||
label="Font Size"
|
||||
placeholder="Type or select font size (8-200)"
|
||||
value={fontSizeInput}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setFontSizeInput(value);
|
||||
|
||||
// Parse and validate the typed value in real-time
|
||||
const size = parseInt(value);
|
||||
if (!isNaN(size) && size >= 8 && size <= 200) {
|
||||
onFontSizeChange(size);
|
||||
}
|
||||
|
||||
fontSizeCombobox.openDropdown();
|
||||
fontSizeCombobox.updateSelectedOptionIndex();
|
||||
}}
|
||||
onClick={() => fontSizeCombobox.openDropdown()}
|
||||
onFocus={() => fontSizeCombobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
fontSizeCombobox.closeDropdown();
|
||||
// Clean up invalid values on blur
|
||||
const size = parseInt(fontSizeInput);
|
||||
if (isNaN(size) || size < 8 || size > 200) {
|
||||
setFontSizeInput(fontSize.toString());
|
||||
} else {
|
||||
onFontSizeChange(size);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{fontSizeOptions.map((size) => (
|
||||
<Combobox.Option value={size} key={size}>
|
||||
{size}px
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
|
||||
{/* Text Color Picker */}
|
||||
{onTextColorChange && (
|
||||
<Box>
|
||||
<TextInput
|
||||
label="Text Color"
|
||||
value={textColor}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
rightSection={
|
||||
<Box
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
backgroundColor: textColor,
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
cursor: disabled ? 'default' : 'pointer'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
{onTextColorChange && (
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={textColor}
|
||||
onColorChange={onTextColorChange}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
|
||||
import { DrawingCanvas } from '../shared/DrawingCanvas';
|
||||
|
||||
interface DrawingToolProps {
|
||||
onDrawingChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DrawingTool: React.FC<DrawingToolProps> = ({
|
||||
onDrawingChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [selectedColor] = useState('#000000');
|
||||
const [penSize, setPenSize] = useState(2);
|
||||
const [penSizeInput, setPenSizeInput] = useState('2');
|
||||
|
||||
const toolConfig = {
|
||||
enableDrawing: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Drawing"
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={onDrawingChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<DrawingCanvas
|
||||
selectedColor={selectedColor}
|
||||
penSize={penSize}
|
||||
penSizeInput={penSizeInput}
|
||||
onColorSwatchClick={() => {}} // Color picker handled by BaseAnnotationTool
|
||||
onPenSizeChange={setPenSize}
|
||||
onPenSizeInputChange={setPenSizeInput}
|
||||
onSignatureDataChange={onDrawingChange || (() => {})}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
|
||||
import { ImageUploader } from '../shared/ImageUploader';
|
||||
|
||||
interface ImageToolProps {
|
||||
onImageChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ImageTool: React.FC<ImageToolProps> = ({
|
||||
onImageChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [, setImageData] = useState<string | null>(null);
|
||||
|
||||
const handleImageUpload = async (file: File | null) => {
|
||||
if (file && !disabled) {
|
||||
try {
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (e.target?.result) {
|
||||
resolve(e.target.result as string);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
setImageData(result);
|
||||
onImageChange?.(result);
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
} else if (!file) {
|
||||
setImageData(null);
|
||||
onImageChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toolConfig = {
|
||||
enableImageUpload: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Image"
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={onImageChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<ImageUploader
|
||||
onImageChange={handleImageUpload}
|
||||
disabled={disabled}
|
||||
label="Upload Image"
|
||||
placeholder="Select image file"
|
||||
hint="Upload a PNG, JPG, or other image file to place on the PDF"
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
|
||||
import { TextInputWithFont } from '../shared/TextInputWithFont';
|
||||
|
||||
interface TextToolProps {
|
||||
onTextChange?: (text: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TextTool: React.FC<TextToolProps> = ({
|
||||
onTextChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [text, setText] = useState('');
|
||||
const [fontSize, setFontSize] = useState(16);
|
||||
const [fontFamily, setFontFamily] = useState('Helvetica');
|
||||
|
||||
const handleTextChange = (newText: string) => {
|
||||
setText(newText);
|
||||
onTextChange?.(newText);
|
||||
};
|
||||
|
||||
const handleSignatureDataChange = (data: string | null) => {
|
||||
if (data) {
|
||||
onTextChange?.(data);
|
||||
}
|
||||
};
|
||||
|
||||
const toolConfig = {
|
||||
enableTextInput: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Text"
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={handleSignatureDataChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<TextInputWithFont
|
||||
text={text}
|
||||
onTextChange={handleTextChange}
|
||||
fontSize={fontSize}
|
||||
onFontSizeChange={setFontSize}
|
||||
fontFamily={fontFamily}
|
||||
onFontFamilyChange={setFontFamily}
|
||||
disabled={disabled}
|
||||
label="Text Content"
|
||||
placeholder="Enter text to place on the PDF"
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
@@ -1,177 +0,0 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
import styles from './FileEditor.module.css';
|
||||
|
||||
interface AddFileCardProps {
|
||||
onFileSelect: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const AddFileCard = ({
|
||||
onFileSelect,
|
||||
accept,
|
||||
multiple = true
|
||||
}: AddFileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [isUploadHover, setIsUploadHover] = useState(false);
|
||||
|
||||
const handleCardClick = () => {
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleNativeUploadClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleOpenFilesModal = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
onFileSelect(files);
|
||||
}
|
||||
// Reset input so same files can be selected again
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={t('fileEditor.addFiles', 'Add files')}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleCardClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header bar - matches FileEditorThumbnail structure */}
|
||||
<div className={`${styles.header} ${styles.addFileHeader}`}>
|
||||
<div className={styles.logoMark}>
|
||||
<AddIcon sx={{ color: 'inherit', fontSize: '1.5rem' }} />
|
||||
</div>
|
||||
<div className={styles.headerIndex}>
|
||||
{t('fileEditor.addFiles', 'Add Files')}
|
||||
</div>
|
||||
<div className={styles.kebab} />
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className={styles.addFileContent}>
|
||||
{/* Stirling PDF Branding */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.6rem',
|
||||
width: '100%',
|
||||
marginTop: '0.8rem',
|
||||
marginBottom: '0.8rem'
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '2rem',
|
||||
height: '38px',
|
||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||
paddingRight: isUploadHover ? 0 : '1rem',
|
||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||
minWidth: isUploadHover ? '58px' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||
{!isUploadHover && (
|
||||
<span>
|
||||
{t('landing.addFiles', 'Add Files')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? 'calc(100% - 58px - 0.6rem)' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddFileCard;
|
||||
@@ -34,9 +34,9 @@
|
||||
.header {
|
||||
height: 2.25rem;
|
||||
border-radius: 0.0625rem 0.0625rem 0 0;
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 6px;
|
||||
user-select: none;
|
||||
background: var(--bg-toolbar);
|
||||
@@ -86,23 +86,14 @@
|
||||
}
|
||||
|
||||
.headerIndex {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.headerIconButton {
|
||||
.kebab {
|
||||
justify-self: end;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
@@ -225,11 +216,6 @@
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.pinned {
|
||||
color: #FFC107 !important;
|
||||
}
|
||||
|
||||
|
||||
/* Unsupported file indicator */
|
||||
.unsupportedPill {
|
||||
margin-left: 1.75rem;
|
||||
@@ -318,84 +304,4 @@
|
||||
/* Light mode selected header stroke override */
|
||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||
outline-color: #3B4B6E;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Add File Card Styles
|
||||
========================= */
|
||||
|
||||
.addFileCard {
|
||||
background: var(--file-card-bg);
|
||||
border: 2px dashed var(--border-default);
|
||||
border-radius: 0.0625rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.addFileCard:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-blue-500);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.addFileCard:focus {
|
||||
outline: 2px solid var(--color-blue-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.addFileHeader {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileHeader {
|
||||
background: var(--color-blue-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.addFileContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.addFileIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-subtle);
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileIcon {
|
||||
background: var(--color-blue-50);
|
||||
}
|
||||
|
||||
.addFileText {
|
||||
font-weight: 500;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileText {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addFileSubtext {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Text, Center, Box, LoadingOverlay, Stack, Group
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
|
||||
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
|
||||
import { useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
import FileEditorThumbnail from './FileEditorThumbnail';
|
||||
import AddFileCard from './AddFileCard';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||
@@ -37,7 +36,6 @@ const FileEditor = ({
|
||||
// Use optimized FileContext hooks
|
||||
const { state, selectors } = useFileState();
|
||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
||||
@@ -62,7 +60,7 @@ const FileEditor = ({
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (toolMode) {
|
||||
setSelectionMode(true);
|
||||
}
|
||||
@@ -173,8 +171,8 @@ const FileEditor = ({
|
||||
|
||||
// Process all extracted files
|
||||
if (allExtractedFiles.length > 0) {
|
||||
// Add files to context and select them automatically
|
||||
await addFiles(allExtractedFiles, { selectFiles: true });
|
||||
// Add files to context (they will be processed automatically)
|
||||
await addFiles(allExtractedFiles);
|
||||
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -288,7 +286,7 @@ const FileEditor = ({
|
||||
|
||||
|
||||
// File operations using context
|
||||
const handleCloseFile = useCallback((fileId: FileId) => {
|
||||
const handleDeleteFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
@@ -310,48 +308,6 @@ const FileEditor = ({
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, _setStatus]);
|
||||
|
||||
const handleUnzipFile = useCallback(async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
try {
|
||||
// Extract and store files using shared service method
|
||||
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
|
||||
|
||||
if (result.success && result.extractedStubs.length > 0) {
|
||||
// Add extracted file stubs to FileContext
|
||||
await actions.addStirlingFileStubs(result.extractedStubs);
|
||||
|
||||
// Remove the original ZIP file
|
||||
removeFiles([fileId], false);
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: `Failed to extract files from ${file.name}`,
|
||||
body: result.errors.join('\n'),
|
||||
expandable: true,
|
||||
durationMs: 3500
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to unzip file:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: `Error unzipping ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, actions, removeFiles]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (record) {
|
||||
@@ -391,7 +347,7 @@ const FileEditor = ({
|
||||
<Box pos="relative" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={false} />
|
||||
|
||||
<Box p="md">
|
||||
<Box p="md" pt="xl">
|
||||
|
||||
|
||||
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||
@@ -449,14 +405,6 @@ const FileEditor = ({
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{/* Add File Card - only show when files exist */}
|
||||
{activeStirlingFileStubs.length > 0 && (
|
||||
<AddFileCard
|
||||
key="add-file-card"
|
||||
onFileSelect={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
return (
|
||||
<FileEditorThumbnail
|
||||
@@ -467,12 +415,11 @@ const FileEditor = ({
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
onCloseFile={handleCloseFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onViewFile={handleViewFile}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
onUnzipFile={handleUnzipFile}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
/>
|
||||
@@ -490,7 +437,7 @@ const FileEditor = ({
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
</Box>
|
||||
</Dropzone>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip } from '@mantine/core';
|
||||
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||
import { alert } from '../toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
|
||||
import styles from './FileEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
@@ -29,12 +27,11 @@ interface FileEditorThumbnailProps {
|
||||
selectedFiles: FileId[];
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onCloseFile: (fileId: FileId) => void;
|
||||
onDeleteFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
_onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile: (fileId: FileId) => void;
|
||||
onUnzipFile?: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
}
|
||||
@@ -44,12 +41,10 @@ const FileEditorThumbnail = ({
|
||||
index,
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onCloseFile,
|
||||
onViewFile,
|
||||
onDeleteFile,
|
||||
_onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -69,9 +64,6 @@ const FileEditorThumbnail = ({
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
// Check if this is a ZIP file
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
|
||||
const pageCount = file.processedFile?.totalPages || 0;
|
||||
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
@@ -206,11 +198,6 @@ const FileEditorThumbnail = ({
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
const handleCardDoubleClick = () => {
|
||||
if (!isSupported) return;
|
||||
onViewFile(file.id);
|
||||
};
|
||||
|
||||
// ---- Style helpers ----
|
||||
const getHeaderClassName = () => {
|
||||
if (hasError) return styles.headerError;
|
||||
@@ -232,7 +219,6 @@ const FileEditorThumbnail = ({
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
onClick={handleCardClick}
|
||||
onDoubleClick={handleCardDoubleClick}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
@@ -265,60 +251,18 @@ const FileEditorThumbnail = ({
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Action buttons group */}
|
||||
<div className={styles.headerActions}>
|
||||
{/* Pin/Unpin icon */}
|
||||
<Tooltip label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}>
|
||||
<ActionIcon
|
||||
aria-label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}
|
||||
variant="subtle"
|
||||
className={isPinned ? styles.pinned : styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Download icon */}
|
||||
<Tooltip label={t('download', 'Download')}>
|
||||
<ActionIcon
|
||||
aria-label={t('download', 'Download')}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadFile(file.id);
|
||||
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
}}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Kebab menu */}
|
||||
<ActionIcon
|
||||
aria-label={t('moreOptions', 'More options')}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions((v) => !v);
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
{/* Kebab menu */}
|
||||
<ActionIcon
|
||||
aria-label={t('moreOptions', 'More options')}
|
||||
variant="subtle"
|
||||
className={styles.kebab}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions((v) => !v);
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
{/* Actions overlay */}
|
||||
@@ -343,7 +287,7 @@ const FileEditorThumbnail = ({
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon className={styles.pinned} fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
|
||||
</button>
|
||||
|
||||
@@ -355,28 +299,18 @@ const FileEditorThumbnail = ({
|
||||
<span>{t('download', 'Download')}</span>
|
||||
</button>
|
||||
|
||||
{isZipFile && onUnzipFile && (
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { onUnzipFile(file.id); alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
|
||||
>
|
||||
<UnarchiveIcon fontSize="small" />
|
||||
<span>{t('fileManager.unzip', 'Unzip')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={styles.actionsDivider} />
|
||||
|
||||
<button
|
||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||
onClick={() => {
|
||||
onCloseFile(file.id);
|
||||
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
onDeleteFile(file.id);
|
||||
alert({ alertType: 'neutral', title: `Deleted ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
<span>{t('close', 'Close')}</span>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
<span>{t('delete', 'Delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -390,7 +324,7 @@ const FileEditorThumbnail = ({
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
<Text size="lg" fw={700} className={`${styles.title} ph-no-capture `} lineClamp={2}>
|
||||
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text
|
||||
@@ -416,7 +350,6 @@ const FileEditorThumbnail = ({
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnailUrl && (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={file.thumbnailUrl}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
@@ -443,6 +376,13 @@ const FileEditorThumbnail = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pin indicator (bottom-left) */}
|
||||
{isPinned && (
|
||||
<span className={styles.pinIndicator} aria-hidden>
|
||||
<PushPinIcon fontSize="small" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
|
||||
@@ -42,7 +42,6 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{currentFile && thumbnail ? (
|
||||
<img
|
||||
className='ph-no-capture'
|
||||
src={thumbnail}
|
||||
alt={currentFile.name}
|
||||
style={{
|
||||
@@ -67,7 +66,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
|
||||
{/* File info */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text className='ph-no-capture' size="sm" fw={500} truncate>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{currentFile ? currentFile.name : 'No file selected'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
|
||||
const EmptyFilesState: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { onLocalFileClick } = useFileManagerContext();
|
||||
const [isUploadHover, setIsUploadHover] = useState(false);
|
||||
|
||||
const handleUploadClick = () => {
|
||||
onLocalFileClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem'
|
||||
}}
|
||||
>
|
||||
{/* Container */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
padding: '3rem 2rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1.5rem',
|
||||
minWidth: '20rem',
|
||||
maxWidth: '28rem',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{/* No Recent Files Message */}
|
||||
<Stack align="center" gap="sm">
|
||||
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
||||
<Text c="dimmed" ta="center" size="lg">
|
||||
{t('fileManager.noRecentFiles', 'No recent files')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Stirling PDF Logo */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Upload Button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem'
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-file-manager)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: isUploadHover ? '2rem' : '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? '100%' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease, border-radius .5s ease'
|
||||
}}
|
||||
onClick={handleUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyFilesState;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, Button, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
|
||||
@@ -50,7 +50,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
};
|
||||
|
||||
// Reset index when selection changes
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (currentFileIndex >= selectedFiles.length) {
|
||||
setCurrentFileIndex(0);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
</Group>
|
||||
|
||||
<Box ml="md">
|
||||
{sortedHistory.map((historyFile) => (
|
||||
{sortedHistory.map((historyFile, _index) => (
|
||||
<FileListItem
|
||||
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
|
||||
file={historyFile}
|
||||
@@ -56,6 +56,7 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
onDoubleClick={() => onFileDoubleClick(historyFile)}
|
||||
isHistoryFile={true} // This enables "Add to Recents" in menu
|
||||
isLatestVersion={false} // History files are never latest
|
||||
// onAddToRecents is accessed from context by FileListItem
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -26,7 +26,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
<ScrollArea style={{ flex: 1 }} p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text className='ph-no-capture' size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
|
||||
<Text size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
|
||||
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
|
||||
{currentFile ? currentFile.name : ''}
|
||||
</Text>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Center, ScrollArea, Text, Stack } from '@mantine/core';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileListItem from './FileListItem';
|
||||
import FileHistoryGroup from './FileHistoryGroup';
|
||||
import EmptyFilesState from './EmptyFilesState';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
|
||||
interface FileListAreaProps {
|
||||
@@ -29,7 +29,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isFileSupported,
|
||||
isLoading,
|
||||
} = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -44,11 +43,15 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
scrollbarSize={8}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{recentFiles.length === 0 && !isLoading ? (
|
||||
<EmptyFilesState />
|
||||
) : recentFiles.length === 0 && isLoading ? (
|
||||
{recentFiles.length === 0 ? (
|
||||
<Center style={{ height: '12.5rem' }}>
|
||||
<Text c="dimmed" ta="center">{t('fileManager.loadingFiles', 'Loading files...')}</Text>
|
||||
<Stack align="center" gap="sm">
|
||||
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
||||
<Text c="dimmed" ta="center">{t('fileManager.noRecentFiles', 'No recent files')}</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" style={{ opacity: 0.7 }}>
|
||||
{t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
filteredFiles.map((file, index) => {
|
||||
|
||||
@@ -5,12 +5,10 @@ import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import RestoreIcon from '@mui/icons-material/Restore';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||
import { FileId, StirlingFileStub } from '../../types/fileContext';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
|
||||
interface FileListItemProps {
|
||||
@@ -40,10 +38,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
|
||||
|
||||
// Check if this is a ZIP file
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
|
||||
|
||||
// Keep item in hovered state if menu is open
|
||||
const shouldShowHovered = isHovered || isMenuOpen;
|
||||
@@ -98,7 +93,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" fw={500} className='ph-no-capture' truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
<Badge size="xs" variant="light" color={"blue"}>
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
@@ -188,6 +183,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddToRecents(file);
|
||||
}}
|
||||
>
|
||||
{t('fileManager.restore', 'Restore')}
|
||||
@@ -196,22 +192,6 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Unzip option for ZIP files */}
|
||||
{isZipFile && !isHistoryFile && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<UnarchiveIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnzipFile(file);
|
||||
}}
|
||||
>
|
||||
{t('fileManager.unzip', 'Unzip')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import UploadIcon from '@mui/icons-material/Upload';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import { useGoogleDrivePicker } from '../../hooks/useGoogleDrivePicker';
|
||||
|
||||
interface FileSourceButtonsProps {
|
||||
horizontal?: boolean;
|
||||
@@ -14,20 +13,8 @@ interface FileSourceButtonsProps {
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
horizontal = false
|
||||
}) => {
|
||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
|
||||
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
||||
|
||||
const handleGoogleDriveClick = async () => {
|
||||
try {
|
||||
const files = await openGoogleDrivePicker({ multiple: true });
|
||||
if (files.length > 0) {
|
||||
onGoogleDriveSelect(files);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick files from Google Drive:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonProps = {
|
||||
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
|
||||
@@ -80,24 +67,15 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color='var(--mantine-color-gray-6)'
|
||||
variant={buttonProps.variant('drive')}
|
||||
leftSection={<CloudIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
onClick={handleGoogleDriveClick}
|
||||
onClick={() => onSourceChange('drive')}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
disabled={!isGoogleDriveEnabled}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: isGoogleDriveEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={!isGoogleDriveEnabled ? t('fileManager.googleDriveNotAvailable', 'Google Drive integration not available') : undefined}
|
||||
disabled
|
||||
color={activeSource === 'drive' ? 'gray' : undefined}
|
||||
styles={buttonProps.getStyles('drive')}
|
||||
>
|
||||
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
||||
</Button>
|
||||
|
||||
@@ -9,6 +9,7 @@ const HiddenFileInput: React.FC = () => {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={true}
|
||||
accept={["*/*"] as any}
|
||||
onChange={onFileInputChange}
|
||||
style={{ display: 'none' }}
|
||||
data-testid="file-input"
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
import { HotkeyBinding } from '../../utils/hotkeys';
|
||||
import { useHotkeys } from '../../contexts/HotkeyContext';
|
||||
|
||||
interface HotkeyDisplayProps {
|
||||
binding: HotkeyBinding | null | undefined;
|
||||
size?: 'sm' | 'md';
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
const baseKeyStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'var(--mantine-color-gray-1)',
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: '0.125rem 0.35rem',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1,
|
||||
fontFamily: 'var(--mantine-font-family-monospace, monospace)',
|
||||
minWidth: '1.35rem',
|
||||
color: 'var(--mantine-color-text)',
|
||||
};
|
||||
|
||||
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 'sm', muted = false }) => {
|
||||
const { getDisplayParts } = useHotkeys();
|
||||
const parts = getDisplayParts(binding);
|
||||
|
||||
if (!binding || parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyStyle = size === 'md'
|
||||
? { ...baseKeyStyle, fontSize: '0.85rem', padding: '0.2rem 0.5rem' }
|
||||
: baseKeyStyle;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
color: muted ? 'var(--mantine-color-dimmed)' : 'inherit',
|
||||
fontWeight: muted ? 500 : 600,
|
||||
}}
|
||||
>
|
||||
{parts.map((part, index) => (
|
||||
<React.Fragment key={`${part}-${index}`}>
|
||||
<kbd style={keyStyle}>{part}</kbd>
|
||||
{index < parts.length - 1 && <span aria-hidden style={{ fontWeight: 400 }}>+</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotkeyDisplay;
|
||||
@@ -1,12 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||
import { useFileState } from '../../contexts/FileContext';
|
||||
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { isBaseWorkbench } from '../../types/workbench';
|
||||
import { useViewer } from '../../contexts/ViewerContext';
|
||||
import './Workbench.css';
|
||||
|
||||
import TopControls from '../shared/TopControls';
|
||||
@@ -23,19 +20,18 @@ export default function Workbench() {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { selectors } = useFileState();
|
||||
const { state } = useFileState();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
const activeFiles = selectors.getFiles();
|
||||
const activeFiles = state.files.ids;
|
||||
const {
|
||||
previewFile,
|
||||
pageEditorFunctions,
|
||||
sidebarsVisible,
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSidebarsVisible,
|
||||
customWorkbenchViews,
|
||||
setSidebarsVisible
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { handleToolSelect } = useToolWorkflow();
|
||||
@@ -48,9 +44,6 @@ export default function Workbench() {
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addFiles } = useFileHandler();
|
||||
|
||||
// Get active file index from ViewerContext
|
||||
const { activeFileIndex, setActiveFileIndex } = useViewer();
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
setPreviewFile(null);
|
||||
const previousMode = sessionStorage.getItem('previousMode');
|
||||
@@ -102,8 +95,6 @@ export default function Workbench() {
|
||||
setSidebarsVisible={setSidebarsVisible}
|
||||
previewFile={previewFile}
|
||||
onClose={handlePreviewClose}
|
||||
activeFileIndex={activeFileIndex}
|
||||
setActiveFileIndex={setActiveFileIndex}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -139,14 +130,9 @@ export default function Workbench() {
|
||||
);
|
||||
|
||||
default:
|
||||
if (!isBaseWorkbench(currentView)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||
if (customView) {
|
||||
const CustomComponent = customView.component;
|
||||
return <CustomComponent data={customView.data} />;
|
||||
}
|
||||
}
|
||||
return <LandingPage />;
|
||||
return (
|
||||
<LandingPage/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,19 +146,11 @@ export default function Workbench() {
|
||||
}
|
||||
>
|
||||
{/* Top Controls */}
|
||||
{activeFiles.length > 0 && (
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
customViews={customWorkbenchViews}
|
||||
activeFiles={activeFiles.map(f => {
|
||||
const stub = selectors.getStirlingFileStub(f.fileId);
|
||||
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
|
||||
})}
|
||||
currentFileIndex={activeFileIndex}
|
||||
onFileSelect={setActiveFileIndex}
|
||||
/>
|
||||
)}
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
selectedToolKey={selectedToolId}
|
||||
/>
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
@@ -182,7 +160,6 @@ export default function Workbench() {
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
|
||||
@@ -317,7 +317,6 @@ const FileThumbnail = ({
|
||||
>
|
||||
{file.thumbnail && (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={file.thumbnail}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { useFileState, useFileActions } from "../../contexts/FileContext";
|
||||
import { useNavigationGuard } from "../../contexts/NavigationContext";
|
||||
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
|
||||
import { pdfExportService } from "../../services/pdfExportService";
|
||||
import { documentManipulationService } from "../../services/documentManipulationService";
|
||||
import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers";
|
||||
import { createStirlingFilesAndStubs } from "../../services/fileStubHelpers";
|
||||
// Thumbnail generation is now handled by individual PageThumbnail components
|
||||
import './PageEditor.module.css';
|
||||
import PageThumbnail from './PageThumbnail';
|
||||
@@ -39,9 +36,6 @@ const PageEditor = ({
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges } = useNavigationGuard();
|
||||
|
||||
// Prefer IDs + selectors to avoid array identity churn
|
||||
const activeFileIds = state.files.ids;
|
||||
|
||||
@@ -88,12 +82,6 @@ const PageEditor = ({
|
||||
updateUndoRedoState();
|
||||
}, [updateUndoRedoState]);
|
||||
|
||||
// Wrapper for executeCommand to track unsaved changes
|
||||
const executeCommandWithTracking = useCallback((command: any) => {
|
||||
undoManagerRef.current.executeCommand(command);
|
||||
setHasUnsavedChanges(true);
|
||||
}, [setHasUnsavedChanges]);
|
||||
|
||||
// Watch for container size changes to update split line positions
|
||||
useEffect(() => {
|
||||
const container = gridContainerRef.current;
|
||||
@@ -150,16 +138,17 @@ const PageEditor = ({
|
||||
// DOM-first command handlers
|
||||
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
}, [executeCommandWithTracking]);
|
||||
undoManagerRef.current.executeCommand(bulkRotateCommand);
|
||||
}, []);
|
||||
|
||||
// Command factory functions for PageThumbnail
|
||||
const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({
|
||||
execute: () => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
|
||||
undoManagerRef.current.executeCommand(bulkRotateCommand);
|
||||
}
|
||||
}), [executeCommandWithTracking]);
|
||||
}), []);
|
||||
|
||||
const createDeleteCommand = useCallback((pageIds: string[]) => ({
|
||||
execute: () => {
|
||||
@@ -185,10 +174,10 @@ const PageEditor = ({
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
closePdf
|
||||
);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
}
|
||||
}
|
||||
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
|
||||
|
||||
const createSplitCommand = useCallback((position: number) => ({
|
||||
execute: () => {
|
||||
@@ -197,9 +186,9 @@ const PageEditor = ({
|
||||
() => splitPositions,
|
||||
setSplitPositions
|
||||
);
|
||||
executeCommandWithTracking(splitCommand);
|
||||
undoManagerRef.current.executeCommand(splitCommand);
|
||||
}
|
||||
}), [splitPositions, executeCommandWithTracking]);
|
||||
}), [splitPositions]);
|
||||
|
||||
// Command executor for PageThumbnail
|
||||
const executeCommand = useCallback((command: any) => {
|
||||
@@ -243,8 +232,8 @@ const PageEditor = ({
|
||||
() => selectedPageNumbers,
|
||||
closePdf
|
||||
);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers, executeCommandWithTracking]);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers]);
|
||||
|
||||
const handleDeletePage = useCallback((pageNumber: number) => {
|
||||
if (!displayDocument) return;
|
||||
@@ -262,8 +251,8 @@ const PageEditor = ({
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
closePdf
|
||||
);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
|
||||
|
||||
const handleSplit = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -309,8 +298,8 @@ const PageEditor = ({
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
|
||||
};
|
||||
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
undoManagerRef.current.executeCommand(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
|
||||
|
||||
const handleSplitAll = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -355,8 +344,8 @@ const PageEditor = ({
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
|
||||
};
|
||||
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
undoManagerRef.current.executeCommand(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
|
||||
|
||||
const handlePageBreak = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -369,8 +358,8 @@ const PageEditor = ({
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
undoManagerRef.current.executeCommand(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
|
||||
|
||||
const handlePageBreakAll = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -383,8 +372,8 @@ const PageEditor = ({
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
undoManagerRef.current.executeCommand(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
|
||||
|
||||
const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => {
|
||||
if (!displayDocument || files.length === 0) return;
|
||||
@@ -427,8 +416,8 @@ const PageEditor = ({
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
executeCommandWithTracking(reorderCommand);
|
||||
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
undoManagerRef.current.executeCommand(reorderCommand);
|
||||
}, [displayDocument, getPageNumbersFromIds]);
|
||||
|
||||
// Helper function to collect source files for multi-file export
|
||||
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
|
||||
@@ -510,14 +499,13 @@ const PageEditor = ({
|
||||
|
||||
// Step 4: Download the result
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
|
||||
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]);
|
||||
|
||||
const onExportAll = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
@@ -526,79 +514,87 @@ const PageEditor = ({
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
mergedPdfDocument || displayDocument, // Original order
|
||||
displayDocument, // Current display order (includes reordering)
|
||||
splitPositions // Position-based splits
|
||||
);
|
||||
|
||||
// Step 2: Export to files
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
|
||||
// Step 2: Check if we have multiple documents (splits) or single document
|
||||
if (Array.isArray(processedDocuments)) {
|
||||
// Multiple documents (splits) - export as ZIP
|
||||
const blobs: Blob[] = [];
|
||||
const filenames: string[] = [];
|
||||
|
||||
// Step 3: Download
|
||||
if (files.length > 1) {
|
||||
// Multiple files - create ZIP
|
||||
const sourceFiles = getSourceFiles();
|
||||
const baseExportFilename = getExportFilename();
|
||||
const baseName = baseExportFilename.replace(/\.pdf$/i, '');
|
||||
|
||||
for (let i = 0; i < processedDocuments.length; i++) {
|
||||
const doc = processedDocuments[i];
|
||||
const partFilename = `${baseName}_part_${i + 1}.pdf`;
|
||||
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename })
|
||||
: await pdfExportService.exportPDF(doc, [], { filename: partFilename });
|
||||
blobs.push(result.blob);
|
||||
filenames.push(result.filename);
|
||||
}
|
||||
|
||||
// Create ZIP file
|
||||
const JSZip = await import('jszip');
|
||||
const zip = new JSZip.default();
|
||||
|
||||
files.forEach((file) => {
|
||||
zip.file(file.name, file);
|
||||
blobs.forEach((blob, index) => {
|
||||
zip.file(filenames[index], blob);
|
||||
});
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const exportFilename = getExportFilename();
|
||||
const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip');
|
||||
const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip');
|
||||
|
||||
pdfExportService.downloadFile(zipBlob, zipFilename);
|
||||
} else {
|
||||
// Single file - download directly
|
||||
const file = files[0];
|
||||
pdfExportService.downloadFile(file, file.name);
|
||||
// Single document - regular export
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(
|
||||
processedDocuments,
|
||||
sourceFiles,
|
||||
[],
|
||||
{ selectedOnly: false, filename: exportFilename }
|
||||
)
|
||||
: await pdfExportService.exportPDF(
|
||||
processedDocuments,
|
||||
[],
|
||||
{ selectedOnly: false, filename: exportFilename }
|
||||
);
|
||||
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]);
|
||||
|
||||
// Apply DOM changes to document state using dedicated service
|
||||
const applyChanges = useCallback(async () => {
|
||||
const applyChanges = useCallback(() => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
// Pass current display document (which includes reordering) to get both reordering AND DOM changes
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument, // Original order
|
||||
displayDocument, // Current display order (includes reordering)
|
||||
splitPositions // Position-based splits
|
||||
);
|
||||
|
||||
// Step 2: Export to files
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
|
||||
// For apply changes, we only set the first document if it's an array (splits shouldn't affect document state)
|
||||
const documentToSet = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
|
||||
setEditedDocument(documentToSet);
|
||||
|
||||
// Step 3: Create StirlingFiles and stubs for version history
|
||||
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
|
||||
if (!parentStub) throw new Error('Parent stub not found');
|
||||
|
||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool');
|
||||
|
||||
// Step 4: Consume files (replace in context)
|
||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Apply changes failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]);
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions]);
|
||||
|
||||
|
||||
const closePdf = useCallback(() => {
|
||||
@@ -662,7 +658,7 @@ const PageEditor = ({
|
||||
const displayedPages = displayDocument?.pages || [];
|
||||
|
||||
return (
|
||||
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||
<Box pos="relative" h='100%' pt={40} style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
|
||||
|
||||
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
|
||||
@@ -783,14 +779,7 @@ const PageEditor = ({
|
||||
)}
|
||||
|
||||
|
||||
<NavigationWarningModal
|
||||
onApplyAndContinue={async () => {
|
||||
await applyChanges();
|
||||
}}
|
||||
onExportAndContinue={async () => {
|
||||
await onExportAll();
|
||||
}}
|
||||
/>
|
||||
<NavigationWarningModal />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -371,11 +371,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
</div>
|
||||
) : thumbnailUrl ? (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={thumbnailUrl}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
draggable={false}
|
||||
data-original-rotation={page.rotation}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
||||
@@ -17,34 +17,32 @@ export class RotatePageCommand extends DOMCommand {
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Only update DOM for immediate visual feedback
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
// Extract current rotation from transform property to match the animated CSS
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
let newRotation = currentRotation + this.degrees;
|
||||
|
||||
newRotation = ((newRotation % 360) + 360) % 360;
|
||||
|
||||
const newRotation = currentRotation + this.degrees;
|
||||
img.style.transform = `rotate(${newRotation}deg)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Only update DOM
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
// Extract current rotation from transform property
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
let previousRotation = currentRotation - this.degrees;
|
||||
|
||||
previousRotation = ((previousRotation % 360) + 360) % 360;
|
||||
|
||||
const previousRotation = currentRotation - this.degrees;
|
||||
img.style.transform = `rotate(${previousRotation}deg)`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
/* AppConfigModal styles */
|
||||
.modal-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 37.5rem; /* 600px */
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
width: 15rem; /* 240px */
|
||||
height: 37.5rem; /* 600px */
|
||||
border-top-left-radius: 0.75rem; /* 12px */
|
||||
border-bottom-left-radius: 0.75rem; /* 12px */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Mobile: compact icon-only navigation */
|
||||
@media (max-width: 1024px) {
|
||||
.modal-container {
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
width: 5rem; /* 80px - wider for larger icons */
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.modal-nav-scroll {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-item.mobile {
|
||||
padding: 1rem;
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-nav-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding-bottom: 2rem;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.modal-nav-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-nav-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-nav-section-items {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-item {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.625rem; /* 8px 10px */
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem; /* 4px */
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
height: 37.5rem; /* 600px */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-content-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.modal-content-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 2rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.confirm-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.confirm-modal-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Modal, Text, ActionIcon } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import Overview from './config/configSections/Overview';
|
||||
import { createConfigNavSections } from './config/configNavSections';
|
||||
import { NavKey } from './config/types';
|
||||
import './AppConfigModal.css';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
|
||||
import React from 'react';
|
||||
import { Modal, Button, Stack, Text, Code, ScrollArea, Group, Badge, Alert, Loader } from '@mantine/core';
|
||||
import { useAppConfig } from '../../hooks/useAppConfig';
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
@@ -14,143 +8,131 @@ interface AppConfigModalProps {
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const [active, setActive] = useState<NavKey>('overview');
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
const { config, loading, error, refetch } = useAppConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||
if (detail?.key) {
|
||||
setActive(detail.key);
|
||||
}
|
||||
};
|
||||
window.addEventListener('appConfig:navigate', handler as EventListener);
|
||||
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
|
||||
}, []);
|
||||
const renderConfigSection = (title: string, data: any) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
const colors = useMemo(() => ({
|
||||
navBg: 'var(--modal-nav-bg)',
|
||||
sectionTitle: 'var(--modal-nav-section-title)',
|
||||
navItem: 'var(--modal-nav-item)',
|
||||
navItemActive: 'var(--modal-nav-item-active)',
|
||||
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
|
||||
contentBg: 'var(--modal-content-bg)',
|
||||
headerBorder: 'var(--modal-header-border)',
|
||||
}), []);
|
||||
|
||||
// Placeholder logout handler (not needed in open-source but keeps SaaS compatibility)
|
||||
const handleLogout = () => {
|
||||
// In SaaS this would sign out, in open-source it does nothing
|
||||
console.log('Logout placeholder for SaaS compatibility');
|
||||
return (
|
||||
<Stack gap="xs" mb="md">
|
||||
<Text fw={600} size="md" c="blue">{title}</Text>
|
||||
<Stack gap="xs" pl="md">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<Group key={key} wrap="nowrap" align="flex-start">
|
||||
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
|
||||
{key}:
|
||||
</Text>
|
||||
{typeof value === 'boolean' ? (
|
||||
<Badge color={value ? 'green' : 'red'} size="sm">
|
||||
{value ? 'true' : 'false'}
|
||||
</Badge>
|
||||
) : typeof value === 'object' ? (
|
||||
<Code block>{JSON.stringify(value, null, 2)}</Code>
|
||||
) : (
|
||||
String(value) || 'null'
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useMemo(() =>
|
||||
createConfigNavSections(
|
||||
Overview,
|
||||
handleLogout
|
||||
),
|
||||
[]
|
||||
);
|
||||
const basicConfig = config ? {
|
||||
appName: config.appName,
|
||||
appNameNavbar: config.appNameNavbar,
|
||||
baseUrl: config.baseUrl,
|
||||
contextPath: config.contextPath,
|
||||
serverPort: config.serverPort,
|
||||
} : null;
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
if (found) return found.label;
|
||||
}
|
||||
return '';
|
||||
}, [configNavSections, active]);
|
||||
const securityConfig = config ? {
|
||||
enableLogin: config.enableLogin,
|
||||
} : null;
|
||||
|
||||
const activeComponent = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
if (found) return found.component;
|
||||
}
|
||||
return null;
|
||||
}, [configNavSections, active]);
|
||||
const systemConfig = config ? {
|
||||
enableAlphaFunctionality: config.enableAlphaFunctionality,
|
||||
enableAnalytics: config.enableAnalytics,
|
||||
} : null;
|
||||
|
||||
const premiumConfig = config ? {
|
||||
premiumEnabled: config.premiumEnabled,
|
||||
premiumKey: config.premiumKey ? '***hidden***' : null,
|
||||
runningProOrHigher: config.runningProOrHigher,
|
||||
runningEE: config.runningEE,
|
||||
license: config.license,
|
||||
} : null;
|
||||
|
||||
const integrationConfig = config ? {
|
||||
GoogleDriveEnabled: config.GoogleDriveEnabled,
|
||||
SSOAutoLogin: config.SSOAutoLogin,
|
||||
} : null;
|
||||
|
||||
const legalConfig = config ? {
|
||||
termsAndConditions: config.termsAndConditions,
|
||||
privacyPolicy: config.privacyPolicy,
|
||||
cookiePolicy: config.cookiePolicy,
|
||||
impressum: config.impressum,
|
||||
accessibilityStatement: config.accessibilityStatement,
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={null}
|
||||
size={isMobile ? "100%" : 980}
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
title="App Configuration (Testing)"
|
||||
size="lg"
|
||||
style={{ zIndex: 1000 }}
|
||||
>
|
||||
<div className="modal-container">
|
||||
{/* Left navigation */}
|
||||
<div
|
||||
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: colors.navBg,
|
||||
borderRight: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="modal-nav-scroll">
|
||||
{configNavSections.map(section => (
|
||||
<div key={section.title} className="modal-nav-section">
|
||||
{!isMobile && (
|
||||
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||
{section.title}
|
||||
</Text>
|
||||
)}
|
||||
<div className="modal-nav-section-items">
|
||||
{section.items.map(item => {
|
||||
const isActive = active === item.key;
|
||||
const color = isActive ? colors.navItemActive : colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => setActive(item.key)}
|
||||
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : 'transparent',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
||||
{!isMobile && (
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
This modal shows the current application configuration for testing purposes only.
|
||||
</Text>
|
||||
<Button size="xs" variant="light" onClick={refetch}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Right content */}
|
||||
<div className="modal-content">
|
||||
<div className="modal-content-scroll">
|
||||
{/* Sticky header with section title and small close button */}
|
||||
<div
|
||||
className="modal-header"
|
||||
style={{
|
||||
background: colors.contentBg,
|
||||
borderBottom: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="lg">{activeLabel}</Text>
|
||||
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{activeComponent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{loading && (
|
||||
<Stack align="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">Loading configuration...</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert color="red" title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<ScrollArea h={400}>
|
||||
<Stack gap="lg">
|
||||
{renderConfigSection('Basic Configuration', basicConfig)}
|
||||
{renderConfigSection('Security Configuration', securityConfig)}
|
||||
{renderConfigSection('System Configuration', systemConfig)}
|
||||
{renderConfigSection('Premium/Enterprise Configuration', premiumConfig)}
|
||||
{renderConfigSection('Integration Configuration', integrationConfig)}
|
||||
{renderConfigSection('Legal Configuration', legalConfig)}
|
||||
|
||||
{config.error && (
|
||||
<Alert color="yellow" title="Configuration Warning">
|
||||
{config.error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="md" c="blue">Raw Configuration</Text>
|
||||
<Code block style={{ fontSize: '11px' }}>
|
||||
{JSON.stringify(config, null, 2)}
|
||||
</Code>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'default' | 'colored';
|
||||
color?: string;
|
||||
textColor?: string;
|
||||
backgroundColor?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const Badge: React.FC<BadgeProps> = ({
|
||||
children,
|
||||
size = 'sm',
|
||||
variant = 'default',
|
||||
color,
|
||||
textColor,
|
||||
backgroundColor,
|
||||
className,
|
||||
style
|
||||
}) => {
|
||||
const getSizeStyles = () => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return {
|
||||
padding: '0.125rem 0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
borderRadius: '0.5rem',
|
||||
};
|
||||
case 'md':
|
||||
return {
|
||||
padding: '0.25rem 0.75rem',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 700,
|
||||
borderRadius: '0.625rem',
|
||||
};
|
||||
case 'lg':
|
||||
return {
|
||||
padding: '0.375rem 1rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 700,
|
||||
borderRadius: '0.75rem',
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const getVariantStyles = () => {
|
||||
// If explicit colors are provided, use them
|
||||
if (textColor && backgroundColor) {
|
||||
return {
|
||||
backgroundColor,
|
||||
color: textColor,
|
||||
};
|
||||
}
|
||||
|
||||
// If a single color is provided, use it for text and 20% opacity for background
|
||||
if (color) {
|
||||
return {
|
||||
backgroundColor: `color-mix(in srgb, ${color} 20%, transparent)`,
|
||||
color: color,
|
||||
};
|
||||
}
|
||||
|
||||
// If variant is colored but no color provided, use default colored styling
|
||||
if (variant === 'colored') {
|
||||
return {
|
||||
backgroundColor: `color-mix(in srgb, var(--category-color-default) 15%, transparent)`,
|
||||
color: 'var(--category-color-default)',
|
||||
borderColor: `color-mix(in srgb, var(--category-color-default) 30%, transparent)`,
|
||||
border: '1px solid',
|
||||
};
|
||||
}
|
||||
|
||||
// Default styling
|
||||
return {
|
||||
background: 'var(--tool-header-badge-bg)',
|
||||
color: 'var(--tool-header-badge-text)',
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
style={{
|
||||
...getSizeStyles(),
|
||||
...getVariantStyles(),
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
@@ -1,78 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Menu, Loader, Group, Text } from '@mantine/core';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import FitText from './FitText';
|
||||
|
||||
interface FileDropdownMenuProps {
|
||||
displayName: string;
|
||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>;
|
||||
currentFileIndex: number;
|
||||
onFileSelect?: (index: number) => void;
|
||||
switchingTo?: string | null;
|
||||
viewOptionStyle: React.CSSProperties;
|
||||
pillRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
||||
displayName,
|
||||
activeFiles,
|
||||
currentFileIndex,
|
||||
onFileSelect,
|
||||
switchingTo,
|
||||
viewOptionStyle,
|
||||
}) => {
|
||||
return (
|
||||
<Menu trigger="click" position="bottom" width="30rem">
|
||||
<Menu.Target>
|
||||
<div style={{...viewOptionStyle, cursor: 'pointer'}}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
)}
|
||||
<FitText text={displayName} fontSize={14} minimumFontScale={0.6} className="ph-no-capture" />
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{
|
||||
backgroundColor: 'var(--right-rail-bg)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
maxHeight: '50vh',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{activeFiles.map((file, index) => {
|
||||
const itemName = file?.name || 'Untitled';
|
||||
const isActive = index === currentFileIndex;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={file.fileId}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFileSelect?.(index);
|
||||
}}
|
||||
className="viewer-file-tab"
|
||||
{...(isActive && { 'data-active': true })}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
|
||||
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} className="ph-no-capture" />
|
||||
</div>
|
||||
{file.versionNumber && file.versionNumber > 1 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
v{file.versionNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
@@ -125,7 +125,6 @@ const FilePickerModal = ({
|
||||
title={t("fileUpload.selectFromStorage", "Select Files from Storage")}
|
||||
size="lg"
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
zIndex={1100}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{storedFiles.length === 0 ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef } from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { FileButton, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -15,7 +15,7 @@ interface FileUploadButtonProps {
|
||||
const FileUploadButton = ({
|
||||
file,
|
||||
onChange,
|
||||
accept,
|
||||
accept = "*/*",
|
||||
disabled = false,
|
||||
placeholder,
|
||||
variant = "outline",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCookieConsent } from '../../hooks/useCookieConsent';
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ const LandingPage = () => {
|
||||
{/* White PDF Page Background */}
|
||||
<Dropzone
|
||||
onDrop={handleFileDrop}
|
||||
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
|
||||
multiple={true}
|
||||
className="w-4/5 flex items-center justify-center h-[95%]"
|
||||
style={{
|
||||
@@ -177,6 +178,7 @@ const LandingPage = () => {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.zip"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { supportedLanguages } from '../../i18n';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import styles from './LanguageSelector.module.css';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
|
||||
|
||||
// Types
|
||||
interface LanguageSelectorProps {
|
||||
@@ -210,7 +209,6 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
width={600}
|
||||
position={position}
|
||||
offset={offset}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
transitionProps={{
|
||||
transition: 'scale-y',
|
||||
duration: 200,
|
||||
@@ -266,15 +264,13 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
|
||||
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
|
||||
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
|
||||
}}
|
||||
>
|
||||
<ScrollArea h={190} type="scroll">
|
||||
<div className={styles.languageGrid}>
|
||||
{languageOptions.map((option, index) => {
|
||||
// Enable languages with >90% translation completion
|
||||
const enabledLanguages = ['en-GB', 'ar-AR', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'pt-BR', 'ru-RU', 'zh-CN'];
|
||||
const isDisabled = !enabledLanguages.includes(option.value);
|
||||
const isEnglishGB = option.value === 'en-GB'; // Currently only English GB has enough translations to use
|
||||
const isDisabled = !isEnglishGB;
|
||||
|
||||
return (
|
||||
<LanguageItem
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { Box, Group, Text, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -36,7 +37,7 @@ const MultiSelectControls = ({
|
||||
>
|
||||
{t("fileManager.clearSelection", "Clear Selection")}
|
||||
</Button>
|
||||
|
||||
|
||||
{onAddToUpload && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -46,7 +47,7 @@ const MultiSelectControls = ({
|
||||
{t("fileManager.addToUpload", "Add to Upload")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{onOpenInFileEditor && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -57,7 +58,7 @@ const MultiSelectControls = ({
|
||||
{t("fileManager.openInFileEditor", "Open in File Editor")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{onOpenInPageEditor && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -68,7 +69,7 @@ const MultiSelectControls = ({
|
||||
{t("fileManager.openInPageEditor", "Open in Page Editor")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{onDeleteAll && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -84,4 +85,4 @@ const MultiSelectControls = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiSelectControls;
|
||||
export default MultiSelectControls;
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
|
||||
import { useNavigationGuard } from "../../contexts/NavigationContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import React from 'react';
|
||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||
import { useNavigationGuard } from '../../contexts/NavigationContext';
|
||||
|
||||
interface NavigationWarningModalProps {
|
||||
onApplyAndContinue?: () => Promise<void>;
|
||||
onExportAndContinue?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
|
||||
useNavigationGuard();
|
||||
const NavigationWarningModal = ({
|
||||
onApplyAndContinue,
|
||||
onExportAndContinue
|
||||
}: NavigationWarningModalProps) => {
|
||||
const {
|
||||
showNavigationWarning,
|
||||
hasUnsavedChanges,
|
||||
cancelNavigation,
|
||||
confirmNavigation,
|
||||
setHasUnsavedChanges
|
||||
} = useNavigationGuard();
|
||||
|
||||
const handleKeepWorking = () => {
|
||||
cancelNavigation();
|
||||
@@ -32,14 +36,13 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const _handleExportAndContinue = async () => {
|
||||
const handleExportAndContinue = async () => {
|
||||
if (onExportAndContinue) {
|
||||
await onExportAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
const BUTTON_WIDTH = "10rem";
|
||||
|
||||
if (!hasUnsavedChanges) {
|
||||
return null;
|
||||
@@ -49,58 +52,55 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
|
||||
<Modal
|
||||
opened={showNavigationWarning}
|
||||
onClose={handleKeepWorking}
|
||||
title={t("unsavedChangesTitle", "Unsaved Changes")}
|
||||
title="Unsaved Changes"
|
||||
centered
|
||||
size="auto"
|
||||
closeOnClickOutside={true}
|
||||
closeOnEscape={true}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
>
|
||||
<Stack>
|
||||
<Stack ta="center" p="md">
|
||||
<Text size="md" fw="300">
|
||||
{t("unsavedChanges", "You have unsaved changes to your PDF.")}
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
You have unsaved changes to your PDF. What would you like to do?
|
||||
</Text>
|
||||
<Text size="lg" fw="500" >
|
||||
{t("areYouSure", "Are you sure you want to leave?")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Desktop layout: 2 groups side by side */}
|
||||
<Group justify="space-between" gap="xl" visibleFrom="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
|
||||
{t("keepWorking", "Keep Working")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
|
||||
{t("discardChanges", "Discard Changes")}
|
||||
</Button>
|
||||
{onApplyAndContinue && (
|
||||
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||
{t("applyAndContinue", "Apply & Leave")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Mobile layout: centered stack of 4 buttons */}
|
||||
<Stack align="center" gap="sm" hiddenFrom="md">
|
||||
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
|
||||
{t("keepWorking", "Keep Working")}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={handleKeepWorking}
|
||||
>
|
||||
Keep Working
|
||||
</Button>
|
||||
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
|
||||
{t("discardChanges", "Discard Changes")}
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={handleDiscardChanges}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
|
||||
{onApplyAndContinue && (
|
||||
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||
{t("applyAndContinue", "Apply & Leave")}
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={handleApplyAndContinue}
|
||||
>
|
||||
Apply & Continue
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{onExportAndContinue && (
|
||||
<Button
|
||||
color="green"
|
||||
onClick={handleExportAndContinue}
|
||||
>
|
||||
Export & Continue
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationWarningModal;
|
||||
export default NavigationWarningModal;
|
||||
@@ -12,8 +12,6 @@ import { ButtonConfig } from '../../types/sidebar';
|
||||
import './quickAccessBar/QuickAccessBar.css';
|
||||
import AllToolsNavButton from './AllToolsNavButton';
|
||||
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
|
||||
import AppConfigModal from './AppConfigModal';
|
||||
import { useAppConfig } from '../../hooks/useAppConfig';
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
@@ -24,9 +22,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
const { config } = useAppConfig();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
@@ -44,10 +41,10 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
// Helper function to render navigation buttons with URL support
|
||||
const renderNavButton = (config: ButtonConfig, index: number) => {
|
||||
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
|
||||
|
||||
|
||||
// Check if this button has URL navigation support
|
||||
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
|
||||
? getToolNavigation(config.id)
|
||||
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
|
||||
? getToolNavigation(config.id)
|
||||
: null;
|
||||
|
||||
const handleClick = (e?: React.MouseEvent) => {
|
||||
@@ -62,14 +59,13 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
return (
|
||||
<div key={config.id} className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
|
||||
<ActionIcon
|
||||
{...(navProps ? {
|
||||
{...(navProps ? {
|
||||
component: "a" as const,
|
||||
href: navProps.href,
|
||||
onClick: (e: React.MouseEvent) => handleClick(e),
|
||||
'aria-label': config.name
|
||||
} : {
|
||||
onClick: () => handleClick(),
|
||||
'aria-label': config.name
|
||||
onClick: () => handleClick()
|
||||
})}
|
||||
size={isActive ? (config.size || 'lg') : 'lg'}
|
||||
variant="subtle"
|
||||
@@ -99,10 +95,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
type: 'navigation',
|
||||
onClick: () => {
|
||||
setActiveButton('read');
|
||||
handleBackToTools();
|
||||
handleReaderToggle();
|
||||
}
|
||||
},
|
||||
// {
|
||||
// TODO: Add sign
|
||||
//{
|
||||
// id: 'sign',
|
||||
// name: t("quickAccess.sign", "Sign"),
|
||||
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
|
||||
@@ -113,7 +111,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
// setActiveButton('sign');
|
||||
// handleToolSelect('sign');
|
||||
// }
|
||||
// },
|
||||
//},
|
||||
{
|
||||
id: 'automate',
|
||||
name: t("quickAccess.automate", "Automate"),
|
||||
@@ -152,8 +150,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
//},
|
||||
{
|
||||
id: 'config',
|
||||
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
|
||||
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
name: t("quickAccess.config", "Config"),
|
||||
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'lg',
|
||||
type: 'modal',
|
||||
onClick: () => {
|
||||
@@ -219,7 +217,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
<div className="spacer" />
|
||||
|
||||
{/* Config button at the bottom */}
|
||||
{buttonConfigs
|
||||
{/* {buttonConfigs
|
||||
.filter(config => config.id === 'config')
|
||||
.map(config => (
|
||||
<div key={config.id} className="flex flex-col items-center gap-1">
|
||||
@@ -229,7 +227,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
onClick={config.onClick}
|
||||
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
|
||||
className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''}
|
||||
aria-label={config.name}
|
||||
data-testid={`${config.id}-button`}
|
||||
>
|
||||
<span className="iconContainer">
|
||||
@@ -240,18 +237,16 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
{config.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
))} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppConfigModal
|
||||
{/* <AppConfigModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
QuickAccessBar.displayName = 'QuickAccessBar';
|
||||
|
||||
export default QuickAccessBar;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, ReactNode } from 'react';
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
|
||||
import { mantineTheme } from '../../theme/mantineTheme';
|
||||
|
||||
@@ -14,36 +14,28 @@ import { Tooltip } from '../shared/Tooltip';
|
||||
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
|
||||
import { SearchInterface } from '../viewer/SearchInterface';
|
||||
import { ViewerContext } from '../../contexts/ViewerContext';
|
||||
import { useSignature } from '../../contexts/SignatureContext';
|
||||
import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls';
|
||||
|
||||
import { parseSelection } from '../../utils/bulkselection/parseSelection';
|
||||
|
||||
|
||||
import { useSidebarContext } from '../../contexts/SidebarContext';
|
||||
|
||||
export default function RightRail() {
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { t } = useTranslation();
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
|
||||
// Viewer context for PDF controls - safely handle when not available
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
const { toggleTheme } = useRainbowThemeContext();
|
||||
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
||||
|
||||
const { buttons, actions } = useRightRail();
|
||||
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
|
||||
|
||||
// Access PageEditor functions for page-editor-specific actions
|
||||
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
|
||||
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
|
||||
const { pageEditorFunctions } = useToolWorkflow();
|
||||
|
||||
// CSV input state for page selection
|
||||
const [csvInput, setCsvInput] = useState<string>("");
|
||||
|
||||
// Navigation view
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const isCustomWorkbench = typeof currentView === 'string' && currentView.startsWith('custom:');
|
||||
|
||||
// File state and selection
|
||||
const { state, selectors } = useFileState();
|
||||
@@ -51,9 +43,6 @@ export default function RightRail() {
|
||||
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||
const { removeFiles } = useFileManagement();
|
||||
|
||||
// Signature context for checking if signatures have been applied
|
||||
const { signaturesApplied } = useSignature();
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
|
||||
@@ -77,9 +66,6 @@ export default function RightRail() {
|
||||
|
||||
const { totalItems, selectedCount } = getSelectionState();
|
||||
|
||||
// Get export state for viewer mode
|
||||
const exportState = viewerContext?.getExportState?.();
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
// Select all file IDs
|
||||
@@ -109,17 +95,8 @@ export default function RightRail() {
|
||||
}
|
||||
}, [currentView, setSelectedFiles, pageEditorFunctions]);
|
||||
|
||||
const handleExportAll = useCallback(async () => {
|
||||
if (currentView === 'viewer') {
|
||||
// Check if signatures have been applied
|
||||
if (!signaturesApplied) {
|
||||
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use EmbedPDF export functionality for viewer mode
|
||||
viewerContext?.exportActions?.download();
|
||||
} else if (currentView === 'fileEditor') {
|
||||
const handleExportAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
// Download selected files (or all if none selected)
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
|
||||
@@ -136,7 +113,7 @@ export default function RightRail() {
|
||||
// Export all pages (not just selected)
|
||||
pageEditorFunctions?.onExportAll?.();
|
||||
}
|
||||
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext, signaturesApplied, selectors, fileActions]);
|
||||
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
|
||||
|
||||
const handleCloseSelected = useCallback(() => {
|
||||
if (currentView !== 'fileEditor') return;
|
||||
@@ -182,19 +159,19 @@ export default function RightRail() {
|
||||
}, [currentView]);
|
||||
|
||||
return (
|
||||
<div ref={sidebarRefs.rightRailRef} className={`right-rail`} data-sidebar="right-rail">
|
||||
<div className="right-rail">
|
||||
<div className="right-rail-inner">
|
||||
{topButtons.length > 0 && !isCustomWorkbench && (
|
||||
{topButtons.length > 0 && (
|
||||
<>
|
||||
<div className="right-rail-section">
|
||||
{topButtons.map(btn => (
|
||||
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => actions[btn.id]?.()}
|
||||
disabled={btn.disabled || allButtonsDisabled || disableForFullscreen}
|
||||
disabled={btn.disabled}
|
||||
>
|
||||
{btn.icon}
|
||||
</ActionIcon>
|
||||
@@ -206,14 +183,13 @@ export default function RightRail() {
|
||||
)}
|
||||
|
||||
{/* Group: PDF Viewer Controls - visible only in viewer mode */}
|
||||
{!isCustomWorkbench && (
|
||||
<div
|
||||
className={`right-rail-slot ${currentView === 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||
aria-hidden={currentView !== 'viewer'}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
{/* Search */}
|
||||
<Tooltip content={t('rightRail.search', 'Search PDF')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip content={t('rightRail.search', 'Search PDF')} position="left" offset={12} arrow>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
@@ -221,7 +197,7 @@ export default function RightRail() {
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
|
||||
disabled={currentView !== 'viewer'}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.search', 'Search PDF') : 'Search PDF'}
|
||||
>
|
||||
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
|
||||
@@ -241,7 +217,7 @@ export default function RightRail() {
|
||||
|
||||
|
||||
{/* Pan Mode */}
|
||||
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant={isPanning ? "filled" : "subtle"}
|
||||
color={isPanning ? "blue" : undefined}
|
||||
@@ -251,14 +227,14 @@ export default function RightRail() {
|
||||
viewerContext?.panActions.togglePan();
|
||||
setIsPanning(!isPanning);
|
||||
}}
|
||||
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Rotate Left */}
|
||||
<Tooltip content={t('rightRail.rotateLeft', 'Rotate Left')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip content={t('rightRail.rotateLeft', 'Rotate Left')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
@@ -266,14 +242,14 @@ export default function RightRail() {
|
||||
onClick={() => {
|
||||
viewerContext?.rotationActions.rotateBackward();
|
||||
}}
|
||||
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Rotate Right */}
|
||||
<Tooltip content={t('rightRail.rotateRight', 'Rotate Right')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip content={t('rightRail.rotateRight', 'Rotate Right')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
@@ -281,14 +257,14 @@ export default function RightRail() {
|
||||
onClick={() => {
|
||||
viewerContext?.rotationActions.rotateForward();
|
||||
}}
|
||||
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon icon="rotate-right" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Sidebar Toggle */}
|
||||
<Tooltip content={t('rightRail.toggleSidebar', 'Toggle Sidebar')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip content={t('rightRail.toggleSidebar', 'Toggle Sidebar')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
@@ -296,38 +272,30 @@ export default function RightRail() {
|
||||
onClick={() => {
|
||||
viewerContext?.toggleThumbnailSidebar();
|
||||
}}
|
||||
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Controls */}
|
||||
<ViewerAnnotationControls
|
||||
currentView={currentView}
|
||||
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
|
||||
/>
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
||||
{!isCustomWorkbench && (
|
||||
<div
|
||||
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||
aria-hidden={currentView === 'viewer'}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
{/* Select All Button */}
|
||||
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleSelectAll}
|
||||
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems || allButtonsDisabled || disableForFullscreen}
|
||||
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems}
|
||||
>
|
||||
<LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
@@ -335,14 +303,14 @@ export default function RightRail() {
|
||||
</Tooltip>
|
||||
|
||||
{/* Deselect All Button */}
|
||||
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleDeselectAll}
|
||||
disabled={currentView === 'viewer' || selectedCount === 0 || allButtonsDisabled || disableForFullscreen}
|
||||
disabled={currentView === 'viewer' || selectedCount === 0}
|
||||
>
|
||||
<LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
@@ -351,7 +319,7 @@ export default function RightRail() {
|
||||
|
||||
{/* Select by Numbers - page editor only, with animated presence */}
|
||||
{pageControlsMounted && (
|
||||
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow>
|
||||
|
||||
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
@@ -361,7 +329,7 @@ export default function RightRail() {
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={!pageControlsVisible || totalItems === 0 || allButtonsDisabled || disableForFullscreen}
|
||||
disabled={!pageControlsVisible || totalItems === 0}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'}
|
||||
>
|
||||
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
|
||||
@@ -388,7 +356,7 @@ export default function RightRail() {
|
||||
|
||||
{/* Delete Selected Pages - page editor only, with animated presence */}
|
||||
{pageControlsMounted && (
|
||||
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow>
|
||||
|
||||
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
@@ -397,7 +365,7 @@ export default function RightRail() {
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => { pageEditorFunctions?.handleDelete?.(); }}
|
||||
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || allButtonsDisabled || disableForFullscreen}
|
||||
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
|
||||
>
|
||||
<LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />
|
||||
@@ -410,7 +378,7 @@ export default function RightRail() {
|
||||
|
||||
{/* Export Selected Pages - page editor only */}
|
||||
{pageControlsMounted && (
|
||||
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow>
|
||||
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
@@ -418,7 +386,7 @@ export default function RightRail() {
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => { pageEditorFunctions?.onExportSelected?.(); }}
|
||||
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading || allButtonsDisabled || disableForFullscreen}
|
||||
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
@@ -429,7 +397,7 @@ export default function RightRail() {
|
||||
)}
|
||||
|
||||
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
|
||||
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
@@ -439,8 +407,7 @@ export default function RightRail() {
|
||||
disabled={
|
||||
currentView === 'viewer' ||
|
||||
(currentView === 'fileEditor' && selectedCount === 0) ||
|
||||
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf)) ||
|
||||
allButtonsDisabled || disableForFullscreen
|
||||
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf))
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />
|
||||
@@ -451,12 +418,10 @@ export default function RightRail() {
|
||||
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme toggle and Language dropdown */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow portalTarget={document.body}
|
||||
>
|
||||
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
@@ -467,26 +432,20 @@ export default function RightRail() {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('rightRail.language', 'Language')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<LanguageSelector position="left-start" offset={6} compact />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<LanguageSelector position="left-start" offset={6} compact />
|
||||
|
||||
<Tooltip content={
|
||||
currentView === 'pageEditor'
|
||||
? t('rightRail.exportAll', 'Export PDF')
|
||||
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
|
||||
} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
} position="left" offset={12} arrow>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleExportAll}
|
||||
disabled={
|
||||
disableForFullscreen || (currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
|
||||
}
|
||||
disabled={currentView === 'viewer' || totalItems === 0}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
@@ -500,3 +459,4 @@ export default function RightRail() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -107,5 +107,3 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TextInput.displayName = 'TextInput';
|
||||
|
||||
@@ -32,7 +32,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
|
||||
|
||||
const getToolName = (toolId: ToolId) => {
|
||||
return t(`home.${toolId}.title`, toolId);
|
||||
};
|
||||
}
|
||||
|
||||
// Create full tool chain for tooltip
|
||||
const fullChainDisplay = displayStyle === 'badges' ? (
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
interface ToolIconProps {
|
||||
icon: React.ReactNode;
|
||||
opacity?: number;
|
||||
color?: string;
|
||||
marginRight?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared icon component for consistent tool icon styling across the application.
|
||||
* Uses the same visual pattern as ToolButton: scaled to 0.8, centered transform, consistent spacing.
|
||||
*/
|
||||
export const ToolIcon: React.FC<ToolIconProps> = ({
|
||||
icon,
|
||||
opacity = 1,
|
||||
color = "var(--tools-text-and-icon-color)",
|
||||
marginRight = "0.5rem"
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="tool-button-icon"
|
||||
style={{
|
||||
color,
|
||||
marginRight,
|
||||
transform: "scale(0.8)",
|
||||
transformOrigin: "center",
|
||||
opacity
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import { TooltipContent } from './tooltip/TooltipContent';
|
||||
import { useSidebarContext } from '../../contexts/SidebarContext';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
import styles from './tooltip/Tooltip.module.css';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
|
||||
|
||||
export interface TooltipProps {
|
||||
sidebarTooltip?: boolean;
|
||||
@@ -29,10 +28,6 @@ export interface TooltipProps {
|
||||
pinOnClick?: boolean;
|
||||
/** If true, clicking outside also closes when not pinned (default true) */
|
||||
closeOnOutside?: boolean;
|
||||
/** If true, tooltip interaction is disabled entirely */
|
||||
disabled?: boolean;
|
||||
/** If false, tooltip will not open on focus (hover only) */
|
||||
openOnFocus?: boolean;
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
@@ -53,8 +48,6 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
containerStyle = {},
|
||||
pinOnClick = false,
|
||||
closeOnOutside = true,
|
||||
disabled = false,
|
||||
openOnFocus = true,
|
||||
}) => {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
@@ -75,7 +68,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
|
||||
const open = isControlled ? !!controlledOpen : internalOpen;
|
||||
|
||||
const setOpen = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
@@ -155,16 +148,15 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
// === Trigger handlers ===
|
||||
const openWithDelay = useCallback(() => {
|
||||
clearTimers();
|
||||
if (disabled) return;
|
||||
openTimeoutRef.current = setTimeout(() => setOpen(true), Math.max(0, delay || 0));
|
||||
}, [clearTimers, setOpen, delay, disabled]);
|
||||
}, [clearTimers, setOpen, delay]);
|
||||
|
||||
const handlePointerEnter = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isPinned && !disabled) openWithDelay();
|
||||
if (!isPinned) openWithDelay();
|
||||
(children.props as any)?.onPointerEnter?.(e);
|
||||
},
|
||||
[isPinned, openWithDelay, children.props, disabled]
|
||||
[isPinned, openWithDelay, children.props]
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback(
|
||||
@@ -227,10 +219,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
// Keyboard / focus accessibility
|
||||
const handleFocus = useCallback(
|
||||
(e: React.FocusEvent) => {
|
||||
if (!isPinned && !disabled && openOnFocus) openWithDelay();
|
||||
if (!isPinned) openWithDelay();
|
||||
(children.props as any)?.onFocus?.(e);
|
||||
},
|
||||
[isPinned, openWithDelay, children.props, disabled, openOnFocus]
|
||||
[isPinned, openWithDelay, children.props]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
@@ -299,7 +291,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
left: coords.left,
|
||||
width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
|
||||
minWidth,
|
||||
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
|
||||
zIndex: 9999,
|
||||
visibility: positionReady ? 'visible' : 'hidden',
|
||||
opacity: positionReady ? 1 : 0,
|
||||
color: 'var(--text-primary)',
|
||||
@@ -353,13 +345,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
return (
|
||||
<>
|
||||
{childWithHandlers}
|
||||
{(() => {
|
||||
const defaultTarget = typeof document !== 'undefined' ? document.body : null;
|
||||
const target = portalTarget ?? defaultTarget;
|
||||
return tooltipElement && target
|
||||
? createPortal(tooltipElement, target)
|
||||
: tooltipElement;
|
||||
})()}
|
||||
{portalTarget && document.body.contains(portalTarget)
|
||||
? tooltipElement && createPortal(tooltipElement, portalTarget)
|
||||
: tooltipElement}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,56 +5,30 @@ import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
|
||||
import type { CustomWorkbenchViewInstance } from '../../contexts/ToolWorkflowContext';
|
||||
import { FileDropdownMenu } from './FileDropdownMenu';
|
||||
|
||||
|
||||
const viewOptionStyle: React.CSSProperties = {
|
||||
const viewOptionStyle = {
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
whiteSpace: 'nowrap',
|
||||
paddingTop: '0.3rem',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Build view options showing text always
|
||||
const createViewOptions = (
|
||||
currentView: WorkbenchType,
|
||||
switchingTo: WorkbenchType | null,
|
||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
|
||||
currentFileIndex: number,
|
||||
onFileSelect?: (index: number) => void,
|
||||
customViews?: CustomWorkbenchViewInstance[]
|
||||
) => {
|
||||
const currentFile = activeFiles[currentFileIndex];
|
||||
const isInViewer = currentView === 'viewer';
|
||||
const fileName = currentFile?.name || '';
|
||||
const displayName = isInViewer && fileName ? fileName : 'Viewer';
|
||||
const hasMultipleFiles = activeFiles.length > 1;
|
||||
const showDropdown = isInViewer && hasMultipleFiles;
|
||||
|
||||
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null, isToolSelected: boolean) => {
|
||||
const viewerOption = {
|
||||
label: showDropdown ? (
|
||||
<FileDropdownMenu
|
||||
displayName={displayName}
|
||||
activeFiles={activeFiles}
|
||||
currentFileIndex={currentFileIndex}
|
||||
onFileSelect={onFileSelect}
|
||||
switchingTo={switchingTo}
|
||||
viewOptionStyle={viewOptionStyle}
|
||||
/>
|
||||
) : (
|
||||
<div style={viewOptionStyle}>
|
||||
label: (
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
)}
|
||||
<span className="ph-no-capture">{displayName}</span>
|
||||
<span>Viewer</span>
|
||||
</div>
|
||||
),
|
||||
value: "viewer",
|
||||
@@ -62,7 +36,7 @@ const createViewOptions = (
|
||||
|
||||
const pageEditorOption = {
|
||||
label: (
|
||||
<div style={viewOptionStyle}>
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{currentView === "pageEditor" ? (
|
||||
<>
|
||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
||||
@@ -81,7 +55,7 @@ const createViewOptions = (
|
||||
|
||||
const fileEditorOption = {
|
||||
label: (
|
||||
<div style={viewOptionStyle}>
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{currentView === "fileEditor" ? (
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
@@ -98,51 +72,30 @@ const createViewOptions = (
|
||||
value: "fileEditor",
|
||||
};
|
||||
|
||||
const baseOptions = [
|
||||
// Build options array conditionally
|
||||
return [
|
||||
viewerOption,
|
||||
pageEditorOption,
|
||||
...(isToolSelected ? [] : [pageEditorOption]),
|
||||
fileEditorOption,
|
||||
];
|
||||
|
||||
const customOptions = (customViews ?? [])
|
||||
.filter((view) => view.data != null)
|
||||
.map((view) => ({
|
||||
label: (
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{switchingTo === view.workbenchId ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
view.icon || <PictureAsPdfIcon fontSize="small" />
|
||||
)}
|
||||
<span>{view.label}</span>
|
||||
</div>
|
||||
),
|
||||
value: view.workbenchId,
|
||||
}));
|
||||
|
||||
return [...baseOptions, ...customOptions];
|
||||
};
|
||||
|
||||
interface TopControlsProps {
|
||||
currentView: WorkbenchType;
|
||||
setCurrentView: (view: WorkbenchType) => void;
|
||||
customViews?: CustomWorkbenchViewInstance[];
|
||||
activeFiles?: Array<{ fileId: string; name: string; versionNumber?: number }>;
|
||||
currentFileIndex?: number;
|
||||
onFileSelect?: (index: number) => void;
|
||||
selectedToolKey?: string | null;
|
||||
}
|
||||
|
||||
const TopControls = ({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
customViews = [],
|
||||
activeFiles = [],
|
||||
currentFileIndex = 0,
|
||||
onFileSelect,
|
||||
selectedToolKey,
|
||||
}: TopControlsProps) => {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
|
||||
|
||||
const isToolSelected = selectedToolKey !== null;
|
||||
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
if (!isValidWorkbench(view)) {
|
||||
return;
|
||||
@@ -169,7 +122,7 @@ const TopControls = ({
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="flex justify-center mt-[0.5rem]">
|
||||
<SegmentedControl
|
||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect)}
|
||||
data={createViewOptions(currentView, switchingTo, isToolSelected)}
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
import { NavKey } from './types';
|
||||
import HotkeysSection from './configSections/HotkeysSection';
|
||||
import GeneralSection from './configSections/GeneralSection';
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: string;
|
||||
component: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ConfigNavSection {
|
||||
title: string;
|
||||
items: ConfigNavItem[];
|
||||
}
|
||||
|
||||
export interface ConfigColors {
|
||||
navBg: string;
|
||||
sectionTitle: string;
|
||||
navItem: string;
|
||||
navItemActive: string;
|
||||
navItemActiveBg: string;
|
||||
contentBg: string;
|
||||
headerBorder: string;
|
||||
}
|
||||
|
||||
export const createConfigNavSections = (
|
||||
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
|
||||
onLogoutClick: () => void
|
||||
): ConfigNavSection[] => {
|
||||
const sections: ConfigNavSection[] = [
|
||||
{
|
||||
title: 'Account',
|
||||
items: [
|
||||
{
|
||||
key: 'overview',
|
||||
label: 'Overview',
|
||||
icon: 'person-rounded',
|
||||
component: <Overview onLogoutClick={onLogoutClick} />
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Preferences',
|
||||
items: [
|
||||
{
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
icon: 'settings-rounded',
|
||||
component: <GeneralSection />
|
||||
},
|
||||
{
|
||||
key: 'hotkeys',
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: 'keyboard-rounded',
|
||||
component: <HotkeysSection />
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return sections;
|
||||
};
|
||||
@@ -1,108 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePreferences } from '../../../../contexts/PreferencesContext';
|
||||
import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState';
|
||||
|
||||
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
|
||||
|
||||
const GeneralSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const [fileLimitInput, setFileLimitInput] = useState<number | string>(preferences.autoUnzipFileLimit);
|
||||
|
||||
// Sync local state with preference changes
|
||||
useEffect(() => {
|
||||
setFileLimitInput(preferences.autoUnzipFileLimit);
|
||||
}, [preferences.autoUnzipFileLimit]);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.description', 'Configure general application preferences.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSection;
|
||||
@@ -1,204 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolWorkflow } from '../../../../contexts/ToolWorkflowContext';
|
||||
import { useHotkeys } from '../../../../contexts/HotkeyContext';
|
||||
import { ToolId } from '../../../../types/toolId';
|
||||
import HotkeyDisplay from '../../../hotkeys/HotkeyDisplay';
|
||||
import { bindingEquals, eventToBinding, HotkeyBinding } from '../../../../utils/hotkeys';
|
||||
import { ToolRegistryEntry } from 'src/data/toolsTaxonomy';
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
};
|
||||
|
||||
const rowHeaderStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.5rem',
|
||||
};
|
||||
|
||||
const HotkeysSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
|
||||
const [editingTool, setEditingTool] = useState<ToolId | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
const tools = useMemo(() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], [toolRegistry]);
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!searchQuery.trim()) return tools;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return tools.filter(([toolId, tool]) =>
|
||||
tool.name.toLowerCase().includes(query) ||
|
||||
tool.description.toLowerCase().includes(query) ||
|
||||
toolId.toLowerCase().includes(query)
|
||||
);
|
||||
}, [tools, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTool) {
|
||||
return;
|
||||
}
|
||||
pauseHotkeys();
|
||||
return () => {
|
||||
resumeHotkeys();
|
||||
};
|
||||
}, [editingTool, pauseHotkeys, resumeHotkeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setEditingTool(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const binding = eventToBinding(event as KeyboardEvent);
|
||||
if (!binding) {
|
||||
const osKey = isMac ? 'mac' : 'windows';
|
||||
const fallbackText = isMac
|
||||
? 'Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.'
|
||||
: 'Include Ctrl, Alt, or another modifier in your shortcut.';
|
||||
setError(t(`settings.hotkeys.errorModifier.${osKey}`, fallbackText));
|
||||
return;
|
||||
}
|
||||
|
||||
const conflictEntry = (Object.entries(hotkeys) as [ToolId, HotkeyBinding][]).find(([toolId, existing]) => (
|
||||
toolId !== editingTool && bindingEquals(existing, binding)
|
||||
));
|
||||
|
||||
if (conflictEntry) {
|
||||
const conflictKey = conflictEntry[0];
|
||||
const conflictTool = (conflictKey in toolRegistry)
|
||||
? toolRegistry[conflictKey as ToolId]?.name
|
||||
: conflictKey;
|
||||
setError(t('settings.hotkeys.errorConflict', 'Shortcut already used by {{tool}}.', { tool: conflictTool }));
|
||||
return;
|
||||
}
|
||||
|
||||
updateHotkey(editingTool, binding);
|
||||
setEditingTool(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, [editingTool, hotkeys, toolRegistry, updateHotkey, t]);
|
||||
|
||||
const handleStartCapture = (toolId: ToolId) => {
|
||||
setEditingTool(toolId);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">Keyboard Shortcuts</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Customize keyboard shortcuts for quick tool access. Click "Change shortcut" and press a new key combination. Press Esc to cancel.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
placeholder={t('settings.hotkeys.searchPlaceholder', 'Search tools...')}
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.currentTarget.value)}
|
||||
size="md"
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
{filteredTools.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t('toolPicker.noToolsFound', 'No tools found')}
|
||||
</Text>
|
||||
) : (
|
||||
filteredTools.map(([toolId, tool], index) => {
|
||||
const currentBinding = hotkeys[toolId];
|
||||
const defaultBinding = defaults[toolId];
|
||||
const isEditing = editingTool === toolId;
|
||||
const defaultParts = getDisplayParts(defaultBinding);
|
||||
const defaultLabel = defaultParts.length > 0
|
||||
? defaultParts.join(' + ')
|
||||
: t('settings.hotkeys.none', 'Not assigned');
|
||||
|
||||
return (
|
||||
<React.Fragment key={toolId}>
|
||||
<Box style={rowStyle} data-testid={`hotkey-row-${toolId}`}>
|
||||
<div style={rowHeaderStyle}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', minWidth: 0 }}>
|
||||
<Text fw={600}>{tool.name}</Text>
|
||||
<Group gap="xs" wrap="wrap" align="center">
|
||||
<HotkeyDisplay binding={currentBinding} size="md" />
|
||||
{!bindingEquals(currentBinding, defaultBinding) && (
|
||||
<Badge variant="light" color="orange" radius="sm">
|
||||
{t('settings.hotkeys.customBadge', 'Custom')}
|
||||
</Badge>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('settings.hotkeys.defaultLabel', 'Default: {{shortcut}}', { shortcut: defaultLabel })}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={isEditing ? 'filled' : 'default'}
|
||||
color={isEditing ? 'blue' : undefined}
|
||||
onClick={() => handleStartCapture(toolId)}
|
||||
>
|
||||
{isEditing
|
||||
? t('settings.hotkeys.capturing', 'Press keys… (Esc to cancel)')
|
||||
: t('settings.hotkeys.change', 'Change shortcut')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
disabled={bindingEquals(currentBinding, defaultBinding)}
|
||||
onClick={() => resetHotkey(toolId)}
|
||||
>
|
||||
{t('settings.hotkeys.reset', 'Reset')}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{isEditing && error && (
|
||||
<Alert color="red" radius="sm" variant="filled">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{index < filteredTools.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotkeysSection;
|
||||
@@ -1,101 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core';
|
||||
import { useAppConfig } from '../../../../hooks/useAppConfig';
|
||||
|
||||
const Overview: React.FC = () => {
|
||||
const { config, loading, error } = useAppConfig();
|
||||
|
||||
const renderConfigSection = (title: string, data: any) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
return (
|
||||
<Stack gap="xs" mb="md">
|
||||
<Text fw={600} size="md" c="blue">{title}</Text>
|
||||
<Stack gap="xs" pl="md">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<Group key={key} wrap="nowrap" align="flex-start">
|
||||
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
|
||||
{key}:
|
||||
</Text>
|
||||
{typeof value === 'boolean' ? (
|
||||
<Badge color={value ? 'green' : 'red'} size="sm">
|
||||
{value ? 'true' : 'false'}
|
||||
</Badge>
|
||||
) : typeof value === 'object' ? (
|
||||
<Code block>{JSON.stringify(value, null, 2)}</Code>
|
||||
) : (
|
||||
String(value) || 'null'
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const basicConfig = config ? {
|
||||
appName: config.appName,
|
||||
appNameNavbar: config.appNameNavbar,
|
||||
baseUrl: config.baseUrl,
|
||||
contextPath: config.contextPath,
|
||||
serverPort: config.serverPort,
|
||||
} : null;
|
||||
|
||||
const securityConfig = config ? {
|
||||
enableLogin: config.enableLogin,
|
||||
} : null;
|
||||
|
||||
const systemConfig = config ? {
|
||||
enableAlphaFunctionality: config.enableAlphaFunctionality,
|
||||
enableAnalytics: config.enableAnalytics,
|
||||
} : null;
|
||||
|
||||
const integrationConfig = config ? {
|
||||
SSOAutoLogin: config.SSOAutoLogin,
|
||||
} : null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">Loading configuration...</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">Application Configuration</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Current application settings and configuration details.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{config && (
|
||||
<>
|
||||
{renderConfigSection('Basic Configuration', basicConfig)}
|
||||
{renderConfigSection('Security Configuration', securityConfig)}
|
||||
{renderConfigSection('System Configuration', systemConfig)}
|
||||
{renderConfigSection('Integration Configuration', integrationConfig)}
|
||||
|
||||
{config.error && (
|
||||
<Alert color="yellow" title="Configuration Warning">
|
||||
{config.error}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Overview;
|
||||
@@ -1,19 +0,0 @@
|
||||
export type NavKey =
|
||||
| 'overview'
|
||||
| 'preferences'
|
||||
| 'notifications'
|
||||
| 'connections'
|
||||
| 'general'
|
||||
| 'people'
|
||||
| 'teams'
|
||||
| 'security'
|
||||
| 'identity'
|
||||
| 'plan'
|
||||
| 'payments'
|
||||
| 'requests'
|
||||
| 'developer'
|
||||
| 'api-keys'
|
||||
| 'hotkeys';
|
||||
|
||||
|
||||
// some of these are not used yet, but appear in figma designs
|
||||
@@ -19,7 +19,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
children
|
||||
}) => {
|
||||
if (!file) return null;
|
||||
|
||||
|
||||
const containerStyle = {
|
||||
position: 'relative' as const,
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
@@ -36,7 +36,6 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Image
|
||||
className='ph-no-capture'
|
||||
src={thumbnail}
|
||||
alt={`Preview of ${file.name}`}
|
||||
fit="contain"
|
||||
@@ -50,12 +49,11 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
|
||||
<PictureAsPdfIcon
|
||||
className='ph-no-capture'
|
||||
style={{
|
||||
fontSize: '2rem',
|
||||
color: 'var(--mantine-color-gray-6)'
|
||||
}}
|
||||
<PictureAsPdfIcon
|
||||
style={{
|
||||
fontSize: '2rem',
|
||||
color: 'var(--mantine-color-gray-6)'
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
{children}
|
||||
@@ -63,4 +61,4 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentThumbnail;
|
||||
export default DocumentThumbnail;
|
||||
@@ -33,11 +33,8 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
||||
const { getHomeNavigation } = useSidebarNavigation();
|
||||
|
||||
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
|
||||
// Special case: multiTool should always show even when sidebars are hidden
|
||||
const indicatorShouldShow = Boolean(
|
||||
selectedToolKey &&
|
||||
((leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)) ||
|
||||
selectedToolKey === 'multiTool')
|
||||
selectedToolKey && leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)
|
||||
);
|
||||
|
||||
// Local animation and hover state
|
||||
@@ -50,7 +47,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
||||
const animTimeoutRef = useRef<number | null>(null);
|
||||
const replayRafRef = useRef<number | null>(null);
|
||||
|
||||
const isSwitchingToNewTool = () => { return prevKeyRef.current && prevKeyRef.current !== selectedToolKey; };
|
||||
const isSwitchingToNewTool = () => { return prevKeyRef.current && prevKeyRef.current !== selectedToolKey };
|
||||
|
||||
const clearTimers = () => {
|
||||
if (collapseTimeoutRef.current) {
|
||||
@@ -81,7 +78,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
||||
setReplayAnim(false);
|
||||
animTimeoutRef.current = null;
|
||||
}, 500);
|
||||
};
|
||||
}
|
||||
|
||||
const firstShow = () => {
|
||||
clearTimers();
|
||||
@@ -91,7 +88,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
||||
animTimeoutRef.current = window.setTimeout(() => {
|
||||
animTimeoutRef.current = null;
|
||||
}, 500);
|
||||
};
|
||||
}
|
||||
|
||||
const triggerCollapse = () => {
|
||||
clearTimers();
|
||||
@@ -101,7 +98,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
||||
prevKeyRef.current = null;
|
||||
collapseTimeoutRef.current = null;
|
||||
}, 500); // match CSS transition duration
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (indicatorShouldShow) {
|
||||
|
||||
@@ -12,7 +12,7 @@ export const isNavButtonActive = (
|
||||
isFilesModalOpen: boolean,
|
||||
configModalOpen: boolean,
|
||||
selectedToolKey?: string | null,
|
||||
leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
|
||||
leftPanelView?: 'toolPicker' | 'toolContent'
|
||||
): boolean => {
|
||||
const isActiveByLocalState = config.type === 'navigation' && activeButton === config.id;
|
||||
const isActiveByContext =
|
||||
@@ -35,7 +35,7 @@ export const getNavButtonStyle = (
|
||||
isFilesModalOpen: boolean,
|
||||
configModalOpen: boolean,
|
||||
selectedToolKey?: string | null,
|
||||
leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
|
||||
leftPanelView?: 'toolPicker' | 'toolContent'
|
||||
) => {
|
||||
const isActive = isNavButtonActive(
|
||||
config,
|
||||
|
||||
@@ -46,13 +46,6 @@
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* When all buttons are disabled via context */
|
||||
.right-rail--all-disabled .right-rail-icon {
|
||||
color: var(--right-rail-icon-disabled) !important;
|
||||
background-color: transparent !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.right-rail-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -74,7 +67,7 @@
|
||||
}
|
||||
|
||||
.right-rail-slot.visible {
|
||||
max-height: 40rem; /* increased to fit additional controls + divider */
|
||||
max-height: 18rem; /* increased to fit additional controls + divider */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -84,14 +77,14 @@
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
max-height: 40rem;
|
||||
max-height: 18rem;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rightRailShrinkUp {
|
||||
0% {
|
||||
max-height: 40rem;
|
||||
max-height: 18rem;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '../LocalIcon';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
import { ViewerContext } from '../../../contexts/ViewerContext';
|
||||
import { useSignature } from '../../../contexts/SignatureContext';
|
||||
import { ColorSwatchButton, ColorPicker } from '../../annotation/shared/ColorPicker';
|
||||
import { useFileState, useFileContext } from '../../../contexts/FileContext';
|
||||
import { generateThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
|
||||
import { createProcessedFile } from '../../../contexts/file/fileActions';
|
||||
import { createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
|
||||
import { useNavigationState } from '../../../contexts/NavigationContext';
|
||||
|
||||
interface ViewerAnnotationControlsProps {
|
||||
currentView: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
|
||||
|
||||
// Viewer context for PDF controls - safely handle when not available
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
|
||||
// Signature context for accessing drawing API
|
||||
const { signatureApiRef, isPlacementMode } = useSignature();
|
||||
|
||||
// File state for save functionality
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const activeFiles = selectors.getFiles();
|
||||
|
||||
// Check if we're in sign mode
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isSignMode = selectedTool === 'sign';
|
||||
|
||||
// Turn off annotation mode when switching away from viewer
|
||||
useEffect(() => {
|
||||
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
|
||||
viewerContext.setAnnotationMode(false);
|
||||
}
|
||||
}, [currentView, viewerContext]);
|
||||
|
||||
// Don't show any annotation controls in sign mode
|
||||
if (isSignMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Annotation Visibility Toggle */}
|
||||
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationsVisibility();
|
||||
}}
|
||||
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Mode Toggle with Drawing Controls */}
|
||||
{viewerContext?.isAnnotationMode ? (
|
||||
// When active: Show color picker on hover
|
||||
<div
|
||||
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
|
||||
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
|
||||
style={{ display: 'inline-flex' }}
|
||||
>
|
||||
<Popover
|
||||
opened={isHoverColorPickerOpen}
|
||||
onClose={() => setIsHoverColorPickerOpen(false)}
|
||||
position="left"
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
|
||||
// Deactivate drawing tool when exiting annotation mode
|
||||
if (signatureApiRef?.current) {
|
||||
try {
|
||||
signatureApiRef.current.deactivateTools();
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label="Drawing mode active"
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '8rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
size={32}
|
||||
onClick={() => {
|
||||
setIsHoverColorPickerOpen(false); // Close hover picker
|
||||
setIsColorPickerOpen(true); // Open main color picker modal
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
// When inactive: Show "Draw" tooltip
|
||||
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
// Activate ink drawing tool when entering annotation mode
|
||||
if (signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.activateDrawMode();
|
||||
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Save PDF with Annotations */}
|
||||
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={async () => {
|
||||
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
|
||||
try {
|
||||
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
|
||||
if (pdfArrayBuffer) {
|
||||
// Create new File object with flattened annotations
|
||||
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
|
||||
|
||||
// Get the original file name or use a default
|
||||
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
|
||||
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
|
||||
|
||||
// Replace the current file in context with the saved version (exact same logic as Sign tool)
|
||||
if (activeFiles.length > 0) {
|
||||
// Generate thumbnail and metadata for the saved file
|
||||
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
|
||||
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
|
||||
|
||||
// Get current file info
|
||||
const currentFileIds = state.files.ids;
|
||||
if (currentFileIds.length > 0) {
|
||||
const currentFileId = currentFileIds[0];
|
||||
const currentRecord = selectors.getStirlingFileStub(currentFileId);
|
||||
|
||||
if (!currentRecord) {
|
||||
console.error('No file record found for:', currentFileId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create output stub and file (exact same as Sign tool)
|
||||
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
|
||||
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
|
||||
|
||||
// Replace the original file with the saved version
|
||||
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving PDF:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={(color) => {
|
||||
setSelectedColor(color);
|
||||
// Update drawing tool color if annotation mode is active
|
||||
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.updateDrawSettings(color, 2);
|
||||
} catch (error) {
|
||||
console.log('Unable to update drawing settings:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Choose Drawing Color"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user