mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3adf3d4a3a | ||
|
|
c9eee00d66 | ||
|
|
3e23dc59b6 | ||
|
|
3e6236d957 | ||
|
|
5354f08766 | ||
|
|
a8573c99b7 | ||
|
|
f6a7b983a0 | ||
|
|
949a16e6eb | ||
|
|
43887c8179 | ||
|
|
bcd7762594 | ||
|
|
28e45917a2 | ||
|
|
af57ae02dd | ||
|
|
3cebcc70af | ||
|
|
b54beaa66b | ||
|
|
e542f4bc61 | ||
|
|
a339f71116 | ||
|
|
b695e3900e | ||
|
|
2158ee4db6 | ||
|
|
3090a85726 | ||
|
|
d714a1617f | ||
|
|
2a29bda34f | ||
|
|
ab6edd3196 | ||
|
|
be7e79be55 | ||
|
|
c9e1b8eec5 | ||
|
|
eba93a3b6c | ||
|
|
03e81a0f16 | ||
|
|
f9ac1bd62e | ||
|
|
8aa6aff53a | ||
|
|
458bb641b5 | ||
|
|
247f82b5a7 | ||
|
|
06b4c147bd | ||
|
|
25154e4dbe | ||
|
|
989eea9e24 | ||
|
|
510e1c38eb | ||
|
|
ec05c5c049 | ||
|
|
d86a13cc89 | ||
|
|
85dedf4b28 | ||
|
|
0fa53185f2 | ||
|
|
3dd4a33595 | ||
|
|
fe2bfd8739 | ||
|
|
a15b0e33d5 | ||
|
|
0a242f6a57 | ||
|
|
3c46010155 | ||
|
|
02189a67bd | ||
|
|
d4985f57d4 | ||
|
|
4ab66fdf14 | ||
|
|
c19abe0da7 | ||
|
|
dd6b7968db | ||
|
|
2228ae7197 | ||
|
|
30987dcad2 | ||
|
|
43beadbdcb | ||
|
|
416d79aed3 | ||
|
|
abc0988fdf | ||
|
|
c7e0ea5b5b | ||
|
|
b35447934e | ||
|
|
d1e82eb8f1 | ||
|
|
7d44cc1a40 |
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
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,6 +12,7 @@ on:
|
||||
branches:
|
||||
- V2
|
||||
paths:
|
||||
- ".github/workflows/frontend-licenses-update.yml"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
@@ -28,12 +29,12 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR head (default)
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -48,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Checkout BASE branch (safe script)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
@@ -56,9 +57,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
@@ -114,7 +115,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@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -167,7 +168,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@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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,6 +192,11 @@ 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
|
||||
|
||||
@@ -97,7 +97,6 @@ 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:
|
||||
@@ -116,46 +115,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,12 +258,6 @@ public class AppConfig {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "GoogleDriveEnabled")
|
||||
@Profile("default")
|
||||
public boolean googleDriveEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "license")
|
||||
@Profile("default")
|
||||
public String licenseType() {
|
||||
|
||||
+36
-21
@@ -120,6 +120,7 @@ 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();
|
||||
@@ -308,6 +309,41 @@ 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
|
||||
@@ -530,7 +566,6 @@ public class ApplicationProperties {
|
||||
private boolean ssoAutoLogin;
|
||||
private boolean database;
|
||||
private CustomMetadata customMetadata = new CustomMetadata();
|
||||
private GoogleDrive googleDrive = new GoogleDrive();
|
||||
|
||||
@Data
|
||||
public static class CustomMetadata {
|
||||
@@ -549,26 +584,6 @@ 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,22 +109,6 @@ 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,11 +98,6 @@ 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",
|
||||
|
||||
+129
-43
@@ -5,9 +5,11 @@ 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;
|
||||
|
||||
@@ -32,6 +34,7 @@ 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;
|
||||
@@ -42,6 +45,7 @@ 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 {
|
||||
@@ -65,8 +69,9 @@ public class ValidateSignatureController {
|
||||
@Operation(
|
||||
summary = "Validate PDF Digital Signature",
|
||||
description =
|
||||
"Validates the digital signatures in a PDF file against default or custom"
|
||||
+ " certificates. Input:PDF Output:JSON Type:SISO")
|
||||
"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")
|
||||
@AutoJobPostMapping(
|
||||
value = "/validate-signature",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -74,12 +79,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 (certFile != null && !certFile.isEmpty()) {
|
||||
try (ByteArrayInputStream certStream = new ByteArrayInputStream(certFile.getBytes())) {
|
||||
if (request.getCertFile() != null && !request.getCertFile().isEmpty()) {
|
||||
try (ByteArrayInputStream certStream =
|
||||
new ByteArrayInputStream(request.getCertFile().getBytes())) {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
customCert = (X509Certificate) cf.generateCertificate(certStream);
|
||||
} catch (CertificateException e) {
|
||||
@@ -108,67 +113,150 @@ public class ValidateSignatureController {
|
||||
Store<X509CertificateHolder> certStore = signedData.getCertificates();
|
||||
SignerInformationStore signerStore = signedData.getSignerInfos();
|
||||
|
||||
for (SignerInformation signer : signerStore.getSigners()) {
|
||||
for (SignerInformation signerInfo : signerStore.getSigners()) {
|
||||
X509CertificateHolder certHolder =
|
||||
(X509CertificateHolder)
|
||||
certStore.getMatches(signer.getSID()).iterator().next();
|
||||
X509Certificate cert =
|
||||
certStore.getMatches(signerInfo.getSID()).iterator().next();
|
||||
X509Certificate signerCert =
|
||||
new JcaX509CertificateConverter().getCertificate(certHolder);
|
||||
|
||||
boolean isValid =
|
||||
signer.verify(new JcaSimpleSignerInfoVerifierBuilder().build(cert));
|
||||
result.setValid(isValid);
|
||||
// Extract intermediate certificates from CMS
|
||||
Collection<X509Certificate> intermediates =
|
||||
certValidationService.extractIntermediateCertificates(
|
||||
certStore, signerCert);
|
||||
|
||||
// Additional validations
|
||||
result.setChainValid(
|
||||
customCert != null
|
||||
? certValidationService
|
||||
.validateCertificateChainWithCustomCert(
|
||||
cert, customCert)
|
||||
: certValidationService.validateCertificateChain(cert));
|
||||
// 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());
|
||||
}
|
||||
|
||||
result.setTrustValid(
|
||||
customCert != null
|
||||
? certValidationService.validateTrustWithCustomCert(
|
||||
cert, customCert)
|
||||
: certValidationService.validateTrustStore(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.setNotRevoked(!certValidationService.isRevoked(cert));
|
||||
result.setNotExpired(!cert.getNotAfter().before(new Date()));
|
||||
// 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");
|
||||
}
|
||||
|
||||
// Set basic signature info
|
||||
result.setSignerName(sig.getName());
|
||||
result.setSignatureDate(sig.getSignDate().getTime().toString());
|
||||
result.setSignatureDate(
|
||||
sig.getSignDate() != null
|
||||
? sig.getSignDate().getTime().toString()
|
||||
: null);
|
||||
result.setReason(sig.getReason());
|
||||
result.setLocation(sig.getLocation());
|
||||
|
||||
// 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());
|
||||
// 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());
|
||||
|
||||
// Get key size (if possible)
|
||||
try {
|
||||
result.setKeySize(
|
||||
((RSAPublicKey) cert.getPublicKey()).getModulus().bitLength());
|
||||
((RSAPublicKey) signerCert.getPublicKey())
|
||||
.getModulus()
|
||||
.bitLength());
|
||||
} catch (Exception e) {
|
||||
// If not RSA or error, set to 0
|
||||
result.setKeySize(0);
|
||||
}
|
||||
|
||||
result.setVersion(String.valueOf(cert.getVersion()));
|
||||
result.setVersion(String.valueOf(signerCert.getVersion()));
|
||||
|
||||
// Set key usage
|
||||
List<String> keyUsages = new ArrayList<>();
|
||||
boolean[] keyUsageFlags = cert.getKeyUsage();
|
||||
boolean[] keyUsageFlags = signerCert.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]) {
|
||||
@@ -178,10 +266,8 @@ public class ValidateSignatureController {
|
||||
}
|
||||
result.setKeyUsages(keyUsages);
|
||||
|
||||
// Check if self-signed
|
||||
result.setSelfSigned(
|
||||
cert.getSubjectX500Principal()
|
||||
.equals(cert.getIssuerX500Principal()));
|
||||
// Check if self-signed (properly)
|
||||
result.setSelfSigned(certValidationService.isSelfSigned(signerCert));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.setValid(false);
|
||||
|
||||
+19
-4
@@ -6,17 +6,32 @@ 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
|
||||
|
||||
+814
-94
@@ -1,143 +1,863 @@
|
||||
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.KeyStoreException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.cert.*;
|
||||
import java.util.*;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
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 io.github.pixee.security.BoundedLineReader;
|
||||
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 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 {
|
||||
private KeyStore trustStore;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
private void initializeTrustStore() throws Exception {
|
||||
trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
trustStore.load(null, null);
|
||||
loadMozillaCertificates();
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
while ((line = BoundedLineReader.readLine(reader, 5_000_000)) != null) {
|
||||
if (line.startsWith("CKA_VALUE MULTILINE_OCTAL")) {
|
||||
inCert = true;
|
||||
certData = new StringBuilder();
|
||||
continue;
|
||||
// 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));
|
||||
}
|
||||
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) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] parseOctalData(String data) {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
String[] tokens = data.split("\\\\");
|
||||
for (String token : tokens) {
|
||||
token = token.trim();
|
||||
if (!token.isEmpty()) {
|
||||
baos.write(Integer.parseInt(token, 8));
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
log.debug("Error extracting validation time: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean validateCertificateChain(X509Certificate cert) {
|
||||
/**
|
||||
* 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) {
|
||||
try {
|
||||
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();
|
||||
cert.checkValidity(at);
|
||||
return false;
|
||||
} catch (CertificateExpiredException | CertificateNotYetValidException e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean validateCertificateChainWithCustomCert(
|
||||
X509Certificate cert, X509Certificate customCert) {
|
||||
/**
|
||||
* 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) {
|
||||
try {
|
||||
cert.verify(customCert.getPublicKey());
|
||||
if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) {
|
||||
return false;
|
||||
}
|
||||
cert.verify(cert.getPublicKey());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean validateTrustWithCustomCert(X509Certificate cert, X509Certificate customCert) {
|
||||
/**
|
||||
* Calculate SHA-256 fingerprint of certificate
|
||||
*
|
||||
* @param cert Certificate
|
||||
* @return Hex string of SHA-256 hash
|
||||
*/
|
||||
public String sha256Fingerprint(X509Certificate cert) {
|
||||
try {
|
||||
// Compare the issuer of the signature certificate with the custom certificate
|
||||
return cert.getIssuerX500Principal().equals(customCert.getSubjectX500Principal());
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = md.digest(cert.getEncoded());
|
||||
return bytesToHex(hash);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +1916,7 @@ 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
|
||||
@@ -1945,6 +1946,25 @@ 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
|
||||
@@ -1957,3 +1977,39 @@ 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,6 +65,22 @@ 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
|
||||
@@ -76,11 +92,6 @@ premium:
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
producer: Stirling-PDF
|
||||
googleDrive:
|
||||
enabled: false
|
||||
clientId: ''
|
||||
apiKey: ''
|
||||
appId: ''
|
||||
enterpriseFeatures:
|
||||
audit:
|
||||
enabled: true # Enable audit logging
|
||||
|
||||
@@ -422,10 +422,6 @@
|
||||
<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>
|
||||
@@ -443,16 +439,4 @@
|
||||
</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>
|
||||
|
||||
+46
-100
@@ -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 javax.security.auth.x500.X500Principal;
|
||||
import java.util.Date;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/** Tests for the CertificateValidationService using mocked certificates. */
|
||||
class CertificateValidationServiceTest {
|
||||
@@ -26,121 +26,67 @@ class CertificateValidationServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
validationService = new CertificateValidationService();
|
||||
// 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(trust.isServerAsAnchor()).thenReturn(false);
|
||||
when(trust.isUseSystemTrust()).thenReturn(false);
|
||||
when(trust.isUseMozillaBundle()).thenReturn(false);
|
||||
when(trust.isUseAATL()).thenReturn(false);
|
||||
when(trust.isUseEUTL()).thenReturn(false);
|
||||
when(revocation.getMode()).thenReturn("none");
|
||||
when(revocation.isHardFail()).thenReturn(false);
|
||||
|
||||
validationService = new CertificateValidationService(null, applicationProperties);
|
||||
|
||||
// Create mock certificates
|
||||
validCertificate = mock(X509Certificate.class);
|
||||
expiredCertificate = mock(X509Certificate.class);
|
||||
|
||||
// Set up behaviors for valid certificate
|
||||
doNothing().when(validCertificate).checkValidity(); // No exception means valid
|
||||
// Set up behaviors for valid certificate (both overloads)
|
||||
doNothing().when(validCertificate).checkValidity();
|
||||
doNothing().when(validCertificate).checkValidity(any(Date.class));
|
||||
|
||||
// Set up behaviors for expired certificate
|
||||
// Set up behaviors for expired certificate (both overloads)
|
||||
doThrow(new CertificateExpiredException("Certificate expired"))
|
||||
.when(expiredCertificate)
|
||||
.checkValidity();
|
||||
doThrow(new CertificateExpiredException("Certificate expired"))
|
||||
.when(expiredCertificate)
|
||||
.checkValidity(any(Date.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsRevoked_ValidCertificate() {
|
||||
void testIsOutsideValidityPeriod_ValidCertificate() {
|
||||
// When certificate is valid (not expired)
|
||||
boolean result = validationService.isRevoked(validCertificate);
|
||||
boolean result = validationService.isOutsideValidityPeriod(validCertificate, new Date());
|
||||
|
||||
// Then it should not be considered revoked
|
||||
assertFalse(result, "Valid certificate should not be considered revoked");
|
||||
// Then it should not be outside validity period
|
||||
assertFalse(result, "Valid certificate should not be outside validity period");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsRevoked_ExpiredCertificate() {
|
||||
void testIsOutsideValidityPeriod_ExpiredCertificate() {
|
||||
// When certificate is expired
|
||||
boolean result = validationService.isRevoked(expiredCertificate);
|
||||
boolean result = validationService.isOutsideValidityPeriod(expiredCertificate, new Date());
|
||||
|
||||
// Then it should be considered revoked
|
||||
assertTrue(result, "Expired certificate should be considered revoked");
|
||||
// Then it should be outside validity period
|
||||
assertTrue(result, "Expired certificate should be outside validity period");
|
||||
}
|
||||
|
||||
@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");
|
||||
}
|
||||
// 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.
|
||||
}
|
||||
|
||||
-14
@@ -12,7 +12,6 @@ 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)
|
||||
@@ -55,19 +54,6 @@ 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) {
|
||||
|
||||
+8
-1
@@ -50,7 +50,14 @@ 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,6 +47,9 @@ 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,6 +44,9 @@ 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,6 +46,9 @@ 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 ./
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
+67
-21
@@ -1,42 +1,88 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
|
||||
const srcGlobs = [
|
||||
'src/**/*.{js,mjs,jsx,ts,tsx}',
|
||||
];
|
||||
const nodeGlobs = [
|
||||
'scripts/**/*.{js,ts,mjs}',
|
||||
'*.config.{js,ts,mjs}',
|
||||
];
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
// Everything that contains 3rd party code that we don't want to lint
|
||||
ignores: [
|
||||
'dist',
|
||||
'node_modules',
|
||||
'public',
|
||||
],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
ignores: [
|
||||
"dist", // Contains 3rd party code
|
||||
"public", // Contains 3rd party code
|
||||
],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
"no-undef": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-empty-object-type": [
|
||||
"error",
|
||||
'@typescript-eslint/no-empty-object-type': [
|
||||
'error',
|
||||
{
|
||||
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
|
||||
allowInterfaces: 'with-single-extends',
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
'@typescript-eslint/no-explicit-any': 'off', // Temporarily disabled until codebase conformant
|
||||
'@typescript-eslint/no-require-imports': 'off', // Temporarily disabled until codebase conformant
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
"args": "all", // All function args must be used (or explicitly ignored)
|
||||
"argsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
|
||||
"caughtErrors": "all", // Caught errors must be used (or explicitly ignored)
|
||||
"caughtErrorsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
|
||||
"destructuredArrayIgnorePattern": "^_", // Allow unused variables beginning with an underscore
|
||||
"varsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
|
||||
"ignoreRestSiblings": true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
|
||||
'args': 'all', // All function args must be used (or explicitly ignored)
|
||||
'argsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
|
||||
'caughtErrors': 'all', // Caught errors must be used (or explicitly ignored)
|
||||
'caughtErrorsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
|
||||
'destructuredArrayIgnorePattern': '^_', // Allow unused variables beginning with an underscore
|
||||
'varsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
|
||||
'ignoreRestSiblings': true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
// Config for browser scripts
|
||||
{
|
||||
files: srcGlobs,
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
}
|
||||
}
|
||||
},
|
||||
// Config for node scripts
|
||||
{
|
||||
files: nodeGlobs,
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
}
|
||||
}
|
||||
},
|
||||
// Config for import plugin
|
||||
{
|
||||
...importPlugin.flatConfigs.recommended,
|
||||
...importPlugin.flatConfigs.typescript,
|
||||
rules: {
|
||||
// ...importPlugin.flatConfigs.recommended.rules, // Temporarily disabled until codebase conformant
|
||||
...importPlugin.flatConfigs.typescript.rules,
|
||||
'import/no-cycle': 'error',
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Generated
+2882
-315
File diff suppressed because it is too large
Load Diff
+29
-16
@@ -6,22 +6,24 @@
|
||||
"proxy": "http://localhost:8080",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@embedpdf/core": "^1.3.0",
|
||||
"@embedpdf/engines": "^1.2.1",
|
||||
"@embedpdf/plugin-export": "^1.3.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.0",
|
||||
"@embedpdf/plugin-loader": "^1.3.0",
|
||||
"@embedpdf/plugin-pan": "^1.3.0",
|
||||
"@embedpdf/plugin-render": "^1.3.0",
|
||||
"@embedpdf/plugin-rotate": "^1.3.0",
|
||||
"@embedpdf/plugin-scroll": "^1.3.0",
|
||||
"@embedpdf/plugin-search": "^1.3.0",
|
||||
"@embedpdf/plugin-selection": "^1.3.0",
|
||||
"@embedpdf/plugin-spread": "^1.3.0",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.0",
|
||||
"@embedpdf/plugin-tiling": "^1.3.0",
|
||||
"@embedpdf/plugin-viewport": "^1.3.0",
|
||||
"@embedpdf/plugin-zoom": "^1.3.0",
|
||||
"@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",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -31,10 +33,12 @@
|
||||
"@mantine/hooks": "^8.3.1",
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@reactour/tour": "^3.8.0",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@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",
|
||||
@@ -47,6 +51,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -62,6 +67,7 @@
|
||||
"generate-licenses": "node scripts/generate-licenses.js",
|
||||
"generate-icons": "node scripts/generate-icons.js",
|
||||
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
|
||||
"generate-sample-pdf": "node scripts/sample-pdf/generate.mjs",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:watch": "vitest --watch",
|
||||
@@ -102,6 +108,10 @@
|
||||
"@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",
|
||||
@@ -111,6 +121,8 @@
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"license-checker": "^25.0.1",
|
||||
"madge": "^8.0.0",
|
||||
@@ -118,6 +130,7 @@
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"puppeteer": "^24.25.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"vite": "^7.1.7",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -87,7 +87,10 @@
|
||||
"showStack": "Stack-Trace anzeigen",
|
||||
"copyStack": "Stack-Trace kopieren",
|
||||
"githubSubmit": "GitHub - Ein Ticket einreichen",
|
||||
"discordSubmit": "Discord - Unterstützungsbeitrag 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."
|
||||
},
|
||||
"warning": {
|
||||
"tooltipTitle": "Warnung"
|
||||
@@ -358,179 +361,223 @@
|
||||
"sortBy": "Sortieren nach:",
|
||||
"multiTool": {
|
||||
"title": "PDF-Multitool",
|
||||
"desc": "Seiten zusammenführen, drehen, neu anordnen und entfernen"
|
||||
"desc": "Seiten zusammenführen, drehen, neu anordnen und entfernen",
|
||||
"tags": "mehrere,werkzeuge"
|
||||
},
|
||||
"merge": {
|
||||
"title": "Zusammenführen",
|
||||
"desc": "Mehrere PDF-Dateien zu einer einzigen zusammenführen"
|
||||
"desc": "Mehrere PDF-Dateien zu einer einzigen zusammenführen",
|
||||
"tags": "kombinieren,zusammenführen,vereinen"
|
||||
},
|
||||
"split": {
|
||||
"title": "Aufteilen",
|
||||
"desc": "PDFs in mehrere Dokumente aufteilen"
|
||||
"desc": "PDFs in mehrere Dokumente aufteilen",
|
||||
"tags": "teilen,trennen,aufteilen"
|
||||
},
|
||||
"rotate": {
|
||||
"title": "Drehen",
|
||||
"desc": "Drehen Sie Ihre PDFs ganz einfach"
|
||||
"desc": "Drehen Sie Ihre PDFs ganz einfach",
|
||||
"tags": "drehen,spiegeln,ausrichten"
|
||||
},
|
||||
"convert": {
|
||||
"title": "Umwandeln",
|
||||
"desc": "Dateien zwischen verschiedenen Formaten konvertieren"
|
||||
"desc": "Dateien zwischen verschiedenen Formaten konvertieren",
|
||||
"tags": "umwandeln,ändern"
|
||||
},
|
||||
"pdfOrganiser": {
|
||||
"title": "Organisieren",
|
||||
"desc": "Seiten entfernen und Seitenreihenfolge ändern"
|
||||
"desc": "Seiten entfernen und Seitenreihenfolge ändern",
|
||||
"tags": "organisieren,umordnen,neu anordnen"
|
||||
},
|
||||
"addImage": {
|
||||
"title": "Bild einfügen",
|
||||
"desc": "Fügt ein Bild an eine bestimmte Stelle im PDF ein (in Arbeit)"
|
||||
"desc": "Fügt ein Bild an eine bestimmte Stelle im PDF ein (in Arbeit)",
|
||||
"tags": "einfügen,einbetten,platzieren"
|
||||
},
|
||||
"addAttachments": {
|
||||
"title": "Anhänge hinzufügen",
|
||||
"desc": "Eingebettete Dateien (Anhänge) zu einer PDF hinzufügen oder entfernen"
|
||||
"desc": "Eingebettete Dateien (Anhänge) zu einer PDF hinzufügen oder entfernen",
|
||||
"tags": "einbetten,anhängen,einfügen"
|
||||
},
|
||||
"watermark": {
|
||||
"title": "Wasserzeichen hinzufügen",
|
||||
"desc": "Fügen Sie ein eigenes Wasserzeichen zu Ihrem PDF hinzu"
|
||||
"desc": "Fügen Sie ein eigenes Wasserzeichen zu Ihrem PDF hinzu",
|
||||
"tags": "stempel,markierung,überlagerung"
|
||||
},
|
||||
"removePassword": {
|
||||
"title": "Passwort entfernen",
|
||||
"desc": "Den Passwortschutz eines PDFs entfernen"
|
||||
"desc": "Den Passwortschutz eines PDFs entfernen",
|
||||
"tags": "entsperren"
|
||||
},
|
||||
"compress": {
|
||||
"title": "Komprimieren",
|
||||
"desc": "PDF komprimieren um die Dateigröße zu reduzieren"
|
||||
"desc": "PDF komprimieren um die Dateigröße zu reduzieren",
|
||||
"tags": "verkleinern,reduzieren,optimieren"
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"title": "Schreibgeschützte PDF-Formfelder entfernen",
|
||||
"desc": "Entfernen Sie die schreibgeschützte Eigenschaft von Formularfeldern in einem PDF-Dokument."
|
||||
"desc": "Entfernen Sie die schreibgeschützte Eigenschaft von Formularfeldern in einem PDF-Dokument.",
|
||||
"tags": "entsperren,aktivieren,bearbeiten"
|
||||
},
|
||||
"changeMetadata": {
|
||||
"title": "Metadaten ändern",
|
||||
"desc": "Ändern/Entfernen/Hinzufügen von Metadaten aus einem PDF-Dokument"
|
||||
"desc": "Ändern/Entfernen/Hinzufügen von Metadaten aus einem PDF-Dokument",
|
||||
"tags": "bearbeiten,ändern,aktualisieren"
|
||||
},
|
||||
"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"
|
||||
"desc": "Cleanup scannt und erkennt Text aus Bildern in einer PDF-Datei und fügt ihn erneut als Text hinzu",
|
||||
"tags": "extrahieren,scannen"
|
||||
},
|
||||
"extractImages": {
|
||||
"title": "Bilder extrahieren",
|
||||
"desc": "Extrahiert alle Bilder aus einer PDF-Datei und speichert sie als Zip-Archiv"
|
||||
"desc": "Extrahiert alle Bilder aus einer PDF-Datei und speichert sie als Zip-Archiv",
|
||||
"tags": "extrahieren,speichern,exportieren"
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"title": "Gescannte Fotos erkennen/aufteilen",
|
||||
"desc": "Teilt mehrere Fotos aus einem Foto/PDF auf"
|
||||
"desc": "Teilt mehrere Fotos aus einem Foto/PDF auf",
|
||||
"tags": "erkennen,teilen,fotos"
|
||||
},
|
||||
"sign": {
|
||||
"title": "Signieren",
|
||||
"desc": "Fügt PDF-Signaturen durch Zeichnung, Text oder Bild hinzu"
|
||||
"desc": "Fügt PDF-Signaturen durch Zeichnung, Text oder Bild hinzu",
|
||||
"tags": "unterschrift,autogramm"
|
||||
},
|
||||
"flatten": {
|
||||
"title": "Abflachen",
|
||||
"desc": "Alle interaktiven Elemente und Formulare aus einem PDF entfernen"
|
||||
"desc": "Alle interaktiven Elemente und Formulare aus einem PDF entfernen",
|
||||
"tags": "vereinfachen,entfernen,interaktiv"
|
||||
},
|
||||
"certSign": {
|
||||
"title": "Mit Zertifikat signieren",
|
||||
"desc": "Ein PDF mit einem Zertifikat/Schlüssel (PEM/P12) 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"
|
||||
},
|
||||
"repair": {
|
||||
"title": "Reparatur",
|
||||
"desc": "Versucht, ein beschädigtes/kaputtes PDF zu reparieren"
|
||||
"desc": "Versucht, ein beschädigtes/kaputtes PDF zu reparieren",
|
||||
"tags": "reparieren,wiederherstellen"
|
||||
},
|
||||
"removeBlanks": {
|
||||
"title": "Leere Seiten entfernen",
|
||||
"desc": "Erkennt und entfernt leere Seiten aus einem Dokument"
|
||||
"desc": "Erkennt und entfernt leere Seiten aus einem Dokument",
|
||||
"tags": "löschen,bereinigen,leer"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"title": "Anmerkungen entfernen",
|
||||
"desc": "Entfernt alle Kommentare/Anmerkungen aus einem PDF"
|
||||
"desc": "Entfernt alle Kommentare/Anmerkungen aus einem PDF",
|
||||
"tags": "löschen,bereinigen,entfernen"
|
||||
},
|
||||
"compare": {
|
||||
"title": "Vergleichen",
|
||||
"desc": "Vergleicht und zeigt die Unterschiede zwischen zwei PDF-Dokumenten an"
|
||||
"desc": "Vergleicht und zeigt die Unterschiede zwischen zwei PDF-Dokumenten an",
|
||||
"tags": "unterschied"
|
||||
},
|
||||
"removeCertSign": {
|
||||
"title": "Zertifikatsignatur entfernen",
|
||||
"desc": "Zertifikatsignatur aus PDF entfernen"
|
||||
"desc": "Zertifikatsignatur aus PDF entfernen",
|
||||
"tags": "entfernen,löschen,entsperren"
|
||||
},
|
||||
"pageLayout": {
|
||||
"title": "Mehrseitiges Layout",
|
||||
"desc": "Mehrere Seiten eines PDF zu einer Seite zusammenführen"
|
||||
"desc": "Mehrere Seiten eines PDF zu einer Seite zusammenführen",
|
||||
"tags": "layout,anordnen,kombinieren"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"title": "Broschüren-Layout",
|
||||
"desc": "Broschüren mit korrekter Seitenreihenfolge und mehrseitigem Layout für Druck und Bindung erstellen"
|
||||
"desc": "Broschüren mit korrekter Seitenreihenfolge und mehrseitigem Layout für Druck und Bindung erstellen",
|
||||
"tags": "broschüre,druck,bindung"
|
||||
},
|
||||
"scalePages": {
|
||||
"title": "Seitengröße/Skalierung anpassen",
|
||||
"desc": "Größe/Skalierung der Seite und/oder des Inhalts ändern"
|
||||
"desc": "Größe/Skalierung der Seite und/oder des Inhalts ändern",
|
||||
"tags": "größe ändern,anpassen,skalieren"
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"title": "Seitenzahlen hinzufügen",
|
||||
"desc": "Hinzufügen von Seitenzahlen an einer bestimmten Stelle"
|
||||
"desc": "Hinzufügen von Seitenzahlen an einer bestimmten Stelle",
|
||||
"tags": "nummerieren,paginierung,zählen"
|
||||
},
|
||||
"autoRename": {
|
||||
"title": "PDF-Datei automatisch umbenennen",
|
||||
"desc": "Benennt eine PDF-Datei automatisch basierend auf der erkannten Überschrift um"
|
||||
"desc": "Benennt eine PDF-Datei automatisch basierend auf der erkannten Überschrift um",
|
||||
"tags": "auto-erkennung,kopfzeilen-basiert,organisieren,umbenennen"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Farben/Kontrast anpassen",
|
||||
"desc": "Kontrast, Sättigung und Helligkeit einer PDF anpassen"
|
||||
"desc": "Kontrast, Sättigung und Helligkeit einer PDF anpassen",
|
||||
"tags": "kontrast,helligkeit,sättigung"
|
||||
},
|
||||
"crop": {
|
||||
"title": "PDF zuschneiden",
|
||||
"desc": "PDF zuschneiden um die Größe zu verändern (Text bleibt erhalten!)"
|
||||
"desc": "PDF zuschneiden um die Größe zu verändern (Text bleibt erhalten!)",
|
||||
"tags": "zuschneiden,schneiden,größe ändern"
|
||||
},
|
||||
"autoSplitPDF": {
|
||||
"title": "PDF automatisch teilen",
|
||||
"desc": "Physisch gescannte PDF anhand von Splitter-Seiten und QR-Codes aufteilen"
|
||||
"desc": "Physisch gescannte PDF anhand von Splitter-Seiten und QR-Codes aufteilen",
|
||||
"tags": "auto,teilen,QR"
|
||||
},
|
||||
"sanitize": {
|
||||
"title": "Bereinigen",
|
||||
"desc": "Potentiell schädliche Elemente aus PDF-Dateien entfernen"
|
||||
"desc": "Potentiell schädliche Elemente aus PDF-Dateien entfernen",
|
||||
"tags": "bereinigen,löschen,entfernen"
|
||||
},
|
||||
"getPdfInfo": {
|
||||
"title": "Alle Informationen anzeigen",
|
||||
"desc": "Erfasst alle möglichen Informationen in einer PDF"
|
||||
"desc": "Erfasst alle möglichen Informationen in einer PDF",
|
||||
"tags": "info,metadaten,details"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"title": "PDF zu einer Seite zusammenfassen",
|
||||
"desc": "Fügt alle PDF-Seiten zu einer einzigen großen Seite zusammen"
|
||||
"desc": "Fügt alle PDF-Seiten zu einer einzigen großen Seite zusammen",
|
||||
"tags": "kombinieren,zusammenführen,einzeln"
|
||||
},
|
||||
"showJS": {
|
||||
"title": "Javascript anzeigen",
|
||||
"desc": "Alle Javascript Funktionen in einer PDF anzeigen"
|
||||
"desc": "Alle Javascript Funktionen in einer PDF anzeigen",
|
||||
"tags": "javascript,code,skript"
|
||||
},
|
||||
"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)"
|
||||
"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"
|
||||
},
|
||||
"overlayPdfs": {
|
||||
"title": "PDFs überlagern",
|
||||
"desc": "PDFs über eine andere PDF überlagern"
|
||||
"desc": "PDFs über eine andere PDF überlagern",
|
||||
"tags": "überlagern,kombinieren,stapeln"
|
||||
},
|
||||
"splitBySections": {
|
||||
"title": "PDF nach Abschnitten aufteilen",
|
||||
"desc": "Jede Seite einer PDF in kleinere horizontale und vertikale Abschnitte unterteilen"
|
||||
"desc": "Jede Seite einer PDF in kleinere horizontale und vertikale Abschnitte unterteilen",
|
||||
"tags": "teilen,abschnitte,aufteilen"
|
||||
},
|
||||
"addStamp": {
|
||||
"title": "Stempel zu PDF hinzufügen",
|
||||
"desc": "Text- oder Bildstempel an festgelegten Positionen hinzufügen"
|
||||
"desc": "Text- oder Bildstempel an festgelegten Positionen hinzufügen",
|
||||
"tags": "stempel,markierung,siegel"
|
||||
},
|
||||
"removeImage": {
|
||||
"title": "Bild entfernen",
|
||||
"desc": "Bild aus PDF entfernen, um die Dateigröße zu verringern"
|
||||
"desc": "Bild aus PDF entfernen, um die Dateigröße zu verringern",
|
||||
"tags": "entfernen,löschen,bereinigen"
|
||||
},
|
||||
"splitByChapters": {
|
||||
"title": "PDF-Datei nach Kapiteln aufteilen",
|
||||
"desc": "Aufteilung einer PDF-Datei in mehrere Dateien auf Basis der Kapitelstruktur."
|
||||
"desc": "Aufteilung einer PDF-Datei in mehrere Dateien auf Basis der Kapitelstruktur.",
|
||||
"tags": "teilen,kapitel,struktur"
|
||||
},
|
||||
"validateSignature": {
|
||||
"title": "PDF-Signatur überprüfen",
|
||||
"desc": "Digitale Signaturen und Zertifikate in PDF-Dokumenten überprüfen"
|
||||
"desc": "Digitale Signaturen und Zertifikate in PDF-Dokumenten überprüfen",
|
||||
"tags": "validieren,überprüfen,zertifikat"
|
||||
},
|
||||
"swagger": {
|
||||
"title": "API-Dokumentation",
|
||||
"desc": "API-Dokumentation anzeigen und Endpunkte testen"
|
||||
"desc": "API-Dokumentation anzeigen und Endpunkte testen",
|
||||
"tags": "API,dokumentation,test"
|
||||
},
|
||||
"fakeScan": {
|
||||
"title": "Scan simulieren",
|
||||
@@ -538,42 +585,52 @@
|
||||
},
|
||||
"editTableOfContents": {
|
||||
"title": "Inhaltsverzeichnis bearbeiten",
|
||||
"desc": "Hinzufügen oder Bearbeiten von Lesezeichen und Inhaltsverzeichnissen in PDF-Dokumenten"
|
||||
"desc": "Hinzufügen oder Bearbeiten von Lesezeichen und Inhaltsverzeichnissen in PDF-Dokumenten",
|
||||
"tags": "lesezeichen,inhalt,bearbeiten"
|
||||
},
|
||||
"manageCertificates": {
|
||||
"title": "Zertifikate verwalten",
|
||||
"desc": "Digitale Zertifikatsdateien für die PDF-Signierung importieren, exportieren oder löschen."
|
||||
"desc": "Digitale Zertifikatsdateien für die PDF-Signierung importieren, exportieren oder löschen.",
|
||||
"tags": "zertifikate,importieren,exportieren"
|
||||
},
|
||||
"read": {
|
||||
"title": "Lesen",
|
||||
"desc": "PDFs anzeigen und kommentieren. Text hervorheben, zeichnen oder Kommentare für Überprüfung und Zusammenarbeit einfügen."
|
||||
"desc": "PDFs anzeigen und kommentieren. Text hervorheben, zeichnen oder Kommentare für Überprüfung und Zusammenarbeit einfügen.",
|
||||
"tags": "anzeigen,öffnen,anzeigen"
|
||||
},
|
||||
"reorganizePages": {
|
||||
"title": "Seiten neu anordnen",
|
||||
"desc": "PDF-Seiten mit visueller Drag-and-Drop-Steuerung neu anordnen, duplizieren oder löschen."
|
||||
"desc": "PDF-Seiten mit visueller Drag-and-Drop-Steuerung neu anordnen, duplizieren oder löschen.",
|
||||
"tags": "umordnen,neu anordnen,organisieren"
|
||||
},
|
||||
"extractPages": {
|
||||
"title": "Seiten extrahieren",
|
||||
"desc": "Spezifische Seiten aus einem PDF-Dokument extrahieren"
|
||||
"desc": "Spezifische Seiten aus einem PDF-Dokument extrahieren",
|
||||
"tags": "extrahieren,auswählen,kopieren"
|
||||
},
|
||||
"removePages": {
|
||||
"title": "Entfernen",
|
||||
"desc": "Ungewollte Seiten aus dem PDF entfernen"
|
||||
"desc": "Ungewollte Seiten aus dem PDF entfernen",
|
||||
"tags": "löschen,extrahieren,ausschließen"
|
||||
},
|
||||
"autoSizeSplitPDF": {
|
||||
"title": "Teilen nach Größe/Anzahl",
|
||||
"desc": "Teilen Sie ein einzelnes PDF basierend auf Größe, Seitenanzahl oder Dokumentanzahl in mehrere Dokumente auf"
|
||||
"desc": "Teilen Sie ein einzelnes PDF basierend auf Größe, Seitenanzahl oder Dokumentanzahl in mehrere Dokumente auf",
|
||||
"tags": "auto,teilen,größe"
|
||||
},
|
||||
"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"
|
||||
"desc": "Link zur API-Dokumentation",
|
||||
"tags": "API,entwicklung,dokumentation",
|
||||
"title": "API"
|
||||
},
|
||||
"devFolderScanning": {
|
||||
"title": "Automatische Ordnerüberwachung",
|
||||
"desc": "Link zum Leitfaden für automatisches Ordner-Scannen"
|
||||
"desc": "Link zum Leitfaden für automatisches Ordner-Scannen",
|
||||
"tags": "automatisierung,ordner,scannen"
|
||||
},
|
||||
"devSsoGuide": {
|
||||
"title": "SSO-Anleitung",
|
||||
@@ -593,7 +650,17 @@
|
||||
},
|
||||
"automate": {
|
||||
"title": "Automatisieren",
|
||||
"desc": "Mehrstufige Arbeitsabläufe durch Verkettung von PDF-Aktionen erstellen. Ideal für wiederkehrende Aufgaben."
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
@@ -633,8 +700,18 @@
|
||||
"merge": {
|
||||
"tags": "zusammenführen,seitenvorgänge,back end,serverseitig",
|
||||
"title": "Zusammenführen",
|
||||
"removeDigitalSignature": "Digitale Signatur in der zusammengeführten Datei entfernen?",
|
||||
"generateTableOfContents": "Inhaltsverzeichnis in der zusammengeführten Datei erstellen?",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
@@ -860,7 +937,13 @@
|
||||
"images": "Bilder",
|
||||
"officeDocs": "Office-Dokumente (Word, Excel, PowerPoint)",
|
||||
"imagesExt": "Bilder (JPG, PNG, usw.)",
|
||||
"grayscale": "Graustufen"
|
||||
"grayscale": "Graustufen",
|
||||
"dpi": "DPI",
|
||||
"markdown": "Markdown",
|
||||
"odtExt": "OpenDocument Text (.odt)",
|
||||
"pptExt": "PowerPoint (.pptx)",
|
||||
"rtfExt": "Rich Text Format (.rtf)",
|
||||
"textRtf": "Text/RTF"
|
||||
},
|
||||
"imageToPdf": {
|
||||
"tags": "konvertierung,img,jpg,bild,foto"
|
||||
@@ -900,7 +983,20 @@
|
||||
"10": "Ungerade-Gerade-Zusammenführung",
|
||||
"11": "Alle Seiten duplizieren"
|
||||
},
|
||||
"placeholder": "(z.B. 1,3,2 oder 4-8,2,10-12 oder 2n-1)"
|
||||
"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)."
|
||||
}
|
||||
},
|
||||
"addImage": {
|
||||
"tags": "img,jpg,bild,foto",
|
||||
@@ -929,7 +1025,8 @@
|
||||
"failed": "Ein Fehler ist beim Hinzufügen des Wasserzeichens zur PDF aufgetreten."
|
||||
},
|
||||
"watermarkType": {
|
||||
"image": "Bild"
|
||||
"image": "Bild",
|
||||
"text": "Text"
|
||||
},
|
||||
"settings": {
|
||||
"type": "Wasserzeichen-Typ",
|
||||
@@ -1333,7 +1430,9 @@
|
||||
},
|
||||
"trapped": {
|
||||
"label": "Trapped-Status",
|
||||
"unknown": "Unbekannt"
|
||||
"unknown": "Unbekannt",
|
||||
"false": "Falsch",
|
||||
"true": "Wahr"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Erweiterte Optionen"
|
||||
@@ -1522,7 +1621,13 @@
|
||||
"header": "Bilder extrahieren",
|
||||
"selectText": "Wählen Sie das Bildformat aus, in das extrahierte Bilder konvertiert werden sollen",
|
||||
"allowDuplicates": "Doppelte Bilder speichern",
|
||||
"submit": "Extrahieren"
|
||||
"submit": "Extrahieren",
|
||||
"error": {
|
||||
"failed": "Beim Extrahieren der Bilder aus der PDF ist ein Fehler aufgetreten."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen"
|
||||
}
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "archiv,langfristig,standard,konvertierung,speicherung,aufbewahrung",
|
||||
@@ -1599,8 +1704,14 @@
|
||||
"title": "Signieren",
|
||||
"header": "PDFs signieren",
|
||||
"upload": "Bild hochladen",
|
||||
"draw": "Signatur zeichnen",
|
||||
"text": "Texteingabe",
|
||||
"draw": {
|
||||
"clear": "Löschen",
|
||||
"title": "Zeichnen Sie Ihre Unterschrift"
|
||||
},
|
||||
"text": {
|
||||
"name": "Name des Unterzeichners",
|
||||
"placeholder": "Geben Sie Ihren vollständigen Namen ein"
|
||||
},
|
||||
"clear": "Leeren",
|
||||
"add": "Signieren",
|
||||
"saved": "Gespeicherte Signaturen",
|
||||
@@ -1616,7 +1727,35 @@
|
||||
"previous": "Vorherige Seite",
|
||||
"maintainRatio": "Seitenverhältnis beibehalten ein-/ausschalten",
|
||||
"undo": "Rückgängig",
|
||||
"redo": "Wiederherstellen"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"flatten": {
|
||||
"tags": "statisch,deaktivieren,nicht interaktiv,optimieren",
|
||||
@@ -1635,7 +1774,8 @@
|
||||
"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."
|
||||
"note": "Das Abflachen entfernt interaktive Elemente aus der PDF und macht sie nicht mehr bearbeitbar.",
|
||||
"flattenOnlyForms": "Nur Formulare vereinfachen"
|
||||
},
|
||||
"results": {
|
||||
"title": "Reduzierungs-Ergebnisse"
|
||||
@@ -1693,7 +1833,8 @@
|
||||
"label": "Pixel-Weißheitsschwellwert"
|
||||
},
|
||||
"whitePercent": {
|
||||
"label": "Weiß-Prozentsatz-Schwellwert"
|
||||
"label": "Weiß-Prozentsatz-Schwellwert",
|
||||
"unit": "%"
|
||||
},
|
||||
"includeBlankPages": {
|
||||
"label": "Erkannte leere Seiten einschließen"
|
||||
@@ -1730,7 +1871,17 @@
|
||||
"tags": "kommentare,hervorheben,notizen,markieren,entfernen",
|
||||
"title": "Kommentare entfernen",
|
||||
"header": "Kommentare entfernen",
|
||||
"submit": "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"
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
"tags": "differenzieren,kontrastieren,verändern,analysieren",
|
||||
@@ -2015,7 +2166,9 @@
|
||||
},
|
||||
"pageSize": {
|
||||
"label": "Ziel-Seitengröße",
|
||||
"keep": "Ursprüngliche Größe beibehalten"
|
||||
"keep": "Ursprüngliche Größe beibehalten",
|
||||
"legal": "Legal",
|
||||
"letter": "Letter"
|
||||
},
|
||||
"submit": "Seitenskalierung anpassen",
|
||||
"error": {
|
||||
@@ -2306,7 +2459,8 @@
|
||||
"showLayers": "Ebenen anzeigen (Doppelklick, um alle Ebenen auf den Standardzustand zurückzusetzen)",
|
||||
"colourPicker": "Farbwähler",
|
||||
"findCurrentOutlineItem": "Aktuelles Gliederungselement finden",
|
||||
"applyChanges": "Änderungen anwenden"
|
||||
"applyChanges": "Änderungen anwenden",
|
||||
"zoom": "Zoom"
|
||||
}
|
||||
},
|
||||
"tableExtraxt": {
|
||||
@@ -2496,7 +2650,8 @@
|
||||
"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}}"
|
||||
"unexpectedError": "Unerwarteter Fehler: {{message}}",
|
||||
"debug": "Debug"
|
||||
},
|
||||
"signup": {
|
||||
"title": "Konto erstellen",
|
||||
@@ -2518,7 +2673,8 @@
|
||||
"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}}"
|
||||
"unexpectedError": "Unerwarteter Fehler: {{message}}",
|
||||
"name": "Name"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"title": "PDF zu einer Seite zusammenfassen",
|
||||
@@ -2961,7 +3117,12 @@
|
||||
"selectedCount": "{{count}} ausgewählt",
|
||||
"download": "Herunterladen",
|
||||
"delete": "Löschen",
|
||||
"unsupported": "Nicht unterstützt"
|
||||
"unsupported": "Nicht unterstützt",
|
||||
"fileFormat": "Format",
|
||||
"fileName": "Name",
|
||||
"fileVersion": "Version",
|
||||
"googleDrive": "Google Drive",
|
||||
"googleDriveShort": "Drive"
|
||||
},
|
||||
"storage": {
|
||||
"temporaryNotice": "Dateien werden temporär in Ihrem Browser gespeichert und können automatisch gelöscht werden",
|
||||
@@ -2992,12 +3153,24 @@
|
||||
"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": "JavaScript entfernen",
|
||||
"removeEmbeddedFiles": "Eingebettete Dateien entfernen",
|
||||
"removeXMPMetadata": "XMP-Metadaten entfernen",
|
||||
"removeMetadata": "Dokument-Metadaten entfernen",
|
||||
"removeLinks": "Links entfernen",
|
||||
"removeFonts": "Schriftarten entfernen"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"addPassword": {
|
||||
@@ -3025,7 +3198,8 @@
|
||||
"keyLength": {
|
||||
"label": "Verschlüsselungsschlüssellänge",
|
||||
"40bit": "40-bit (Niedrig)",
|
||||
"256bit": "256-bit (Hoch)"
|
||||
"256bit": "256-bit (Hoch)",
|
||||
"128bit": "128-bit (Standard)"
|
||||
}
|
||||
},
|
||||
"results": {
|
||||
@@ -3264,5 +3438,58 @@
|
||||
},
|
||||
"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,4 +1,40 @@
|
||||
{
|
||||
"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",
|
||||
"comingSoon": "Coming soon:",
|
||||
"favorite": "Add to favourites",
|
||||
"favorites": "Favourites",
|
||||
"heading": "All tools (fullscreen view)",
|
||||
"noResults": "Try adjusting your search or toggle descriptions to find what you need.",
|
||||
"recommended": "Recommended",
|
||||
"unfavorite": "Remove from favourites"
|
||||
},
|
||||
"placeholder": "Choose a tool to get started",
|
||||
"toggle": {
|
||||
"fullscreen": "Switch to fullscreen mode",
|
||||
"sidebar": "Switch to sidebar mode"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -10,16 +46,34 @@
|
||||
"selectText": {
|
||||
"1": "Select PDF file:",
|
||||
"2": "Margin Size",
|
||||
"3": "Position",
|
||||
"3": "Position Selection",
|
||||
"4": "Starting Number",
|
||||
"5": "Pages to Number",
|
||||
"6": "Custom Text"
|
||||
"6": "Custom Text Format"
|
||||
},
|
||||
"customTextDesc": "Custom Text",
|
||||
"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"
|
||||
"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."
|
||||
},
|
||||
"pageSelectionPrompt": "Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1)",
|
||||
"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",
|
||||
@@ -30,7 +84,6 @@
|
||||
"uploadLimitExceededPlural": "are too large. Maximum allowed size is",
|
||||
"processTimeWarning": "Warning: This process can take up to a minute depending on file-size",
|
||||
"pageOrderPrompt": "Custom Page Order (Enter a comma-separated list of page numbers or Functions like 2n+1) :",
|
||||
"pageSelectionPrompt": "Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1) :",
|
||||
"goToPage": "Go",
|
||||
"true": "True",
|
||||
"false": "False",
|
||||
@@ -41,11 +94,18 @@
|
||||
"save": "Save",
|
||||
"saveToBrowser": "Save to Browser",
|
||||
"download": "Download",
|
||||
"pin": "Pin File (keep active after tool run)",
|
||||
"unpin": "Unpin File (replace after tool run)",
|
||||
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
|
||||
"undo": "Undo",
|
||||
"moreOptions": "More Options",
|
||||
"editYourNewFiles": "Edit your new file(s)",
|
||||
"close": "Close",
|
||||
"openInViewer": "Open in Viewer",
|
||||
"confirmClose": "Confirm Close",
|
||||
"confirmCloseMessage": "Are you sure you want to close this file?",
|
||||
"confirmCloseCancel": "Cancel",
|
||||
"confirmCloseConfirm": "Close File",
|
||||
"fileSelected": "Selected: {{filename}}",
|
||||
"chooseFile": "Choose File",
|
||||
"filesSelected": "{{count}} files selected",
|
||||
@@ -55,7 +115,9 @@
|
||||
"uploadFiles": "Upload Files",
|
||||
"addFiles": "Add files",
|
||||
"selectFromWorkbench": "Select files from the workbench or ",
|
||||
"selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or "
|
||||
"selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or ",
|
||||
"created": "Created",
|
||||
"size": "File Size"
|
||||
},
|
||||
"noFavourites": "No favourites added",
|
||||
"downloadComplete": "Download Complete",
|
||||
@@ -231,6 +293,40 @@
|
||||
"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.",
|
||||
"defaultToolPickerMode": "Default tool picker mode",
|
||||
"defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default",
|
||||
"mode": {
|
||||
"fullscreen": "Fullscreen",
|
||||
"sidebar": "Sidebar"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"searchPlaceholder": "Search tools..."
|
||||
}
|
||||
},
|
||||
"changeCreds": {
|
||||
@@ -359,6 +455,9 @@
|
||||
"alphabetical": "Alphabetical",
|
||||
"globalPopularity": "Global Popularity",
|
||||
"sortBy": "Sort by:",
|
||||
"mobile": {
|
||||
"brandAlt": "Stirling PDF logo"
|
||||
},
|
||||
"multiTool": {
|
||||
"tags": "multiple,tools",
|
||||
"title": "PDF Multi Tool",
|
||||
@@ -507,7 +606,7 @@
|
||||
"adjustContrast": {
|
||||
"tags": "contrast,brightness,saturation",
|
||||
"title": "Adjust Colours/Contrast",
|
||||
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
|
||||
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,cut,resize",
|
||||
@@ -544,11 +643,6 @@
|
||||
"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",
|
||||
@@ -579,9 +673,9 @@
|
||||
"title": "API Documentation",
|
||||
"desc": "View API documentation and test endpoints"
|
||||
},
|
||||
"fakeScan": {
|
||||
"scannerEffect": {
|
||||
"tags": "scan,simulate,create",
|
||||
"title": "Fake Scan",
|
||||
"title": "Scanner Effect",
|
||||
"desc": "Create a PDF that looks like it was scanned"
|
||||
},
|
||||
"editTableOfContents": {
|
||||
@@ -594,9 +688,9 @@
|
||||
"title": "Manage Certificates",
|
||||
"desc": "Import, export, or delete digital certificate files used for signing PDFs."
|
||||
},
|
||||
"read": {
|
||||
"tags": "view,open,display",
|
||||
"title": "Read",
|
||||
"read": {
|
||||
"tags": "view,open,display",
|
||||
"title": "Read",
|
||||
"desc": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."
|
||||
},
|
||||
"reorganizePages": {
|
||||
@@ -653,6 +747,20 @@
|
||||
"tags": "workflow,sequence,automation",
|
||||
"title": "Automate",
|
||||
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
||||
},
|
||||
"mobile": {
|
||||
"brandAlt": "Stirling PDF logo",
|
||||
"openFiles": "Open files",
|
||||
"swipeHint": "Swipe left or right to switch views",
|
||||
"tools": "Tools",
|
||||
"toolsSlide": "Tool selection panel",
|
||||
"viewSwitcher": "Switch workspace view",
|
||||
"workbenchSlide": "Workspace panel",
|
||||
"workspace": "Workspace"
|
||||
},
|
||||
"overlay-pdfs": {
|
||||
"desc": "Overlay one PDF on top of another",
|
||||
"title": "Overlay PDFs"
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
@@ -838,12 +946,50 @@
|
||||
"bullet1": "Bookmark Level: Which level to split on (1=top level)",
|
||||
"bullet2": "Include Metadata: Preserve document properties",
|
||||
"bullet3": "Allow Duplicates: Handle repeated bookmark names"
|
||||
},
|
||||
"byDocCount": {
|
||||
"bullet1": "Enter the number of output files you want",
|
||||
"bullet2": "Pages are distributed as evenly as possible",
|
||||
"bullet3": "Useful when you need a specific number of files",
|
||||
"text": "Create a specific number of output files by evenly distributing pages across them.",
|
||||
"title": "Split by Document Count"
|
||||
},
|
||||
"byPageCount": {
|
||||
"bullet1": "Enter the number of pages per output file",
|
||||
"bullet2": "Last file may have fewer pages if not evenly divisible",
|
||||
"bullet3": "Useful for batch processing workflows",
|
||||
"text": "Create multiple PDFs with a specific number of pages each. Perfect for creating uniform document chunks.",
|
||||
"title": "Split by Page Count"
|
||||
},
|
||||
"byPageDivider": {
|
||||
"bullet1": "Print divider sheets from the download link",
|
||||
"bullet2": "Insert divider sheets between your documents",
|
||||
"bullet3": "Scan all documents together as one PDF",
|
||||
"bullet4": "Upload - divider pages are automatically detected and removed",
|
||||
"bullet5": "Enable Duplex Mode if scanning both sides of divider sheets",
|
||||
"text": "Automatically split scanned documents using physical divider sheets with QR codes. Perfect for processing multiple documents scanned together.",
|
||||
"title": "Split by Page Divider"
|
||||
}
|
||||
}
|
||||
},
|
||||
"methodSelection": {
|
||||
"tooltip": {
|
||||
"bullet1": "Click on a method card to select it",
|
||||
"bullet2": "Hover over each card to see a quick description",
|
||||
"bullet3": "The settings step will appear after you select a method",
|
||||
"bullet4": "You can change methods at any time before processing",
|
||||
"header": {
|
||||
"text": "Choose how you want to split your PDF document. Each method is optimized for different use cases and document types.",
|
||||
"title": "Split Method Selection"
|
||||
},
|
||||
"title": "Choose Your Split Method"
|
||||
}
|
||||
},
|
||||
"selectMethod": "Select a split method"
|
||||
},
|
||||
"rotate": {
|
||||
"title": "Rotate PDF",
|
||||
"submit": "Apply Rotation",
|
||||
"selectRotation": "Select Rotation Angle (Clockwise)",
|
||||
"error": {
|
||||
"failed": "An error occurred while rotating the PDF."
|
||||
},
|
||||
@@ -931,7 +1077,8 @@
|
||||
"imagesExt": "Images (JPG, PNG, etc.)",
|
||||
"markdown": "Markdown",
|
||||
"textRtf": "Text/RTF",
|
||||
"grayscale": "Greyscale"
|
||||
"grayscale": "Greyscale",
|
||||
"errorConversion": "An error occurred while converting the file."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"tags": "conversion,img,jpg,picture,photo"
|
||||
@@ -958,7 +1105,7 @@
|
||||
"header": "PDF Page Organiser",
|
||||
"submit": "Rearrange Pages",
|
||||
"mode": {
|
||||
"_value": "Organization mode",
|
||||
"_value": "Organisation mode",
|
||||
"1": "Custom Page Order",
|
||||
"2": "Reverse Order",
|
||||
"3": "Duplex Sort",
|
||||
@@ -969,7 +1116,20 @@
|
||||
"8": "Remove Last",
|
||||
"9": "Remove First and Last",
|
||||
"10": "Odd-Even Merge",
|
||||
"11": "Duplicate all pages"
|
||||
"11": "Duplicate all pages",
|
||||
"desc": {
|
||||
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
|
||||
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
|
||||
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
|
||||
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
|
||||
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
|
||||
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
|
||||
"REMOVE_FIRST": "Remove the first page from the document.",
|
||||
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document.",
|
||||
"REMOVE_LAST": "Remove the last page from the document.",
|
||||
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
|
||||
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimized for binding on the side)."
|
||||
}
|
||||
},
|
||||
"desc": {
|
||||
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
|
||||
@@ -1035,7 +1195,9 @@
|
||||
"opacity": "Opacity (%)",
|
||||
"spacing": {
|
||||
"horizontal": "Horizontal Spacing",
|
||||
"vertical": "Vertical Spacing"
|
||||
"vertical": "Vertical Spacing",
|
||||
"height": "Height Spacing",
|
||||
"width": "Width Spacing"
|
||||
},
|
||||
"convertToImage": "Flatten PDF pages to images"
|
||||
},
|
||||
@@ -1178,6 +1340,10 @@
|
||||
"bullet4": "Best for sensitive or copyrighted content"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"1": "Text",
|
||||
"2": "Image"
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
@@ -1291,6 +1457,38 @@
|
||||
},
|
||||
"examples": {
|
||||
"title": "Examples"
|
||||
},
|
||||
"complex": {
|
||||
"bullet1": "<strong>1,3-5,8,2n</strong> → pages 1, 3–5, 8, plus evens",
|
||||
"bullet2": "<strong>10-,2n-1</strong> → from page 10 to end + odd pages",
|
||||
"description": "Mix different types.",
|
||||
"title": "Complex Combinations"
|
||||
},
|
||||
"description": "Choose which pages to use for the operation. Supports single pages, ranges, formulas, and the all keyword.",
|
||||
"individual": {
|
||||
"bullet1": "<strong>1,3,5</strong> → selects pages 1, 3, 5",
|
||||
"bullet2": "<strong>2,7,12</strong> → selects pages 2, 7, 12",
|
||||
"description": "Enter numbers separated by commas.",
|
||||
"title": "Individual Pages"
|
||||
},
|
||||
"mathematical": {
|
||||
"bullet1": "<strong>2n</strong> → all even pages (2, 4, 6…)",
|
||||
"bullet2": "<strong>2n-1</strong> → all odd pages (1, 3, 5…)",
|
||||
"bullet3": "<strong>3n</strong> → every 3rd page (3, 6, 9…)",
|
||||
"bullet4": "<strong>4n-1</strong> → pages 3, 7, 11, 15…",
|
||||
"description": "Use n in formulas for patterns.",
|
||||
"title": "Mathematical Functions"
|
||||
},
|
||||
"ranges": {
|
||||
"bullet1": "<strong>3-6</strong> → selects pages 3–6",
|
||||
"bullet2": "<strong>10-15</strong> → selects pages 10–15",
|
||||
"bullet3": "<strong>5-</strong> → selects pages 5 to end",
|
||||
"description": "Use - for consecutive pages.",
|
||||
"title": "Page Ranges"
|
||||
},
|
||||
"special": {
|
||||
"bullet1": "<strong>all</strong> → selects all pages",
|
||||
"title": "Special Keywords"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1600,6 +1798,9 @@
|
||||
"text": "Post-processes the final PDF by removing OCR artefacts and optimising the text layer for better readability and smaller file size."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"failed": "OCR operation failed"
|
||||
}
|
||||
},
|
||||
"extractImages": {
|
||||
@@ -1720,12 +1921,19 @@
|
||||
"title": "Sign",
|
||||
"header": "Sign PDFs",
|
||||
"upload": "Upload Image",
|
||||
"draw": "Draw Signature",
|
||||
"text": "Text Input",
|
||||
"draw": {
|
||||
"title": "Draw your signature",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"text": {
|
||||
"name": "Signer Name",
|
||||
"placeholder": "Enter your full name"
|
||||
},
|
||||
"clear": "Clear",
|
||||
"add": "Add",
|
||||
"saved": "Saved Signatures",
|
||||
"save": "Save Signature",
|
||||
"applySignatures": "Apply Signatures",
|
||||
"personalSigs": "Personal Signatures",
|
||||
"sharedSigs": "Shared Signatures",
|
||||
"noSavedSigs": "No saved signatures found",
|
||||
@@ -1737,7 +1945,42 @@
|
||||
"previous": "Previous page",
|
||||
"maintainRatio": "Toggle maintain aspect ratio",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo"
|
||||
"redo": "Redo",
|
||||
"submit": "Sign Document",
|
||||
"steps": {
|
||||
"configure": "Configure Signature"
|
||||
},
|
||||
"type": {
|
||||
"title": "Signature Type",
|
||||
"draw": "Draw",
|
||||
"canvas": "Canvas",
|
||||
"image": "Image",
|
||||
"text": "Text"
|
||||
},
|
||||
"image": {
|
||||
"label": "Upload signature image",
|
||||
"placeholder": "Select image file",
|
||||
"hint": "Upload a PNG or JPG image of your signature"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"flatten": {
|
||||
"title": "Flatten",
|
||||
@@ -1845,13 +2088,29 @@
|
||||
"bullet3": "Can be disabled to reduce output file size"
|
||||
}
|
||||
},
|
||||
"submit": "Remove blank pages"
|
||||
"submit": "Remove blank pages",
|
||||
"error": {
|
||||
"failed": "Failed to remove blank pages"
|
||||
},
|
||||
"results": {
|
||||
"title": "Removed Blank Pages"
|
||||
}
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"tags": "comments,highlight,notes,markup,remove",
|
||||
"title": "Remove Annotations",
|
||||
"header": "Remove Annotations",
|
||||
"submit": "Remove"
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
"tags": "differentiate,contrast,changes,analysis",
|
||||
@@ -1937,7 +2196,12 @@
|
||||
"bullet3": "Choose which page to place the signature",
|
||||
"bullet4": "Optional logo can be included"
|
||||
}
|
||||
}
|
||||
},
|
||||
"invisible": "Invisible",
|
||||
"options": {
|
||||
"title": "Signature Details"
|
||||
},
|
||||
"visible": "Visible"
|
||||
},
|
||||
"sign": {
|
||||
"submit": "Sign PDF",
|
||||
@@ -1998,7 +2262,22 @@
|
||||
"text": "Convert your file to a Java keystore (.jks) with keytool, then pick JKS."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"chooseCertificate": "Choose Certificate File",
|
||||
"chooseJksFile": "Choose JKS File",
|
||||
"chooseP12File": "Choose PKCS12 File",
|
||||
"choosePfxFile": "Choose PFX File",
|
||||
"choosePrivateKey": "Choose Private Key File",
|
||||
"location": "Location",
|
||||
"logoTitle": "Logo",
|
||||
"name": "Name",
|
||||
"noLogo": "No Logo",
|
||||
"pageNumber": "Page Number",
|
||||
"password": "Certificate Password",
|
||||
"passwordOptional": "Leave empty if no password",
|
||||
"reason": "Reason",
|
||||
"serverCertMessage": "Using server certificate - no files or password required",
|
||||
"showLogo": "Show Logo"
|
||||
},
|
||||
"removeCertSign": {
|
||||
"tags": "authenticate,PEM,P12,official,decrypt",
|
||||
@@ -2024,7 +2303,17 @@
|
||||
"header": "Multi Page Layout",
|
||||
"pagesPerSheet": "Pages per sheet:",
|
||||
"addBorder": "Add Borders",
|
||||
"submit": "Submit"
|
||||
"submit": "Submit",
|
||||
"desc": {
|
||||
"2": "Place 2 pages side-by-side on a single sheet.",
|
||||
"3": "Place 3 pages on a single sheet in a single row.",
|
||||
"4": "Place 4 pages on a single sheet (2 × 2 grid).",
|
||||
"9": "Place 9 pages on a single sheet (3 × 3 grid).",
|
||||
"16": "Place 16 pages on a single sheet (4 × 4 grid)."
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while creating the multi-page layout."
|
||||
}
|
||||
},
|
||||
"bookletImposition": {
|
||||
"tags": "booklet,imposition,printing,binding,folding,signature",
|
||||
@@ -2210,10 +2499,22 @@
|
||||
"reset": "Reset to full PDF",
|
||||
"coordinates": {
|
||||
"title": "Position and Size",
|
||||
"x": "X Position",
|
||||
"y": "Y Position",
|
||||
"width": "Width",
|
||||
"height": "Height"
|
||||
"x": {
|
||||
"label": "X Position",
|
||||
"desc": "Left edge (points)"
|
||||
},
|
||||
"y": {
|
||||
"label": "Y Position",
|
||||
"desc": "Bottom edge (points)"
|
||||
},
|
||||
"width": {
|
||||
"label": "Width",
|
||||
"desc": "Crop width (points)"
|
||||
},
|
||||
"height": {
|
||||
"label": "Height",
|
||||
"desc": "Crop height (points)"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalidArea": "Crop area extends beyond PDF boundaries",
|
||||
@@ -2231,6 +2532,10 @@
|
||||
},
|
||||
"results": {
|
||||
"title": "Crop Results"
|
||||
},
|
||||
"automation": {
|
||||
"info": "Enter crop coordinates in PDF points. Origin (0,0) is at bottom-left. These values will be applied to all PDFs processed in this automation.",
|
||||
"reference": "Reference: A4 page is 595.28 × 841.89 points (210mm × 297mm). 1 inch = 72 points."
|
||||
}
|
||||
},
|
||||
"autoSplitPDF": {
|
||||
@@ -2441,11 +2746,15 @@
|
||||
"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"
|
||||
"label": "Select Overlay PDF Files",
|
||||
"placeholder": "Choose PDF(s)...",
|
||||
"addMore": "Add more PDFs..."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Select Overlay Mode",
|
||||
@@ -2455,14 +2764,53 @@
|
||||
},
|
||||
"counts": {
|
||||
"label": "Overlay Counts (for Fixed Repeat Mode)",
|
||||
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)"
|
||||
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
|
||||
"item": "Count for file",
|
||||
"noFiles": "Add overlay files to configure counts"
|
||||
},
|
||||
"position": {
|
||||
"label": "Select Overlay Position",
|
||||
"foreground": "Foreground",
|
||||
"background": "Background"
|
||||
},
|
||||
"submit": "Submit"
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while overlaying PDFs."
|
||||
}
|
||||
},
|
||||
"split-by-sections": {
|
||||
"tags": "Section Split, Divide, Customize,Customise",
|
||||
@@ -2497,7 +2845,18 @@
|
||||
"customMargin": "Custom Margin",
|
||||
"customColor": "Custom Text Colour",
|
||||
"submit": "Submit",
|
||||
"noStampSelected": "No stamp selected. Return to Step 1."
|
||||
"noStampSelected": "No stamp selected. Return to Step 1.",
|
||||
"customPosition": "Drag the stamp to the desired location in the preview window.",
|
||||
"error": {
|
||||
"failed": "An error occurred while adding stamp to the PDF."
|
||||
},
|
||||
"imageSize": "Image Size",
|
||||
"margin": "Margin",
|
||||
"positionAndFormatting": "Position & Formatting",
|
||||
"quickPosition": "Select a position on the page to place the stamp.",
|
||||
"results": {
|
||||
"title": "Stamp Results"
|
||||
}
|
||||
},
|
||||
"removeImagePdf": {
|
||||
"tags": "Remove Image,Page operations,Back end,server side"
|
||||
@@ -2515,7 +2874,8 @@
|
||||
"status": {
|
||||
"_value": "Status",
|
||||
"valid": "Valid",
|
||||
"invalid": "Invalid"
|
||||
"invalid": "Invalid",
|
||||
"complete": "Validation complete"
|
||||
},
|
||||
"signer": "Signer",
|
||||
"date": "Date",
|
||||
@@ -2542,16 +2902,71 @@
|
||||
"version": "Version",
|
||||
"keyUsage": "Key Usage",
|
||||
"selfSigned": "Self-Signed",
|
||||
"bits": "bits"
|
||||
"bits": "bits",
|
||||
"details": "Certificate Details"
|
||||
},
|
||||
"signature": {
|
||||
"info": "Signature Information",
|
||||
"_value": "Signature",
|
||||
"mathValid": "Signature is mathematically valid BUT:"
|
||||
},
|
||||
"selectCustomCert": "Custom Certificate File X.509 (Optional)"
|
||||
"selectCustomCert": "Custom Certificate File X.509 (Optional)",
|
||||
"downloadCsv": "Download CSV",
|
||||
"downloadJson": "Download JSON",
|
||||
"downloadPdf": "Download PDF Report",
|
||||
"downloadType": {
|
||||
"csv": "CSV",
|
||||
"json": "JSON",
|
||||
"pdf": "PDF"
|
||||
},
|
||||
"error": {
|
||||
"allFailed": "Unable to validate the selected files.",
|
||||
"partial": "Some files could not be validated.",
|
||||
"reportGeneration": "Could not generate the PDF report. JSON and CSV are available.",
|
||||
"unexpected": "Unexpected error during validation."
|
||||
},
|
||||
"finalizing": "Preparing downloads...",
|
||||
"issue": {
|
||||
"certExpired": "Certificate expired",
|
||||
"certRevocationUnknown": "Certificate revocation status unknown",
|
||||
"certRevoked": "Certificate revoked",
|
||||
"chainInvalid": "Certificate chain invalid",
|
||||
"signatureInvalid": "Signature cryptographic check failed",
|
||||
"trustInvalid": "Certificate not trusted"
|
||||
},
|
||||
"noResults": "Run the validation to generate a report.",
|
||||
"noSignaturesShort": "No signatures",
|
||||
"processing": "Validating signatures...",
|
||||
"report": {
|
||||
"continued": "Continued",
|
||||
"downloads": "Downloads",
|
||||
"entryLabel": "Signature Summary",
|
||||
"fields": {
|
||||
"created": "Created",
|
||||
"fileSize": "File Size",
|
||||
"signatureCount": "Total Signatures",
|
||||
"signatureDate": "Signature Date"
|
||||
},
|
||||
"filesEvaluated": "{{count}} files evaluated",
|
||||
"footer": "Validated via Stirling PDF",
|
||||
"generatedAt": "Generated",
|
||||
"noPdf": "PDF report will be available after a successful validation.",
|
||||
"page": "Page",
|
||||
"shortTitle": "Signature Summary",
|
||||
"signatureCountLabel": "{{count}} signatures",
|
||||
"signaturesFound": "{{count}} signatures detected",
|
||||
"signaturesValid": "{{count}} fully valid",
|
||||
"title": "Signature Validation Report"
|
||||
},
|
||||
"settings": {
|
||||
"certHint": "Upload a trusted X.509 certificate to validate against a custom trust source.",
|
||||
"title": "Validation Settings"
|
||||
},
|
||||
"signatureDate": "Signature Date",
|
||||
"totalSignatures": "Total Signatures"
|
||||
},
|
||||
"replaceColor": {
|
||||
"tags": "Replace Colour,Page operations,Back end,server side",
|
||||
"labels": {
|
||||
"settings": "Settings",
|
||||
"colourOperation": "Colour operation"
|
||||
@@ -2592,9 +3007,6 @@
|
||||
"failed": "An error occurred while processing the colour replacement."
|
||||
}
|
||||
},
|
||||
"replaceColor": {
|
||||
"tags": "Replace Colour,Page operations,Back end,server side"
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"header": "Sign in",
|
||||
@@ -2704,12 +3116,25 @@
|
||||
"submit": "Sanitize PDF"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Adjust Contrast",
|
||||
"header": "Adjust Contrast",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"header": "Adjust Colors/Contrast",
|
||||
"basic": "Basic Adjustments",
|
||||
"contrast": "Contrast:",
|
||||
"brightness": "Brightness:",
|
||||
"saturation": "Saturation:",
|
||||
"download": "Download"
|
||||
"download": "Download",
|
||||
"adjustColors": "Adjust Colors",
|
||||
"blue": "Blue",
|
||||
"confirm": "Confirm",
|
||||
"error": {
|
||||
"failed": "Failed to adjust colors/contrast"
|
||||
},
|
||||
"green": "Green",
|
||||
"noPreview": "Select a PDF to preview",
|
||||
"red": "Red",
|
||||
"results": {
|
||||
"title": "Adjusted PDF"
|
||||
}
|
||||
},
|
||||
"compress": {
|
||||
"title": "Compress",
|
||||
@@ -2856,7 +3281,13 @@
|
||||
"title": "Remove image",
|
||||
"header": "Remove image",
|
||||
"removeImage": "Remove image",
|
||||
"submit": "Remove image"
|
||||
"submit": "Remove image",
|
||||
"error": {
|
||||
"failed": "Failed to remove images from the PDF."
|
||||
},
|
||||
"results": {
|
||||
"title": "Remove Images Results"
|
||||
}
|
||||
},
|
||||
"splitByChapters": {
|
||||
"title": "Split PDF by Chapters",
|
||||
@@ -2992,11 +3423,18 @@
|
||||
"panMode": "Pan Mode",
|
||||
"rotateLeft": "Rotate Left",
|
||||
"rotateRight": "Rotate Right",
|
||||
"toggleSidebar": "Toggle Sidebar"
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"exportSelected": "Export Selected Pages",
|
||||
"toggleAnnotations": "Toggle Annotations Visibility",
|
||||
"annotationMode": "Toggle Annotation Mode",
|
||||
"draw": "Draw",
|
||||
"save": "Save"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search PDF",
|
||||
"placeholder": "Enter search term..."
|
||||
"placeholder": "Enter search term...",
|
||||
"noResults": "No results found",
|
||||
"searching": "Searching..."
|
||||
},
|
||||
"guestBanner": {
|
||||
"title": "You're using Stirling PDF as a guest!",
|
||||
@@ -3034,6 +3472,8 @@
|
||||
"automate": "Automate",
|
||||
"files": "Files",
|
||||
"activity": "Activity",
|
||||
"help": "Help",
|
||||
"account": "Account",
|
||||
"config": "Config",
|
||||
"allTools": "All Tools"
|
||||
},
|
||||
@@ -3061,6 +3501,9 @@
|
||||
"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",
|
||||
@@ -3089,6 +3532,7 @@
|
||||
"lastModified": "Last Modified",
|
||||
"toolChain": "Tools Applied",
|
||||
"restore": "Restore",
|
||||
"unzip": "Unzip",
|
||||
"searchFiles": "Search files...",
|
||||
"recent": "Recent",
|
||||
"localFiles": "Local Files",
|
||||
@@ -3096,7 +3540,6 @@
|
||||
"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",
|
||||
@@ -3114,7 +3557,17 @@
|
||||
"selectedCount": "{{count}} selected",
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"unsupported": "Unsupported"
|
||||
"unsupported": "Unsupported",
|
||||
"addToUpload": "Add to Upload",
|
||||
"deleteAll": "Delete All",
|
||||
"loadingFiles": "Loading files...",
|
||||
"noFiles": "No files available",
|
||||
"noFilesFound": "No files found matching your search",
|
||||
"openInPageEditor": "Open in Page Editor",
|
||||
"showAll": "Show All",
|
||||
"sortByDate": "Sort by Date",
|
||||
"sortByName": "Sort by Name",
|
||||
"sortBySize": "Sort by Size"
|
||||
},
|
||||
"storage": {
|
||||
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
|
||||
@@ -3377,7 +3830,8 @@
|
||||
"remaining": "remaining",
|
||||
"used": "used",
|
||||
"available": "available",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"config": {
|
||||
"account": {
|
||||
@@ -3435,8 +3889,122 @@
|
||||
"submit": "Add Attachments",
|
||||
"results": {
|
||||
"title": "Attachment Results"
|
||||
},
|
||||
"error": {
|
||||
"failed": "Add attachments operation failed"
|
||||
}
|
||||
},
|
||||
"termsAndConditions": "Terms & Conditions",
|
||||
"logOut": "Log out"
|
||||
}
|
||||
"logOut": "Log out",
|
||||
"addAttachments": {
|
||||
"error": {
|
||||
"failed": "An error occurred while adding attachments to the PDF."
|
||||
}
|
||||
},
|
||||
"autoRename": {
|
||||
"description": "This tool will automatically rename PDF files based on their content. It analyzes the document to find the most suitable title from the text."
|
||||
},
|
||||
"customPosition": "Custom Position",
|
||||
"details": "Details",
|
||||
"downloadUnavailable": "Download unavailable for this item",
|
||||
"invalidUndoData": "Cannot undo: invalid operation data",
|
||||
"margin": {
|
||||
"large": "Large",
|
||||
"medium": "Medium",
|
||||
"small": "Small",
|
||||
"xLarge": "Extra Large"
|
||||
},
|
||||
"noFilesToUndo": "Cannot undo: no files were processed in the last operation",
|
||||
"noOperationToUndo": "No operation to undo",
|
||||
"noValidFiles": "No valid files to process",
|
||||
"operationCancelled": "Operation cancelled",
|
||||
"pageEdit": {
|
||||
"deselectAll": "Select None",
|
||||
"selectAll": "Select All"
|
||||
},
|
||||
"quickPosition": "Quick Position",
|
||||
"reorganizePages": {
|
||||
"error": {
|
||||
"failed": "Failed to reorganize pages"
|
||||
},
|
||||
"results": {
|
||||
"title": "Pages Reorganized"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"submit": "Reorganize Pages"
|
||||
},
|
||||
"replace-color": {
|
||||
"options": {
|
||||
"fill": "Fill colour",
|
||||
"gradient": "Gradient"
|
||||
},
|
||||
"previewOverlayOpacity": "Preview overlay opacity",
|
||||
"previewOverlayTransparency": "Preview overlay transparency",
|
||||
"previewOverlayVisibility": "Show preview overlay",
|
||||
"selectText": {
|
||||
"1": "Replace or invert colour options",
|
||||
"2": "Default (preset high contrast colours)",
|
||||
"3": "Custom (choose your own colours)",
|
||||
"4": "Full invert (invert all colours)",
|
||||
"5": "High contrast color 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 Color",
|
||||
"11": "Choose background Color",
|
||||
"12": "Choose start colour",
|
||||
"13": "Choose end colour"
|
||||
},
|
||||
"submit": "Replace",
|
||||
"title": "Replace-Invert-Color"
|
||||
},
|
||||
"size": "Size",
|
||||
"submit": "Submit",
|
||||
"success": "Success",
|
||||
"tools": {
|
||||
"noSearchResults": "No tools found",
|
||||
"noTools": "No tools available"
|
||||
},
|
||||
"undoDataMismatch": "Cannot undo: operation data is corrupted",
|
||||
"undoFailed": "Failed to undo operation",
|
||||
"undoQuotaError": "Cannot undo: insufficient storage space",
|
||||
"undoStorageError": "Undo completed but some files could not be saved to storage",
|
||||
"undoSuccess": "Operation undone successfully",
|
||||
"unsupported": "Unsupported",
|
||||
"onboarding": {
|
||||
"welcomeModal": {
|
||||
"title": "Welcome to Stirling PDF!",
|
||||
"description": "Would you like to take a quick 1-minute tour to learn the key features and how to get started?",
|
||||
"helpHint": "You can always access this tour later from the <strong>Help</strong> button in the bottom left.",
|
||||
"startTour": "Start Tour",
|
||||
"maybeLater": "Maybe Later",
|
||||
"dontShowAgain": "Don't Show Again"
|
||||
},
|
||||
"allTools": "This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.",
|
||||
"selectCropTool": "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
|
||||
"toolInterface": "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
|
||||
"filesButton": "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
|
||||
"fileSources": "You can upload new files or access recent files from here. For the tour, we'll just use a sample file.",
|
||||
"workbench": "This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.",
|
||||
"viewSwitcher": "Use these controls to select how you want to view your PDFs.",
|
||||
"viewer": "The <strong>Viewer</strong> lets you read and annotate your PDFs.",
|
||||
"pageEditor": "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting.",
|
||||
"activeFiles": "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process.",
|
||||
"fileCheckbox": "Clicking one of the files selects it for processing. You can select multiple files for batch operations.",
|
||||
"selectControls": "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language.",
|
||||
"cropSettings": "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to.",
|
||||
"runButton": "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs.",
|
||||
"results": "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. ",
|
||||
"fileReplacement": "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools.",
|
||||
"pinButton": "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them.",
|
||||
"wrapUp": "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again.",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"finish": "Finish",
|
||||
"startTour": "Start Tour",
|
||||
"startTourDescription": "Take a guided tour of Stirling PDF's key features"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
"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",
|
||||
@@ -562,7 +579,7 @@
|
||||
"adjustContrast": {
|
||||
"tags": "contrast,brightness,saturation",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
|
||||
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,cut,resize",
|
||||
@@ -1243,7 +1260,17 @@
|
||||
"tags": "comments,highlight,notes,markup,remove",
|
||||
"title": "Remove Annotations",
|
||||
"header": "Remove Annotations",
|
||||
"submit": "Remove"
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
"tags": "differentiate,contrast,changes,analysis",
|
||||
@@ -1511,11 +1538,15 @@
|
||||
"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"
|
||||
"label": "Select Overlay PDF Files",
|
||||
"placeholder": "Choose PDF(s)...",
|
||||
"addMore": "Add more PDFs..."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Select Overlay Mode",
|
||||
@@ -1525,14 +1556,49 @@
|
||||
},
|
||||
"counts": {
|
||||
"label": "Overlay Counts (for Fixed Repeat Mode)",
|
||||
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)"
|
||||
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
|
||||
"item": "Count for file"
|
||||
},
|
||||
"position": {
|
||||
"label": "Select Overlay Position",
|
||||
"foreground": "Foreground",
|
||||
"background": "Background"
|
||||
},
|
||||
"submit": "Submit"
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
"split-by-sections": {
|
||||
"tags": "Section Split, Divide, Customize",
|
||||
@@ -1702,8 +1768,9 @@
|
||||
"submit": "Sanitize PDF"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Adjust Contrast",
|
||||
"header": "Adjust Contrast",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"header": "Adjust Colors/Contrast",
|
||||
"basic": "Basic Adjustments",
|
||||
"contrast": "Contrast:",
|
||||
"brightness": "Brightness:",
|
||||
"saturation": "Saturation:",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -87,7 +87,10 @@
|
||||
"showStack": "Mostra traccia dello stack",
|
||||
"copyStack": "Copia traccia dello stack",
|
||||
"githubSubmit": "GitHub: apri un ticket",
|
||||
"discordSubmit": "Discord: invia post di supporto"
|
||||
"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."
|
||||
},
|
||||
"warning": {
|
||||
"tooltipTitle": "Avviso"
|
||||
@@ -358,179 +361,223 @@
|
||||
"sortBy": "Ordinamento:",
|
||||
"multiTool": {
|
||||
"title": "Multifunzione PDF",
|
||||
"desc": "Unisci, Ruota, Riordina, e Rimuovi pagine"
|
||||
"desc": "Unisci, Ruota, Riordina, e Rimuovi pagine",
|
||||
"tags": "multipli,strumenti"
|
||||
},
|
||||
"merge": {
|
||||
"title": "Unisci",
|
||||
"desc": "Unisci facilmente più PDF in uno."
|
||||
"desc": "Unisci facilmente più PDF in uno.",
|
||||
"tags": "combina,unisci,unifica"
|
||||
},
|
||||
"split": {
|
||||
"title": "Dividi",
|
||||
"desc": "Dividi un singolo PDF in più documenti."
|
||||
"desc": "Dividi un singolo PDF in più documenti.",
|
||||
"tags": "dividi,separa,spezza"
|
||||
},
|
||||
"rotate": {
|
||||
"title": "Ruota",
|
||||
"desc": "Ruota un PDF."
|
||||
"desc": "Ruota un PDF.",
|
||||
"tags": "ruota,capovolgi,orienta"
|
||||
},
|
||||
"convert": {
|
||||
"title": "Converti",
|
||||
"desc": "Converti file tra diversi formati"
|
||||
"desc": "Converti file tra diversi formati",
|
||||
"tags": "trasforma,cambia"
|
||||
},
|
||||
"pdfOrganiser": {
|
||||
"title": "Organizza",
|
||||
"desc": "Rimuovi/Riordina le pagine in qualsiasi ordine."
|
||||
"desc": "Rimuovi/Riordina le pagine in qualsiasi ordine.",
|
||||
"tags": "organizza,riordina,riorganizza"
|
||||
},
|
||||
"addImage": {
|
||||
"title": "Aggiungi Immagine",
|
||||
"desc": "Aggiungi un'immagine in un punto specifico del PDF (Lavori in corso)"
|
||||
"desc": "Aggiungi un'immagine in un punto specifico del PDF (Lavori in corso)",
|
||||
"tags": "inserisci,incorpora,posiziona"
|
||||
},
|
||||
"addAttachments": {
|
||||
"title": "Aggiungi allegati",
|
||||
"desc": "Aggiungi o rimuovi file incorporati (allegati) da/verso un PDF"
|
||||
"desc": "Aggiungi o rimuovi file incorporati (allegati) da/verso un PDF",
|
||||
"tags": "incorpora,allega,includi"
|
||||
},
|
||||
"watermark": {
|
||||
"title": "Aggiungi Filigrana",
|
||||
"desc": "Aggiungi una filigrana al tuo PDF."
|
||||
"desc": "Aggiungi una filigrana al tuo PDF.",
|
||||
"tags": "timbro,marca,sovrapponi"
|
||||
},
|
||||
"removePassword": {
|
||||
"title": "Rimuovi Password",
|
||||
"desc": "Rimuovi la password dal tuo PDF."
|
||||
"desc": "Rimuovi la password dal tuo PDF.",
|
||||
"tags": "sblocca"
|
||||
},
|
||||
"compress": {
|
||||
"title": "Comprimi",
|
||||
"desc": "Comprimi PDF per ridurne le dimensioni."
|
||||
"desc": "Comprimi PDF per ridurne le dimensioni.",
|
||||
"tags": "riduci,comprimi,ottimizza"
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"title": "Sblocca moduli PDF",
|
||||
"desc": "Rimuovi la proprietà di sola lettura dei campi del modulo in un documento PDF."
|
||||
"desc": "Rimuovi la proprietà di sola lettura dei campi del modulo in un documento PDF.",
|
||||
"tags": "sblocca,abilita,modifica"
|
||||
},
|
||||
"changeMetadata": {
|
||||
"title": "Modifica Proprietà",
|
||||
"desc": "Modifica/Aggiungi/Rimuovi le proprietà di un documento PDF."
|
||||
"desc": "Modifica/Aggiungi/Rimuovi le proprietà di un documento PDF.",
|
||||
"tags": "modifica,cambia,aggiorna"
|
||||
},
|
||||
"ocr": {
|
||||
"title": "OCR / Pulisci scansioni",
|
||||
"desc": "Pulisci scansioni ed estrai testo da immagini, convertendo le immagini in testo puro."
|
||||
"desc": "Pulisci scansioni ed estrai testo da immagini, convertendo le immagini in testo puro.",
|
||||
"tags": "estrai,scansiona"
|
||||
},
|
||||
"extractImages": {
|
||||
"title": "Estrai immagini",
|
||||
"desc": "Estrai tutte le immagini da un PDF e salvale come zip."
|
||||
"desc": "Estrai tutte le immagini da un PDF e salvale come zip.",
|
||||
"tags": "estrai,salva,esporta"
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"title": "Rileva/Dividi foto scansionate",
|
||||
"desc": "Divide più foto all’interno di una foto/PDF"
|
||||
"desc": "Divide più foto all’interno di una foto/PDF",
|
||||
"tags": "rileva,dividi,foto"
|
||||
},
|
||||
"sign": {
|
||||
"title": "Firma",
|
||||
"desc": "Aggiungi una firma al PDF da disegno, testo o immagine."
|
||||
"desc": "Aggiungi una firma al PDF da disegno, testo o immagine.",
|
||||
"tags": "firma,autografo"
|
||||
},
|
||||
"flatten": {
|
||||
"title": "Appiattisci",
|
||||
"desc": "Rimuovi tutti gli elementi interattivi e moduli da un PDF."
|
||||
"desc": "Rimuovi tutti gli elementi interattivi e moduli da un PDF.",
|
||||
"tags": "semplifica,rimuovi,interattivo"
|
||||
},
|
||||
"certSign": {
|
||||
"title": "Firma con certificato",
|
||||
"desc": "Firma un PDF con un certificato/chiave (PEM/P12)"
|
||||
"desc": "Firma un PDF con un certificato/chiave (PEM/P12)",
|
||||
"tags": "autentica,PEM,P12,ufficiale,cripta,firma,certificato,PKCS12,JKS,server,manuale,auto"
|
||||
},
|
||||
"repair": {
|
||||
"title": "Ripara",
|
||||
"desc": "Prova a riparare un PDF corrotto."
|
||||
"desc": "Prova a riparare un PDF corrotto.",
|
||||
"tags": "ripara,ripristina"
|
||||
},
|
||||
"removeBlanks": {
|
||||
"title": "Rimuovi pagine vuote",
|
||||
"desc": "Trova e rimuovi pagine vuote da un PDF."
|
||||
"desc": "Trova e rimuovi pagine vuote da un PDF.",
|
||||
"tags": "elimina,pulisci,vuote"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"title": "Rimuovi annotazioni",
|
||||
"desc": "Rimuove tutti i commenti/annotazioni da un PDF"
|
||||
"desc": "Rimuove tutti i commenti/annotazioni da un PDF",
|
||||
"tags": "elimina,pulisci,rimuovi"
|
||||
},
|
||||
"compare": {
|
||||
"title": "Compara",
|
||||
"desc": "Vedi e compara le differenze tra due PDF."
|
||||
"desc": "Vedi e compara le differenze tra due PDF.",
|
||||
"tags": "differenza"
|
||||
},
|
||||
"removeCertSign": {
|
||||
"title": "Rimuovere firma dal certificato",
|
||||
"desc": "Rimuovi la firma del certificato dal PDF"
|
||||
"desc": "Rimuovi la firma del certificato dal PDF",
|
||||
"tags": "rimuovi,elimina,sblocca"
|
||||
},
|
||||
"pageLayout": {
|
||||
"title": "Layout multipagina",
|
||||
"desc": "Unisci più pagine di un documento PDF in un'unica pagina"
|
||||
"desc": "Unisci più pagine di un documento PDF in un'unica pagina",
|
||||
"tags": "layout,disponi,combina"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"title": "Imposizione a libretto",
|
||||
"desc": "Crea libretti con corretto ordinamento pagine e layout multipagina per stampa e rilegatura"
|
||||
"desc": "Crea libretti con corretto ordinamento pagine e layout multipagina per stampa e rilegatura",
|
||||
"tags": "opuscolo,stampa,rilegatura"
|
||||
},
|
||||
"scalePages": {
|
||||
"title": "Regola le dimensioni/scala della pagina",
|
||||
"desc": "Modificare le dimensioni/scala della pagina e/o dei suoi contenuti."
|
||||
"desc": "Modificare le dimensioni/scala della pagina e/o dei suoi contenuti.",
|
||||
"tags": "ridimensiona,adatta,scala"
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"title": "Aggiungi numeri di pagina",
|
||||
"desc": "Aggiungi numeri di pagina in tutto un documento in una posizione prestabilita"
|
||||
"desc": "Aggiungi numeri di pagina in tutto un documento in una posizione prestabilita",
|
||||
"tags": "numero,paginazione,conteggio"
|
||||
},
|
||||
"autoRename": {
|
||||
"title": "Rinomina automatica file PDF",
|
||||
"desc": "Rinomina automaticamente un file PDF in base all’intestazione rilevata"
|
||||
"desc": "Rinomina automaticamente un file PDF in base all’intestazione rilevata",
|
||||
"tags": "auto-rilevamento,basato su intestazione,organizza,rinomina"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Regola colori/contrasto",
|
||||
"desc": "Regola contrasto, saturazione e luminosità di un PDF"
|
||||
"desc": "Regola contrasto, saturazione e luminosità di un PDF",
|
||||
"tags": "contrasto,luminosità,saturazione"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Ritaglia PDF",
|
||||
"desc": "Ritaglia un PDF per ridurne le dimensioni (mantiene il testo!)"
|
||||
"desc": "Ritaglia un PDF per ridurne le dimensioni (mantiene il testo!)",
|
||||
"tags": "ritaglia,taglia,ridimensiona"
|
||||
},
|
||||
"autoSplitPDF": {
|
||||
"title": "Pagine divise automaticamente",
|
||||
"desc": "Dividi automaticamente il PDF scansionato con il codice QR dello divisore di pagina fisico scansionato"
|
||||
"desc": "Dividi automaticamente il PDF scansionato con il codice QR dello divisore di pagina fisico scansionato",
|
||||
"tags": "auto,dividi,QR"
|
||||
},
|
||||
"sanitize": {
|
||||
"title": "Sanitizza",
|
||||
"desc": "Rimuovi elementi potenzialmente dannosi dai PDF"
|
||||
"desc": "Rimuovi elementi potenzialmente dannosi dai PDF",
|
||||
"tags": "pulisci,elimina,rimuovi"
|
||||
},
|
||||
"getPdfInfo": {
|
||||
"title": "Ottieni TUTTE le informazioni in PDF",
|
||||
"desc": "Raccogli tutte le informazioni possibili sui PDF"
|
||||
"desc": "Raccogli tutte le informazioni possibili sui PDF",
|
||||
"tags": "info,metadati,dettagli"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"title": "PDF in un'unica pagina di grandi dimensioni",
|
||||
"desc": "Unisce tutte le pagine PDF in un'unica grande pagina"
|
||||
"desc": "Unisce tutte le pagine PDF in un'unica grande pagina",
|
||||
"tags": "combina,unisci,singola"
|
||||
},
|
||||
"showJS": {
|
||||
"title": "Mostra Javascript",
|
||||
"desc": "Cerca e visualizza qualsiasi JS inserito in un PDF"
|
||||
"desc": "Cerca e visualizza qualsiasi JS inserito in un PDF",
|
||||
"tags": "javascript,codice,script"
|
||||
},
|
||||
"redact": {
|
||||
"title": "Redazione manuale",
|
||||
"desc": "Redige un PDF in base al testo selezionato, alle forme disegnate e/o alle pagina selezionata(e)"
|
||||
"desc": "Redige un PDF in base al testo selezionato, alle forme disegnate e/o alle pagina selezionata(e)",
|
||||
"tags": "censura,oscura,nascondi"
|
||||
},
|
||||
"overlayPdfs": {
|
||||
"title": "Sovrapponi PDF",
|
||||
"desc": "Sovrapponi PDF sopra un altro PDF"
|
||||
"desc": "Sovrapponi PDF sopra un altro PDF",
|
||||
"tags": "sovrapponi,combina,impila"
|
||||
},
|
||||
"splitBySections": {
|
||||
"title": "Dividi PDF per sezioni",
|
||||
"desc": "Divide ogni pagina di un PDF in sezioni orizzontali e verticali più piccole"
|
||||
"desc": "Divide ogni pagina di un PDF in sezioni orizzontali e verticali più piccole",
|
||||
"tags": "dividi,sezioni,separa"
|
||||
},
|
||||
"addStamp": {
|
||||
"title": "Aggiungi timbro al PDF",
|
||||
"desc": "Aggiungi timbri di testo o immagine in posizioni specifiche"
|
||||
"desc": "Aggiungi timbri di testo o immagine in posizioni specifiche",
|
||||
"tags": "timbro,marca,sigillo"
|
||||
},
|
||||
"removeImage": {
|
||||
"title": "Rimuovi immagine",
|
||||
"desc": "Rimuovi le immagini dal PDF per ridurre la dimensione del file"
|
||||
"desc": "Rimuovi le immagini dal PDF per ridurre la dimensione del file",
|
||||
"tags": "rimuovi,elimina,pulisci"
|
||||
},
|
||||
"splitByChapters": {
|
||||
"title": "Dividi PDF per capitoli",
|
||||
"desc": "Dividi un PDF in più file in base alla struttura dei capitoli."
|
||||
"desc": "Dividi un PDF in più file in base alla struttura dei capitoli.",
|
||||
"tags": "dividi,capitoli,struttura"
|
||||
},
|
||||
"validateSignature": {
|
||||
"title": "Convalida la firma PDF",
|
||||
"desc": "Verificare le firme digitali e i certificati nei documenti PDF"
|
||||
"desc": "Verificare le firme digitali e i certificati nei documenti PDF",
|
||||
"tags": "convalida,verifica,certificato"
|
||||
},
|
||||
"swagger": {
|
||||
"title": "Documentazione API",
|
||||
"desc": "Visualizza documentazione API e testa gli endpoint"
|
||||
"desc": "Visualizza documentazione API e testa gli endpoint",
|
||||
"tags": "API,documentazione,test"
|
||||
},
|
||||
"fakeScan": {
|
||||
"title": "Finta scansione",
|
||||
@@ -538,31 +585,38 @@
|
||||
},
|
||||
"editTableOfContents": {
|
||||
"title": "Modifica indice",
|
||||
"desc": "Aggiungi o modifica segnalibri e sommario nei documenti PDF"
|
||||
"desc": "Aggiungi o modifica segnalibri e sommario nei documenti PDF",
|
||||
"tags": "segnalibri,contenuti,modifica"
|
||||
},
|
||||
"manageCertificates": {
|
||||
"title": "Gestisci certificati",
|
||||
"desc": "Importa, esporta o elimina i file certificato usati per firmare i PDF."
|
||||
"desc": "Importa, esporta o elimina i file certificato usati per firmare i PDF.",
|
||||
"tags": "certificati,importa,esporta"
|
||||
},
|
||||
"read": {
|
||||
"title": "Leggi",
|
||||
"desc": "Visualizza e annota PDF. Evidenzia testo, disegna o inserisci commenti per revisione e collaborazione."
|
||||
"desc": "Visualizza e annota PDF. Evidenzia testo, disegna o inserisci commenti per revisione e collaborazione.",
|
||||
"tags": "visualizza,apri,mostra"
|
||||
},
|
||||
"reorganizePages": {
|
||||
"title": "Riorganizza pagine",
|
||||
"desc": "Riorganizza, duplica o elimina pagine PDF con controllo visivo drag‑and‑drop."
|
||||
"desc": "Riorganizza, duplica o elimina pagine PDF con controllo visivo drag‑and‑drop.",
|
||||
"tags": "riordina,riorganizza,organizza"
|
||||
},
|
||||
"extractPages": {
|
||||
"title": "Estrai pagine",
|
||||
"desc": "Estrai pagine specifiche da un PDF"
|
||||
"desc": "Estrai pagine specifiche da un PDF",
|
||||
"tags": "estrai,seleziona,copia"
|
||||
},
|
||||
"removePages": {
|
||||
"title": "Rimuovi",
|
||||
"desc": "Elimina alcune pagine dal PDF."
|
||||
"desc": "Elimina alcune pagine dal PDF.",
|
||||
"tags": "elimina,estrai,escludi"
|
||||
},
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"replaceColorPdf": {
|
||||
"title": "Sostituisci e inverti il colore",
|
||||
@@ -570,11 +624,13 @@
|
||||
},
|
||||
"devApi": {
|
||||
"title": "API",
|
||||
"desc": "Link alla documentazione API"
|
||||
"desc": "Link alla documentazione API",
|
||||
"tags": "API,sviluppo,documentazione"
|
||||
},
|
||||
"devFolderScanning": {
|
||||
"title": "Scansione cartelle automatizzata",
|
||||
"desc": "Link alla guida per scansione cartelle automatizzata"
|
||||
"desc": "Link alla guida per scansione cartelle automatizzata",
|
||||
"tags": "automazione,cartella,scansione"
|
||||
},
|
||||
"devSsoGuide": {
|
||||
"title": "Guida SSO",
|
||||
@@ -594,7 +650,17 @@
|
||||
},
|
||||
"automate": {
|
||||
"title": "Automatizza",
|
||||
"desc": "Crea flussi multi‑step concatenando azioni PDF. Ideale per attività ricorrenti."
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
@@ -654,7 +720,9 @@
|
||||
},
|
||||
"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",
|
||||
@@ -913,7 +981,20 @@
|
||||
"10": "Unione pari-dispari",
|
||||
"11": "Duplica tutte le pagine"
|
||||
},
|
||||
"placeholder": "(ad es. 1,3,2 o 4-8,2,10-12 o 2n-1)"
|
||||
"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)."
|
||||
}
|
||||
},
|
||||
"addImage": {
|
||||
"tags": "img,jpg,immagine,foto",
|
||||
@@ -1347,7 +1428,7 @@
|
||||
},
|
||||
"trapped": {
|
||||
"label": "Stato Trapped",
|
||||
"unknown": "Unknown",
|
||||
"unknown": "Sconosciuto",
|
||||
"true": "True",
|
||||
"false": "False"
|
||||
},
|
||||
@@ -1538,7 +1619,13 @@
|
||||
"header": "Estrai immagini",
|
||||
"selectText": "Seleziona il formato in cui salvare le immagini estratte",
|
||||
"allowDuplicates": "Salva le immagini duplicate",
|
||||
"submit": "Estrai"
|
||||
"submit": "Estrai",
|
||||
"error": {
|
||||
"failed": "Si è verificato un errore durante l'estrazione delle immagini dal PDF."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni"
|
||||
}
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "archivio,a lungo termine,standard,conversione,archiviazione,conservazione",
|
||||
@@ -1615,8 +1702,14 @@
|
||||
"title": "Firma",
|
||||
"header": "Firma PDF",
|
||||
"upload": "Carica immagine",
|
||||
"draw": "Disegna Firma",
|
||||
"text": "Testo",
|
||||
"draw": {
|
||||
"clear": "Cancella",
|
||||
"title": "Disegna la tua firma"
|
||||
},
|
||||
"text": {
|
||||
"name": "Nome firmatario",
|
||||
"placeholder": "Inserisci il tuo nome completo"
|
||||
},
|
||||
"clear": "Cancella",
|
||||
"add": "Aggiungi",
|
||||
"saved": "Firme salvate",
|
||||
@@ -1632,7 +1725,35 @@
|
||||
"previous": "Pagina precedente",
|
||||
"maintainRatio": "Attiva il mantenimento delle proporzioni",
|
||||
"undo": "Annulla",
|
||||
"redo": "Rifare"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"flatten": {
|
||||
"tags": "statico,disattivato,non interattivo,ottimizzato",
|
||||
@@ -1651,7 +1772,8 @@
|
||||
"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."
|
||||
"note": "Il flattening rimuove gli elementi interattivi dal PDF, rendendoli non modificabili.",
|
||||
"flattenOnlyForms": "Appiattisci solo i moduli"
|
||||
},
|
||||
"results": {
|
||||
"title": "Risultati Flatten"
|
||||
@@ -1747,7 +1869,17 @@
|
||||
"tags": "commenti,evidenziazioni,note,markup,rimozione",
|
||||
"title": "Rimuovi Annotazioni",
|
||||
"header": "Rimuovi Annotazioni",
|
||||
"submit": "Rimuovi"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
"tags": "differenziare,contrastare,cambiare,analisi",
|
||||
@@ -3024,7 +3156,13 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"addPassword": {
|
||||
@@ -3294,5 +3432,56 @@
|
||||
}
|
||||
},
|
||||
"termsAndConditions": "Termini e condizioni",
|
||||
"logOut": "Esci"
|
||||
"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"
|
||||
}
|
||||
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,7 +87,10 @@
|
||||
"showStack": "显示堆栈跟踪",
|
||||
"copyStack": "复制堆栈跟踪",
|
||||
"githubSubmit": "GitHub - 提交工单",
|
||||
"discordSubmit": "Discord - 提交支持帖子"
|
||||
"discordSubmit": "Discord - 提交支持帖子",
|
||||
"dismissAllErrors": "关闭所有错误",
|
||||
"encryptedPdfMustRemovePassword": "此 PDF 已加密或受密码保护。请在转换为 PDF/A 之前将其解锁。",
|
||||
"incorrectPasswordProvided": "PDF 密码不正确或未提供。"
|
||||
},
|
||||
"warning": {
|
||||
"tooltipTitle": "警告"
|
||||
@@ -358,179 +361,223 @@
|
||||
"sortBy": "排序:",
|
||||
"multiTool": {
|
||||
"title": "PDF 多功能工具",
|
||||
"desc": "合并、旋转、重新排列和删除 PDF 页面"
|
||||
"desc": "合并、旋转、重新排列和删除 PDF 页面",
|
||||
"tags": "多个,工具"
|
||||
},
|
||||
"merge": {
|
||||
"title": "合并",
|
||||
"desc": "轻松将多个 PDF 合并成一个。"
|
||||
"desc": "轻松将多个 PDF 合并成一个。",
|
||||
"tags": "组合,合并,联合"
|
||||
},
|
||||
"split": {
|
||||
"title": "拆分",
|
||||
"desc": "将 PDF 拆分为多个文档。"
|
||||
"desc": "将 PDF 拆分为多个文档。",
|
||||
"tags": "分割,分离,拆分"
|
||||
},
|
||||
"rotate": {
|
||||
"title": "旋转",
|
||||
"desc": "旋转 PDF。"
|
||||
"desc": "旋转 PDF。",
|
||||
"tags": "旋转,翻转,定向"
|
||||
},
|
||||
"convert": {
|
||||
"title": "转换",
|
||||
"desc": "在不同格式之间转换文件"
|
||||
"desc": "在不同格式之间转换文件",
|
||||
"tags": "转换,更改"
|
||||
},
|
||||
"pdfOrganiser": {
|
||||
"title": "整理",
|
||||
"desc": "按任意顺序删除/重新排列页面。"
|
||||
"desc": "按任意顺序删除/重新排列页面。",
|
||||
"tags": "组织,重新排列,重新排序"
|
||||
},
|
||||
"addImage": {
|
||||
"title": "在 PDF 中添加图片",
|
||||
"desc": "将图像添加到 PDF 的指定位置。"
|
||||
"desc": "将图像添加到 PDF 的指定位置。",
|
||||
"tags": "插入,嵌入,放置"
|
||||
},
|
||||
"addAttachments": {
|
||||
"title": "添加附件",
|
||||
"desc": "向 PDF 添加或移除嵌入文件(附件)"
|
||||
"desc": "向 PDF 添加或移除嵌入文件(附件)",
|
||||
"tags": "嵌入,附加,包含"
|
||||
},
|
||||
"watermark": {
|
||||
"title": "添加水印",
|
||||
"desc": "在 PDF 中添加自定义水印。"
|
||||
"desc": "在 PDF 中添加自定义水印。",
|
||||
"tags": "印章,标记,叠加"
|
||||
},
|
||||
"removePassword": {
|
||||
"title": "删除密码",
|
||||
"desc": "从 PDF 文档中移除密码保护。"
|
||||
"desc": "从 PDF 文档中移除密码保护。",
|
||||
"tags": "解锁"
|
||||
},
|
||||
"compress": {
|
||||
"title": "压缩",
|
||||
"desc": "压缩 PDF 文件以减小文件大小。"
|
||||
"desc": "压缩 PDF 文件以减小文件大小。",
|
||||
"tags": "缩小,减少,优化"
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"title": "解锁PDF表单",
|
||||
"desc": "移除表单字段只读属性"
|
||||
"desc": "移除表单字段只读属性",
|
||||
"tags": "解锁,启用,编辑"
|
||||
},
|
||||
"changeMetadata": {
|
||||
"title": "更改元数据",
|
||||
"desc": "更改/删除/添加 PDF 文档的元数据。"
|
||||
"desc": "更改/删除/添加 PDF 文档的元数据。",
|
||||
"tags": "编辑,修改,更新"
|
||||
},
|
||||
"ocr": {
|
||||
"title": "运行 OCR /清理扫描",
|
||||
"desc": "清理和识别 PDF 中的图像文本,并将其转换为可编辑文本。"
|
||||
"desc": "清理和识别 PDF 中的图像文本,并将其转换为可编辑文本。",
|
||||
"tags": "提取,扫描"
|
||||
},
|
||||
"extractImages": {
|
||||
"title": "提取图像",
|
||||
"desc": "从 PDF 中提取所有图像并保存到压缩包中。"
|
||||
"desc": "从 PDF 中提取所有图像并保存到压缩包中。",
|
||||
"tags": "提取,保存,导出"
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"title": "检测/拆分扫描照片",
|
||||
"desc": "从照片/PDF 中拆分出多张照片"
|
||||
"desc": "从照片/PDF 中拆分出多张照片",
|
||||
"tags": "检测,拆分,照片"
|
||||
},
|
||||
"sign": {
|
||||
"title": "签名",
|
||||
"desc": "通过绘图、文字或图像向 PDF 添加签名"
|
||||
"desc": "通过绘图、文字或图像向 PDF 添加签名",
|
||||
"tags": "签名,亲笔签名"
|
||||
},
|
||||
"flatten": {
|
||||
"title": "展平",
|
||||
"desc": "从 PDF 中删除所有互动元素和表单"
|
||||
"desc": "从 PDF 中删除所有互动元素和表单",
|
||||
"tags": "简化,删除,交互式"
|
||||
},
|
||||
"certSign": {
|
||||
"title": "使用证书签名",
|
||||
"desc": "使用证书/密钥(PEM/P12)对PDF进行签名"
|
||||
"desc": "使用证书/密钥(PEM/P12)对PDF进行签名",
|
||||
"tags": "认证,PEM,P12,官方,加密,签名,证书,PKCS12,JKS,服务器,手动,自动"
|
||||
},
|
||||
"repair": {
|
||||
"title": "修复",
|
||||
"desc": "尝试修复损坏/损坏的 PDF"
|
||||
"desc": "尝试修复损坏/损坏的 PDF",
|
||||
"tags": "修复,恢复"
|
||||
},
|
||||
"removeBlanks": {
|
||||
"title": "删除空白页",
|
||||
"desc": "检测并删除文档中的空白页"
|
||||
"desc": "检测并删除文档中的空白页",
|
||||
"tags": "删除,清理,空白"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"title": "删除标注",
|
||||
"desc": "删除 PDF 中的所有标注/评论"
|
||||
"desc": "删除 PDF 中的所有标注/评论",
|
||||
"tags": "删除,清理,删除"
|
||||
},
|
||||
"compare": {
|
||||
"title": "比较",
|
||||
"desc": "比较并显示两个 PDF 文档之间的差异"
|
||||
"desc": "比较并显示两个 PDF 文档之间的差异",
|
||||
"tags": "差异"
|
||||
},
|
||||
"removeCertSign": {
|
||||
"title": "移除证书签名",
|
||||
"desc": "移除 PDF 的证书签名"
|
||||
"desc": "移除 PDF 的证书签名",
|
||||
"tags": "删除,删除,解锁"
|
||||
},
|
||||
"pageLayout": {
|
||||
"title": "多页布局",
|
||||
"desc": "将 PDF 文档的多个页面合并成一页"
|
||||
"desc": "将 PDF 文档的多个页面合并成一页",
|
||||
"tags": "布局,排列,组合"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"title": "小册子拼版",
|
||||
"desc": "创建具有正确页面顺序和多页布局的小册子,用于打印和装订"
|
||||
"desc": "创建具有正确页面顺序和多页布局的小册子,用于打印和装订",
|
||||
"tags": "小册子,打印,装订"
|
||||
},
|
||||
"scalePages": {
|
||||
"title": "调整页面尺寸/缩放",
|
||||
"desc": "调整页面及/或其内容的尺寸/缩放"
|
||||
"desc": "调整页面及/或其内容的尺寸/缩放",
|
||||
"tags": "调整大小,调整,缩放"
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"title": "添加页码",
|
||||
"desc": "在文档的指定位置添加页码"
|
||||
"desc": "在文档的指定位置添加页码",
|
||||
"tags": "编号,分页,计数"
|
||||
},
|
||||
"autoRename": {
|
||||
"title": "自动重命名 PDF 文件",
|
||||
"desc": "基于检测到的页眉自动重命名 PDF 文件"
|
||||
"desc": "基于检测到的页眉自动重命名 PDF 文件",
|
||||
"tags": "自动检测,基于标题,组织,重新标记"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "调整颜色/对比度",
|
||||
"desc": "调整 PDF 的对比度、饱和度和亮度"
|
||||
"desc": "调整 PDF 的对比度、饱和度和亮度",
|
||||
"tags": "对比度,亮度,饱和度"
|
||||
},
|
||||
"crop": {
|
||||
"title": "裁剪 PDF",
|
||||
"desc": "裁剪 PDF 以减小其文件大小(保留文本!)"
|
||||
"desc": "裁剪 PDF 以减小其文件大小(保留文本!)",
|
||||
"tags": "裁剪,剪切,调整大小"
|
||||
},
|
||||
"autoSplitPDF": {
|
||||
"title": "自动拆分页面",
|
||||
"desc": "使用物理扫描页面分割器 QR 代码自动拆分扫描的 PDF"
|
||||
"desc": "使用物理扫描页面分割器 QR 代码自动拆分扫描的 PDF",
|
||||
"tags": "自动,拆分,QR"
|
||||
},
|
||||
"sanitize": {
|
||||
"title": "安全清理",
|
||||
"desc": "移除 PDF 文件中的潜在有害元素"
|
||||
"desc": "移除 PDF 文件中的潜在有害元素",
|
||||
"tags": "清理,清除,删除"
|
||||
},
|
||||
"getPdfInfo": {
|
||||
"title": "获取 PDF 的所有信息",
|
||||
"desc": "获取 PDF 的所有可能的信息"
|
||||
"desc": "获取 PDF 的所有可能的信息",
|
||||
"tags": "信息,元数据,详细信息"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"title": "PDF 转单一大页",
|
||||
"desc": "将所有 PDF 页面合并为一个大的单页"
|
||||
"desc": "将所有 PDF 页面合并为一个大的单页",
|
||||
"tags": "组合,合并,单页"
|
||||
},
|
||||
"showJS": {
|
||||
"title": "显示 JavaScript",
|
||||
"desc": "搜索并显示嵌入到 PDF 中的任何 JavaScript 代码"
|
||||
"desc": "搜索并显示嵌入到 PDF 中的任何 JavaScript 代码",
|
||||
"tags": "javascript,代码,脚本"
|
||||
},
|
||||
"redact": {
|
||||
"title": "手动修订",
|
||||
"desc": "根据选定的文本、绘制的形状和/或选定的页面编辑PDF"
|
||||
"desc": "根据选定的文本、绘制的形状和/或选定的页面编辑PDF",
|
||||
"tags": "审查,涂黑,隐藏"
|
||||
},
|
||||
"overlayPdfs": {
|
||||
"title": "叠加 PDF",
|
||||
"desc": "将一个 PDF 叠加到另一个 PDF 之上"
|
||||
"desc": "将一个 PDF 叠加到另一个 PDF 之上",
|
||||
"tags": "叠加,组合,堆叠"
|
||||
},
|
||||
"splitBySections": {
|
||||
"title": "按区块拆分 PDF",
|
||||
"desc": "将 PDF 的每一页分割为更小的横向与纵向区块"
|
||||
"desc": "将 PDF 的每一页分割为更小的横向与纵向区块",
|
||||
"tags": "拆分,部分,分割"
|
||||
},
|
||||
"addStamp": {
|
||||
"title": "向 PDF 添加印章",
|
||||
"desc": "在指定位置添加文本或图像印章"
|
||||
"desc": "在指定位置添加文本或图像印章",
|
||||
"tags": "印章,标记,盖章"
|
||||
},
|
||||
"removeImage": {
|
||||
"title": "删除图像",
|
||||
"desc": "删除图像减少 PDF 大小"
|
||||
"desc": "删除图像减少 PDF 大小",
|
||||
"tags": "删除,删除,清理"
|
||||
},
|
||||
"splitByChapters": {
|
||||
"title": "按章节拆分 PDF",
|
||||
"desc": "根据其章节结构将 PDF 拆分为多个文件。"
|
||||
"desc": "根据其章节结构将 PDF 拆分为多个文件。",
|
||||
"tags": "拆分,章节,结构"
|
||||
},
|
||||
"validateSignature": {
|
||||
"title": "验证 PDF 签名",
|
||||
"desc": "验证 PDF 文档中的数字签名和证书"
|
||||
"desc": "验证 PDF 文档中的数字签名和证书",
|
||||
"tags": "验证,核实,证书"
|
||||
},
|
||||
"swagger": {
|
||||
"title": "API 文档",
|
||||
"desc": "查看 API 文档并测试端点"
|
||||
"desc": "查看 API 文档并测试端点",
|
||||
"tags": "API,文档,测试"
|
||||
},
|
||||
"fakeScan": {
|
||||
"title": "伪扫描",
|
||||
@@ -538,31 +585,38 @@
|
||||
},
|
||||
"editTableOfContents": {
|
||||
"title": "编辑目录",
|
||||
"desc": "为 PDF 文档添加或编辑目录和书签"
|
||||
"desc": "为 PDF 文档添加或编辑目录和书签",
|
||||
"tags": "书签,目录,编辑"
|
||||
},
|
||||
"manageCertificates": {
|
||||
"title": "管理证书",
|
||||
"desc": "导入、导出或删除用于签名 PDF 的数字证书文件。"
|
||||
"desc": "导入、导出或删除用于签名 PDF 的数字证书文件。",
|
||||
"tags": "证书,导入,导出"
|
||||
},
|
||||
"read": {
|
||||
"title": "阅读",
|
||||
"desc": "查看与批注 PDF。高亮、绘制或插入评论以便审阅协作。"
|
||||
"desc": "查看与批注 PDF。高亮、绘制或插入评论以便审阅协作。",
|
||||
"tags": "查看,打开,显示"
|
||||
},
|
||||
"reorganizePages": {
|
||||
"title": "重组页面",
|
||||
"desc": "通过可视化拖放控制重新排列、复制或删除 PDF 页面。"
|
||||
"desc": "通过可视化拖放控制重新排列、复制或删除 PDF 页面。",
|
||||
"tags": "重新排列,重新排序,组织"
|
||||
},
|
||||
"extractPages": {
|
||||
"title": "提取页面",
|
||||
"desc": "从 PDF 文档中提取特定页面"
|
||||
"desc": "从 PDF 文档中提取特定页面",
|
||||
"tags": "提取,选择,复制"
|
||||
},
|
||||
"removePages": {
|
||||
"title": "删除",
|
||||
"desc": "从 PDF 文档中删除不需要的页面。"
|
||||
"desc": "从 PDF 文档中删除不需要的页面。",
|
||||
"tags": "删除,提取,排除"
|
||||
},
|
||||
"autoSizeSplitPDF": {
|
||||
"title": "自动根据大小/数目拆分 PDF",
|
||||
"desc": "将单个 PDF 拆分为多个文档,基于大小、页数或文档数"
|
||||
"desc": "将单个 PDF 拆分为多个文档,基于大小、页数或文档数",
|
||||
"tags": "自动,拆分,大小"
|
||||
},
|
||||
"replaceColorPdf": {
|
||||
"title": "替换和反转颜色",
|
||||
@@ -570,11 +624,13 @@
|
||||
},
|
||||
"devApi": {
|
||||
"title": "API",
|
||||
"desc": "跳转至 API 文档"
|
||||
"desc": "跳转至 API 文档",
|
||||
"tags": "API,开发,文档"
|
||||
},
|
||||
"devFolderScanning": {
|
||||
"title": "自动文件夹扫描",
|
||||
"desc": "跳转至自动文件夹扫描指南"
|
||||
"desc": "跳转至自动文件夹扫描指南",
|
||||
"tags": "自动化,文件夹,扫描"
|
||||
},
|
||||
"devSsoGuide": {
|
||||
"title": "SSO 指南",
|
||||
@@ -594,7 +650,17 @@
|
||||
},
|
||||
"automate": {
|
||||
"title": "自动化",
|
||||
"desc": "通过串联 PDF 操作构建多步工作流。适合重复性任务。"
|
||||
"desc": "通过串联 PDF 操作构建多步工作流。适合重复性任务。",
|
||||
"tags": "工作流,序列,自动化"
|
||||
},
|
||||
"replaceColor": {
|
||||
"desc": "替换或反转 PDF 文档中的颜色",
|
||||
"title": "替换和反转颜色"
|
||||
},
|
||||
"scannerEffect": {
|
||||
"desc": "创建看起来像扫描的 PDF",
|
||||
"tags": "扫描,模拟,创建",
|
||||
"title": "扫描仪效果"
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
@@ -654,7 +720,9 @@
|
||||
},
|
||||
"error": {
|
||||
"failed": "合并 PDF 时发生错误。"
|
||||
}
|
||||
},
|
||||
"generateTableOfContents": "在合并的文件中生成目录?",
|
||||
"removeDigitalSignature": "在合并的文件中删除数字签名?"
|
||||
},
|
||||
"split": {
|
||||
"tags": "页面操作,划分,多页面,剪切,服务器端",
|
||||
@@ -815,7 +883,7 @@
|
||||
"settings": "设置",
|
||||
"conversionCompleted": "转换完成",
|
||||
"results": "结果",
|
||||
"defaultFilename": "converted_file",
|
||||
"defaultFilename": "已转换文件",
|
||||
"conversionResults": "转换结果",
|
||||
"convertFrom": "转换来源",
|
||||
"convertTo": "转换为",
|
||||
@@ -913,7 +981,20 @@
|
||||
"10": "奇偶合并",
|
||||
"11": "复制所有页面"
|
||||
},
|
||||
"placeholder": "(例如:1,3,2 或 4-8,2,10-12 或 2n-1)"
|
||||
"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": "排列页面以进行侧缝小册子打印(针对侧面装订进行了优化)。"
|
||||
}
|
||||
},
|
||||
"addImage": {
|
||||
"tags": "图像、JPG、图片、照片",
|
||||
@@ -937,7 +1018,7 @@
|
||||
"desc": "向 PDF 添加文本或图像水印",
|
||||
"completed": "已添加水印",
|
||||
"submit": "添加水印",
|
||||
"filenamePrefix": "watermarked",
|
||||
"filenamePrefix": "已加水印",
|
||||
"error": {
|
||||
"failed": "向 PDF 添加水印时发生错误。"
|
||||
},
|
||||
@@ -1136,7 +1217,7 @@
|
||||
"placeholder": "例如:1,3,5-8,10",
|
||||
"error": "无效的页码格式。使用数字、范围(1-5)或数学表达式(2n+1)"
|
||||
},
|
||||
"filenamePrefix": "pages_removed",
|
||||
"filenamePrefix": "已删除页面",
|
||||
"files": {
|
||||
"placeholder": "在主视图中选择一个 PDF 文件以开始"
|
||||
},
|
||||
@@ -1284,7 +1365,7 @@
|
||||
"header": "解锁 PDF 表单",
|
||||
"submit": "Remove",
|
||||
"description": "该工具将移除 PDF 表单字段的只读限制,使其可编辑、可填写。",
|
||||
"filenamePrefix": "unlocked_forms",
|
||||
"filenamePrefix": "已解锁表单",
|
||||
"files": {
|
||||
"placeholder": "在主视图中选择一个 PDF 文件以开始"
|
||||
},
|
||||
@@ -1299,7 +1380,7 @@
|
||||
"tags": "标题、作者、日期、创建、时间、发布者、制作人、统计数据",
|
||||
"header": "更改元数据",
|
||||
"submit": "更改",
|
||||
"filenamePrefix": "metadata",
|
||||
"filenamePrefix": "元数据",
|
||||
"settings": {
|
||||
"title": "元数据设置"
|
||||
},
|
||||
@@ -1347,7 +1428,7 @@
|
||||
},
|
||||
"trapped": {
|
||||
"label": "陷印状态",
|
||||
"unknown": "Unknown",
|
||||
"unknown": "未知",
|
||||
"true": "True",
|
||||
"false": "False"
|
||||
},
|
||||
@@ -1538,7 +1619,13 @@
|
||||
"header": "提取图像",
|
||||
"selectText": "选择图像格式,将提取的图像转换为",
|
||||
"allowDuplicates": "保存重复图像",
|
||||
"submit": "提取"
|
||||
"submit": "提取",
|
||||
"error": {
|
||||
"failed": "从 PDF 提取图像时发生错误。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置"
|
||||
}
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "归档、长期、标准、转换、存储、保存",
|
||||
@@ -1615,8 +1702,14 @@
|
||||
"title": "签名",
|
||||
"header": "签署 PDF",
|
||||
"upload": "上传图片",
|
||||
"draw": "绘制签名",
|
||||
"text": "文本输入",
|
||||
"draw": {
|
||||
"clear": "清除",
|
||||
"title": "绘制您的签名"
|
||||
},
|
||||
"text": {
|
||||
"name": "签署人姓名",
|
||||
"placeholder": "输入您的全名"
|
||||
},
|
||||
"clear": "清除",
|
||||
"add": "添加",
|
||||
"saved": "已保存签名",
|
||||
@@ -1632,7 +1725,35 @@
|
||||
"previous": "上一页",
|
||||
"maintainRatio": "切换保持长宽比",
|
||||
"undo": "撤销",
|
||||
"redo": "重做"
|
||||
"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": "签名类型"
|
||||
}
|
||||
},
|
||||
"flatten": {
|
||||
"tags": "静态、停用、非交互、简化",
|
||||
@@ -1640,7 +1761,7 @@
|
||||
"header": "展平 PDF",
|
||||
"flattenOnlyForms": "仅展平表格",
|
||||
"submit": "展平",
|
||||
"filenamePrefix": "flattened",
|
||||
"filenamePrefix": "已扁平化",
|
||||
"files": {
|
||||
"placeholder": "在主视图中选择一个 PDF 文件以开始"
|
||||
},
|
||||
@@ -1651,7 +1772,8 @@
|
||||
"stepTitle": "扁平化选项",
|
||||
"title": "扁平化选项",
|
||||
"flattenOnlyForms.desc": "仅扁平化表单字段,保留其他交互元素",
|
||||
"note": "扁平化会移除 PDF 的交互元素,使其不可编辑。"
|
||||
"note": "扁平化会移除 PDF 的交互元素,使其不可编辑。",
|
||||
"flattenOnlyForms": "仅扁平化表单"
|
||||
},
|
||||
"results": {
|
||||
"title": "扁平化结果"
|
||||
@@ -1687,7 +1809,7 @@
|
||||
"header": "修复 PDF",
|
||||
"submit": "修复",
|
||||
"description": "该工具将尝试修复损坏或受损的 PDF 文件。无需额外设置。",
|
||||
"filenamePrefix": "repaired",
|
||||
"filenamePrefix": "已修复",
|
||||
"files": {
|
||||
"placeholder": "在主视图中选择一个 PDF 文件以开始"
|
||||
},
|
||||
@@ -1747,7 +1869,17 @@
|
||||
"tags": "评论、高亮、笔记、标注、删除",
|
||||
"title": "删除标注",
|
||||
"header": "删除标注",
|
||||
"submit": "删除"
|
||||
"submit": "删除",
|
||||
"error": {
|
||||
"failed": "从 PDF 删除注释时发生错误。"
|
||||
},
|
||||
"info": {
|
||||
"description": "此工具将从您的 PDF 文档中删除所有注释(评论、高亮、笔记等)。",
|
||||
"title": "关于删除注释"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置"
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
"tags": "区分、对比、更改、分析",
|
||||
@@ -1779,7 +1911,7 @@
|
||||
"certSign": {
|
||||
"tags": "身份验证、PEM、P12、官方、加密",
|
||||
"title": "证书签名",
|
||||
"filenamePrefix": "signed",
|
||||
"filenamePrefix": "已签名",
|
||||
"signMode": {
|
||||
"stepTitle": "签名模式",
|
||||
"tooltip": {
|
||||
@@ -1903,7 +2035,7 @@
|
||||
"selectPDF": "选择 PDF 文件:",
|
||||
"submit": "移除签名",
|
||||
"description": "该工具将从您的 PDF 文档中移除数字证书签名。",
|
||||
"filenamePrefix": "unsigned",
|
||||
"filenamePrefix": "未签名",
|
||||
"files": {
|
||||
"placeholder": "在主视图中选择一个 PDF 文件以开始"
|
||||
},
|
||||
@@ -1923,7 +2055,7 @@
|
||||
"submit": "提交"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"tags": "booklet,imposition,printing,binding,folding,signature",
|
||||
"tags": "小册子,拼版,打印,装订,折叠,签名",
|
||||
"title": "小册子拼版",
|
||||
"header": "小册子拼版",
|
||||
"submit": "创建小册子",
|
||||
@@ -2024,7 +2156,7 @@
|
||||
"submit": "提交"
|
||||
},
|
||||
"adjustPageScale": {
|
||||
"tags": "resize,modify,dimension,adapt",
|
||||
"tags": "调整大小,修改,尺寸,适应",
|
||||
"title": "调整页面比例",
|
||||
"header": "调整页面比例",
|
||||
"scaleFactor": {
|
||||
@@ -2547,7 +2679,7 @@
|
||||
"header": "将 PDF 转换为单页",
|
||||
"submit": "转为单页",
|
||||
"description": "该工具会将 PDF 的所有页面合并为一张超长单页。宽度保持与原页面相同,高度为所有页面高度之和。",
|
||||
"filenamePrefix": "single_page",
|
||||
"filenamePrefix": "单页",
|
||||
"files": {
|
||||
"placeholder": "在主视图中选择一个 PDF 文件以开始"
|
||||
},
|
||||
@@ -2768,7 +2900,7 @@
|
||||
"title": "API 文档",
|
||||
"header": "API 文档",
|
||||
"desc": "查看并测试 Stirling PDF 的 API 端点",
|
||||
"tags": "api,documentation,swagger,endpoints,development"
|
||||
"tags": "api,文档,swagger,端点,开发"
|
||||
},
|
||||
"cookieBanner": {
|
||||
"popUp": {
|
||||
@@ -3006,7 +3138,7 @@
|
||||
"completed": "安全清理成功完成",
|
||||
"error.generic": "安全清理失败",
|
||||
"error.failed": "安全清理 PDF 时发生错误。",
|
||||
"filenamePrefix": "sanitised",
|
||||
"filenamePrefix": "已清理",
|
||||
"sanitizationResults": "安全清理结果",
|
||||
"steps": {
|
||||
"files": "文件",
|
||||
@@ -3024,7 +3156,13 @@
|
||||
"removeXMPMetadata.desc": "从 PDF 中移除 XMP 元数据",
|
||||
"removeMetadata.desc": "移除文档信息元数据(标题、作者等)",
|
||||
"removeLinks.desc": "移除外部链接与启动动作",
|
||||
"removeFonts.desc": "从 PDF 中移除嵌入字体"
|
||||
"removeFonts.desc": "从 PDF 中移除嵌入字体",
|
||||
"removeEmbeddedFiles": "删除嵌入文件",
|
||||
"removeFonts": "删除字体",
|
||||
"removeJavaScript": "删除 JavaScript",
|
||||
"removeLinks": "删除链接",
|
||||
"removeMetadata": "删除文档元数据",
|
||||
"removeXMPMetadata": "删除 XMP 元数据"
|
||||
}
|
||||
},
|
||||
"addPassword": {
|
||||
@@ -3032,7 +3170,7 @@
|
||||
"desc": "使用密码加密您的 PDF 文档。",
|
||||
"completed": "已应用密码保护",
|
||||
"submit": "加密",
|
||||
"filenamePrefix": "encrypted",
|
||||
"filenamePrefix": "已加密",
|
||||
"error": {
|
||||
"failed": "加密 PDF 时发生错误。"
|
||||
},
|
||||
@@ -3141,7 +3279,7 @@
|
||||
"placeholder": "输入当前密码",
|
||||
"completed": "密码已配置"
|
||||
},
|
||||
"filenamePrefix": "decrypted",
|
||||
"filenamePrefix": "已解密",
|
||||
"error": {
|
||||
"failed": "移除 PDF 密码时发生错误。"
|
||||
},
|
||||
@@ -3294,5 +3432,56 @@
|
||||
}
|
||||
},
|
||||
"termsAndConditions": "条款与条件",
|
||||
"logOut": "退出登录"
|
||||
"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": "未保存的更改"
|
||||
}
|
||||
Binary file not shown.
@@ -1,17 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
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 { execSync } = require('node:child_process');
|
||||
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
import { argv } from 'node:process';
|
||||
const { argv } = require('node:process');
|
||||
const inputIdx = argv.indexOf('--input');
|
||||
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
|
||||
const POSTPROCESS_ONLY = !!INPUT_FILE;
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
// __dirname is available in CommonJS by default
|
||||
|
||||
/**
|
||||
* Generate 3rd party licenses for frontend dependencies
|
||||
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Stirling PDF Sample Document Generator
|
||||
*
|
||||
* This script uses Puppeteer to generate a sample PDF from a HTML template.
|
||||
* The output is used in the onboarding tour and as a demo document
|
||||
* for users to experiment with Stirling PDF's features.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { existsSync, mkdirSync, statSync } from 'fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const TEMPLATE_PATH = join(__dirname, 'template.html');
|
||||
const OUTPUT_DIR = join(__dirname, '../../public/samples');
|
||||
const OUTPUT_PATH = join(OUTPUT_DIR, 'Sample.pdf');
|
||||
|
||||
async function generatePDF() {
|
||||
console.log('🚀 Starting Stirling PDF sample document generation...\n');
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!existsSync(OUTPUT_DIR)) {
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
console.log(`✅ Created output directory: ${OUTPUT_DIR}`);
|
||||
}
|
||||
|
||||
// Check if template exists
|
||||
if (!existsSync(TEMPLATE_PATH)) {
|
||||
console.error(`❌ Template file not found: ${TEMPLATE_PATH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`📄 Reading template: ${TEMPLATE_PATH}`);
|
||||
|
||||
let browser;
|
||||
try {
|
||||
// Launch Puppeteer
|
||||
console.log('🌐 Launching browser...');
|
||||
browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Set viewport to match A4 proportions
|
||||
await page.setViewport({
|
||||
width: 794, // A4 width in pixels at 96 DPI
|
||||
height: 1123, // A4 height in pixels at 96 DPI
|
||||
deviceScaleFactor: 2 // Higher quality rendering
|
||||
});
|
||||
|
||||
// Navigate to the template file
|
||||
const fileUrl = `file://${TEMPLATE_PATH}`;
|
||||
console.log('📖 Loading HTML template...');
|
||||
await page.goto(fileUrl, {
|
||||
waitUntil: 'networkidle0' // Wait for all resources to load
|
||||
});
|
||||
|
||||
// Generate PDF with A4 dimensions
|
||||
console.log('📝 Generating PDF...');
|
||||
await page.pdf({
|
||||
path: OUTPUT_PATH,
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
},
|
||||
preferCSSPageSize: true
|
||||
});
|
||||
|
||||
console.log('\n✅ PDF generated successfully!');
|
||||
console.log(`📦 Output: ${OUTPUT_PATH}`);
|
||||
|
||||
// Get file size
|
||||
const stats = statSync(OUTPUT_PATH);
|
||||
const fileSizeInKB = (stats.size / 1024).toFixed(2);
|
||||
console.log(`📊 File size: ${fileSizeInKB} KB`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error generating PDF:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
console.log('🔒 Browser closed.');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n🎉 Done! Sample PDF is ready for use in Stirling PDF.\n');
|
||||
}
|
||||
|
||||
// Run the generator
|
||||
generatePDF().catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,432 @@
|
||||
/* Stirling PDF Sample Document Styles */
|
||||
|
||||
:root {
|
||||
/* Brand Colors */
|
||||
--brand-red: #8e3231;
|
||||
--brand-blue: #3b82f6;
|
||||
|
||||
/* Category Colors */
|
||||
--color-general: #3b82f6;
|
||||
--color-security: #f59e0b;
|
||||
--color-formatting: #8b5cf6;
|
||||
--color-automation: #ec4899;
|
||||
|
||||
/* Neutral Colors */
|
||||
--color-black: #111827;
|
||||
--color-gray-dark: #4b5563;
|
||||
--color-gray-medium: #6b7280;
|
||||
--color-gray-light: #e5e7eb;
|
||||
--color-gray-lighter: #f3f4f6;
|
||||
--color-white: #ffffff;
|
||||
|
||||
/* Font Stack */
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
color: var(--color-black);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Page Structure - A4 Dimensions */
|
||||
.page {
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
background: white;
|
||||
page-break-after: always;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page:last-child {
|
||||
page-break-after: auto;
|
||||
}
|
||||
|
||||
/* Page 1: Hero / Cover */
|
||||
.page-1 {
|
||||
background: var(--brand-red);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Decorative shapes container */
|
||||
.decorative-shapes {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.shape {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Logo SVG shape - top-right */
|
||||
.shape-1 {
|
||||
top: -120px;
|
||||
right: -100px;
|
||||
width: 450px;
|
||||
height: auto;
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
/* Logo SVG shape - top-left */
|
||||
.shape-2 {
|
||||
top: -80px;
|
||||
left: -80px;
|
||||
width: 350px;
|
||||
height: auto;
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
/* Logo SVG shape - bottom-left */
|
||||
.shape-3 {
|
||||
bottom: -180px;
|
||||
left: -150px;
|
||||
width: 550px;
|
||||
height: auto;
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
/* Logo SVG shape - bottom-right */
|
||||
.shape-4 {
|
||||
bottom: -100px;
|
||||
right: -120px;
|
||||
width: 400px;
|
||||
height: auto;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
/* Small accent shape center-right */
|
||||
.shape-5 {
|
||||
top: 50%;
|
||||
right: -30px;
|
||||
width: 200px;
|
||||
height: auto;
|
||||
opacity: 0.08;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
margin-bottom: 48px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-logo {
|
||||
width: 280px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.hero-tagline {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: var(--color-white);
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 48px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: var(--brand-red);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 18px;
|
||||
color: var(--color-gray-dark);
|
||||
margin-top: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-features {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feature-pill {
|
||||
padding: 12px 24px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border-radius: 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Page 2: What is Stirling PDF */
|
||||
.page-2 {
|
||||
padding: 60px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--brand-red);
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 4px solid var(--brand-red);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.intro-text {
|
||||
font-size: 16px;
|
||||
color: var(--color-gray-dark);
|
||||
margin-bottom: 48px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.value-props {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.value-prop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.value-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--brand-red);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.value-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.value-prop h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.value-prop p {
|
||||
font-size: 14px;
|
||||
color: var(--color-gray-dark);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Page 3: Key Features */
|
||||
.page-3 {
|
||||
padding: 60px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: white;
|
||||
border: 2px solid var(--color-gray-light);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.feature-card[data-category="general"] {
|
||||
border-color: var(--color-general);
|
||||
}
|
||||
|
||||
.feature-card[data-category="security"] {
|
||||
border-color: var(--color-security);
|
||||
}
|
||||
|
||||
.feature-card[data-category="formatting"] {
|
||||
border-color: var(--color-formatting);
|
||||
}
|
||||
|
||||
.feature-card[data-category="automation"] {
|
||||
border-color: var(--color-automation);
|
||||
}
|
||||
|
||||
.feature-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feature-icon-large {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-card[data-category="general"] .feature-icon-large {
|
||||
color: var(--color-general);
|
||||
}
|
||||
|
||||
.feature-card[data-category="security"] .feature-icon-large {
|
||||
color: var(--color-security);
|
||||
}
|
||||
|
||||
.feature-card[data-category="formatting"] .feature-icon-large {
|
||||
color: var(--color-formatting);
|
||||
}
|
||||
|
||||
.feature-card[data-category="automation"] .feature-icon-large {
|
||||
color: var(--color-automation);
|
||||
}
|
||||
|
||||
.feature-icon-large svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
font-size: 14px;
|
||||
color: var(--color-gray-dark);
|
||||
padding: 6px 0;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.feature-list li::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--brand-red);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.additional-features {
|
||||
background: white;
|
||||
border: 2px solid var(--brand-red);
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.additional-features-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.additional-features-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--brand-red);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.additional-features-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.additional-features h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-black);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.additional-features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.additional-features-grid ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.additional-features-grid li {
|
||||
font-size: 15px;
|
||||
color: var(--color-gray-dark);
|
||||
padding: 4px 0;
|
||||
padding-left: 24px;
|
||||
position: relative;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.additional-features-grid li::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--brand-red);
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page {
|
||||
margin: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Stirling PDF - Sample Document</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Page 1: Hero / Cover Page -->
|
||||
<div class="page page-1">
|
||||
<div class="decorative-shapes">
|
||||
<img src="../../public/branding/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="">
|
||||
<img src="../../public/branding/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="">
|
||||
<img src="../../public/branding/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="">
|
||||
<img src="../../public/branding/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="">
|
||||
<img src="../../public/branding/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" alt="">
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="logo-container">
|
||||
<img src="../../public/branding/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo">
|
||||
</div>
|
||||
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
|
||||
<div class="hero-stats">
|
||||
<div class="stat-badge">
|
||||
<span class="stat-number">10M+</span>
|
||||
<span class="stat-label">Downloads</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-features">
|
||||
<div class="feature-pill">Open Source</div>
|
||||
<div class="feature-pill">Privacy First</div>
|
||||
<div class="feature-pill">Self-Hosted</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page 2: What is Stirling PDF -->
|
||||
<div class="page page-2">
|
||||
<div class="content-wrapper">
|
||||
<h2 class="page-title">What is Stirling PDF?</h2>
|
||||
<p class="intro-text">
|
||||
Stirling PDF is a robust, web-based PDF manipulation tool.
|
||||
It enables you to carry out various operations on PDF files, including splitting,
|
||||
merging, converting, rearranging, adding images, rotating, compressing, and more.
|
||||
</p>
|
||||
|
||||
<div class="value-props">
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>50+ PDF Operations</h3>
|
||||
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Workflow Automation</h3>
|
||||
<p>Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Multi-Language Support</h3>
|
||||
<p>Available in over 30 languages with community-contributed translations. Accessible to users worldwide.</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
<circle cx="12" cy="16" r="1" />
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Privacy First</h3>
|
||||
<p>Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Open Source</h3>
|
||||
<p>Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>API Access</h3>
|
||||
<p>RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page 3: Key Features -->
|
||||
<div class="page page-3">
|
||||
<div class="content-wrapper">
|
||||
<h2 class="page-title">Key Features</h2>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card" data-category="general">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Page Operations</h3>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Merge & split PDFs</li>
|
||||
<li>Rearrange pages</li>
|
||||
<li>Rotate & crop</li>
|
||||
<li>Extract pages</li>
|
||||
<li>Multi-page layout</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card" data-category="security">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Security & Signing</h3>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Password protection</li>
|
||||
<li>Digital signatures</li>
|
||||
<li>Watermarks</li>
|
||||
<li>Permission controls</li>
|
||||
<li>Redaction tools</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card" data-category="formatting">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="m5.825 17l1.9 1.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-3.6-3.6q-.15-.15-.213-.325T2.426 16t.063-.375t.212-.325l3.6-3.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L5.825 15H20q.425 0 .713.288T21 16t-.288.713T20 17zm12.35-8H4q-.425 0-.712-.288T3 8t.288-.712T4 7h14.175l-1.9-1.9q-.3-.3-.287-.7t.312-.7q.3-.275.7-.288t.7.288l3.6 3.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.6 3.6q-.275.275-.687.275T16.3 12.3q-.3-.3-.3-.712t.3-.713z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>File Conversions</h3>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>PDF to/from images</li>
|
||||
<li>Office documents</li>
|
||||
<li>HTML to PDF</li>
|
||||
<li>Markdown to PDF</li>
|
||||
<li>PDF to Word/Excel</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card" data-category="automation">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Automation</h3>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Multi-step workflows</li>
|
||||
<li>Chain PDF operations</li>
|
||||
<li>Save recurring tasks</li>
|
||||
<li>Batch file processing</li>
|
||||
<li>API integration</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="additional-features">
|
||||
<div class="additional-features-header">
|
||||
<div class="additional-features-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 20q-.825 0-1.4125-.5875T4 18t.5875-1.4125T6 16t1.4125.5875T8 18t-.5875 1.4125T6 20m6 0q-.825 0-1.4125-.5875T10 18t.5875-1.4125T12 16t1.4125.5875T14 18t-.5875 1.4125T12 20m6 0q-.825 0-1.4125-.5875T16 18t.5875-1.4125T18 16t1.4125.5875T20 18t-.5875 1.4125T18 20M6 14q-.825 0-1.4125-.5875T4 12t.5875-1.4125T6 10t1.4125.5875T8 12t-.5875 1.4125T6 14m6 0q-.825 0-1.4125-.5875T10 12t.5875-1.4125T12 10t1.4125.5875T14 12t-.5875 1.4125T12 14m6 0q-.825 0-1.4125-.5875T16 12t.5875-1.4125T18 10t1.4125.5875T20 12t-.5875 1.4125T18 14M6 8q-.825 0-1.4125-.5875T4 6t.5875-1.4125T6 4t1.4125.5875T8 6t-.5875 1.4125T6 8m6 0q-.825 0-1.4125-.5875T10 6t.5875-1.4125T12 4t1.4125.5875T14 6t-.5875 1.4125T12 8m6 0q-.825 0-1.4125-.5875T16 6t.5875-1.4125T18 4t1.4125.5875T20 6t-.5875 1.4125T18 8" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Plus Many More</h3>
|
||||
</div>
|
||||
<div class="additional-features-grid">
|
||||
<ul>
|
||||
<li>OCR text recognition</li>
|
||||
<li>Compress PDFs</li>
|
||||
<li>Add images & stamps</li>
|
||||
<li>Detect blank pages</li>
|
||||
<li>Extract images</li>
|
||||
<li>Edit metadata</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>Flatten forms</li>
|
||||
<li>PDF/A conversion</li>
|
||||
<li>Add page numbers</li>
|
||||
<li>Remove pages</li>
|
||||
<li>Repair PDFs</li>
|
||||
<li>And 40+ more tools</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+40
-20
@@ -1,12 +1,18 @@
|
||||
import React, { Suspense } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
|
||||
import { FileContextProvider } from "./contexts/FileContext";
|
||||
import { NavigationProvider } from "./contexts/NavigationContext";
|
||||
import { ToolRegistryProvider } from "./contexts/ToolRegistryProvider";
|
||||
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 { OnboardingProvider } from "./contexts/OnboardingContext";
|
||||
import { TourOrchestrationProvider } from "./contexts/TourOrchestrationContext";
|
||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import OnboardingTour from "./components/onboarding/OnboardingTour";
|
||||
|
||||
// Import global styles
|
||||
import "./styles/tailwind.css";
|
||||
@@ -14,6 +20,7 @@ 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";
|
||||
@@ -37,25 +44,38 @@ const LoadingFallback = () => (
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<OnboardingProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<HomePage />
|
||||
<OnboardingTour />
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</OnboardingProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</PreferencesProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,132 @@
|
||||
"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",
|
||||
@@ -35,6 +161,13 @@
|
||||
"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",
|
||||
@@ -143,7 +276,7 @@
|
||||
{
|
||||
"moduleName": "posthog-js",
|
||||
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
|
||||
"moduleVersion": "1.266.0",
|
||||
"moduleVersion": "1.268.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,6 +9,9 @@ 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;
|
||||
@@ -20,7 +23,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile } = useFileManager();
|
||||
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
||||
|
||||
// File management handlers
|
||||
const isFileSupported = useCallback((fileName: string) => {
|
||||
@@ -84,6 +87,30 @@ 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';
|
||||
@@ -100,6 +127,7 @@ 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',
|
||||
@@ -123,7 +151,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onDrop={handleNewFileUpload}
|
||||
onDragEnter={() => setIsDragging(true)}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
accept={{}}
|
||||
multiple={true}
|
||||
activateOnClick={false}
|
||||
style={{
|
||||
@@ -147,6 +174,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
modalHeight={modalHeight}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
isLoading={loading}
|
||||
>
|
||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||
</FileManagerProvider>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,281 @@
|
||||
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;
|
||||
@@ -0,0 +1,60 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
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;
|
||||
@@ -9,7 +9,7 @@
|
||||
transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
@@ -34,9 +34,9 @@
|
||||
.header {
|
||||
height: 2.25rem;
|
||||
border-radius: 0.0625rem 0.0625rem 0 0;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 6px;
|
||||
user-select: none;
|
||||
background: var(--bg-toolbar);
|
||||
@@ -86,14 +86,23 @@
|
||||
}
|
||||
|
||||
.headerIndex {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.kebab {
|
||||
justify-self: end;
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.headerIconButton {
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
@@ -216,6 +225,11 @@
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.pinned {
|
||||
color: #FFC107 !important;
|
||||
}
|
||||
|
||||
|
||||
/* Unsupported file indicator */
|
||||
.unsupportedPill {
|
||||
margin-left: 1.75rem;
|
||||
@@ -304,4 +318,84 @@
|
||||
/* 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,18 +1,19 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Text, Center, Box, LoadingOverlay, Stack, Group
|
||||
Text, Center, Box, LoadingOverlay, Stack
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } 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';
|
||||
import { alert } from '../toast';
|
||||
import { downloadBlob } from '../../utils/downloadUtils';
|
||||
import { useFileEditorRightRailButtons } from './fileEditorRightRailButtons';
|
||||
|
||||
|
||||
interface FileEditorProps {
|
||||
@@ -36,10 +37,15 @@ const FileEditor = ({
|
||||
// Use optimized FileContext hooks
|
||||
const { state, selectors } = useFileState();
|
||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { actions: fileContextActions } = useFileContext();
|
||||
const { clearAllFileErrors } = fileContextActions;
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
const totalItems = state.files.ids.length;
|
||||
const selectedCount = selectedFileIds.length;
|
||||
|
||||
// Get navigation actions
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
@@ -60,25 +66,12 @@ const FileEditor = ({
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (toolMode) {
|
||||
setSelectionMode(true);
|
||||
}
|
||||
}, [toolMode]);
|
||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||
const [zipExtractionProgress, setZipExtractionProgress] = useState<{
|
||||
isExtracting: boolean;
|
||||
currentFile: string;
|
||||
progress: number;
|
||||
extractedCount: number;
|
||||
totalFiles: number;
|
||||
}>({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
// Get selected file IDs from context (defensive programming)
|
||||
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
||||
|
||||
@@ -89,107 +82,63 @@ const FileEditor = ({
|
||||
// Use activeStirlingFileStubs directly - no conversion needed
|
||||
const localSelectedIds = contextSelectedIds;
|
||||
|
||||
const handleSelectAllFiles = useCallback(() => {
|
||||
setSelectedFiles(state.files.ids);
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Failed to clear file errors on select all:', error);
|
||||
}
|
||||
}
|
||||
}, [state.files.ids, setSelectedFiles, clearAllFileErrors]);
|
||||
|
||||
const handleDeselectAllFiles = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Failed to clear file errors on deselect:', error);
|
||||
}
|
||||
}
|
||||
}, [setSelectedFiles, clearAllFileErrors]);
|
||||
|
||||
const handleCloseSelectedFiles = useCallback(() => {
|
||||
if (selectedFileIds.length === 0) return;
|
||||
void removeFiles(selectedFileIds, false);
|
||||
setSelectedFiles([]);
|
||||
}, [selectedFileIds, removeFiles, setSelectedFiles]);
|
||||
|
||||
useFileEditorRightRailButtons({
|
||||
totalItems,
|
||||
selectedCount,
|
||||
onSelectAll: handleSelectAllFiles,
|
||||
onDeselectAll: handleDeselectAllFiles,
|
||||
onCloseSelected: handleCloseSelectedFiles,
|
||||
});
|
||||
|
||||
// Process uploaded files using context
|
||||
// ZIP extraction is now handled automatically in FileContext based on user preferences
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
_setError(null);
|
||||
|
||||
try {
|
||||
const allExtractedFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of uploadedFiles) {
|
||||
if (file.type === 'application/pdf') {
|
||||
// Handle PDF files normally
|
||||
allExtractedFiles.push(file);
|
||||
} else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
|
||||
// Handle ZIP files - only expand if they contain PDFs
|
||||
try {
|
||||
// Validate ZIP file first
|
||||
const validation = await zipFileService.validateZipFile(file);
|
||||
|
||||
if (validation.isValid && validation.containsPDFs) {
|
||||
// ZIP contains PDFs - extract them
|
||||
setZipExtractionProgress({
|
||||
isExtracting: true,
|
||||
currentFile: file.name,
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: validation.fileCount
|
||||
});
|
||||
|
||||
const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
|
||||
setZipExtractionProgress({
|
||||
isExtracting: true,
|
||||
currentFile: progress.currentFile,
|
||||
progress: progress.progress,
|
||||
extractedCount: progress.extractedCount,
|
||||
totalFiles: progress.totalFiles
|
||||
});
|
||||
});
|
||||
|
||||
// Reset extraction progress
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
|
||||
if (extractionResult.success) {
|
||||
allExtractedFiles.push(...extractionResult.extractedFiles);
|
||||
|
||||
if (extractionResult.errors.length > 0) {
|
||||
errors.push(...extractionResult.errors);
|
||||
}
|
||||
} else {
|
||||
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
// ZIP doesn't contain PDFs or is invalid - treat as regular file
|
||||
allExtractedFiles.push(file);
|
||||
}
|
||||
} catch (zipError) {
|
||||
errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`);
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
allExtractedFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Show any errors
|
||||
if (errors.length > 0) {
|
||||
showError(errors.join('\n'));
|
||||
}
|
||||
|
||||
// Process all extracted files
|
||||
if (allExtractedFiles.length > 0) {
|
||||
// Add files to context (they will be processed automatically)
|
||||
await addFiles(allExtractedFiles);
|
||||
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
|
||||
if (uploadedFiles.length > 0) {
|
||||
// FileContext will automatically handle ZIP extraction based on user preferences
|
||||
// - Respects autoUnzip setting
|
||||
// - Respects autoUnzipFileLimit
|
||||
// - HTML ZIPs stay intact
|
||||
// - Non-ZIP files pass through unchanged
|
||||
await addFiles(uploadedFiles, { selectFiles: true });
|
||||
showStatus(`Added ${uploadedFiles.length} file(s)`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
showError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
|
||||
// Reset extraction progress on error
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
}
|
||||
}, [addFiles]);
|
||||
}, [addFiles, showStatus, showError]);
|
||||
|
||||
const toggleFile = useCallback((fileId: FileId) => {
|
||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||
@@ -286,7 +235,7 @@ const FileEditor = ({
|
||||
|
||||
|
||||
// File operations using context
|
||||
const handleDeleteFile = useCallback((fileId: FileId) => {
|
||||
const handleCloseFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
@@ -308,6 +257,48 @@ 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 fileActions.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, fileActions, removeFiles]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (record) {
|
||||
@@ -347,10 +338,10 @@ const FileEditor = ({
|
||||
<Box pos="relative" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={false} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Box p="md">
|
||||
|
||||
|
||||
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||
{activeStirlingFileStubs.length === 0 ? (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">📁</Text>
|
||||
@@ -358,43 +349,6 @@ const FileEditor = ({
|
||||
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
|
||||
<Box>
|
||||
<SkeletonLoader type="controls" />
|
||||
|
||||
{/* ZIP Extraction Progress */}
|
||||
{zipExtractionProgress.isExtracting && (
|
||||
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-orange-0)', borderRadius: 8 }}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" fw={500}>Extracting ZIP archive...</Text>
|
||||
<Text size="sm" c="dimmed">{Math.round(zipExtractionProgress.progress)}%</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{zipExtractionProgress.currentFile || 'Processing files...'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
|
||||
</Text>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${Math.round(zipExtractionProgress.progress)}%`,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-orange-6)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
<SkeletonLoader type="fileGrid" count={6} />
|
||||
</Box>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
@@ -405,6 +359,14 @@ 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
|
||||
@@ -415,11 +377,12 @@ const FileEditor = ({
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onCloseFile={handleCloseFile}
|
||||
onViewFile={handleViewFile}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
onUnzipFile={handleUnzipFile}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
/>
|
||||
@@ -437,7 +400,7 @@ const FileEditor = ({
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
</Box>
|
||||
</Dropzone>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { alert } from '../toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
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';
|
||||
@@ -17,6 +20,7 @@ import { useFileState } from '../../contexts/file/fileHooks';
|
||||
import { FileId } from '../../types/file';
|
||||
import { formatFileSize } from '../../utils/fileUtils';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
import HoverActionMenu, { HoverAction } from '../shared/HoverActionMenu';
|
||||
|
||||
|
||||
|
||||
@@ -27,11 +31,12 @@ interface FileEditorThumbnailProps {
|
||||
selectedFiles: FileId[];
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onDeleteFile: (fileId: FileId) => void;
|
||||
onCloseFile: (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;
|
||||
}
|
||||
@@ -41,10 +46,12 @@ const FileEditorThumbnail = ({
|
||||
index,
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onCloseFile,
|
||||
onViewFile,
|
||||
_onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -55,8 +62,9 @@ const FileEditorThumbnail = ({
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const [actionsWidth, setActionsWidth] = useState<number | undefined>(undefined);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [showHoverMenu, setShowHoverMenu] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
@@ -64,6 +72,9 @@ 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);
|
||||
@@ -147,46 +158,66 @@ const FileEditorThumbnail = ({
|
||||
};
|
||||
}, [file.id, file.name, selectedFiles, onReorderFiles]);
|
||||
|
||||
// Update dropdown width on resize
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
// Handle close with confirmation
|
||||
const handleCloseWithConfirmation = useCallback(() => {
|
||||
setShowCloseModal(true);
|
||||
}, []);
|
||||
|
||||
// Close the actions dropdown when hovering outside this file card (and its dropdown)
|
||||
useEffect(() => {
|
||||
if (!showActions) return;
|
||||
const handleConfirmClose = useCallback(() => {
|
||||
onCloseFile(file.id);
|
||||
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowCloseModal(false);
|
||||
}, [file.id, file.name, onCloseFile]);
|
||||
|
||||
const isInsideCard = (target: EventTarget | null) => {
|
||||
const container = dragElementRef.current;
|
||||
if (!container) return false;
|
||||
return target instanceof Node && container.contains(target);
|
||||
};
|
||||
const handleCancelClose = useCallback(() => {
|
||||
setShowCloseModal(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isInsideCard(e.target)) {
|
||||
setShowActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// On touch devices, close if the touch target is outside the card
|
||||
if (!isInsideCard(e.target)) {
|
||||
setShowActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('touchstart', handleTouchStart);
|
||||
};
|
||||
}, [showActions]);
|
||||
// Build hover menu actions
|
||||
const hoverActions = useMemo<HoverAction[]>(() => [
|
||||
{
|
||||
id: 'view',
|
||||
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
|
||||
label: t('openInViewer', 'Open in Viewer'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onViewFile(file.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
||||
label: t('download', 'Download'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadFile(file.id);
|
||||
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'unzip',
|
||||
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
|
||||
label: t('fileManager.unzip', 'Unzip'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (onUnzipFile) {
|
||||
onUnzipFile(file.id);
|
||||
alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
}
|
||||
},
|
||||
hidden: !isZipFile || !onUnzipFile,
|
||||
},
|
||||
{
|
||||
id: 'close',
|
||||
icon: <CloseIcon style={{ fontSize: 20 }} />,
|
||||
label: t('close', 'Close'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
handleCloseWithConfirmation();
|
||||
},
|
||||
color: 'red',
|
||||
}
|
||||
], [t, file.id, file.name, isZipFile, onViewFile, onDownloadFile, onUnzipFile, handleCloseWithConfirmation]);
|
||||
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
@@ -198,6 +229,11 @@ const FileEditorThumbnail = ({
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
const handleCardDoubleClick = () => {
|
||||
if (!isSupported) return;
|
||||
onViewFile(file.id);
|
||||
};
|
||||
|
||||
// ---- Style helpers ----
|
||||
const getHeaderClassName = () => {
|
||||
if (hasError) return styles.headerError;
|
||||
@@ -211,6 +247,7 @@ const FileEditorThumbnail = ({
|
||||
ref={fileElementRef}
|
||||
data-file-id={file.id}
|
||||
data-testid="file-thumbnail"
|
||||
data-tour="file-card-checkbox"
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
@@ -219,6 +256,9 @@ const FileEditorThumbnail = ({
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
onClick={handleCardClick}
|
||||
onMouseEnter={() => setShowHoverMenu(true)}
|
||||
onMouseLeave={() => setShowHoverMenu(false)}
|
||||
onDoubleClick={handleCardDoubleClick}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
@@ -251,69 +291,33 @@ const FileEditorThumbnail = ({
|
||||
{index + 1}
|
||||
</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 */}
|
||||
{showActions && (
|
||||
<div
|
||||
className={styles.actionsOverlay}
|
||||
style={{ width: actionsWidth }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => {
|
||||
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 });
|
||||
{/* Action buttons group */}
|
||||
<div className={styles.headerActions}>
|
||||
{/* Pin/Unpin icon */}
|
||||
<Tooltip label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}>
|
||||
<ActionIcon
|
||||
aria-label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}
|
||||
variant="subtle"
|
||||
className={isPinned ? styles.pinned : styles.headerIconButton}
|
||||
data-tour="file-card-pin"
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { onDownloadFile(file.id); alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
<span>{t('download', 'Download')}</span>
|
||||
</button>
|
||||
|
||||
<div className={styles.actionsDivider} />
|
||||
|
||||
<button
|
||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||
onClick={() => {
|
||||
onDeleteFile(file.id);
|
||||
alert({ alertType: 'neutral', title: `Deleted ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
<span>{t('delete', 'Delete')}</span>
|
||||
</button>
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + meta line */}
|
||||
<div
|
||||
@@ -324,7 +328,7 @@ const FileEditorThumbnail = ({
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
|
||||
<Text size="lg" fw={700} className={`${styles.title} ph-no-capture `} lineClamp={2}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text
|
||||
@@ -350,6 +354,7 @@ const FileEditorThumbnail = ({
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnailUrl && (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={file.thumbnailUrl}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
@@ -376,13 +381,6 @@ 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" />
|
||||
@@ -411,6 +409,37 @@ const FileEditorThumbnail = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover Menu */}
|
||||
<HoverActionMenu
|
||||
show={showHoverMenu || isMobile}
|
||||
actions={hoverActions}
|
||||
position="outside"
|
||||
/>
|
||||
|
||||
{/* Close Confirmation Modal */}
|
||||
<Modal
|
||||
opened={showCloseModal}
|
||||
onClose={handleCancelClose}
|
||||
title={t('confirmClose', 'Confirm Close')}
|
||||
centered
|
||||
size="auto"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
{t('confirmCloseCancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||
{t('confirmCloseConfirm', 'Close File')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRightRailButtons, RightRailButtonWithAction } from '../../hooks/useRightRailButtons';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
|
||||
interface FileEditorRightRailButtonsParams {
|
||||
totalItems: number;
|
||||
selectedCount: number;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
onCloseSelected: () => void;
|
||||
}
|
||||
|
||||
export function useFileEditorRightRailButtons({
|
||||
totalItems,
|
||||
selectedCount,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
onCloseSelected,
|
||||
}: FileEditorRightRailButtonsParams) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => [
|
||||
{
|
||||
id: 'file-select-all',
|
||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('rightRail.selectAll', 'Select All'),
|
||||
ariaLabel: typeof t === 'function' ? t('rightRail.selectAll', 'Select All') : 'Select All',
|
||||
section: 'top' as const,
|
||||
order: 10,
|
||||
disabled: totalItems === 0 || selectedCount === totalItems,
|
||||
visible: totalItems > 0,
|
||||
onClick: onSelectAll,
|
||||
},
|
||||
{
|
||||
id: 'file-deselect-all',
|
||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('rightRail.deselectAll', 'Deselect All'),
|
||||
ariaLabel: typeof t === 'function' ? t('rightRail.deselectAll', 'Deselect All') : 'Deselect All',
|
||||
section: 'top' as const,
|
||||
order: 20,
|
||||
disabled: selectedCount === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onDeselectAll,
|
||||
},
|
||||
{
|
||||
id: 'file-close-selected',
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('rightRail.closeSelected', 'Close Selected Files'),
|
||||
ariaLabel: typeof t === 'function' ? t('rightRail.closeSelected', 'Close Selected Files') : 'Close Selected Files',
|
||||
section: 'top' as const,
|
||||
order: 30,
|
||||
disabled: selectedCount === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onCloseSelected,
|
||||
},
|
||||
], [t, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected]);
|
||||
|
||||
useRightRailButtons(buttons);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ 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={{
|
||||
@@ -66,7 +67,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
|
||||
{/* File info */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
<Text className='ph-no-capture' size="sm" fw={500} truncate>
|
||||
{currentFile ? currentFile.name : 'No file selected'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
|
||||
@@ -23,7 +23,7 @@ const DesktopLayout: React.FC = () => {
|
||||
width: '13.625rem',
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
}}>
|
||||
}} data-tour="file-sources">
|
||||
<FileSourceButtons />
|
||||
</Grid.Col>
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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, { useState } from 'react';
|
||||
import React, { useEffect, 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
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (currentFileIndex >= selectedFiles.length) {
|
||||
setCurrentFileIndex(0);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
</Group>
|
||||
|
||||
<Box ml="md">
|
||||
{sortedHistory.map((historyFile, _index) => (
|
||||
{sortedHistory.map((historyFile) => (
|
||||
<FileListItem
|
||||
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
|
||||
file={historyFile}
|
||||
@@ -56,7 +56,6 @@ 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 size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
|
||||
<Text className='ph-no-capture' 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,6 +29,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isFileSupported,
|
||||
isLoading,
|
||||
} = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -43,15 +44,11 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
scrollbarSize={8}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{recentFiles.length === 0 ? (
|
||||
{recentFiles.length === 0 && !isLoading ? (
|
||||
<EmptyFilesState />
|
||||
) : recentFiles.length === 0 && isLoading ? (
|
||||
<Center style={{ height: '12.5rem' }}>
|
||||
<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>
|
||||
<Text c="dimmed" ta="center">{t('fileManager.loadingFiles', 'Loading files...')}</Text>
|
||||
</Center>
|
||||
) : (
|
||||
filteredFiles.map((file, index) => {
|
||||
|
||||
@@ -5,11 +5,14 @@ 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';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '../../styles/zIndex';
|
||||
|
||||
interface FileListItemProps {
|
||||
file: StirlingFileStub;
|
||||
@@ -38,7 +41,10 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
|
||||
const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
|
||||
|
||||
// Check if this is a ZIP file
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
|
||||
// Keep item in hovered state if menu is open
|
||||
const shouldShowHovered = isHovered || isMenuOpen;
|
||||
@@ -93,7 +99,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
<Text size="sm" fw={500} className='ph-no-capture' truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
<Badge size="xs" variant="light" color={"blue"}>
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
@@ -122,6 +128,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
withinPortal
|
||||
onOpen={() => setIsMenuOpen(true)}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
@@ -183,7 +190,6 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddToRecents(file);
|
||||
}}
|
||||
>
|
||||
{t('fileManager.restore', 'Restore')}
|
||||
@@ -192,6 +198,22 @@ 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,6 +5,7 @@ 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;
|
||||
@@ -13,8 +14,20 @@ interface FileSourceButtonsProps {
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
horizontal = false
|
||||
}) => {
|
||||
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
|
||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = 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',
|
||||
@@ -67,15 +80,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={buttonProps.variant('drive')}
|
||||
variant="subtle"
|
||||
color='var(--mantine-color-gray-6)'
|
||||
leftSection={<CloudIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
onClick={() => onSourceChange('drive')}
|
||||
onClick={handleGoogleDriveClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
disabled
|
||||
color={activeSource === 'drive' ? 'gray' : undefined}
|
||||
styles={buttonProps.getStyles('drive')}
|
||||
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}
|
||||
>
|
||||
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
||||
</Button>
|
||||
|
||||
@@ -9,7 +9,6 @@ const HiddenFileInput: React.FC = () => {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={true}
|
||||
accept={["*/*"] as any}
|
||||
onChange={onFileInputChange}
|
||||
style={{ display: 'none' }}
|
||||
data-testid="file-input"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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,9 +1,12 @@
|
||||
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';
|
||||
@@ -20,18 +23,19 @@ export default function Workbench() {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { state } = useFileState();
|
||||
const { selectors } = useFileState();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
const activeFiles = state.files.ids;
|
||||
const activeFiles = selectors.getFiles();
|
||||
const {
|
||||
previewFile,
|
||||
pageEditorFunctions,
|
||||
sidebarsVisible,
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSidebarsVisible
|
||||
setSidebarsVisible,
|
||||
customWorkbenchViews,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { handleToolSelect } = useToolWorkflow();
|
||||
@@ -44,6 +48,9 @@ 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');
|
||||
@@ -95,6 +102,8 @@ export default function Workbench() {
|
||||
setSidebarsVisible={setSidebarsVisible}
|
||||
previewFile={previewFile}
|
||||
onClose={handlePreviewClose}
|
||||
activeFileIndex={activeFileIndex}
|
||||
setActiveFileIndex={setActiveFileIndex}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -130,15 +139,21 @@ export default function Workbench() {
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<LandingPage/>
|
||||
);
|
||||
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 (
|
||||
<Box
|
||||
className="flex-1 h-full min-w-80 relative flex flex-col"
|
||||
data-tour="workbench"
|
||||
style={
|
||||
isRainbowMode
|
||||
? {} // No background color in rainbow mode
|
||||
@@ -146,11 +161,19 @@ export default function Workbench() {
|
||||
}
|
||||
>
|
||||
{/* Top Controls */}
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
selectedToolKey={selectedToolId}
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
@@ -160,6 +183,7 @@ 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()}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/* Glow effect for tour highlighted area */
|
||||
.tour-highlight-glow {
|
||||
stroke: var(--mantine-primary-color-filled);
|
||||
stroke-width: 3px;
|
||||
rx: 8px;
|
||||
ry: 8px;
|
||||
filter: drop-shadow(0 0 10px var(--mantine-primary-color-filled));
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import React from "react";
|
||||
import { TourProvider, useTour, type StepType } from '@reactour/tour';
|
||||
import { useOnboarding } from '../../contexts/OnboardingContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseButton, ActionIcon } from '@mantine/core';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import { useTourOrchestration } from '../../contexts/TourOrchestrationContext';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import TourWelcomeModal from './TourWelcomeModal';
|
||||
import './OnboardingTour.css';
|
||||
|
||||
// Enum case order defines order steps will appear
|
||||
enum TourStep {
|
||||
ALL_TOOLS,
|
||||
SELECT_CROP_TOOL,
|
||||
TOOL_INTERFACE,
|
||||
FILES_BUTTON,
|
||||
FILE_SOURCES,
|
||||
WORKBENCH,
|
||||
VIEW_SWITCHER,
|
||||
VIEWER,
|
||||
PAGE_EDITOR,
|
||||
ACTIVE_FILES,
|
||||
FILE_CHECKBOX,
|
||||
SELECT_CONTROLS,
|
||||
CROP_SETTINGS,
|
||||
RUN_BUTTON,
|
||||
RESULTS,
|
||||
FILE_REPLACEMENT,
|
||||
PIN_BUTTON,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
function TourContent() {
|
||||
const { isOpen } = useOnboarding();
|
||||
const { setIsOpen, setCurrentStep } = useTour();
|
||||
const previousIsOpenRef = React.useRef(isOpen);
|
||||
|
||||
// Sync tour open state with context and reset to step 0 when reopening
|
||||
React.useEffect(() => {
|
||||
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
|
||||
previousIsOpenRef.current = isOpen;
|
||||
|
||||
if (wasClosedNowOpen) {
|
||||
// Tour is being opened (Help button pressed), reset to first step
|
||||
setCurrentStep(0);
|
||||
}
|
||||
setIsOpen(isOpen);
|
||||
}, [isOpen, setIsOpen, setCurrentStep]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function OnboardingTour() {
|
||||
const { t } = useTranslation();
|
||||
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour } = useOnboarding();
|
||||
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
restoreWorkbenchState,
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
} = useTourOrchestration();
|
||||
|
||||
// Define steps as object keyed by enum - TypeScript ensures all keys are present
|
||||
const stepsConfig: Record<TourStep, StepType> = {
|
||||
[TourStep.ALL_TOOLS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.allTools', 'This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => {
|
||||
saveWorkbenchState();
|
||||
closeFilesModal();
|
||||
backToAllTools();
|
||||
},
|
||||
},
|
||||
[TourStep.SELECT_CROP_TOOL]: {
|
||||
selector: '[data-tour="tool-button-crop"]',
|
||||
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => selectCropTool(),
|
||||
},
|
||||
[TourStep.TOOL_INTERFACE]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILES_BUTTON]: {
|
||||
selector: '[data-tour="files-button"]',
|
||||
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => openFilesModal(),
|
||||
},
|
||||
[TourStep.FILE_SOURCES]: {
|
||||
selector: '[data-tour="file-sources"]',
|
||||
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => {
|
||||
loadSampleFile();
|
||||
closeFilesModal();
|
||||
}
|
||||
},
|
||||
[TourStep.WORKBENCH]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEW_SWITCHER]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
|
||||
position: 'bottom',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEWER]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToViewer(),
|
||||
},
|
||||
[TourStep.PAGE_EDITOR]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToPageEditor(),
|
||||
},
|
||||
[TourStep.ACTIVE_FILES]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToActiveFiles(),
|
||||
},
|
||||
[TourStep.FILE_CHECKBOX]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.SELECT_CONTROLS]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
|
||||
position: 'left',
|
||||
padding: 5,
|
||||
action: () => selectFirstFile(),
|
||||
},
|
||||
[TourStep.CROP_SETTINGS]: {
|
||||
selector: '[data-tour="crop-settings"]',
|
||||
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => modifyCropSettings(),
|
||||
},
|
||||
[TourStep.RUN_BUTTON]: {
|
||||
selector: '[data-tour="run-button"]',
|
||||
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
actionAfter: () => executeTool(),
|
||||
},
|
||||
[TourStep.RESULTS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILE_REPLACEMENT]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.PIN_BUTTON]: {
|
||||
selector: '[data-tour="file-card-pin"]',
|
||||
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => pinFile(),
|
||||
},
|
||||
[TourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
},
|
||||
};
|
||||
|
||||
// Convert to array using enum's numeric ordering
|
||||
const steps = Object.values(stepsConfig);
|
||||
|
||||
const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: {
|
||||
setCurrentStep: (value: number | ((prev: number) => number)) => void;
|
||||
currentStep: number;
|
||||
steps?: StepType[];
|
||||
setIsOpen: (value: boolean) => void;
|
||||
}) => {
|
||||
if (steps && currentStep === steps.length - 1) {
|
||||
setIsOpen(false);
|
||||
restoreWorkbenchState();
|
||||
completeTour();
|
||||
} else if (steps) {
|
||||
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
|
||||
setIsOpen(false);
|
||||
restoreWorkbenchState();
|
||||
completeTour();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TourWelcomeModal
|
||||
opened={showWelcomeModal}
|
||||
onStartTour={() => {
|
||||
setShowWelcomeModal(false);
|
||||
startTour();
|
||||
}}
|
||||
onMaybeLater={() => {
|
||||
setShowWelcomeModal(false);
|
||||
}}
|
||||
onDontShowAgain={() => {
|
||||
setShowWelcomeModal(false);
|
||||
completeTour();
|
||||
}}
|
||||
/>
|
||||
<TourProvider
|
||||
steps={steps}
|
||||
onClickClose={handleCloseTour}
|
||||
onClickMask={advanceTour}
|
||||
onClickHighlighted={(e, clickProps) => {
|
||||
e.stopPropagation();
|
||||
advanceTour(clickProps);
|
||||
}}
|
||||
keyboardHandler={(e, clickProps, status) => {
|
||||
// Handle right arrow key to advance tour
|
||||
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
advanceTour(clickProps);
|
||||
}
|
||||
// Handle escape key to close tour
|
||||
else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
handleCloseTour(clickProps);
|
||||
}
|
||||
}}
|
||||
styles={{
|
||||
popover: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
maxWidth: '400px',
|
||||
}),
|
||||
maskArea: (base) => ({
|
||||
...base,
|
||||
rx: 8,
|
||||
}),
|
||||
badge: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-primary-color-filled)',
|
||||
}),
|
||||
controls: (base) => ({
|
||||
...base,
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
}}
|
||||
highlightedMaskClassName="tour-highlight-glow"
|
||||
showNavigation={true}
|
||||
showBadge={false}
|
||||
showCloseButton={true}
|
||||
disableInteraction={true}
|
||||
disableDotsNavigation={true}
|
||||
prevButton={() => null}
|
||||
nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
||||
const isLast = currentStep === stepsLength - 1;
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
advanceTour({ setCurrentStep, currentStep, steps, setIsOpen });
|
||||
}}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
|
||||
>
|
||||
{isLast ? <CheckIcon /> : <ArrowForwardIcon />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}}
|
||||
components={{
|
||||
Close: ({ onClick }) => (
|
||||
<CloseButton
|
||||
onClick={onClick}
|
||||
size="md"
|
||||
style={{ position: 'absolute', top: '8px', right: '8px' }}
|
||||
/>
|
||||
),
|
||||
Content: ({ content } : {content: string}) => (
|
||||
<div
|
||||
style={{ paddingRight: '16px' /* Ensure text doesn't overlap with close button */ }}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<TourContent />
|
||||
</TourProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Modal, Title, Text, Button, Stack, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
|
||||
|
||||
interface TourWelcomeModalProps {
|
||||
opened: boolean;
|
||||
onStartTour: () => void;
|
||||
onMaybeLater: () => void;
|
||||
onDontShowAgain: () => void;
|
||||
}
|
||||
|
||||
export default function TourWelcomeModal({
|
||||
opened,
|
||||
onStartTour,
|
||||
onMaybeLater,
|
||||
onDontShowAgain,
|
||||
}: TourWelcomeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onMaybeLater}
|
||||
centered
|
||||
size="md"
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Stack gap="xs">
|
||||
<Title order={2}>
|
||||
{t('onboarding.welcomeModal.title', 'Welcome to Stirling PDF!')}
|
||||
</Title>
|
||||
<Text size="md" c="dimmed">
|
||||
{t('onboarding.welcomeModal.description',
|
||||
"Would you like to take a quick 1-minute tour to learn the key features and how to get started?"
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
size="md"
|
||||
c="dimmed"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('onboarding.welcomeModal.helpHint',
|
||||
'You can always access this tour later from the <strong>Help</strong> button in the bottom left.'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
onClick={onStartTour}
|
||||
size="md"
|
||||
variant="filled"
|
||||
fullWidth
|
||||
>
|
||||
{t('onboarding.welcomeModal.startTour', 'Start Tour')}
|
||||
</Button>
|
||||
|
||||
<Group grow>
|
||||
<Button
|
||||
onClick={onMaybeLater}
|
||||
size="md"
|
||||
variant="light"
|
||||
>
|
||||
{t('onboarding.welcomeModal.maybeLater', 'Maybe Later')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onDontShowAgain}
|
||||
size="md"
|
||||
variant="light"
|
||||
>
|
||||
{t('onboarding.welcomeModal.dontShowAgain', "Don't Show Again")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -317,6 +317,7 @@ const FileThumbnail = ({
|
||||
>
|
||||
{file.thumbnail && (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={file.thumbnail}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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';
|
||||
@@ -23,6 +26,8 @@ import {
|
||||
import { GRID_CONSTANTS } from './constants';
|
||||
import { usePageDocument } from './hooks/usePageDocument';
|
||||
import { usePageEditorState } from './hooks/usePageEditorState';
|
||||
import { parseSelection } from "../../utils/bulkselection/parseSelection";
|
||||
import { usePageEditorRightRailButtons } from "./pageEditorRightRailButtons";
|
||||
|
||||
export interface PageEditorProps {
|
||||
onFunctionsReady?: (functions: PageEditorFunctions) => void;
|
||||
@@ -36,8 +41,12 @@ 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;
|
||||
const activeFilesSignature = selectors.getFilesSignature();
|
||||
|
||||
// UI state
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
@@ -59,6 +68,12 @@ const PageEditor = ({
|
||||
togglePage, toggleSelectAll, animateReorder
|
||||
} = usePageEditorState();
|
||||
|
||||
const [csvInput, setCsvInput] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setCsvInput('');
|
||||
}, [activeFilesSignature]);
|
||||
|
||||
// Grid container ref for positioning split indicators
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -82,6 +97,12 @@ 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;
|
||||
@@ -106,6 +127,8 @@ const PageEditor = ({
|
||||
|
||||
// Interface functions for parent component
|
||||
const displayDocument = editedDocument || mergedPdfDocument;
|
||||
const totalPages = displayDocument?.pages.length ?? 0;
|
||||
const selectedPageCount = selectedPageIds.length;
|
||||
|
||||
// Utility functions to convert between page IDs and page numbers
|
||||
const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => {
|
||||
@@ -138,17 +161,16 @@ const PageEditor = ({
|
||||
// DOM-first command handlers
|
||||
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
undoManagerRef.current.executeCommand(bulkRotateCommand);
|
||||
}, []);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
}, [executeCommandWithTracking]);
|
||||
|
||||
// Command factory functions for PageThumbnail
|
||||
const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({
|
||||
execute: () => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
|
||||
undoManagerRef.current.executeCommand(bulkRotateCommand);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
}
|
||||
}), []);
|
||||
}), [executeCommandWithTracking]);
|
||||
|
||||
const createDeleteCommand = useCallback((pageIds: string[]) => ({
|
||||
execute: () => {
|
||||
@@ -174,10 +196,10 @@ const PageEditor = ({
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
closePdf
|
||||
);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}
|
||||
}
|
||||
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
|
||||
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const createSplitCommand = useCallback((position: number) => ({
|
||||
execute: () => {
|
||||
@@ -186,9 +208,9 @@ const PageEditor = ({
|
||||
() => splitPositions,
|
||||
setSplitPositions
|
||||
);
|
||||
undoManagerRef.current.executeCommand(splitCommand);
|
||||
executeCommandWithTracking(splitCommand);
|
||||
}
|
||||
}), [splitPositions]);
|
||||
}), [splitPositions, executeCommandWithTracking]);
|
||||
|
||||
// Command executor for PageThumbnail
|
||||
const executeCommand = useCallback((command: any) => {
|
||||
@@ -232,8 +254,8 @@ const PageEditor = ({
|
||||
() => selectedPageNumbers,
|
||||
closePdf
|
||||
);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers]);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers, executeCommandWithTracking]);
|
||||
|
||||
const handleDeletePage = useCallback((pageNumber: number) => {
|
||||
if (!displayDocument) return;
|
||||
@@ -251,8 +273,8 @@ const PageEditor = ({
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
closePdf
|
||||
);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleSplit = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -298,8 +320,8 @@ const PageEditor = ({
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
|
||||
};
|
||||
|
||||
undoManagerRef.current.executeCommand(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleSplitAll = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -344,8 +366,8 @@ const PageEditor = ({
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
|
||||
};
|
||||
|
||||
undoManagerRef.current.executeCommand(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handlePageBreak = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -358,8 +380,8 @@ const PageEditor = ({
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
undoManagerRef.current.executeCommand(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handlePageBreakAll = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -372,8 +394,8 @@ const PageEditor = ({
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
undoManagerRef.current.executeCommand(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => {
|
||||
if (!displayDocument || files.length === 0) return;
|
||||
@@ -403,6 +425,12 @@ const PageEditor = ({
|
||||
setSelectedPageIds(pageIds);
|
||||
}, [getPageIdsFromNumbers, setSelectedPageIds]);
|
||||
|
||||
const updatePagesFromCSV = useCallback((override?: string) => {
|
||||
if (totalPages === 0) return;
|
||||
const normalized = parseSelection(override ?? csvInput, totalPages);
|
||||
handleSetSelectedPages(normalized);
|
||||
}, [csvInput, totalPages, handleSetSelectedPages]);
|
||||
|
||||
const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
@@ -416,8 +444,8 @@ const PageEditor = ({
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
undoManagerRef.current.executeCommand(reorderCommand);
|
||||
}, [displayDocument, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(reorderCommand);
|
||||
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
// Helper function to collect source files for multi-file export
|
||||
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
|
||||
@@ -499,13 +527,14 @@ 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]);
|
||||
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
|
||||
const onExportAll = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
@@ -514,87 +543,79 @@ const PageEditor = ({
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument, // Original order
|
||||
displayDocument, // Current display order (includes reordering)
|
||||
splitPositions // Position-based splits
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
|
||||
// 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 2: Export to files
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
|
||||
|
||||
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
|
||||
// Step 3: Download
|
||||
if (files.length > 1) {
|
||||
// Multiple files - create ZIP
|
||||
const JSZip = await import('jszip');
|
||||
const zip = new JSZip.default();
|
||||
|
||||
blobs.forEach((blob, index) => {
|
||||
zip.file(filenames[index], blob);
|
||||
files.forEach((file) => {
|
||||
zip.file(file.name, file);
|
||||
});
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip');
|
||||
const exportFilename = getExportFilename();
|
||||
const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip');
|
||||
|
||||
pdfExportService.downloadFile(zipBlob, zipFilename);
|
||||
} else {
|
||||
// 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);
|
||||
// Single file - download directly
|
||||
const file = files[0];
|
||||
pdfExportService.downloadFile(file, file.name);
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]);
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
|
||||
// Apply DOM changes to document state using dedicated service
|
||||
const applyChanges = useCallback(() => {
|
||||
const applyChanges = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
// 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
|
||||
);
|
||||
setExportLoading(true);
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
|
||||
// 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 2: Export to files
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
|
||||
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions]);
|
||||
// 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]);
|
||||
|
||||
|
||||
const closePdf = useCallback(() => {
|
||||
@@ -605,6 +626,23 @@ const PageEditor = ({
|
||||
setSelectionMode(false);
|
||||
}, [actions]);
|
||||
|
||||
usePageEditorRightRailButtons({
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPageIds,
|
||||
displayDocument: displayDocument || undefined,
|
||||
updatePagesFromCSV,
|
||||
handleSelectAll,
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
exportLoading,
|
||||
activeFileCount: activeFileIds.length,
|
||||
closePdf,
|
||||
});
|
||||
|
||||
// Export preview function - defined after export functions to avoid circular dependency
|
||||
const handleExportPreview = useCallback((selectedOnly: boolean = false) => {
|
||||
if (!displayDocument) return;
|
||||
@@ -658,7 +696,7 @@ const PageEditor = ({
|
||||
const displayedPages = displayDocument?.pages || [];
|
||||
|
||||
return (
|
||||
<Box pos="relative" h='100%' pt={40} style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
|
||||
|
||||
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
|
||||
@@ -779,7 +817,14 @@ const PageEditor = ({
|
||||
)}
|
||||
|
||||
|
||||
<NavigationWarningModal />
|
||||
<NavigationWarningModal
|
||||
onApplyAndContinue={async () => {
|
||||
await applyChanges();
|
||||
}}
|
||||
onExportAndContinue={async () => {
|
||||
await onExportAll();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import BulkSelectionPanel from './BulkSelectionPanel';
|
||||
|
||||
interface PageSelectByNumberButtonProps {
|
||||
disabled: boolean;
|
||||
totalPages: number;
|
||||
label: string;
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
updatePagesFromCSV: (override?: string) => void;
|
||||
}
|
||||
|
||||
export default function PageSelectByNumberButton({
|
||||
disabled,
|
||||
totalPages,
|
||||
label,
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
updatePagesFromCSV,
|
||||
}: PageSelectByNumberButtonProps) {
|
||||
return (
|
||||
<Tooltip content={label} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<div className={`right-rail-fade enter`}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={disabled || totalPages === 0}
|
||||
aria-label={label}
|
||||
>
|
||||
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selectedPageIds}
|
||||
displayDocument={displayDocument}
|
||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core';
|
||||
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Text, Checkbox } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||
@@ -12,6 +13,7 @@ import { PDFPage, PDFDocument } from '../../types/pageEditor';
|
||||
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import styles from './PageEditor.module.css';
|
||||
import HoverActionMenu, { HoverAction } from '../shared/HoverActionMenu';
|
||||
|
||||
|
||||
interface PageThumbnailProps {
|
||||
@@ -65,6 +67,8 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||
@@ -270,8 +274,75 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsMouseDown(false);
|
||||
setMouseStartPos(null);
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
// Build hover menu actions
|
||||
const hoverActions = useMemo<HoverAction[]>(() => [
|
||||
{
|
||||
id: 'move-left',
|
||||
icon: <ArrowBackIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Move Left',
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (index > 0 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
onReorderPages(page.pageNumber, index - 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||
}
|
||||
},
|
||||
disabled: index === 0
|
||||
},
|
||||
{
|
||||
id: 'move-right',
|
||||
icon: <ArrowForwardIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Move Right',
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
onReorderPages(page.pageNumber, index + 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||
}
|
||||
},
|
||||
disabled: index === totalPages - 1
|
||||
},
|
||||
{
|
||||
id: 'rotate-left',
|
||||
icon: <RotateLeftIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Rotate Left',
|
||||
onClick: handleRotateLeft,
|
||||
},
|
||||
{
|
||||
id: 'rotate-right',
|
||||
icon: <RotateRightIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Rotate Right',
|
||||
onClick: handleRotateRight,
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
icon: <DeleteIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Delete Page',
|
||||
onClick: handleDelete,
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
id: 'split',
|
||||
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Split After',
|
||||
onClick: handleSplit,
|
||||
hidden: index >= totalPages - 1,
|
||||
},
|
||||
{
|
||||
id: 'insert',
|
||||
icon: <AddIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Insert File After',
|
||||
onClick: handleInsertFileAfter,
|
||||
}
|
||||
], [index, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageElementRef}
|
||||
@@ -302,6 +373,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
draggable={false}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{
|
||||
@@ -371,9 +443,11 @@ 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%',
|
||||
@@ -411,128 +485,12 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
{page.pageNumber}
|
||||
</Text>
|
||||
|
||||
<div
|
||||
<HoverActionMenu
|
||||
show={isHovered || isMobile}
|
||||
actions={hoverActions}
|
||||
position="inside"
|
||||
className={styles.pageHoverControls}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: 'var(--bg-toolbar)',
|
||||
border: '1px solid var(--border-default)',
|
||||
padding: '6px 12px',
|
||||
borderRadius: 20,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
zIndex: 3,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip label="Move Left">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
disabled={index === 0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (index > 0 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
// Actually move the page left (swap with previous page)
|
||||
onReorderPages(page.pageNumber, index - 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowBackIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Move Right">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
disabled={index === totalPages - 1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
// Actually move the page right (swap with next page)
|
||||
onReorderPages(page.pageNumber, index + 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowForwardIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Rotate Left">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
onClick={handleRotateLeft}
|
||||
>
|
||||
<RotateLeftIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Rotate Right">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
onClick={handleRotateRight}
|
||||
>
|
||||
<RotateRightIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Delete Page">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="red"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{index < totalPages - 1 && (
|
||||
<Tooltip label="Split After">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
onClick={handleSplit}
|
||||
>
|
||||
<ContentCutIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip label="Insert File After">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
onClick={handleInsertFileAfter}
|
||||
>
|
||||
<AddIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,32 +17,34 @@ 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;
|
||||
const newRotation = currentRotation + this.degrees;
|
||||
let newRotation = currentRotation + this.degrees;
|
||||
|
||||
newRotation = ((newRotation % 360) + 360) % 360;
|
||||
|
||||
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;
|
||||
const previousRotation = currentRotation - this.degrees;
|
||||
let previousRotation = currentRotation - this.degrees;
|
||||
|
||||
previousRotation = ((previousRotation % 360) + 360) % 360;
|
||||
|
||||
img.style.transform = `rotate(${previousRotation}deg)`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
const primaryFileId = activeFileIds[0] ?? null;
|
||||
|
||||
// Stable signature for effects (prevents loops)
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
const activeFilesSignature = selectors.getFilesSignature();
|
||||
|
||||
// UI state
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
@@ -156,7 +156,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
};
|
||||
|
||||
return mergedDoc;
|
||||
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
|
||||
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature]);
|
||||
|
||||
// Large document detection for smart loading
|
||||
const isVeryLargeDocument = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRightRailButtons, RightRailButtonWithAction } from '../../hooks/useRightRailButtons';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
import PageSelectByNumberButton from './PageSelectByNumberButton';
|
||||
|
||||
interface PageEditorRightRailButtonsParams {
|
||||
totalPages: number;
|
||||
selectedPageCount: number;
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
updatePagesFromCSV: (override?: string) => void;
|
||||
handleSelectAll: () => void;
|
||||
handleDeselectAll: () => void;
|
||||
handleDelete: () => void;
|
||||
onExportSelected: () => void;
|
||||
exportLoading: boolean;
|
||||
activeFileCount: number;
|
||||
closePdf: () => void;
|
||||
}
|
||||
|
||||
export function usePageEditorRightRailButtons(params: PageEditorRightRailButtonsParams) {
|
||||
const {
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
updatePagesFromCSV,
|
||||
handleSelectAll,
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
} = params;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Lift i18n labels out of memo for clarity
|
||||
const selectAllLabel = t('rightRail.selectAll', 'Select All');
|
||||
const deselectAllLabel = t('rightRail.deselectAll', 'Deselect All');
|
||||
const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers');
|
||||
const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages');
|
||||
const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages');
|
||||
const closePdfLabel = t('rightRail.closePdf', 'Close PDF');
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'page-select-all',
|
||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: selectAllLabel,
|
||||
ariaLabel: selectAllLabel,
|
||||
section: 'top' as const,
|
||||
order: 10,
|
||||
disabled: totalPages === 0 || selectedPageCount === totalPages,
|
||||
visible: totalPages > 0,
|
||||
onClick: handleSelectAll,
|
||||
},
|
||||
{
|
||||
id: 'page-deselect-all',
|
||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: deselectAllLabel,
|
||||
ariaLabel: deselectAllLabel,
|
||||
section: 'top' as const,
|
||||
order: 20,
|
||||
disabled: selectedPageCount === 0,
|
||||
visible: totalPages > 0,
|
||||
onClick: handleDeselectAll,
|
||||
},
|
||||
{
|
||||
id: 'page-select-by-number',
|
||||
tooltip: selectByNumberLabel,
|
||||
ariaLabel: selectByNumberLabel,
|
||||
section: 'top' as const,
|
||||
order: 30,
|
||||
disabled: totalPages === 0,
|
||||
visible: totalPages > 0,
|
||||
render: ({ disabled }) => (
|
||||
<PageSelectByNumberButton
|
||||
disabled={disabled}
|
||||
totalPages={totalPages}
|
||||
label={selectByNumberLabel}
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selectedPageIds}
|
||||
displayDocument={displayDocument}
|
||||
updatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'page-delete-selected',
|
||||
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: deleteSelectedLabel,
|
||||
ariaLabel: deleteSelectedLabel,
|
||||
section: 'top' as const,
|
||||
order: 40,
|
||||
disabled: selectedPageCount === 0,
|
||||
visible: totalPages > 0,
|
||||
onClick: handleDelete,
|
||||
},
|
||||
{
|
||||
id: 'page-export-selected',
|
||||
icon: <LocalIcon icon="download" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: exportSelectedLabel,
|
||||
ariaLabel: exportSelectedLabel,
|
||||
section: 'top' as const,
|
||||
order: 50,
|
||||
disabled: selectedPageCount === 0 || exportLoading,
|
||||
visible: totalPages > 0,
|
||||
onClick: onExportSelected,
|
||||
},
|
||||
{
|
||||
id: 'page-close-pdf',
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: closePdfLabel,
|
||||
ariaLabel: closePdfLabel,
|
||||
section: 'top' as const,
|
||||
order: 60,
|
||||
disabled: activeFileCount === 0,
|
||||
visible: activeFileCount > 0,
|
||||
onClick: closePdf,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
t,
|
||||
selectAllLabel,
|
||||
deselectAllLabel,
|
||||
selectByNumberLabel,
|
||||
deleteSelectedLabel,
|
||||
exportSelectedLabel,
|
||||
closePdfLabel,
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
updatePagesFromCSV,
|
||||
handleSelectAll,
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
]);
|
||||
|
||||
useRightRailButtons(buttons);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* 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,6 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Modal, Button, Stack, Text, Code, ScrollArea, Group, Badge, Alert, Loader } from '@mantine/core';
|
||||
import { useAppConfig } from '../../hooks/useAppConfig';
|
||||
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';
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
@@ -8,133 +14,145 @@ interface AppConfigModalProps {
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const { config, loading, error, refetch } = useAppConfig();
|
||||
const [active, setActive] = useState<NavKey>('overview');
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
|
||||
const renderConfigSection = (title: string, data: any) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
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);
|
||||
}, []);
|
||||
|
||||
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 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');
|
||||
};
|
||||
|
||||
const basicConfig = config ? {
|
||||
appName: config.appName,
|
||||
appNameNavbar: config.appNameNavbar,
|
||||
baseUrl: config.baseUrl,
|
||||
contextPath: config.contextPath,
|
||||
serverPort: config.serverPort,
|
||||
} : null;
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useMemo(() =>
|
||||
createConfigNavSections(
|
||||
Overview,
|
||||
handleLogout
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const securityConfig = config ? {
|
||||
enableLogin: config.enableLogin,
|
||||
} : 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 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;
|
||||
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]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="App Configuration (Testing)"
|
||||
size="lg"
|
||||
style={{ zIndex: 1000 }}
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
{/* 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>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppConfigModal;
|
||||
export default AppConfigModal;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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;
|
||||
@@ -51,6 +51,7 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onSelect}
|
||||
data-testid="file-card"
|
||||
data-tour="file-card-checkbox"
|
||||
>
|
||||
<Stack gap={6} align="center">
|
||||
<Box
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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 React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
@@ -125,6 +125,7 @@ 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 React, { useRef } from "react";
|
||||
import { 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,5 +1,4 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCookieConsent } from '../../hooks/useCookieConsent';
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/* Base Hover Menu */
|
||||
.hoverMenu {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
background: var(--bg-toolbar);
|
||||
border: 1px solid var(--border-default);
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 30;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Inside positioning (Page Editor style) - within container */
|
||||
.inside {
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
/* Outside positioning (File Editor style) - below container */
|
||||
.outside {
|
||||
bottom: -8px;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { ActionIcon, Tooltip } from '@mantine/core';
|
||||
import styles from './HoverActionMenu.module.css';
|
||||
|
||||
export interface HoverAction {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
color?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
interface HoverActionMenuProps {
|
||||
show: boolean;
|
||||
actions: HoverAction[];
|
||||
position?: 'inside' | 'outside';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
show,
|
||||
actions,
|
||||
position = 'inside',
|
||||
className = ''
|
||||
}) => {
|
||||
const visibleActions = actions.filter(action => !action.hidden);
|
||||
|
||||
if (visibleActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.hoverMenu} ${position === 'outside' ? styles.outside : styles.inside} ${className}`}
|
||||
style={{ opacity: show ? 1 : 0 }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{visibleActions.map((action) => (
|
||||
<Tooltip key={action.id} label={action.label}>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: action.color || 'var(--mantine-color-dimmed)' }}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
c={action.color}
|
||||
>
|
||||
{action.icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoverActionMenu;
|
||||
@@ -41,7 +41,6 @@ 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={{
|
||||
@@ -178,7 +177,6 @@ const LandingPage = () => {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.zip"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -209,6 +210,7 @@ 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,
|
||||
@@ -264,13 +266,15 @@ 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) => {
|
||||
const isEnglishGB = option.value === 'en-GB'; // Currently only English GB has enough translations to use
|
||||
const isDisabled = !isEnglishGB;
|
||||
// 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);
|
||||
|
||||
return (
|
||||
<LanguageItem
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { Box, Group, Text, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -37,7 +36,7 @@ const MultiSelectControls = ({
|
||||
>
|
||||
{t("fileManager.clearSelection", "Clear Selection")}
|
||||
</Button>
|
||||
|
||||
|
||||
{onAddToUpload && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -47,7 +46,7 @@ const MultiSelectControls = ({
|
||||
{t("fileManager.addToUpload", "Add to Upload")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{onOpenInFileEditor && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -58,7 +57,7 @@ const MultiSelectControls = ({
|
||||
{t("fileManager.openInFileEditor", "Open in File Editor")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{onOpenInPageEditor && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -69,7 +68,7 @@ const MultiSelectControls = ({
|
||||
{t("fileManager.openInPageEditor", "Open in Page Editor")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{onDeleteAll && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -85,4 +84,4 @@ const MultiSelectControls = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiSelectControls;
|
||||
export default MultiSelectControls;
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||
import { useNavigationGuard } from '../../contexts/NavigationContext';
|
||||
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";
|
||||
|
||||
interface NavigationWarningModalProps {
|
||||
onApplyAndContinue?: () => Promise<void>;
|
||||
onExportAndContinue?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const NavigationWarningModal = ({
|
||||
onApplyAndContinue,
|
||||
onExportAndContinue
|
||||
}: NavigationWarningModalProps) => {
|
||||
const {
|
||||
showNavigationWarning,
|
||||
hasUnsavedChanges,
|
||||
cancelNavigation,
|
||||
confirmNavigation,
|
||||
setHasUnsavedChanges
|
||||
} = useNavigationGuard();
|
||||
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
|
||||
useNavigationGuard();
|
||||
|
||||
const handleKeepWorking = () => {
|
||||
cancelNavigation();
|
||||
@@ -36,13 +32,14 @@ const NavigationWarningModal = ({
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const handleExportAndContinue = async () => {
|
||||
const _handleExportAndContinue = async () => {
|
||||
if (onExportAndContinue) {
|
||||
await onExportAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
const BUTTON_WIDTH = "10rem";
|
||||
|
||||
if (!hasUnsavedChanges) {
|
||||
return null;
|
||||
@@ -52,55 +49,58 @@ const NavigationWarningModal = ({
|
||||
<Modal
|
||||
opened={showNavigationWarning}
|
||||
onClose={handleKeepWorking}
|
||||
title="Unsaved Changes"
|
||||
title={t("unsavedChangesTitle", "Unsaved Changes")}
|
||||
centered
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
size="auto"
|
||||
closeOnClickOutside={true}
|
||||
closeOnEscape={true}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
You have unsaved changes to your PDF. What would you like to do?
|
||||
<Stack>
|
||||
<Stack ta="center" p="md">
|
||||
<Text size="md" fw="300">
|
||||
{t("unsavedChanges", "You have unsaved changes to your PDF.")}
|
||||
</Text>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={handleKeepWorking}
|
||||
>
|
||||
Keep Working
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={handleDiscardChanges}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
|
||||
{onApplyAndContinue && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={handleApplyAndContinue}
|
||||
>
|
||||
Apply & Continue
|
||||
<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>
|
||||
)}
|
||||
|
||||
{onExportAndContinue && (
|
||||
<Button
|
||||
color="green"
|
||||
onClick={handleExportAndContinue}
|
||||
>
|
||||
Export & Continue
|
||||
</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")}
|
||||
</Button>
|
||||
<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>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationWarningModal;
|
||||
export default NavigationWarningModal;
|
||||
|
||||
@@ -12,6 +12,9 @@ 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 { useOnboarding } from '../../contexts/OnboardingContext';
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
@@ -22,8 +25,10 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
const { config } = useAppConfig();
|
||||
const { startTour } = useOnboarding();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
@@ -41,10 +46,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) => {
|
||||
@@ -57,15 +62,21 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
|
||||
// Render navigation button with conditional URL support
|
||||
return (
|
||||
<div key={config.id} className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
|
||||
<div
|
||||
key={config.id}
|
||||
className="flex flex-col items-center gap-1"
|
||||
style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}
|
||||
data-tour={`${config.id}-button`}
|
||||
>
|
||||
<ActionIcon
|
||||
{...(navProps ? {
|
||||
{...(navProps ? {
|
||||
component: "a" as const,
|
||||
href: navProps.href,
|
||||
onClick: (e: React.MouseEvent) => handleClick(e),
|
||||
'aria-label': config.name
|
||||
} : {
|
||||
onClick: () => handleClick()
|
||||
onClick: () => handleClick(),
|
||||
'aria-label': config.name
|
||||
})}
|
||||
size={isActive ? (config.size || 'lg') : 'lg'}
|
||||
variant="subtle"
|
||||
@@ -84,8 +95,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const buttonConfigs: ButtonConfig[] = [
|
||||
const mainButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'read',
|
||||
name: t("quickAccess.read", "Read"),
|
||||
@@ -95,12 +105,10 @@ 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" />,
|
||||
@@ -111,7 +119,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
// setActiveButton('sign');
|
||||
// handleToolSelect('sign');
|
||||
// }
|
||||
//},
|
||||
// },
|
||||
{
|
||||
id: 'automate',
|
||||
name: t("quickAccess.automate", "Automate"),
|
||||
@@ -129,6 +137,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const middleButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'files',
|
||||
name: t("quickAccess.files", "Files"),
|
||||
@@ -148,10 +159,24 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
// type: 'navigation',
|
||||
// onClick: () => setActiveButton('activity')
|
||||
//},
|
||||
];
|
||||
|
||||
const bottomButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'help',
|
||||
name: t("quickAccess.help", "Help"),
|
||||
icon: <LocalIcon icon="help-rounded" width="1.5rem" height="1.5rem" />,
|
||||
isRound: true,
|
||||
size: 'lg',
|
||||
type: 'action',
|
||||
onClick: () => {
|
||||
startTour();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
name: t("quickAccess.config", "Config"),
|
||||
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
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" />,
|
||||
size: 'lg',
|
||||
type: 'modal',
|
||||
onClick: () => {
|
||||
@@ -160,8 +185,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -196,57 +219,52 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
}}
|
||||
>
|
||||
<div className="scrollable-content">
|
||||
{/* Top section with main buttons */}
|
||||
{/* Main navigation section */}
|
||||
<Stack gap="lg" align="center">
|
||||
{buttonConfigs.slice(0, -1).map((config, index) => (
|
||||
{mainButtons.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
|
||||
{/* Add divider after Automate button (index 1) and Files button (index 2) */}
|
||||
{index === 1 && (
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Spacer to push Config button to bottom */}
|
||||
{/* Divider after main buttons */}
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
/>
|
||||
|
||||
{/* Middle section */}
|
||||
<Stack gap="lg" align="center">
|
||||
{middleButtons.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Spacer to push bottom buttons to bottom */}
|
||||
<div className="spacer" />
|
||||
|
||||
{/* Config button at the bottom */}
|
||||
{/* {buttonConfigs
|
||||
.filter(config => config.id === 'config')
|
||||
.map(config => (
|
||||
<div key={config.id} className="flex flex-col items-center gap-1">
|
||||
<ActionIcon
|
||||
size={config.size || 'lg'}
|
||||
variant="subtle"
|
||||
onClick={config.onClick}
|
||||
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
|
||||
className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''}
|
||||
data-testid={`${config.id}-button`}
|
||||
>
|
||||
<span className="iconContainer">
|
||||
{config.icon}
|
||||
</span>
|
||||
</ActionIcon>
|
||||
<span className={`button-text ${isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'active' : 'inactive'}`}>
|
||||
{config.name}
|
||||
</span>
|
||||
</div>
|
||||
))} */}
|
||||
{/* Bottom section */}
|
||||
<Stack gap="lg" align="center">
|
||||
{bottomButtons.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <AppConfigModal
|
||||
<AppConfigModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/> */}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
QuickAccessBar.displayName = 'QuickAccessBar';
|
||||
|
||||
export default QuickAccessBar;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { createContext, useContext, ReactNode } from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
|
||||
import { mantineTheme } from '../../theme/mantineTheme';
|
||||
@@ -6,9 +6,10 @@ import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import { ToastProvider } from '../toast';
|
||||
import ToastRenderer from '../toast/ToastRenderer';
|
||||
import { ToastPortalBinder } from '../toast';
|
||||
import type { ThemeMode } from '../../constants/theme';
|
||||
|
||||
interface RainbowThemeContextType {
|
||||
themeMode: 'light' | 'dark' | 'rainbow';
|
||||
themeMode: ThemeMode;
|
||||
isRainbowMode: boolean;
|
||||
isToggleDisabled: boolean;
|
||||
toggleTheme: () => void;
|
||||
|
||||
@@ -1,433 +1,193 @@
|
||||
import React, { useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import { ActionIcon, Divider, Popover } from '@mantine/core';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { ActionIcon, Divider } from '@mantine/core';
|
||||
import './rightRail/RightRail.css';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useRightRail } from '../../contexts/RightRailContext';
|
||||
import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext';
|
||||
import { useFileState, useFileSelection } from '../../contexts/FileContext';
|
||||
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import LanguageSelector from '../shared/LanguageSelector';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
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 LocalIcon from './LocalIcon';
|
||||
|
||||
import { parseSelection } from '../../utils/bulkselection/parseSelection';
|
||||
import { useSidebarContext } from '../../contexts/SidebarContext';
|
||||
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '../../types/rightRail';
|
||||
|
||||
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
|
||||
|
||||
export default function RightRail() {
|
||||
const { t } = useTranslation();
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
function renderWithTooltip(
|
||||
node: React.ReactNode,
|
||||
tooltip: React.ReactNode | undefined
|
||||
) {
|
||||
if (!tooltip) return node;
|
||||
|
||||
// Viewer context for PDF controls - safely handle when not available
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
const { toggleTheme } = useRainbowThemeContext();
|
||||
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 } = useToolWorkflow();
|
||||
|
||||
// CSV input state for page selection
|
||||
const [csvInput, setCsvInput] = useState<string>("");
|
||||
|
||||
// Navigation view
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
|
||||
// File state and selection
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||
const { removeFiles } = useFileManagement();
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
|
||||
// Compute selection state and total items
|
||||
const getSelectionState = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
const totalItems = activeFiles.length;
|
||||
const selectedCount = selectedFileIds.length;
|
||||
return { totalItems, selectedCount };
|
||||
}
|
||||
|
||||
if (currentView === 'pageEditor') {
|
||||
// Use PageEditor's own state
|
||||
const totalItems = pageEditorFunctions?.totalPages || 0;
|
||||
const selectedCount = pageEditorFunctions?.selectedPageIds?.length || 0;
|
||||
return { totalItems, selectedCount };
|
||||
}
|
||||
|
||||
return { totalItems: 0, selectedCount: 0 };
|
||||
}, [currentView, activeFiles, selectedFileIds, pageEditorFunctions]);
|
||||
|
||||
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
|
||||
const allIds = state.files.ids;
|
||||
setSelectedFiles(allIds);
|
||||
// Clear any previous error flags when selecting all
|
||||
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentView === 'pageEditor') {
|
||||
// Use PageEditor's select all function
|
||||
pageEditorFunctions?.handleSelectAll?.();
|
||||
}
|
||||
}, [currentView, state.files.ids, setSelectedFiles, pageEditorFunctions]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
setSelectedFiles([]);
|
||||
// Clear any previous error flags when deselecting all
|
||||
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
|
||||
return;
|
||||
}
|
||||
if (currentView === 'pageEditor') {
|
||||
// Use PageEditor's deselect all function
|
||||
pageEditorFunctions?.handleDeselectAll?.();
|
||||
}
|
||||
}, [currentView, setSelectedFiles, pageEditorFunctions]);
|
||||
|
||||
const handleExportAll = useCallback(() => {
|
||||
if (currentView === 'viewer') {
|
||||
// Use EmbedPDF export functionality for viewer mode
|
||||
viewerContext?.exportActions?.download();
|
||||
} else if (currentView === 'fileEditor') {
|
||||
// Download selected files (or all if none selected)
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
|
||||
filesToDownload.forEach(file => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
});
|
||||
} else if (currentView === 'pageEditor') {
|
||||
// Export all pages (not just selected)
|
||||
pageEditorFunctions?.onExportAll?.();
|
||||
}
|
||||
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext]);
|
||||
|
||||
const handleCloseSelected = useCallback(() => {
|
||||
if (currentView !== 'fileEditor') return;
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
// Close only selected files (do not delete from storage)
|
||||
removeFiles(selectedFileIds, false);
|
||||
|
||||
// Clear selection after closing
|
||||
setSelectedFiles([]);
|
||||
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
|
||||
|
||||
const updatePagesFromCSV = useCallback((override?: string) => {
|
||||
const maxPages = pageEditorFunctions?.totalPages || 0;
|
||||
const normalized = parseSelection(override ?? csvInput, maxPages);
|
||||
pageEditorFunctions?.handleSetSelectedPages?.(normalized);
|
||||
}, [csvInput, pageEditorFunctions]);
|
||||
|
||||
// Do not overwrite user's expression input when selection changes.
|
||||
|
||||
// Clear CSV input when files change (use stable signature to avoid ref churn)
|
||||
useEffect(() => {
|
||||
setCsvInput("");
|
||||
}, [filesSignature]);
|
||||
|
||||
// Mount/visibility for page-editor-only buttons to allow exit animation, then remove to avoid flex gap
|
||||
const [pageControlsMounted, setPageControlsMounted] = useState<boolean>(currentView === 'pageEditor');
|
||||
const [pageControlsVisible, setPageControlsVisible] = useState<boolean>(currentView === 'pageEditor');
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView === 'pageEditor') {
|
||||
// Mount and show
|
||||
setPageControlsMounted(true);
|
||||
// Next tick to ensure transition applies
|
||||
requestAnimationFrame(() => setPageControlsVisible(true));
|
||||
} else {
|
||||
// Start exit animation
|
||||
setPageControlsVisible(false);
|
||||
// After transition, unmount to remove flex gap
|
||||
const timer = setTimeout(() => setPageControlsMounted(false), 240);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [currentView]);
|
||||
const portalTarget = typeof document !== 'undefined' ? document.body : undefined;
|
||||
|
||||
return (
|
||||
<div className="right-rail">
|
||||
<Tooltip content={tooltip} position="left" offset={12} arrow portalTarget={portalTarget}>
|
||||
<div className="right-rail-tooltip-wrapper">{node}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightRail() {
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { t } = useTranslation();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
const { toggleTheme } = useRainbowThemeContext();
|
||||
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
||||
|
||||
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
|
||||
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
|
||||
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
|
||||
const { selectors } = useFileState();
|
||||
const { selectedFiles, selectedFileIds } = useFileSelection();
|
||||
const { signaturesApplied } = useSignature();
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
|
||||
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
|
||||
const exportState = viewerContext?.getExportState?.();
|
||||
|
||||
const totalItems = useMemo(() => {
|
||||
if (currentView === 'pageEditor') return pageEditorTotalPages;
|
||||
return activeFiles.length;
|
||||
}, [currentView, pageEditorTotalPages, activeFiles.length]);
|
||||
|
||||
const selectedCount = useMemo(() => {
|
||||
if (currentView === 'pageEditor') {
|
||||
return pageEditorSelectedCount;
|
||||
}
|
||||
return selectedFileIds.length;
|
||||
}, [currentView, pageEditorSelectedCount, selectedFileIds.length]);
|
||||
|
||||
const sectionsWithButtons = useMemo(() => {
|
||||
return SECTION_ORDER
|
||||
.map(section => {
|
||||
const sectionButtons = buttons.filter(btn => (btn.section ?? 'top') === section && (btn.visible ?? true));
|
||||
return { section, buttons: sectionButtons };
|
||||
})
|
||||
.filter(entry => entry.buttons.length > 0);
|
||||
}, [buttons]);
|
||||
|
||||
const renderButton = useCallback(
|
||||
(btn: RightRailButtonConfig) => {
|
||||
const action = actions[btn.id];
|
||||
const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen);
|
||||
|
||||
const triggerAction = () => {
|
||||
if (!disabled) action?.();
|
||||
};
|
||||
|
||||
if (btn.render) {
|
||||
const context: RightRailRenderContext = {
|
||||
id: btn.id,
|
||||
disabled,
|
||||
allButtonsDisabled,
|
||||
action,
|
||||
triggerAction,
|
||||
};
|
||||
return btn.render(context) ?? null;
|
||||
}
|
||||
|
||||
if (!btn.icon) return null;
|
||||
|
||||
const ariaLabel =
|
||||
btn.ariaLabel || (typeof btn.tooltip === 'string' ? (btn.tooltip as string) : undefined);
|
||||
const className = ['right-rail-icon', btn.className].filter(Boolean).join(' ');
|
||||
const buttonNode = (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className={className}
|
||||
onClick={triggerAction}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{btn.icon}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
return renderWithTooltip(buttonNode, btn.tooltip);
|
||||
},
|
||||
[actions, allButtonsDisabled, disableForFullscreen]
|
||||
);
|
||||
|
||||
const handleExportAll = useCallback(async () => {
|
||||
if (currentView === 'viewer') {
|
||||
if (!signaturesApplied) {
|
||||
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
|
||||
return;
|
||||
}
|
||||
viewerContext?.exportActions?.download();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentView === 'pageEditor') {
|
||||
pageEditorFunctions?.onExportAll?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
filesToDownload.forEach(file => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
});
|
||||
}, [
|
||||
currentView,
|
||||
selectedFiles,
|
||||
activeFiles,
|
||||
pageEditorFunctions,
|
||||
viewerContext,
|
||||
signaturesApplied
|
||||
]);
|
||||
|
||||
const downloadTooltip = useMemo(() => {
|
||||
if (currentView === 'pageEditor') {
|
||||
return t('rightRail.exportAll', 'Export PDF');
|
||||
}
|
||||
if (selectedCount > 0) {
|
||||
return t('rightRail.downloadSelected', 'Download Selected Files');
|
||||
}
|
||||
return t('rightRail.downloadAll', 'Download All');
|
||||
}, [currentView, selectedCount, t]);
|
||||
|
||||
return (
|
||||
<div ref={sidebarRefs.rightRailRef} className="right-rail" data-sidebar="right-rail">
|
||||
<div className="right-rail-inner">
|
||||
{topButtons.length > 0 && (
|
||||
<>
|
||||
<div className="right-rail-section">
|
||||
{topButtons.map(btn => (
|
||||
<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}
|
||||
{sectionsWithButtons.map(({ section, buttons: sectionButtons }) => (
|
||||
<React.Fragment key={section}>
|
||||
<div className="right-rail-section" data-tour="right-rail-controls">
|
||||
{sectionButtons.map((btn, index) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div
|
||||
key={btn.id}
|
||||
className="right-rail-button-wrapper"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{btn.icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
))}
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Group: PDF Viewer Controls - visible only in viewer mode */}
|
||||
<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>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={currentView !== 'viewer'}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.search', 'Search PDF') : 'Search PDF'}
|
||||
>
|
||||
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '20rem' }}>
|
||||
<SearchInterface
|
||||
visible={true}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
{/* Pan Mode */}
|
||||
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant={isPanning ? "filled" : "subtle"}
|
||||
color={isPanning ? "blue" : undefined}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.panActions.togglePan();
|
||||
setIsPanning(!isPanning);
|
||||
}}
|
||||
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>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.rotationActions.rotateBackward();
|
||||
}}
|
||||
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>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.rotationActions.rotateForward();
|
||||
}}
|
||||
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>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleThumbnailSidebar();
|
||||
}}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
|
||||
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
||||
<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>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleSelectAll}
|
||||
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems}
|
||||
>
|
||||
<LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Deselect All Button */}
|
||||
<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}
|
||||
>
|
||||
<LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Select by Numbers - page editor only, with animated presence */}
|
||||
{pageControlsMounted && (
|
||||
<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}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
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" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
|
||||
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={Array.isArray(pageEditorFunctions?.selectedPageIds) ? pageEditorFunctions.selectedPageIds : []}
|
||||
displayDocument={pageEditorFunctions?.displayDocument}
|
||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
)}
|
||||
|
||||
{/* Delete Selected Pages - page editor only, with animated presence */}
|
||||
{pageControlsMounted && (
|
||||
<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' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => { pageEditorFunctions?.handleDelete?.(); }}
|
||||
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" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
)}
|
||||
|
||||
{/* Export Selected Pages - page editor only */}
|
||||
{pageControlsMounted && (
|
||||
<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
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => { pageEditorFunctions?.onExportSelected?.(); }}
|
||||
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" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={currentView === 'pageEditor' ? () => pageEditorFunctions?.closePdf?.() : handleCloseSelected}
|
||||
disabled={
|
||||
currentView === 'viewer' ||
|
||||
(currentView === 'fileEditor' && selectedCount === 0) ||
|
||||
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf))
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }} data-tour="right-rail-settings">
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
@@ -435,30 +195,32 @@ export default function RightRail() {
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon>,
|
||||
t('rightRail.toggleTheme', 'Toggle Theme')
|
||||
)}
|
||||
|
||||
<LanguageSelector position="left-start" offset={6} compact />
|
||||
{renderWithTooltip(
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<LanguageSelector position="left-start" offset={6} compact />
|
||||
</div>,
|
||||
t('rightRail.language', 'Language')
|
||||
)}
|
||||
|
||||
<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>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleExportAll}
|
||||
disabled={
|
||||
currentView === 'viewer' ? !exportState?.canExport : totalItems === 0
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleExportAll}
|
||||
disabled={
|
||||
disableForFullscreen ||
|
||||
(currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>,
|
||||
downloadTooltip
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="right-rail-spacer" />
|
||||
@@ -466,5 +228,3 @@ export default function RightRail() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -107,3 +107,5 @@ 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' ? (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user