Compare commits

..
Author SHA1 Message Date
Anthony Stirling d765985952 cleanup and move files 2025-09-25 10:19:45 +01:00
Anthony StirlingandGitHub 08b78e7409 Merge branch 'V2' into lang_translation 2025-09-25 09:27:30 +01:00
Anthony Stirling 94c230a5a2 clean 2025-09-25 09:26:24 +01:00
Anthony Stirling 920bd74d64 langs 2025-09-25 08:58:27 +01:00
Anthony Stirling 826ff580db hook.js:608 <Router basename="/."> is not able to match the URL 2025-09-24 20:02:19 +01:00
Anthony Stirling ea78f78760 empty path fix 2025-09-24 19:55:13 +01:00
Anthony Stirling 40fd5bb808 typeerror fml 2025-09-24 19:46:30 +01:00
Anthony Stirling a5356b3832 path 2025-09-24 19:26:57 +01:00
329 changed files with 5223 additions and 33314 deletions
-345
View File
@@ -1,345 +0,0 @@
"""
Author: Ludy87
Description: This script processes JSON translation files for localization checks. It compares translation files in a branch with
a reference file to ensure consistency. The script performs two main checks:
1. Verifies that the number of translation keys in the translation files matches the reference file.
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
The script also provides functionality to update the translation files to match the reference file by adding missing keys and
adjusting the format.
Usage:
python check_language_json.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# Sample for Windows:
# python .github/scripts/check_language_json.py --reference-file frontend/public/locales/en-GB/translation.json --branch "" --files frontend/public/locales/de-DE/translation.json frontend/public/locales/fr-FR/translation.json
import copy
import glob
import os
import argparse
import re
import json
def find_duplicate_keys(file_path, keys=None, prefix=""):
"""
Identifies duplicate keys in a JSON file (including nested keys).
:param file_path: Path to the JSON file.
:param keys: Dictionary to track keys (used for recursion).
:param prefix: Prefix for nested keys.
:return: List of tuples (key, first_occurrence_path, duplicate_path).
"""
if keys is None:
keys = {}
duplicates = []
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
def process_dict(obj, current_prefix=""):
for key, value in obj.items():
full_key = f"{current_prefix}.{key}" if current_prefix else key
if isinstance(value, dict):
process_dict(value, full_key)
else:
if full_key in keys:
duplicates.append((full_key, keys[full_key], full_key))
else:
keys[full_key] = full_key
process_dict(data, prefix)
return duplicates
# Maximum size for JSON files (e.g., 500 KB)
MAX_FILE_SIZE = 500 * 1024
def parse_json_file(file_path):
"""
Parses a JSON translation file and returns a flat dictionary of all keys.
:param file_path: Path to the JSON file.
:return: Dictionary with flattened keys.
"""
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
def flatten_dict(d, parent_key="", sep="."):
items = {}
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.update(flatten_dict(v, new_key, sep=sep))
else:
items[new_key] = v
return items
return flatten_dict(data)
def unflatten_dict(d, sep="."):
"""
Converts a flat dictionary with dot notation keys back to nested dict.
:param d: Flattened dictionary.
:param sep: Separator used in keys.
:return: Nested dictionary.
"""
result = {}
for key, value in d.items():
parts = key.split(sep)
current = result
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
return result
def write_json_file(file_path, updated_properties):
"""
Writes updated properties back to the JSON file.
:param file_path: Path to the JSON file.
:param updated_properties: Dictionary of updated properties to write.
"""
nested_data = unflatten_dict(updated_properties)
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
json.dump(nested_data, file, ensure_ascii=False, indent=2)
file.write("\n") # Add trailing newline
def update_missing_keys(reference_file, file_list, branch=""):
"""
Updates missing keys in the translation files based on the reference file.
:param reference_file: Path to the reference JSON file.
:param file_list: List of translation files to update.
:param branch: Branch where the files are located.
"""
reference_properties = parse_json_file(reference_file)
for file_path in file_list:
basename_current_file = os.path.basename(os.path.join(branch, file_path))
if (
basename_current_file == os.path.basename(reference_file)
or not file_path.endswith(".json")
or not os.path.dirname(file_path).endswith("locales")
):
continue
current_properties = parse_json_file(os.path.join(branch, file_path))
updated_properties = {}
for ref_key, ref_value in reference_properties.items():
if ref_key in current_properties:
# Keep the current translation
updated_properties[ref_key] = current_properties[ref_key]
else:
# Add missing key with reference value
updated_properties[ref_key] = ref_value
write_json_file(os.path.join(branch, file_path), updated_properties)
def check_for_missing_keys(reference_file, file_list, branch):
update_missing_keys(reference_file, file_list, branch)
def read_json_keys(file_path):
if os.path.isfile(file_path) and os.path.exists(file_path):
return parse_json_file(file_path)
return {}
def check_for_differences(reference_file, file_list, branch, actor):
reference_branch = branch
basename_reference_file = os.path.basename(reference_file)
report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
reference_keys = read_json_keys(reference_file)
has_differences = False
only_reference_file = True
file_arr = file_list
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = os.path.abspath(
os.path.join(os.getcwd(), "frontend", "public", "locales")
)
for file_path in file_arr:
file_normpath = os.path.normpath(file_path)
absolute_path = os.path.abspath(file_normpath)
# Verify that file is within the expected directory
if not absolute_path.startswith(base_dir):
raise ValueError(f"Unsafe file found: {file_normpath}")
# Verify file size before processing
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
raise ValueError(
f"The file {file_normpath} is too large and could pose a security risk."
)
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
locale_dir = os.path.basename(os.path.dirname(file_normpath))
if (
basename_current_file == basename_reference_file
and locale_dir == "en-GB"
):
continue
if not file_normpath.endswith(".json") or basename_current_file != "translation.json":
continue
only_reference_file = False
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
current_keys = read_json_keys(os.path.join(branch, file_path))
reference_key_count = len(reference_keys)
current_key_count = len(current_keys)
if reference_key_count != current_key_count:
report.append("")
report.append("1. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
has_differences = True
if reference_key_count > current_key_count:
report.append(
f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing."
)
elif reference_key_count < current_key_count:
report.append(
f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed."
)
else:
report.append("1. **Test Status:** ✅ **_Passed_**")
# Check for missing or extra keys
current_keys_set = set(current_keys.keys())
reference_keys_set = set(reference_keys.keys())
missing_keys = current_keys_set.difference(reference_keys_set)
extra_keys = reference_keys_set.difference(current_keys_set)
missing_keys_list = list(missing_keys)
extra_keys_list = list(extra_keys)
if missing_keys_list or extra_keys_list:
has_differences = True
missing_keys_str = "`, `".join(missing_keys_list)
extra_keys_str = "`, `".join(extra_keys_list)
report.append("2. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
if missing_keys_list:
report.append(
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
if extra_keys_list:
report.append(
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
else:
report.append("2. **Test Status:** ✅ **_Passed_**")
if find_duplicate_keys(os.path.join(branch, file_normpath)):
has_differences = True
output = "\n".join(
[
f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
for key, first, duplicate in find_duplicate_keys(
os.path.join(branch, file_normpath)
)
]
)
report.append("3. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
report.append(" - duplicate entries were found:")
report.append(output)
else:
report.append("3. **Test Status:** ✅ **_Passed_**")
report.append("")
report.append("---")
report.append("")
if has_differences:
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
report.append("")
report.append(
f"Thanks @{actor} for your help in keeping the translations up to date."
)
if not only_reference_file:
print("\n".join(report))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Find missing keys")
parser.add_argument(
"--actor",
required=False,
help="Actor from PR.",
)
parser.add_argument(
"--reference-file",
required=True,
help="Path to the reference file.",
)
parser.add_argument(
"--branch",
type=str,
required=True,
help="Branch name.",
)
parser.add_argument(
"--check-file",
type=str,
required=False,
help="List of changed files, separated by spaces.",
)
parser.add_argument(
"--files",
nargs="+",
required=False,
help="List of changed files, separated by spaces.",
)
args = parser.parse_args()
# Sanitize --actor input to avoid injection attacks
if args.actor:
args.actor = re.sub(r"[^a-zA-Z0-9_\\-]", "", args.actor)
# Sanitize --branch input to avoid injection attacks
if args.branch:
args.branch = re.sub(r"[^a-zA-Z0-9\\-]", "", args.branch)
file_list = args.files
if file_list is None:
if args.check_file:
file_list = [args.check_file]
else:
file_list = glob.glob(
os.path.join(
os.getcwd(),
"frontend",
"public",
"locales",
"*",
"translation.json",
)
)
update_missing_keys(args.reference_file, file_list)
else:
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
@@ -12,7 +12,6 @@ on:
branches:
- V2
paths:
- ".github/workflows/frontend-licenses-update.yml"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/scripts/generate-licenses.js"
@@ -29,12 +28,12 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout PR head (default)
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
@@ -49,7 +48,7 @@ jobs:
- name: Checkout BASE branch (safe script)
if: github.event_name == 'pull_request'
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
@@ -57,9 +56,9 @@ jobs:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '22'
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
@@ -115,7 +114,7 @@ jobs:
# PR Event: Check licenses and comment on PR
- name: Delete previous license check comments
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -168,7 +167,7 @@ jobs:
- name: Comment on PR - License Check Results
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
-118
View File
@@ -1,118 +0,0 @@
name: Sync Files V2
on:
workflow_dispatch:
push:
branches:
- V2
- syncLangTest
paths:
- "build.gradle"
- "README.md"
- "frontend/public/locales/*/translation.json"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
sync-files:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Sync translation JSON files
run: |
python .github/scripts/check_language_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
- name: Commit translation files
run: |
git add frontend/public/locales/*/translation.json
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
- name: Install dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation_v2.py
- name: Run git add
run: |
git add README.md scripts/ignore_translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
- name: Create Pull Request
if: always()
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: Update files
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync_readme_v2
base: V2
title: ":globe_with_meridians: [V2] Sync Translations + Update README Progress Table"
body: |
### Description of Changes
This Pull Request was automatically generated to synchronize updates to translation files and documentation for the **V2 branch**. Below are the details of the changes made:
#### **1. Synchronization of Translation Files**
- Updated translation files (`frontend/public/locales/*/translation.json`) to reflect changes in the reference file `en-GB/translation.json`.
- Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations.
#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported languages.
- Included up-to-date statistics on translation coverage.
#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.
---
Auto-generated by [create-pull-request][1].
[1]: https://github.com/peter-evans/create-pull-request
draft: false
delete-branch: true
labels: github-actions
sign-commits: true
add-paths: |
README.md
frontend/public/locales/*/translation.json
-5
View File
@@ -192,11 +192,6 @@ return useToolOperation({
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
## Translation Rules
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
- Translation files are located in `frontend/public/locales/`
## Important Notes
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
+38 -37
View File
@@ -97,6 +97,7 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
# 📖 Get Started
Visit our comprehensive documentation at [docs.stirlingpdf.com](https://docs.stirlingpdf.com) for:
@@ -115,46 +116,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![95%](https://geps.dev/progress/95) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![35%](https://geps.dev/progress/35) |
| Basque (Euskara) (eu_ES) | ![20%](https://geps.dev/progress/20) |
| Bulgarian (Български) (bg_BG) | ![39%](https://geps.dev/progress/39) |
| Catalan (Català) (ca_CA) | ![37%](https://geps.dev/progress/37) |
| Croatian (Hrvatski) (hr_HR) | ![34%](https://geps.dev/progress/34) |
| Czech (Česky) (cs_CZ) | ![38%](https://geps.dev/progress/38) |
| Danish (Dansk) (da_DK) | ![34%](https://geps.dev/progress/34) |
| Dutch (Nederlands) (nl_NL) | ![34%](https://geps.dev/progress/34) |
| Arabic (العربية) (ar_AR) | ![61%](https://geps.dev/progress/61) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![62%](https://geps.dev/progress/62) |
| Basque (Euskara) (eu_ES) | ![36%](https://geps.dev/progress/36) |
| Bulgarian (Български) (bg_BG) | ![68%](https://geps.dev/progress/68) |
| Catalan (Català) (ca_CA) | ![68%](https://geps.dev/progress/68) |
| Croatian (Hrvatski) (hr_HR) | ![60%](https://geps.dev/progress/60) |
| Czech (Česky) (cs_CZ) | ![70%](https://geps.dev/progress/70) |
| Danish (Dansk) (da_DK) | ![61%](https://geps.dev/progress/61) |
| Dutch (Nederlands) (nl_NL) | ![60%](https://geps.dev/progress/60) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![93%](https://geps.dev/progress/93) |
| German (Deutsch) (de_DE) | ![95%](https://geps.dev/progress/95) |
| Greek (Ελληνικά) (el_GR) | ![38%](https://geps.dev/progress/38) |
| Hindi (हिंदी) (hi_IN) | ![38%](https://geps.dev/progress/38) |
| Hungarian (Magyar) (hu_HU) | ![42%](https://geps.dev/progress/42) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![34%](https://geps.dev/progress/34) |
| Irish (Gaeilge) (ga_IE) | ![38%](https://geps.dev/progress/38) |
| Italian (Italiano) (it_IT) | ![95%](https://geps.dev/progress/95) |
| Japanese (日本語) (ja_JP) | ![70%](https://geps.dev/progress/70) |
| Korean (한국어) (ko_KR) | ![38%](https://geps.dev/progress/38) |
| Norwegian (Norsk) (no_NB) | ![36%](https://geps.dev/progress/36) |
| Persian (فارسی) (fa_IR) | ![38%](https://geps.dev/progress/38) |
| Polish (Polski) (pl_PL) | ![40%](https://geps.dev/progress/40) |
| Portuguese (Português) (pt_PT) | ![38%](https://geps.dev/progress/38) |
| Portuguese Brazilian (Português) (pt_BR) | ![95%](https://geps.dev/progress/95) |
| Romanian (Română) (ro_RO) | ![32%](https://geps.dev/progress/32) |
| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![42%](https://geps.dev/progress/42) |
| Simplified Chinese (简体中文) (zh_CN) | ![96%](https://geps.dev/progress/96) |
| Slovakian (Slovensky) (sk_SK) | ![28%](https://geps.dev/progress/28) |
| Slovenian (Slovenščina) (sl_SI) | ![40%](https://geps.dev/progress/40) |
| Spanish (Español) (es_ES) | ![95%](https://geps.dev/progress/95) |
| Swedish (Svenska) (sv_SE) | ![37%](https://geps.dev/progress/37) |
| Thai (ไทย) (th_TH) | ![34%](https://geps.dev/progress/34) |
| French (Français) (fr_FR) | ![89%](https://geps.dev/progress/89) |
| German (Deutsch) (de_DE) | ![98%](https://geps.dev/progress/98) |
| Greek (Ελληνικά) (el_GR) | ![67%](https://geps.dev/progress/67) |
| Hindi (हिंदी) (hi_IN) | ![67%](https://geps.dev/progress/67) |
| Hungarian (Magyar) (hu_HU) | ![99%](https://geps.dev/progress/99) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![62%](https://geps.dev/progress/62) |
| Irish (Gaeilge) (ga_IE) | ![68%](https://geps.dev/progress/68) |
| Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) |
| Japanese (日本語) (ja_JP) | ![93%](https://geps.dev/progress/93) |
| Korean (한국어) (ko_KR) | ![67%](https://geps.dev/progress/67) |
| Norwegian (Norsk) (no_NB) | ![66%](https://geps.dev/progress/66) |
| Persian (فارسی) (fa_IR) | ![64%](https://geps.dev/progress/64) |
| Polish (Polski) (pl_PL) | ![72%](https://geps.dev/progress/72) |
| Portuguese (Português) (pt_PT) | ![69%](https://geps.dev/progress/69) |
| Portuguese Brazilian (Português) (pt_BR) | ![76%](https://geps.dev/progress/76) |
| Romanian (Română) (ro_RO) | ![57%](https://geps.dev/progress/57) |
| Russian (Русский) (ru_RU) | ![88%](https://geps.dev/progress/88) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![95%](https://geps.dev/progress/95) |
| Simplified Chinese (简体中文) (zh_CN) | ![93%](https://geps.dev/progress/93) |
| Slovakian (Slovensky) (sk_SK) | ![51%](https://geps.dev/progress/51) |
| Slovenian (Slovenščina) (sl_SI) | ![71%](https://geps.dev/progress/71) |
| Spanish (Español) (es_ES) | ![74%](https://geps.dev/progress/74) |
| Swedish (Svenska) (sv_SE) | ![65%](https://geps.dev/progress/65) |
| Thai (ไทย) (th_TH) | ![59%](https://geps.dev/progress/59) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![42%](https://geps.dev/progress/42) |
| Turkish (Türkçe) (tr_TR) | ![41%](https://geps.dev/progress/41) |
| Ukrainian (Українська) (uk_UA) | ![40%](https://geps.dev/progress/40) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![31%](https://geps.dev/progress/31) |
| Traditional Chinese (繁體中文) (zh_TW) | ![97%](https://geps.dev/progress/97) |
| Turkish (Türkçe) (tr_TR) | ![80%](https://geps.dev/progress/80) |
| Ukrainian (Українська) (uk_UA) | ![71%](https://geps.dev/progress/71) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![57%](https://geps.dev/progress/57) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
@@ -258,6 +258,12 @@ public class AppConfig {
return false;
}
@Bean(name = "GoogleDriveEnabled")
@Profile("default")
public boolean googleDriveEnabled() {
return false;
}
@Bean(name = "license")
@Profile("default")
public String licenseType() {
@@ -530,6 +530,7 @@ public class ApplicationProperties {
private boolean ssoAutoLogin;
private boolean database;
private CustomMetadata customMetadata = new CustomMetadata();
private GoogleDrive googleDrive = new GoogleDrive();
@Data
public static class CustomMetadata {
@@ -548,6 +549,26 @@ public class ApplicationProperties {
: producer;
}
}
@Data
public static class GoogleDrive {
private boolean enabled;
private String clientId;
private String apiKey;
private String appId;
public String getClientId() {
return clientId == null || clientId.trim().isEmpty() ? "" : clientId;
}
public String getApiKey() {
return apiKey == null || apiKey.trim().isEmpty() ? "" : apiKey;
}
public String getAppId() {
return appId == null || appId.trim().isEmpty() ? "" : appId;
}
}
}
@Data
@@ -109,6 +109,22 @@ class ApplicationPropertiesLogicTest {
assertTrue(ex.getMessage().toLowerCase().contains("not supported"));
}
@Test
void premium_google_drive_getters_return_empty_string_on_null_or_blank() {
Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive();
assertEquals("", gd.getClientId());
assertEquals("", gd.getApiKey());
assertEquals("", gd.getAppId());
gd.setClientId(" id ");
gd.setApiKey(" key ");
gd.setAppId(" app ");
assertEquals(" id ", gd.getClientId());
assertEquals(" key ", gd.getApiKey());
assertEquals(" app ", gd.getAppId());
}
@Test
void ui_getters_return_null_for_blank() {
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
@@ -3,7 +3,6 @@ package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes;
@@ -21,8 +20,6 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
@@ -114,32 +111,6 @@ public class MergeController {
}
}
// Parse client file IDs from JSON string
private String[] parseClientFileIds(String clientFileIds) {
if (clientFileIds == null || clientFileIds.trim().isEmpty()) {
return new String[0];
}
try {
// Simple JSON array parsing - remove brackets and split by comma
String trimmed = clientFileIds.trim();
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
String inside = trimmed.substring(1, trimmed.length() - 1).trim();
if (inside.isEmpty()) {
return new String[0];
}
String[] parts = inside.split(",");
String[] result = new String[parts.length];
for (int i = 0; i < parts.length; i++) {
result[i] = parts[i].trim().replaceAll("^\"|\"$", "");
}
return result;
}
} catch (Exception e) {
log.warn("Failed to parse client file IDs: {}", clientFileIds, e);
}
return new String[0];
}
// Adds a table of contents to the merged document using filenames as chapter titles
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
// Create the document outline
@@ -206,48 +177,15 @@ public class MergeController {
PDFMergerUtility mergerUtility = new PDFMergerUtility();
long totalSize = 0;
List<Integer> invalidIndexes = new ArrayList<>();
for (int index = 0; index < files.length; index++) {
MultipartFile multipartFile = files[index];
for (MultipartFile multipartFile : files) {
totalSize += multipartFile.getSize();
File tempFile =
GeneralUtils.convertMultipartFileToFile(
multipartFile); // Convert MultipartFile to File
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
// Pre-validate each PDF so we can report which one(s) are broken
// Use the original MultipartFile to avoid deleting the tempFile during validation
try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) {
// OK
} catch (IOException e) {
ExceptionUtils.logException("PDF pre-validate", e);
invalidIndexes.add(index);
}
mergerUtility.addSource(tempFile); // Add source file to the merger utility
}
if (!invalidIndexes.isEmpty()) {
// Parse client file IDs (always present from frontend)
String[] clientIds = parseClientFileIds(request.getClientFileIds());
// Map invalid indexes to client IDs
List<String> errorFileIds = new ArrayList<>();
for (Integer index : invalidIndexes) {
if (index < clientIds.length) {
errorFileIds.add(clientIds[index]);
}
}
String payload =
String.format(
"{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}",
errorFileIds.toString());
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.body(payload.getBytes(StandardCharsets.UTF_8));
}
mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
@@ -98,6 +98,11 @@ public class ConfigController {
if (applicationContext.containsBean("license")) {
configData.put("license", applicationContext.getBean("license", String.class));
}
if (applicationContext.containsBean("GoogleDriveEnabled")) {
configData.put(
"GoogleDriveEnabled",
applicationContext.getBean("GoogleDriveEnabled", Boolean.class));
}
if (applicationContext.containsBean("SSOAutoLogin")) {
configData.put(
"SSOAutoLogin",
@@ -39,10 +39,4 @@ public class MergePdfsRequest extends MultiplePDFFiles {
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean generateToc = false;
@Schema(
description =
"JSON array of client-provided IDs for each uploaded file (same order as fileInput)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String clientFileIds;
}
@@ -1916,7 +1916,6 @@ fileManager.storageError=Storage error occurred
fileManager.storageLow=Storage is running low. Consider removing old files.
fileManager.uploadError=Failed to upload some files.
fileManager.supportMessage=Powered by browser database storage for unlimited capacity
fileManager.loadingFiles=Loading files...
# Page Editor
pageEditor.title=Page Editor
@@ -1946,25 +1945,6 @@ viewer.zoomIn=Zoom in
# Tool Picker
toolPicker.searchPlaceholder=Search tools...
toolPicker.noToolsFound=No tools found
toolPanel.toggle.legacy=Switch to legacy mode
toolPanel.toggle.sidebar=Switch to sidebar mode
toolPanel.placeholder=Choose a tool to get started
toolPanel.legacy.heading=All tools (legacy view)
toolPanel.legacy.tagline=Browse and launch tools while keeping the classic full-width gallery.
toolPanel.legacy.descriptionsOn=Showing descriptions
toolPanel.legacy.descriptionsOff=Descriptions hidden
toolPanel.legacy.noResults=Try adjusting your search or toggle descriptions to find what you need.
toolPanel.legacy.matchedSynonym=Matches "{{text}}"
toolPanel.modePrompt.title=Choose how you browse tools
toolPanel.modePrompt.description=Preview both layouts and decide how you want to explore Stirling PDF tools.
toolPanel.modePrompt.sidebarTitle=Advanced sidebar
toolPanel.modePrompt.sidebarDescription=Keep tools alongside your workspace for quick switching.
toolPanel.modePrompt.recommended=Recommended
toolPanel.modePrompt.chooseSidebar=Use advanced sidebar
toolPanel.modePrompt.legacyTitle=Legacy fullscreen
toolPanel.modePrompt.legacyDescription=Browse every tool in a catalogue that covers the workspace until you pick one.
toolPanel.modePrompt.chooseLegacy=Use legacy fullscreen
toolPanel.modePrompt.dismiss=Maybe later
pageEditor.reset=Reset Changes
pageEditor.zoomIn=Zoom In
pageEditor.zoomOut=Zoom Out
@@ -1977,39 +1957,3 @@ viewer.nextPage=Next Page
viewer.pageNavigation=Page Navigation
viewer.currentPage=Current Page
viewer.totalPages=Total Pages
toolPanel.legacy.favorites=Favourites
toolPanel.legacy.recent=Recently used
toolPanel.legacy.favorite=Add to favourites
toolPanel.legacy.unfavorite=Remove from favourites
toolPanel.legacy.settings.title=Customise appearance
toolPanel.legacy.settings.iconBackground.label=Tool icon background
toolPanel.legacy.settings.iconBackground.description=When to show coloured backgrounds behind tool icons
toolPanel.legacy.settings.iconBackground.none=None
toolPanel.legacy.settings.iconBackground.hover=On hover
toolPanel.legacy.settings.iconBackground.always=Always
toolPanel.legacy.settings.iconColor.label=Tool icon colour
toolPanel.legacy.settings.iconColor.description=Colour scheme for tool icons
toolPanel.legacy.settings.iconColor.colored=Coloured
toolPanel.legacy.settings.iconColor.vibrant=Vibrant
toolPanel.legacy.settings.iconColor.monochrome=Monochrome
toolPanel.legacy.settings.sectionTitle.label=Section titles
toolPanel.legacy.settings.sectionTitle.description=Colour for category section titles
toolPanel.legacy.settings.sectionTitle.colored=Coloured
toolPanel.legacy.settings.sectionTitle.neutral=Neutral
toolPanel.legacy.settings.headerIcon.label=Section header icons
toolPanel.legacy.settings.headerIcon.description=Colour for Favourites/Recent icons
toolPanel.legacy.settings.headerIcon.colored=Coloured
toolPanel.legacy.settings.headerIcon.monochrome=Monochrome
toolPanel.legacy.settings.headerBadge.label=Section header badges
toolPanel.legacy.settings.headerBadge.description=Colour for count badges in section headers
toolPanel.legacy.settings.headerBadge.colored=Coloured
toolPanel.legacy.settings.headerBadge.neutral=Neutral
toolPanel.legacy.settings.border.label=Tool item borders
toolPanel.legacy.settings.border.description=Show borders around tool items
toolPanel.legacy.settings.border.visible=Visible
toolPanel.legacy.settings.border.hidden=Hidden
toolPanel.legacy.settings.hover.label=Hover effect intensity
toolPanel.legacy.settings.hover.description=How prominent the hover effect should be
toolPanel.legacy.settings.hover.subtle=Subtle
toolPanel.legacy.settings.hover.moderate=Moderate
toolPanel.legacy.settings.hover.prominent=Prominent
@@ -76,6 +76,11 @@ premium:
author: username
creator: Stirling-PDF
producer: Stirling-PDF
googleDrive:
enabled: false
clientId: ''
apiKey: ''
appId: ''
enterpriseFeatures:
audit:
enabled: true # Enable audit logging
@@ -422,6 +422,10 @@
<span th:text="#{fileChooser.or}" style="margin: 0 5px;"></span>
<span th:text="#{fileChooser.dragAndDrop}" id="dragAndDrop"></span>
</div>
<hr th:if="${@GoogleDriveEnabled == true}" class="horizontal-divider" >
</div>
<div th:if="${@GoogleDriveEnabled == true}" th:id="${name}+'-google-drive-button'" class="google-drive-button" th:attr="data-name=${name}, data-multiple=${!disableMultipleFiles}, data-accept=${accept}" >
<img th:src="@{'/images/google-drive.svg'}" alt="google drive">
</div>
</div>
<div class="selected-files flex-wrap"></div>
@@ -439,4 +443,16 @@
</div>
</div>
<script th:src="@{'/js/fileInput.js'}" type="module"></script>
<div th:if="${@GoogleDriveEnabled == true}" >
<script type="text/javascript" th:src="@{'/js/googleFilePicker.js'}"></script>
<script async defer src="https://apis.google.com/js/api.js" onload="gapiLoaded()"></script>
<script async defer src="https://accounts.google.com/gsi/client" onload="gisLoaded()"></script>
<script th:inline="javascript">
window.stirlingPDF.GoogleDriveClientId = /*[[${@GoogleDriveConfig.getClientId()}]]*/ null;
window.stirlingPDF.GoogleDriveApiKey = /*[[${@GoogleDriveConfig.getApiKey()}]]*/ null;
window.stirlingPDF.GoogleDriveAppId = /*[[${@GoogleDriveConfig.getAppId()}]]*/ null;
</script>
</div>
</th:block>
@@ -12,6 +12,7 @@ import org.springframework.core.annotation.Order;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.EnterpriseEdition;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.GoogleDrive;
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE)
@@ -54,6 +55,19 @@ public class EEAppConfig {
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
}
@Profile("security")
@Bean(name = "GoogleDriveEnabled")
@Primary
public boolean googleDriveEnabled() {
return runningProOrHigher()
&& applicationProperties.getPremium().getProFeatures().getGoogleDrive().isEnabled();
}
@Bean(name = "GoogleDriveConfig")
public GoogleDrive googleDriveConfig() {
return applicationProperties.getPremium().getProFeatures().getGoogleDrive();
}
// TODO: Remove post migration
@SuppressWarnings("deprecation")
public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) {
+1 -8
View File
@@ -50,14 +50,7 @@ docker-compose -f docker/compose/docker-compose.fat.yml up --build
- **Custom Ports**: Modify port mappings in docker-compose files
- **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat)
### [Google Drive Integration](https://developers.google.com/workspace/drive/picker/guides/overview)
- **VITE_GOOGLE_DRIVE_CLIENT_ID**: [OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients/create)
- **VITE_GOOGLE_DRIVE_API_KEY**: [Create New API](https://console.cloud.google.com/apis)
- **VITE_GOOGLE_DRIVE_APP_ID**: This is your [project number](https://console.cloud.google.com/iam-admin/settings) in the GoogleCloud Settings
## Development vs Production
- **Development**: Keep backend port 8080 exposed for debugging
- **Production**: Remove backend port exposure, use only frontend proxy
- **Production**: Remove backend port exposure, use only frontend proxy
-3
View File
@@ -47,9 +47,6 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
@@ -44,9 +44,6 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
-3
View File
@@ -46,9 +46,6 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
+1 -1
View File
@@ -4,7 +4,7 @@ FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
COPY frontend/package.json frontend/package-lock.json ./
COPY frontend/package*.json ./
# Install dependencies
RUN npm ci
-13
View File
@@ -6,11 +6,6 @@ http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Add .mjs MIME type mapping
types {
text/javascript mjs;
}
# Gzip compression
gzip on;
gzip_vary on;
@@ -95,14 +90,6 @@ http {
proxy_set_header X-Forwarded-Port $server_port;
}
# Serve .mjs files with correct MIME type (must come before general static assets)
location ~* \.mjs$ {
try_files $uri =404;
add_header Content-Type "text/javascript; charset=utf-8" always;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
+2 -28
View File
@@ -1,18 +1,9 @@
// @ts-check
import eslint from '@eslint/js';
import globals from "globals";
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
const srcGlobs = [
'src/**/*.{js,mjs,jsx,ts,tsx}',
];
const nodeGlobs = [
'scripts/**/*.{js,ts,mjs}',
'*.config.{js,ts,mjs}',
];
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
@@ -24,6 +15,7 @@ export default defineConfig(
},
{
rules: {
"no-undef": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-empty-object-type": [
"error",
{
@@ -46,23 +38,5 @@ export default defineConfig(
},
],
},
},
// Config for browser scripts
{
files: srcGlobs,
languageOptions: {
globals: {
...globals.browser,
}
}
},
// Config for node scripts
{
files: nodeGlobs,
languageOptions: {
globals: {
...globals.node,
}
}
},
}
);
+115 -291
View File
@@ -10,24 +10,21 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.3.14",
"@embedpdf/engines": "^1.3.14",
"@embedpdf/plugin-annotation": "^1.3.14",
"@embedpdf/plugin-export": "^1.3.14",
"@embedpdf/plugin-history": "^1.3.14",
"@embedpdf/plugin-interaction-manager": "^1.3.14",
"@embedpdf/plugin-loader": "^1.3.14",
"@embedpdf/plugin-pan": "^1.3.14",
"@embedpdf/plugin-render": "^1.3.14",
"@embedpdf/plugin-rotate": "^1.3.14",
"@embedpdf/plugin-scroll": "^1.3.14",
"@embedpdf/plugin-search": "^1.3.14",
"@embedpdf/plugin-selection": "^1.3.14",
"@embedpdf/plugin-spread": "^1.3.14",
"@embedpdf/plugin-thumbnail": "^1.3.14",
"@embedpdf/plugin-tiling": "^1.3.14",
"@embedpdf/plugin-viewport": "^1.3.14",
"@embedpdf/plugin-zoom": "^1.3.14",
"@embedpdf/core": "^1.2.1",
"@embedpdf/engines": "^1.2.1",
"@embedpdf/plugin-interaction-manager": "^1.2.1",
"@embedpdf/plugin-loader": "^1.2.1",
"@embedpdf/plugin-pan": "^1.2.1",
"@embedpdf/plugin-render": "^1.2.1",
"@embedpdf/plugin-rotate": "^1.2.1",
"@embedpdf/plugin-scroll": "^1.2.1",
"@embedpdf/plugin-search": "^1.2.1",
"@embedpdf/plugin-selection": "^1.2.1",
"@embedpdf/plugin-spread": "^1.2.1",
"@embedpdf/plugin-thumbnail": "^1.2.1",
"@embedpdf/plugin-tiling": "^1.2.1",
"@embedpdf/plugin-viewport": "^1.2.1",
"@embedpdf/plugin-zoom": "^1.2.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -41,7 +38,6 @@
"@tanstack/react-virtual": "^3.13.12",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^16.4.0",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
@@ -54,7 +50,6 @@
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
"react-router-dom": "^7.9.1",
"signature_pad": "^5.0.4",
"tailwindcss": "^4.1.13",
"web-vitals": "^5.1.0"
},
@@ -67,10 +62,6 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/gapi": "^0.0.47",
"@types/gapi.client.drive-v3": "^0.0.5",
"@types/google.accounts": "^0.0.18",
"@types/google.picker": "^0.0.51",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
@@ -497,13 +488,12 @@
}
},
"node_modules/@embedpdf/core": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.14.tgz",
"integrity": "sha512-lE/vfhA53CxamaCfGWEibrEPr+JeZT42QCF+cOELUwv4+Zt6b+IE6+4wsznx/8wjjJYwllXJ3GJ/un1UzTqARw==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.2.1.tgz",
"integrity": "sha512-2VwRPsN3+LmaBrD8TCN1t1ni/Vc9CxAfl/SApDjZYwE7zOieQT4ZHt+nkgF0F4I3xSgvvyHDjmOonhjBIrT6xA==",
"dependencies": {
"@embedpdf/engines": "1.3.14",
"@embedpdf/models": "1.3.14"
"@embedpdf/engines": "1.2.1",
"@embedpdf/models": "1.2.1"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -513,13 +503,13 @@
}
},
"node_modules/@embedpdf/engines": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.3.14.tgz",
"integrity": "sha512-+/FPW2gAzj2lQYvsMH/Oj9+MEXgkyEuyYDC+HFkltTuXvmiP2S/3BD0YslZDX9K4BzcmMxnWB+BiQpNJokbDVg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.2.1.tgz",
"integrity": "sha512-nhycZ7Buq2B34dcpo6n7RdFwdhwTvKzvnRy7QX+uU00Dz5vftkCG4OK+pBVzxE4y7vAu+Yb4wNpdc7HmIj3B6w==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.14",
"@embedpdf/pdfium": "1.3.14"
"@embedpdf/models": "1.2.1",
"@embedpdf/pdfium": "1.2.1"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -529,79 +519,26 @@
}
},
"node_modules/@embedpdf/models": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.3.14.tgz",
"integrity": "sha512-BujY4bmr8b2DQdoZkOge03SzoRVoWxzfIQATLSPPtp4WiFh1U4BPp6cADlGuCwGkp6zBcH/aM4h8PwwA75d/eg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.2.1.tgz",
"integrity": "sha512-FzJU51jsqihfgt50B00FEpgyym87/Dn2iGmMq4++Vu/oO6qBx/y69m4/cCAh4p4KkTJsvKNWC7T7dwSKa0FjHA==",
"license": "MIT"
},
"node_modules/@embedpdf/pdfium": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.3.14.tgz",
"integrity": "sha512-TQMZabXzHmzvvfPwopubFcYgQuYV7POvMgjICYu3Pgfn3sgr+UdIUh3aNXR/COcl3q8sXPMFQ2GDuyOHR9QQnA==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.2.1.tgz",
"integrity": "sha512-QWf1jg7EqUlku2q6KYhlXCNfk5IAykFerPuzKJepHTeAEaRcAfu84fJgEsoUTCK4D6dfzVNp2Iuxw6Kv7MpSeg==",
"license": "MIT"
},
"node_modules/@embedpdf/plugin-annotation": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.3.14.tgz",
"integrity": "sha512-JJYqEWwUKCdBZsXCDq/CW96p3pVLn8N+XZ4W3OyL7djI2fvYC9x6ys9m82vwlSathAVOxk1D7xXiY8AzJQVF0Q==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.14",
"@embedpdf/utils": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-history": "1.3.14",
"@embedpdf/plugin-interaction-manager": "1.3.14",
"@embedpdf/plugin-selection": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-export": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.14.tgz",
"integrity": "sha512-fMGp2YxvI4uTRIViUKxfnJts2Jw/vktEM45XUNGNSjT/kAW6znVNgdceYjpK++xU8CGs2grAQ1i5UvMd3aRNDA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-history": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.14.tgz",
"integrity": "sha512-77hnNLp0W0FHw8lT7SeqzCgp8bOClfeOAPZdcInu/jPDhVASUGYbtE/0fkLhiaqPH7kyMirNCLif4sF6n4b5vg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.14.tgz",
"integrity": "sha512-nR0ZxNoTQtGqOHhweFh6QJ+nUJ4S4Ag1wWur6vAUAi8U95HUOfZhOEa0polZo0zR9WmmblGqRWjFM+mVSOoi1w==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.2.1.tgz",
"integrity": "sha512-HhEBuDjDNMH6wu76Eo3yHwjG01U1lNZShkOsFoib/rtx8HByTgZS8iVpovaOprr6gfS04ZLqWcsN1nt5qAH90w==",
"dependencies": {
"@embedpdf/models": "1.3.14"
"@embedpdf/models": "1.2.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/core": "1.2.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -609,15 +546,14 @@
}
},
"node_modules/@embedpdf/plugin-loader": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.14.tgz",
"integrity": "sha512-KoJX1MacEWE2DrO1OeZeG/Ehz76//u+ida/xb4r9BfwqAp5TfYlksq09cOvcF8LMW5FY4pbAL+AHKI1Hjz+HNA==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.2.1.tgz",
"integrity": "sha512-VblKErfEiHcVao18TfCmc0UJlKAkqxE29DaLJrXQHGUw/qc+pC9HlvMVpDz3+Eb13UafYS6ZUZuEng2/fQ+JJw==",
"dependencies": {
"@embedpdf/models": "1.3.14"
"@embedpdf/models": "1.2.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/core": "1.2.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -625,17 +561,16 @@
}
},
"node_modules/@embedpdf/plugin-pan": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.3.14.tgz",
"integrity": "sha512-7EG+I5nn8yDCV8pT4x/g5mv7zJli2t3wPrh6Kt8uIpUorPHNb6J0Z67gl0uc/8rEasNzuKOuT0er46Y6/UYLzQ==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.2.1.tgz",
"integrity": "sha512-/BTOyRl31tvnCmoLs4qNPROMRLaG34jGYNyMQquB0uPUXZjwdMloikriwos91qCOLUrhvs4SaDpC3Ghv2BO5kA==",
"dependencies": {
"@embedpdf/models": "1.3.14"
"@embedpdf/models": "1.2.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-interaction-manager": "1.3.14",
"@embedpdf/plugin-viewport": "1.3.14",
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-interaction-manager": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -643,15 +578,14 @@
}
},
"node_modules/@embedpdf/plugin-render": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.14.tgz",
"integrity": "sha512-IPj7GCQXJBsY++JaU+z7y+FwX5NaDBj4YYV6hsHNtSGf42Y1AdlwJzDYetivG2bA84xmk7KgD1X2Y3eIFBhjwA==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.2.1.tgz",
"integrity": "sha512-iMfuVJqttJmm7Zb8oOaqNVNrC3NS57bDNNAc4MIc2f2TxIFSznvBPlwWN+PN45qNcTQiGzFc1ZMqIQDOG4qFnQ==",
"dependencies": {
"@embedpdf/models": "1.3.14"
"@embedpdf/models": "1.2.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/core": "1.2.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -659,15 +593,14 @@
}
},
"node_modules/@embedpdf/plugin-rotate": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.3.14.tgz",
"integrity": "sha512-OroEm11x/fPPXI9C0X+nm9LOjwaI0MvsToZRH+HpV60/FbQeOJvt6D8wThCDVLK95Na6A+JeYIMEu+Hiix7H+A==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.2.1.tgz",
"integrity": "sha512-UhHds5donLDXm3i9nKrhSmo3yawVtjb6gID0MDrhj3+Lci/YQ3wDvGUhk7dNmgLcOt7G8pMa0wesnnpVWirUXA==",
"dependencies": {
"@embedpdf/models": "1.3.14"
"@embedpdf/models": "1.2.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/core": "1.2.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -675,16 +608,15 @@
}
},
"node_modules/@embedpdf/plugin-scroll": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.14.tgz",
"integrity": "sha512-fQbt7OlRMLQJMuZj/Bzh0qpRxMw1ld5Qe/OTw8N54b/plljnFA52joE7cITl3H03huWWyHS3NKOScbw7f34dog==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.2.1.tgz",
"integrity": "sha512-I1haDXIOzs59uhOWEP6UvP5jzjcQHMLQuQbfRVJM0zdWU6t3jwSfcwPUI7iv4CAAepbuyJKL328yc8736r/FYw==",
"dependencies": {
"@embedpdf/models": "1.3.14"
"@embedpdf/models": "1.2.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-viewport": "1.3.14",
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -692,16 +624,15 @@
}
},
"node_modules/@embedpdf/plugin-search": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.3.14.tgz",
"integrity": "sha512-tlZEgR2tG+GSNnh2u1SjCxhUHfTDgcr38sE/xRK1bRLDGPZWlr6Ln7qP7JSWqeYBGni75sGrj0iZqcZbPWyJag==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.2.1.tgz",
"integrity": "sha512-sl9FBQzbOBtdmPpf6UI0bnWCTPWDkj47rTxyK07bpnGfGuFof4zhcxmMaFdyP7zqBh4Y9XqGu4A0uMTO2d/t7g==",
"dependencies": {
"@embedpdf/models": "1.3.14"
"@embedpdf/models": "1.2.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-loader": "1.3.14",
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-loader": "1.2.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -709,17 +640,16 @@
}
},
"node_modules/@embedpdf/plugin-selection": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.14.tgz",
"integrity": "sha512-EXENuaAsse3rT6cjA1nYzyrNvoy62ojJl28wblCng6zcs3HSlGPemIQZAvaYKPUxoY608M+6nKlcMQ5neRnk/A==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.2.1.tgz",
"integrity": "sha512-wgG1X1sl6sed3pv7WLIO74SX0x3389/ax+/OLMty/LFbDNYMRO+n8ZQss8aUM700HARIqkPJy7UoSQt91o4nwA==",
"dependencies": {
"@embedpdf/models": "1.3.14"
"@embedpdf/models": "1.2.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-interaction-manager": "1.3.14",
"@embedpdf/plugin-viewport": "1.3.14",
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-interaction-manager": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -727,16 +657,15 @@
}
},
"node_modules/@embedpdf/plugin-spread": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.3.14.tgz",
"integrity": "sha512-DVlk6tDgUoDRkp2S4Jc3LrRTuf4DPMlph9vywJw5z6Qpbh0vgcMnObg896/S0Eu5FgACNAj0WGcXpLrcrn5b9Q==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.2.1.tgz",
"integrity": "sha512-rpadnutT1wSdBQV7RQz40zYdKgCRgmJde/tamgB8oHQypcnZGQcAG6/ZfX5j12s9pG18hKwwL+KmMoBnXD9IjQ==",
"dependencies": {
"@embedpdf/models": "1.3.14"
"@embedpdf/models": "1.2.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-loader": "1.3.14",
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-loader": "1.2.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -744,35 +673,32 @@
}
},
"node_modules/@embedpdf/plugin-thumbnail": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.3.14.tgz",
"integrity": "sha512-cnwb5dG8Jph8XSArys1WFCQ6kK2R5FKoO0B5mDrHFv9Fcm2pKszlmZC/NDoskX4pgNUgSnwhI1X3cP37ebF9Ng==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.2.1.tgz",
"integrity": "sha512-TjHPkK8p3+FDMLcUdb3/4VREjm+liVooufLPVZ3FCXHbiC0PeUkqnwAxpCS2Jw1n+EtkY8pefRdRJeZhO6plOQ==",
"dependencies": {
"@embedpdf/models": "1.3.14"
"@embedpdf/models": "1.2.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-render": "1.3.14",
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-render": "1.2.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
"react-dom": ">=16.8.0"
}
},
"node_modules/@embedpdf/plugin-tiling": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.3.14.tgz",
"integrity": "sha512-SaCTo2LdZwGeE6jCqkwJxvwt8YKbsI3QGxa9S7Ez+5OcBchlhHeTfLQswcErDQ3WH2p8WHtGuucAcOLrVVOm0A==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.2.1.tgz",
"integrity": "sha512-C9uOGVIsoxUw+uQMXfJFZ8ibRLQeNOnaKC2izjx967iGu0ZoecAv+mKtH/Ge0vMEKYM1109AlF5T2EGwKQW2YA==",
"dependencies": {
"@embedpdf/models": "1.3.14"
"@embedpdf/models": "1.2.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-render": "1.3.14",
"@embedpdf/plugin-scroll": "1.3.14",
"@embedpdf/plugin-viewport": "1.3.14",
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-render": "1.2.1",
"@embedpdf/plugin-scroll": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -780,15 +706,14 @@
}
},
"node_modules/@embedpdf/plugin-viewport": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.14.tgz",
"integrity": "sha512-mfJ7EbbU68eKk6oFvQ4ozGJNpxUxWbjQ5Gm3uuB+Gj5/tWgBocBOX36k/9LgivEEeX7g2S0tOgyErljApmH8Vg==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.2.1.tgz",
"integrity": "sha512-yvftOis7FLBjM3w2VYO5LXVKXoHkmFV/SPy7U6SbuLJTX126F4ohSij9euMHJjaqOgr5tBNvrf4xemVRglxM9w==",
"dependencies": {
"@embedpdf/models": "1.3.14"
"@embedpdf/models": "1.2.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/core": "1.2.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -796,31 +721,18 @@
}
},
"node_modules/@embedpdf/plugin-zoom": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.3.14.tgz",
"integrity": "sha512-/N5tyMk+8OzhObrS3O9yPkcmX8EPiuTo+WaT2QCVSmIUqKnOO4AnKpHJ6Vl0uVhcuXHCMwLucZKyhJ7tRqavwg==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.2.1.tgz",
"integrity": "sha512-hsp/nM4C8q0FM9P6FkpQLbU8IYawUgmiYgD3HXqHWBVRk30OIaXs4N0KC9vsHwn8ZAiyLl7jhlAXpgoacH5xEQ==",
"dependencies": {
"@embedpdf/models": "1.3.14",
"@embedpdf/models": "1.2.1",
"hammerjs": "^2.0.8"
},
"peerDependencies": {
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-interaction-manager": "1.3.14",
"@embedpdf/plugin-scroll": "1.3.14",
"@embedpdf/plugin-viewport": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/utils": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.3.14.tgz",
"integrity": "sha512-gxEJD12nageCMqAjdbicNfDQolXU3nvnV0EX96OdZITRNj0Q1tisutVYoaxcCiJu3vvIEOzipjsAnQOubbFCEA==",
"license": "MIT",
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-interaction-manager": "1.2.1",
"@embedpdf/plugin-scroll": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1767,19 +1679,6 @@
"mlly": "^1.7.4"
}
},
"node_modules/@iconify/utils/node_modules/globals": {
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2015,28 +1914,6 @@
"react": "^18.x || ^19.x"
}
},
"node_modules/@maxim_mazurok/gapi.client.discovery-v1": {
"version": "0.4.20200806",
"resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.4.20200806.tgz",
"integrity": "sha512-Jeo/KZqK39DI6ExXHcJ4lqnn1O/wEqboQ6eQ8WnNpu5eJ7wUnX/C5KazOgs1aRhnIB/dVzDe8wm62nmtkMIoaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/gapi.client": "*",
"@types/gapi.client.discovery-v1": "*"
}
},
"node_modules/@maxim_mazurok/gapi.client.drive-v3": {
"version": "0.1.20250930",
"resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.drive-v3/-/gapi.client.drive-v3-0.1.20250930.tgz",
"integrity": "sha512-zNR7HtaFl2Pvf8Ck2zP8cppUst7ouY2isKn7hrGf6hQ4/0ULsu19qMRSQgRb0HxBYcGjak7kGK4pZI4a2z4CWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/gapi.client": "*",
"@types/gapi.client.discovery-v1": "*"
}
},
"node_modules/@mui/core-downloads-tracker": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz",
@@ -3623,54 +3500,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/gapi": {
"version": "0.0.47",
"resolved": "https://registry.npmjs.org/@types/gapi/-/gapi-0.0.47.tgz",
"integrity": "sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/gapi.client": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/gapi.client/-/gapi.client-1.0.8.tgz",
"integrity": "sha512-qJQUmmumbYym3Amax0S8CVzuSngcXsC1fJdwRS2zeW5lM63zXkw4wJFP+bG0jzgi0R6EsJKoHnGNVTDbOyG1ng==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/gapi.client.discovery-v1": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@types/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.0.4.tgz",
"integrity": "sha512-uevhRumNE65F5mf2gABLaReOmbFSXONuzFZjNR3dYv6BmkHg+wciubHrfBAsp3554zNo3Dcg6dUAlwMqQfpwjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@maxim_mazurok/gapi.client.discovery-v1": "latest"
}
},
"node_modules/@types/gapi.client.drive-v3": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@types/gapi.client.drive-v3/-/gapi.client.drive-v3-0.0.5.tgz",
"integrity": "sha512-yYBxiqMqJVBg4bns4Q28+f2XdJnd3tVA9dxQX1lXMVmzT2B+pZdyCi1u9HLwGveVlookSsAXuqfLfS9KO6MF6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@maxim_mazurok/gapi.client.drive-v3": "latest"
}
},
"node_modules/@types/google.accounts": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@types/google.accounts/-/google.accounts-0.0.18.tgz",
"integrity": "sha512-yHaPznll97ZnMJlPABHyeiIlLn3u6gQaUjA5k/O9lrrpgFB9VT10CKPLuKM0qTHMl50uXpW5sIcG+utm8jMOHw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/google.picker": {
"version": "0.0.51",
"resolved": "https://registry.npmjs.org/@types/google.picker/-/google.picker-0.0.51.tgz",
"integrity": "sha512-z6o2J4PQTcXvlW1rtgQx65d5uEF+rMI1hzrnazKQxBONdEuYAr4AeOSH2KZy12WHPmqMX+aWYyfcZ0uktBBhhA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-cache-semantics": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
@@ -6505,9 +6334,10 @@
}
},
"node_modules/globals": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
"integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -9994,12 +9824,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/signature_pad": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-5.1.1.tgz",
"integrity": "sha512-BT5JJygS5BS0oV+tffPRorIud6q17bM7v/1LdQwd0o6mTqGoI25yY1NjSL99OqkekWltS4uon6p52Y8j1Zqu7g==",
"license": "MIT"
},
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
+15 -24
View File
@@ -6,24 +6,21 @@
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.3.14",
"@embedpdf/engines": "^1.3.14",
"@embedpdf/plugin-annotation": "^1.3.14",
"@embedpdf/plugin-export": "^1.3.14",
"@embedpdf/plugin-history": "^1.3.14",
"@embedpdf/plugin-interaction-manager": "^1.3.14",
"@embedpdf/plugin-loader": "^1.3.14",
"@embedpdf/plugin-pan": "^1.3.14",
"@embedpdf/plugin-render": "^1.3.14",
"@embedpdf/plugin-rotate": "^1.3.14",
"@embedpdf/plugin-scroll": "^1.3.14",
"@embedpdf/plugin-search": "^1.3.14",
"@embedpdf/plugin-selection": "^1.3.14",
"@embedpdf/plugin-spread": "^1.3.14",
"@embedpdf/plugin-thumbnail": "^1.3.14",
"@embedpdf/plugin-tiling": "^1.3.14",
"@embedpdf/plugin-viewport": "^1.3.14",
"@embedpdf/plugin-zoom": "^1.3.14",
"@embedpdf/core": "^1.2.1",
"@embedpdf/engines": "^1.2.1",
"@embedpdf/plugin-interaction-manager": "^1.2.1",
"@embedpdf/plugin-loader": "^1.2.1",
"@embedpdf/plugin-pan": "^1.2.1",
"@embedpdf/plugin-render": "^1.2.1",
"@embedpdf/plugin-rotate": "^1.2.1",
"@embedpdf/plugin-scroll": "^1.2.1",
"@embedpdf/plugin-search": "^1.2.1",
"@embedpdf/plugin-selection": "^1.2.1",
"@embedpdf/plugin-spread": "^1.2.1",
"@embedpdf/plugin-thumbnail": "^1.2.1",
"@embedpdf/plugin-tiling": "^1.2.1",
"@embedpdf/plugin-viewport": "^1.2.1",
"@embedpdf/plugin-zoom": "^1.2.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -37,7 +34,6 @@
"@tanstack/react-virtual": "^3.13.12",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^16.4.0",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
@@ -50,7 +46,6 @@
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
"react-router-dom": "^7.9.1",
"signature_pad": "^5.0.4",
"tailwindcss": "^4.1.13",
"web-vitals": "^5.1.0"
},
@@ -106,10 +101,6 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/gapi": "^0.0.47",
"@types/gapi.client.drive-v3": "^0.0.5",
"@types/google.accounts": "^0.0.18",
"@types/google.picker": "^0.0.51",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
File diff suppressed because it is too large Load Diff
+81 -308
View File
@@ -87,10 +87,7 @@
"showStack": "Stack-Trace anzeigen",
"copyStack": "Stack-Trace kopieren",
"githubSubmit": "GitHub - Ein Ticket einreichen",
"discordSubmit": "Discord - Unterstützungsbeitrag einreichen",
"dismissAllErrors": "Alle Fehler ausblenden",
"encryptedPdfMustRemovePassword": "Diese PDF ist verschlüsselt oder passwortgeschützt. Bitte entsperren Sie sie, bevor Sie in PDF/A konvertieren.",
"incorrectPasswordProvided": "Das PDF-Passwort ist falsch oder wurde nicht angegeben."
"discordSubmit": "Discord - Unterstützungsbeitrag einreichen"
},
"warning": {
"tooltipTitle": "Warnung"
@@ -361,223 +358,179 @@
"sortBy": "Sortieren nach:",
"multiTool": {
"title": "PDF-Multitool",
"desc": "Seiten zusammenführen, drehen, neu anordnen und entfernen",
"tags": "mehrere,werkzeuge"
"desc": "Seiten zusammenführen, drehen, neu anordnen und entfernen"
},
"merge": {
"title": "Zusammenführen",
"desc": "Mehrere PDF-Dateien zu einer einzigen zusammenführen",
"tags": "kombinieren,zusammenführen,vereinen"
"desc": "Mehrere PDF-Dateien zu einer einzigen zusammenführen"
},
"split": {
"title": "Aufteilen",
"desc": "PDFs in mehrere Dokumente aufteilen",
"tags": "teilen,trennen,aufteilen"
"desc": "PDFs in mehrere Dokumente aufteilen"
},
"rotate": {
"title": "Drehen",
"desc": "Drehen Sie Ihre PDFs ganz einfach",
"tags": "drehen,spiegeln,ausrichten"
"desc": "Drehen Sie Ihre PDFs ganz einfach"
},
"convert": {
"title": "Umwandeln",
"desc": "Dateien zwischen verschiedenen Formaten konvertieren",
"tags": "umwandeln,ändern"
"desc": "Dateien zwischen verschiedenen Formaten konvertieren"
},
"pdfOrganiser": {
"title": "Organisieren",
"desc": "Seiten entfernen und Seitenreihenfolge ändern",
"tags": "organisieren,umordnen,neu anordnen"
"desc": "Seiten entfernen und Seitenreihenfolge ändern"
},
"addImage": {
"title": "Bild einfügen",
"desc": "Fügt ein Bild an eine bestimmte Stelle im PDF ein (in Arbeit)",
"tags": "einfügen,einbetten,platzieren"
"desc": "Fügt ein Bild an eine bestimmte Stelle im PDF ein (in Arbeit)"
},
"addAttachments": {
"title": "Anhänge hinzufügen",
"desc": "Eingebettete Dateien (Anhänge) zu einer PDF hinzufügen oder entfernen",
"tags": "einbetten,anhängen,einfügen"
"desc": "Eingebettete Dateien (Anhänge) zu einer PDF hinzufügen oder entfernen"
},
"watermark": {
"title": "Wasserzeichen hinzufügen",
"desc": "Fügen Sie ein eigenes Wasserzeichen zu Ihrem PDF hinzu",
"tags": "stempel,markierung,überlagerung"
"desc": "Fügen Sie ein eigenes Wasserzeichen zu Ihrem PDF hinzu"
},
"removePassword": {
"title": "Passwort entfernen",
"desc": "Den Passwortschutz eines PDFs entfernen",
"tags": "entsperren"
"desc": "Den Passwortschutz eines PDFs entfernen"
},
"compress": {
"title": "Komprimieren",
"desc": "PDF komprimieren um die Dateigröße zu reduzieren",
"tags": "verkleinern,reduzieren,optimieren"
"desc": "PDF komprimieren um die Dateigröße zu reduzieren"
},
"unlockPDFForms": {
"title": "Schreibgeschützte PDF-Formfelder entfernen",
"desc": "Entfernen Sie die schreibgeschützte Eigenschaft von Formularfeldern in einem PDF-Dokument.",
"tags": "entsperren,aktivieren,bearbeiten"
"desc": "Entfernen Sie die schreibgeschützte Eigenschaft von Formularfeldern in einem PDF-Dokument."
},
"changeMetadata": {
"title": "Metadaten ändern",
"desc": "Ändern/Entfernen/Hinzufügen von Metadaten aus einem PDF-Dokument",
"tags": "bearbeiten,ändern,aktualisieren"
"desc": "Ändern/Entfernen/Hinzufügen von Metadaten aus einem PDF-Dokument"
},
"ocr": {
"title": "Führe OCR/Cleanup-Scans aus",
"desc": "Cleanup scannt und erkennt Text aus Bildern in einer PDF-Datei und fügt ihn erneut als Text hinzu",
"tags": "extrahieren,scannen"
"desc": "Cleanup scannt und erkennt Text aus Bildern in einer PDF-Datei und fügt ihn erneut als Text hinzu"
},
"extractImages": {
"title": "Bilder extrahieren",
"desc": "Extrahiert alle Bilder aus einer PDF-Datei und speichert sie als Zip-Archiv",
"tags": "extrahieren,speichern,exportieren"
"desc": "Extrahiert alle Bilder aus einer PDF-Datei und speichert sie als Zip-Archiv"
},
"scannerImageSplit": {
"title": "Gescannte Fotos erkennen/aufteilen",
"desc": "Teilt mehrere Fotos aus einem Foto/PDF auf",
"tags": "erkennen,teilen,fotos"
"desc": "Teilt mehrere Fotos aus einem Foto/PDF auf"
},
"sign": {
"title": "Signieren",
"desc": "Fügt PDF-Signaturen durch Zeichnung, Text oder Bild hinzu",
"tags": "unterschrift,autogramm"
"desc": "Fügt PDF-Signaturen durch Zeichnung, Text oder Bild hinzu"
},
"flatten": {
"title": "Abflachen",
"desc": "Alle interaktiven Elemente und Formulare aus einem PDF entfernen",
"tags": "vereinfachen,entfernen,interaktiv"
"desc": "Alle interaktiven Elemente und Formulare aus einem PDF entfernen"
},
"certSign": {
"title": "Mit Zertifikat signieren",
"desc": "Ein PDF mit einem Zertifikat/Schlüssel (PEM/P12) signieren",
"tags": "authentifizieren,PEM,P12,offiziell,verschlüsseln,signieren,zertifikat,PKCS12,JKS,server,manuell,auto"
"desc": "Ein PDF mit einem Zertifikat/Schlüssel (PEM/P12) signieren"
},
"repair": {
"title": "Reparatur",
"desc": "Versucht, ein beschädigtes/kaputtes PDF zu reparieren",
"tags": "reparieren,wiederherstellen"
"desc": "Versucht, ein beschädigtes/kaputtes PDF zu reparieren"
},
"removeBlanks": {
"title": "Leere Seiten entfernen",
"desc": "Erkennt und entfernt leere Seiten aus einem Dokument",
"tags": "löschen,bereinigen,leer"
"desc": "Erkennt und entfernt leere Seiten aus einem Dokument"
},
"removeAnnotations": {
"title": "Anmerkungen entfernen",
"desc": "Entfernt alle Kommentare/Anmerkungen aus einem PDF",
"tags": "löschen,bereinigen,entfernen"
"desc": "Entfernt alle Kommentare/Anmerkungen aus einem PDF"
},
"compare": {
"title": "Vergleichen",
"desc": "Vergleicht und zeigt die Unterschiede zwischen zwei PDF-Dokumenten an",
"tags": "unterschied"
"desc": "Vergleicht und zeigt die Unterschiede zwischen zwei PDF-Dokumenten an"
},
"removeCertSign": {
"title": "Zertifikatsignatur entfernen",
"desc": "Zertifikatsignatur aus PDF entfernen",
"tags": "entfernen,löschen,entsperren"
"desc": "Zertifikatsignatur aus PDF entfernen"
},
"pageLayout": {
"title": "Mehrseitiges Layout",
"desc": "Mehrere Seiten eines PDF zu einer Seite zusammenführen",
"tags": "layout,anordnen,kombinieren"
"desc": "Mehrere Seiten eines PDF zu einer Seite zusammenführen"
},
"bookletImposition": {
"title": "Broschüren-Layout",
"desc": "Broschüren mit korrekter Seitenreihenfolge und mehrseitigem Layout für Druck und Bindung erstellen",
"tags": "broschüre,druck,bindung"
"desc": "Broschüren mit korrekter Seitenreihenfolge und mehrseitigem Layout für Druck und Bindung erstellen"
},
"scalePages": {
"title": "Seitengröße/Skalierung anpassen",
"desc": "Größe/Skalierung der Seite und/oder des Inhalts ändern",
"tags": "größe ändern,anpassen,skalieren"
"desc": "Größe/Skalierung der Seite und/oder des Inhalts ändern"
},
"addPageNumbers": {
"title": "Seitenzahlen hinzufügen",
"desc": "Hinzufügen von Seitenzahlen an einer bestimmten Stelle",
"tags": "nummerieren,paginierung,zählen"
"desc": "Hinzufügen von Seitenzahlen an einer bestimmten Stelle"
},
"autoRename": {
"title": "PDF-Datei automatisch umbenennen",
"desc": "Benennt eine PDF-Datei automatisch basierend auf der erkannten Überschrift um",
"tags": "auto-erkennung,kopfzeilen-basiert,organisieren,umbenennen"
"desc": "Benennt eine PDF-Datei automatisch basierend auf der erkannten Überschrift um"
},
"adjustContrast": {
"title": "Farben/Kontrast anpassen",
"desc": "Kontrast, Sättigung und Helligkeit einer PDF anpassen",
"tags": "kontrast,helligkeit,sättigung"
"desc": "Kontrast, Sättigung und Helligkeit einer PDF anpassen"
},
"crop": {
"title": "PDF zuschneiden",
"desc": "PDF zuschneiden um die Größe zu verändern (Text bleibt erhalten!)",
"tags": "zuschneiden,schneiden,größe ändern"
"desc": "PDF zuschneiden um die Größe zu verändern (Text bleibt erhalten!)"
},
"autoSplitPDF": {
"title": "PDF automatisch teilen",
"desc": "Physisch gescannte PDF anhand von Splitter-Seiten und QR-Codes aufteilen",
"tags": "auto,teilen,QR"
"desc": "Physisch gescannte PDF anhand von Splitter-Seiten und QR-Codes aufteilen"
},
"sanitize": {
"title": "Bereinigen",
"desc": "Potentiell schädliche Elemente aus PDF-Dateien entfernen",
"tags": "bereinigen,löschen,entfernen"
"desc": "Potentiell schädliche Elemente aus PDF-Dateien entfernen"
},
"getPdfInfo": {
"title": "Alle Informationen anzeigen",
"desc": "Erfasst alle möglichen Informationen in einer PDF",
"tags": "info,metadaten,details"
"desc": "Erfasst alle möglichen Informationen in einer PDF"
},
"pdfToSinglePage": {
"title": "PDF zu einer Seite zusammenfassen",
"desc": "Fügt alle PDF-Seiten zu einer einzigen großen Seite zusammen",
"tags": "kombinieren,zusammenführen,einzeln"
"desc": "Fügt alle PDF-Seiten zu einer einzigen großen Seite zusammen"
},
"showJS": {
"title": "Javascript anzeigen",
"desc": "Alle Javascript Funktionen in einer PDF anzeigen",
"tags": "javascript,code,skript"
"desc": "Alle Javascript Funktionen in einer PDF anzeigen"
},
"redact": {
"title": "Manuell zensieren/schwärzen",
"desc": "Zensiere (Schwärze) eine PDF-Datei durch Auswählen von Text, gezeichneten Formen und/oder ausgewählten Seite(n)",
"tags": "zensieren,schwärzen,verbergen"
"desc": "Zensiere (Schwärze) eine PDF-Datei durch Auswählen von Text, gezeichneten Formen und/oder ausgewählten Seite(n)"
},
"overlayPdfs": {
"title": "PDFs überlagern",
"desc": "PDFs über eine andere PDF überlagern",
"tags": "überlagern,kombinieren,stapeln"
"desc": "PDFs über eine andere PDF überlagern"
},
"splitBySections": {
"title": "PDF nach Abschnitten aufteilen",
"desc": "Jede Seite einer PDF in kleinere horizontale und vertikale Abschnitte unterteilen",
"tags": "teilen,abschnitte,aufteilen"
"desc": "Jede Seite einer PDF in kleinere horizontale und vertikale Abschnitte unterteilen"
},
"addStamp": {
"title": "Stempel zu PDF hinzufügen",
"desc": "Text- oder Bildstempel an festgelegten Positionen hinzufügen",
"tags": "stempel,markierung,siegel"
"desc": "Text- oder Bildstempel an festgelegten Positionen hinzufügen"
},
"removeImage": {
"title": "Bild entfernen",
"desc": "Bild aus PDF entfernen, um die Dateigröße zu verringern",
"tags": "entfernen,löschen,bereinigen"
"desc": "Bild aus PDF entfernen, um die Dateigröße zu verringern"
},
"splitByChapters": {
"title": "PDF-Datei nach Kapiteln aufteilen",
"desc": "Aufteilung einer PDF-Datei in mehrere Dateien auf Basis der Kapitelstruktur.",
"tags": "teilen,kapitel,struktur"
"desc": "Aufteilung einer PDF-Datei in mehrere Dateien auf Basis der Kapitelstruktur."
},
"validateSignature": {
"title": "PDF-Signatur überprüfen",
"desc": "Digitale Signaturen und Zertifikate in PDF-Dokumenten überprüfen",
"tags": "validieren,überprüfen,zertifikat"
"desc": "Digitale Signaturen und Zertifikate in PDF-Dokumenten überprüfen"
},
"swagger": {
"title": "API-Dokumentation",
"desc": "API-Dokumentation anzeigen und Endpunkte testen",
"tags": "API,dokumentation,test"
"desc": "API-Dokumentation anzeigen und Endpunkte testen"
},
"fakeScan": {
"title": "Scan simulieren",
@@ -585,52 +538,42 @@
},
"editTableOfContents": {
"title": "Inhaltsverzeichnis bearbeiten",
"desc": "Hinzufügen oder Bearbeiten von Lesezeichen und Inhaltsverzeichnissen in PDF-Dokumenten",
"tags": "lesezeichen,inhalt,bearbeiten"
"desc": "Hinzufügen oder Bearbeiten von Lesezeichen und Inhaltsverzeichnissen in PDF-Dokumenten"
},
"manageCertificates": {
"title": "Zertifikate verwalten",
"desc": "Digitale Zertifikatsdateien für die PDF-Signierung importieren, exportieren oder löschen.",
"tags": "zertifikate,importieren,exportieren"
"desc": "Digitale Zertifikatsdateien für die PDF-Signierung importieren, exportieren oder löschen."
},
"read": {
"title": "Lesen",
"desc": "PDFs anzeigen und kommentieren. Text hervorheben, zeichnen oder Kommentare für Überprüfung und Zusammenarbeit einfügen.",
"tags": "anzeigen,öffnen,anzeigen"
"desc": "PDFs anzeigen und kommentieren. Text hervorheben, zeichnen oder Kommentare für Überprüfung und Zusammenarbeit einfügen."
},
"reorganizePages": {
"title": "Seiten neu anordnen",
"desc": "PDF-Seiten mit visueller Drag-and-Drop-Steuerung neu anordnen, duplizieren oder löschen.",
"tags": "umordnen,neu anordnen,organisieren"
"desc": "PDF-Seiten mit visueller Drag-and-Drop-Steuerung neu anordnen, duplizieren oder löschen."
},
"extractPages": {
"title": "Seiten extrahieren",
"desc": "Spezifische Seiten aus einem PDF-Dokument extrahieren",
"tags": "extrahieren,auswählen,kopieren"
"desc": "Spezifische Seiten aus einem PDF-Dokument extrahieren"
},
"removePages": {
"title": "Entfernen",
"desc": "Ungewollte Seiten aus dem PDF entfernen",
"tags": "löschen,extrahieren,ausschließen"
"desc": "Ungewollte Seiten aus dem PDF entfernen"
},
"autoSizeSplitPDF": {
"title": "Teilen nach Größe/Anzahl",
"desc": "Teilen Sie ein einzelnes PDF basierend auf Größe, Seitenanzahl oder Dokumentanzahl in mehrere Dokumente auf",
"tags": "auto,teilen,größe"
"desc": "Teilen Sie ein einzelnes PDF basierend auf Größe, Seitenanzahl oder Dokumentanzahl in mehrere Dokumente auf"
},
"replaceColorPdf": {
"title": "Farbe ersetzen und invertieren",
"desc": "Ersetzen Sie die Farbe des Texts und Hintergrund der PDF-Datei und invertieren Sie die komplette Farbe der PDF-Datei, um die Dateigröße zu reduzieren"
},
"devApi": {
"desc": "Link zur API-Dokumentation",
"tags": "API,entwicklung,dokumentation",
"title": "API"
"desc": "Link zur API-Dokumentation"
},
"devFolderScanning": {
"title": "Automatische Ordnerüberwachung",
"desc": "Link zum Leitfaden für automatisches Ordner-Scannen",
"tags": "automatisierung,ordner,scannen"
"desc": "Link zum Leitfaden für automatisches Ordner-Scannen"
},
"devSsoGuide": {
"title": "SSO-Anleitung",
@@ -650,17 +593,7 @@
},
"automate": {
"title": "Automatisieren",
"desc": "Mehrstufige Arbeitsabläufe durch Verkettung von PDF-Aktionen erstellen. Ideal für wiederkehrende Aufgaben.",
"tags": "arbeitsablauf,sequenz,automatisierung"
},
"replaceColor": {
"desc": "Farben in PDF-Dokumenten ersetzen oder invertieren",
"title": "Farbe ersetzen & invertieren"
},
"scannerEffect": {
"desc": "Erstellen Sie eine PDF, die aussieht, als wäre sie gescannt worden",
"tags": "scannen,simulieren,erstellen",
"title": "Scanner-Effekt"
"desc": "Mehrstufige Arbeitsabläufe durch Verkettung von PDF-Aktionen erstellen. Ideal für wiederkehrende Aufgaben."
}
},
"landing": {
@@ -700,18 +633,8 @@
"merge": {
"tags": "zusammenführen,seitenvorgänge,back end,serverseitig",
"title": "Zusammenführen",
"removeDigitalSignature": {
"tooltip": {
"description": "Digitale Signaturen werden beim Zusammenführen von Dateien ungültig. Aktivieren Sie diese Option, um sie aus der endgültigen zusammengeführten PDF zu entfernen.",
"title": "Digitale Signatur entfernen"
}
},
"generateTableOfContents": {
"tooltip": {
"description": "Erstellt automatisch ein klickbares Inhaltsverzeichnis in der zusammengeführten PDF basierend auf den ursprünglichen Dateinamen und Seitenzahlen.",
"title": "Inhaltsverzeichnis generieren"
}
},
"removeDigitalSignature": "Digitale Signatur in der zusammengeführten Datei entfernen?",
"generateTableOfContents": "Inhaltsverzeichnis in der zusammengeführten Datei erstellen?",
"submit": "Zusammenführen",
"sortBy": {
"description": "Dateien werden in der Reihenfolge zusammengeführt, in der sie ausgewählt wurden. Ziehen Sie zum Neuordnen oder sortieren Sie unten.",
@@ -937,13 +860,7 @@
"images": "Bilder",
"officeDocs": "Office-Dokumente (Word, Excel, PowerPoint)",
"imagesExt": "Bilder (JPG, PNG, usw.)",
"grayscale": "Graustufen",
"dpi": "DPI",
"markdown": "Markdown",
"odtExt": "OpenDocument Text (.odt)",
"pptExt": "PowerPoint (.pptx)",
"rtfExt": "Rich Text Format (.rtf)",
"textRtf": "Text/RTF"
"grayscale": "Graustufen"
},
"imageToPdf": {
"tags": "konvertierung,img,jpg,bild,foto"
@@ -983,20 +900,7 @@
"10": "Ungerade-Gerade-Zusammenführung",
"11": "Alle Seiten duplizieren"
},
"placeholder": "(z.B. 1,3,2 oder 4-8,2,10-12 oder 2n-1)",
"desc": {
"BOOKLET_SORT": "Seiten für den Broschüren-Druck anordnen (letzte, erste, zweite, vorletzte, …).",
"CUSTOM": "Verwenden Sie eine benutzerdefinierte Sequenz von Seitenzahlen oder Ausdrücken, um eine neue Reihenfolge zu definieren.",
"DUPLEX_SORT": "Vorder- und Rückseiten verschachteln, als ob ein Duplex-Scanner alle Vorderseiten und dann alle Rückseiten gescannt hätte (1, n, 2, n-1, …).",
"DUPLICATE": "Jede Seite entsprechend der benutzerdefinierten Anzahl duplizieren (z.B. 4 dupliziert jede Seite 4×).",
"ODD_EVEN_MERGE": "Zwei PDFs durch abwechselnde Seiten zusammenführen: ungerade aus der ersten, gerade aus der zweiten.",
"ODD_EVEN_SPLIT": "Das Dokument in zwei Ausgaben aufteilen: alle ungeraden Seiten und alle geraden Seiten.",
"REMOVE_FIRST": "Die erste Seite aus dem Dokument entfernen.",
"REMOVE_FIRST_AND_LAST": "Sowohl die erste als auch die letzte Seite aus dem Dokument entfernen.",
"REMOVE_LAST": "Die letzte Seite aus dem Dokument entfernen.",
"REVERSE_ORDER": "Das Dokument umkehren, sodass die letzte Seite zur ersten wird usw.",
"SIDE_STITCH_BOOKLET_SORT": "Seiten für den Seitenheft-Broschüren-Druck anordnen (optimiert für die Bindung an der Seite)."
}
"placeholder": "(z.B. 1,3,2 oder 4-8,2,10-12 oder 2n-1)"
},
"addImage": {
"tags": "img,jpg,bild,foto",
@@ -1025,8 +929,7 @@
"failed": "Ein Fehler ist beim Hinzufügen des Wasserzeichens zur PDF aufgetreten."
},
"watermarkType": {
"image": "Bild",
"text": "Text"
"image": "Bild"
},
"settings": {
"type": "Wasserzeichen-Typ",
@@ -1430,9 +1333,7 @@
},
"trapped": {
"label": "Trapped-Status",
"unknown": "Unbekannt",
"false": "Falsch",
"true": "Wahr"
"unknown": "Unbekannt"
},
"advanced": {
"title": "Erweiterte Optionen"
@@ -1621,13 +1522,7 @@
"header": "Bilder extrahieren",
"selectText": "Wählen Sie das Bildformat aus, in das extrahierte Bilder konvertiert werden sollen",
"allowDuplicates": "Doppelte Bilder speichern",
"submit": "Extrahieren",
"error": {
"failed": "Beim Extrahieren der Bilder aus der PDF ist ein Fehler aufgetreten."
},
"settings": {
"title": "Einstellungen"
}
"submit": "Extrahieren"
},
"pdfToPDFA": {
"tags": "archiv,langfristig,standard,konvertierung,speicherung,aufbewahrung",
@@ -1704,14 +1599,8 @@
"title": "Signieren",
"header": "PDFs signieren",
"upload": "Bild hochladen",
"draw": {
"clear": "Löschen",
"title": "Zeichnen Sie Ihre Unterschrift"
},
"text": {
"name": "Name des Unterzeichners",
"placeholder": "Geben Sie Ihren vollständigen Namen ein"
},
"draw": "Signatur zeichnen",
"text": "Texteingabe",
"clear": "Leeren",
"add": "Signieren",
"saved": "Gespeicherte Signaturen",
@@ -1727,35 +1616,7 @@
"previous": "Vorherige Seite",
"maintainRatio": "Seitenverhältnis beibehalten ein-/ausschalten",
"undo": "Rückgängig",
"redo": "Wiederherstellen",
"activate": "Signatur-Platzierung aktivieren",
"applySignatures": "Signaturen anwenden",
"deactivate": "Signatur-Platzierung beenden",
"error": {
"failed": "Beim Signieren der PDF ist ein Fehler aufgetreten."
},
"image": {
"hint": "Laden Sie ein PNG- oder JPG-Bild Ihrer Unterschrift hoch",
"label": "Unterschriftsbild hochladen",
"placeholder": "Bilddatei auswählen"
},
"instructions": {
"title": "So fügen Sie eine Unterschrift hinzu"
},
"results": {
"title": "Signatur-Ergebnisse"
},
"steps": {
"configure": "Signatur konfigurieren"
},
"submit": "Dokument signieren",
"type": {
"canvas": "Canvas",
"draw": "Zeichnen",
"image": "Bild",
"text": "Text",
"title": "Signaturtyp"
}
"redo": "Wiederherstellen"
},
"flatten": {
"tags": "statisch,deaktivieren,nicht interaktiv,optimieren",
@@ -1774,8 +1635,7 @@
"stepTitle": "Abflachungs-Optionen",
"title": "Abflachungs-Optionen",
"flattenOnlyForms.desc": "Nur Formularfelder vereinfachen, andere interaktive Elemente unverändert lassen",
"note": "Das Abflachen entfernt interaktive Elemente aus der PDF und macht sie nicht mehr bearbeitbar.",
"flattenOnlyForms": "Nur Formulare vereinfachen"
"note": "Das Abflachen entfernt interaktive Elemente aus der PDF und macht sie nicht mehr bearbeitbar."
},
"results": {
"title": "Reduzierungs-Ergebnisse"
@@ -1833,8 +1693,7 @@
"label": "Pixel-Weißheitsschwellwert"
},
"whitePercent": {
"label": "Weiß-Prozentsatz-Schwellwert",
"unit": "%"
"label": "Weiß-Prozentsatz-Schwellwert"
},
"includeBlankPages": {
"label": "Erkannte leere Seiten einschließen"
@@ -1871,17 +1730,7 @@
"tags": "kommentare,hervorheben,notizen,markieren,entfernen",
"title": "Kommentare entfernen",
"header": "Kommentare entfernen",
"submit": "Entfernen",
"error": {
"failed": "Beim Entfernen der Anmerkungen aus der PDF ist ein Fehler aufgetreten."
},
"info": {
"description": "Dieses Werkzeug entfernt alle Anmerkungen (Kommentare, Hervorhebungen, Notizen usw.) aus Ihren PDF-Dokumenten.",
"title": "Über Anmerkungen entfernen"
},
"settings": {
"title": "Einstellungen"
}
"submit": "Entfernen"
},
"compare": {
"tags": "differenzieren,kontrastieren,verändern,analysieren",
@@ -2166,9 +2015,7 @@
},
"pageSize": {
"label": "Ziel-Seitengröße",
"keep": "Ursprüngliche Größe beibehalten",
"legal": "Legal",
"letter": "Letter"
"keep": "Ursprüngliche Größe beibehalten"
},
"submit": "Seitenskalierung anpassen",
"error": {
@@ -2459,8 +2306,7 @@
"showLayers": "Ebenen anzeigen (Doppelklick, um alle Ebenen auf den Standardzustand zurückzusetzen)",
"colourPicker": "Farbwähler",
"findCurrentOutlineItem": "Aktuelles Gliederungselement finden",
"applyChanges": "Änderungen anwenden",
"zoom": "Zoom"
"applyChanges": "Änderungen anwenden"
}
},
"tableExtraxt": {
@@ -2650,8 +2496,7 @@
"magicLinkSent": "Magic Link wurde an {{email}} gesendet! Prüfen Sie Ihre E-Mails und klicken Sie auf den Link zur Anmeldung.",
"passwordResetSent": "Passwort-Reset-Link wurde an {{email}} gesendet! Prüfen Sie Ihre E-Mails und folgen Sie den Anweisungen.",
"failedToSignIn": "Anmeldung mit {{provider}} fehlgeschlagen: {{message}}",
"unexpectedError": "Unerwarteter Fehler: {{message}}",
"debug": "Debug"
"unexpectedError": "Unerwarteter Fehler: {{message}}"
},
"signup": {
"title": "Konto erstellen",
@@ -2673,8 +2518,7 @@
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"checkEmailConfirmation": "Prüfen Sie Ihre E-Mails auf einen Bestätigungslink, um die Registrierung abzuschließen.",
"accountCreatedSuccessfully": "Konto erfolgreich erstellt! Sie können sich jetzt anmelden.",
"unexpectedError": "Unerwarteter Fehler: {{message}}",
"name": "Name"
"unexpectedError": "Unerwarteter Fehler: {{message}}"
},
"pdfToSinglePage": {
"title": "PDF zu einer Seite zusammenfassen",
@@ -3117,12 +2961,7 @@
"selectedCount": "{{count}} ausgewählt",
"download": "Herunterladen",
"delete": "Löschen",
"unsupported": "Nicht unterstützt",
"fileFormat": "Format",
"fileName": "Name",
"fileVersion": "Version",
"googleDrive": "Google Drive",
"googleDriveShort": "Drive"
"unsupported": "Nicht unterstützt"
},
"storage": {
"temporaryNotice": "Dateien werden temporär in Ihrem Browser gespeichert und können automatisch gelöscht werden",
@@ -3153,24 +2992,12 @@
"options": {
"title": "Bereinigungs-Optionen",
"note": "Wählen Sie die Elemente aus, die Sie aus der PDF entfernen möchten. Mindestens eine Option muss ausgewählt werden.",
"removeJavaScript": {
"desc": "JavaScript-Aktionen und Skripte aus der PDF entfernen"
},
"removeEmbeddedFiles": {
"desc": "Alle in der PDF eingebetteten Dateien entfernen"
},
"removeXMPMetadata": {
"desc": "XMP-Metadaten aus der PDF entfernen"
},
"removeMetadata": {
"desc": "Dokumentinformations-Metadaten (Titel, Autor usw.) entfernen"
},
"removeLinks": {
"desc": "Externe Links und Launch-Aktionen aus der PDF entfernen"
},
"removeFonts": {
"desc": "Eingebettete Schriftarten aus der PDF entfernen"
}
"removeJavaScript": "JavaScript entfernen",
"removeEmbeddedFiles": "Eingebettete Dateien entfernen",
"removeXMPMetadata": "XMP-Metadaten entfernen",
"removeMetadata": "Dokument-Metadaten entfernen",
"removeLinks": "Links entfernen",
"removeFonts": "Schriftarten entfernen"
}
},
"addPassword": {
@@ -3198,8 +3025,7 @@
"keyLength": {
"label": "Verschlüsselungsschlüssellänge",
"40bit": "40-bit (Niedrig)",
"256bit": "256-bit (Hoch)",
"128bit": "128-bit (Standard)"
"256bit": "256-bit (Hoch)"
}
},
"results": {
@@ -3438,58 +3264,5 @@
},
"generateError": "Wir konnten Ihren API-Schlüssel nicht generieren."
}
},
"AddAttachmentsRequest": {
"addMoreFiles": "Weitere Dateien hinzufügen...",
"attachments": "Anhänge auswählen",
"info": "Wählen Sie Dateien aus, die Sie Ihrer PDF anhängen möchten. Diese Dateien werden eingebettet und über das Anhangs-Panel der PDF zugänglich sein.",
"placeholder": "Dateien auswählen...",
"results": {
"title": "Anhangs-Ergebnisse"
},
"selectFiles": "Dateien zum Anhängen auswählen",
"selectedFiles": "Ausgewählte Dateien",
"submit": "Anhänge hinzufügen"
},
"applyAndContinue": "Anwenden & Fortfahren",
"discardChanges": "Änderungen verwerfen",
"exportAndContinue": "Exportieren & Fortfahren",
"keepWorking": "Weiterarbeiten",
"logOut": "Abmelden",
"replaceColor": {
"tags": "Farbe ersetzen,Seitenoperationen,Backend,serverseitig"
},
"scannerImageSplit": {
"error": {
"failed": "Beim Extrahieren der Bild-Scans ist ein Fehler aufgetreten."
},
"submit": "Bild-Scans extrahieren",
"title": "Extrahierte Bilder",
"tooltip": {
"headsUp": "Hinweis",
"headsUpDesc": "Überlappende Fotos oder Hintergründe, die farblich sehr nah an den Fotos liegen, können die Genauigkeit verringern - versuchen Sie einen helleren oder dunkleren Hintergrund und lassen Sie mehr Platz.",
"problem1": "Fotos nicht erkannt → Toleranz auf 30-50 erhöhen",
"problem2": "Zu viele Falscherkennungen → Mindestfläche auf 15.000-20.000 erhöhen",
"problem3": "Zuschnitte sind zu eng → Randgröße auf 5-10 erhöhen",
"problem4": "Geneigte Fotos nicht begradigt → Winkelschwelle auf ~5° senken",
"problem5": "Staub-/Rausch-Boxen → Mindest-Konturfläche auf 1000-2000 erhöhen",
"quickFixes": "Schnelle Lösungen",
"setupTips": "Einrichtungstipps",
"tip1": "Verwenden Sie einen einfachen, hellen Hintergrund",
"tip2": "Lassen Sie einen kleinen Abstand (≈1 cm) zwischen den Fotos",
"tip3": "Scannen Sie mit 300-600 DPI",
"tip4": "Reinigen Sie die Scanner-Glasplatte",
"title": "Foto-Teiler",
"useCase1": "Ganze Album-Seiten in einem Durchgang scannen",
"useCase2": "Flachbett-Stapel in separate Dateien aufteilen",
"useCase3": "Collagen in einzelne Fotos aufteilen",
"useCase4": "Fotos aus Dokumenten extrahieren",
"whatThisDoes": "Was dies tut",
"whatThisDoesDesc": "Findet und extrahiert automatisch jedes Foto von einer gescannten Seite oder einem zusammengesetzten Bild - kein manuelles Zuschneiden erforderlich.",
"whenToUse": "Wann zu verwenden"
}
},
"termsAndConditions": "Allgemeine Geschäftsbedingungen",
"unsavedChanges": "Sie haben ungespeicherte Änderungen an Ihrer PDF. Was möchten Sie tun?",
"unsavedChangesTitle": "Ungespeicherte Änderungen"
}
}
+58 -373
View File
@@ -1,28 +1,4 @@
{
"toolPanel": {
"modePrompt": {
"title": "Choose how you browse tools",
"description": "Preview both layouts and decide how you want to explore Stirling PDF tools.",
"sidebarTitle": "Sidebar mode",
"sidebarDescription": "Keep tools alongside your workspace for quick switching.",
"recommended": "Recommended",
"chooseSidebar": "Use sidebar mode",
"fullscreenTitle": "Fullscreen mode - (legacy)",
"fullscreenDescription": "Browse every tool in a catalogue that covers the workspace until you pick one.",
"chooseFullscreen": "Use fullscreen mode",
"dismiss": "Maybe later"
},
"fullscreen": {
"showDetails": "Show Details"
}
},
"unsavedChanges": "You have unsaved changes to your PDF.",
"areYouSure": "Are you sure you want to leave?",
"unsavedChangesTitle": "Unsaved Changes",
"keepWorking": "Keep Working",
"discardChanges": "Discard & Leave",
"applyAndContinue": "Save & Leave",
"exportAndContinue": "Export & Continue",
"language": {
"direction": "ltr"
},
@@ -34,34 +10,16 @@
"selectText": {
"1": "Select PDF file:",
"2": "Margin Size",
"3": "Position Selection",
"3": "Position",
"4": "Starting Number",
"5": "Pages to Number",
"6": "Custom Text Format"
"6": "Custom Text"
},
"customTextDesc": "Custom Text",
"numberPagesDesc": "e.g., 1,3,5-8 or leave blank for all pages",
"customNumberDesc": "e.g., \"Page {n}\" or leave blank for just numbers",
"submit": "Add Page Numbers",
"configuration": "Configuration",
"customize": "Customize Appearance",
"pagesAndStarting": "Pages & Starting Number",
"positionAndPages": "Position & Pages",
"error": {
"failed": "Add page numbers operation failed"
},
"results": {
"title": "Page Number Results"
},
"preview": "Position Selection",
"previewDisclaimer": "Preview is approximate. Final output may vary due to PDF font metrics."
"numberPagesDesc": "Which pages to number, default 'all', also accepts 1-5 or 2,5,9 etc",
"customNumberDesc": "Defaults to {n}, also accepts 'Page {n} of {total}', 'Text-{n}', '{filename}-{n}",
"submit": "Add Page Numbers"
},
"pageSelectionPrompt": "Specify which pages to add numbers to. Examples: \"1,3,5\" for specific pages, \"1-5\" for ranges, \"2n\" for even pages, or leave blank for all pages.",
"startingNumberTooltip": "The first number to display. Subsequent pages will increment from this number.",
"marginTooltip": "Distance between the page number and the edge of the page.",
"fontSizeTooltip": "Size of the page number text in points. Larger numbers create bigger text.",
"fontTypeTooltip": "Font family for the page numbers. Choose based on your document style.",
"customTextTooltip": "Optional custom format for page numbers. Use {n} as placeholder for the number. Example: \"Page {n}\" will show \"Page 1\", \"Page 2\", etc.",
"pdfPrompt": "Select PDF(s)",
"multiPdfPrompt": "Select PDFs (2+)",
"multiPdfDropPrompt": "Select (or drag & drop) all PDFs you require",
@@ -83,8 +41,6 @@
"save": "Save",
"saveToBrowser": "Save to Browser",
"download": "Download",
"pin": "Pin",
"unpin": "Unpin",
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
"undo": "Undo",
"moreOptions": "More Options",
@@ -118,10 +74,7 @@
},
"error": {
"pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect",
"encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.",
"incorrectPasswordProvided": "The PDF password is incorrect or not provided.",
"_value": "Error",
"dismissAllErrors": "Dismiss All Errors",
"sorry": "Sorry for the issue!",
"needHelp": "Need help / Found an issue?",
"contactTip": "If you're still having trouble, don't hesitate to reach out to us for help. You can submit a ticket on our GitHub page or contact us through Discord:",
@@ -275,33 +228,6 @@
"cacheInputs": {
"name": "Save form inputs",
"help": "Enable to store previously used inputs for future runs"
},
"general": {
"title": "General",
"description": "Configure general application preferences.",
"autoUnzip": "Auto-unzip API responses",
"autoUnzipDescription": "Automatically extract files from ZIP responses",
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
"autoUnzipFileLimit": "Auto-unzip file limit",
"autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP",
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs."
},
"hotkeys": {
"title": "Keyboard Shortcuts",
"description": "Hover a tool to see its shortcut or customise it below. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel.",
"errorModifier": {
"mac": "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.",
"windows": "Include Ctrl, Alt, or another modifier in your shortcut."
},
"errorConflict": "Shortcut already used by {{tool}}.",
"none": "Not assigned",
"customBadge": "Custom",
"defaultLabel": "Default: {{shortcut}}",
"capturing": "Press keys… (Esc to cancel)",
"change": "Change shortcut",
"reset": "Reset",
"shortcut": "Shortcut",
"noShortcut": "No shortcut set"
}
},
"changeCreds": {
@@ -431,271 +357,222 @@
"globalPopularity": "Global Popularity",
"sortBy": "Sort by:",
"multiTool": {
"tags": "multiple,tools",
"title": "PDF Multi Tool",
"desc": "Merge, Rotate, Rearrange, Split, and Remove pages"
},
"merge": {
"tags": "combine,join,unite",
"title": "Merge",
"desc": "Easily merge multiple PDFs into one."
},
"split": {
"tags": "divide,separate,break",
"title": "Split",
"desc": "Split PDFs into multiple documents"
},
"rotate": {
"tags": "turn,flip,orient",
"title": "Rotate",
"desc": "Easily rotate your PDFs."
},
"convert": {
"tags": "transform,change",
"title": "Convert",
"desc": "Convert files between different formats"
},
"pdfOrganiser": {
"tags": "organize,rearrange,reorder",
"title": "Organise",
"desc": "Remove/Rearrange pages in any order"
},
"addImage": {
"tags": "insert,embed,place",
"title": "Add image",
"desc": "Adds a image onto a set location on the PDF"
},
"addAttachments": {
"tags": "embed,attach,include",
"title": "Add Attachments",
"desc": "Add or remove embedded files (attachments) to/from a PDF"
},
"watermark": {
"tags": "stamp,mark,overlay",
"title": "Add Watermark",
"desc": "Add a custom watermark to your PDF document."
},
"removePassword": {
"tags": "unlock",
"title": "Remove Password",
"desc": "Remove password protection from your PDF document."
},
"compress": {
"tags": "shrink,reduce,optimize",
"title": "Compress",
"desc": "Compress PDFs to reduce their file size."
},
"unlockPDFForms": {
"tags": "unlock,enable,edit",
"title": "Unlock PDF Forms",
"desc": "Remove read-only property of form fields in a PDF document."
},
"changeMetadata": {
"tags": "edit,modify,update",
"title": "Change Metadata",
"desc": "Change/Remove/Add metadata from a PDF document"
},
"ocr": {
"tags": "extract,scan",
"title": "OCR / Cleanup scans",
"desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text."
},
"extractImages": {
"tags": "pull,save,export",
"title": "Extract Images",
"desc": "Extracts all images from a PDF and saves them to zip"
},
"scannerImageSplit": {
"tags": "detect,split,photos",
"title": "Detect & Split Scanned Photos",
"desc": "Detect and split scanned photos into separate pages"
"title": "Detect/Split Scanned photos",
"desc": "Splits multiple photos from within a photo/PDF"
},
"sign": {
"tags": "signature,autograph",
"title": "Sign",
"desc": "Adds signature to PDF by drawing, text or image"
},
"flatten": {
"tags": "simplify,remove,interactive",
"title": "Flatten",
"desc": "Remove all interactive elements and forms from a PDF"
},
"certSign": {
"tags": "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto",
"title": "Sign with Certificate",
"desc": "Signs a PDF with a Certificate/Key (PEM/P12)"
},
"repair": {
"tags": "fix,restore",
"title": "Repair",
"desc": "Tries to repair a corrupt/broken PDF"
},
"removeBlanks": {
"tags": "delete,clean,empty",
"title": "Remove Blank pages",
"desc": "Detects and removes blank pages from a document"
},
"removeAnnotations": {
"tags": "delete,clean,strip",
"title": "Remove Annotations",
"desc": "Removes all comments/annotations from a PDF"
},
"compare": {
"tags": "difference",
"title": "Compare",
"desc": "Compares and shows the differences between 2 PDF Documents"
},
"removeCertSign": {
"tags": "remove,delete,unlock",
"title": "Remove Certificate Sign",
"desc": "Remove certificate signature from PDF"
},
"pageLayout": {
"tags": "layout,arrange,combine",
"title": "Multi-Page Layout",
"desc": "Merge multiple pages of a PDF document into a single page"
},
"bookletImposition": {
"tags": "booklet,print,binding",
"title": "Booklet Imposition",
"desc": "Create booklets with proper page ordering and multi-page layout for printing and binding"
},
"scalePages": {
"tags": "resize,adjust,scale",
"title": "Adjust page size/scale",
"desc": "Change the size/scale of a page and/or its contents."
},
"addPageNumbers": {
"tags": "number,pagination,count",
"title": "Add Page Numbers",
"desc": "Add Page numbers throughout a document in a set location"
},
"autoRename": {
"tags": "auto-detect,header-based,organize,relabel",
"title": "Auto Rename PDF File",
"desc": "Auto renames a PDF file based on its detected header"
},
"adjustContrast": {
"tags": "contrast,brightness,saturation",
"title": "Adjust Colours/Contrast",
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
},
"crop": {
"tags": "trim,cut,resize",
"title": "Crop PDF",
"desc": "Crop a PDF to reduce its size (maintains text!)"
},
"autoSplitPDF": {
"tags": "auto,split,QR",
"title": "Auto Split Pages",
"desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code"
},
"sanitize": {
"tags": "clean,purge,remove",
"title": "Sanitise",
"desc": "Remove potentially harmful elements from PDF files"
},
"getPdfInfo": {
"tags": "info,metadata,details",
"title": "Get ALL Info on PDF",
"desc": "Grabs any and all information possible on PDFs"
},
"pdfToSinglePage": {
"tags": "combine,merge,single",
"title": "PDF to Single Large Page",
"desc": "Merges all PDF pages into one large single page"
},
"showJS": {
"tags": "javascript,code,script",
"title": "Show Javascript",
"desc": "Searches and displays any JS injected into a PDF"
},
"redact": {
"tags": "censor,blackout,hide",
"title": "Redact",
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
},
"overlayPdfs": {
"title": "Overlay PDFs",
"desc": "Overlays PDFs on-top of another PDF"
},
"splitBySections": {
"tags": "split,sections,divide",
"title": "Split PDF by Sections",
"desc": "Divide each page of a PDF into smaller horizontal and vertical sections"
},
"addStamp": {
"tags": "stamp,mark,seal",
"title": "Add Stamp to PDF",
"desc": "Add text or add image stamps at set locations"
},
"removeImage": {
"tags": "remove,delete,clean",
"title": "Remove image",
"desc": "Remove image from PDF to reduce file size"
},
"splitByChapters": {
"tags": "split,chapters,structure",
"title": "Split PDF by Chapters",
"desc": "Split a PDF into multiple files based on its chapter structure."
},
"validateSignature": {
"tags": "validate,verify,certificate",
"title": "Validate PDF Signature",
"desc": "Verify digital signatures and certificates in PDF documents"
},
"swagger": {
"tags": "API,documentation,test",
"title": "API Documentation",
"desc": "View API documentation and test endpoints"
},
"scannerEffect": {
"tags": "scan,simulate,create",
"title": "Scanner Effect",
"fakeScan": {
"title": "Fake Scan",
"desc": "Create a PDF that looks like it was scanned"
},
"editTableOfContents": {
"tags": "bookmarks,contents,edit",
"title": "Edit Table of Contents",
"desc": "Add or edit bookmarks and table of contents in PDF documents"
},
"manageCertificates": {
"tags": "certificates,import,export",
"title": "Manage Certificates",
"desc": "Import, export, or delete digital certificate files used for signing PDFs."
},
"read": {
"tags": "view,open,display",
"title": "Read",
"read": {
"title": "Read",
"desc": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."
},
"reorganizePages": {
"tags": "rearrange,reorder,organize",
"title": "Reorganize Pages",
"desc": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
},
"extractPages": {
"tags": "pull,select,copy",
"title": "Extract Pages",
"desc": "Extract specific pages from a PDF document"
},
"removePages": {
"tags": "delete,extract,exclude",
"title": "Remove Pages",
"desc": "Remove specific pages from a PDF document"
},
"autoSizeSplitPDF": {
"tags": "auto,split,size",
"title": "Auto Split by Size/Count",
"desc": "Automatically split PDFs by file size or page count"
},
"replaceColor": {
"replaceColorPdf": {
"title": "Replace & Invert Colour",
"desc": "Replace or invert colours in PDF documents"
},
"devApi": {
"tags": "API,development,documentation",
"title": "API",
"desc": "Link to API documentation"
},
"devFolderScanning": {
"tags": "automation,folder,scanning",
"title": "Automated Folder Scanning",
"desc": "Link to automated folder scanning guide"
},
@@ -716,7 +593,6 @@
"desc": "Change document restrictions and permissions"
},
"automate": {
"tags": "workflow,sequence,automation",
"title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
}
@@ -783,6 +659,7 @@
}
},
"split": {
"tags": "Page operations,divide,Multi Page,cut,server side",
"title": "Split PDF",
"header": "Split PDF",
"desc": {
@@ -908,9 +785,9 @@
}
},
"rotate": {
"tags": "server side",
"title": "Rotate PDF",
"submit": "Apply Rotation",
"selectRotation": "Select Rotation Angle (Clockwise)",
"error": {
"failed": "An error occurred while rotating the PDF."
},
@@ -1025,7 +902,7 @@
"header": "PDF Page Organiser",
"submit": "Rearrange Pages",
"mode": {
"_value": "Organization mode",
"_value": "Mode",
"1": "Custom Page Order",
"2": "Reverse Order",
"3": "Duplex Sort",
@@ -1038,19 +915,6 @@
"10": "Odd-Even Merge",
"11": "Duplicate all pages"
},
"desc": {
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for sidestitch booklet printing (optimised for binding on the side).",
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
"REMOVE_FIRST": "Remove the first page from the document.",
"REMOVE_LAST": "Remove the last page from the document.",
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
},
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
},
"addImage": {
@@ -1434,6 +1298,7 @@
}
},
"changeMetadata": {
"tags": "Title,author,date,creation,time,publisher,producer,stats",
"header": "Change Metadata",
"submit": "Change",
"filenamePrefix": "metadata",
@@ -1675,13 +1540,7 @@
"header": "Extract Images",
"selectText": "Select image format to convert extracted images to",
"allowDuplicates": "Save duplicate images",
"submit": "Extract",
"settings": {
"title": "Settings"
},
"error": {
"failed": "An error occurred while extracting images from the PDF."
}
"submit": "Extract"
},
"pdfToPDFA": {
"tags": "archive,long-term,standard,conversion,storage,preservation",
@@ -1741,49 +1600,20 @@
"tags": "separate,auto-detect,scans,multi-photo,organize",
"selectText": {
"1": "Angle Threshold:",
"2": "Tilt (in degrees) needed before we auto-straighten a photo.",
"2": "Sets the minimum absolute angle required for the image to be rotated (default: 10).",
"3": "Tolerance:",
"4": "How closely a colour must match the page background to count as background. Higher = looser, lower = stricter.",
"4": "Determines the range of colour variation around the estimated background colour (default: 30).",
"5": "Minimum Area:",
"6": "Smallest photo size (in pixels²) we'll keep to avoid tiny fragments.",
"6": "Sets the minimum area threshold for a photo (default: 10000).",
"7": "Minimum Contour Area:",
"8": "Smallest edge/shape we consider when finding photos (filters dust and specks).",
"8": "Sets the minimum contour area threshold for a photo",
"9": "Border Size:",
"10": "Extra padding (in pixels) around each saved photo so edges aren't cut."
"10": "Sets the size of the border added and removed to prevent white borders in the output (default: 1)."
},
"info": "Python is not installed. It is required to run."
},
"scannerImageSplit": {
"title": "Extracted Images",
"submit": "Extract Image Scans",
"error": {
"failed": "An error occurred while extracting image scans."
},
"tooltip": {
"title": "Photo Splitter",
"whatThisDoes": "What this does",
"whatThisDoesDesc": "Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.",
"whenToUse": "When to use",
"useCase1": "Scan whole album pages in one go",
"useCase2": "Split flatbed batches into separate files",
"useCase3": "Break collages into individual photos",
"useCase4": "Pull photos from documents",
"quickFixes": "Quick fixes",
"problem1": "Photos not detected → increase Tolerance to 30-50",
"problem2": "Too many false detections → increase Minimum Area to 15,000-20,000",
"problem3": "Crops are too tight → increase Border Size to 5-10",
"problem4": "Tilted photos not straightened → lower Angle Threshold to ~5°",
"problem5": "Dust/noise boxes → increase Minimum Contour Area to 1000-2000",
"setupTips": "Setup tips",
"tip1": "Use a plain, light background",
"tip2": "Leave a small gap (≈1 cm) between photos",
"tip3": "Scan at 300-600 DPI",
"tip4": "Clean the scanner glass",
"headsUp": "Heads-up",
"headsUpDesc": "Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy-try a lighter or darker background and leave more space."
}
},
"sign": {
"tags": "authorize,initials,drawn-signature,text-sign,image-signature",
"title": "Sign",
"header": "Sign PDFs",
"upload": "Upload Image",
@@ -1793,7 +1623,6 @@
"add": "Add",
"saved": "Saved Signatures",
"save": "Save Signature",
"applySignatures": "Apply Signatures",
"personalSigs": "Personal Signatures",
"sharedSigs": "Shared Signatures",
"noSavedSigs": "No saved signatures found",
@@ -1805,52 +1634,10 @@
"previous": "Previous page",
"maintainRatio": "Toggle maintain aspect ratio",
"undo": "Undo",
"redo": "Redo",
"submit": "Sign Document",
"steps": {
"configure": "Configure Signature"
},
"type": {
"title": "Signature Type",
"draw": "Draw",
"canvas": "Canvas",
"image": "Image",
"text": "Text"
},
"draw": {
"title": "Draw your signature",
"clear": "Clear"
},
"image": {
"label": "Upload signature image",
"placeholder": "Select image file",
"hint": "Upload a PNG or JPG image of your signature"
},
"text": {
"name": "Signer Name",
"placeholder": "Enter your full name"
},
"instructions": {
"title": "How to add signature",
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
"text": "After entering your name above, click anywhere on the PDF to place your signature."
},
"mode": {
"move": "Move Signature",
"place": "Place Signature"
},
"updateAndPlace": "Update and Place",
"activate": "Activate Signature Placement",
"deactivate": "Stop Placing Signatures",
"results": {
"title": "Signature Results"
},
"error": {
"failed": "An error occurred while signing the PDF."
}
"redo": "Redo"
},
"flatten": {
"tags": "static,deactivate,non-interactive,streamline",
"title": "Flatten",
"header": "Flatten PDF",
"flattenOnlyForms": "Flatten only forms",
@@ -1915,6 +1702,7 @@
}
},
"removeBlanks": {
"tags": "cleanup,streamline,non-content,organize",
"title": "Remove Blanks",
"header": "Remove Blank Pages",
"settings": {
@@ -1962,17 +1750,7 @@
"tags": "comments,highlight,notes,markup,remove",
"title": "Remove Annotations",
"header": "Remove Annotations",
"submit": "Remove",
"settings": {
"title": "Settings"
},
"info": {
"title": "About Remove Annotations",
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
},
"error": {
"failed": "An error occurred while removing annotations from the PDF."
}
"submit": "Remove"
},
"compare": {
"tags": "differentiate,contrast,changes,analysis",
@@ -2321,6 +2099,7 @@
"tags": "color-correction,tune,modify,enhance,colour-correction"
},
"crop": {
"tags": "trim,shrink,edit,shape",
"title": "Crop",
"header": "Crop PDF",
"submit": "Apply Crop",
@@ -2562,15 +2341,11 @@
"overlay-pdfs": {
"tags": "Overlay",
"header": "Overlay PDF Files",
"title": "Overlay PDFs",
"desc": "Overlay one PDF on top of another",
"baseFile": {
"label": "Select Base PDF File"
},
"overlayFiles": {
"label": "Select Overlay PDF Files",
"placeholder": "Choose PDF(s)...",
"addMore": "Add more PDFs..."
"label": "Select Overlay PDF Files"
},
"mode": {
"label": "Select Overlay Mode",
@@ -2580,49 +2355,14 @@
},
"counts": {
"label": "Overlay Counts (for Fixed Repeat Mode)",
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
"item": "Count for file"
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)"
},
"position": {
"label": "Select Overlay Position",
"foreground": "Foreground",
"background": "Background"
},
"submit": "Submit",
"settings": {
"title": "Settings"
},
"results": {
"title": "Overlay Results"
},
"tooltip": {
"header": {
"title": "Overlay PDFs Overview"
},
"description": {
"title": "Description",
"text": "Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background."
},
"mode": {
"title": "Overlay Mode",
"text": "Choose how to distribute overlay pages across the base PDF pages.",
"sequential": "Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.",
"interleaved": "Interleaved Overlay: Take one page from each overlay in turn.",
"fixedRepeat": "Fixed Repeat Overlay: Take a set number of pages from each overlay before moving to the next. Use Counts to set the numbers."
},
"position": {
"title": "Overlay Position",
"text": "Foreground places the overlay on top of the page. Background places it behind."
},
"overlayFiles": {
"title": "Overlay Files",
"text": "Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes."
},
"counts": {
"title": "Counts (Fixed Repeat only)",
"text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat."
}
}
"submit": "Submit"
},
"split-by-sections": {
"tags": "Section Split, Divide, Customize,Customise",
@@ -2711,48 +2451,25 @@
},
"selectCustomCert": "Custom Certificate File X.509 (Optional)"
},
"replaceColor": {
"labels": {
"settings": "Settings",
"colourOperation": "Colour operation"
"replace-color": {
"title": "Advanced Colour options",
"header": "Replace-Invert Colour PDF",
"selectText": {
"1": "Replace or Invert colour Options",
"2": "Default(Default high contrast colours)",
"3": "Custom(Customised colours)",
"4": "Full-Invert(Invert all colours)",
"5": "High contrast colour options",
"6": "white text on black background",
"7": "Black text on white background",
"8": "Yellow text on black background",
"9": "Green text on black background",
"10": "Choose text Colour",
"11": "Choose background Colour"
},
"options": {
"highContrast": "High contrast",
"invertAll": "Invert all colours",
"custom": "Custom"
},
"tooltip": {
"header": {
"title": "Replace & Invert Colour Settings Overview"
},
"description": {
"title": "Description",
"text": "Transform PDF colours to improve readability and accessibility. Choose from high contrast presets, invert all colours, or create custom colour schemes."
},
"highContrast": {
"title": "High Contrast",
"text": "Apply predefined high contrast colour combinations designed for better readability and accessibility compliance.",
"bullet1": "White text on black background - Classic dark mode",
"bullet2": "Black text on white background - Standard high contrast",
"bullet3": "Yellow text on black background - High visibility option",
"bullet4": "Green text on black background - Alternative high contrast"
},
"invertAll": {
"title": "Invert All Colours",
"text": "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
},
"custom": {
"title": "Custom Colours",
"text": "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements.",
"bullet1": "Text colour - Choose the colour for text elements",
"bullet2": "Background colour - Set the background colour for the document"
}
},
"error": {
"failed": "An error occurred while processing the colour replacement."
}
"submit": "Replace"
},
"replaceColor": {
"replaceColorPdf": {
"tags": "Replace Colour,Page operations,Back end,server side"
},
"login": {
@@ -2864,9 +2581,8 @@
"submit": "Sanitize PDF"
},
"adjustContrast": {
"title": "Adjust Colors/Contrast",
"header": "Adjust Colors/Contrast",
"basic": "Basic Adjustments",
"title": "Adjust Contrast",
"header": "Adjust Contrast",
"contrast": "Contrast:",
"brightness": "Brightness:",
"saturation": "Saturation:",
@@ -3153,12 +2869,7 @@
"panMode": "Pan Mode",
"rotateLeft": "Rotate Left",
"rotateRight": "Rotate Right",
"toggleSidebar": "Toggle Sidebar",
"exportSelected": "Export Selected Pages",
"toggleAnnotations": "Toggle Annotations Visibility",
"annotationMode": "Toggle Annotation Mode",
"draw": "Draw",
"save": "Save"
"toggleSidebar": "Toggle Sidebar"
},
"search": {
"title": "Search PDF",
@@ -3200,7 +2911,6 @@
"automate": "Automate",
"files": "Files",
"activity": "Activity",
"account": "Account",
"config": "Config",
"allTools": "All Tools"
},
@@ -3228,9 +2938,6 @@
"addFiles": "Add Files",
"dragFilesInOrClick": "Drag files in or click \"Add Files\" to browse"
},
"fileEditor": {
"addFiles": "Add Files"
},
"fileManager": {
"title": "Upload PDF Files",
"subtitle": "Add files to your storage for easy access across tools",
@@ -3259,7 +2966,6 @@
"lastModified": "Last Modified",
"toolChain": "Tools Applied",
"restore": "Restore",
"unzip": "Unzip",
"searchFiles": "Search files...",
"recent": "Recent",
"localFiles": "Local Files",
@@ -3267,6 +2973,7 @@
"googleDriveShort": "Drive",
"myFiles": "My Files",
"noRecentFiles": "No recent files found",
"dropFilesHint": "Drop files here to upload",
"googleDriveNotAvailable": "Google Drive integration not available",
"openFiles": "Open Files",
"openFile": "Open File",
@@ -3539,16 +3246,6 @@
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
}
},
"viewer": {
"firstPage": "First Page",
"lastPage": "Last Page",
"previousPage": "Previous Page",
"nextPage": "Next Page",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"singlePageView": "Single Page View",
"dualPageView": "Dual Page View"
},
"common": {
"copy": "Copy",
"copied": "Copied!",
@@ -3605,18 +3302,6 @@
"generateError": "We couldn't generate your API key."
}
},
"AddAttachmentsRequest": {
"attachments": "Select Attachments",
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
"selectFiles": "Select Files to Attach",
"placeholder": "Choose files...",
"addMoreFiles": "Add more files...",
"selectedFiles": "Selected Files",
"submit": "Add Attachments",
"results": {
"title": "Attachment Results"
}
},
"termsAndConditions": "Terms & Conditions",
"logOut": "Log out"
}
}
+16 -189
View File
@@ -2,23 +2,6 @@
"language": {
"direction": "ltr"
},
"toolPanel": {
"modePrompt": {
"title": "Choose how you browse tools",
"description": "Preview both layouts and decide how you want to explore Stirling PDF tools.",
"sidebarTitle": "Sidebar mode",
"sidebarDescription": "Keep tools alongside your workspace for quick switching.",
"recommended": "Recommended",
"chooseSidebar": "Use sidebar mode",
"fullscreenTitle": "Fullscreen mode - (legacy)",
"fullscreenDescription": "Browse every tool in a catalogue that covers the workspace until you pick one.",
"chooseFullscreen": "Use fullscreen mode",
"dismiss": "Maybe later"
},
"fullscreen": {
"showDetails": "Show Details"
}
},
"addPageNumbers": {
"fontSize": "Font Size",
"fontName": "Font Name",
@@ -85,8 +68,6 @@
},
"error": {
"pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect",
"encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.",
"incorrectPasswordProvided": "The PDF password is incorrect or not provided.",
"_value": "Error",
"sorry": "Sorry for the issue!",
"needHelp": "Need help / Found an issue?",
@@ -367,257 +348,206 @@
"globalPopularity": "Global Popularity",
"sortBy": "Sort by:",
"multiTool": {
"tags": "multiple,tools",
"title": "PDF Multi Tool",
"desc": "Merge, Rotate, Rearrange, Split, and Remove pages"
},
"merge": {
"tags": "combine,join,unite",
"title": "Merge",
"desc": "Easily merge multiple PDFs into one."
},
"split": {
"tags": "divide,separate,break",
"title": "Split",
"desc": "Split PDFs into multiple documents"
},
"rotate": {
"tags": "turn,flip,orient",
"title": "Rotate",
"desc": "Easily rotate your PDFs."
},
"imageToPDF": {
"tags": "convert,image,transform",
"title": "Image to PDF",
"desc": "Convert a image (PNG, JPEG, GIF) to PDF."
},
"pdfToImage": {
"tags": "convert,image,extract",
"title": "PDF to Image",
"desc": "Convert a PDF to a image. (PNG, JPEG, GIF)"
},
"pdfOrganiser": {
"tags": "organize,rearrange,reorder",
"title": "Organize",
"desc": "Remove/Rearrange pages in any order"
},
"addImage": {
"tags": "insert,embed,place",
"title": "Add image",
"desc": "Adds a image onto a set location on the PDF"
},
"watermark": {
"tags": "stamp,mark,overlay",
"title": "Add Watermark",
"desc": "Add a custom watermark to your PDF document."
},
"permissions": {
"tags": "permissions,security,access",
"title": "Change Permissions",
"desc": "Change the permissions of your PDF document"
},
"pageRemover": {
"tags": "remove,delete,pages",
"title": "Remove",
"desc": "Delete unwanted pages from your PDF document."
},
"addPassword": {
"tags": "password,encrypt,secure",
"title": "Add Password",
"desc": "Encrypt your PDF document with a password."
},
"changePermissions": {
"tags": "permissions,restrictions,security",
"title": "Change Permissions",
"desc": "Change document restrictions and permissions."
},
"removePassword": {
"tags": "unlock,remove,password",
"title": "Remove Password",
"desc": "Remove password protection from your PDF document."
},
"compress": {
"tags": "shrink,reduce,optimize",
"title": "Compress",
"desc": "Compress PDFs to reduce their file size."
},
"sanitize": {
"tags": "clean,purge,remove",
"title": "Sanitize",
"desc": "Remove potentially harmful elements from PDF files."
},
"unlockPDFForms": {
"tags": "unlock,enable,edit",
"title": "Unlock PDF Forms",
"desc": "Remove read-only property of form fields in a PDF document."
},
"changeMetadata": {
"tags": "edit,modify,update",
"title": "Change Metadata",
"desc": "Change/Remove/Add metadata from a PDF document"
},
"fileToPDF": {
"tags": "convert,transform,change",
"title": "Convert file to PDF",
"desc": "Convert nearly any file to PDF (DOCX, PNG, XLS, PPT, TXT and more)"
},
"ocr": {
"tags": "extract,scan",
"title": "OCR / Cleanup scans",
"desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text."
},
"extractImages": {
"tags": "pull,save,export",
"title": "Extract Images",
"desc": "Extracts all images from a PDF and saves them to zip"
},
"pdfToPDFA": {
"tags": "convert,archive,long-term",
"title": "PDF to PDF/A",
"desc": "Convert PDF to PDF/A for long-term storage"
},
"PDFToWord": {
"tags": "convert,word,doc",
"title": "PDF to Word",
"desc": "Convert PDF to Word formats (DOC, DOCX and ODT)"
},
"PDFToPresentation": {
"tags": "convert,presentation,ppt",
"title": "PDF to Presentation",
"desc": "Convert PDF to Presentation formats (PPT, PPTX and ODP)"
},
"PDFToText": {
"tags": "convert,text,rtf",
"title": "PDF to RTF (Text)",
"desc": "Convert PDF to Text or RTF format"
},
"PDFToHTML": {
"tags": "convert,html,web",
"title": "PDF to HTML",
"desc": "Convert PDF to HTML format"
},
"PDFToXML": {
"tags": "convert,xml,data",
"title": "PDF to XML",
"desc": "Convert PDF to XML format"
},
"ScannerImageSplit": {
"tags": "detect,split,photos",
"title": "Detect/Split Scanned photos",
"desc": "Splits multiple photos from within a photo/PDF"
},
"sign": {
"tags": "signature,autograph",
"title": "Sign",
"desc": "Adds signature to PDF by drawing, text or image"
},
"flatten": {
"tags": "simplify,remove,interactive",
"title": "Flatten",
"desc": "Remove all interactive elements and forms from a PDF"
},
"repair": {
"tags": "fix,restore",
"title": "Repair",
"desc": "Tries to repair a corrupt/broken PDF"
},
"removeBlanks": {
"tags": "delete,clean,empty",
"title": "Remove Blank pages",
"desc": "Detects and removes blank pages from a document"
},
"removeAnnotations": {
"tags": "delete,clean,strip",
"title": "Remove Annotations",
"desc": "Removes all comments/annotations from a PDF"
},
"compare": {
"tags": "difference",
"title": "Compare",
"desc": "Compares and shows the differences between 2 PDF Documents"
},
"certSign": {
"tags": "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto",
"title": "Sign with Certificate",
"desc": "Signs a PDF with a Certificate/Key (PEM/P12)"
},
"removeCertSign": {
"tags": "remove,delete,unlock",
"title": "Remove Certificate Sign",
"desc": "Remove certificate signature from PDF"
},
"pageLayout": {
"tags": "layout,arrange,combine",
"title": "Multi-Page Layout",
"desc": "Merge multiple pages of a PDF document into a single page"
},
"bookletImposition": {
"tags": "booklet,print,binding",
"title": "Booklet Imposition",
"desc": "Create booklets with proper page ordering and multi-page layout for printing and binding"
},
"scalePages": {
"tags": "resize,adjust,scale",
"title": "Adjust page size/scale",
"desc": "Change the size/scale of a page and/or its contents."
},
"pipeline": {
"tags": "automation,script,workflow",
"title": "Pipeline",
"desc": "Run multiple actions on PDFs by defining pipeline scripts"
},
"addPageNumbers": {
"tags": "number,pagination,count",
"title": "Add Page Numbers",
"desc": "Add Page numbers throughout a document in a set location"
},
"auto-rename": {
"tags": "auto-detect,header-based,organize,relabel",
"title": "Auto Rename PDF File",
"desc": "Auto renames a PDF file based on its detected header"
},
"adjustContrast": {
"tags": "contrast,brightness,saturation",
"title": "Adjust Colors/Contrast",
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
},
"crop": {
"tags": "trim,cut,resize",
"title": "Crop PDF",
"desc": "Crop a PDF to reduce its size (maintains text!)"
},
"autoSplitPDF": {
"tags": "auto,split,QR",
"title": "Auto Split Pages",
"desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code"
},
"sanitizePDF": {
"tags": "clean,purge,remove",
"title": "Sanitize",
"desc": "Remove scripts and other elements from PDF files"
},
"URLToPDF": {
"tags": "convert,url,website",
"title": "URL/Website To PDF",
"desc": "Converts any http(s)URL to PDF"
},
"HTMLToPDF": {
"tags": "convert,html,web",
"title": "HTML to PDF",
"desc": "Converts any HTML file or zip to PDF"
},
"MarkdownToPDF": {
"tags": "convert,markdown,md",
"title": "Markdown to PDF",
"desc": "Converts any Markdown file to PDF"
},
"PDFToMarkdown": {
"tags": "convert,markdown,md",
"title": "PDF to Markdown",
"desc": "Converts any PDF to Markdown"
},
"getPdfInfo": {
"tags": "info,metadata,details",
"title": "Get ALL Info on PDF",
"desc": "Grabs any and all information possible on PDFs"
},
@@ -634,62 +564,50 @@
"desc": "Searches and displays any JS injected into a PDF"
},
"autoRedact": {
"tags": "auto,redact,censor",
"title": "Auto Redact",
"desc": "Auto Redacts(Blacks out) text in a PDF based on input text"
},
"redact": {
"tags": "censor,blackout,hide",
"title": "Manual Redaction",
"desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)"
},
"PDFToCSV": {
"tags": "convert,csv,table",
"title": "PDF to CSV",
"desc": "Extracts Tables from a PDF converting it to CSV"
},
"split-by-size-or-count": {
"tags": "auto,split,size",
"title": "Auto Split by Size/Count",
"desc": "Split a single PDF into multiple documents based on size, page count, or document count"
},
"overlay-pdfs": {
"tags": "overlay,combine,stack",
"title": "Overlay PDFs",
"desc": "Overlays PDFs on-top of another PDF"
},
"split-by-sections": {
"tags": "split,sections,divide",
"title": "Split PDF by Sections",
"desc": "Divide each page of a PDF into smaller horizontal and vertical sections"
},
"AddStampRequest": {
"tags": "stamp,mark,seal",
"title": "Add Stamp to PDF",
"desc": "Add text or add image stamps at set locations"
},
"removeImage": {
"tags": "remove,delete,clean",
"title": "Remove image",
"desc": "Remove image from PDF to reduce file size"
},
"splitByChapters": {
"tags": "split,chapters,structure",
"title": "Split PDF by Chapters",
"desc": "Split a PDF into multiple files based on its chapter structure."
},
"validateSignature": {
"tags": "validate,verify,certificate",
"title": "Validate PDF Signature",
"desc": "Verify digital signatures and certificates in PDF documents"
},
"swagger": {
"tags": "API,documentation,test",
"title": "API Documentation",
"desc": "View API documentation and test endpoints"
},
"replace-color": {
"tags": "color,replace,invert",
"title": "Replace and Invert Color",
"desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size"
}
@@ -786,7 +704,7 @@
"header": "PDF Page Organizer",
"submit": "Rearrange Pages",
"mode": {
"_value": "Organization mode",
"_value": "Mode",
"1": "Custom Page Order",
"2": "Reverse Order",
"3": "Duplex Sort",
@@ -799,19 +717,6 @@
"10": "Odd-Even Merge",
"11": "Duplicate all pages"
},
"desc": {
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for sidestitch booklet printing (optimized for binding on the side).",
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
"REMOVE_FIRST": "Remove the first page from the document.",
"REMOVE_LAST": "Remove the last page from the document.",
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
},
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
},
"addImage": {
@@ -1086,13 +991,7 @@
"header": "Extract Images",
"selectText": "Select image format to convert extracted images to",
"allowDuplicates": "Save duplicate images",
"submit": "Extract",
"settings": {
"title": "Settings"
},
"error": {
"failed": "An error occurred while extracting images from the PDF."
}
"submit": "Extract"
},
"pdfToPDFA": {
"tags": "archive,long-term,standard,conversion,storage,preservation",
@@ -1165,6 +1064,7 @@
"info": "Python is not installed. It is required to run."
},
"sign": {
"tags": "authorize,initials,drawn-signature,text-sign,image-signature",
"title": "Sign",
"header": "Sign PDFs",
"upload": "Upload Image",
@@ -1260,17 +1160,7 @@
"tags": "comments,highlight,notes,markup,remove",
"title": "Remove Annotations",
"header": "Remove Annotations",
"submit": "Remove",
"settings": {
"title": "Settings"
},
"info": {
"title": "About Remove Annotations",
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
},
"error": {
"failed": "An error occurred while removing annotations from the PDF."
}
"submit": "Remove"
},
"compare": {
"tags": "differentiate,contrast,changes,analysis",
@@ -1538,15 +1428,11 @@
"overlay-pdfs": {
"tags": "Overlay",
"header": "Overlay PDF Files",
"title": "Overlay PDFs",
"desc": "Overlay one PDF on top of another",
"baseFile": {
"label": "Select Base PDF File"
},
"overlayFiles": {
"label": "Select Overlay PDF Files",
"placeholder": "Choose PDF(s)...",
"addMore": "Add more PDFs..."
"label": "Select Overlay PDF Files"
},
"mode": {
"label": "Select Overlay Mode",
@@ -1556,49 +1442,14 @@
},
"counts": {
"label": "Overlay Counts (for Fixed Repeat Mode)",
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
"item": "Count for file"
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)"
},
"position": {
"label": "Select Overlay Position",
"foreground": "Foreground",
"background": "Background"
},
"submit": "Submit",
"settings": {
"title": "Settings"
},
"results": {
"title": "Overlay Results"
},
"tooltip": {
"header": {
"title": "Overlay PDFs Overview"
},
"description": {
"title": "Description",
"text": "Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background."
},
"mode": {
"title": "Overlay Mode",
"text": "Choose how to distribute overlay pages across the base PDF pages.",
"sequential": "Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.",
"interleaved": "Interleaved Overlay: Take one page from each overlay in turn.",
"fixedRepeat": "Fixed Repeat Overlay: Take a set number of pages from each overlay before moving to the next. Use Counts to set the numbers."
},
"position": {
"title": "Overlay Position",
"text": "Foreground places the overlay on top of the page. Background places it behind."
},
"overlayFiles": {
"title": "Overlay Files",
"text": "Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes."
},
"counts": {
"title": "Counts (Fixed Repeat only)",
"text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat."
}
}
"submit": "Submit"
},
"split-by-sections": {
"tags": "Section Split, Divide, Customize",
@@ -1768,9 +1619,8 @@
"submit": "Sanitize PDF"
},
"adjustContrast": {
"title": "Adjust Colors/Contrast",
"header": "Adjust Colors/Contrast",
"basic": "Basic Adjustments",
"title": "Adjust Contrast",
"header": "Adjust Contrast",
"contrast": "Contrast:",
"brightness": "Brightness:",
"saturation": "Saturation:",
@@ -1890,16 +1740,10 @@
}
},
"removeImage": {
"title": "Remove Images",
"header": "Remove Images",
"removeImage": "Remove Images",
"submit": "Remove Images",
"results": {
"title": "Remove Images Results"
},
"error": {
"failed": "Failed to remove images from the PDF."
}
"title": "Remove image",
"header": "Remove image",
"removeImage": "Remove image",
"submit": "Remove image"
},
"splitByChapters": {
"title": "Split PDF by Chapters",
@@ -1944,7 +1788,7 @@
"title": "How we use Cookies",
"description": {
"1": "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.",
"2": "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
"2": "If youd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
},
"acceptAllBtn": "Okay",
"acceptNecessaryBtn": "No Thanks",
@@ -1968,7 +1812,7 @@
"1": "Strictly Necessary Cookies",
"2": "Always Enabled"
},
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off."
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they cant be turned off."
},
"analytics": {
"title": "Analytics",
@@ -2448,22 +2292,5 @@
},
"automate": {
"copyToSaved": "Copy to Saved"
},
"AddAttachmentsRequest": {
"attachments": "Select Attachments",
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
"selectFiles": "Select Files to Attach",
"placeholder": "Choose files...",
"addMoreFiles": "Add more files...",
"selectedFiles": "Selected Files",
"submit": "Add Attachments",
"results": {
"title": "Attachment Results"
}
},
"addAttachments": {
"error": {
"failed": "An error occurred while adding attachments to the PDF."
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+66 -255
View File
@@ -87,10 +87,7 @@
"showStack": "Mostra traccia dello stack",
"copyStack": "Copia traccia dello stack",
"githubSubmit": "GitHub: apri un ticket",
"discordSubmit": "Discord: invia post di supporto",
"dismissAllErrors": "Chiudi tutti gli errori",
"encryptedPdfMustRemovePassword": "Questo PDF è crittografato o protetto da password. Si prega di sbloccarlo prima di convertire in PDF/A.",
"incorrectPasswordProvided": "La password del PDF è errata o non è stata fornita."
"discordSubmit": "Discord: invia post di supporto"
},
"warning": {
"tooltipTitle": "Avviso"
@@ -361,223 +358,179 @@
"sortBy": "Ordinamento:",
"multiTool": {
"title": "Multifunzione PDF",
"desc": "Unisci, Ruota, Riordina, e Rimuovi pagine",
"tags": "multipli,strumenti"
"desc": "Unisci, Ruota, Riordina, e Rimuovi pagine"
},
"merge": {
"title": "Unisci",
"desc": "Unisci facilmente più PDF in uno.",
"tags": "combina,unisci,unifica"
"desc": "Unisci facilmente più PDF in uno."
},
"split": {
"title": "Dividi",
"desc": "Dividi un singolo PDF in più documenti.",
"tags": "dividi,separa,spezza"
"desc": "Dividi un singolo PDF in più documenti."
},
"rotate": {
"title": "Ruota",
"desc": "Ruota un PDF.",
"tags": "ruota,capovolgi,orienta"
"desc": "Ruota un PDF."
},
"convert": {
"title": "Converti",
"desc": "Converti file tra diversi formati",
"tags": "trasforma,cambia"
"desc": "Converti file tra diversi formati"
},
"pdfOrganiser": {
"title": "Organizza",
"desc": "Rimuovi/Riordina le pagine in qualsiasi ordine.",
"tags": "organizza,riordina,riorganizza"
"desc": "Rimuovi/Riordina le pagine in qualsiasi ordine."
},
"addImage": {
"title": "Aggiungi Immagine",
"desc": "Aggiungi un'immagine in un punto specifico del PDF (Lavori in corso)",
"tags": "inserisci,incorpora,posiziona"
"desc": "Aggiungi un'immagine in un punto specifico del PDF (Lavori in corso)"
},
"addAttachments": {
"title": "Aggiungi allegati",
"desc": "Aggiungi o rimuovi file incorporati (allegati) da/verso un PDF",
"tags": "incorpora,allega,includi"
"desc": "Aggiungi o rimuovi file incorporati (allegati) da/verso un PDF"
},
"watermark": {
"title": "Aggiungi Filigrana",
"desc": "Aggiungi una filigrana al tuo PDF.",
"tags": "timbro,marca,sovrapponi"
"desc": "Aggiungi una filigrana al tuo PDF."
},
"removePassword": {
"title": "Rimuovi Password",
"desc": "Rimuovi la password dal tuo PDF.",
"tags": "sblocca"
"desc": "Rimuovi la password dal tuo PDF."
},
"compress": {
"title": "Comprimi",
"desc": "Comprimi PDF per ridurne le dimensioni.",
"tags": "riduci,comprimi,ottimizza"
"desc": "Comprimi PDF per ridurne le dimensioni."
},
"unlockPDFForms": {
"title": "Sblocca moduli PDF",
"desc": "Rimuovi la proprietà di sola lettura dei campi del modulo in un documento PDF.",
"tags": "sblocca,abilita,modifica"
"desc": "Rimuovi la proprietà di sola lettura dei campi del modulo in un documento PDF."
},
"changeMetadata": {
"title": "Modifica Proprietà",
"desc": "Modifica/Aggiungi/Rimuovi le proprietà di un documento PDF.",
"tags": "modifica,cambia,aggiorna"
"desc": "Modifica/Aggiungi/Rimuovi le proprietà di un documento PDF."
},
"ocr": {
"title": "OCR / Pulisci scansioni",
"desc": "Pulisci scansioni ed estrai testo da immagini, convertendo le immagini in testo puro.",
"tags": "estrai,scansiona"
"desc": "Pulisci scansioni ed estrai testo da immagini, convertendo le immagini in testo puro."
},
"extractImages": {
"title": "Estrai immagini",
"desc": "Estrai tutte le immagini da un PDF e salvale come zip.",
"tags": "estrai,salva,esporta"
"desc": "Estrai tutte le immagini da un PDF e salvale come zip."
},
"scannerImageSplit": {
"title": "Rileva/Dividi foto scansionate",
"desc": "Divide più foto allinterno di una foto/PDF",
"tags": "rileva,dividi,foto"
"desc": "Divide più foto allinterno di una foto/PDF"
},
"sign": {
"title": "Firma",
"desc": "Aggiungi una firma al PDF da disegno, testo o immagine.",
"tags": "firma,autografo"
"desc": "Aggiungi una firma al PDF da disegno, testo o immagine."
},
"flatten": {
"title": "Appiattisci",
"desc": "Rimuovi tutti gli elementi interattivi e moduli da un PDF.",
"tags": "semplifica,rimuovi,interattivo"
"desc": "Rimuovi tutti gli elementi interattivi e moduli da un PDF."
},
"certSign": {
"title": "Firma con certificato",
"desc": "Firma un PDF con un certificato/chiave (PEM/P12)",
"tags": "autentica,PEM,P12,ufficiale,cripta,firma,certificato,PKCS12,JKS,server,manuale,auto"
"desc": "Firma un PDF con un certificato/chiave (PEM/P12)"
},
"repair": {
"title": "Ripara",
"desc": "Prova a riparare un PDF corrotto.",
"tags": "ripara,ripristina"
"desc": "Prova a riparare un PDF corrotto."
},
"removeBlanks": {
"title": "Rimuovi pagine vuote",
"desc": "Trova e rimuovi pagine vuote da un PDF.",
"tags": "elimina,pulisci,vuote"
"desc": "Trova e rimuovi pagine vuote da un PDF."
},
"removeAnnotations": {
"title": "Rimuovi annotazioni",
"desc": "Rimuove tutti i commenti/annotazioni da un PDF",
"tags": "elimina,pulisci,rimuovi"
"desc": "Rimuove tutti i commenti/annotazioni da un PDF"
},
"compare": {
"title": "Compara",
"desc": "Vedi e compara le differenze tra due PDF.",
"tags": "differenza"
"desc": "Vedi e compara le differenze tra due PDF."
},
"removeCertSign": {
"title": "Rimuovere firma dal certificato",
"desc": "Rimuovi la firma del certificato dal PDF",
"tags": "rimuovi,elimina,sblocca"
"desc": "Rimuovi la firma del certificato dal PDF"
},
"pageLayout": {
"title": "Layout multipagina",
"desc": "Unisci più pagine di un documento PDF in un'unica pagina",
"tags": "layout,disponi,combina"
"desc": "Unisci più pagine di un documento PDF in un'unica pagina"
},
"bookletImposition": {
"title": "Imposizione a libretto",
"desc": "Crea libretti con corretto ordinamento pagine e layout multipagina per stampa e rilegatura",
"tags": "opuscolo,stampa,rilegatura"
"desc": "Crea libretti con corretto ordinamento pagine e layout multipagina per stampa e rilegatura"
},
"scalePages": {
"title": "Regola le dimensioni/scala della pagina",
"desc": "Modificare le dimensioni/scala della pagina e/o dei suoi contenuti.",
"tags": "ridimensiona,adatta,scala"
"desc": "Modificare le dimensioni/scala della pagina e/o dei suoi contenuti."
},
"addPageNumbers": {
"title": "Aggiungi numeri di pagina",
"desc": "Aggiungi numeri di pagina in tutto un documento in una posizione prestabilita",
"tags": "numero,paginazione,conteggio"
"desc": "Aggiungi numeri di pagina in tutto un documento in una posizione prestabilita"
},
"autoRename": {
"title": "Rinomina automatica file PDF",
"desc": "Rinomina automaticamente un file PDF in base allintestazione rilevata",
"tags": "auto-rilevamento,basato su intestazione,organizza,rinomina"
"desc": "Rinomina automaticamente un file PDF in base allintestazione rilevata"
},
"adjustContrast": {
"title": "Regola colori/contrasto",
"desc": "Regola contrasto, saturazione e luminosità di un PDF",
"tags": "contrasto,luminosità,saturazione"
"desc": "Regola contrasto, saturazione e luminosità di un PDF"
},
"crop": {
"title": "Ritaglia PDF",
"desc": "Ritaglia un PDF per ridurne le dimensioni (mantiene il testo!)",
"tags": "ritaglia,taglia,ridimensiona"
"desc": "Ritaglia un PDF per ridurne le dimensioni (mantiene il testo!)"
},
"autoSplitPDF": {
"title": "Pagine divise automaticamente",
"desc": "Dividi automaticamente il PDF scansionato con il codice QR dello divisore di pagina fisico scansionato",
"tags": "auto,dividi,QR"
"desc": "Dividi automaticamente il PDF scansionato con il codice QR dello divisore di pagina fisico scansionato"
},
"sanitize": {
"title": "Sanitizza",
"desc": "Rimuovi elementi potenzialmente dannosi dai PDF",
"tags": "pulisci,elimina,rimuovi"
"desc": "Rimuovi elementi potenzialmente dannosi dai PDF"
},
"getPdfInfo": {
"title": "Ottieni TUTTE le informazioni in PDF",
"desc": "Raccogli tutte le informazioni possibili sui PDF",
"tags": "info,metadati,dettagli"
"desc": "Raccogli tutte le informazioni possibili sui PDF"
},
"pdfToSinglePage": {
"title": "PDF in un'unica pagina di grandi dimensioni",
"desc": "Unisce tutte le pagine PDF in un'unica grande pagina",
"tags": "combina,unisci,singola"
"desc": "Unisce tutte le pagine PDF in un'unica grande pagina"
},
"showJS": {
"title": "Mostra Javascript",
"desc": "Cerca e visualizza qualsiasi JS inserito in un PDF",
"tags": "javascript,codice,script"
"desc": "Cerca e visualizza qualsiasi JS inserito in un PDF"
},
"redact": {
"title": "Redazione manuale",
"desc": "Redige un PDF in base al testo selezionato, alle forme disegnate e/o alle pagina selezionata(e)",
"tags": "censura,oscura,nascondi"
"desc": "Redige un PDF in base al testo selezionato, alle forme disegnate e/o alle pagina selezionata(e)"
},
"overlayPdfs": {
"title": "Sovrapponi PDF",
"desc": "Sovrapponi PDF sopra un altro PDF",
"tags": "sovrapponi,combina,impila"
"desc": "Sovrapponi PDF sopra un altro PDF"
},
"splitBySections": {
"title": "Dividi PDF per sezioni",
"desc": "Divide ogni pagina di un PDF in sezioni orizzontali e verticali più piccole",
"tags": "dividi,sezioni,separa"
"desc": "Divide ogni pagina di un PDF in sezioni orizzontali e verticali più piccole"
},
"addStamp": {
"title": "Aggiungi timbro al PDF",
"desc": "Aggiungi timbri di testo o immagine in posizioni specifiche",
"tags": "timbro,marca,sigillo"
"desc": "Aggiungi timbri di testo o immagine in posizioni specifiche"
},
"removeImage": {
"title": "Rimuovi immagine",
"desc": "Rimuovi le immagini dal PDF per ridurre la dimensione del file",
"tags": "rimuovi,elimina,pulisci"
"desc": "Rimuovi le immagini dal PDF per ridurre la dimensione del file"
},
"splitByChapters": {
"title": "Dividi PDF per capitoli",
"desc": "Dividi un PDF in più file in base alla struttura dei capitoli.",
"tags": "dividi,capitoli,struttura"
"desc": "Dividi un PDF in più file in base alla struttura dei capitoli."
},
"validateSignature": {
"title": "Convalida la firma PDF",
"desc": "Verificare le firme digitali e i certificati nei documenti PDF",
"tags": "convalida,verifica,certificato"
"desc": "Verificare le firme digitali e i certificati nei documenti PDF"
},
"swagger": {
"title": "Documentazione API",
"desc": "Visualizza documentazione API e testa gli endpoint",
"tags": "API,documentazione,test"
"desc": "Visualizza documentazione API e testa gli endpoint"
},
"fakeScan": {
"title": "Finta scansione",
@@ -585,38 +538,31 @@
},
"editTableOfContents": {
"title": "Modifica indice",
"desc": "Aggiungi o modifica segnalibri e sommario nei documenti PDF",
"tags": "segnalibri,contenuti,modifica"
"desc": "Aggiungi o modifica segnalibri e sommario nei documenti PDF"
},
"manageCertificates": {
"title": "Gestisci certificati",
"desc": "Importa, esporta o elimina i file certificato usati per firmare i PDF.",
"tags": "certificati,importa,esporta"
"desc": "Importa, esporta o elimina i file certificato usati per firmare i PDF."
},
"read": {
"title": "Leggi",
"desc": "Visualizza e annota PDF. Evidenzia testo, disegna o inserisci commenti per revisione e collaborazione.",
"tags": "visualizza,apri,mostra"
"desc": "Visualizza e annota PDF. Evidenzia testo, disegna o inserisci commenti per revisione e collaborazione."
},
"reorganizePages": {
"title": "Riorganizza pagine",
"desc": "Riorganizza, duplica o elimina pagine PDF con controllo visivo draganddrop.",
"tags": "riordina,riorganizza,organizza"
"desc": "Riorganizza, duplica o elimina pagine PDF con controllo visivo draganddrop."
},
"extractPages": {
"title": "Estrai pagine",
"desc": "Estrai pagine specifiche da un PDF",
"tags": "estrai,seleziona,copia"
"desc": "Estrai pagine specifiche da un PDF"
},
"removePages": {
"title": "Rimuovi",
"desc": "Elimina alcune pagine dal PDF.",
"tags": "elimina,estrai,escludi"
"desc": "Elimina alcune pagine dal PDF."
},
"autoSizeSplitPDF": {
"title": "Divisione automatica per dimensione/numero",
"desc": "Dividi un singolo PDF in più documenti in base alle dimensioni, al numero di pagine o al numero di documenti",
"tags": "auto,dividi,dimensione"
"desc": "Dividi un singolo PDF in più documenti in base alle dimensioni, al numero di pagine o al numero di documenti"
},
"replaceColorPdf": {
"title": "Sostituisci e inverti il colore",
@@ -624,13 +570,11 @@
},
"devApi": {
"title": "API",
"desc": "Link alla documentazione API",
"tags": "API,sviluppo,documentazione"
"desc": "Link alla documentazione API"
},
"devFolderScanning": {
"title": "Scansione cartelle automatizzata",
"desc": "Link alla guida per scansione cartelle automatizzata",
"tags": "automazione,cartella,scansione"
"desc": "Link alla guida per scansione cartelle automatizzata"
},
"devSsoGuide": {
"title": "Guida SSO",
@@ -650,17 +594,7 @@
},
"automate": {
"title": "Automatizza",
"desc": "Crea flussi multistep concatenando azioni PDF. Ideale per attività ricorrenti.",
"tags": "flusso di lavoro,sequenza,automazione"
},
"replaceColor": {
"desc": "Sostituisci o inverti i colori nei documenti PDF",
"title": "Sostituisci e inverti colore"
},
"scannerEffect": {
"desc": "Crea un PDF che sembra essere stato scansionato",
"tags": "scansiona,simula,crea",
"title": "Effetto scanner"
"desc": "Crea flussi multistep concatenando azioni PDF. Ideale per attività ricorrenti."
}
},
"landing": {
@@ -720,9 +654,7 @@
},
"error": {
"failed": "Si è verificato un errore durante lunione dei PDF."
},
"generateTableOfContents": "Generare l'indice nel file unito?",
"removeDigitalSignature": "Rimuovere la firma digitale nel file unito?"
}
},
"split": {
"tags": "Operazioni sulla pagina,divisione,multi pagina,taglio,lato server",
@@ -981,20 +913,7 @@
"10": "Unione pari-dispari",
"11": "Duplica tutte le pagine"
},
"placeholder": "(ad es. 1,3,2 o 4-8,2,10-12 o 2n-1)",
"desc": {
"BOOKLET_SORT": "Disporre le pagine per la stampa a opuscolo (ultima, prima, seconda, penultima, …).",
"CUSTOM": "Utilizzare una sequenza personalizzata di numeri di pagina o espressioni per definire un nuovo ordine.",
"DUPLEX_SORT": "Alternare fronte e retro come se uno scanner duplex avesse scansionato tutti i fronti, poi tutti i retri (1, n, 2, n-1, …).",
"DUPLICATE": "Duplicare ogni pagina secondo il conteggio dell'ordine personalizzato (ad es., 4 duplica ogni pagina 4×).",
"ODD_EVEN_MERGE": "Unire due PDF alternando le pagine: dispari dal primo, pari dal secondo.",
"ODD_EVEN_SPLIT": "Dividere il documento in due output: tutte le pagine dispari e tutte le pagine pari.",
"REMOVE_FIRST": "Rimuovere la prima pagina dal documento.",
"REMOVE_FIRST_AND_LAST": "Rimuovere sia la prima che l'ultima pagina dal documento.",
"REMOVE_LAST": "Rimuovere l'ultima pagina dal documento.",
"REVERSE_ORDER": "Capovolgere il documento in modo che l'ultima pagina diventi la prima e così via.",
"SIDE_STITCH_BOOKLET_SORT": "Disporre le pagine per la stampa a opuscolo con cucitura laterale (ottimizzato per la rilegatura sul lato)."
}
"placeholder": "(ad es. 1,3,2 o 4-8,2,10-12 o 2n-1)"
},
"addImage": {
"tags": "img,jpg,immagine,foto",
@@ -1428,7 +1347,7 @@
},
"trapped": {
"label": "Stato Trapped",
"unknown": "Sconosciuto",
"unknown": "Unknown",
"true": "True",
"false": "False"
},
@@ -1619,13 +1538,7 @@
"header": "Estrai immagini",
"selectText": "Seleziona il formato in cui salvare le immagini estratte",
"allowDuplicates": "Salva le immagini duplicate",
"submit": "Estrai",
"error": {
"failed": "Si è verificato un errore durante l'estrazione delle immagini dal PDF."
},
"settings": {
"title": "Impostazioni"
}
"submit": "Estrai"
},
"pdfToPDFA": {
"tags": "archivio,a lungo termine,standard,conversione,archiviazione,conservazione",
@@ -1702,14 +1615,8 @@
"title": "Firma",
"header": "Firma PDF",
"upload": "Carica immagine",
"draw": {
"clear": "Cancella",
"title": "Disegna la tua firma"
},
"text": {
"name": "Nome firmatario",
"placeholder": "Inserisci il tuo nome completo"
},
"draw": "Disegna Firma",
"text": "Testo",
"clear": "Cancella",
"add": "Aggiungi",
"saved": "Firme salvate",
@@ -1725,35 +1632,7 @@
"previous": "Pagina precedente",
"maintainRatio": "Attiva il mantenimento delle proporzioni",
"undo": "Annulla",
"redo": "Rifare",
"activate": "Attiva posizionamento firma",
"applySignatures": "Applica firme",
"deactivate": "Interrompi posizionamento firme",
"error": {
"failed": "Si è verificato un errore durante la firma del PDF."
},
"image": {
"hint": "Carica un'immagine PNG o JPG della tua firma",
"label": "Carica immagine firma",
"placeholder": "Seleziona file immagine"
},
"instructions": {
"title": "Come aggiungere la firma"
},
"results": {
"title": "Risultati firma"
},
"steps": {
"configure": "Configura firma"
},
"submit": "Firma documento",
"type": {
"canvas": "Canvas",
"draw": "Disegna",
"image": "Immagine",
"text": "Testo",
"title": "Tipo di firma"
}
"redo": "Rifare"
},
"flatten": {
"tags": "statico,disattivato,non interattivo,ottimizzato",
@@ -1772,8 +1651,7 @@
"stepTitle": "Opzioni di flattening",
"title": "Opzioni di flattening",
"flattenOnlyForms.desc": "Appiattisci solo i campi modulo, lasciando intatti gli altri elementi interattivi",
"note": "Il flattening rimuove gli elementi interattivi dal PDF, rendendoli non modificabili.",
"flattenOnlyForms": "Appiattisci solo i moduli"
"note": "Il flattening rimuove gli elementi interattivi dal PDF, rendendoli non modificabili."
},
"results": {
"title": "Risultati Flatten"
@@ -1869,17 +1747,7 @@
"tags": "commenti,evidenziazioni,note,markup,rimozione",
"title": "Rimuovi Annotazioni",
"header": "Rimuovi Annotazioni",
"submit": "Rimuovi",
"error": {
"failed": "Si è verificato un errore durante la rimozione delle annotazioni dal PDF."
},
"info": {
"description": "Questo strumento rimuoverà tutte le annotazioni (commenti, evidenziazioni, note, ecc.) dai tuoi documenti PDF.",
"title": "Informazioni su Rimuovi annotazioni"
},
"settings": {
"title": "Impostazioni"
}
"submit": "Rimuovi"
},
"compare": {
"tags": "differenziare,contrastare,cambiare,analisi",
@@ -3156,13 +3024,7 @@
"removeXMPMetadata.desc": "Rimuovi i metadati XMP dal PDF",
"removeMetadata.desc": "Rimuovi le informazioni (titolo, autore, ecc.)",
"removeLinks.desc": "Rimuovi link esterni e azioni di avvio dal PDF",
"removeFonts.desc": "Rimuovi i font incorporati dal PDF",
"removeEmbeddedFiles": "Rimuovi file incorporati",
"removeFonts": "Rimuovi caratteri",
"removeJavaScript": "Rimuovi JavaScript",
"removeLinks": "Rimuovi collegamenti",
"removeMetadata": "Rimuovi metadati documento",
"removeXMPMetadata": "Rimuovi metadati XMP"
"removeFonts.desc": "Rimuovi i font incorporati dal PDF"
}
},
"addPassword": {
@@ -3432,56 +3294,5 @@
}
},
"termsAndConditions": "Termini e condizioni",
"logOut": "Esci",
"AddAttachmentsRequest": {
"addMoreFiles": "Aggiungi altri file...",
"attachments": "Seleziona allegati",
"info": "Seleziona i file da allegare al tuo PDF. Questi file saranno incorporati e accessibili tramite il pannello allegati del PDF.",
"placeholder": "Scegli file...",
"results": {
"title": "Risultati allegati"
},
"selectFiles": "Seleziona file da allegare",
"selectedFiles": "File selezionati",
"submit": "Aggiungi allegati"
},
"applyAndContinue": "Applica e continua",
"discardChanges": "Scarta modifiche",
"exportAndContinue": "Esporta e continua",
"keepWorking": "Continua a lavorare",
"replaceColor": {
"tags": "Sostituisci colore,Operazioni pagina,Back end,lato server"
},
"scannerImageSplit": {
"error": {
"failed": "Si è verificato un errore durante l'estrazione delle scansioni di immagini."
},
"submit": "Estrai scansioni di immagini",
"title": "Immagini estratte",
"tooltip": {
"headsUp": "Attenzione",
"headsUpDesc": "Foto sovrapposte o sfondi molto simili nel colore alle foto possono ridurre la precisione - prova uno sfondo più chiaro o più scuro e lascia più spazio.",
"problem1": "Foto non rilevate → aumentare la tolleranza a 30-50",
"problem2": "Troppe rilevazioni errate → aumentare l'area minima a 15.000-20.000",
"problem3": "I ritagli sono troppo stretti → aumentare la dimensione del bordo a 5-10",
"problem4": "Foto inclinate non raddrizzate → abbassare la soglia angolare a ~5°",
"problem5": "Caselle di polvere/rumore → aumentare l'area minima del contorno a 1000-2000",
"quickFixes": "Correzioni rapide",
"setupTips": "Suggerimenti di configurazione",
"tip1": "Usa uno sfondo semplice e chiaro",
"tip2": "Lascia un piccolo spazio (≈1 cm) tra le foto",
"tip3": "Scansiona a 300-600 DPI",
"tip4": "Pulisci il vetro dello scanner",
"title": "Divisore di foto",
"useCase1": "Scansiona intere pagine di album in una volta",
"useCase2": "Dividi i lotti flatbed in file separati",
"useCase3": "Suddividi collage in singole foto",
"useCase4": "Estrai foto dai documenti",
"whatThisDoes": "Cosa fa",
"whatThisDoesDesc": "Trova ed estrae automaticamente ogni foto da una pagina scansionata o da un'immagine composita - senza ritaglio manuale.",
"whenToUse": "Quando usare"
}
},
"unsavedChanges": "Hai modifiche non salvate al tuo PDF. Cosa vuoi fare?",
"unsavedChangesTitle": "Modifiche non salvate"
"logOut": "Esci"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+82 -271
View File
@@ -87,10 +87,7 @@
"showStack": "显示堆栈跟踪",
"copyStack": "复制堆栈跟踪",
"githubSubmit": "GitHub - 提交工单",
"discordSubmit": "Discord - 提交支持帖子",
"dismissAllErrors": "关闭所有错误",
"encryptedPdfMustRemovePassword": "此 PDF 已加密或受密码保护。请在转换为 PDF/A 之前将其解锁。",
"incorrectPasswordProvided": "PDF 密码不正确或未提供。"
"discordSubmit": "Discord - 提交支持帖子"
},
"warning": {
"tooltipTitle": "警告"
@@ -361,223 +358,179 @@
"sortBy": "排序:",
"multiTool": {
"title": "PDF 多功能工具",
"desc": "合并、旋转、重新排列和删除 PDF 页面",
"tags": "多个,工具"
"desc": "合并、旋转、重新排列和删除 PDF 页面"
},
"merge": {
"title": "合并",
"desc": "轻松将多个 PDF 合并成一个。",
"tags": "组合,合并,联合"
"desc": "轻松将多个 PDF 合并成一个。"
},
"split": {
"title": "拆分",
"desc": "将 PDF 拆分为多个文档。",
"tags": "分割,分离,拆分"
"desc": "将 PDF 拆分为多个文档。"
},
"rotate": {
"title": "旋转",
"desc": "旋转 PDF。",
"tags": "旋转,翻转,定向"
"desc": "旋转 PDF。"
},
"convert": {
"title": "转换",
"desc": "在不同格式之间转换文件",
"tags": "转换,更改"
"desc": "在不同格式之间转换文件"
},
"pdfOrganiser": {
"title": "整理",
"desc": "按任意顺序删除/重新排列页面。",
"tags": "组织,重新排列,重新排序"
"desc": "按任意顺序删除/重新排列页面。"
},
"addImage": {
"title": "在 PDF 中添加图片",
"desc": "将图像添加到 PDF 的指定位置。",
"tags": "插入,嵌入,放置"
"desc": "将图像添加到 PDF 的指定位置。"
},
"addAttachments": {
"title": "添加附件",
"desc": "向 PDF 添加或移除嵌入文件(附件)",
"tags": "嵌入,附加,包含"
"desc": "向 PDF 添加或移除嵌入文件(附件)"
},
"watermark": {
"title": "添加水印",
"desc": "在 PDF 中添加自定义水印。",
"tags": "印章,标记,叠加"
"desc": "在 PDF 中添加自定义水印。"
},
"removePassword": {
"title": "删除密码",
"desc": "从 PDF 文档中移除密码保护。",
"tags": "解锁"
"desc": "从 PDF 文档中移除密码保护。"
},
"compress": {
"title": "压缩",
"desc": "压缩 PDF 文件以减小文件大小。",
"tags": "缩小,减少,优化"
"desc": "压缩 PDF 文件以减小文件大小。"
},
"unlockPDFForms": {
"title": "解锁PDF表单",
"desc": "移除表单字段只读属性",
"tags": "解锁,启用,编辑"
"desc": "移除表单字段只读属性"
},
"changeMetadata": {
"title": "更改元数据",
"desc": "更改/删除/添加 PDF 文档的元数据。",
"tags": "编辑,修改,更新"
"desc": "更改/删除/添加 PDF 文档的元数据。"
},
"ocr": {
"title": "运行 OCR /清理扫描",
"desc": "清理和识别 PDF 中的图像文本,并将其转换为可编辑文本。",
"tags": "提取,扫描"
"desc": "清理和识别 PDF 中的图像文本,并将其转换为可编辑文本。"
},
"extractImages": {
"title": "提取图像",
"desc": "从 PDF 中提取所有图像并保存到压缩包中。",
"tags": "提取,保存,导出"
"desc": "从 PDF 中提取所有图像并保存到压缩包中。"
},
"scannerImageSplit": {
"title": "检测/拆分扫描照片",
"desc": "从照片/PDF 中拆分出多张照片",
"tags": "检测,拆分,照片"
"desc": "从照片/PDF 中拆分出多张照片"
},
"sign": {
"title": "签名",
"desc": "通过绘图、文字或图像向 PDF 添加签名",
"tags": "签名,亲笔签名"
"desc": "通过绘图、文字或图像向 PDF 添加签名"
},
"flatten": {
"title": "展平",
"desc": "从 PDF 中删除所有互动元素和表单",
"tags": "简化,删除,交互式"
"desc": "从 PDF 中删除所有互动元素和表单"
},
"certSign": {
"title": "使用证书签名",
"desc": "使用证书/密钥(PEM/P12)对PDF进行签名",
"tags": "认证,PEM,P12,官方,加密,签名,证书,PKCS12,JKS,服务器,手动,自动"
"desc": "使用证书/密钥(PEM/P12)对PDF进行签名"
},
"repair": {
"title": "修复",
"desc": "尝试修复损坏/损坏的 PDF",
"tags": "修复,恢复"
"desc": "尝试修复损坏/损坏的 PDF"
},
"removeBlanks": {
"title": "删除空白页",
"desc": "检测并删除文档中的空白页",
"tags": "删除,清理,空白"
"desc": "检测并删除文档中的空白页"
},
"removeAnnotations": {
"title": "删除标注",
"desc": "删除 PDF 中的所有标注/评论",
"tags": "删除,清理,删除"
"desc": "删除 PDF 中的所有标注/评论"
},
"compare": {
"title": "比较",
"desc": "比较并显示两个 PDF 文档之间的差异",
"tags": "差异"
"desc": "比较并显示两个 PDF 文档之间的差异"
},
"removeCertSign": {
"title": "移除证书签名",
"desc": "移除 PDF 的证书签名",
"tags": "删除,删除,解锁"
"desc": "移除 PDF 的证书签名"
},
"pageLayout": {
"title": "多页布局",
"desc": "将 PDF 文档的多个页面合并成一页",
"tags": "布局,排列,组合"
"desc": "将 PDF 文档的多个页面合并成一页"
},
"bookletImposition": {
"title": "小册子拼版",
"desc": "创建具有正确页面顺序和多页布局的小册子,用于打印和装订",
"tags": "小册子,打印,装订"
"desc": "创建具有正确页面顺序和多页布局的小册子,用于打印和装订"
},
"scalePages": {
"title": "调整页面尺寸/缩放",
"desc": "调整页面及/或其内容的尺寸/缩放",
"tags": "调整大小,调整,缩放"
"desc": "调整页面及/或其内容的尺寸/缩放"
},
"addPageNumbers": {
"title": "添加页码",
"desc": "在文档的指定位置添加页码",
"tags": "编号,分页,计数"
"desc": "在文档的指定位置添加页码"
},
"autoRename": {
"title": "自动重命名 PDF 文件",
"desc": "基于检测到的页眉自动重命名 PDF 文件",
"tags": "自动检测,基于标题,组织,重新标记"
"desc": "基于检测到的页眉自动重命名 PDF 文件"
},
"adjustContrast": {
"title": "调整颜色/对比度",
"desc": "调整 PDF 的对比度、饱和度和亮度",
"tags": "对比度,亮度,饱和度"
"desc": "调整 PDF 的对比度、饱和度和亮度"
},
"crop": {
"title": "裁剪 PDF",
"desc": "裁剪 PDF 以减小其文件大小(保留文本!)",
"tags": "裁剪,剪切,调整大小"
"desc": "裁剪 PDF 以减小其文件大小(保留文本!)"
},
"autoSplitPDF": {
"title": "自动拆分页面",
"desc": "使用物理扫描页面分割器 QR 代码自动拆分扫描的 PDF",
"tags": "自动,拆分,QR"
"desc": "使用物理扫描页面分割器 QR 代码自动拆分扫描的 PDF"
},
"sanitize": {
"title": "安全清理",
"desc": "移除 PDF 文件中的潜在有害元素",
"tags": "清理,清除,删除"
"desc": "移除 PDF 文件中的潜在有害元素"
},
"getPdfInfo": {
"title": "获取 PDF 的所有信息",
"desc": "获取 PDF 的所有可能的信息",
"tags": "信息,元数据,详细信息"
"desc": "获取 PDF 的所有可能的信息"
},
"pdfToSinglePage": {
"title": "PDF 转单一大页",
"desc": "将所有 PDF 页面合并为一个大的单页",
"tags": "组合,合并,单页"
"desc": "将所有 PDF 页面合并为一个大的单页"
},
"showJS": {
"title": "显示 JavaScript",
"desc": "搜索并显示嵌入到 PDF 中的任何 JavaScript 代码",
"tags": "javascript,代码,脚本"
"desc": "搜索并显示嵌入到 PDF 中的任何 JavaScript 代码"
},
"redact": {
"title": "手动修订",
"desc": "根据选定的文本、绘制的形状和/或选定的页面编辑PDF",
"tags": "审查,涂黑,隐藏"
"desc": "根据选定的文本、绘制的形状和/或选定的页面编辑PDF"
},
"overlayPdfs": {
"title": "叠加 PDF",
"desc": "将一个 PDF 叠加到另一个 PDF 之上",
"tags": "叠加,组合,堆叠"
"desc": "将一个 PDF 叠加到另一个 PDF 之上"
},
"splitBySections": {
"title": "按区块拆分 PDF",
"desc": "将 PDF 的每一页分割为更小的横向与纵向区块",
"tags": "拆分,部分,分割"
"desc": "将 PDF 的每一页分割为更小的横向与纵向区块"
},
"addStamp": {
"title": "向 PDF 添加印章",
"desc": "在指定位置添加文本或图像印章",
"tags": "印章,标记,盖章"
"desc": "在指定位置添加文本或图像印章"
},
"removeImage": {
"title": "删除图像",
"desc": "删除图像减少 PDF 大小",
"tags": "删除,删除,清理"
"desc": "删除图像减少 PDF 大小"
},
"splitByChapters": {
"title": "按章节拆分 PDF",
"desc": "根据其章节结构将 PDF 拆分为多个文件。",
"tags": "拆分,章节,结构"
"desc": "根据其章节结构将 PDF 拆分为多个文件。"
},
"validateSignature": {
"title": "验证 PDF 签名",
"desc": "验证 PDF 文档中的数字签名和证书",
"tags": "验证,核实,证书"
"desc": "验证 PDF 文档中的数字签名和证书"
},
"swagger": {
"title": "API 文档",
"desc": "查看 API 文档并测试端点",
"tags": "API,文档,测试"
"desc": "查看 API 文档并测试端点"
},
"fakeScan": {
"title": "伪扫描",
@@ -585,38 +538,31 @@
},
"editTableOfContents": {
"title": "编辑目录",
"desc": "为 PDF 文档添加或编辑目录和书签",
"tags": "书签,目录,编辑"
"desc": "为 PDF 文档添加或编辑目录和书签"
},
"manageCertificates": {
"title": "管理证书",
"desc": "导入、导出或删除用于签名 PDF 的数字证书文件。",
"tags": "证书,导入,导出"
"desc": "导入、导出或删除用于签名 PDF 的数字证书文件。"
},
"read": {
"title": "阅读",
"desc": "查看与批注 PDF。高亮、绘制或插入评论以便审阅协作。",
"tags": "查看,打开,显示"
"desc": "查看与批注 PDF。高亮、绘制或插入评论以便审阅协作。"
},
"reorganizePages": {
"title": "重组页面",
"desc": "通过可视化拖放控制重新排列、复制或删除 PDF 页面。",
"tags": "重新排列,重新排序,组织"
"desc": "通过可视化拖放控制重新排列、复制或删除 PDF 页面。"
},
"extractPages": {
"title": "提取页面",
"desc": "从 PDF 文档中提取特定页面",
"tags": "提取,选择,复制"
"desc": "从 PDF 文档中提取特定页面"
},
"removePages": {
"title": "删除",
"desc": "从 PDF 文档中删除不需要的页面。",
"tags": "删除,提取,排除"
"desc": "从 PDF 文档中删除不需要的页面。"
},
"autoSizeSplitPDF": {
"title": "自动根据大小/数目拆分 PDF",
"desc": "将单个 PDF 拆分为多个文档,基于大小、页数或文档数",
"tags": "自动,拆分,大小"
"desc": "将单个 PDF 拆分为多个文档,基于大小、页数或文档数"
},
"replaceColorPdf": {
"title": "替换和反转颜色",
@@ -624,13 +570,11 @@
},
"devApi": {
"title": "API",
"desc": "跳转至 API 文档",
"tags": "API,开发,文档"
"desc": "跳转至 API 文档"
},
"devFolderScanning": {
"title": "自动文件夹扫描",
"desc": "跳转至自动文件夹扫描指南",
"tags": "自动化,文件夹,扫描"
"desc": "跳转至自动文件夹扫描指南"
},
"devSsoGuide": {
"title": "SSO 指南",
@@ -650,17 +594,7 @@
},
"automate": {
"title": "自动化",
"desc": "通过串联 PDF 操作构建多步工作流。适合重复性任务。",
"tags": "工作流,序列,自动化"
},
"replaceColor": {
"desc": "替换或反转 PDF 文档中的颜色",
"title": "替换和反转颜色"
},
"scannerEffect": {
"desc": "创建看起来像扫描的 PDF",
"tags": "扫描,模拟,创建",
"title": "扫描仪效果"
"desc": "通过串联 PDF 操作构建多步工作流。适合重复性任务。"
}
},
"landing": {
@@ -720,9 +654,7 @@
},
"error": {
"failed": "合并 PDF 时发生错误。"
},
"generateTableOfContents": "在合并的文件中生成目录?",
"removeDigitalSignature": "在合并的文件中删除数字签名?"
}
},
"split": {
"tags": "页面操作,划分,多页面,剪切,服务器端",
@@ -883,7 +815,7 @@
"settings": "设置",
"conversionCompleted": "转换完成",
"results": "结果",
"defaultFilename": "已转换文件",
"defaultFilename": "converted_file",
"conversionResults": "转换结果",
"convertFrom": "转换来源",
"convertTo": "转换为",
@@ -981,20 +913,7 @@
"10": "奇偶合并",
"11": "复制所有页面"
},
"placeholder": "(例如:1,3,2 或 4-8,2,10-12 或 2n-1",
"desc": {
"BOOKLET_SORT": "排列页面以进行小册子打印(最后,第一,第二,倒数第二,...)。",
"CUSTOM": "使用自定义的页码序列或表达式来定义新顺序。",
"DUPLEX_SORT": "交错正面然后背面,就像双面扫描仪扫描了所有正面,然后所有背面(1, n, 2, n-1, ...)。",
"DUPLICATE": "根据自定义顺序计数复制每页(例如,4 复制每页 4×)。",
"ODD_EVEN_MERGE": "通过交替页面合并两个 PDF:第一个的奇数页,第二个的偶数页。",
"ODD_EVEN_SPLIT": "将文档拆分为两个输出:所有奇数页和所有偶数页。",
"REMOVE_FIRST": "从文档中删除第一页。",
"REMOVE_FIRST_AND_LAST": "从文档中删除第一页和最后一页。",
"REMOVE_LAST": "从文档中删除最后一页。",
"REVERSE_ORDER": "翻转文档,使最后一页变为第一页,依此类推。",
"SIDE_STITCH_BOOKLET_SORT": "排列页面以进行侧缝小册子打印(针对侧面装订进行了优化)。"
}
"placeholder": "(例如:1,3,2 或 4-8,2,10-12 或 2n-1"
},
"addImage": {
"tags": "图像、JPG、图片、照片",
@@ -1018,7 +937,7 @@
"desc": "向 PDF 添加文本或图像水印",
"completed": "已添加水印",
"submit": "添加水印",
"filenamePrefix": "已加水印",
"filenamePrefix": "watermarked",
"error": {
"failed": "向 PDF 添加水印时发生错误。"
},
@@ -1217,7 +1136,7 @@
"placeholder": "例如:1,3,5-8,10",
"error": "无效的页码格式。使用数字、范围(1-5)或数学表达式(2n+1)"
},
"filenamePrefix": "已删除页面",
"filenamePrefix": "pages_removed",
"files": {
"placeholder": "在主视图中选择一个 PDF 文件以开始"
},
@@ -1365,7 +1284,7 @@
"header": "解锁 PDF 表单",
"submit": "Remove",
"description": "该工具将移除 PDF 表单字段的只读限制,使其可编辑、可填写。",
"filenamePrefix": "已解锁表单",
"filenamePrefix": "unlocked_forms",
"files": {
"placeholder": "在主视图中选择一个 PDF 文件以开始"
},
@@ -1380,7 +1299,7 @@
"tags": "标题、作者、日期、创建、时间、发布者、制作人、统计数据",
"header": "更改元数据",
"submit": "更改",
"filenamePrefix": "元数据",
"filenamePrefix": "metadata",
"settings": {
"title": "元数据设置"
},
@@ -1428,7 +1347,7 @@
},
"trapped": {
"label": "陷印状态",
"unknown": "未知",
"unknown": "Unknown",
"true": "True",
"false": "False"
},
@@ -1619,13 +1538,7 @@
"header": "提取图像",
"selectText": "选择图像格式,将提取的图像转换为",
"allowDuplicates": "保存重复图像",
"submit": "提取",
"error": {
"failed": "从 PDF 提取图像时发生错误。"
},
"settings": {
"title": "设置"
}
"submit": "提取"
},
"pdfToPDFA": {
"tags": "归档、长期、标准、转换、存储、保存",
@@ -1702,14 +1615,8 @@
"title": "签名",
"header": "签署 PDF",
"upload": "上传图片",
"draw": {
"clear": "清除",
"title": "绘制您的签名"
},
"text": {
"name": "签署人姓名",
"placeholder": "输入您的全名"
},
"draw": "绘制签名",
"text": "文本输入",
"clear": "清除",
"add": "添加",
"saved": "已保存签名",
@@ -1725,35 +1632,7 @@
"previous": "上一页",
"maintainRatio": "切换保持长宽比",
"undo": "撤销",
"redo": "重做",
"activate": "激活签名放置",
"applySignatures": "应用签名",
"deactivate": "停止放置签名",
"error": {
"failed": "签署 PDF 时发生错误。"
},
"image": {
"hint": "上传 PNG 或 JPG 格式的签名图像",
"label": "上传签名图像",
"placeholder": "选择图像文件"
},
"instructions": {
"title": "如何添加签名"
},
"results": {
"title": "签名结果"
},
"steps": {
"configure": "配置签名"
},
"submit": "签署文档",
"type": {
"canvas": "画布",
"draw": "绘制",
"image": "图像",
"text": "文本",
"title": "签名类型"
}
"redo": "重做"
},
"flatten": {
"tags": "静态、停用、非交互、简化",
@@ -1761,7 +1640,7 @@
"header": "展平 PDF",
"flattenOnlyForms": "仅展平表格",
"submit": "展平",
"filenamePrefix": "已扁平化",
"filenamePrefix": "flattened",
"files": {
"placeholder": "在主视图中选择一个 PDF 文件以开始"
},
@@ -1772,8 +1651,7 @@
"stepTitle": "扁平化选项",
"title": "扁平化选项",
"flattenOnlyForms.desc": "仅扁平化表单字段,保留其他交互元素",
"note": "扁平化会移除 PDF 的交互元素,使其不可编辑。",
"flattenOnlyForms": "仅扁平化表单"
"note": "扁平化会移除 PDF 的交互元素,使其不可编辑。"
},
"results": {
"title": "扁平化结果"
@@ -1809,7 +1687,7 @@
"header": "修复 PDF",
"submit": "修复",
"description": "该工具将尝试修复损坏或受损的 PDF 文件。无需额外设置。",
"filenamePrefix": "已修复",
"filenamePrefix": "repaired",
"files": {
"placeholder": "在主视图中选择一个 PDF 文件以开始"
},
@@ -1869,17 +1747,7 @@
"tags": "评论、高亮、笔记、标注、删除",
"title": "删除标注",
"header": "删除标注",
"submit": "删除",
"error": {
"failed": "从 PDF 删除注释时发生错误。"
},
"info": {
"description": "此工具将从您的 PDF 文档中删除所有注释(评论、高亮、笔记等)。",
"title": "关于删除注释"
},
"settings": {
"title": "设置"
}
"submit": "删除"
},
"compare": {
"tags": "区分、对比、更改、分析",
@@ -1911,7 +1779,7 @@
"certSign": {
"tags": "身份验证、PEM、P12、官方、加密",
"title": "证书签名",
"filenamePrefix": "已签名",
"filenamePrefix": "signed",
"signMode": {
"stepTitle": "签名模式",
"tooltip": {
@@ -2035,7 +1903,7 @@
"selectPDF": "选择 PDF 文件:",
"submit": "移除签名",
"description": "该工具将从您的 PDF 文档中移除数字证书签名。",
"filenamePrefix": "未签名",
"filenamePrefix": "unsigned",
"files": {
"placeholder": "在主视图中选择一个 PDF 文件以开始"
},
@@ -2055,7 +1923,7 @@
"submit": "提交"
},
"bookletImposition": {
"tags": "小册子,拼版,打印,装订,折叠,签名",
"tags": "booklet,imposition,printing,binding,folding,signature",
"title": "小册子拼版",
"header": "小册子拼版",
"submit": "创建小册子",
@@ -2156,7 +2024,7 @@
"submit": "提交"
},
"adjustPageScale": {
"tags": "调整大小,修改,尺寸,适应",
"tags": "resize,modify,dimension,adapt",
"title": "调整页面比例",
"header": "调整页面比例",
"scaleFactor": {
@@ -2679,7 +2547,7 @@
"header": "将 PDF 转换为单页",
"submit": "转为单页",
"description": "该工具会将 PDF 的所有页面合并为一张超长单页。宽度保持与原页面相同,高度为所有页面高度之和。",
"filenamePrefix": "单页",
"filenamePrefix": "single_page",
"files": {
"placeholder": "在主视图中选择一个 PDF 文件以开始"
},
@@ -2900,7 +2768,7 @@
"title": "API 文档",
"header": "API 文档",
"desc": "查看并测试 Stirling PDF 的 API 端点",
"tags": "api,文档,swagger,端点,开发"
"tags": "api,documentation,swagger,endpoints,development"
},
"cookieBanner": {
"popUp": {
@@ -3138,7 +3006,7 @@
"completed": "安全清理成功完成",
"error.generic": "安全清理失败",
"error.failed": "安全清理 PDF 时发生错误。",
"filenamePrefix": "已清理",
"filenamePrefix": "sanitised",
"sanitizationResults": "安全清理结果",
"steps": {
"files": "文件",
@@ -3156,13 +3024,7 @@
"removeXMPMetadata.desc": "从 PDF 中移除 XMP 元数据",
"removeMetadata.desc": "移除文档信息元数据(标题、作者等)",
"removeLinks.desc": "移除外部链接与启动动作",
"removeFonts.desc": "从 PDF 中移除嵌入字体",
"removeEmbeddedFiles": "删除嵌入文件",
"removeFonts": "删除字体",
"removeJavaScript": "删除 JavaScript",
"removeLinks": "删除链接",
"removeMetadata": "删除文档元数据",
"removeXMPMetadata": "删除 XMP 元数据"
"removeFonts.desc": "从 PDF 中移除嵌入字体"
}
},
"addPassword": {
@@ -3170,7 +3032,7 @@
"desc": "使用密码加密您的 PDF 文档。",
"completed": "已应用密码保护",
"submit": "加密",
"filenamePrefix": "已加密",
"filenamePrefix": "encrypted",
"error": {
"failed": "加密 PDF 时发生错误。"
},
@@ -3279,7 +3141,7 @@
"placeholder": "输入当前密码",
"completed": "密码已配置"
},
"filenamePrefix": "已解密",
"filenamePrefix": "decrypted",
"error": {
"failed": "移除 PDF 密码时发生错误。"
},
@@ -3432,56 +3294,5 @@
}
},
"termsAndConditions": "条款与条件",
"logOut": "退出登录",
"AddAttachmentsRequest": {
"addMoreFiles": "添加更多文件...",
"attachments": "选择附件",
"info": "选择要附加到 PDF 的文件。这些文件将被嵌入并可通过 PDF 的附件面板访问。",
"placeholder": "选择文件...",
"results": {
"title": "附件结果"
},
"selectFiles": "选择要附加的文件",
"selectedFiles": "已选择的文件",
"submit": "添加附件"
},
"applyAndContinue": "应用并继续",
"discardChanges": "放弃更改",
"exportAndContinue": "导出并继续",
"keepWorking": "继续工作",
"replaceColor": {
"tags": "替换颜色,页面操作,后端,服务器端"
},
"scannerImageSplit": {
"error": {
"failed": "提取图像扫描时发生错误。"
},
"submit": "提取图像扫描",
"title": "已提取的图像",
"tooltip": {
"headsUp": "注意",
"headsUpDesc": "重叠的照片或颜色与照片非常接近的背景会降低准确性 - 尝试使用更浅或更深的背景并留出更多空间。",
"problem1": "未检测到照片 → 将容差增加到 30-50",
"problem2": "误检测太多 → 将最小面积增加到 15,000-20,000",
"problem3": "裁剪太紧 → 将边框大小增加到 5-10",
"problem4": "倾斜的照片未矫正 → 将角度阈值降低到 ~5°",
"problem5": "灰尘/噪声框 → 将最小轮廓面积增加到 1000-2000",
"quickFixes": "快速修复",
"setupTips": "设置提示",
"tip1": "使用简单的浅色背景",
"tip2": "在照片之间留出小间隙(≈1 厘米)",
"tip3": "以 300-600 DPI 扫描",
"tip4": "清洁扫描仪玻璃",
"title": "照片分割器",
"useCase1": "一次扫描整个相册页面",
"useCase2": "将平板批次拆分为单独的文件",
"useCase3": "将拼贴画拆分为单独的照片",
"useCase4": "从文档中提取照片",
"whatThisDoes": "功能说明",
"whatThisDoesDesc": "自动查找并从扫描页面或合成图像中提取每张照片 - 无需手动裁剪。",
"whenToUse": "何时使用"
}
},
"unsavedChanges": "您的 PDF 有未保存的更改。您想做什么?",
"unsavedChangesTitle": "未保存的更改"
"logOut": "退出登录"
}
+7 -5
View File
@@ -1,15 +1,17 @@
#!/usr/bin/env node
const { execSync } = require('node:child_process');
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('node:fs');
const path = require('node:path');
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const { argv } = require('node:process');
import { argv } from 'node:process';
const inputIdx = argv.indexOf('--input');
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
const POSTPROCESS_ONLY = !!INPUT_FILE;
// __dirname is available in CommonJS by default
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Generate 3rd party licenses for frontend dependencies
+16 -25
View File
@@ -1,12 +1,10 @@
import { Suspense } from "react";
import React, { Suspense } from "react";
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
import { FileContextProvider } from "./contexts/FileContext";
import { NavigationProvider } from "./contexts/NavigationContext";
import { FilesModalProvider } from "./contexts/FilesModalContext";
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
import { HotkeyProvider } from "./contexts/HotkeyContext";
import { SidebarProvider } from "./contexts/SidebarContext";
import { PreferencesProvider } from "./contexts/PreferencesContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import HomePage from "./pages/HomePage";
@@ -16,7 +14,6 @@ import "./styles/cookieconsent.css";
import "./index.css";
import { RightRailProvider } from "./contexts/RightRailContext";
import { ViewerProvider } from "./contexts/ViewerContext";
import { SignatureProvider } from "./contexts/SignatureContext";
// Import file ID debugging helpers (development only)
import "./utils/fileIdSafety";
@@ -42,27 +39,21 @@ export default function App() {
<Suspense fallback={<LoadingFallback />}>
<RainbowThemeProvider>
<ErrorBoundary>
<PreferencesProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
</PreferencesProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<SidebarProvider>
<ViewerProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</ViewerProvider>
</SidebarProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</Suspense>
+1 -134
View File
@@ -7,132 +7,6 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
},
{
"moduleName": "@embedpdf/core",
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
},
{
"moduleName": "@embedpdf/engines",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-annotation",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-export",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-history",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-interaction-manager",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-loader",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-pan",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-render",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-rotate",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-scroll",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-search",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-selection",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-spread",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-thumbnail",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-tiling",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-viewport",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-zoom",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@emotion/react",
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
@@ -161,13 +35,6 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/dates",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/dropzone",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
@@ -276,7 +143,7 @@
{
"moduleName": "posthog-js",
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
"moduleVersion": "1.268.0",
"moduleVersion": "1.266.0",
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
},
+2 -30
View File
@@ -9,9 +9,6 @@ import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
import DragOverlay from './fileManager/DragOverlay';
import { FileManagerProvider } from '../contexts/FileManagerContext';
import { Z_INDEX_FILE_MANAGER_MODAL } from '../styles/zIndex';
import { isGoogleDriveConfigured } from '../services/googleDrivePickerService';
import { loadScript } from '../utils/scriptLoader';
interface FileManagerProps {
selectedTool?: Tool | null;
@@ -23,7 +20,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
const { loadRecentFiles, handleRemoveFile } = useFileManager();
// File management handlers
const isFileSupported = useCallback((fileName: string) => {
@@ -87,30 +84,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
};
}, []);
// Preload Google Drive scripts if configured
useEffect(() => {
if (isGoogleDriveConfigured()) {
// Load scripts in parallel without blocking
Promise.all([
loadScript({
src: 'https://apis.google.com/js/api.js',
id: 'gapi-script',
async: true,
defer: true,
}),
loadScript({
src: 'https://accounts.google.com/gsi/client',
id: 'gis-script',
async: true,
defer: true,
}),
]).catch((error) => {
console.warn('Failed to preload Google Drive scripts:', error);
});
}
}, []);
// Modal size constants for consistent scaling
const modalHeight = '80vh';
const modalWidth = isMobile ? '100%' : '80vw';
@@ -127,7 +100,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
radius="md"
className="overflow-hidden p-0"
withCloseButton={false}
zIndex={Z_INDEX_FILE_MANAGER_MODAL}
styles={{
content: {
position: 'relative',
@@ -151,6 +123,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onDrop={handleNewFileUpload}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
accept={{}}
multiple={true}
activateOnClick={false}
style={{
@@ -174,7 +147,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onFileRemove={handleRemoveFileByIndex}
modalHeight={modalHeight}
refreshRecentFiles={refreshRecentFiles}
isLoading={loading}
>
{isMobile ? <MobileLayout /> : <DesktopLayout />}
</FileManagerProvider>
@@ -1,91 +0,0 @@
import React, { createContext, useContext, ReactNode } from 'react';
interface PDFAnnotationContextValue {
// Drawing mode management
activateDrawMode: () => void;
deactivateDrawMode: () => void;
activateSignaturePlacementMode: () => void;
activateDeleteMode: () => void;
// Drawing settings
updateDrawSettings: (color: string, size: number) => void;
// History operations
undo: () => void;
redo: () => void;
// Image data management
storeImageData: (id: string, data: string) => void;
getImageData: (id: string) => string | undefined;
// Placement state
isPlacementMode: boolean;
// Signature configuration
signatureConfig: any | null;
setSignatureConfig: (config: any | null) => void;
}
const PDFAnnotationContext = createContext<PDFAnnotationContextValue | undefined>(undefined);
interface PDFAnnotationProviderProps {
children: ReactNode;
// These would come from the signature context
activateDrawMode: () => void;
deactivateDrawMode: () => void;
activateSignaturePlacementMode: () => void;
activateDeleteMode: () => void;
updateDrawSettings: (color: string, size: number) => void;
undo: () => void;
redo: () => void;
storeImageData: (id: string, data: string) => void;
getImageData: (id: string) => string | undefined;
isPlacementMode: boolean;
signatureConfig: any | null;
setSignatureConfig: (config: any | null) => void;
}
export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
children,
activateDrawMode,
deactivateDrawMode,
activateSignaturePlacementMode,
activateDeleteMode,
updateDrawSettings,
undo,
redo,
storeImageData,
getImageData,
isPlacementMode,
signatureConfig,
setSignatureConfig
}) => {
const contextValue: PDFAnnotationContextValue = {
activateDrawMode,
deactivateDrawMode,
activateSignaturePlacementMode,
activateDeleteMode,
updateDrawSettings,
undo,
redo,
storeImageData,
getImageData,
isPlacementMode,
signatureConfig,
setSignatureConfig
};
return (
<PDFAnnotationContext.Provider value={contextValue}>
{children}
</PDFAnnotationContext.Provider>
);
};
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
const context = useContext(PDFAnnotationContext);
if (context === undefined) {
throw new Error('usePDFAnnotation must be used within a PDFAnnotationProvider');
}
return context;
};
@@ -1,89 +0,0 @@
import React, { useState } from 'react';
import { Stack, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { DrawingControls } from './DrawingControls';
import { ColorPicker } from './ColorPicker';
import { usePDFAnnotation } from '../providers/PDFAnnotationProvider';
export interface AnnotationToolConfig {
enableDrawing?: boolean;
enableImageUpload?: boolean;
enableTextInput?: boolean;
showPlaceButton?: boolean;
placeButtonText?: string;
}
interface BaseAnnotationToolProps {
config: AnnotationToolConfig;
children: React.ReactNode;
onSignatureDataChange?: (data: string | null) => void;
disabled?: boolean;
}
export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
config,
children,
onSignatureDataChange,
disabled = false
}) => {
const { t } = useTranslation();
const {
activateSignaturePlacementMode,
undo,
redo
} = usePDFAnnotation();
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [signatureData, setSignatureData] = useState<string | null>(null);
const handleSignatureDataChange = (data: string | null) => {
setSignatureData(data);
onSignatureDataChange?.(data);
};
const handlePlaceSignature = () => {
if (activateSignaturePlacementMode) {
activateSignaturePlacementMode();
}
};
return (
<Stack gap="md">
{/* Drawing Controls (Undo/Redo/Place) */}
<DrawingControls
onUndo={undo}
onRedo={redo}
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
hasSignatureData={!!signatureData}
disabled={disabled}
showPlaceButton={config.showPlaceButton}
placeButtonText={config.placeButtonText}
/>
{/* Tool Content */}
{React.cloneElement(children as React.ReactElement<any>, {
selectedColor,
signatureData,
onSignatureDataChange: handleSignatureDataChange,
onColorSwatchClick: () => setIsColorPickerOpen(true),
disabled
})}
{/* Instructions for placing signature */}
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
<Text size="sm">
Click anywhere on the PDF to place your annotation.
</Text>
</Alert>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
/>
</Stack>
);
};
@@ -1,67 +0,0 @@
import React from 'react';
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
interface ColorPickerProps {
isOpen: boolean;
onClose: () => void;
selectedColor: string;
onColorChange: (color: string) => void;
title?: string;
}
export const ColorPicker: React.FC<ColorPickerProps> = ({
isOpen,
onClose,
selectedColor,
onColorChange,
title = "Choose Color"
}) => {
return (
<Modal
opened={isOpen}
onClose={onClose}
title={title}
size="sm"
centered
>
<Stack gap="md">
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={onColorChange}
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
swatchesPerRow={6}
size="lg"
fullWidth
/>
<Group justify="flex-end">
<Button onClick={onClose}>
Done
</Button>
</Group>
</Stack>
</Modal>
);
};
interface ColorSwatchButtonProps {
color: string;
onClick: () => void;
size?: number;
}
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
color,
onClick,
size = 24
}) => {
return (
<ColorSwatch
color={color}
size={size}
radius={0}
style={{ cursor: 'pointer' }}
onClick={onClick}
/>
);
};
@@ -1,281 +0,0 @@
import React, { useRef, useState } from 'react';
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
import { ColorSwatchButton } from './ColorPicker';
import PenSizeSelector from '../../tools/sign/PenSizeSelector';
import SignaturePad from 'signature_pad';
interface DrawingCanvasProps {
selectedColor: string;
penSize: number;
penSizeInput: string;
onColorSwatchClick: () => void;
onPenSizeChange: (size: number) => void;
onPenSizeInputChange: (input: string) => void;
onSignatureDataChange: (data: string | null) => void;
onDrawingComplete?: () => void;
disabled?: boolean;
width?: number;
height?: number;
modalWidth?: number;
modalHeight?: number;
additionalButtons?: React.ReactNode;
}
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
selectedColor,
penSize,
penSizeInput,
onColorSwatchClick,
onPenSizeChange,
onPenSizeInputChange,
onSignatureDataChange,
onDrawingComplete,
disabled = false,
width = 400,
height = 150,
}) => {
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
const padRef = useRef<SignaturePad | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [colorPickerOpen, setColorPickerOpen] = useState(false);
const initPad = (canvas: HTMLCanvasElement) => {
if (!padRef.current) {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
padRef.current = new SignaturePad(canvas, {
penColor: selectedColor,
minWidth: penSize * 0.5,
maxWidth: penSize * 2.5,
throttle: 10,
minDistance: 5,
velocityFilterWeight: 0.7,
});
}
};
const openModal = () => {
// Clear pad ref so it reinitializes
if (padRef.current) {
padRef.current.off();
padRef.current = null;
}
setModalOpen(true);
};
const trimCanvas = (canvas: HTMLCanvasElement): string => {
const ctx = canvas.getContext('2d');
if (!ctx) return canvas.toDataURL('image/png');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
// Find bounds of non-transparent pixels
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const alpha = pixels[(y * canvas.width + x) * 4 + 3];
if (alpha > 0) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
const trimWidth = maxX - minX + 1;
const trimHeight = maxY - minY + 1;
// Create trimmed canvas
const trimmedCanvas = document.createElement('canvas');
trimmedCanvas.width = trimWidth;
trimmedCanvas.height = trimHeight;
const trimmedCtx = trimmedCanvas.getContext('2d');
if (trimmedCtx) {
trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
}
return trimmedCanvas.toDataURL('image/png');
};
const closeModal = () => {
if (padRef.current && !padRef.current.isEmpty()) {
const canvas = modalCanvasRef.current;
if (canvas) {
const trimmedPng = trimCanvas(canvas);
onSignatureDataChange(trimmedPng);
// Update preview canvas with proper aspect ratio
const img = new Image();
img.onload = () => {
if (previewCanvasRef.current) {
const ctx = previewCanvasRef.current.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
// Calculate scaling to fit within preview canvas while maintaining aspect ratio
const scale = Math.min(
previewCanvasRef.current.width / img.width,
previewCanvasRef.current.height / img.height
);
const scaledWidth = img.width * scale;
const scaledHeight = img.height * scale;
const x = (previewCanvasRef.current.width - scaledWidth) / 2;
const y = (previewCanvasRef.current.height - scaledHeight) / 2;
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
}
}
};
img.src = trimmedPng;
if (onDrawingComplete) {
onDrawingComplete();
}
}
}
if (padRef.current) {
padRef.current.off();
padRef.current = null;
}
setModalOpen(false);
};
const clear = () => {
if (padRef.current) {
padRef.current.clear();
}
if (previewCanvasRef.current) {
const ctx = previewCanvasRef.current.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
}
}
onSignatureDataChange(null);
};
const updatePenColor = (color: string) => {
if (padRef.current) {
padRef.current.penColor = color;
}
};
const updatePenSize = (size: number) => {
if (padRef.current) {
padRef.current.minWidth = size * 0.8;
padRef.current.maxWidth = size * 1.2;
}
};
return (
<>
<Paper withBorder p="md">
<Stack gap="sm">
<Text fw={500}>Draw your signature</Text>
<canvas
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'pointer',
backgroundColor: '#ffffff',
width: '100%',
}}
onClick={disabled ? undefined : openModal}
/>
<Text size="sm" c="dimmed" ta="center">
Click to open drawing canvas
</Text>
</Stack>
</Paper>
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
<Stack gap="md">
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
<div>
<Text size="sm" fw={500} mb="xs">Color</Text>
<Popover
opened={colorPickerOpen}
onChange={setColorPickerOpen}
position="bottom-start"
withArrow
withinPortal={false}
>
<Popover.Target>
<div>
<ColorSwatchButton
color={selectedColor}
onClick={() => setColorPickerOpen(!colorPickerOpen)}
/>
</div>
</Popover.Target>
<Popover.Dropdown>
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={(color) => {
onColorSwatchClick();
updatePenColor(color);
}}
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
/>
</Popover.Dropdown>
</Popover>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={(size) => {
onPenSizeChange(size);
updatePenSize(size);
}}
onInputChange={onPenSizeInputChange}
placeholder="Size"
size="compact-sm"
style={{ width: '60px' }}
/>
</div>
</div>
<canvas
ref={(el) => {
modalCanvasRef.current = el;
if (el) initPad(el);
}}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
display: 'block',
touchAction: 'none',
backgroundColor: 'white',
width: '100%',
maxWidth: '800px',
height: '400px',
cursor: 'crosshair',
}}
/>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button variant="subtle" color="red" onClick={clear}>
Clear Canvas
</Button>
<Button onClick={closeModal}>
Done
</Button>
</div>
</Stack>
</Modal>
</>
);
};
export default DrawingCanvas;
@@ -1,60 +0,0 @@
import React from 'react';
import { Group, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface DrawingControlsProps {
onUndo?: () => void;
onRedo?: () => void;
onPlaceSignature?: () => void;
hasSignatureData?: boolean;
disabled?: boolean;
showPlaceButton?: boolean;
placeButtonText?: string;
}
export const DrawingControls: React.FC<DrawingControlsProps> = ({
onUndo,
onRedo,
onPlaceSignature,
hasSignatureData = false,
disabled = false,
showPlaceButton = true,
placeButtonText = "Update and Place"
}) => {
const { t } = useTranslation();
return (
<Group gap="sm">
{/* Undo/Redo Controls */}
<Button
variant="outline"
onClick={onUndo}
disabled={disabled}
flex={1}
>
{t('sign.undo', 'Undo')}
</Button>
<Button
variant="outline"
onClick={onRedo}
disabled={disabled}
flex={1}
>
{t('sign.redo', 'Redo')}
</Button>
{/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && (
<Button
variant="filled"
color="blue"
onClick={onPlaceSignature}
disabled={disabled || !hasSignatureData}
flex={1}
>
{placeButtonText}
</Button>
)}
</Group>
);
};
@@ -1,55 +0,0 @@
import React from 'react';
import { FileInput, Text, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface ImageUploaderProps {
onImageChange: (file: File | null) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
hint?: string;
}
export const ImageUploader: React.FC<ImageUploaderProps> = ({
onImageChange,
disabled = false,
label,
placeholder,
hint
}) => {
const { t } = useTranslation();
const handleImageChange = async (file: File | null) => {
if (file && !disabled) {
try {
// Validate that it's actually an image file
if (!file.type.startsWith('image/')) {
console.error('Selected file is not an image');
return;
}
onImageChange(file);
} catch (error) {
console.error('Error processing image file:', error);
}
} else if (!file) {
// Clear image data when no file is selected
onImageChange(null);
}
};
return (
<Stack gap="sm">
<FileInput
label={label || t('sign.image.label', 'Upload signature image')}
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
accept="image/*"
onChange={handleImageChange}
disabled={disabled}
/>
<Text size="sm" c="dimmed">
{hint || t('sign.image.hint', 'Upload an image of your signature')}
</Text>
</Stack>
);
};
@@ -1,172 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorPicker } from './ColorPicker';
interface TextInputWithFontProps {
text: string;
onTextChange: (text: string) => void;
fontSize: number;
onFontSizeChange: (size: number) => void;
fontFamily: string;
onFontFamilyChange: (family: string) => void;
textColor?: string;
onTextColorChange?: (color: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
}
export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
text,
onTextChange,
fontSize,
onFontSizeChange,
fontFamily,
onFontFamilyChange,
textColor = '#000000',
onTextColorChange,
disabled = false,
label,
placeholder
}) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
// Sync font size input with prop changes
useEffect(() => {
setFontSizeInput(fontSize.toString());
}, [fontSize]);
const fontOptions = [
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Times-Roman', label: 'Times' },
{ value: 'Courier', label: 'Courier' },
{ value: 'Arial', label: 'Arial' },
{ value: 'Georgia', label: 'Georgia' },
];
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
return (
<Stack gap="sm">
<TextInput
label={label || t('sign.text.name', 'Signer Name')}
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
value={text}
onChange={(e) => onTextChange(e.target.value)}
disabled={disabled}
required
/>
{/* Font Selection */}
<Select
label="Font"
value={fontFamily}
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
data={fontOptions}
disabled={disabled}
searchable
allowDeselect={false}
/>
{/* Font Size and Color */}
<Group grow>
<Combobox
onOptionSubmit={(optionValue) => {
setFontSizeInput(optionValue);
const size = parseInt(optionValue);
if (!isNaN(size)) {
onFontSizeChange(size);
}
fontSizeCombobox.closeDropdown();
}}
store={fontSizeCombobox}
withinPortal={false}
>
<Combobox.Target>
<TextInput
label="Font Size"
placeholder="Type or select font size (8-200)"
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
setFontSizeInput(value);
// Parse and validate the typed value in real-time
const size = parseInt(value);
if (!isNaN(size) && size >= 8 && size <= 200) {
onFontSizeChange(size);
}
fontSizeCombobox.openDropdown();
fontSizeCombobox.updateSelectedOptionIndex();
}}
onClick={() => fontSizeCombobox.openDropdown()}
onFocus={() => fontSizeCombobox.openDropdown()}
onBlur={() => {
fontSizeCombobox.closeDropdown();
// Clean up invalid values on blur
const size = parseInt(fontSizeInput);
if (isNaN(size) || size < 8 || size > 200) {
setFontSizeInput(fontSize.toString());
} else {
onFontSizeChange(size);
}
}}
disabled={disabled}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{fontSizeOptions.map((size) => (
<Combobox.Option value={size} key={size}>
{size}px
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
{/* Text Color Picker */}
{onTextColorChange && (
<Box>
<TextInput
label="Text Color"
value={textColor}
readOnly
disabled={disabled}
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{ cursor: disabled ? 'default' : 'pointer' }}
rightSection={
<Box
style={{
width: 24,
height: 24,
backgroundColor: textColor,
border: '1px solid #ccc',
borderRadius: 4,
cursor: disabled ? 'default' : 'pointer'
}}
/>
}
/>
</Box>
)}
</Group>
{/* Color Picker Modal */}
{onTextColorChange && (
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={textColor}
onColorChange={onTextColorChange}
/>
)}
</Stack>
);
};
@@ -1,45 +0,0 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
import { DrawingCanvas } from '../shared/DrawingCanvas';
interface DrawingToolProps {
onDrawingChange?: (data: string | null) => void;
disabled?: boolean;
}
export const DrawingTool: React.FC<DrawingToolProps> = ({
onDrawingChange,
disabled = false
}) => {
const [selectedColor] = useState('#000000');
const [penSize, setPenSize] = useState(2);
const [penSizeInput, setPenSizeInput] = useState('2');
const toolConfig = {
enableDrawing: true,
showPlaceButton: true,
placeButtonText: "Place Drawing"
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onDrawingChange}
disabled={disabled}
>
<Stack gap="sm">
<DrawingCanvas
selectedColor={selectedColor}
penSize={penSize}
penSizeInput={penSizeInput}
onColorSwatchClick={() => {}} // Color picker handled by BaseAnnotationTool
onPenSizeChange={setPenSize}
onPenSizeInputChange={setPenSizeInput}
onSignatureDataChange={onDrawingChange || (() => {})}
disabled={disabled}
/>
</Stack>
</BaseAnnotationTool>
);
};
@@ -1,67 +0,0 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
import { ImageUploader } from '../shared/ImageUploader';
interface ImageToolProps {
onImageChange?: (data: string | null) => void;
disabled?: boolean;
}
export const ImageTool: React.FC<ImageToolProps> = ({
onImageChange,
disabled = false
}) => {
const [, setImageData] = useState<string | null>(null);
const handleImageUpload = async (file: File | null) => {
if (file && !disabled) {
try {
const result = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
setImageData(result);
onImageChange?.(result);
} catch (error) {
console.error('Error reading file:', error);
}
} else if (!file) {
setImageData(null);
onImageChange?.(null);
}
};
const toolConfig = {
enableImageUpload: true,
showPlaceButton: true,
placeButtonText: "Place Image"
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onImageChange}
disabled={disabled}
>
<Stack gap="sm">
<ImageUploader
onImageChange={handleImageUpload}
disabled={disabled}
label="Upload Image"
placeholder="Select image file"
hint="Upload a PNG, JPG, or other image file to place on the PDF"
/>
</Stack>
</BaseAnnotationTool>
);
};
@@ -1,57 +0,0 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
import { TextInputWithFont } from '../shared/TextInputWithFont';
interface TextToolProps {
onTextChange?: (text: string) => void;
disabled?: boolean;
}
export const TextTool: React.FC<TextToolProps> = ({
onTextChange,
disabled = false
}) => {
const [text, setText] = useState('');
const [fontSize, setFontSize] = useState(16);
const [fontFamily, setFontFamily] = useState('Helvetica');
const handleTextChange = (newText: string) => {
setText(newText);
onTextChange?.(newText);
};
const handleSignatureDataChange = (data: string | null) => {
if (data) {
onTextChange?.(data);
}
};
const toolConfig = {
enableTextInput: true,
showPlaceButton: true,
placeButtonText: "Place Text"
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={handleSignatureDataChange}
disabled={disabled}
>
<Stack gap="sm">
<TextInputWithFont
text={text}
onTextChange={handleTextChange}
fontSize={fontSize}
onFontSizeChange={setFontSize}
fontFamily={fontFamily}
onFontFamilyChange={setFontFamily}
disabled={disabled}
label="Text Content"
placeholder="Enter text to place on the PDF"
/>
</Stack>
</BaseAnnotationTool>
);
};
@@ -1,177 +0,0 @@
import React, { useRef, useState } from 'react';
import { Button, Group, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import AddIcon from '@mui/icons-material/Add';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import LocalIcon from '../shared/LocalIcon';
import { BASE_PATH } from '../../constants/app';
import styles from './FileEditor.module.css';
interface AddFileCardProps {
onFileSelect: (files: File[]) => void;
accept?: string;
multiple?: boolean;
}
const AddFileCard = ({
onFileSelect,
accept,
multiple = true
}: AddFileCardProps) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const [isUploadHover, setIsUploadHover] = useState(false);
const handleCardClick = () => {
openFilesModal();
};
const handleNativeUploadClick = (e: React.MouseEvent) => {
e.stopPropagation();
fileInputRef.current?.click();
};
const handleOpenFilesModal = (e: React.MouseEvent) => {
e.stopPropagation();
openFilesModal();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) {
onFileSelect(files);
}
// Reset input so same files can be selected again
event.target.value = '';
};
return (
<>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
tabIndex={0}
role="button"
aria-label={t('fileEditor.addFiles', 'Add files')}
onClick={handleCardClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleCardClick();
}
}}
>
{/* Header bar - matches FileEditorThumbnail structure */}
<div className={`${styles.header} ${styles.addFileHeader}`}>
<div className={styles.logoMark}>
<AddIcon sx={{ color: 'inherit', fontSize: '1.5rem' }} />
</div>
<div className={styles.headerIndex}>
{t('fileEditor.addFiles', 'Add Files')}
</div>
<div className={styles.kebab} />
</div>
{/* Main content area */}
<div className={styles.addFileContent}>
{/* Stirling PDF Branding */}
<Group gap="xs" align="center">
<img
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
alt="Stirling PDF"
style={{ height: '2.2rem', width: 'auto' }}
/>
</Group>
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.6rem',
width: '100%',
marginTop: '0.8rem',
marginBottom: '0.8rem'
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 58px - 0.6rem)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }}
>
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
</span>
</div>
</div>
</>
);
};
export default AddFileCard;
@@ -34,9 +34,9 @@
.header {
height: 2.25rem;
border-radius: 0.0625rem 0.0625rem 0 0;
display: flex;
display: grid;
grid-template-columns: 44px 1fr 44px;
align-items: center;
justify-content: space-between;
padding: 0 6px;
user-select: none;
background: var(--bg-toolbar);
@@ -56,20 +56,6 @@
border-bottom: 1px solid var(--header-selected-bg);
}
/* Error highlight (transient) */
.headerError {
background: var(--color-red-200);
color: var(--text-primary);
border-bottom: 2px solid var(--color-red-500);
}
/* Unsupported (but not errored) header appearance */
.headerUnsupported {
background: var(--unsupported-bar-bg); /* neutral gray */
color: #FFFFFF;
border-bottom: 1px solid var(--unsupported-bar-border);
}
/* Selected border color in light mode */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: var(--card-selected-border);
@@ -86,24 +72,14 @@
}
.headerIndex {
position: absolute;
left: 50%;
transform: translateX(-50%);
text-align: center;
font-weight: 500;
font-size: 18px;
letter-spacing: 0.04em;
}
.headerActions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.headerIconButton {
color: #FFFFFF !important;
.kebab {
justify-self: end;
}
/* Menu dropdown */
@@ -225,11 +201,6 @@
color: rgba(0, 0, 0, 0.35);
}
.pinned {
color: #FFC107 !important;
}
/* Unsupported file indicator */
.unsupportedPill {
margin-left: 1.75rem;
@@ -246,22 +217,6 @@
height: 20px;
}
/* Error pill shown when a file failed processing */
.errorPill {
margin-left: 1.75rem;
background: var(--color-red-500);
color: #ffffff;
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
min-width: 56px;
height: 20px;
}
/* Animations */
@keyframes pulse {
0%, 100% {
@@ -318,84 +273,4 @@
/* Light mode selected header stroke override */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: #3B4B6E;
}
/* =========================
Add File Card Styles
========================= */
.addFileCard {
background: var(--file-card-bg);
border: 2px dashed var(--border-default);
border-radius: 0.0625rem;
cursor: pointer;
transition: all 0.18s ease;
max-width: 100%;
max-height: 100%;
overflow: hidden;
margin-left: 0.5rem;
margin-right: 0.5rem;
opacity: 0.7;
}
.addFileCard:hover {
opacity: 1;
border-color: var(--color-blue-500);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.addFileCard:focus {
outline: 2px solid var(--color-blue-500);
outline-offset: 2px;
}
.addFileHeader {
background: var(--bg-subtle);
color: var(--text-secondary);
border-bottom: 1px solid var(--border-default);
}
.addFileCard:hover .addFileHeader {
background: var(--color-blue-500);
color: white;
}
.addFileContent {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
gap: 0.5rem;
}
.addFileIcon {
display: flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
border-radius: 50%;
background: var(--bg-subtle);
transition: background-color 0.18s ease;
}
.addFileCard:hover .addFileIcon {
background: var(--color-blue-50);
}
.addFileText {
font-weight: 500;
transition: color 0.18s ease;
}
.addFileCard:hover .addFileText {
color: var(--text-primary);
}
.addFileSubtext {
font-size: 0.875rem;
opacity: 0.8;
}
}
@@ -1,18 +1,16 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import React, { useState, useCallback, useRef, useMemo } from 'react';
import {
Text, Center, Box, LoadingOverlay, Stack, Group
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
import { useNavigationActions } from '../../contexts/NavigationContext';
import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
import FileEditorThumbnail from './FileEditorThumbnail';
import AddFileCard from './AddFileCard';
import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
import { FileId, StirlingFile } from '../../types/fileContext';
import { alert } from '../toast';
import { downloadBlob } from '../../utils/downloadUtils';
@@ -37,7 +35,6 @@ const FileEditor = ({
// Use optimized FileContext hooks
const { state, selectors } = useFileState();
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
const { actions } = useFileActions();
// Extract needed values from state (memoized to prevent infinite loops)
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
@@ -49,20 +46,12 @@ const FileEditor = ({
// Get file selection context
const { setSelectedFiles } = useFileSelection();
const [_status, _setStatus] = useState<string | null>(null);
const [_error, _setError] = useState<string | null>(null);
// Toast helpers
const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => {
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
}, []);
const showError = useCallback((message: string) => {
alert({ alertType: 'error', title: 'Error', body: message, expandable: true });
}, []);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Enable selection mode automatically in tool mode
useEffect(() => {
React.useEffect(() => {
if (toolMode) {
setSelectionMode(true);
}
@@ -93,7 +82,7 @@ const FileEditor = ({
// Process uploaded files using context
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
_setError(null);
setError(null);
try {
const allExtractedFiles: File[] = [];
@@ -168,18 +157,18 @@ const FileEditor = ({
// Show any errors
if (errors.length > 0) {
showError(errors.join('\n'));
setError(errors.join('\n'));
}
// Process all extracted files
if (allExtractedFiles.length > 0) {
// Add files to context and select them automatically
await addFiles(allExtractedFiles, { selectFiles: true });
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
// Add files to context (they will be processed automatically)
await addFiles(allExtractedFiles);
setStatus(`Added ${allExtractedFiles.length} files`);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
showError(errorMessage);
setError(errorMessage);
console.error('File processing error:', err);
// Reset extraction progress on error
@@ -217,7 +206,7 @@ const FileEditor = ({
} else {
// Check if we've hit the selection limit
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning');
setStatus(`Maximum ${maxAllowed} files can be selected`);
return;
}
newSelection = [...currentSelectedIds, contextFileId];
@@ -226,7 +215,7 @@ const FileEditor = ({
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]);
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
// File reordering handler for drag and drop
@@ -282,13 +271,13 @@ const FileEditor = ({
// Update status
const moveCount = filesToMove.length;
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeStirlingFileStubs, reorderFiles, _setStatus]);
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
// File operations using context
const handleCloseFile = useCallback((fileId: FileId) => {
const handleDeleteFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
@@ -308,49 +297,7 @@ const FileEditor = ({
if (record && file) {
downloadBlob(file, file.name);
}
}, [activeStirlingFileStubs, selectors, _setStatus]);
const handleUnzipFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
try {
// Extract and store files using shared service method
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
if (result.success && result.extractedStubs.length > 0) {
// Add extracted file stubs to FileContext
await actions.addStirlingFileStubs(result.extractedStubs);
// Remove the original ZIP file
removeFiles([fileId], false);
alert({
alertType: 'success',
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
expandable: false,
durationMs: 3500
});
} else {
alert({
alertType: 'error',
title: `Failed to extract files from ${file.name}`,
body: result.errors.join('\n'),
expandable: true,
durationMs: 3500
});
}
} catch (error) {
console.error('Failed to unzip file:', error);
alert({
alertType: 'error',
title: `Error unzipping ${file.name}`,
expandable: false,
durationMs: 3500
});
}
}
}, [activeStirlingFileStubs, selectors, actions, removeFiles]);
}, [activeStirlingFileStubs, selectors, setStatus]);
const handleViewFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
@@ -367,10 +314,10 @@ const FileEditor = ({
try {
// Use FileContext to handle loading stored files
// The files are already in FileContext, just need to add them to active files
showStatus(`Loaded ${selectedFiles.length} files from storage`);
setStatus(`Loaded ${selectedFiles.length} files from storage`);
} catch (err) {
console.error('Error loading files from storage:', err);
showError('Failed to load some files from storage');
setError('Failed to load some files from storage');
}
}, []);
@@ -391,7 +338,7 @@ const FileEditor = ({
<Box pos="relative" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={false} />
<Box p="md">
<Box p="md" pt="xl">
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
@@ -449,14 +396,6 @@ const FileEditor = ({
pointerEvents: 'auto'
}}
>
{/* Add File Card - only show when files exist */}
{activeStirlingFileStubs.length > 0 && (
<AddFileCard
key="add-file-card"
onFileSelect={handleFileUpload}
/>
)}
{activeStirlingFileStubs.map((record, index) => {
return (
<FileEditorThumbnail
@@ -467,12 +406,11 @@ const FileEditor = ({
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onCloseFile={handleCloseFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
_onSetStatus={showStatus}
onSetStatus={setStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
/>
@@ -490,7 +428,31 @@ const FileEditor = ({
onSelectFiles={handleLoadFromStorage}
/>
{status && (
<Portal>
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }}
>
{status}
</Notification>
</Portal>
)}
{error && (
<Portal>
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
>
{error}
</Notification>
</Portal>
)}
</Box>
</Dropzone>
);
@@ -1,21 +1,17 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { Text, ActionIcon, CheckboxIndicator, Tooltip } from '@mantine/core';
import { alert } from '../toast';
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import CloseIcon from '@mui/icons-material/Close';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { StirlingFileStub } from '../../types/fileContext';
import { zipFileService } from '../../services/zipFileService';
import styles from './FileEditor.module.css';
import { useFileContext } from '../../contexts/FileContext';
import { useFileState } from '../../contexts/file/fileHooks';
import { FileId } from '../../types/file';
import { formatFileSize } from '../../utils/fileUtils';
import ToolChain from '../shared/ToolChain';
@@ -29,12 +25,11 @@ interface FileEditorThumbnailProps {
selectedFiles: FileId[];
selectionMode: boolean;
onToggleFile: (fileId: FileId) => void;
onCloseFile: (fileId: FileId) => void;
onDeleteFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void;
_onSetStatus: (status: string) => void;
onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
onDownloadFile: (fileId: FileId) => void;
onUnzipFile?: (fileId: FileId) => void;
toolMode?: boolean;
isSupported?: boolean;
}
@@ -44,18 +39,14 @@ const FileEditorThumbnail = ({
index,
selectedFiles,
onToggleFile,
onCloseFile,
onViewFile,
_onSetStatus,
onDeleteFile,
onSetStatus,
onReorderFiles,
onDownloadFile,
onUnzipFile,
isSupported = true,
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
const { state } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
@@ -69,9 +60,6 @@ const FileEditorThumbnail = ({
}, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
const pageCount = file.processedFile?.totalPages || 0;
const handleRef = useRef<HTMLSpanElement | null>(null);
@@ -199,25 +187,9 @@ const FileEditorThumbnail = ({
// ---- Card interactions ----
const handleCardClick = () => {
if (!isSupported) return;
// Clear error state if file has an error (click to clear error)
if (hasError) {
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
}
onToggleFile(file.id);
};
const handleCardDoubleClick = () => {
if (!isSupported) return;
onViewFile(file.id);
};
// ---- Style helpers ----
const getHeaderClassName = () => {
if (hasError) return styles.headerError;
if (!isSupported) return styles.headerUnsupported;
return isSelected ? styles.headerSelected : styles.headerResting;
};
return (
<div
@@ -227,25 +199,24 @@ const FileEditorThumbnail = ({
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
style={{opacity: isDragging ? 0.9 : 1}}
style={{
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)',
}}
tabIndex={0}
role="listitem"
aria-selected={isSelected}
onClick={handleCardClick}
onDoubleClick={handleCardDoubleClick}
>
{/* Header bar */}
<div
className={`${styles.header} ${getHeaderClassName()}`}
data-has-error={hasError}
className={`${styles.header} ${
isSelected ? styles.headerSelected : styles.headerResting
}`}
>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{hasError ? (
<div className={styles.errorPill}>
<span>{t('error._value', 'Error')}</span>
</div>
) : isSupported ? (
{isSupported ? (
<CheckboxIndicator
checked={isSelected}
onChange={() => onToggleFile(file.id)}
@@ -265,60 +236,18 @@ const FileEditorThumbnail = ({
{index + 1}
</div>
{/* Action buttons group */}
<div className={styles.headerActions}>
{/* Pin/Unpin icon */}
<Tooltip label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}>
<ActionIcon
aria-label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}
variant="subtle"
className={isPinned ? styles.pinned : styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
} else {
pinFile(actualFile);
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
}
}
}}
>
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</ActionIcon>
</Tooltip>
{/* Download icon */}
<Tooltip label={t('download', 'Download')}>
<ActionIcon
aria-label={t('download', 'Download')}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
onDownloadFile(file.id);
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
}}
>
<DownloadOutlinedIcon fontSize="small" />
</ActionIcon>
</Tooltip>
{/* Kebab menu */}
<ActionIcon
aria-label={t('moreOptions', 'More options')}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
setShowActions((v) => !v);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</div>
{/* Kebab menu */}
<ActionIcon
aria-label={t('moreOptions', 'More options')}
variant="subtle"
className={styles.kebab}
onClick={(e) => {
e.stopPropagation();
setShowActions((v) => !v);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</div>
{/* Actions overlay */}
@@ -334,49 +263,39 @@ const FileEditorThumbnail = ({
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
onSetStatus?.(`Unpinned ${file.name}`);
} else {
pinFile(actualFile);
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
onSetStatus?.(`Pinned ${file.name}`);
}
}
setShowActions(false);
}}
>
{isPinned ? <PushPinIcon className={styles.pinned} fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
</button>
<button
className={styles.actionRow}
onClick={() => { onDownloadFile(file.id); alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
onClick={() => { onDownloadFile(file.id); setShowActions(false); }}
>
<DownloadOutlinedIcon fontSize="small" />
<span>{t('download', 'Download')}</span>
</button>
{isZipFile && onUnzipFile && (
<button
className={styles.actionRow}
onClick={() => { onUnzipFile(file.id); alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
>
<UnarchiveIcon fontSize="small" />
<span>{t('fileManager.unzip', 'Unzip')}</span>
</button>
)}
<div className={styles.actionsDivider} />
<button
className={`${styles.actionRow} ${styles.actionDanger}`}
onClick={() => {
onCloseFile(file.id);
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
onDeleteFile(file.id);
onSetStatus(`Deleted ${file.name}`);
setShowActions(false);
}}
>
<CloseIcon fontSize="small" />
<span>{t('close', 'Close')}</span>
<DeleteOutlineIcon fontSize="small" />
<span>{t('delete', 'Delete')}</span>
</button>
</div>
)}
@@ -390,7 +309,7 @@ const FileEditorThumbnail = ({
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}>
<Text size="lg" fw={700} className={`${styles.title} ph-no-capture `} lineClamp={2}>
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
{file.name}
</Text>
<Text
@@ -409,14 +328,10 @@ const FileEditorThumbnail = ({
</div>
{/* Preview area */}
<div
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
>
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
<div className={styles.previewPaper}>
{file.thumbnailUrl && (
<img
className="ph-no-capture"
src={file.thumbnailUrl}
alt={file.name}
draggable={false}
@@ -443,6 +358,13 @@ const FileEditorThumbnail = ({
)}
</div>
{/* Pin indicator (bottom-left) */}
{isPinned && (
<span className={styles.pinIndicator} aria-hidden>
<PushPinIcon fontSize="small" />
</span>
)}
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
@@ -42,7 +42,6 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{currentFile && thumbnail ? (
<img
className='ph-no-capture'
src={thumbnail}
alt={currentFile.name}
style={{
@@ -67,7 +66,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text className='ph-no-capture' size="sm" fw={500} truncate>
<Text size="sm" fw={500} truncate>
{currentFile ? currentFile.name : 'No file selected'}
</Text>
<Text size="xs" c="dimmed">
@@ -1,115 +0,0 @@
import React, { useState } from 'react';
import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core';
import HistoryIcon from '@mui/icons-material/History';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import LocalIcon from '../shared/LocalIcon';
import { BASE_PATH } from '../../constants/app';
const EmptyFilesState: React.FC = () => {
const { t } = useTranslation();
const { colorScheme } = useMantineColorScheme();
const { onLocalFileClick } = useFileManagerContext();
const [isUploadHover, setIsUploadHover] = useState(false);
const handleUploadClick = () => {
onLocalFileClick();
};
return (
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem'
}}
>
{/* Container */}
<div
style={{
backgroundColor: 'transparent',
padding: '3rem 2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5rem',
minWidth: '20rem',
maxWidth: '28rem',
width: '100%'
}}
>
{/* No Recent Files Message */}
<Stack align="center" gap="sm">
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
<Text c="dimmed" ta="center" size="lg">
{t('fileManager.noRecentFiles', 'No recent files')}
</Text>
</Stack>
{/* Stirling PDF Logo */}
<Group gap="xs" align="center">
<img
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
alt="Stirling PDF"
style={{ height: '2.2rem', width: 'auto' }}
/>
</Group>
{/* Upload Button */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
marginTop: '0.5rem',
marginBottom: '0.5rem'
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--bg-file-manager)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: isUploadHover ? '2rem' : '1rem',
height: '38px',
width: isUploadHover ? '100%' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease, border-radius .5s ease'
}}
onClick={handleUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem', textAlign: 'center' }}
>
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
</span>
</div>
</div>
);
};
export default EmptyFilesState;
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { Stack, Button, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
@@ -50,7 +50,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
};
// Reset index when selection changes
useEffect(() => {
React.useEffect(() => {
if (currentFileIndex >= selectedFiles.length) {
setCurrentFileIndex(0);
}
@@ -44,7 +44,7 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
</Group>
<Box ml="md">
{sortedHistory.map((historyFile) => (
{sortedHistory.map((historyFile, _index) => (
<FileListItem
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
file={historyFile}
@@ -56,6 +56,7 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
onDoubleClick={() => onFileDoubleClick(historyFile)}
isHistoryFile={true} // This enables "Add to Recents" in menu
isLatestVersion={false} // History files are never latest
// onAddToRecents is accessed from context by FileListItem
/>
))}
</Box>
@@ -26,7 +26,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<ScrollArea style={{ flex: 1 }} p="md">
<Stack gap="sm">
<Group justify="space-between" py="xs">
<Text className='ph-no-capture' size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
<Text size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
{currentFile ? currentFile.name : ''}
</Text>
@@ -1,10 +1,10 @@
import React from 'react';
import { Center, ScrollArea, Text, Stack } from '@mantine/core';
import CloudIcon from '@mui/icons-material/Cloud';
import HistoryIcon from '@mui/icons-material/History';
import { useTranslation } from 'react-i18next';
import FileListItem from './FileListItem';
import FileHistoryGroup from './FileHistoryGroup';
import EmptyFilesState from './EmptyFilesState';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
interface FileListAreaProps {
@@ -29,7 +29,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
onFileDoubleClick,
onDownloadSingle,
isFileSupported,
isLoading,
} = useFileManagerContext();
const { t } = useTranslation();
@@ -44,11 +43,15 @@ const FileListArea: React.FC<FileListAreaProps> = ({
scrollbarSize={8}
>
<Stack gap={0}>
{recentFiles.length === 0 && !isLoading ? (
<EmptyFilesState />
) : recentFiles.length === 0 && isLoading ? (
{recentFiles.length === 0 ? (
<Center style={{ height: '12.5rem' }}>
<Text c="dimmed" ta="center">{t('fileManager.loadingFiles', 'Loading files...')}</Text>
<Stack align="center" gap="sm">
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
<Text c="dimmed" ta="center">{t('fileManager.noRecentFiles', 'No recent files')}</Text>
<Text size="xs" c="dimmed" ta="center" style={{ opacity: 0.7 }}>
{t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
</Text>
</Stack>
</Center>
) : (
filteredFiles.map((file, index) => {
@@ -5,12 +5,10 @@ import DeleteIcon from '@mui/icons-material/Delete';
import DownloadIcon from '@mui/icons-material/Download';
import HistoryIcon from '@mui/icons-material/History';
import RestoreIcon from '@mui/icons-material/Restore';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import { useTranslation } from 'react-i18next';
import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { FileId, StirlingFileStub } from '../../types/fileContext';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import { zipFileService } from '../../services/zipFileService';
import ToolChain from '../shared/ToolChain';
interface FileListItemProps {
@@ -40,10 +38,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation();
const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
@@ -98,7 +93,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" align="center">
<Text size="sm" fw={500} className='ph-no-capture' truncate style={{ flex: 1 }}>{file.name}</Text>
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
<Badge size="xs" variant="light" color={"blue"}>
v{currentVersion}
</Badge>
@@ -188,6 +183,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onAddToRecents(file);
}}
>
{t('fileManager.restore', 'Restore')}
@@ -196,22 +192,6 @@ const FileListItem: React.FC<FileListItemProps> = ({
</>
)}
{/* Unzip option for ZIP files */}
{isZipFile && !isHistoryFile && (
<>
<Menu.Item
leftSection={<UnarchiveIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onUnzipFile(file);
}}
>
{t('fileManager.unzip', 'Unzip')}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
@@ -5,7 +5,6 @@ import UploadIcon from '@mui/icons-material/Upload';
import CloudIcon from '@mui/icons-material/Cloud';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import { useGoogleDrivePicker } from '../../hooks/useGoogleDrivePicker';
interface FileSourceButtonsProps {
horizontal?: boolean;
@@ -14,20 +13,8 @@ interface FileSourceButtonsProps {
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false
}) => {
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
const { t } = useTranslation();
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
const handleGoogleDriveClick = async () => {
try {
const files = await openGoogleDrivePicker({ multiple: true });
if (files.length > 0) {
onGoogleDriveSelect(files);
}
} catch (error) {
console.error('Failed to pick files from Google Drive:', error);
}
};
const buttonProps = {
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
@@ -80,24 +67,15 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
</Button>
<Button
variant="subtle"
color='var(--mantine-color-gray-6)'
variant={buttonProps.variant('drive')}
leftSection={<CloudIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={handleGoogleDriveClick}
onClick={() => onSourceChange('drive')}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
disabled={!isGoogleDriveEnabled}
styles={{
root: {
backgroundColor: 'transparent',
border: 'none',
'&:hover': {
backgroundColor: isGoogleDriveEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
}
}
}}
title={!isGoogleDriveEnabled ? t('fileManager.googleDriveNotAvailable', 'Google Drive integration not available') : undefined}
disabled
color={activeSource === 'drive' ? 'gray' : undefined}
styles={buttonProps.getStyles('drive')}
>
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
</Button>
@@ -9,6 +9,7 @@ const HiddenFileInput: React.FC = () => {
ref={fileInputRef}
type="file"
multiple={true}
accept={["*/*"] as any}
onChange={onFileInputChange}
style={{ display: 'none' }}
data-testid="file-input"
@@ -1,58 +0,0 @@
import React from 'react';
import { HotkeyBinding } from '../../utils/hotkeys';
import { useHotkeys } from '../../contexts/HotkeyContext';
interface HotkeyDisplayProps {
binding: HotkeyBinding | null | undefined;
size?: 'sm' | 'md';
muted?: boolean;
}
const baseKeyStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '0.375rem',
background: 'var(--mantine-color-gray-1)',
border: '1px solid var(--mantine-color-gray-3)',
padding: '0.125rem 0.35rem',
fontSize: '0.75rem',
lineHeight: 1,
fontFamily: 'var(--mantine-font-family-monospace, monospace)',
minWidth: '1.35rem',
color: 'var(--mantine-color-text)',
};
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 'sm', muted = false }) => {
const { getDisplayParts } = useHotkeys();
const parts = getDisplayParts(binding);
if (!binding || parts.length === 0) {
return null;
}
const keyStyle = size === 'md'
? { ...baseKeyStyle, fontSize: '0.85rem', padding: '0.2rem 0.5rem' }
: baseKeyStyle;
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
color: muted ? 'var(--mantine-color-dimmed)' : 'inherit',
fontWeight: muted ? 500 : 600,
}}
>
{parts.map((part, index) => (
<React.Fragment key={`${part}-${index}`}>
<kbd style={keyStyle}>{part}</kbd>
{index < parts.length - 1 && <span aria-hidden style={{ fontWeight: 400 }}>+</span>}
</React.Fragment>
))}
</span>
);
};
export default HotkeyDisplay;
+7 -26
View File
@@ -1,11 +1,9 @@
import React from 'react';
import { Box } from '@mantine/core';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileState } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import { useViewer } from '../../contexts/ViewerContext';
import './Workbench.css';
import TopControls from '../shared/TopControls';
@@ -15,18 +13,17 @@ import PageEditorControls from '../pageEditor/PageEditorControls';
import Viewer from '../viewer/Viewer';
import LandingPage from '../shared/LandingPage';
import Footer from '../shared/Footer';
import DismissAllErrorsButton from '../shared/DismissAllErrorsButton';
// No props needed - component uses contexts directly
export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext();
// Use context-based hooks to eliminate all prop drilling
const { selectors } = useFileState();
const { state } = useFileState();
const { workbench: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setWorkbench;
const activeFiles = selectors.getFiles();
const activeFiles = state.files.ids;
const {
previewFile,
pageEditorFunctions,
@@ -46,9 +43,6 @@ export default function Workbench() {
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
const { addFiles } = useFileHandler();
// Get active file index from ViewerContext
const { activeFileIndex, setActiveFileIndex } = useViewer();
const handlePreviewClose = () => {
setPreviewFile(null);
const previousMode = sessionStorage.getItem('previousMode');
@@ -100,8 +94,6 @@ export default function Workbench() {
setSidebarsVisible={setSidebarsVisible}
previewFile={previewFile}
onClose={handlePreviewClose}
activeFileIndex={activeFileIndex}
setActiveFileIndex={setActiveFileIndex}
/>
);
@@ -153,28 +145,17 @@ export default function Workbench() {
}
>
{/* Top Controls */}
{activeFiles.length > 0 && (
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
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 />
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
selectedToolKey={selectedToolId}
/>
{/* Main content area */}
<Box
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
style={{
transition: 'opacity 0.15s ease-in-out',
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
}}
>
{renderMainContent()}
@@ -317,7 +317,6 @@ const FileThumbnail = ({
>
{file.thumbnail && (
<img
className="ph-no-capture"
src={file.thumbnail}
alt={file.name}
draggable={false}
@@ -1,12 +1,9 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useFileState, useFileActions } from "../../contexts/FileContext";
import { useNavigationGuard } from "../../contexts/NavigationContext";
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
import { pdfExportService } from "../../services/pdfExportService";
import { documentManipulationService } from "../../services/documentManipulationService";
import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers";
import { createStirlingFilesAndStubs } from "../../services/fileStubHelpers";
// Thumbnail generation is now handled by individual PageThumbnail components
import './PageEditor.module.css';
import PageThumbnail from './PageThumbnail';
@@ -39,9 +36,6 @@ const PageEditor = ({
const { state, selectors } = useFileState();
const { actions } = useFileActions();
// Navigation guard for unsaved changes
const { setHasUnsavedChanges } = useNavigationGuard();
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
@@ -88,12 +82,6 @@ const PageEditor = ({
updateUndoRedoState();
}, [updateUndoRedoState]);
// Wrapper for executeCommand to track unsaved changes
const executeCommandWithTracking = useCallback((command: any) => {
undoManagerRef.current.executeCommand(command);
setHasUnsavedChanges(true);
}, [setHasUnsavedChanges]);
// Watch for container size changes to update split line positions
useEffect(() => {
const container = gridContainerRef.current;
@@ -150,16 +138,17 @@ const PageEditor = ({
// DOM-first command handlers
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
executeCommandWithTracking(bulkRotateCommand);
}, [executeCommandWithTracking]);
undoManagerRef.current.executeCommand(bulkRotateCommand);
}, []);
// Command factory functions for PageThumbnail
const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({
execute: () => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
executeCommandWithTracking(bulkRotateCommand);
undoManagerRef.current.executeCommand(bulkRotateCommand);
}
}), [executeCommandWithTracking]);
}), []);
const createDeleteCommand = useCallback((pageIds: string[]) => ({
execute: () => {
@@ -185,10 +174,10 @@ const PageEditor = ({
() => getPageNumbersFromIds(selectedPageIds),
closePdf
);
executeCommandWithTracking(deleteCommand);
undoManagerRef.current.executeCommand(deleteCommand);
}
}
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
const createSplitCommand = useCallback((position: number) => ({
execute: () => {
@@ -197,9 +186,9 @@ const PageEditor = ({
() => splitPositions,
setSplitPositions
);
executeCommandWithTracking(splitCommand);
undoManagerRef.current.executeCommand(splitCommand);
}
}), [splitPositions, executeCommandWithTracking]);
}), [splitPositions]);
// Command executor for PageThumbnail
const executeCommand = useCallback((command: any) => {
@@ -243,8 +232,8 @@ const PageEditor = ({
() => selectedPageNumbers,
closePdf
);
executeCommandWithTracking(deleteCommand);
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers, executeCommandWithTracking]);
undoManagerRef.current.executeCommand(deleteCommand);
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers]);
const handleDeletePage = useCallback((pageNumber: number) => {
if (!displayDocument) return;
@@ -262,8 +251,8 @@ const PageEditor = ({
() => getPageNumbersFromIds(selectedPageIds),
closePdf
);
executeCommandWithTracking(deleteCommand);
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
undoManagerRef.current.executeCommand(deleteCommand);
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
const handleSplit = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -309,8 +298,8 @@ const PageEditor = ({
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
};
executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
undoManagerRef.current.executeCommand(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
const handleSplitAll = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -355,8 +344,8 @@ const PageEditor = ({
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
};
executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
undoManagerRef.current.executeCommand(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
const handlePageBreak = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -369,8 +358,8 @@ const PageEditor = ({
() => displayDocument,
setEditedDocument
);
executeCommandWithTracking(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
undoManagerRef.current.executeCommand(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
const handlePageBreakAll = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -383,8 +372,8 @@ const PageEditor = ({
() => displayDocument,
setEditedDocument
);
executeCommandWithTracking(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
undoManagerRef.current.executeCommand(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => {
if (!displayDocument || files.length === 0) return;
@@ -427,8 +416,8 @@ const PageEditor = ({
() => displayDocument,
setEditedDocument
);
executeCommandWithTracking(reorderCommand);
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
undoManagerRef.current.executeCommand(reorderCommand);
}, [displayDocument, getPageNumbersFromIds]);
// Helper function to collect source files for multi-file export
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
@@ -510,14 +499,13 @@ const PageEditor = ({
// Step 4: Download the result
pdfExportService.downloadFile(result.blob, result.filename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]);
const onExportAll = useCallback(async () => {
if (!displayDocument) return;
@@ -526,79 +514,87 @@ const PageEditor = ({
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument,
displayDocument,
splitPositions
mergedPdfDocument || displayDocument, // Original order
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
);
// Step 2: Export to files
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
// Step 2: Check if we have multiple documents (splits) or single document
if (Array.isArray(processedDocuments)) {
// Multiple documents (splits) - export as ZIP
const blobs: Blob[] = [];
const filenames: string[] = [];
// Step 3: Download
if (files.length > 1) {
// Multiple files - create ZIP
const sourceFiles = getSourceFiles();
const baseExportFilename = getExportFilename();
const baseName = baseExportFilename.replace(/\.pdf$/i, '');
for (let i = 0; i < processedDocuments.length; i++) {
const doc = processedDocuments[i];
const partFilename = `${baseName}_part_${i + 1}.pdf`;
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename })
: await pdfExportService.exportPDF(doc, [], { filename: partFilename });
blobs.push(result.blob);
filenames.push(result.filename);
}
// Create ZIP file
const JSZip = await import('jszip');
const zip = new JSZip.default();
files.forEach((file) => {
zip.file(file.name, file);
blobs.forEach((blob, index) => {
zip.file(filenames[index], blob);
});
const zipBlob = await zip.generateAsync({ type: 'blob' });
const exportFilename = getExportFilename();
const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip');
const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip');
pdfExportService.downloadFile(zipBlob, zipFilename);
} else {
// Single file - download directly
const file = files[0];
pdfExportService.downloadFile(file, file.name);
// Single document - regular export
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(
processedDocuments,
sourceFiles,
[],
{ selectedOnly: false, filename: exportFilename }
)
: await pdfExportService.exportPDF(
processedDocuments,
[],
{ selectedOnly: false, filename: exportFilename }
);
pdfExportService.downloadFile(result.blob, result.filename);
}
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]);
// Apply DOM changes to document state using dedicated service
const applyChanges = useCallback(async () => {
const applyChanges = useCallback(() => {
if (!displayDocument) return;
setExportLoading(true);
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument,
displayDocument,
splitPositions
);
// Pass current display document (which includes reordering) to get both reordering AND DOM changes
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument, // Original order
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
);
// Step 2: Export to files
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
// For apply changes, we only set the first document if it's an array (splits shouldn't affect document state)
const documentToSet = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
setEditedDocument(documentToSet);
// Step 3: Create StirlingFiles and stubs for version history
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
if (!parentStub) throw new Error('Parent stub not found');
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool');
// Step 4: Consume files (replace in context)
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error('Apply changes failed:', error);
setExportLoading(false);
}
}, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]);
}, [displayDocument, mergedPdfDocument, splitPositions]);
const closePdf = useCallback(() => {
@@ -662,7 +658,7 @@ const PageEditor = ({
const displayedPages = displayDocument?.pages || [];
return (
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
<Box pos="relative" h='100%' pt={40} style={{ overflow: 'auto' }} data-scrolling-container="true">
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
@@ -783,14 +779,7 @@ const PageEditor = ({
)}
<NavigationWarningModal
onApplyAndContinue={async () => {
await applyChanges();
}}
onExportAndContinue={async () => {
await onExportAll();
}}
/>
<NavigationWarningModal />
</Box>
);
};
@@ -371,11 +371,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
</div>
) : thumbnailUrl ? (
<img
className="ph-no-capture"
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
draggable={false}
data-original-rotation={page.rotation}
style={{
width: '100%',
height: '100%',
@@ -17,34 +17,32 @@ export class RotatePageCommand extends DOMCommand {
}
execute(): void {
// Only update DOM for immediate visual feedback
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
// Extract current rotation from transform property to match the animated CSS
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
let newRotation = currentRotation + this.degrees;
newRotation = ((newRotation % 360) + 360) % 360;
const newRotation = currentRotation + this.degrees;
img.style.transform = `rotate(${newRotation}deg)`;
}
}
}
undo(): void {
// Only update DOM
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
// Extract current rotation from transform property
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
let previousRotation = currentRotation - this.degrees;
previousRotation = ((previousRotation % 360) + 360) % 360;
const previousRotation = currentRotation - this.degrees;
img.style.transform = `rotate(${previousRotation}deg)`;
}
}
@@ -1,130 +0,0 @@
/* AppConfigModal styles */
.modal-container {
display: flex;
gap: 0;
height: 37.5rem; /* 600px */
}
.modal-nav {
width: 15rem; /* 240px */
height: 37.5rem; /* 600px */
border-top-left-radius: 0.75rem; /* 12px */
border-bottom-left-radius: 0.75rem; /* 12px */
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Mobile: compact icon-only navigation */
@media (max-width: 1024px) {
.modal-container {
height: 100vh !important;
max-height: none !important;
}
.modal-nav {
width: 5rem; /* 80px - wider for larger icons */
height: 100vh !important;
max-height: none !important;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.modal-nav-scroll {
padding: 1rem 0.5rem;
}
.modal-nav-section {
margin-bottom: 1.5rem;
}
.modal-nav-item.mobile {
padding: 1rem;
justify-content: center;
border-radius: 0.75rem;
margin-bottom: 0.75rem;
}
.modal-content {
height: 100vh !important;
max-height: none !important;
border-radius: 0;
}
}
.modal-nav-scroll {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding-bottom: 2rem;
scrollbar-width: none;
-ms-overflow-style: none;
}
.modal-nav-scroll::-webkit-scrollbar {
display: none;
}
.modal-nav-section {
margin-bottom: 1rem;
}
.modal-nav-section-items {
margin-top: 0.5rem;
}
.modal-nav-item {
cursor: pointer;
padding: 0.5rem 0.625rem; /* 8px 10px */
border-radius: 0.5rem; /* 8px */
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem; /* 4px */
}
.modal-content {
flex: 1;
height: 37.5rem; /* 600px */
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-content-scroll {
flex: 1;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.modal-content-scroll::-webkit-scrollbar {
display: none;
}
.modal-header {
position: sticky;
top: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
}
.modal-body {
padding: 2rem;
padding-top: 1rem;
}
.confirm-modal-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.confirm-modal-buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
+115 -133
View File
@@ -1,12 +1,6 @@
import React, { useMemo, useState, useEffect } from 'react';
import { Modal, Text, ActionIcon } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import LocalIcon from './LocalIcon';
import Overview from './config/configSections/Overview';
import { createConfigNavSections } from './config/configNavSections';
import { NavKey } from './config/types';
import './AppConfigModal.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
import React from 'react';
import { Modal, Button, Stack, Text, Code, ScrollArea, Group, Badge, Alert, Loader } from '@mantine/core';
import { useAppConfig } from '../../hooks/useAppConfig';
interface AppConfigModalProps {
opened: boolean;
@@ -14,143 +8,131 @@ interface AppConfigModalProps {
}
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const [active, setActive] = useState<NavKey>('overview');
const isMobile = useMediaQuery("(max-width: 1024px)");
const { config, loading, error, refetch } = useAppConfig();
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
if (detail?.key) {
setActive(detail.key);
}
};
window.addEventListener('appConfig:navigate', handler as EventListener);
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
}, []);
const renderConfigSection = (title: string, data: any) => {
if (!data || typeof data !== 'object') return null;
const colors = useMemo(() => ({
navBg: 'var(--modal-nav-bg)',
sectionTitle: 'var(--modal-nav-section-title)',
navItem: 'var(--modal-nav-item)',
navItemActive: 'var(--modal-nav-item-active)',
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
contentBg: 'var(--modal-content-bg)',
headerBorder: 'var(--modal-header-border)',
}), []);
// Placeholder logout handler (not needed in open-source but keeps SaaS compatibility)
const handleLogout = () => {
// In SaaS this would sign out, in open-source it does nothing
console.log('Logout placeholder for SaaS compatibility');
return (
<Stack gap="xs" mb="md">
<Text fw={600} size="md" c="blue">{title}</Text>
<Stack gap="xs" pl="md">
{Object.entries(data).map(([key, value]) => (
<Group key={key} wrap="nowrap" align="flex-start">
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
{key}:
</Text>
{typeof value === 'boolean' ? (
<Badge color={value ? 'green' : 'red'} size="sm">
{value ? 'true' : 'false'}
</Badge>
) : typeof value === 'object' ? (
<Code block>{JSON.stringify(value, null, 2)}</Code>
) : (
String(value) || 'null'
)}
</Group>
))}
</Stack>
</Stack>
);
};
// Left navigation structure and icons
const configNavSections = useMemo(() =>
createConfigNavSections(
Overview,
handleLogout
),
[]
);
const basicConfig = config ? {
appName: config.appName,
appNameNavbar: config.appNameNavbar,
baseUrl: config.baseUrl,
contextPath: config.contextPath,
serverPort: config.serverPort,
} : null;
const activeLabel = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
if (found) return found.label;
}
return '';
}, [configNavSections, active]);
const securityConfig = config ? {
enableLogin: config.enableLogin,
} : null;
const activeComponent = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
if (found) return found.component;
}
return null;
}, [configNavSections, active]);
const systemConfig = config ? {
enableAlphaFunctionality: config.enableAlphaFunctionality,
enableAnalytics: config.enableAnalytics,
} : null;
const premiumConfig = config ? {
premiumEnabled: config.premiumEnabled,
premiumKey: config.premiumKey ? '***hidden***' : null,
runningProOrHigher: config.runningProOrHigher,
runningEE: config.runningEE,
license: config.license,
} : null;
const integrationConfig = config ? {
GoogleDriveEnabled: config.GoogleDriveEnabled,
SSOAutoLogin: config.SSOAutoLogin,
} : null;
const legalConfig = config ? {
termsAndConditions: config.termsAndConditions,
privacyPolicy: config.privacyPolicy,
cookiePolicy: config.cookiePolicy,
impressum: config.impressum,
accessibilityStatement: config.accessibilityStatement,
} : null;
return (
<Modal
opened={opened}
onClose={onClose}
title={null}
size={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
title="App Configuration (Testing)"
size="lg"
style={{ zIndex: 1000 }}
>
<div className="modal-container">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
style={{
background: colors.navBg,
borderRight: `1px solid ${colors.headerBorder}`,
}}
>
<div className="modal-nav-scroll">
{configNavSections.map(section => (
<div key={section.title} className="modal-nav-section">
{!isMobile && (
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{section.title}
</Text>
)}
<div className="modal-nav-section-items">
{section.items.map(item => {
const isActive = active === item.key;
const color = isActive ? colors.navItemActive : colors.navItem;
const iconSize = isMobile ? 28 : 18;
return (
<div
key={item.key}
onClick={() => setActive(item.key)}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
}}
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
{!isMobile && (
<Text size="sm" fw={500} style={{ color }}>
{item.label}
</Text>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
<Stack>
<Group justify="space-between">
<Text size="sm" c="dimmed">
This modal shows the current application configuration for testing purposes only.
</Text>
<Button size="xs" variant="light" onClick={refetch}>
Refresh
</Button>
</Group>
{/* Right content */}
<div className="modal-content">
<div className="modal-content-scroll">
{/* Sticky header with section title and small close button */}
<div
className="modal-header"
style={{
background: colors.contentBg,
borderBottom: `1px solid ${colors.headerBorder}`,
}}
>
<Text fw={700} size="lg">{activeLabel}</Text>
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
</div>
<div className="modal-body">
{activeComponent}
</div>
</div>
</div>
</div>
{loading && (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading configuration...</Text>
</Stack>
)}
{error && (
<Alert color="red" title="Error">
{error}
</Alert>
)}
{config && (
<ScrollArea h={400}>
<Stack gap="lg">
{renderConfigSection('Basic Configuration', basicConfig)}
{renderConfigSection('Security Configuration', securityConfig)}
{renderConfigSection('System Configuration', systemConfig)}
{renderConfigSection('Premium/Enterprise Configuration', premiumConfig)}
{renderConfigSection('Integration Configuration', integrationConfig)}
{renderConfigSection('Legal Configuration', legalConfig)}
{config.error && (
<Alert color="yellow" title="Configuration Warning">
{config.error}
</Alert>
)}
<Stack gap="xs">
<Text fw={600} size="md" c="blue">Raw Configuration</Text>
<Code block style={{ fontSize: '11px' }}>
{JSON.stringify(config, null, 2)}
</Code>
</Stack>
</Stack>
</ScrollArea>
)}
</Stack>
</Modal>
);
};
-101
View File
@@ -1,101 +0,0 @@
import React from 'react';
import { Box } from '@mantine/core';
interface BadgeProps {
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'colored';
color?: string;
textColor?: string;
backgroundColor?: string;
className?: string;
style?: React.CSSProperties;
}
const Badge: React.FC<BadgeProps> = ({
children,
size = 'sm',
variant = 'default',
color,
textColor,
backgroundColor,
className,
style
}) => {
const getSizeStyles = () => {
switch (size) {
case 'sm':
return {
padding: '0.125rem 0.5rem',
fontSize: '0.75rem',
fontWeight: 700,
borderRadius: '0.5rem',
};
case 'md':
return {
padding: '0.25rem 0.75rem',
fontSize: '0.875rem',
fontWeight: 700,
borderRadius: '0.625rem',
};
case 'lg':
return {
padding: '0.375rem 1rem',
fontSize: '1rem',
fontWeight: 700,
borderRadius: '0.75rem',
};
default:
return {};
}
};
const getVariantStyles = () => {
// If explicit colors are provided, use them
if (textColor && backgroundColor) {
return {
backgroundColor,
color: textColor,
};
}
// If a single color is provided, use it for text and 20% opacity for background
if (color) {
return {
backgroundColor: `color-mix(in srgb, ${color} 20%, transparent)`,
color: color,
};
}
// If variant is colored but no color provided, use default colored styling
if (variant === 'colored') {
return {
backgroundColor: `color-mix(in srgb, var(--category-color-default) 15%, transparent)`,
color: 'var(--category-color-default)',
borderColor: `color-mix(in srgb, var(--category-color-default) 30%, transparent)`,
border: '1px solid',
};
}
// Default styling
return {
background: 'var(--tool-header-badge-bg)',
color: 'var(--tool-header-badge-text)',
};
};
return (
<Box
className={className}
style={{
...getSizeStyles(),
...getVariantStyles(),
...style,
}}
>
{children}
</Box>
);
};
export default Badge;
@@ -1,51 +0,0 @@
import React from 'react';
import { Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useFileState } from '../../contexts/FileContext';
import { useFileActions } from '../../contexts/file/fileHooks';
import CloseIcon from '@mui/icons-material/Close';
interface DismissAllErrorsButtonProps {
className?: string;
}
const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ className }) => {
const { t } = useTranslation();
const { state } = useFileState();
const { actions } = useFileActions();
// Check if there are any files in error state
const hasErrors = state.ui.errorFileIds.length > 0;
// Don't render if there are no errors
if (!hasErrors) {
return null;
}
const handleDismissAllErrors = () => {
actions.clearAllFileErrors();
};
return (
<Group className={className}>
<Button
variant="light"
color="red"
size="sm"
leftSection={<CloseIcon fontSize="small" />}
onClick={handleDismissAllErrors}
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
zIndex: 1000,
pointerEvents: 'auto'
}}
>
{t('error.dismissAllErrors', 'Dismiss All Errors')} ({state.ui.errorFileIds.length})
</Button>
</Group>
);
};
export default DismissAllErrorsButton;
@@ -1,78 +0,0 @@
import React from 'react';
import { Menu, Loader, Group, Text } from '@mantine/core';
import VisibilityIcon from '@mui/icons-material/Visibility';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import FitText from './FitText';
interface FileDropdownMenuProps {
displayName: string;
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>;
currentFileIndex: number;
onFileSelect?: (index: number) => void;
switchingTo?: string | null;
viewOptionStyle: React.CSSProperties;
pillRef?: React.RefObject<HTMLDivElement>;
}
export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
displayName,
activeFiles,
currentFileIndex,
onFileSelect,
switchingTo,
viewOptionStyle,
}) => {
return (
<Menu trigger="click" position="bottom" width="30rem">
<Menu.Target>
<div style={{...viewOptionStyle, cursor: 'pointer'}}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
<FitText text={displayName} fontSize={14} minimumFontScale={0.6} className="ph-no-capture" />
<KeyboardArrowDownIcon fontSize="small" />
</div>
</Menu.Target>
<Menu.Dropdown style={{
backgroundColor: 'var(--right-rail-bg)',
border: '1px solid var(--border-subtle)',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
maxHeight: '50vh',
overflowY: 'auto'
}}>
{activeFiles.map((file, index) => {
const itemName = file?.name || 'Untitled';
const isActive = index === currentFileIndex;
return (
<Menu.Item
key={file.fileId}
onClick={(e) => {
e.stopPropagation();
onFileSelect?.(index);
}}
className="viewer-file-tab"
{...(isActive && { 'data-active': true })}
style={{
justifyContent: 'flex-start',
}}
>
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} className="ph-no-capture" />
</div>
{file.versionNumber && file.versionNumber > 1 && (
<Text size="xs" c="dimmed">
v{file.versionNumber}
</Text>
)}
</Group>
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
);
};
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import {
Modal,
Text,
@@ -125,7 +125,6 @@ const FilePickerModal = ({
title={t("fileUpload.selectFromStorage", "Select Files from Storage")}
size="lg"
scrollAreaComponent={ScrollArea.Autosize}
zIndex={1100}
>
<Stack gap="md">
{storedFiles.length === 0 ? (
@@ -1,4 +1,4 @@
import { useRef } from "react";
import React, { useRef } from "react";
import { FileButton, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
@@ -15,7 +15,7 @@ interface FileUploadButtonProps {
const FileUploadButton = ({
file,
onChange,
accept,
accept = "*/*",
disabled = false,
placeholder,
variant = "outline",
@@ -1,4 +1,5 @@
import { Flex } from '@mantine/core';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useCookieConsent } from '../../hooks/useCookieConsent';
@@ -41,6 +41,7 @@ const LandingPage = () => {
{/* White PDF Page Background */}
<Dropzone
onDrop={handleFileDrop}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
multiple={true}
className="w-4/5 flex items-center justify-center h-[95%]"
style={{
@@ -177,6 +178,7 @@ const LandingPage = () => {
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.zip"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../../i18n';
import LocalIcon from './LocalIcon';
import styles from './LanguageSelector.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
// Types
interface LanguageSelectorProps {
@@ -210,7 +209,6 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
width={600}
position={position}
offset={offset}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
transitionProps={{
transition: 'scale-y',
duration: 200,
@@ -266,15 +264,13 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
}}
>
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => {
// Enable languages with >90% translation completion
const enabledLanguages = ['en-GB', 'ar-AR', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'pt-BR', 'ru-RU', 'zh-CN'];
const isDisabled = !enabledLanguages.includes(option.value);
const isEnglishGB = option.value === 'en-GB'; // Currently only English GB has enough translations to use
const isDisabled = !isEnglishGB;
return (
<LanguageItem
@@ -1,3 +1,4 @@
import React from "react";
import { Box, Group, Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
@@ -36,7 +37,7 @@ const MultiSelectControls = ({
>
{t("fileManager.clearSelection", "Clear Selection")}
</Button>
{onAddToUpload && (
<Button
size="xs"
@@ -46,7 +47,7 @@ const MultiSelectControls = ({
{t("fileManager.addToUpload", "Add to Upload")}
</Button>
)}
{onOpenInFileEditor && (
<Button
size="xs"
@@ -57,7 +58,7 @@ const MultiSelectControls = ({
{t("fileManager.openInFileEditor", "Open in File Editor")}
</Button>
)}
{onOpenInPageEditor && (
<Button
size="xs"
@@ -68,7 +69,7 @@ const MultiSelectControls = ({
{t("fileManager.openInPageEditor", "Open in Page Editor")}
</Button>
)}
{onDeleteAll && (
<Button
size="xs"
@@ -84,4 +85,4 @@ const MultiSelectControls = ({
);
};
export default MultiSelectControls;
export default MultiSelectControls;
@@ -1,19 +1,23 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useNavigationGuard } from "../../contexts/NavigationContext";
import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import { useNavigationGuard } from '../../contexts/NavigationContext';
interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>;
onExportAndContinue?: () => Promise<void>;
}
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
const { t } = useTranslation();
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
useNavigationGuard();
const NavigationWarningModal = ({
onApplyAndContinue,
onExportAndContinue
}: NavigationWarningModalProps) => {
const {
showNavigationWarning,
hasUnsavedChanges,
cancelNavigation,
confirmNavigation,
setHasUnsavedChanges
} = useNavigationGuard();
const handleKeepWorking = () => {
cancelNavigation();
@@ -32,14 +36,13 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
confirmNavigation();
};
const _handleExportAndContinue = async () => {
const handleExportAndContinue = async () => {
if (onExportAndContinue) {
await onExportAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
const BUTTON_WIDTH = "10rem";
if (!hasUnsavedChanges) {
return null;
@@ -49,58 +52,55 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
<Modal
opened={showNavigationWarning}
onClose={handleKeepWorking}
title={t("unsavedChangesTitle", "Unsaved Changes")}
title="Unsaved Changes"
centered
size="auto"
closeOnClickOutside={true}
closeOnEscape={true}
closeOnClickOutside={false}
closeOnEscape={false}
>
<Stack>
<Stack ta="center" p="md">
<Text size="md" fw="300">
{t("unsavedChanges", "You have unsaved changes to your PDF.")}
<Stack gap="md">
<Text>
You have unsaved changes to your PDF. What would you like to do?
</Text>
<Text size="lg" fw="500" >
{t("areYouSure", "Are you sure you want to leave?")}
</Text>
</Stack>
{/* Desktop layout: 2 groups side by side */}
<Group justify="space-between" gap="xl" visibleFrom="md">
<Group gap="sm">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
{t("keepWorking", "Keep Working")}
</Button>
</Group>
<Group gap="sm">
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
{t("discardChanges", "Discard Changes")}
</Button>
{onApplyAndContinue && (
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("applyAndContinue", "Apply & Leave")}
</Button>
)}
</Group>
</Group>
{/* Mobile layout: centered stack of 4 buttons */}
<Stack align="center" gap="sm" hiddenFrom="md">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
{t("keepWorking", "Keep Working")}
<Group justify="flex-end" gap="sm">
<Button
variant="light"
color="gray"
onClick={handleKeepWorking}
>
Keep Working
</Button>
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
{t("discardChanges", "Discard Changes")}
<Button
variant="light"
color="red"
onClick={handleDiscardChanges}
>
Discard Changes
</Button>
{onApplyAndContinue && (
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("applyAndContinue", "Apply & Leave")}
<Button
variant="light"
color="blue"
onClick={handleApplyAndContinue}
>
Apply & Continue
</Button>
)}
</Stack>
{onExportAndContinue && (
<Button
color="green"
onClick={handleExportAndContinue}
>
Export & Continue
</Button>
)}
</Group>
</Stack>
</Modal>
);
};
export default NavigationWarningModal;
export default NavigationWarningModal;
@@ -12,8 +12,6 @@ import { ButtonConfig } from '../../types/sidebar';
import './quickAccessBar/QuickAccessBar.css';
import AllToolsNavButton from './AllToolsNavButton';
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
import AppConfigModal from './AppConfigModal';
import { useAppConfig } from '../../hooks/useAppConfig';
import {
isNavButtonActive,
getNavButtonStyle,
@@ -24,9 +22,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
@@ -44,10 +41,10 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
// Helper function to render navigation buttons with URL support
const renderNavButton = (config: ButtonConfig, index: number) => {
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
// Check if this button has URL navigation support
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
? getToolNavigation(config.id)
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
? getToolNavigation(config.id)
: null;
const handleClick = (e?: React.MouseEvent) => {
@@ -62,14 +59,13 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
return (
<div key={config.id} className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
<ActionIcon
{...(navProps ? {
{...(navProps ? {
component: "a" as const,
href: navProps.href,
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
} : {
onClick: () => handleClick(),
'aria-label': config.name
onClick: () => handleClick()
})}
size={isActive ? (config.size || 'lg') : 'lg'}
variant="subtle"
@@ -99,10 +95,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
type: 'navigation',
onClick: () => {
setActiveButton('read');
handleBackToTools();
handleReaderToggle();
}
},
// {
// TODO: Add sign
//{
// id: 'sign',
// name: t("quickAccess.sign", "Sign"),
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
@@ -113,7 +111,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
// setActiveButton('sign');
// handleToolSelect('sign');
// }
// },
//},
{
id: 'automate',
name: t("quickAccess.automate", "Automate"),
@@ -152,8 +150,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
//},
{
id: 'config',
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
name: t("quickAccess.config", "Config"),
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'lg',
type: 'modal',
onClick: () => {
@@ -219,7 +217,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div className="spacer" />
{/* Config button at the bottom */}
{buttonConfigs
{/* {buttonConfigs
.filter(config => config.id === 'config')
.map(config => (
<div key={config.id} className="flex flex-col items-center gap-1">
@@ -229,7 +227,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
onClick={config.onClick}
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''}
aria-label={config.name}
data-testid={`${config.id}-button`}
>
<span className="iconContainer">
@@ -240,18 +237,16 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
{config.name}
</span>
</div>
))}
))} */}
</div>
</div>
<AppConfigModal
{/* <AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
/> */}
</div>
);
});
QuickAccessBar.displayName = 'QuickAccessBar';
export default QuickAccessBar;
@@ -1,11 +1,8 @@
import { createContext, useContext, ReactNode } from 'react';
import React, { createContext, useContext, ReactNode } from 'react';
import { MantineProvider } from '@mantine/core';
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
import { mantineTheme } from '../../theme/mantineTheme';
import rainbowStyles from '../../styles/rainbow.module.css';
import { ToastProvider } from '../toast';
import ToastRenderer from '../toast/ToastRenderer';
import { ToastPortalBinder } from '../toast';
interface RainbowThemeContextType {
themeMode: 'light' | 'dark' | 'rainbow';
@@ -47,11 +44,7 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
style={{ minHeight: '100vh' }}
>
<ToastProvider>
<ToastPortalBinder />
{children}
<ToastRenderer />
</ToastProvider>
{children}
</div>
</MantineProvider>
</RainbowThemeContext.Provider>
+36 -76
View File
@@ -4,7 +4,7 @@ import LocalIcon from './LocalIcon';
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, useFileManagement } from '../../contexts/FileContext';
import { useNavigationState } from '../../contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
@@ -14,29 +14,22 @@ import { Tooltip } from '../shared/Tooltip';
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import { SearchInterface } from '../viewer/SearchInterface';
import { ViewerContext } from '../../contexts/ViewerContext';
import { useSignature } from '../../contexts/SignatureContext';
import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls';
import { parseSelection } from '../../utils/bulkselection/parseSelection';
import { useSidebarContext } from '../../contexts/SidebarContext';
export default function RightRail() {
const { sidebarRefs } = useSidebarContext();
const { t } = useTranslation();
const [isPanning, setIsPanning] = useState(false);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
const { toggleTheme } = useRainbowThemeContext();
const { buttons, actions, allButtonsDisabled } = useRightRail();
const { buttons, actions } = useRightRail();
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
// Access PageEditor functions for page-editor-specific actions
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
const { pageEditorFunctions } = useToolWorkflow();
// CSV input state for page selection
const [csvInput, setCsvInput] = useState<string>("");
@@ -46,13 +39,9 @@ export default function RightRail() {
// File state and selection
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
const { removeFiles } = useFileManagement();
// Signature context for checking if signatures have been applied
const { signaturesApplied } = useSignature();
const activeFiles = selectors.getFiles();
const filesSignature = selectors.getFilesSignature();
@@ -76,16 +65,11 @@ export default function RightRail() {
const { totalItems, selectedCount } = getSelectionState();
// Get export state for viewer mode
const exportState = viewerContext?.getExportState?.();
const handleSelectAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
// Select all file IDs
const allIds = state.files.ids;
setSelectedFiles(allIds);
// Clear any previous error flags when selecting all
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
return;
}
@@ -98,8 +82,6 @@ export default function RightRail() {
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') {
@@ -108,17 +90,8 @@ export default function RightRail() {
}
}, [currentView, setSelectedFiles, pageEditorFunctions]);
const handleExportAll = useCallback(async () => {
if (currentView === 'viewer') {
// Check if signatures have been applied
if (!signaturesApplied) {
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
return;
}
// Use EmbedPDF export functionality for viewer mode
viewerContext?.exportActions?.download();
} else if (currentView === 'fileEditor') {
const handleExportAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
// Download selected files (or all if none selected)
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
@@ -135,7 +108,7 @@ export default function RightRail() {
// Export all pages (not just selected)
pageEditorFunctions?.onExportAll?.();
}
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext, signaturesApplied, selectors, fileActions]);
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
const handleCloseSelected = useCallback(() => {
if (currentView !== 'fileEditor') return;
@@ -181,19 +154,19 @@ export default function RightRail() {
}, [currentView]);
return (
<div ref={sidebarRefs.rightRailRef} className={`right-rail`} data-sidebar="right-rail">
<div className="right-rail">
<div className="right-rail-inner">
{topButtons.length > 0 && (
<>
<div className="right-rail-section">
{topButtons.map(btn => (
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => actions[btn.id]?.()}
disabled={btn.disabled || allButtonsDisabled || disableForFullscreen}
disabled={btn.disabled}
>
{btn.icon}
</ActionIcon>
@@ -211,7 +184,7 @@ export default function RightRail() {
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
{/* Search */}
<Tooltip content={t('rightRail.search', 'Search PDF')} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip content={t('rightRail.search', 'Search PDF')} position="left" offset={12} arrow>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
@@ -219,7 +192,7 @@ export default function RightRail() {
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
disabled={currentView !== 'viewer'}
aria-label={typeof t === 'function' ? t('rightRail.search', 'Search PDF') : 'Search PDF'}
>
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
@@ -239,7 +212,7 @@ export default function RightRail() {
{/* Pan Mode */}
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow>
<ActionIcon
variant={isPanning ? "filled" : "subtle"}
color={isPanning ? "blue" : undefined}
@@ -249,14 +222,14 @@ export default function RightRail() {
viewerContext?.panActions.togglePan();
setIsPanning(!isPanning);
}}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Rotate Left */}
<Tooltip content={t('rightRail.rotateLeft', 'Rotate Left')} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip content={t('rightRail.rotateLeft', 'Rotate Left')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
@@ -264,14 +237,14 @@ export default function RightRail() {
onClick={() => {
viewerContext?.rotationActions.rotateBackward();
}}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Rotate Right */}
<Tooltip content={t('rightRail.rotateRight', 'Rotate Right')} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip content={t('rightRail.rotateRight', 'Rotate Right')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
@@ -279,14 +252,14 @@ export default function RightRail() {
onClick={() => {
viewerContext?.rotationActions.rotateForward();
}}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="rotate-right" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Sidebar Toggle */}
<Tooltip content={t('rightRail.toggleSidebar', 'Toggle Sidebar')} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip content={t('rightRail.toggleSidebar', 'Toggle Sidebar')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
@@ -294,17 +267,11 @@ export default function RightRail() {
onClick={() => {
viewerContext?.toggleThumbnailSidebar();
}}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Annotation Controls */}
<ViewerAnnotationControls
currentView={currentView}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
/>
</div>
<Divider className="right-rail-divider" />
</div>
@@ -316,14 +283,14 @@ export default function RightRail() {
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
{/* Select All Button */}
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleSelectAll}
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems || allButtonsDisabled || disableForFullscreen}
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems}
>
<LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -331,14 +298,14 @@ export default function RightRail() {
</Tooltip>
{/* Deselect All Button */}
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleDeselectAll}
disabled={currentView === 'viewer' || selectedCount === 0 || allButtonsDisabled || disableForFullscreen}
disabled={currentView === 'viewer' || selectedCount === 0}
>
<LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -347,7 +314,7 @@ export default function RightRail() {
{/* Select by Numbers - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<Popover position="left" withArrow shadow="md" offset={8}>
@@ -357,7 +324,7 @@ export default function RightRail() {
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={!pageControlsVisible || totalItems === 0 || allButtonsDisabled || disableForFullscreen}
disabled={!pageControlsVisible || totalItems === 0}
aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'}
>
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
@@ -384,7 +351,7 @@ export default function RightRail() {
{/* Delete Selected Pages - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<div style={{ display: 'inline-flex' }}>
@@ -393,7 +360,7 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.handleDelete?.(); }}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || allButtonsDisabled || disableForFullscreen}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0}
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
>
<LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />
@@ -406,7 +373,7 @@ export default function RightRail() {
{/* Export Selected Pages - page editor only */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
@@ -414,7 +381,7 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.onExportSelected?.(); }}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading || allButtonsDisabled || disableForFullscreen}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading}
aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
@@ -425,7 +392,7 @@ export default function RightRail() {
)}
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
@@ -435,8 +402,7 @@ export default function RightRail() {
disabled={
currentView === 'viewer' ||
(currentView === 'fileEditor' && selectedCount === 0) ||
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf)) ||
allButtonsDisabled || disableForFullscreen
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf))
}
>
<LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />
@@ -450,8 +416,7 @@ export default function RightRail() {
{/* Theme toggle and Language dropdown */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow portalTarget={document.body}
>
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
@@ -462,26 +427,20 @@ export default function RightRail() {
</ActionIcon>
</Tooltip>
<Tooltip content={t('rightRail.language', 'Language')} position="left" offset={12} arrow portalTarget={document.body}>
<div style={{ display: 'inline-flex' }}>
<LanguageSelector position="left-start" offset={6} compact />
</div>
</Tooltip>
<LanguageSelector position="left-start" offset={6} compact />
<Tooltip content={
currentView === 'pageEditor'
? t('rightRail.exportAll', 'Export PDF')
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
} position="left" offset={12} arrow portalTarget={document.body}>
} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleExportAll}
disabled={
disableForFullscreen || (currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
}
disabled={currentView === 'viewer' || totalItems === 0}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -495,3 +454,4 @@ export default function RightRail() {
);
}
@@ -107,5 +107,3 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
</div>
);
});
TextInput.displayName = 'TextInput';
+1 -1
View File
@@ -32,7 +32,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
const getToolName = (toolId: ToolId) => {
return t(`home.${toolId}.title`, toolId);
};
}
// Create full tool chain for tooltip
const fullChainDisplay = displayStyle === 'badges' ? (
@@ -1,34 +0,0 @@
import React from "react";
interface ToolIconProps {
icon: React.ReactNode;
opacity?: number;
color?: string;
marginRight?: string;
}
/**
* Shared icon component for consistent tool icon styling across the application.
* Uses the same visual pattern as ToolButton: scaled to 0.8, centered transform, consistent spacing.
*/
export const ToolIcon: React.FC<ToolIconProps> = ({
icon,
opacity = 1,
color = "var(--tools-text-and-icon-color)",
marginRight = "0.5rem"
}) => {
return (
<div
className="tool-button-icon"
style={{
color,
marginRight,
transform: "scale(0.8)",
transformOrigin: "center",
opacity
}}
>
{icon}
</div>
);
};
+10 -22
View File
@@ -8,7 +8,6 @@ import { TooltipContent } from './tooltip/TooltipContent';
import { useSidebarContext } from '../../contexts/SidebarContext';
import { BASE_PATH } from '../../constants/app';
import styles from './tooltip/Tooltip.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
export interface TooltipProps {
sidebarTooltip?: boolean;
@@ -29,10 +28,6 @@ export interface TooltipProps {
pinOnClick?: boolean;
/** If true, clicking outside also closes when not pinned (default true) */
closeOnOutside?: boolean;
/** If true, tooltip interaction is disabled entirely */
disabled?: boolean;
/** If false, tooltip will not open on focus (hover only) */
openOnFocus?: boolean;
}
export const Tooltip: React.FC<TooltipProps> = ({
@@ -53,8 +48,6 @@ export const Tooltip: React.FC<TooltipProps> = ({
containerStyle = {},
pinOnClick = false,
closeOnOutside = true,
disabled = false,
openOnFocus = true,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
@@ -75,7 +68,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
const isControlled = controlledOpen !== undefined;
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
const open = isControlled ? !!controlledOpen : internalOpen;
const setOpen = useCallback(
(newOpen: boolean) => {
@@ -155,16 +148,15 @@ export const Tooltip: React.FC<TooltipProps> = ({
// === Trigger handlers ===
const openWithDelay = useCallback(() => {
clearTimers();
if (disabled) return;
openTimeoutRef.current = setTimeout(() => setOpen(true), Math.max(0, delay || 0));
}, [clearTimers, setOpen, delay, disabled]);
}, [clearTimers, setOpen, delay]);
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!isPinned && !disabled) openWithDelay();
if (!isPinned) openWithDelay();
(children.props as any)?.onPointerEnter?.(e);
},
[isPinned, openWithDelay, children.props, disabled]
[isPinned, openWithDelay, children.props]
);
const handlePointerLeave = useCallback(
@@ -227,10 +219,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
// Keyboard / focus accessibility
const handleFocus = useCallback(
(e: React.FocusEvent) => {
if (!isPinned && !disabled && openOnFocus) openWithDelay();
if (!isPinned) openWithDelay();
(children.props as any)?.onFocus?.(e);
},
[isPinned, openWithDelay, children.props, disabled, openOnFocus]
[isPinned, openWithDelay, children.props]
);
const handleBlur = useCallback(
@@ -299,7 +291,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
left: coords.left,
width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
minWidth,
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
zIndex: 9999,
visibility: positionReady ? 'visible' : 'hidden',
opacity: positionReady ? 1 : 0,
color: 'var(--text-primary)',
@@ -353,13 +345,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
return (
<>
{childWithHandlers}
{(() => {
const defaultTarget = typeof document !== 'undefined' ? document.body : null;
const target = portalTarget ?? defaultTarget;
return tooltipElement && target
? createPortal(tooltipElement, target)
: tooltipElement;
})()}
{portalTarget && document.body.contains(portalTarget)
? tooltipElement && createPortal(tooltipElement, portalTarget)
: tooltipElement}
</>
);
};
+14 -39
View File
@@ -6,52 +6,29 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
import { FileDropdownMenu } from './FileDropdownMenu';
const viewOptionStyle: React.CSSProperties = {
const viewOptionStyle = {
display: 'inline-flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
whiteSpace: 'nowrap',
paddingTop: '0.3rem',
};
}
// Build view options showing text always
const createViewOptions = (
currentView: WorkbenchType,
switchingTo: WorkbenchType | null,
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
currentFileIndex: number,
onFileSelect?: (index: number) => void
) => {
const currentFile = activeFiles[currentFileIndex];
const isInViewer = currentView === 'viewer';
const fileName = currentFile?.name || '';
const displayName = isInViewer && fileName ? fileName : 'Viewer';
const hasMultipleFiles = activeFiles.length > 1;
const showDropdown = isInViewer && hasMultipleFiles;
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null, isToolSelected: boolean) => {
const viewerOption = {
label: showDropdown ? (
<FileDropdownMenu
displayName={displayName}
activeFiles={activeFiles}
currentFileIndex={currentFileIndex}
onFileSelect={onFileSelect}
switchingTo={switchingTo}
viewOptionStyle={viewOptionStyle}
/>
) : (
<div style={viewOptionStyle}>
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
<span className="ph-no-capture">{displayName}</span>
<span>Viewer</span>
</div>
),
value: "viewer",
@@ -59,7 +36,7 @@ const createViewOptions = (
const pageEditorOption = {
label: (
<div style={viewOptionStyle}>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "pageEditor" ? (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
@@ -78,7 +55,7 @@ const createViewOptions = (
const fileEditorOption = {
label: (
<div style={viewOptionStyle}>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
@@ -98,7 +75,7 @@ const createViewOptions = (
// Build options array conditionally
return [
viewerOption,
pageEditorOption,
...(isToolSelected ? [] : [pageEditorOption]),
fileEditorOption,
];
};
@@ -106,21 +83,19 @@ const createViewOptions = (
interface TopControlsProps {
currentView: WorkbenchType;
setCurrentView: (view: WorkbenchType) => void;
activeFiles?: Array<{ fileId: string; name: string; versionNumber?: number }>;
currentFileIndex?: number;
onFileSelect?: (index: number) => void;
selectedToolKey?: string | null;
}
const TopControls = ({
currentView,
setCurrentView,
activeFiles = [],
currentFileIndex = 0,
onFileSelect,
selectedToolKey,
}: TopControlsProps) => {
const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
const isToolSelected = selectedToolKey !== null;
const handleViewChange = useCallback((view: string) => {
if (!isValidWorkbench(view)) {
return;
@@ -147,7 +122,7 @@ const TopControls = ({
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<SegmentedControl
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect)}
data={createViewOptions(currentView, switchingTo, isToolSelected)}
value={currentView}
onChange={handleViewChange}
color="blue"
@@ -1,64 +0,0 @@
import React from 'react';
import { NavKey } from './types';
import HotkeysSection from './configSections/HotkeysSection';
import GeneralSection from './configSections/GeneralSection';
export interface ConfigNavItem {
key: NavKey;
label: string;
icon: string;
component: React.ReactNode;
}
export interface ConfigNavSection {
title: string;
items: ConfigNavItem[];
}
export interface ConfigColors {
navBg: string;
sectionTitle: string;
navItem: string;
navItemActive: string;
navItemActiveBg: string;
contentBg: string;
headerBorder: string;
}
export const createConfigNavSections = (
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
onLogoutClick: () => void
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
title: 'Account',
items: [
{
key: 'overview',
label: 'Overview',
icon: 'person-rounded',
component: <Overview onLogoutClick={onLogoutClick} />
},
],
},
{
title: 'Preferences',
items: [
{
key: 'general',
label: 'General',
icon: 'settings-rounded',
component: <GeneralSection />
},
{
key: 'hotkeys',
label: 'Keyboard Shortcuts',
icon: 'keyboard-rounded',
component: <HotkeysSection />
},
],
},
];
return sections;
};
@@ -1,108 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '../../../../contexts/PreferencesContext';
import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState';
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
const GeneralSection: React.FC = () => {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const [fileLimitInput, setFileLimitInput] = useState<number | string>(preferences.autoUnzipFileLimit);
// Sync local state with preference changes
useEffect(() => {
setFileLimitInput(preferences.autoUnzipFileLimit);
}, [preferences.autoUnzipFileLimit]);
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('settings.general.description', 'Configure general application preferences.')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
</Text>
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
data={[
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
]}
/>
</div>
<Tooltip
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
</Text>
</div>
<Switch
checked={preferences.autoUnzip}
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
/>
</div>
</Tooltip>
<Tooltip
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
</Text>
</div>
<NumberInput
value={fileLimitInput}
onChange={setFileLimitInput}
onBlur={() => {
const numValue = Number(fileLimitInput);
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
setFileLimitInput(finalValue);
updatePreference('autoUnzipFileLimit', finalValue);
}}
min={1}
max={100}
step={1}
disabled={!preferences.autoUnzip}
style={{ width: 90 }}
/>
</div>
</Tooltip>
</Stack>
</Paper>
</Stack>
);
};
export default GeneralSection;
@@ -1,204 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '../../../../contexts/ToolWorkflowContext';
import { useHotkeys } from '../../../../contexts/HotkeyContext';
import { ToolId } from '../../../../types/toolId';
import HotkeyDisplay from '../../../hotkeys/HotkeyDisplay';
import { bindingEquals, eventToBinding, HotkeyBinding } from '../../../../utils/hotkeys';
import { ToolRegistryEntry } from 'src/data/toolsTaxonomy';
const rowStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
};
const rowHeaderStyle: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '0.5rem',
};
const HotkeysSection: React.FC = () => {
const { t } = useTranslation();
const { toolRegistry } = useToolWorkflow();
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
const [editingTool, setEditingTool] = useState<ToolId | null>(null);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const tools = useMemo(() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], [toolRegistry]);
const filteredTools = useMemo(() => {
if (!searchQuery.trim()) return tools;
const query = searchQuery.toLowerCase();
return tools.filter(([toolId, tool]) =>
tool.name.toLowerCase().includes(query) ||
tool.description.toLowerCase().includes(query) ||
toolId.toLowerCase().includes(query)
);
}, [tools, searchQuery]);
useEffect(() => {
if (!editingTool) {
return;
}
pauseHotkeys();
return () => {
resumeHotkeys();
};
}, [editingTool, pauseHotkeys, resumeHotkeys]);
useEffect(() => {
if (!editingTool) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setEditingTool(null);
setError(null);
return;
}
event.preventDefault();
event.stopPropagation();
const binding = eventToBinding(event as KeyboardEvent);
if (!binding) {
const osKey = isMac ? 'mac' : 'windows';
const fallbackText = isMac
? 'Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.'
: 'Include Ctrl, Alt, or another modifier in your shortcut.';
setError(t(`settings.hotkeys.errorModifier.${osKey}`, fallbackText));
return;
}
const conflictEntry = (Object.entries(hotkeys) as [ToolId, HotkeyBinding][]).find(([toolId, existing]) => (
toolId !== editingTool && bindingEquals(existing, binding)
));
if (conflictEntry) {
const conflictKey = conflictEntry[0];
const conflictTool = (conflictKey in toolRegistry)
? toolRegistry[conflictKey as ToolId]?.name
: conflictKey;
setError(t('settings.hotkeys.errorConflict', 'Shortcut already used by {{tool}}.', { tool: conflictTool }));
return;
}
updateHotkey(editingTool, binding);
setEditingTool(null);
setError(null);
};
window.addEventListener('keydown', handleKeyDown, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
};
}, [editingTool, hotkeys, toolRegistry, updateHotkey, t]);
const handleStartCapture = (toolId: ToolId) => {
setEditingTool(toolId);
setError(null);
};
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">Keyboard Shortcuts</Text>
<Text size="sm" c="dimmed">
Customize keyboard shortcuts for quick tool access. Click "Change shortcut" and press a new key combination. Press Esc to cancel.
</Text>
</div>
<TextInput
placeholder={t('settings.hotkeys.searchPlaceholder', 'Search tools...')}
value={searchQuery}
onChange={(event) => setSearchQuery(event.currentTarget.value)}
size="md"
radius="md"
/>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{filteredTools.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t('toolPicker.noToolsFound', 'No tools found')}
</Text>
) : (
filteredTools.map(([toolId, tool], index) => {
const currentBinding = hotkeys[toolId];
const defaultBinding = defaults[toolId];
const isEditing = editingTool === toolId;
const defaultParts = getDisplayParts(defaultBinding);
const defaultLabel = defaultParts.length > 0
? defaultParts.join(' + ')
: t('settings.hotkeys.none', 'Not assigned');
return (
<React.Fragment key={toolId}>
<Box style={rowStyle} data-testid={`hotkey-row-${toolId}`}>
<div style={rowHeaderStyle}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', minWidth: 0 }}>
<Text fw={600}>{tool.name}</Text>
<Group gap="xs" wrap="wrap" align="center">
<HotkeyDisplay binding={currentBinding} size="md" />
{!bindingEquals(currentBinding, defaultBinding) && (
<Badge variant="light" color="orange" radius="sm">
{t('settings.hotkeys.customBadge', 'Custom')}
</Badge>
)}
<Text size="xs" c="dimmed">
{t('settings.hotkeys.defaultLabel', 'Default: {{shortcut}}', { shortcut: defaultLabel })}
</Text>
</Group>
</div>
<Group gap="xs">
<Button
size="xs"
variant={isEditing ? 'filled' : 'default'}
color={isEditing ? 'blue' : undefined}
onClick={() => handleStartCapture(toolId)}
>
{isEditing
? t('settings.hotkeys.capturing', 'Press keys… (Esc to cancel)')
: t('settings.hotkeys.change', 'Change shortcut')}
</Button>
<Button
size="xs"
variant="subtle"
disabled={bindingEquals(currentBinding, defaultBinding)}
onClick={() => resetHotkey(toolId)}
>
{t('settings.hotkeys.reset', 'Reset')}
</Button>
</Group>
</div>
{isEditing && error && (
<Alert color="red" radius="sm" variant="filled">
{error}
</Alert>
)}
</Box>
{index < filteredTools.length - 1 && <Divider />}
</React.Fragment>
);
})
)}
</Stack>
</Paper>
</Stack>
);
};
export default HotkeysSection;
@@ -1,101 +0,0 @@
import React from 'react';
import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core';
import { useAppConfig } from '../../../../hooks/useAppConfig';
const Overview: React.FC = () => {
const { config, loading, error } = useAppConfig();
const renderConfigSection = (title: string, data: any) => {
if (!data || typeof data !== 'object') return null;
return (
<Stack gap="xs" mb="md">
<Text fw={600} size="md" c="blue">{title}</Text>
<Stack gap="xs" pl="md">
{Object.entries(data).map(([key, value]) => (
<Group key={key} wrap="nowrap" align="flex-start">
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
{key}:
</Text>
{typeof value === 'boolean' ? (
<Badge color={value ? 'green' : 'red'} size="sm">
{value ? 'true' : 'false'}
</Badge>
) : typeof value === 'object' ? (
<Code block>{JSON.stringify(value, null, 2)}</Code>
) : (
String(value) || 'null'
)}
</Group>
))}
</Stack>
</Stack>
);
};
const basicConfig = config ? {
appName: config.appName,
appNameNavbar: config.appNameNavbar,
baseUrl: config.baseUrl,
contextPath: config.contextPath,
serverPort: config.serverPort,
} : null;
const securityConfig = config ? {
enableLogin: config.enableLogin,
} : null;
const systemConfig = config ? {
enableAlphaFunctionality: config.enableAlphaFunctionality,
enableAnalytics: config.enableAnalytics,
} : null;
const integrationConfig = config ? {
SSOAutoLogin: config.SSOAutoLogin,
} : null;
if (loading) {
return (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading configuration...</Text>
</Stack>
);
}
if (error) {
return (
<Alert color="red" title="Error">
{error}
</Alert>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">Application Configuration</Text>
<Text size="sm" c="dimmed">
Current application settings and configuration details.
</Text>
</div>
{config && (
<>
{renderConfigSection('Basic Configuration', basicConfig)}
{renderConfigSection('Security Configuration', securityConfig)}
{renderConfigSection('System Configuration', systemConfig)}
{renderConfigSection('Integration Configuration', integrationConfig)}
{config.error && (
<Alert color="yellow" title="Configuration Warning">
{config.error}
</Alert>
)}
</>
)}
</Stack>
);
};
export default Overview;
@@ -1,19 +0,0 @@
export type NavKey =
| 'overview'
| 'preferences'
| 'notifications'
| 'connections'
| 'general'
| 'people'
| 'teams'
| 'security'
| 'identity'
| 'plan'
| 'payments'
| 'requests'
| 'developer'
| 'api-keys'
| 'hotkeys';
// some of these are not used yet, but appear in figma designs
@@ -19,7 +19,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
children
}) => {
if (!file) return null;
const containerStyle = {
position: 'relative' as const,
cursor: onClick ? 'pointer' : 'default',
@@ -36,7 +36,6 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
return (
<Box style={containerStyle} onClick={onClick}>
<Image
className='ph-no-capture'
src={thumbnail}
alt={`Preview of ${file.name}`}
fit="contain"
@@ -50,12 +49,11 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
return (
<Box style={containerStyle} onClick={onClick}>
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
<PictureAsPdfIcon
className='ph-no-capture'
style={{
fontSize: '2rem',
color: 'var(--mantine-color-gray-6)'
}}
<PictureAsPdfIcon
style={{
fontSize: '2rem',
color: 'var(--mantine-color-gray-6)'
}}
/>
</Center>
{children}
@@ -63,4 +61,4 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
);
};
export default DocumentThumbnail;
export default DocumentThumbnail;
@@ -33,11 +33,8 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
const { getHomeNavigation } = useSidebarNavigation();
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
// Special case: multiTool should always show even when sidebars are hidden
const indicatorShouldShow = Boolean(
selectedToolKey &&
((leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)) ||
selectedToolKey === 'multiTool')
selectedToolKey && leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)
);
// Local animation and hover state
@@ -50,7 +47,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
const animTimeoutRef = useRef<number | null>(null);
const replayRafRef = useRef<number | null>(null);
const isSwitchingToNewTool = () => { return prevKeyRef.current && prevKeyRef.current !== selectedToolKey; };
const isSwitchingToNewTool = () => { return prevKeyRef.current && prevKeyRef.current !== selectedToolKey };
const clearTimers = () => {
if (collapseTimeoutRef.current) {
@@ -81,7 +78,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
setReplayAnim(false);
animTimeoutRef.current = null;
}, 500);
};
}
const firstShow = () => {
clearTimers();
@@ -91,7 +88,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
animTimeoutRef.current = window.setTimeout(() => {
animTimeoutRef.current = null;
}, 500);
};
}
const triggerCollapse = () => {
clearTimers();
@@ -101,7 +98,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
prevKeyRef.current = null;
collapseTimeoutRef.current = null;
}, 500); // match CSS transition duration
};
}
useEffect(() => {
if (indicatorShouldShow) {
@@ -12,7 +12,7 @@ export const isNavButtonActive = (
isFilesModalOpen: boolean,
configModalOpen: boolean,
selectedToolKey?: string | null,
leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
leftPanelView?: 'toolPicker' | 'toolContent'
): boolean => {
const isActiveByLocalState = config.type === 'navigation' && activeButton === config.id;
const isActiveByContext =
@@ -35,7 +35,7 @@ export const getNavButtonStyle = (
isFilesModalOpen: boolean,
configModalOpen: boolean,
selectedToolKey?: string | null,
leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
leftPanelView?: 'toolPicker' | 'toolContent'
) => {
const isActive = isNavButtonActive(
config,
@@ -46,13 +46,6 @@
background-color: transparent !important;
}
/* When all buttons are disabled via context */
.right-rail--all-disabled .right-rail-icon {
color: var(--right-rail-icon-disabled) !important;
background-color: transparent !important;
pointer-events: none !important;
}
.right-rail-spacer {
flex: 1;
}
@@ -74,7 +67,7 @@
}
.right-rail-slot.visible {
max-height: 40rem; /* increased to fit additional controls + divider */
max-height: 18rem; /* increased to fit additional controls + divider */
opacity: 1;
}
@@ -84,14 +77,14 @@
opacity: 0;
}
100% {
max-height: 40rem;
max-height: 18rem;
opacity: 1;
}
}
@keyframes rightRailShrinkUp {
0% {
max-height: 40rem;
max-height: 18rem;
opacity: 1;
}
100% {
@@ -1,233 +0,0 @@
import React, { useState, useEffect } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '../LocalIcon';
import { Tooltip } from '../Tooltip';
import { ViewerContext } from '../../../contexts/ViewerContext';
import { useSignature } from '../../../contexts/SignatureContext';
import { ColorSwatchButton, ColorPicker } from '../../annotation/shared/ColorPicker';
import { useFileState, useFileContext } from '../../../contexts/FileContext';
import { generateThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
import { createProcessedFile } from '../../../contexts/file/fileActions';
import { createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
import { useNavigationState } from '../../../contexts/NavigationContext';
interface ViewerAnnotationControlsProps {
currentView: string;
disabled?: boolean;
}
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
const { t } = useTranslation();
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
// Signature context for accessing drawing API
const { signatureApiRef, isPlacementMode } = useSignature();
// File state for save functionality
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const activeFiles = selectors.getFiles();
// Check if we're in sign mode
const { selectedTool } = useNavigationState();
const isSignMode = selectedTool === 'sign';
// Turn off annotation mode when switching away from viewer
useEffect(() => {
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
viewerContext.setAnnotationMode(false);
}
}, [currentView, viewerContext]);
// Don't show any annotation controls in sign mode
if (isSignMode) {
return null;
}
return (
<>
{/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
width="1.5rem"
height="1.5rem"
/>
</ActionIcon>
</Tooltip>
{/* Annotation Mode Toggle with Drawing Controls */}
{viewerContext?.isAnnotationMode ? (
// When active: Show color picker on hover
<div
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
style={{ display: 'inline-flex' }}
>
<Popover
opened={isHoverColorPickerOpen}
onClose={() => setIsHoverColorPickerOpen(false)}
position="left"
withArrow
shadow="md"
offset={8}
>
<Popover.Target>
<ActionIcon
variant="filled"
color="blue"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
// Deactivate drawing tool when exiting annotation mode
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={disabled}
aria-label="Drawing mode active"
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '8rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
<ColorSwatchButton
color={selectedColor}
size={32}
onClick={() => {
setIsHoverColorPickerOpen(false); // Close hover picker
setIsColorPickerOpen(true); // Open main color picker modal
}}
/>
</div>
</div>
</Popover.Dropdown>
</Popover>
</div>
) : (
// When inactive: Show "Draw" tooltip
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
// Activate ink drawing tool when entering annotation mode
if (signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.activateDrawMode();
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={disabled}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)}
{/* Save PDF with Annotations */}
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={async () => {
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
try {
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (pdfArrayBuffer) {
// Create new File object with flattened annotations
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
// Get the original file name or use a default
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
// Replace the current file in context with the saved version (exact same logic as Sign tool)
if (activeFiles.length > 0) {
// Generate thumbnail and metadata for the saved file
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
// Get current file info
const currentFileIds = state.files.ids;
if (currentFileIds.length > 0) {
const currentFileId = currentFileIds[0];
const currentRecord = selectors.getStirlingFileStub(currentFileId);
if (!currentRecord) {
console.error('No file record found for:', currentFileId);
return;
}
// Create output stub and file (exact same as Sign tool)
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
// Replace the original file with the saved version
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
}
}
}
} catch (error) {
console.error('Error saving PDF:', error);
}
}
}}
disabled={disabled}
>
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={(color) => {
setSelectedColor(color);
// Update drawing tool color if annotation mode is active
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.updateDrawSettings(color, 2);
} catch (error) {
console.log('Unable to update drawing settings:', error);
}
}
}}
title="Choose Drawing Color"
/>
</>
);
}
@@ -1,44 +0,0 @@
import React from 'react';
import { Slider, Text, Group, NumberInput } from '@mantine/core';
interface Props {
label: string;
value: number;
onChange: (value: number) => void;
disabled?: boolean;
min?: number;
max?: number;
step?: number;
}
export default function SliderWithInput({
label,
value,
onChange,
disabled,
min = 0,
max = 200,
step = 1,
}: Props) {
return (
<div>
<Text size="sm" fw={600} mb={4}>{label}: {Math.round(value)}%</Text>
<Group gap="sm" align="center">
<div style={{ flex: 1 }}>
<Slider min={min} max={max} step={step} value={value} onChange={onChange} disabled={disabled} />
</div>
<NumberInput
value={value}
onChange={(v) => onChange(Number(v) || 0)}
min={min}
max={max}
step={step}
disabled={disabled}
style={{ width: 90 }}
/>
</Group>
</div>
);
}
@@ -1,309 +0,0 @@
# Toast Component
A global notification system with expandable content, progress tracking, and smart error coalescing. Provides an imperative API for showing success, error, warning, and neutral notifications with customizable content and behavior.
---
## Highlights
* 🎯 **Global System**: Imperative API accessible from anywhere in the app via `alert()` function.
* 🎨 **Four Alert Types**: Success (green), Error (red), Warning (yellow), Neutral (theme-aware).
* 📱 **Expandable Content**: Collapsible toasts with chevron controls and smooth animations.
* ⚡ **Smart Coalescing**: Duplicate error toasts merge with count badges (e.g., "Server error 4").
* 📊 **Progress Tracking**: Built-in progress bars with completion animations.
* 🎛️ **Customizable**: Rich JSX content, buttons with callbacks, custom icons.
* 🌙 **Themeable**: Uses CSS variables; supports light/dark mode out of the box.
* ♿ **Accessible**: Proper ARIA roles, keyboard navigation, and screen reader support.
* 🔄 **Auto-dismiss**: Configurable duration with persistent popup option.
* 📍 **Positioning**: Four corner positions with proper stacking.
---
## Behavior
### Default
* **Auto-dismiss**: Toasts disappear after 6 seconds unless `isPersistentPopup: true`.
* **Expandable**: Click chevron to expand/collapse body content (default: collapsed).
* **Coalescing**: Identical error toasts merge with count badges.
* **Progress**: Progress bars always visible when present, even when collapsed.
### Error Handling
* **Network Errors**: Automatically caught by Axios and fetch interceptors.
* **Friendly Fallbacks**: Shows "There was an error processing your request" for unhelpful backend responses.
* **Smart Titles**: "Server error" for 5xx, "Request error" for 4xx, "Network error" for others.
---
## Installation
The toast system is already integrated at the app root. No additional setup required.
```tsx
import { alert, updateToast, dismissToast } from '@/components/toast';
```
---
## Basic Usage
### Simple Notifications
```tsx
// Success notification
alert({
alertType: 'success',
title: 'File processed successfully',
body: 'Your document has been converted to PDF.'
});
// Error notification
alert({
alertType: 'error',
title: 'Processing failed',
body: 'Unable to process the selected files.'
});
// Warning notification
alert({
alertType: 'warning',
title: 'Low disk space',
body: 'Consider freeing up some storage space.'
});
// Neutral notification
alert({
alertType: 'neutral',
title: 'Information',
body: 'This is a neutral notification.'
});
```
### With Custom Content
```tsx
// Rich JSX content with buttons
alert({
alertType: 'success',
title: 'Download complete',
body: (
<div>
<p>File saved to Downloads folder</p>
<button onClick={() => openFolder()}>Open folder</button>
</div>
),
buttonText: 'View file',
buttonCallback: () => openFile(),
isPersistentPopup: true
});
```
### Progress Tracking
```tsx
// Show progress
const toastId = alert({
alertType: 'neutral',
title: 'Processing files...',
body: 'Converting your documents',
progressBarPercentage: 0
});
// Update progress
updateToast(toastId, { progressBarPercentage: 50 });
// Complete with success
updateToast(toastId, {
alertType: 'success',
title: 'Processing complete',
body: 'All files converted successfully',
progressBarPercentage: 100
});
```
### Custom Positioning
```tsx
alert({
alertType: 'error',
title: 'Connection lost',
body: 'Please check your internet connection.',
location: 'top-right'
});
```
---
## API
### `alert(options: ToastOptions)`
The primary function for showing toasts.
```ts
interface ToastOptions {
alertType?: 'success' | 'error' | 'warning' | 'neutral';
title: string;
body?: React.ReactNode;
buttonText?: string;
buttonCallback?: () => void;
isPersistentPopup?: boolean;
location?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
icon?: React.ReactNode;
progressBarPercentage?: number; // 0-1 as fraction or 0-100 as percent
durationMs?: number;
id?: string;
expandable?: boolean;
}
```
### `updateToast(id: string, options: Partial<ToastOptions>)`
Update an existing toast.
```tsx
const toastId = alert({ title: 'Processing...', progressBarPercentage: 0 });
updateToast(toastId, { progressBarPercentage: 75 });
```
### `dismissToast(id: string)`
Dismiss a specific toast.
```tsx
dismissToast(toastId);
```
### `dismissAllToasts()`
Dismiss all visible toasts.
```tsx
dismissAllToasts();
```
---
## Alert Types
| Type | Color | Icon | Use Case |
|------|-------|------|----------|
| `success` | Green | ✓ | Successful operations, completions |
| `error` | Red | ✗ | Failures, errors, exceptions |
| `warning` | Yellow | ⚠ | Warnings, cautions, low resources |
| `neutral` | Theme | | Information, general messages |
---
## Positioning
| Location | Description |
|----------|-------------|
| `top-left` | Top-left corner |
| `top-right` | Top-right corner |
| `bottom-left` | Bottom-left corner |
| `bottom-right` | Bottom-right corner (default) |
---
## Accessibility
* Toasts use `role="status"` for screen readers.
* Chevron and close buttons have proper `aria-label` attributes.
* Keyboard navigation supported (Escape to dismiss).
* Focus management for interactive content.
---
## Examples
### File Processing Workflow
```tsx
// Start processing
const toastId = alert({
alertType: 'neutral',
title: 'Processing files...',
body: 'Converting 5 documents',
progressBarPercentage: 0,
isPersistentPopup: true
});
// Update progress
updateToast(toastId, { progressBarPercentage: 30 });
updateToast(toastId, { progressBarPercentage: 60 });
// Complete successfully
updateToast(toastId, {
alertType: 'success',
title: 'Processing complete',
body: 'All 5 documents converted successfully',
progressBarPercentage: 100,
isPersistentPopup: false
});
```
### Error with Action
```tsx
alert({
alertType: 'error',
title: 'Upload failed',
body: 'File size exceeds the 10MB limit.',
buttonText: 'Try again',
buttonCallback: () => retryUpload(),
isPersistentPopup: true
});
```
### Non-expandable Toast
```tsx
alert({
alertType: 'success',
title: 'Settings saved',
body: 'Your preferences have been updated.',
expandable: false,
durationMs: 3000
});
```
### Custom Icon
```tsx
alert({
alertType: 'neutral',
title: 'New feature available',
body: 'Check out the latest updates.',
icon: <LocalIcon icon="star" />
});
```
---
## Integration
### Network Error Handling
The toast system automatically catches network errors from Axios and fetch requests:
```tsx
// These automatically show error toasts
axios.post('/api/convert', formData);
fetch('/api/process', { method: 'POST', body: data });
```
### Manual Error Handling
```tsx
try {
await processFiles();
alert({ alertType: 'success', title: 'Files processed' });
} catch (error) {
alert({
alertType: 'error',
title: 'Processing failed',
body: error.message
});
}
```

Some files were not shown because too many files have changed in this diff Show More