Преглед на файлове

Updated Productivity app

Toby Chui преди 2 дни
родител
ревизия
c91b127b67

+ 33 - 0
src/web/Productivity/embedded.html

@@ -416,6 +416,19 @@
             </svg>
         </button>
 
+        <!-- Open in Web Builder (HTML files only) -->
+        <button class="tb-btn" id="btnWebBuilder" onclick="openInWebBuilder()" title="Open in Web Builder" style="display:none">
+            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
+                <rect x="1" y="2" width="14" height="10" rx="1.3"/>
+                <line x1="1" y1="5.5" x2="15" y2="5.5"/>
+                <circle cx="3" cy="3.8" r="0.6" fill="currentColor" stroke="none"/>
+                <circle cx="5" cy="3.8" r="0.6" fill="currentColor" stroke="none"/>
+                <path d="M5.5 8.5 L4 10 L5.5 11.5"/>
+                <path d="M9.5 8.5 L11 10 L9.5 11.5"/>
+            </svg>
+        </button>
+        <div class="tb-sep" id="sepWebBuilder" style="display:none"></div>
+
         <div class="tb-sep"></div>
 
         <!-- Info toggle -->
@@ -840,6 +853,11 @@ function loadFile(file) {
 
     showViewer(currentType);
 
+    /* Web Builder button — only available for HTML files */
+    var _wbVisible = currentType === 'html';
+    document.getElementById('btnWebBuilder').style.display = _wbVisible ? '' : 'none';
+    document.getElementById('sepWebBuilder').style.display = _wbVisible ? '' : 'none';
+
     /* Support data URLs for files dragged in from the OS desktop */
     var fileUrl = file.dataUrl
         ? file.dataUrl
@@ -928,6 +946,21 @@ function toggleInfo() {
     document.getElementById('btnInfo').classList.toggle('active', sb.classList.contains('open'));
 }
 
+function openInWebBuilder() {
+    if (!currentFile) return;
+    var hashData = {
+        tool: 'webbuilder',
+        file: { filename: currentFile.filename, filepath: currentFile.filepath }
+    };
+    ao_module_newfw({
+        url: 'Productivity/index.html#' + encodeURIComponent(JSON.stringify(hashData)),
+        width: 1080,
+        height: 580,
+        title: currentFile.filename + ' - Web Builder',
+        appicon: 'Productivity/img/module_icon.svg'
+    });
+}
+
 /* ═══════════════════════════════════════════════════════════════
    EVENT WIRING
 ═══════════════════════════════════════════════════════════════ */

+ 116 - 2
src/web/Productivity/index.html

@@ -377,7 +377,17 @@
 
 <script>
 if (window.location.hash && window.location.hash.length > 1) {
-    window.location.href = 'embedded.html' + window.location.hash;
+    try {
+        var _hashData = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
+        if (Array.isArray(_hashData)) {
+            // Standard file-input hash — open the file in the embedded viewer
+            window.location.href = 'embedded.html' + window.location.hash;
+        }
+        // Object hash (e.g. {tool, file}) — let boot handle it below
+    } catch(e) {
+        // Not valid JSON; treat as a file array for backwards compatibility
+        window.location.href = 'embedded.html' + window.location.hash;
+    }
 }
 </script>
 
@@ -409,10 +419,68 @@ var CATEGORIES = [
               '<path d="M5.5 1v3.5h4.5"/>' +
               '<rect x="8" y="8" width="5.5" height="5.5" rx="1"/>' +
               '</svg>'
+    },
+    {
+        id: 'image',
+        label: 'Image Tools',
+        icon: '<svg width="15" height="15" viewBox="0 0 15 15" fill="none"' +
+              ' stroke="currentColor" stroke-width="1.6"' +
+              ' stroke-linecap="round" stroke-linejoin="round">' +
+              '<rect x="1" y="2.5" width="13" height="10" rx="1.5"/>' +
+              '<circle cx="4.5" cy="6.5" r="1.1"/>' +
+              '<path d="M1 12 L4 8.5 L6.5 10.5 L9.5 7 L13 12"/>' +
+              '</svg>'
+    },
+    {
+        id: 'web',
+        label: 'Web Tools',
+        icon: '<svg width="15" height="15" viewBox="0 0 15 15" fill="none"' +
+              ' stroke="currentColor" stroke-width="1.6"' +
+              ' stroke-linecap="round" stroke-linejoin="round">' +
+              '<circle cx="7.5" cy="7.5" r="6"/>' +
+              '<path d="M7.5 1.5 C5 4 5 11 7.5 13.5"/>' +
+              '<path d="M7.5 1.5 C10 4 10 11 7.5 13.5"/>' +
+              '<line x1="1.5" y1="6" x2="13.5" y2="6"/>' +
+              '<line x1="1.5" y1="9" x2="13.5" y2="9"/>' +
+              '</svg>'
     }
 ];
 
 var TOOLS = {
+    image: [
+        {
+            id: 'imgpaste',
+            label: 'Image Paste',
+            desc: 'Paste an image from clipboard and save it to your files',
+            color: '#30B06E',
+            icon: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"' +
+                  ' stroke="#FFF" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
+                  '<rect x="2" y="5.5" width="12" height="14" rx="1.5"/>' +
+                  '<path d="M6 5.5V3.5h5v2"/>' +
+                  '<circle cx="6.5" cy="11.5" r="1.2" fill="#FFF" stroke="none"/>' +
+                  '<path d="M2 18.5 L5 14 L7.5 16 L10.5 12 L14 18.5"/>' +
+                  '</svg>',
+            template: 'tools/imgpaste.html'
+        }
+    ],
+    web: [
+        {
+            id: 'webbuilder',
+            label: 'Web Builder',
+            desc: 'WYSIWYG editor for creating and editing HTML web pages',
+            color: '#FF9500',
+            icon: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"' +
+                  ' stroke="#FFF" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
+                  '<rect x="2" y="3" width="18" height="14" rx="1.5"/>' +
+                  '<line x1="2" y1="7" x2="20" y2="7"/>' +
+                  '<circle cx="5" cy="5" r="0.9" fill="#FFF" stroke="none"/>' +
+                  '<circle cx="8" cy="5" r="0.9" fill="#FFF" stroke="none"/>' +
+                  '<path d="M7.5 12 L5.5 14 L7.5 16"/>' +
+                  '<path d="M11.5 12 L13.5 14 L11.5 16"/>' +
+                  '</svg>',
+            template: 'tools/webbuilder.html'
+        }
+    ],
     pdf: [
         {
             id: 'pdf2img',
@@ -445,6 +513,33 @@ var TOOLS = {
                   '<line x1="16.8" y1="13.5" x2="16.8" y2="18.5"/>' +
                   '</svg>',
             template: 'tools/img2pdf.html'
+        },
+        {
+            id: 'pdfedit',
+            label: 'PDF Editor',
+            desc: 'Add text, images and company stamps onto a PDF',
+            color: '#5856D6',
+            icon: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"' +
+                  ' stroke="#FFF" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
+                  '<rect x="2.5" y="2" width="13" height="18" rx="1.6"/>' +
+                  '<line x1="5.5" y1="6" x2="12.5" y2="6"/>' +
+                  '<line x1="5.5" y1="9" x2="10" y2="9"/>' +
+                  '<path d="M16.5 11.5l3 3-5 5H11.5v-3z"/>' +
+                  '</svg>',
+            template: 'tools/pdfedit.html'
+        },
+        {
+            id: 'choplib',
+            label: 'Chop Library',
+            desc: 'Manage company stamps and chops for use in the editor',
+            color: '#D0021B',
+            icon: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"' +
+                  ' stroke="#FFF" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
+                  '<path d="M11 2.5c-2.7 0-4.2 2.1-3.5 4.6.4 1.5.7 2.6-.5 3.4h8c-1.2-.8-.9-1.9-.5-3.4C15.2 4.6 13.7 2.5 11 2.5Z"/>' +
+                  '<rect x="4" y="13" width="14" height="2.8" rx="0.9"/>' +
+                  '<line x1="3" y1="19" x2="19" y2="19"/>' +
+                  '</svg>',
+            template: 'tools/choplib.html'
         }
     ]
 };
@@ -631,7 +726,26 @@ function svgX() {
 
 /* ── Boot ── */
 renderSidebar();
-if (CATEGORIES.length) selectCat(CATEGORIES[0].id);
+(function() {
+    if (window.location.hash && window.location.hash.length > 1) {
+        try {
+            var _h = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
+            if (_h && !Array.isArray(_h) && _h.tool) {
+                var _catId = null;
+                Object.keys(TOOLS).forEach(function(k) {
+                    TOOLS[k].forEach(function(t) { if (t.id === _h.tool) _catId = k; });
+                });
+                if (_catId) {
+                    if (_h.file) window._productivityLaunchFile = _h.file;
+                    selectCat(_catId);
+                    selectTool(_h.tool);
+                    return;
+                }
+            }
+        } catch(e) {}
+    }
+    if (CATEGORIES.length) selectCat(CATEGORIES[0].id);
+})();
 
 /* ── ArozOS system theme binding ── */
 function applyAozTheme(theme) {

+ 1 - 1
src/web/Productivity/init.agi

@@ -5,7 +5,7 @@
 
 var moduleLaunchInfo = {
     Name: "Productivity",
-    Desc: "PDF tools, image conversion, and file previewer",
+    Desc: "PDF tools, image tools, web builder, and file previewer",
     Group: "Utilities",
     IconPath: "Productivity/img/module_icon.svg",
     Version: "1.0",

+ 174 - 0
src/web/Productivity/tools/choplib.html

@@ -0,0 +1,174 @@
+<style>
+    .chop-grid {
+        display: grid;
+        grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+        gap: 12px;
+        margin-top: 4px;
+    }
+    .chop-card {
+        background: var(--card-bg);
+        border: 1px solid var(--border);
+        border-radius: 10px;
+        padding: 10px;
+        display: flex; flex-direction: column; gap: 8px;
+    }
+    .chop-thumb {
+        width: 100%; height: 96px;
+        border-radius: 7px;
+        background: repeating-conic-gradient(#0000000d 0% 25%, transparent 0% 50%) 50% / 16px 16px;
+        display: flex; align-items: center; justify-content: center;
+        overflow: hidden;
+    }
+    .chop-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
+    .chop-name {
+        font-size: 12.5px; font-weight: 600;
+        overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+        cursor: text;
+    }
+    .chop-card-btns { display: flex; gap: 4px; }
+    .chop-empty {
+        padding: 28px 18px; text-align: center;
+        font-size: 13px; color: var(--sub);
+        border: 1.5px dashed var(--border); border-radius: 10px;
+    }
+</style>
+
+<div class="field">
+    <span class="field-label">Company Chops &amp; Stamps</span>
+    <p style="font-size:12.5px;color:var(--sub);margin-bottom:10px;line-height:1.5;">
+        Load your company stamp as a transparent <strong style="color:var(--text)">PNG</strong>.
+        Saved chops can be placed on any document from the <strong style="color:var(--text)">PDF Editor</strong>.
+    </p>
+    <button class="btn btn-primary" id="chopAddBtn">
+        <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="#FFF" stroke-width="2" stroke-linecap="round">
+            <line x1="6.5" y1="2" x2="6.5" y2="11"/><line x1="2" y1="6.5" x2="11" y2="6.5"/>
+        </svg>
+        Add Chop (PNG)
+    </button>
+</div>
+<div id="chopList"></div>
+<div id="chopResult"></div>
+
+<script>
+(function(){
+    var registry = window.ProductivityToolModules = window.ProductivityToolModules || {};
+
+    /* ── Shared chop store (defined once, reused by the PDF Editor) ── */
+    if (!window.ProductivityChops) {
+        window.ProductivityChops = (function(){
+            var DIR = 'user:/Productivity Chops/';
+            var MOD = 'Productivity', KEY = 'chops';
+
+            function load(cb){
+                ao_module_storage.loadStorage(MOD, KEY, function(raw){
+                    var arr = [];
+                    if (raw){ try { arr = JSON.parse(raw) || []; } catch(e){ arr = []; } }
+                    cb(arr);
+                });
+            }
+            function save(arr, cb){
+                ao_module_storage.setStorage(MOD, KEY, JSON.stringify(arr));
+                if (cb) cb(arr);
+            }
+            function add(srcPath, name, onDone, onErr){
+                fetch(mediaUrl(srcPath)).then(function(r){
+                    if (!r.ok) throw new Error('Cannot read source image');
+                    return r.blob();
+                }).then(function(blob){
+                    var fname = 'chop_' + Date.now() + '.png';
+                    ao_module_uploadFile(new File([blob], fname, {type:'image/png'}), DIR, function(){
+                        load(function(arr){
+                            arr.push({ id: 'c' + Date.now(), name: name || 'Chop', path: DIR + fname });
+                            save(arr, function(){ if (onDone) onDone(arr); });
+                        });
+                    }, undefined, function(status){
+                        if (onErr) onErr('Upload failed (' + status + ')');
+                    });
+                }).catch(function(e){ if (onErr) onErr(e.message || String(e)); });
+            }
+            function remove(id, onDone){
+                load(function(arr){
+                    save(arr.filter(function(c){ return c.id !== id; }), onDone);
+                });
+            }
+            function rename(id, name, onDone){
+                load(function(arr){
+                    arr.forEach(function(c){ if (c.id === id) c.name = name; });
+                    save(arr, onDone);
+                });
+            }
+            return { DIR: DIR, load: load, save: save, add: add, remove: remove, rename: rename };
+        })();
+    }
+
+    registry.choplib = {
+        init: function(root) {
+            var listEl   = root.querySelector('#chopList');
+            var resultEl = root.querySelector('#chopResult');
+
+            function render(){
+                window.ProductivityChops.load(function(chops){
+                    if (!chops.length){
+                        listEl.innerHTML = '<div class="chop-empty">No chops yet. Add a transparent PNG of your company stamp to get started.</div>';
+                        return;
+                    }
+                    var html = '<div class="chop-grid">';
+                    chops.forEach(function(c){
+                        html += '<div class="chop-card">' +
+                            '<div class="chop-thumb"><img src="' + mediaUrl(c.path) + '" alt=""></div>' +
+                            '<div class="chop-name" data-rename="' + c.id + '" title="Click to rename">' + escHtml(c.name) + '</div>' +
+                            '<div class="chop-card-btns">' +
+                                '<button class="btn btn-outline" style="flex:1;padding:5px 8px;font-size:12px" data-rename="' + c.id + '">Rename</button>' +
+                                '<button class="icon-btn rm" data-remove="' + c.id + '" title="Remove">' + svgX() + '</button>' +
+                            '</div>' +
+                        '</div>';
+                    });
+                    html += '</div>';
+                    listEl.innerHTML = html;
+
+                    Array.prototype.forEach.call(listEl.querySelectorAll('[data-remove]'), function(btn){
+                        btn.addEventListener('click', function(){
+                            var id = this.getAttribute('data-remove');
+                            window.ProductivityChops.remove(id, render);
+                        });
+                    });
+                    Array.prototype.forEach.call(listEl.querySelectorAll('[data-rename]'), function(el){
+                        el.addEventListener('click', function(){
+                            var id = this.getAttribute('data-rename');
+                            var card = this.closest('.chop-card');
+                            var current = card ? card.querySelector('.chop-name').textContent : '';
+                            var name = prompt('Chop name', current);
+                            if (name != null && name.trim() !== ''){
+                                window.ProductivityChops.rename(id, name.trim(), render);
+                            }
+                        });
+                    });
+                });
+            }
+
+            root.querySelector('#chopAddBtn').addEventListener('click', function(){
+                ao_module_openFileSelector('_chopAddCb', 'user:/', 'file', false, { filter: ['png'] });
+            });
+
+            window._chopAddCb = function(files){
+                if (!files || !files.length) return;
+                var f = files[0];
+                if (!/\.png$/i.test(f.filename)){
+                    resultEl.innerHTML = '<div class="result-box result-err">Please choose a <strong>PNG</strong> image (transparent background recommended).</div>';
+                    return;
+                }
+                var defaultName = f.filename.replace(/\.png$/i, '');
+                resultEl.innerHTML = '<div class="result-box" style="color:var(--sub)">Importing chop…</div>';
+                window.ProductivityChops.add(f.filepath, defaultName, function(){
+                    resultEl.innerHTML = '';
+                    render();
+                }, function(err){
+                    resultEl.innerHTML = '<div class="result-box result-err"><strong>Error:</strong> ' + escHtml(String(err)) + '</div>';
+                });
+            };
+
+            render();
+        }
+    };
+})();
+</script>

+ 125 - 0
src/web/Productivity/tools/imgpaste.html

@@ -0,0 +1,125 @@
+<div class="field">
+    <p style="font-size:13px;color:var(--sub);margin-bottom:10px;">Copy an image and press <strong style="color:var(--text);">Ctrl + V</strong> anywhere on this page to paste it here.</p>
+    <div id="ip-dropzone" style="min-height:200px;border:1.5px dashed var(--border);border-radius:10px;display:flex;align-items:center;justify-content:center;overflow:hidden;background:var(--inp-bg);position:relative;">
+        <span id="ip-hint" style="font-size:26px;font-weight:200;color:var(--border);pointer-events:none;letter-spacing:3px;">Ctrl + V</span>
+        <img id="ip-preview" src="" alt="" style="max-width:100%;max-height:300px;display:none;border-radius:6px;object-fit:contain;">
+    </div>
+</div>
+<div class="field">
+    <span class="field-label">Save Location</span>
+    <div class="row">
+        <input type="text" id="ip-savepath" placeholder="user:/Desktop" readonly style="flex:1;cursor:default;">
+        <button class="btn btn-outline" id="ip-pickFolder">
+            <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M1 3h3.5l1.5 2H12v6.5H1z"/>
+            </svg>
+            Browse
+        </button>
+    </div>
+</div>
+<div class="field">
+    <label style="display:flex;align-items:center;gap:7px;font-size:13px;cursor:pointer;user-select:none;">
+        <input type="checkbox" id="ip-autoUpload" style="accent-color:var(--accent);width:15px;height:15px;">
+        Upload automatically when an image is pasted
+    </label>
+</div>
+<div class="row">
+    <button class="btn-action" id="ip-uploadBtn" disabled>Upload Image</button>
+</div>
+<div id="ip-result"></div>
+
+<script>
+(function(){
+    var registry = window.ProductivityToolModules = window.ProductivityToolModules || {};
+
+    registry.imgpaste = {
+        init: function(root) {
+            var blob = null;
+            var savePath = 'user:/Desktop';
+            var autoUpload = false;
+
+            var preview   = root.querySelector('#ip-preview');
+            var hint      = root.querySelector('#ip-hint');
+            var saveInp   = root.querySelector('#ip-savepath');
+            var autoCb    = root.querySelector('#ip-autoUpload');
+            var uploadBtn = root.querySelector('#ip-uploadBtn');
+            var resultEl  = root.querySelector('#ip-result');
+            var pickBtn   = root.querySelector('#ip-pickFolder');
+
+            ao_module_storage.loadStorage('imgPaste', 'savepath', function(val){
+                if (val){ savePath = val; saveInp.value = val; }
+                else { saveInp.value = savePath; }
+            });
+            ao_module_storage.loadStorage('imgPaste', 'uploadOnPaste', function(val){
+                if (val === 'true'){ autoCb.checked = true; autoUpload = true; }
+            });
+
+            autoCb.addEventListener('change', function(){
+                autoUpload = this.checked;
+                ao_module_storage.setStorage('imgPaste', 'uploadOnPaste', autoUpload ? 'true' : 'false');
+            });
+
+            pickBtn.addEventListener('click', function(){
+                ao_module_openFileSelector('_ipFolderCb', savePath, 'folder', false);
+            });
+
+            uploadBtn.addEventListener('click', function(){ doUpload(); });
+
+            function doUpload(){
+                if (!blob) return;
+                var now = new Date();
+                var ts = Date.now() + '';
+                var filename = 'ImagePaste ' + now.getUTCFullYear() + '-' + (now.getUTCMonth()+1) + '-' + now.getUTCDate() + ts.substr(ts.length - 6) + '.png';
+                uploadBtn.disabled = true;
+                ao_module_uploadFile(new File([blob], filename), savePath, function(data){
+                    uploadBtn.disabled = false;
+                    if (data && data.error){
+                        resultEl.innerHTML = '<div class="result-box result-err"><strong>Error:</strong> ' + escHtml(String(data.error)) + '</div>';
+                    } else {
+                        resultEl.innerHTML = '<div class="result-box result-ok"><strong>' + escHtml(filename) + '</strong> saved to ' + escHtml(savePath) + '</div>';
+                    }
+                });
+            }
+
+            if (window._imgPasteDocHandler){
+                document.removeEventListener('paste', window._imgPasteDocHandler);
+            }
+            window._imgPasteDocHandler = function(e){
+                if (!document.body.contains(root)){
+                    document.removeEventListener('paste', window._imgPasteDocHandler);
+                    window._imgPasteDocHandler = null;
+                    return;
+                }
+                var items = (e.clipboardData || e.originalEvent.clipboardData).items;
+                for (var i = 0; i < items.length; i++){
+                    if (items[i].kind === 'file'){
+                        var file = items[i].getAsFile();
+                        var reader = new FileReader();
+                        reader.onload = (function(b){
+                            return function(ev){
+                                blob = b;
+                                preview.src = ev.target.result;
+                                preview.style.display = 'block';
+                                hint.style.display = 'none';
+                                uploadBtn.disabled = false;
+                                resultEl.innerHTML = '';
+                                if (autoUpload){ doUpload(); }
+                            };
+                        })(file);
+                        reader.readAsDataURL(file);
+                        break;
+                    }
+                }
+            };
+            document.addEventListener('paste', window._imgPasteDocHandler);
+
+            window._ipFolderCb = function(files){
+                if (!files || !files.length) return;
+                savePath = files[0].filepath;
+                saveInp.value = savePath;
+                ao_module_storage.setStorage('imgPaste', 'savepath', savePath);
+            };
+        }
+    };
+})();
+</script>

Файловите разлики са ограничени, защото са твърде много
+ 14 - 0
src/web/Productivity/tools/lib/pdf-lib.min.js


+ 771 - 0
src/web/Productivity/tools/pdfedit.html

@@ -0,0 +1,771 @@
+<style>
+    /* The editor takes over the whole tool panel body (padding removed in init) */
+    .pe-root {
+        position: relative;
+        display: flex; flex-direction: column;
+        width: 100%; height: 100%;
+        overflow: hidden;
+    }
+
+    .pe-toolbar {
+        display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
+        padding: 8px 12px;
+        border-bottom: 1px solid var(--border);
+        background: var(--card-bg);
+        flex-shrink: 0;
+    }
+    .pe-sep { width: 1px; height: 20px; background: var(--border); margin: 0 3px; }
+    .pe-spacer { flex: 1; }
+    .pe-fname {
+        font-size: 12px; color: var(--sub);
+        max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+    }
+
+    .pe-tbtn {
+        display: inline-flex; align-items: center; gap: 5px;
+        padding: 6px 10px; border-radius: 7px;
+        border: 1px solid var(--border); background: var(--card-bg);
+        color: var(--text); font-size: 12.5px; font-weight: 600;
+        font-family: inherit; cursor: pointer;
+    }
+    .pe-tbtn:hover { background: rgba(0,0,0,0.05); }
+    .pe-tbtn:disabled { opacity: 0.4; cursor: default; }
+    .pe-tbtn:disabled:hover { background: var(--card-bg); }
+    .pe-tbtn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+    .pe-tbtn.primary:hover { opacity: 0.9; background: var(--accent); }
+    .pe-tbtn.primary:disabled:hover { background: var(--accent); }
+
+    .pe-iconbtn {
+        width: 28px; height: 28px; border-radius: 6px;
+        border: 1px solid var(--border); background: var(--card-bg);
+        color: var(--text); cursor: pointer;
+        display: inline-flex; align-items: center; justify-content: center;
+    }
+    .pe-iconbtn:hover { background: rgba(0,0,0,0.05); }
+    .pe-iconbtn:disabled { opacity: 0.4; cursor: default; }
+
+    .pe-page-lbl { font-size: 12px; color: var(--sub); min-width: 70px; text-align: center; }
+    .pe-zoom {
+        font-size: 12.5px; padding: 5px 6px; border-radius: 7px;
+        border: 1px solid var(--border); background: var(--card-bg);
+        color: var(--text); font-family: inherit; cursor: pointer;
+    }
+    .pe-zoom:disabled { opacity: 0.4; cursor: default; }
+
+    /* Stage fills the remaining height; "Fit" sizes the page so it never needs scrolling */
+    .pe-stage {
+        position: relative; flex: 1; min-height: 0;
+        display: flex; overflow: hidden;
+        background: var(--progress-bg);
+    }
+    .pe-stage.zoomed { overflow: auto; }
+    .pe-empty { margin: auto; text-align: center; color: var(--sub); font-size: 13.5px; line-height: 1.6; padding: 20px; }
+
+    .pe-wrap { margin: auto; position: relative; box-shadow: 0 4px 18px rgba(0,0,0,0.22); flex-shrink: 0; }
+    .pe-wrap canvas { display: block; }
+    .pe-overlay { position: absolute; inset: 0; }
+
+    .pe-ov { position: absolute; box-sizing: border-box; cursor: move; border: 1px solid transparent; }
+    .pe-ov.selected { border-color: var(--accent); }
+    .pe-ov.pe-text {
+        padding: 1px 2px; white-space: pre; line-height: 1.25;
+        font-family: 'Helvetica Neue', Arial, sans-serif; outline: none; min-width: 12px;
+    }
+    .pe-ov.pe-text[contenteditable="true"] { cursor: text; background: rgba(0,122,255,0.06); white-space: pre-wrap; }
+    .pe-ov.pe-img img { display: block; width: 100%; height: 100%; pointer-events: none; -webkit-user-drag: none; }
+
+    .pe-handle {
+        position: absolute; right: -6px; bottom: -6px;
+        width: 13px; height: 13px; border-radius: 50%;
+        background: var(--accent); border: 2px solid #fff;
+        cursor: nwse-resize; display: none;
+    }
+    .pe-ov.selected .pe-handle { display: block; }
+    .pe-del {
+        position: absolute; right: -9px; top: -9px;
+        width: 18px; height: 18px; border-radius: 50%;
+        background: var(--danger); color: #fff; border: 2px solid #fff;
+        cursor: pointer; display: none; align-items: center; justify-content: center; padding: 0;
+    }
+    .pe-ov.selected .pe-del { display: flex; }
+
+    /* Floating contextual bar for the selected text element */
+    .pe-props {
+        position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
+        z-index: 30; display: none; align-items: center; gap: 16px;
+        padding: 8px 14px; background: var(--card-bg);
+        border: 1px solid var(--border); border-radius: 10px;
+        box-shadow: 0 6px 22px rgba(0,0,0,0.18);
+    }
+    .pe-props.show { display: flex; }
+    .pe-props label { font-size: 12px; color: var(--sub); display: flex; align-items: center; gap: 7px; }
+    .pe-props input[type=color] { width: 30px; height: 26px; border: 1px solid var(--border); border-radius: 6px; background: none; cursor: pointer; padding: 0; }
+
+    .pe-toast {
+        position: absolute; top: 14px; left: 50%; transform: translateX(-50%);
+        z-index: 40; max-width: 84%; display: none;
+    }
+    .pe-toast.show { display: block; }
+    .pe-toast .result-box { margin: 0; box-shadow: 0 6px 22px rgba(0,0,0,0.18); }
+
+    .pe-stamp-menu {
+        position: absolute; z-index: 50;
+        background: var(--card-bg); border: 1px solid var(--border);
+        border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,0.18);
+        padding: 10px; width: 230px; max-height: 280px; overflow-y: auto; display: none;
+    }
+    .pe-stamp-menu.show { display: block; }
+    .pe-stamp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+    .pe-stamp-item {
+        border: 1px solid var(--border); border-radius: 7px; padding: 6px; cursor: pointer;
+        display: flex; flex-direction: column; gap: 4px; align-items: center;
+        background: repeating-conic-gradient(#0000000d 0% 25%, transparent 0% 50%) 50% / 14px 14px;
+    }
+    .pe-stamp-item:hover { border-color: var(--accent); }
+    .pe-stamp-item img { max-width: 100%; height: 54px; object-fit: contain; }
+    .pe-stamp-item span { font-size: 11px; color: var(--text); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .pe-stamp-empty { font-size: 12px; color: var(--sub); text-align: center; padding: 12px 6px; line-height: 1.5; }
+</style>
+
+<div class="pe-root" id="peRoot">
+    <div class="pe-toolbar">
+        <button class="pe-tbtn primary" id="peOpen" title="Open a PDF from the server">
+            <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M1.2 3.2h3.4l1.3 1.6h6.9v6.4H1.2z"/><path d="M1.2 6h11.6"/>
+            </svg> Open File
+        </button>
+        <span class="pe-fname" id="peFileName"></span>
+
+        <div class="pe-sep"></div>
+
+        <button class="pe-tbtn" id="peAddText" title="Insert a text box" disabled>
+            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
+                <path d="M2 3.2V2h10v1.2M7 2v10M5 12h4"/>
+            </svg> Text
+        </button>
+        <button class="pe-tbtn" id="peAddImg" title="Insert an image from the server" disabled>
+            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
+                <rect x="1.5" y="2.5" width="11" height="9" rx="1.3"/><circle cx="4.7" cy="5.5" r="1"/>
+                <path d="M1.5 10 L5 6.5 L7.5 8.5 L10 5.5 L12.5 9"/>
+            </svg> Image
+        </button>
+        <button class="pe-tbtn" id="peUpload" title="Upload an image from this device" disabled>
+            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M7 9V2.5M4.3 5.2 7 2.5l2.7 2.7"/><path d="M2 9.5v1.5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V9.5"/>
+            </svg> Upload
+        </button>
+        <button class="pe-tbtn" id="peAddStamp" title="Place a saved company chop/stamp" disabled>
+            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M7 1.5c-1.7 0-2.6 1.3-2.2 2.9.2.9.4 1.6-.3 2.1H9.5c-.7-.5-.5-1.2-.3-2.1C9.6 2.8 8.7 1.5 7 1.5Z"/>
+                <rect x="2.5" y="8.5" width="9" height="1.8" rx="0.6"/><line x1="1.8" y1="12.2" x2="12.2" y2="12.2"/>
+            </svg> Stamp
+        </button>
+
+        <div class="pe-sep"></div>
+
+        <button class="pe-iconbtn" id="pePrev" title="Previous page" disabled>
+            <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 2 L4 6.5 L8.5 11"/></svg>
+        </button>
+        <span class="pe-page-lbl" id="pePageLbl">—</span>
+        <button class="pe-iconbtn" id="peNext" title="Next page" disabled>
+            <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 2 L9 6.5 L4.5 11"/></svg>
+        </button>
+
+        <div class="pe-sep"></div>
+
+        <select class="pe-zoom" id="peZoom" title="Zoom" disabled>
+            <option value="fit" selected>Fit</option>
+            <option value="0.5">50%</option>
+            <option value="0.75">75%</option>
+            <option value="1">100%</option>
+            <option value="1.25">125%</option>
+            <option value="1.5">150%</option>
+            <option value="2">200%</option>
+        </select>
+
+        <div class="pe-spacer"></div>
+
+        <button class="pe-tbtn primary" id="peSave" title="Save the edited PDF" disabled>
+            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M2 2h7.5L12 4.5V12H2z"/><path d="M4.5 2v3h4V2"/><rect x="4.5" y="7.5" width="5" height="3.5"/>
+            </svg> Save
+        </button>
+    </div>
+
+    <div class="pe-stage" id="peStage">
+        <div class="pe-empty" id="peEmpty">No PDF open.<br>Click <strong>Open File</strong> to start editing.</div>
+    </div>
+
+    <div class="pe-props" id="peProps">
+        <label>Font size
+            <input type="range" id="peFontSize" min="8" max="120" value="24" style="width:120px">
+            <span id="peFontVal" style="color:var(--text);font-weight:600;min-width:34px">24px</span>
+        </label>
+        <label>Colour <input type="color" id="peColor" value="#d0021b"></label>
+    </div>
+
+    <div class="pe-toast" id="peToast"></div>
+
+    <input type="file" id="peUploadInput" accept="image/*" style="display:none">
+    <div class="pe-stamp-menu" id="peStampMenu"></div>
+</div>
+
+<script>
+(function(){
+    var registry = window.ProductivityToolModules = window.ProductivityToolModules || {};
+    var FONT = "'Helvetica Neue', Arial, sans-serif";
+    var TEXT_BASELINE_RATIO = 0.8; // distance from a line's top to its baseline, as a fraction of font size
+    var pdfJsReady = false;
+
+    function loadPdfJs(onReady, onError){
+        if (pdfJsReady || typeof pdfjsLib !== 'undefined'){
+            if (!pdfJsReady){
+                pdfjsLib.GlobalWorkerOptions.workerSrc = '../PDF Viewer/js/pdf.worker.js';
+                pdfJsReady = true;
+            }
+            onReady(); return;
+        }
+        var s = document.createElement('script');
+        s.src = '../PDF Viewer/js/pdf.js';
+        s.onload = function(){
+            pdfjsLib.GlobalWorkerOptions.workerSrc = '../PDF Viewer/js/pdf.worker.js';
+            pdfJsReady = true; onReady();
+        };
+        s.onerror = function(){ onError(new Error('Failed to load PDF library')); };
+        document.head.appendChild(s);
+    }
+
+    /* pdf-lib (vendored, MIT) — used to add overlays as real PDF content while keeping the original intact */
+    function loadPdfLib(onReady, onError){
+        if (typeof PDFLib !== 'undefined'){ onReady(); return; }
+        var s = document.createElement('script');
+        s.src = 'tools/lib/pdf-lib.min.js';
+        s.onload = function(){ onReady(); };
+        s.onerror = function(){ onError(new Error('Failed to load pdf-lib')); };
+        document.head.appendChild(s);
+    }
+
+    registry.pdfedit = {
+        init: function(root){
+            // Take over the padded, scrolling tool-panel body so the editor fills it edge to edge
+            root.style.padding = '0';
+            root.style.overflow = 'hidden';
+            root.style.display = 'flex';
+            root.style.flexDirection = 'column';
+
+            var S = { pdf:null, filepath:null, page:1, num:0, overlays:{}, sel:null, root:root };
+            var zoom = 'fit'; // 'fit' or a numeric scale (1 = 100%)
+            var resizeTimer = null;
+
+            var elStage  = root.querySelector('#peStage');
+            var elEmpty  = root.querySelector('#peEmpty');
+            var elProps  = root.querySelector('#peProps');
+            var elToast  = root.querySelector('#peToast');
+            var elFontSize = root.querySelector('#peFontSize');
+            var elFontVal  = root.querySelector('#peFontVal');
+            var elColor    = root.querySelector('#peColor');
+            var elZoom     = root.querySelector('#peZoom');
+            var elFileName = root.querySelector('#peFileName');
+            var stampMenu  = root.querySelector('#peStampMenu');
+            var uploadInput = root.querySelector('#peUploadInput');
+            var editButtons = ['#peAddText','#peAddImg','#peUpload','#peAddStamp','#peSave','#peZoom']
+                .map(function(s){ return root.querySelector(s); });
+            var canvas, ctx, overlayEl, wrapEl;
+
+            function alive(){ return document.body.contains(root); }
+
+            function enableEditing(on){
+                editButtons.forEach(function(b){ b.disabled = !on; });
+            }
+
+            function showToast(html, isError){
+                elToast.innerHTML = '<div class="result-box ' + (isError ? 'result-err' : 'result-ok') + '">' + html + '</div>';
+                elToast.classList.add('show');
+                clearTimeout(elToast._t);
+                if (!isError){ elToast._t = setTimeout(function(){ elToast.classList.remove('show'); }, 5000); }
+            }
+            function hideToast(){ elToast.classList.remove('show'); }
+
+            /* ── Selection / property bar ── */
+            function selectOverlay(o, el){
+                clearSelection();
+                S.sel = { o:o, el:el };
+                el.classList.add('selected');
+                if (o.type === 'text'){
+                    elProps.classList.add('show');
+                    var px = Math.round(o.fontFrac * canvas.height);
+                    elFontSize.value = px; elFontVal.textContent = px + 'px';
+                    elColor.value = o.color || '#000000';
+                }
+            }
+            function clearSelection(){
+                if (S.sel){ S.sel.el.classList.remove('selected'); }
+                S.sel = null;
+                elProps.classList.remove('show');
+            }
+
+            elFontSize.addEventListener('input', function(){
+                elFontVal.textContent = this.value + 'px';
+                if (S.sel && S.sel.o.type === 'text'){
+                    S.sel.o.fontFrac = parseInt(this.value, 10) / canvas.height;
+                    S.sel.el.style.fontSize = this.value + 'px';
+                }
+            });
+            elColor.addEventListener('input', function(){
+                if (S.sel && S.sel.o.type === 'text'){
+                    S.sel.o.color = this.value;
+                    S.sel.el.style.color = this.value;
+                }
+            });
+
+            /* ── Overlay element construction ── */
+            function makeOverlayEl(o){
+                var el = document.createElement('div');
+                el.className = 'pe-ov ' + (o.type === 'text' ? 'pe-text' : 'pe-img');
+                el.style.left = (o.fx * canvas.width) + 'px';
+                el.style.top  = (o.fy * canvas.height) + 'px';
+
+                if (o.type === 'text'){
+                    el.textContent = o.t;
+                    el.style.fontSize = (o.fontFrac * canvas.height) + 'px';
+                    el.style.color = o.color || '#000';
+                    el.addEventListener('dblclick', function(){
+                        el.setAttribute('contenteditable', 'true');
+                        el.focus();
+                    });
+                    el.addEventListener('blur', function(){
+                        el.removeAttribute('contenteditable');
+                        o.t = el.textContent;
+                        o.fx = el.offsetLeft / canvas.width;
+                        o.fy = el.offsetTop / canvas.height;
+                    });
+                } else {
+                    el.style.width  = (o.fwFrac * canvas.width) + 'px';
+                    el.style.height = (o.fhFrac * canvas.height) + 'px';
+                    var img = document.createElement('img');
+                    img.src = o.src; el.appendChild(img);
+                    var handle = document.createElement('div');
+                    handle.className = 'pe-handle';
+                    el.appendChild(handle);
+                    wireResize(handle, el, o);
+                }
+
+                var del = document.createElement('button');
+                del.className = 'pe-del';
+                del.innerHTML = '<svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round"><line x1="1" y1="1" x2="8" y2="8"/><line x1="8" y1="1" x2="1" y2="8"/></svg>';
+                del.addEventListener('mousedown', function(e){ e.stopPropagation(); });
+                del.addEventListener('click', function(e){
+                    e.stopPropagation();
+                    var arr = S.overlays[S.page] || [];
+                    var i = arr.indexOf(o);
+                    if (i !== -1) arr.splice(i, 1);
+                    clearSelection();
+                    el.parentNode.removeChild(el);
+                });
+                el.appendChild(del);
+
+                wireDrag(el, o);
+                el.addEventListener('mousedown', function(){ selectOverlay(o, el); });
+                return el;
+            }
+
+            function wireDrag(el, o){
+                el.addEventListener('mousedown', function(e){
+                    if (el.getAttribute('contenteditable') === 'true') return;
+                    if (e.target.classList.contains('pe-handle')) return;
+                    e.preventDefault();
+                    var startX = e.clientX, startY = e.clientY;
+                    var baseL = el.offsetLeft, baseT = el.offsetTop;
+                    function mv(ev){
+                        var nl = Math.max(0, Math.min(canvas.width  - 6, baseL + (ev.clientX - startX)));
+                        var nt = Math.max(0, Math.min(canvas.height - 6, baseT + (ev.clientY - startY)));
+                        el.style.left = nl + 'px'; el.style.top = nt + 'px';
+                        o.fx = nl / canvas.width; o.fy = nt / canvas.height;
+                    }
+                    function up(){ document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); }
+                    document.addEventListener('mousemove', mv);
+                    document.addEventListener('mouseup', up);
+                });
+            }
+
+            function wireResize(handle, el, o){
+                handle.addEventListener('mousedown', function(e){
+                    e.preventDefault(); e.stopPropagation();
+                    var startX = e.clientX;
+                    var startW = el.offsetWidth;
+                    var aspect = el.offsetHeight / el.offsetWidth;
+                    function mv(ev){
+                        var nw = Math.max(18, Math.min(canvas.width, startW + (ev.clientX - startX)));
+                        var nh = nw * aspect;
+                        el.style.width = nw + 'px'; el.style.height = nh + 'px';
+                        o.fwFrac = nw / canvas.width; o.fhFrac = nh / canvas.height;
+                    }
+                    function up(){ document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); }
+                    document.addEventListener('mousemove', mv);
+                    document.addEventListener('mouseup', up);
+                });
+            }
+
+            /* ── Page rendering (Fit fills the stage; numeric zoom scrolls) ── */
+            function pageScale(v1){
+                if (zoom === 'fit'){
+                    var availW = Math.max(80, elStage.clientWidth - 28);
+                    var availH = Math.max(80, elStage.clientHeight - 28);
+                    return Math.min(availW / v1.width, availH / v1.height, 4);
+                }
+                return zoom;
+            }
+
+            function renderPage(n){
+                if (!S.pdf) return;
+                clearSelection();
+                S.pdf.getPage(n).then(function(page){
+                    if (!alive()) return;
+                    var v1 = page.getViewport({ scale: 1 });
+                    var vp = page.getViewport({ scale: pageScale(v1) });
+
+                    elStage.classList.toggle('zoomed', zoom !== 'fit');
+                    elStage.innerHTML = '';
+                    wrapEl = document.createElement('div');
+                    wrapEl.className = 'pe-wrap';
+                    wrapEl.style.width = vp.width + 'px';
+                    wrapEl.style.height = vp.height + 'px';
+                    canvas = document.createElement('canvas');
+                    canvas.width = vp.width; canvas.height = vp.height;
+                    ctx = canvas.getContext('2d');
+                    overlayEl = document.createElement('div');
+                    overlayEl.className = 'pe-overlay';
+                    wrapEl.appendChild(canvas);
+                    wrapEl.appendChild(overlayEl);
+                    elStage.appendChild(wrapEl);
+
+                    overlayEl.addEventListener('mousedown', function(e){
+                        if (e.target === overlayEl) clearSelection();
+                    });
+
+                    page.render({ canvasContext: ctx, viewport: vp }).promise.then(function(){
+                        (S.overlays[n] || []).forEach(function(o){ overlayEl.appendChild(makeOverlayEl(o)); });
+                    });
+
+                    root.querySelector('#pePageLbl').textContent = n + ' / ' + S.num;
+                    root.querySelector('#pePrev').disabled = (n <= 1);
+                    root.querySelector('#peNext').disabled = (n >= S.num);
+                });
+            }
+
+            function addOverlay(o){
+                if (!S.overlays[S.page]) S.overlays[S.page] = [];
+                S.overlays[S.page].push(o);
+                if (overlayEl){
+                    var el = makeOverlayEl(o);
+                    overlayEl.appendChild(el);
+                    selectOverlay(o, el);
+                }
+            }
+
+            // Re-fit the page when the panel/window is resized (only in Fit mode)
+            var ro = new ResizeObserver(function(){
+                if (!alive()){ ro.disconnect(); return; }
+                if (!S.pdf || zoom !== 'fit') return;
+                clearTimeout(resizeTimer);
+                resizeTimer = setTimeout(function(){ if (alive() && S.pdf) renderPage(S.page); }, 120);
+            });
+            ro.observe(elStage);
+
+            /* ── Toolbar actions ── */
+            root.querySelector('#peOpen').addEventListener('click', function(){
+                ao_module_openFileSelector('_peoPickCb', 'user:/', 'file', false, { filter: ['pdf'] });
+            });
+
+            root.querySelector('#peAddText').addEventListener('click', function(){
+                if (!canvas) return;
+                addOverlay({ type:'text', t:'Double-click to edit', fx:0.12, fy:0.12,
+                    fontFrac: 24 / canvas.height, color: elColor.value || '#d0021b' });
+            });
+
+            root.querySelector('#peAddImg').addEventListener('click', function(){
+                ao_module_openFileSelector('_peoImgCb', 'user:/', 'file', false, { filter: ['jpg','jpeg','png','gif','webp','bmp'] });
+            });
+
+            root.querySelector('#peUpload').addEventListener('click', function(){
+                uploadInput.value = '';
+                uploadInput.click();
+            });
+            uploadInput.addEventListener('change', function(){
+                if (!this.files || !this.files.length) return;
+                var reader = new FileReader();
+                reader.onload = function(ev){ placeImageFromSrc(ev.target.result); };
+                reader.readAsDataURL(this.files[0]);
+            });
+
+            root.querySelector('#peAddStamp').addEventListener('click', function(e){
+                e.stopPropagation();
+                openStampMenu(this);
+            });
+
+            root.querySelector('#pePrev').addEventListener('click', function(){
+                if (S.page > 1){ S.page--; renderPage(S.page); }
+            });
+            root.querySelector('#peNext').addEventListener('click', function(){
+                if (S.page < S.num){ S.page++; renderPage(S.page); }
+            });
+
+            elZoom.addEventListener('change', function(){
+                zoom = this.value === 'fit' ? 'fit' : parseFloat(this.value);
+                if (S.pdf) renderPage(S.page);
+            });
+
+            /* ── Stamp picker ── */
+            function placeImageFromSrc(src){
+                if (!canvas) return;
+                var probe = new Image();
+                probe.onload = function(){
+                    var dispW = Math.min(150, canvas.width * 0.32);
+                    var dispH = dispW * (probe.naturalHeight / probe.naturalWidth);
+                    addOverlay({ type:'image', src:src,
+                        fx:0.4, fy:0.4, fwFrac: dispW / canvas.width, fhFrac: dispH / canvas.height });
+                };
+                probe.onerror = function(){ showToast('Could not load that image.', true); };
+                probe.src = src;
+            }
+
+            function openStampMenu(anchor){
+                if (!window.ProductivityChops){
+                    stampMenu.innerHTML = '<div class="pe-stamp-empty">Chop library unavailable.</div>';
+                }
+                window.ProductivityChops && window.ProductivityChops.load(function(chops){
+                    if (!chops.length){
+                        stampMenu.innerHTML = '<div class="pe-stamp-empty">No chops saved yet.<br>Add your company stamp in the <strong>Chop Library</strong> tool first.</div>';
+                    } else {
+                        var html = '<div class="pe-stamp-grid">';
+                        chops.forEach(function(c){
+                            html += '<div class="pe-stamp-item" data-src="' + mediaUrl(c.path) + '">' +
+                                    '<img src="' + mediaUrl(c.path) + '" alt=""><span>' + escHtml(c.name) + '</span></div>';
+                        });
+                        html += '</div>';
+                        stampMenu.innerHTML = html;
+                        Array.prototype.forEach.call(stampMenu.querySelectorAll('.pe-stamp-item'), function(it){
+                            it.addEventListener('click', function(){
+                                placeImageFromSrc(this.getAttribute('data-src'));
+                                stampMenu.classList.remove('show');
+                            });
+                        });
+                    }
+                    stampMenu.style.left = (anchor.offsetLeft) + 'px';
+                    stampMenu.style.top  = (anchor.offsetTop + anchor.offsetHeight + 6) + 'px';
+                    stampMenu.classList.add('show');
+                });
+            }
+            document.addEventListener('mousedown', function(e){
+                if (!stampMenu.contains(e.target) && e.target.id !== 'peAddStamp'){
+                    stampMenu.classList.remove('show');
+                }
+            });
+
+            /* ── File-selector callbacks (global, guarded by alive()) ── */
+            window._peoPickCb = function(files){
+                if (!alive() || !files || !files.length) return;
+                var f = files[0];
+                S.filepath = f.filepath;
+                elFileName.textContent = f.filename;
+                elFileName.title = f.filename;
+                hideToast();
+                S.overlays = {}; S.page = 1; zoom = 'fit'; elZoom.value = 'fit';
+                elStage.classList.remove('zoomed');
+                elStage.innerHTML = '<div class="pe-empty">Opening…</div>';
+                ao_module_setWindowTitle('PDF Editor — ' + f.filename);
+                loadPdfJs(function(){
+                    pdfjsLib.getDocument(mediaUrl(f.filepath)).promise.then(function(pdf){
+                        if (!alive()) return;
+                        S.pdf = pdf; S.num = pdf.numPages; S.page = 1;
+                        enableEditing(true);
+                        renderPage(1);
+                    }, function(err){
+                        elStage.innerHTML = '<div class="pe-empty">Failed to open PDF:<br>' + escHtml(String(err && err.message || err)) + '</div>';
+                    });
+                }, function(err){
+                    elStage.innerHTML = '<div class="pe-empty">' + escHtml(err.message) + '</div>';
+                });
+            };
+
+            window._peoImgCb = function(files){
+                if (!alive() || !files || !files.length) return;
+                placeImageFromSrc(mediaUrl(files[0].filepath));
+            };
+
+            /* ══════════════════════════════════════════════════════════════
+               EXPORT — overlays are written as real PDF content with pdf-lib.
+               Pages without overlays are left byte-for-byte untouched, so the
+               original vector text stays selectable and searchable.
+            ══════════════════════════════════════════════════════════════ */
+            function fetchBytes(url){
+                return fetch(url).then(function(r){
+                    if (!r.ok) throw new Error('HTTP ' + r.status);
+                    return r.arrayBuffer();
+                }).then(function(buf){ return new Uint8Array(buf); });
+            }
+
+            function canvasToPngBytes(c){
+                return new Promise(function(resolve, reject){
+                    c.toBlob(function(blob){
+                        if (!blob){ reject(new Error('Canvas export failed')); return; }
+                        var fr = new FileReader();
+                        fr.onload = function(e){ resolve(new Uint8Array(e.target.result)); };
+                        fr.onerror = reject;
+                        fr.readAsArrayBuffer(blob);
+                    }, 'image/png');
+                });
+            }
+
+            // Embed an overlay image: pass PNG/JPEG through directly, rasterise anything else to PNG.
+            function embedImageSrc(pdfDoc, src, cache){
+                if (cache[src]) return cache[src];
+                var p = fetchBytes(src).then(function(bytes){
+                    if (bytes[0] === 0x89 && bytes[1] === 0x50){ return pdfDoc.embedPng(bytes); }
+                    if (bytes[0] === 0xFF && bytes[1] === 0xD8){ return pdfDoc.embedJpg(bytes); }
+                    return new Promise(function(resolve, reject){
+                        var img = new Image();
+                        img.onload = function(){
+                            var c = document.createElement('canvas');
+                            c.width = img.naturalWidth; c.height = img.naturalHeight;
+                            c.getContext('2d').drawImage(img, 0, 0);
+                            canvasToPngBytes(c).then(function(png){ resolve(pdfDoc.embedPng(png)); }, reject);
+                        };
+                        img.onerror = function(){ reject(new Error('Could not load image')); };
+                        img.src = src;
+                    });
+                });
+                cache[src] = p;
+                return p;
+            }
+
+            // Rasterise one text overlay to a PNG — fallback for glyphs the standard font can't encode (e.g. CJK).
+            function rasteriseText(o, fontPt){
+                var lines = String(o.t).split('\n');
+                var probe = document.createElement('canvas').getContext('2d');
+                probe.font = fontPt + 'px ' + FONT;
+                var wPt = 1;
+                lines.forEach(function(ln){ wPt = Math.max(wPt, probe.measureText(ln).width); });
+                var lineH = fontPt * 1.25;
+                var hPt = lines.length * lineH;
+                var k = 3; // supersample for crisp glyphs
+                var c = document.createElement('canvas');
+                c.width = Math.max(1, Math.ceil(wPt * k));
+                c.height = Math.max(1, Math.ceil(hPt * k));
+                var cx = c.getContext('2d');
+                cx.scale(k, k);
+                cx.fillStyle = o.color || '#000';
+                cx.textBaseline = 'top';
+                cx.font = fontPt + 'px ' + FONT;
+                lines.forEach(function(ln, i){ cx.fillText(ln, 0, i * lineH); });
+                return { canvas: c, wPt: wPt, hPt: hPt };
+            }
+
+            function hexToRgb(hex){
+                hex = (hex || '#000000').replace('#', '');
+                if (hex.length === 3){ hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; }
+                return {
+                    r: parseInt(hex.substr(0,2),16)/255,
+                    g: parseInt(hex.substr(2,2),16)/255,
+                    b: parseInt(hex.substr(4,2),16)/255
+                };
+            }
+
+            var elSave = root.querySelector('#peSave');
+            var SAVE_LABEL = elSave.innerHTML;
+            elSave.addEventListener('click', function(){
+                if (!S.pdf || !S.filepath) return;
+                clearSelection();
+                var btn = elSave;
+                btn.disabled = true;
+                function status(t){ btn.textContent = t; }
+                function done(){ btn.innerHTML = SAVE_LABEL; btn.disabled = false; }
+                function fail(msg){ showToast('<strong>Error:</strong> ' + escHtml(String(msg)), true); done(); }
+
+                status('Loading…');
+                loadPdfLib(function(){
+                    var L = window.PDFLib;
+                    var degrees = L.degrees, rgb = L.rgb;
+
+                    status('Opening…');
+                    fetchBytes(mediaUrl(S.filepath)).then(function(srcBytes){
+                        return L.PDFDocument.load(srcBytes, { ignoreEncryption: true });
+                    }).then(function(pdfDoc){
+                        return pdfDoc.embedFont(L.StandardFonts.Helvetica).then(function(helv){
+                            var imgCache = {};
+                            var libPages = pdfDoc.getPages();
+                            var pageKeys = Object.keys(S.overlays).filter(function(k){ return (S.overlays[k] || []).length; });
+
+                            // Walk only the pages that actually have overlays, in order.
+                            return pageKeys.reduce(function(chain, k){
+                                return chain.then(function(){
+                                    var pn = parseInt(k, 10);
+                                    var libPage = libPages[pn - 1];
+                                    if (!libPage) return;
+                                    status('Writing page ' + pn + '…');
+
+                                    return S.pdf.getPage(pn).then(function(jsPage){
+                                        var vp = jsPage.getViewport({ scale: 1 });
+                                        var rot = vp.rotation || 0;
+
+                                        return (S.overlays[pn] || []).reduce(function(c2, o){
+                                            return c2.then(function(){
+                                                var dx = o.fx * vp.width, dy = o.fy * vp.height;
+
+                                                if (o.type === 'image'){
+                                                    var ew = o.fwFrac * vp.width, eh = o.fhFrac * vp.height;
+                                                    var bl = vp.convertToPdfPoint(dx, dy + eh);
+                                                    return embedImageSrc(pdfDoc, o.src, imgCache).then(function(img){
+                                                        libPage.drawImage(img, { x: bl[0], y: bl[1], width: ew, height: eh, rotate: degrees(rot) });
+                                                    });
+                                                }
+
+                                                var fontPt = o.fontFrac * vp.height;
+                                                var col = hexToRgb(o.color);
+                                                var canEncode = true;
+                                                try { helv.encodeText(o.t); } catch(e){ canEncode = false; }
+
+                                                if (canEncode){
+                                                    String(o.t).split('\n').forEach(function(ln, i){
+                                                        var topY = dy + TEXT_BASELINE_RATIO * fontPt + i * fontPt * 1.25;
+                                                        var pt = vp.convertToPdfPoint(dx, topY);
+                                                        libPage.drawText(ln, {
+                                                            x: pt[0], y: pt[1], size: fontPt, font: helv,
+                                                            color: rgb(col.r, col.g, col.b), rotate: degrees(rot)
+                                                        });
+                                                    });
+                                                    return;
+                                                }
+
+                                                // Unsupported glyphs: rasterise just this text box and place it as an image.
+                                                var rt = rasteriseText(o, fontPt);
+                                                var blt = vp.convertToPdfPoint(dx, dy + rt.hPt);
+                                                return canvasToPngBytes(rt.canvas)
+                                                    .then(function(png){ return pdfDoc.embedPng(png); })
+                                                    .then(function(img){
+                                                        libPage.drawImage(img, { x: blt[0], y: blt[1], width: rt.wPt, height: rt.hPt, rotate: degrees(rot) });
+                                                    });
+                                            });
+                                        }, Promise.resolve());
+                                    });
+                                });
+                            }, Promise.resolve()).then(function(){ return pdfDoc.save(); });
+                        });
+                    }).then(function(outBytes){
+                        var outName = basenameNoExt(S.filepath) + '_edited.pdf';
+                        var outDir = dirOf(S.filepath);
+                        status('Uploading…');
+                        return uploadBlob(new Blob([outBytes], { type: 'application/pdf' }), outName, outDir).then(function(){
+                            showToast('<strong>' + escHtml(outName) + '</strong> saved to ' + escHtml(outDir) +
+                                '. Original text kept selectable.', false);
+                            done();
+                        });
+                    }).catch(function(err){
+                        fail(err && err.message ? err.message : err);
+                    });
+                }, function(err){ fail(err.message); });
+            });
+        }
+    };
+})();
+</script>

+ 34 - 0
src/web/Productivity/tools/webbuilder.html

@@ -0,0 +1,34 @@
+<script>
+(function(){
+    var registry = window.ProductivityToolModules = window.ProductivityToolModules || {};
+
+    registry.webbuilder = {
+        init: function(root) {
+            root.style.padding = '0';
+            root.style.overflow = 'hidden';
+            root.style.position = 'relative';
+
+            var src = '../Web Builder/index.html';
+            if (window._productivityLaunchFile) {
+                src += '#' + encodeURIComponent(JSON.stringify([window._productivityLaunchFile]));
+                delete window._productivityLaunchFile;
+            }
+
+            var iframe = document.createElement('iframe');
+            iframe.title = 'Web Builder';
+            iframe.src = src;
+            iframe.style.position = 'absolute';
+            iframe.style.top = '0';
+            iframe.style.left = '0';
+            iframe.style.right = '0';
+            iframe.style.bottom = '0';
+            iframe.style.width = '100%';
+            iframe.style.height = '100%';
+            iframe.style.border = 'none';
+
+            root.innerHTML = '';
+            root.appendChild(iframe);
+        }
+    };
+})();
+</script>

+ 58 - 0
src/web/Web Builder/index.html

@@ -7,6 +7,64 @@
 		<meta name="theme-color" content="#ff9224">
 		<script src="../script/jquery.min.js"></script>
 		<script src="../script/ao_module.js"></script>
+		<script>
+		// When Web Builder is embedded as an iframe inside another ArozOS app (e.g. Productivity),
+		// ao_module_virtualDesktop is false because parent.isDesktopMode is not on the host app.
+		// This patch detects that case and routes ao_module_openFileSelector through the real desktop,
+		// registering the callback proxy on the host app window so the desktop can resolve it.
+		(function(){
+			try {
+				if (ao_module_virtualDesktop || !parent.ao_module_virtualDesktop) { return; }
+				ao_module_openFileSelector = function(callback, root, type, allowMultiple, options) {
+					root = (root != null) ? root : 'user:/';
+					type = type || 'file';
+					allowMultiple = allowMultiple || false;
+
+					var callbackname;
+					if (typeof callback === 'string') {
+						callbackname = callback;
+					} else if (typeof callback === 'function' && callback.name) {
+						callbackname = callback.name;
+					} else {
+						callbackname = '_aoFs_' + Date.now();
+					}
+					if (options && options.fnameOverride) {
+						callbackname = options.fnameOverride;
+					}
+
+					// Register proxy on the host app window — the desktop resolves callbacks there
+					parent[callbackname] = function(files) {
+						try {
+							var fn = (typeof callback === 'string') ? window[callback] : callback;
+							if (fn) { fn(files); }
+						} finally {
+							delete parent[callbackname];
+						}
+					};
+
+					var initInfo = {
+						root: root,
+						type: type,
+						allowMultiple: allowMultiple,
+						listenerUUID: '',
+						options: options
+					};
+					// parent = host app window, parent.parent = ArozOS desktop
+					parent.parent.newFloatWindow({
+						url: 'SystemAO/file_system/file_selector.html#' + encodeURIComponent(JSON.stringify(initInfo)),
+						width: 700,
+						height: 440,
+						appicon: 'SystemAO/file_system/img/selector.png',
+						title: 'Open',
+						parent: parent.ao_module_windowID,
+						callback: callbackname
+					});
+				};
+			} catch(e) {
+				// Cross-origin or not nested — leave ao_module_openFileSelector unchanged
+			}
+		})();
+		</script>
 		<link rel="stylesheet" href="../script/semantic/semantic.min.css">
 		<script src="../script/semantic/semantic.min.js"></script>
 		<title>Web Builder</title>

Някои файлове не бяха показани, защото твърде много файлове са промени