Initialising…
', 'text/html'); var body = doc.body; // Google Docs wraps everything in
...: unwrap until the tables // are direct children, so the table and the text around it can be told apart var again = true, guard = 0; while (again && guard++ < 50) { again = false; Array.prototype.slice.call(body.children).forEach(function(ch) { if (ch.tagName !== 'TABLE' && ch.querySelector && ch.querySelector('table')) { while (ch.firstChild) body.insertBefore(ch.firstChild, ch); body.removeChild(ch); again = true; } }); } var parts = [], haveTable = false; Array.prototype.forEach.call(body.childNodes, function(n) { if (n.nodeType === 1 && n.tagName === 'TABLE') { var t = window.EchoSafe.cleanTableOnly(n.outerHTML); if (t) { parts.push(t); haveTable = true; } } else if (n.nodeType === 1) { parts.push(window.EchoSafe.sanitizeHtml(n.outerHTML)); } else if (n.nodeType === 3 && n.nodeValue.trim()) { parts.push(window.EchoSafe.esc(n.nodeValue)); } }); if (!haveTable) return null; return window._quillDeltaFromMixedHtml(parts.join('')); }; class TableClipboard extends QuillClipboard { onPaste(e) { var cd = e.clipboardData || window.clipboardData; var pastedHtml = cd && cd.getData('text/html'); if (pastedHtml && / is a single opaque, recognized unit ── // Plain Quill 1.3.7 has no concept of a table at all, which is the // real root cause of every table bug found so far: whenever Quill // reconciles the DOM against its internal Delta model (which it MUST // do to avoid corrupting the rest of the document — see the "document // disappears on Enter" incident), it strips anything it doesn't // recognize. Registering a real BlockEmbed blot for tables means // Quill tracks the table as one legitimate piece of content instead // of foreign material to clean up — fixing the root cause rather than // working around Quill's reconciliation. var RawTableBlot = (function() { var BlockEmbed = Quill.import('blots/block/embed'); class RawTableBlot extends BlockEmbed { static create(value) { var node = super.create(); // Table HTML can come from pasted content, imported .docx files, teammates' reports // and saved projects, so it is cleaned with an allowlist before it touches the DOM. // (cleanTableOnly = sanitise for safety, then rebuild as the plain Google-Docs-style grid) node.innerHTML = (window.EchoSafe && value && value.html) ? window.EchoSafe.cleanTableOnly(value.html) : ''; return node; } static value(node) { // Save only the table itself. The resize handles and edit flags are added at runtime by // _makeTablesEditable; if they were saved they would pile up in every cell on each reload. var clone = node.cloneNode(true); clone.querySelectorAll('.ed-tbl-resize,[data-ed-chrome]').forEach(function(n){ n.remove(); }); clone.querySelectorAll('[contenteditable]').forEach(function(n){ n.removeAttribute('contenteditable'); }); var tbl = clone.querySelector('table'); if (!tbl) return { html: clone.innerHTML }; tbl.removeAttribute('data-ed'); tbl.querySelectorAll('td,th').forEach(function(c){ c.style.removeProperty('min-width'); c.style.removeProperty('position'); }); return { html: tbl.outerHTML }; } } RawTableBlot.blotName = 'rawTable'; RawTableBlot.tagName = 'DIV'; RawTableBlot.className = 'ql-raw-table-embed'; return RawTableBlot; })(); Quill.register(RawTableBlot); // Converts arbitrary HTML (mixed paragraphs/headings/tables) into a // single Delta quill can apply via setContents — normal content goes // through Quill's own HTML→Delta converter exactly as it always // understood it; each
becomes one rawTable embed op instead // of raw DOM Quill has no model for. Used by field-placer.js (loading // a template) and by the paste handler above (pasting a table), so // both paths go through the same real fix instead of two separate // workarounds. window._quillDeltaFromMixedHtml = function(html) { var Delta = Quill.import('delta'); // Quill's converter drops the last line break of what it converts; without it a table that follows is glued to that line // and lands after the paragraphs that come next. Every run of text must end with its own line break. function _runDelta(run) { var d = window._quill.clipboard.convert(run), last = d.ops[d.ops.length - 1]; if (last && (typeof last.insert !== 'string' || !/\n$/.test(last.insert))) d = d.concat(new Delta().insert('\n')); return d; } var tmp = document.createElement('div'); tmp.innerHTML = html; var combined = new Delta(); var run = ''; Array.prototype.forEach.call(tmp.childNodes, function(node) { if (node.nodeType === 1 && node.tagName === 'TABLE') { if (run) { combined = combined.concat(_runDelta(run)); run = ''; } combined = combined.concat(new Delta().insert({ rawTable: { html: node.outerHTML } })); } else { run += (node.outerHTML !== undefined ? node.outerHTML : node.textContent || ''); } }); if (run) combined = combined.concat(_runDelta(run)); return combined; }; // ── Init Quill — toolbar targets #quillToolbar directly ────── window._quill = new Quill('#quillEditor', { theme: 'snow', placeholder: 'Start writing here…', modules: { toolbar: '#quillToolbar', history: { delay: 1000, maxStack: 200 }, clipboard: { matchVisual: false }, }, }); if (window._edAttachDocToolbar) window._edAttachDocToolbar(window._quill); // Download button is in the subtabs bar (#quillDownloadBtn) var dlBtn = document.getElementById('quillDownloadBtn'); if (dlBtn) dlBtn.addEventListener('click', _downloadDocx); // ── Word count ──────────────────────────────────────────────── var _tcTimer = null; window._quill.on('text-change', function() { var words = (window._quill.getText().trim().match(/\S+/g) || []).length; var wc = document.getElementById('pvWordCount'); if (wc) wc.textContent = words; clearTimeout(_tcTimer); _tcTimer = setTimeout(_makeTablesEditable, 800); }); // ── RULER + PAGE BREAKS (Word-like, US Letter @ 96dpi) ──────────────── var RF_PPI = 96; // pixels per inch var RF_PAGE_W = 816; // 8.5" page width var RF_PAGE_H = 1056; // 11" page height var RF_MARGIN_PX = 96; // 1" margins var RF_CONTENT_PX = RF_PAGE_W - 2 * RF_MARGIN_PX; // 624px = 6.5" var RF_PAGE_LEFT = 24; // page is left-anchored 24px from container left var RF_RULER_IN = 14; // ruler spans 14" var RF_GAP_PX = 24; // visual gap between pages var rfLeftIndent = 0; // inches from left content edge var rfRightIndent = 0; // inches from right content edge // The page is now centred, so slide the ruler to sit exactly over it function _rfAlignRuler() { var ed = document.querySelector('.ql-editor'), ruler = document.getElementById('quillRuler'); var track = ruler && ruler.querySelector('.ruler-track'); if (!ed || !track) return; track.style.transform = ''; var dx = Math.round(ed.getBoundingClientRect().left - ruler.getBoundingClientRect().left - RF_PAGE_LEFT); track.style.transform = dx ? 'translateX(' + dx + 'px)' : ''; } // The ruler follows the document's own page: width and margins (imported Word files and Page setup change them) var _rfSig = ''; function _rfReadGeom() { var ed = document.querySelector('.ql-editor'); if (!ed) return ''; var g = function(n) { var v = parseFloat(ed.style.getPropertyValue(n)); return isNaN(v) ? null : v; }; var W = g('--fp-doc-page-width') || 816, mL = g('--fp-doc-margin-left'), mR = g('--fp-doc-margin-right'); RF_PAGE_W = W; RF_MARGIN_PX = mL === null ? 96 : mL; RF_CONTENT_PX = Math.max(96, W - RF_MARGIN_PX - (mR === null ? 96 : mR)); return W + ':' + RF_MARGIN_PX + ':' + RF_CONTENT_PX; } window._rfRefreshRuler = function() { var sig = _rfReadGeom(); if (sig && sig !== _rfSig) { _rfSig = sig; var old = document.getElementById('quillRuler'); if (old) { old.remove(); _initRuler(); } } _rfApplyIndent(); _rfAlignRuler(); }; window._rfApplyIndentNow = function() { _rfApplyIndent(); }; window.addEventListener('resize', function() { _rfAlignRuler(); if (window._rfApplyIndentNow) window._rfApplyIndentNow(); }); function _rfApplyIndent() { var ed = document.querySelector('.ql-editor'); if (!ed) return; // Use the loaded document's real left/right margins (set from its .docx); 1" otherwise var mL = parseFloat(ed.style.getPropertyValue('--fp-doc-margin-left')); var mR = parseFloat(ed.style.getPropertyValue('--fp-doc-margin-right')); // Phones: the text runs edge to edge (small even gutter) instead of keeping the printed page margins var narrow = window.matchMedia && window.matchMedia('(max-width: 768px)').matches; if (narrow) { mL = 14; mR = 14; } var lp = ((isNaN(mL) ? RF_MARGIN_PX : mL) + rfLeftIndent * (narrow ? 24 : RF_PPI)) + 'px'; var rp = ((isNaN(mR) ? RF_MARGIN_PX : mR) + rfRightIndent * (narrow ? 24 : RF_PPI)) + 'px'; // Use setProperty with !important so it beats the stylesheet's !important ed.style.setProperty('padding-left', lp, 'important'); ed.style.setProperty('padding-right', rp, 'important'); } function _initRuler() { _rfSig = _rfReadGeom(); var previewMode = document.getElementById('previewMode'); var editorEl = document.querySelector('.ql-container'); if (!previewMode || !editorEl) { setTimeout(_initRuler, 100); return; } if (document.getElementById('quillRuler')) return; var ruler = document.createElement('div'); ruler.id = 'quillRuler'; previewMode.insertBefore(ruler, editorEl); var track = document.createElement('div'); track.className = 'ruler-track'; ruler.appendChild(track); // Page-edge guides (gray edges of "paper" within ruler) // Left edge: from ruler 0 to page left margin // Content (white): from page left margin to right margin // Right edge: from page right margin to ruler end var wz = document.createElement('div'); wz.className = 'ruler-whitezone'; track.appendChild(wz); // Tick marks across full ruler (0 to 14"), labels at every inch var STEPS_PER_IN = 16; var totalSteps = RF_RULER_IN * STEPS_PER_IN; var contentLeftPx = RF_PAGE_LEFT + RF_MARGIN_PX; // ruler's "0" for (var i = 0; i <= totalSteps; i++) { var inchVal = i / STEPS_PER_IN; var isInch = (i % STEPS_PER_IN === 0); var isQtr = (i % (STEPS_PER_IN / 4) === 0); var isEighth = (i % (STEPS_PER_IN / 8) === 0); var posPx = contentLeftPx + inchVal * RF_PPI; // Don't draw past the right edge of ruler var mark = document.createElement('div'); mark.className = 'ruler-mark ' + (isInch ? 'in' : isQtr ? 'lg' : isEighth ? 'md' : 'sm'); mark.style.left = posPx + 'px'; track.appendChild(mark); if (isInch && i > 0) { var lbl = document.createElement('div'); lbl.className = 'ruler-num'; lbl.textContent = Math.round(inchVal); lbl.style.left = posPx + 'px'; track.appendChild(lbl); } } // Indent triangles var indL = document.createElement('div'); indL.className = 'ruler-indent-left'; indL.id = 'rulerMarginL'; indL.title = 'Left Indent (drag)'; track.appendChild(indL); var indR = document.createElement('div'); indR.className = 'ruler-indent-right'; indR.id = 'rulerMarginR'; indR.title = 'Right Indent (drag)'; track.appendChild(indR); function _placeHandles() { wz.style.left = (RF_PAGE_LEFT + RF_MARGIN_PX) + 'px'; wz.style.width = RF_CONTENT_PX + 'px'; var lPx = contentLeftPx + rfLeftIndent * RF_PPI; var rPx = contentLeftPx + RF_CONTENT_PX - rfRightIndent * RF_PPI; indL.style.left = lPx + 'px'; indR.style.left = rPx + 'px'; } function _drag(handle, isLeft) { var dragging = false, startX = 0, startVal = 0; function onStart(cx) { dragging = true; startX = cx; startVal = isLeft ? rfLeftIndent : rfRightIndent; document.body.style.cursor = 'ew-resize'; } function onMove(cx) { if (!dragging) return; var dx = (cx - startX) / RF_PPI; var v = Math.max(0, Math.min((RF_CONTENT_PX / RF_PPI) - 0.2, startVal + (isLeft ? dx : -dx))); if (isLeft) rfLeftIndent = v; else rfRightIndent = v; _placeHandles(); _rfApplyIndent(); } function onEnd() { dragging = false; document.body.style.cursor = ''; } handle.addEventListener('mousedown', function(e){ onStart(e.clientX); e.preventDefault(); e.stopPropagation(); }); document.addEventListener('mousemove', function(e){ onMove(e.clientX); }); document.addEventListener('mouseup', onEnd); handle.addEventListener('touchstart', function(e){ onStart(e.touches[0].clientX); e.preventDefault(); }, { passive: false }); document.addEventListener('touchmove', function(e){ if (dragging) { onMove(e.touches[0].clientX); e.preventDefault(); } }, { passive: false }); document.addEventListener('touchend', onEnd); } _placeHandles(); _rfApplyIndent(); _rfAlignRuler(); if (window.ResizeObserver) new ResizeObserver(_rfAlignRuler).observe(previewMode); _drag(indL, true); _drag(indR, false); // Live pagination simulation disabled for now — inserting a real // page-break div into the flow whenever content crosses a page // boundary pushes everything below it down, which is disruptive // while actively typing/dictating near that point. Turned off at // the user's request rather than continuing to soften it. // _initPageBreaks(); document.querySelectorAll('.rf-pagebreak').forEach(function(b){ b.remove(); }); } // ── REAL page splitting: inject content-editable=false page-break blocks var _rfPageObs = null; var _rfPageRunning = false; function _initPageBreaks() { var editor = document.querySelector('.ql-editor'); if (!editor) { setTimeout(_initPageBreaks, 200); return; } function _doBreaks() { if (_rfPageRunning) return; _rfPageRunning = true; if (_rfPageObs) _rfPageObs.disconnect(); try { // Use THIS document's real page height/margins if a docx set them // (field-placer.js's --fp-doc-* custom properties) instead of the // hardcoded US-Letter constants. var _rfCs = getComputedStyle(editor); var _pageHVar = parseFloat(_rfCs.getPropertyValue('--fp-doc-page-height')); var _marginTVar = parseFloat(_rfCs.getPropertyValue('--fp-doc-margin-top')); var _marginBVar = parseFloat(_rfCs.getPropertyValue('--fp-doc-margin-bottom')); var pageH = !isNaN(_pageHVar) ? _pageHVar : RF_PAGE_H; var marginTop = !isNaN(_marginTVar) ? _marginTVar : RF_MARGIN_PX; var marginBot = !isNaN(_marginBVar) ? _marginBVar : RF_MARGIN_PX; var pageStep = pageH - marginTop - marginBot; var BREAK_H = marginBot + RF_GAP_PX + marginTop; // Cheap validity check FIRST: do the existing breaks already // correctly bound the content? If so, touch nothing at all. // This was the real remaining cause of jumping even after the // dimension fix — every keystroke/dictated phrase tore down and // rebuilt ALL page breaks unconditionally, even when no page // boundary was actually crossed (the overwhelming majority of // edits). That teardown/rebuild is itself what was visible as // content jumping, independent of whether the math was correct. var checkEnd = marginTop + pageStep; var boundsOk = true; var kids0 = editor.children; for (var j = 0; j < kids0.length; j++) { var kid = kids0[j]; if (kid.classList && kid.classList.contains('rf-pagebreak')) { checkEnd = kid.offsetTop + kid.offsetHeight + pageStep; continue; } if (kid.offsetTop + kid.offsetHeight > checkEnd + 1) { boundsOk = false; break; } } if (boundsOk) { console.log('[pagebreaks] skipped — content still fits existing breaks'); return; } console.log('[pagebreaks] REBUILDING — a page boundary was actually crossed'); // Remove existing page breaks (only reached when actually stale) editor.querySelectorAll('.rf-pagebreak').forEach(function(b){ b.remove(); }); // Page 1 content area ends at this Y inside the editor var pageEnd = marginTop + pageStep; var safety = 80; // max pages while (safety-- > 0) { var crossed = null; var kids = editor.children; for (var i = 0; i < kids.length; i++) { var b = kids[i]; if (b.classList && b.classList.contains('rf-pagebreak')) continue; var top = b.offsetTop; var bot = top + b.offsetHeight; if (bot <= pageEnd) continue; crossed = b; break; } if (!crossed) break; var br = document.createElement('div'); br.className = 'rf-pagebreak'; br.setAttribute('contenteditable', 'false'); br.style.cssText = 'display:block;width:auto;height:' + BREAK_H + 'px;' + 'margin:0 -96px;padding:0;background:' + 'linear-gradient(to bottom,#ffffff 0,#ffffff ' + marginBot + 'px,' + '#808080 ' + marginBot + 'px,#808080 ' + (marginBot + RF_GAP_PX) + 'px,' + '#ffffff ' + (marginBot + RF_GAP_PX) + 'px,#ffffff 100%);' + 'user-select:none;pointer-events:none;'; editor.insertBefore(br, crossed); // Next page boundary pageEnd = br.offsetTop + br.offsetHeight + pageStep; if (pageEnd > editor.scrollHeight + pageH) break; } } finally { _rfPageRunning = false; if (_rfPageObs) { setTimeout(function(){ _rfPageObs.observe(editor, { childList: true, subtree: true, characterData: true }); }, 30); } } } _rfPageObs = new MutationObserver(function(){ clearTimeout(window._rfPageDebounce); window._rfPageDebounce = setTimeout(_doBreaks, 200); }); _rfPageObs.observe(editor, { childList: true, subtree: true, characterData: true }); _doBreaks(); window.addEventListener('resize', function(){ clearTimeout(window._rfPageDebounce); window._rfPageDebounce = setTimeout(_doBreaks, 200); }); } setTimeout(_initRuler, 150); // ═══════════════════════════════════════════════════════════════ // CONTEXT MENU — right-click on editor (text, image, table) // ═══════════════════════════════════════════════════════════════ var _selImg = null; // currently selected image var _ctxMenu = null; // active context menu element var _longPressTimer = null; // ── Close context menu ────────────────────────────────────── function _closeCtx() { if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; } } document.addEventListener('click', function(e) { if (_ctxMenu && !_ctxMenu.contains(e.target)) _closeCtx(); }); document.addEventListener('keydown', function(e) { if (e.key === 'Escape') _closeCtx(); }); // ── Build and show context menu ───────────────────────────── var _ctxX = 0, _ctxY = 0; var _ctxTbl = null; // the table a right-click happened in (its own structural undo comes first) function _urItems() { var q = window._quill, t = _ctxTbl; _ctxTbl = null; var hasU = !!(t && t._u && t._u.length) || !!(q && q.history && q.history.stack && q.history.stack.undo.length) || !!t; var hasR = !!(t && t._r && t._r.length) || !!(q && q.history && q.history.stack && q.history.stack.redo.length) || !!t; return [ ['', 'Undo', false, null, function(){ if (t && _tblUndo(t)) return; if (t && document.queryCommandEnabled && document.queryCommandEnabled('undo')) { document.execCommand('undo'); return; } q && q.history.undo(); }, 'Ctrl+Z', !hasU], ['', 'Redo', false, null, function(){ if (t && _tblRedo(t)) return; if (t && document.queryCommandEnabled && document.queryCommandEnabled('redo')) { document.execCommand('redo'); return; } q && q.history.redo(); }, 'Ctrl+Y', !hasR], null ]; } function _showCtx(x, y, items, opts) { _ctxX = x; _ctxY = y; _closeCtx(); if (opts && opts.undo) items = _urItems().concat(items); else _ctxTbl = null; _ctxMenu = document.createElement('div'); _ctxMenu.className = 'ed-ctx-menu'; _ctxMenu.addEventListener('mousedown', function(e) { e.preventDefault(); }); // keeps the cursor where it is, so Undo / Cut / Copy act on the right place _ctxMenu.style.left = Math.min(x, window.innerWidth - 220) + 'px'; _ctxMenu.style.top = Math.min(y, window.innerHeight - 300) + 'px'; items.forEach(function(item) { if (item === null) { var sep = document.createElement('div'); sep.className = 'ed-ctx-sep'; _ctxMenu.appendChild(sep); return; } var btn = document.createElement('button'); btn.className = 'ed-ctx-item' + (item[2] ? ' ed-ctx-danger' : '') + (item[6] ? ' ed-ctx-dis' : ''); btn.innerHTML = '' + item[0] + '' + item[1] + '' + (item[5] ? '' + item[5] + '' : ''); if (item[6]) { btn.disabled = true; } else if (item[3]) { // Submenu arrow btn.innerHTML += '▶'; btn.addEventListener('click', function(e) { e.stopPropagation(); _showCtxSub(btn, item[3]); }); } else { btn.addEventListener('click', function() { _closeCtx(); item[4] && item[4](); }); } _ctxMenu.appendChild(btn); }); document.body.appendChild(_ctxMenu); } function _showCtxSub(parentBtn, items) { document.querySelectorAll('.ed-ctx-sub').forEach(function(s){ s.remove(); }); var sub = document.createElement('div'); sub.className = 'ed-ctx-menu ed-ctx-sub'; var pr = parentBtn.getBoundingClientRect(); sub.style.left = (pr.right + 4) + 'px'; sub.style.top = pr.top + 'px'; items.forEach(function(item) { var btn = document.createElement('button'); btn.className = 'ed-ctx-item'; btn.innerHTML = '' + item[0] + '' + item[1] + ''; btn.addEventListener('click', function() { _closeCtx(); item[2] && item[2](); }); sub.appendChild(btn); }); document.body.appendChild(sub); } // ── Image: select + corner resize handles ─────────────────── function _selectImg(img) { _deselectImg(); _selImg = img; img.classList.add('ed-img-selected'); _attachResizeHandles(img); } function _deselectImg() { if (_selImg) { _selImg.classList.remove('ed-img-selected'); document.querySelectorAll('.ed-resize-handle,.ed-resize-overlay').forEach(function(h){ h.remove(); }); if (window.EdImageBox) window.EdImageBox.hide(); _selImg = null; } } function _attachResizeHandles(img) { if (window.EdImageBox) { window.EdImageBox.show(img); return; } // Word-style selection box (image-box.js) document.querySelectorAll('.ed-resize-handle,.ed-resize-overlay').forEach(function(h){ h.remove(); }); var overlay = document.createElement('div'); overlay.className = 'ed-resize-overlay'; // Position overlay over the image. Measured against the editor root // (made the positioning context below) rather than assuming the // image and overlay happen to share an offsetParent — that // assumption breaks whenever an ancestor becomes positioned. // The overlay lives on , NOT inside Quill's editor: adding a foreign node to the editor // makes Quill try to adopt it and throw "Cannot read properties of undefined (reading 'emit')". overlay.style.position = 'fixed'; var _dragging = false; function _positionOverlay() { if (!img.isConnected) { overlay.remove(); return; } var ir = img.getBoundingClientRect(); overlay.style.left = ir.left + 'px'; overlay.style.top = ir.top + 'px'; if (!_dragging) { overlay.style.width = ir.width + 'px'; overlay.style.height = ir.height + 'px'; } } document.body.appendChild(overlay); _positionOverlay(); (function _track() { if (!overlay.isConnected) return; _positionOverlay(); requestAnimationFrame(_track); })(); // 4 corner handles: nw, ne, se, sw var corners = [ { cls:'nw', cursor:'nw-resize', dx:-1, dy:-1 }, { cls:'ne', cursor:'ne-resize', dx: 1, dy:-1 }, { cls:'se', cursor:'se-resize', dx: 1, dy: 1 }, { cls:'sw', cursor:'sw-resize', dx:-1, dy: 1 }, ]; corners.forEach(function(c) { var h = document.createElement('div'); h.className = 'ed-resize-handle ed-resize-' + c.cls; overlay.appendChild(h); var startX, startY, startW, startH, aspect; function onDown(ex, ey) { startX = ex; startY = ey; startW = img.offsetWidth; startH = img.offsetHeight; aspect = startW / (startH || 1); } function onMove(ex, ey) { var dw = (ex - startX) * c.dx; var newW = Math.max(40, startW + dw); var newH = Math.round(newW / aspect); img.style.width = newW + 'px'; img.style.height = newH + 'px'; overlay.style.width = newW + 'px'; overlay.style.height = newH + 'px'; } // Mouse h.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); onDown(e.clientX, e.clientY); function mv(e){ onMove(e.clientX, e.clientY); } function up(){ document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up); } document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); // Touch h.addEventListener('touchstart', function(e) { e.preventDefault(); e.stopPropagation(); onDown(e.touches[0].clientX, e.touches[0].clientY); function mv(e){ onMove(e.touches[0].clientX, e.touches[0].clientY); } function up(){ document.removeEventListener('touchmove',mv); document.removeEventListener('touchend',up); } document.addEventListener('touchmove', mv, {passive:false}); document.addEventListener('touchend', up); }, {passive:false}); }); } // ── Image context menu items ──────────────────────────────── function _imgCtxItems(img) { return [ ['🔍', 'Zoom in (125%)', false, null, function(){ img.style.width=Math.round((img.offsetWidth||200)*1.25)+'px'; img.style.height='auto'; }], ['🔎', 'Zoom out (75%)', false, null, function(){ img.style.width=Math.max(40,Math.round((img.offsetWidth||200)*0.75))+'px'; img.style.height='auto'; }], ['📐', 'Original size', false, null, function(){ img.style.width=''; img.style.height=''; }], null, ['⬅', 'Align left', false, null, function(){ img.style.float='left'; img.style.marginRight='10px'; img.parentElement.style.textAlign=''; }], ['⬛', 'Align center', false, null, function(){ img.style.float=''; img.parentElement.style.textAlign='center'; }], ['➡', 'Align right', false, null, function(){ img.style.float='right'; img.style.marginLeft='10px'; img.parentElement.style.textAlign=''; }], null, ['✏', 'Annotate (draw)', false, null, function(){ _openAnnotator(img); }], null, ['🗑', 'Delete image', true, null, function(){ _deselectImg(); img.remove(); window._quill && window._quill.update(); }], ]; } // ── Text context menu items — lean, essentials only ──────── function _textCtxItems() { var q = window._quill; var range = q ? q.getSelection() : null; return [ ['✂', 'Cut', false, null, function(){ document.execCommand('cut'); }], ['📋', 'Copy', false, null, function(){ document.execCommand('copy'); }], ['📄', 'Paste', false, null, function(){ document.execCommand('paste'); }], null, ['B', 'Bold', false, null, function(){ q && q.format('bold', !q.getFormat().bold); }], ['I', 'Italic', false, null, function(){ q && q.format('italic', !q.getFormat().italic); }], null, ['•', 'Bullet list', false, null, function(){ q && q.format('list', 'bullet'); }], ['1.', 'Numbered list', false, null, function(){ q && q.format('list', 'ordered'); }], null, ['⊞', 'Insert table', false, null, function(){ _showTablePicker(_ctxX, _ctxY); }], ['🗑', 'Clear formatting', true, null, function(){ if(q && range) q.removeFormat(range.index, range.length); }], ]; } // ── Right-click handler ───────────────────────────────────── window._quill.root.addEventListener('contextmenu', function(e) { e.preventDefault(); var target = e.target; if (target.tagName === 'IMG') { _selectImg(target); _showCtx(e.clientX, e.clientY, _imgCtxItems(target), { undo: true }); } else if (target.closest('td, th')) { // Table cell — handled by table contextmenu listener already return; } else { _showCtx(e.clientX, e.clientY, _textCtxItems(), { undo: true }); } }); // ── Image click to select / deselect ─────────────────────── // Uses closest('img') rather than an exact tagName check, and listens // on mousedown as well as click: a plain tagName==='IMG' click test // misses cases where the image is wrapped, or where something above // it swallows the click before it lands. Capture phase so nothing // else can stop it first. function _handleImgPointer(e) { var img = e.target && e.target.closest ? e.target.closest('img') : null; if (img && window._quill.root.contains(img)) { e.preventDefault(); e.stopPropagation(); console.log('[img] selected — corner handles should now be visible'); _selectImg(img); } else if (!e.target.closest('.eib-ui,.ed-resize-overlay,.ed-resize-handle')) { _deselectImg(); } } window._quill.root.addEventListener('mousedown', _handleImgPointer, true); window._quill.root.addEventListener('click', _handleImgPointer, true); // ── Mobile: long-press to open context menu ───────────────── window._quill.root.addEventListener('touchstart', function(e) { var t = e.target; _longPressTimer = setTimeout(function() { var touch = e.touches[0] || e.changedTouches[0]; if (!touch) return; if (t.tagName === 'IMG') { _selectImg(t); _showCtx(touch.clientX, touch.clientY, _imgCtxItems(t)); } else if (t.closest('td,th')) { var cell = t.closest('td,th'); var table = cell.closest('table'); if (table) _showTblMenu(touch.clientX, touch.clientY, table, cell); } else { _showCtx(touch.clientX, touch.clientY, _textCtxItems()); } }, 600); // 600ms long press }, {passive:true}); window._quill.root.addEventListener('touchend', function(){ clearTimeout(_longPressTimer); }, {passive:true}); window._quill.root.addEventListener('touchmove', function(){ clearTimeout(_longPressTimer); }, {passive:true}); window._quill.root.addEventListener('touchcancel',function(){ clearTimeout(_longPressTimer); }, {passive:true}); // Deselect image when clicking outside editor document.addEventListener('click', function(e) { if (!window._quill.root.contains(e.target) && !e.target.closest('.eib-ui,.ed-resize-overlay,.ed-resize-handle,.ed-ctx-menu')) { _deselectImg(); } }); // ── Image helper shims (kept for backward compat) ────────── window._imgResize = function(f) { if (_selImg) { _selImg.style.width=Math.round((_selImg.offsetWidth||200)*f)+'px'; _selImg.style.height='auto'; } }; window._imgAlign = function(a) { if (!_selImg) return; var p = _selImg.parentElement; if (a==='center') { p.style.textAlign='center'; _selImg.style.float=''; } else if (a==='left') { _selImg.style.float='left'; _selImg.style.marginRight='10px'; } else if (a==='right') { _selImg.style.float='right'; _selImg.style.marginLeft='10px'; } }; window._imgRemove = function() { if (_selImg) { _deselectImg(); _selImg && _selImg.remove(); } }; // ── Insert blank table ────────────────────────────────────── function _insertTable(rows, cols) { var q = window._quill; if (!q || !window.EchoSafe) return; var html = window.EchoSafe.blankTable(rows || 3, cols || 3); var range = q.getSelection(true); var index = range ? range.index : Math.max(0, q.getLength() - 1); var Delta = Quill.import('delta'); q.updateContents(new Delta().retain(index).delete(range ? range.length : 0).insert({ rawTable: { html: html } }), 'user'); try { q.setSelection(index + 1, 0); } catch (e) {} setTimeout(_makeTablesEditable, 50); } window._insertTable = _insertTable; // ── TABLE SIZE PICKER (shows when Insert Table is clicked) ── // Actually we expose a simple picker dialog window._showTablePicker = function(x, y) { _closeCtx(); var picker = document.createElement('div'); picker.className = 'ed-table-picker'; picker.style.left = Math.min(x, window.innerWidth-220)+'px'; picker.style.top = Math.min(y, window.innerHeight-220)+'px'; var MAX = 6; var selectedR = 3, selectedC = 3; function _build() { picker.innerHTML = '
Insert Table
'+selectedR+' × '+selectedC+'
'; var grid = picker.querySelector('.ed-tp-grid'); for (var r=1;r<=MAX;r++) { for (var c=1;c<=MAX;c++) { (function(row,col){ var cell = document.createElement('div'); cell.className = 'ed-tp-cell' + (row<=selectedR&&col<=selectedC?' active':''); cell.addEventListener('mouseenter', function(){ selectedR=row; selectedC=col; _build(); }); grid.appendChild(cell); })(r,c); } } picker.querySelector('.ed-tp-btn').onclick = function() { picker.remove(); _insertTable(selectedR, selectedC); }; } _build(); document.body.appendChild(picker); setTimeout(function(){ document.addEventListener('click', function off(e){ if(!picker.contains(e.target)){ picker.remove(); document.removeEventListener('click',off); } }); }, 10); }; // ═══════════════════════════════════════════════════════════════ // IMAGE ANNOTATION — draw arrows, circles, freehand on images // ═══════════════════════════════════════════════════════════════ function _openAnnotator(img) { var modal = document.getElementById('imgAnnotModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'imgAnnotModal'; modal.className = 'img-annot-backdrop'; modal.innerHTML = '
' + '
' + 'Annotate Image' + '
' + '' + '' + '' + '' + '' + '
' + '
' + '' + '' + '' + '' + '' + '
' + '' + '' + '' + '
' + '' + '' + '
' + '
' + '
'; document.body.appendChild(modal); } var canvas = document.getElementById('imgAnnotCanvas'); var ctx = canvas.getContext('2d'); var tool = 'pen', color = '#e53e3e', size = 3; var history = []; var drawing = false, startX, startY, snap; var MAX_HIST = 15; var _annImg = img; // Tool & color buttons modal.querySelectorAll('.img-annot-tool').forEach(function(btn){ btn.onclick = function(){ tool = btn.dataset.tool; modal.querySelectorAll('.img-annot-tool').forEach(function(b){ b.classList.remove('active'); }); btn.classList.add('active'); }; }); modal.querySelectorAll('.img-annot-color').forEach(function(btn){ btn.onclick = function(){ color = btn.dataset.color; modal.querySelectorAll('.img-annot-color').forEach(function(b){ b.classList.remove('active'); }); btn.classList.add('active'); }; }); document.getElementById('imgAnnotSize').oninput = function(){ size = parseInt(this.value); }; document.getElementById('imgAnnotUndo').onclick = function(){ if (history.length > 1) { history.pop(); ctx.putImageData(history[history.length-1],0,0); } }; document.getElementById('imgAnnotClear').onclick = function(){ if (history.length) { ctx.putImageData(history[0],0,0); history=[history[0]]; } }; document.getElementById('imgAnnotSave').onclick = function(){ var data = canvas.toDataURL('image/png'); _annImg.src = data; _annImg.style.width = _annImg.offsetWidth + 'px'; _annImg.style.height = _annImg.offsetHeight + 'px'; modal.style.display = 'none'; window._quill && window._quill.update(); window.pvShowToast && window.pvShowToast('Annotation saved', 'success'); }; document.getElementById('imgAnnotCancel').onclick = function(){ modal.style.display='none'; }; modal.onclick = function(e){ if(e.target===modal) modal.style.display='none'; }; // Load image onto canvas modal.style.display = 'flex'; tool='pen'; color='#e53e3e'; size=3; modal.querySelectorAll('.img-annot-tool').forEach(function(b){ b.classList.toggle('active',b.dataset.tool==='pen'); }); modal.querySelectorAll('.img-annot-color').forEach(function(b){ b.classList.toggle('active',b.dataset.color==='#e53e3e'); }); var sizeEl = document.getElementById('imgAnnotSize'); if (sizeEl) sizeEl.value=3; var srcImg = new Image(); srcImg.crossOrigin = 'anonymous'; srcImg.onload = function(){ var maxW=800, maxH=580; var scale=Math.min(maxW/srcImg.naturalWidth, maxH/srcImg.naturalHeight, 1); canvas.width = Math.round(srcImg.naturalWidth * scale); canvas.height = Math.round(srcImg.naturalHeight * scale); ctx.drawImage(srcImg, 0, 0, canvas.width, canvas.height); history = [ctx.getImageData(0,0,canvas.width,canvas.height)]; }; srcImg.src = img.src; // Drawing helpers function getPos(e) { var r=canvas.getBoundingClientRect(); var sx=canvas.width/r.width, sy=canvas.height/r.height; var src=e.touches?e.touches[0]:e; return {x:(src.clientX-r.left)*sx, y:(src.clientY-r.top)*sy}; } function drawArrow(x1,y1,x2,y2) { var hl=Math.max(12,size*4), ang=Math.atan2(y2-y1,x2-x1); ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.lineTo(x2-hl*Math.cos(ang-Math.PI/6),y2-hl*Math.sin(ang-Math.PI/6)); ctx.moveTo(x2,y2); ctx.lineTo(x2-hl*Math.cos(ang+Math.PI/6),y2-hl*Math.sin(ang+Math.PI/6)); ctx.stroke(); } function drawCircle(x1,y1,x2,y2) { var rx=Math.abs(x2-x1)/2||1, ry=Math.abs(y2-y1)/2||1; ctx.beginPath(); ctx.ellipse((x1+x2)/2,(y1+y2)/2,rx,ry,0,0,2*Math.PI); ctx.stroke(); } function drawRect(x1,y1,x2,y2) { ctx.beginPath(); ctx.strokeRect(Math.min(x1,x2),Math.min(y1,y2),Math.abs(x2-x1),Math.abs(y2-y1)); } async function addText(x,y){ var txt = await edPrompt('Enter annotation text:',''); if (!txt) return; ctx.font = (size*5+10)+'px Arial,sans-serif'; ctx.fillStyle = color; ctx.fillText(txt, x, y); history.push(ctx.getImageData(0,0,canvas.width,canvas.height)); if (history.length>MAX_HIST) history.shift(); } // Remove old listeners by cloning canvas var nc = canvas.cloneNode(false); canvas.parentNode.replaceChild(nc, canvas); canvas = nc; ctx = canvas.getContext('2d'); if (history.length) ctx.putImageData(history[history.length-1],0,0); function onDown(e){ e.preventDefault(); drawing=true; var p=getPos(e); startX=p.x; startY=p.y; snap=ctx.getImageData(0,0,canvas.width,canvas.height); if (tool==='pen') { ctx.beginPath(); ctx.moveTo(p.x,p.y); } if (tool==='text') { drawing=false; addText(p.x,p.y); } } function onMove(e){ e.preventDefault(); if (!drawing) return; var p=getPos(e); ctx.strokeStyle=color; ctx.lineWidth=size; ctx.lineCap='round'; ctx.lineJoin='round'; if (tool==='pen') { ctx.lineTo(p.x,p.y); ctx.stroke(); } else { ctx.putImageData(snap,0,0); if (tool==='arrow') drawArrow(startX,startY,p.x,p.y); if (tool==='circle') drawCircle(startX,startY,p.x,p.y); if (tool==='rect') drawRect(startX,startY,p.x,p.y); } } function onUp(){ if (!drawing) return; drawing=false; history.push(ctx.getImageData(0,0,canvas.width,canvas.height)); if (history.length>MAX_HIST) history.shift(); } canvas.addEventListener('mousedown', onDown); canvas.addEventListener('mousemove', onMove); canvas.addEventListener('mouseup', onUp); canvas.addEventListener('mouseleave',onUp); canvas.addEventListener('touchstart',onDown,{passive:false}); canvas.addEventListener('touchmove', onMove,{passive:false}); canvas.addEventListener('touchend', onUp); // also save canvas ref on modal for re-open modal._canvas = canvas; } // ── TABLE EDITING ───────────────────────────────────────────── // Removes a table properly. Tables now live inside a registered // rawTable blot, so a raw table.remove() would leave an empty blot // behind and put Quill's internal model out of sync with the DOM — // the exact failure mode behind the "document disappears on Enter" // bug. Deleting through Quill's own API keeps the model correct. function _deleteTable(table) { var wrap = table.closest('.ql-raw-table-embed'); if (wrap && window.Quill && Quill.find) { var blot = Quill.find(wrap); if (blot) { var idx = window._quill.getIndex(blot); window._quill.deleteText(idx, blot.length(), 'user'); return; } } (wrap || table).remove(); window._quill && window._quill.update(); } function _makeTablesEditable() { window._quill.root.querySelectorAll('table').forEach(function(table) { if (table.dataset.ed) return; table.dataset.ed = '1'; // The blot wrapper must stay non-editable so Quill treats the // table as one opaque unit, while the cells inside are explicitly // editable — the standard nested-contenteditable pattern. Without // this, typing inside a cell can be reverted by Quill. var wrap = table.closest('.ql-raw-table-embed'); if (wrap) wrap.setAttribute('contenteditable', 'false'); // Whole-table resize handle (bottom-right corner), Word-style. // NOTE: this goes on the blot WRAPPER, never inside
— a //
as a direct child of
is invalid HTML and browsers // render/position it unpredictably, which is why the handle was // unresponsive. var gripHost = wrap || table.parentElement; if (gripHost && !gripHost.querySelector('.ed-tbl-resize')) { gripHost.style.position = 'relative'; gripHost.style.display = 'block'; var grip = document.createElement('div'); grip.className = 'ed-tbl-resize'; grip.contentEditable = 'false'; grip.title = 'Drag to resize table'; gripHost.appendChild(grip); grip.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); var startX = e.clientX, startW = table.offsetWidth; var mv = function(ev) { var w = Math.max(120, startW + (ev.clientX - startX)); table.style.width = w + 'px'; }; var up = function() { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); } table.querySelectorAll('td,th').forEach(function(cell) { cell.contentEditable = 'true'; // Column resize var cr = document.createElement('div'); cr.setAttribute('data-ed-chrome', '1'); cr.style.cssText = 'position:absolute;right:-3px;top:0;bottom:0;width:6px;cursor:col-resize;z-index:5;'; cell.appendChild(cr); var sx, sw; cr.addEventListener('mousedown', function(e) { e.preventDefault(); sx=e.clientX; sw=cell.offsetWidth; var ci = Array.from(cell.parentElement.children).indexOf(cell); var mv=function(e){ var w=Math.max(40,sw+(e.clientX-sx)); cell.style.width=w+'px'; table.querySelectorAll('tr').forEach(function(r){ if(r.children[ci]) r.children[ci].style.width=w+'px'; }); }; var up=function(){ document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up); }; document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up); }); }); // Row resize table.querySelectorAll('tr').forEach(function(row) { var first = row.cells[0]; if (!first) return; var rr = document.createElement('div'); rr.setAttribute('data-ed-chrome', '1'); rr.style.cssText = 'position:absolute;bottom:-3px;left:0;right:0;height:6px;cursor:row-resize;z-index:5;'; first.appendChild(rr); var sy, sh; rr.addEventListener('mousedown', function(e) { e.preventDefault(); sy=e.clientY; sh=row.offsetHeight; var mv=function(e){ var h=Math.max(20,sh+(e.clientY-sy)); row.querySelectorAll('td,th').forEach(function(c){c.style.height=h+'px';}); }; var up=function(){ document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up); }; document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up); }); }); // Right-click menu if (!table._ctxBound) { table._ctxBound = true; table.addEventListener('contextmenu', function(e) { var cell = e.target.closest('td,th'); if (!cell) return; e.preventDefault(); _showTblMenu(e.clientX, e.clientY, table, cell); }); } }); } window._makeTablesEditableNow = _makeTablesEditable; // The editor's HTML as it should be saved or exported: no resize handles, no edit flags window._editorHtml = function(q) { q = q || window._quill; if (!q) return ''; var clone = q.root.cloneNode(true); clone.querySelectorAll('.ed-tbl-resize,[data-ed-chrome]').forEach(function(n){ n.remove(); }); clone.querySelectorAll('[contenteditable]').forEach(function(n){ n.removeAttribute('contenteditable'); }); clone.querySelectorAll('.ql-raw-table-embed').forEach(function(n){ n.removeAttribute('style'); }); // wrapper gets position:relative while editing clone.querySelectorAll('table').forEach(function(t) { t.removeAttribute('data-ed'); t.querySelectorAll('td,th').forEach(function(c){ c.style.removeProperty('min-width'); c.style.removeProperty('position'); }); }); // outline numbering (1 / 1.1 / 1.2.1) → real numbers written into the text so Word and PDF show it too clone.querySelectorAll('ol').forEach(function(ol) { var cnt = []; Array.prototype.forEach.call(ol.children, function(li) { if (li.tagName !== 'LI') return; var lv = parseInt((/ql-indent-(\d+)/.exec(li.className) || [0, 0])[1], 10) || 0; if (!/ql-liststyle-outline/.test(li.className)) { cnt = []; return; } cnt.length = lv + 1; for (var i = 0; i <= lv; i++) cnt[i] = cnt[i] || (i < lv ? 1 : 0); cnt[lv]++; var num = cnt.slice(0, lv + 1).join('.') + (lv === 0 ? '.' : ''); li.style.listStyleType = 'none'; li.insertAdjacentHTML('afterbegin', '' + num + '  '); }); }); // numbering style (a. / i. / I.) → plain CSS so Word and PDF show it too clone.querySelectorAll('ol').forEach(function(ol) { var li = ol.querySelector('li[class*="ql-liststyle-"]:not(.ql-liststyle-outline)'); if (!li) return; var m = /ql-liststyle-([a-z-]+)/.exec(li.className); if (m) ol.style.listStyleType = m[1].replace('alpha', 'alpha'); }); return clone.innerHTML; }; // Table context menu — reuses unified _showCtx function _showTblMenu(x, y, table, cell) { var ci = Array.from(cell.parentElement.children).indexOf(cell); _ctxTbl = table; _showCtx(x, y, [ ['➕', 'Add row above', false, null, function(){ _addRow(table, cell, 'before'); }], ['➕', 'Add row below', false, null, function(){ _addRow(table, cell, 'after'); }], ['➕', 'Add col left', false, null, function(){ _addCol(table, ci, 'before'); }], ['➕', 'Add col right', false, null, function(){ _addCol(table, ci, 'after'); }], null, ['🗑', 'Delete row', true, null, function(){ if(table.rows.length > 1) { _tblSnap(table); cell.closest('tr').remove(); } }], ['🗑', 'Delete column', true, null, function(){ if(table.rows[0].cells.length > 1) { _tblSnap(table); Array.from(table.rows).forEach(function(r){ if(r.cells[ci]) r.cells[ci].remove(); }); } }], null, ['↔', 'Fit table to page width', false, null, function(){ _tblSnap(table); table.style.width = '100%'; table.style.tableLayout = 'fixed'; table.querySelectorAll('td,th').forEach(function(c){ c.style.width = ''; }); }], ['⤢', 'Reset column widths', false, null, function(){ _tblSnap(table); table.style.tableLayout = 'auto'; table.querySelectorAll('td,th').forEach(function(c){ c.style.width = ''; }); table.querySelectorAll('tr').forEach(function(r){ r.querySelectorAll('td,th').forEach(function(c){ c.style.height = ''; }); }); }], null, ['🗑', 'Delete entire table', true, null, async function(){ if(await edConfirm('Delete this entire table?')){ _deleteTable(table); } }], ], { undo: true }); } // Undo / redo for the structure of one table (rows, columns, widths). Typing inside a cell uses the browser's own undo. function _tblClean(table) { var c = table.cloneNode(true); c.querySelectorAll('[data-ed-chrome]').forEach(function(n){ n.remove(); }); c.querySelectorAll('[contenteditable]').forEach(function(n){ n.removeAttribute('contenteditable'); }); return { html: c.innerHTML, style: table.getAttribute('style') || '' }; } function _tblSnap(table) { (table._u = table._u || []).push(_tblClean(table)); if (table._u.length > 40) table._u.shift(); table._r = []; } function _tblRestore(table, st) { table.innerHTML = st.html; if (st.style) table.setAttribute('style', st.style); else table.removeAttribute('style'); table.removeAttribute('data-ed'); _makeTablesEditable(); window._quill && window._quill.update(); } function _tblUndo(table) { if (!table._u || !table._u.length) return false; (table._r = table._r || []).push(_tblClean(table)); _tblRestore(table, table._u.pop()); return true; } function _tblRedo(table) { if (!table._r || !table._r.length) return false; (table._u = table._u || []).push(_tblClean(table)); _tblRestore(table, table._r.pop()); return true; } function _addRow(table,cell,where) { _tblSnap(table); var row=cell.closest('tr'); var n=document.createElement('tr'); for(var i=0;i h1:fresh","p[style-name='Heading 2'] => h2:fresh","p[style-name='Heading 3'] => h3:fresh"] }); var html = res.value || '

'; var wasEmpty = window._quill.getText().trim().length === 0; if (wasEmpty && window._fpSafeSetEditorHtml) { window._fpSafeSetEditorHtml(window._quill, html); if (res.style && window._fpApplyDocStyleVars) window._fpApplyDocStyleVars(window._quill.root, res.style); if (res.rawVars && window._fpApplyRawDocStyleVars) window._fpApplyRawDocStyleVars(window._quill.root, res.rawVars); if (window._rfRefreshRuler) window._rfRefreshRuler(); } else { window._quill.clipboard.dangerouslyPasteHTML(0, html); window._quill.setSelection(window._quill.getLength(), 0); } window._importedDocxName = file.name; window.pvShowToast && window.pvShowToast('\u2705 ' + file.name + ' imported', 'success'); setTimeout(_makeTablesEditable, 200); } catch(e) { window.pvShowToast && window.pvShowToast('Import failed: ' + e.message, 'error'); } return; } window.pvShowToast && window.pvShowToast('Supported: .docx .txt', 'warning'); }; // ── DOWNLOAD ────────────────────────────────────────────────── // Builds the .docx for the current document (asks about tracked changes first). Resolves to a Blob, or null if cancelled / empty. window._editorDocxBlob = function() { var content = window._editorHtml(); var text = window._quill.getText().trim(); if (!text || text.length === 0) { window.pvShowToast && window.pvShowToast('The document is empty — add some content first', 'warning'); return Promise.resolve(null); } if (typeof htmlDocx === 'undefined') { window.pvShowToast && window.pvShowToast('Download library not loaded', 'error'); return Promise.resolve(null); } var pre = (window.EchoTC && window.EchoTC.hasChanges()) ? window.EchoTC.exportChoice(content) : Promise.resolve(content); return pre.then(function(c) { if (c == null) return null; // Uses the loaded document's own fonts, margins and page size (generic Word look otherwise) return window.EchoDocx ? window.EchoDocx.exportBlob(window._quill, c) : htmlDocx.asBlob('' + c + ''); }); }; function _downloadDocx() { var name = (window._importedDocxName || 'document').replace(/\.docx?$/i,'') + '_edited.docx'; window._editorDocxBlob().then(function(blob) { if (!blob) return; var a = document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=name; a.click(); URL.revokeObjectURL(a.href); window.pvShowToast && window.pvShowToast('\u2705 Downloaded: ' + name, 'success'); }).catch(function(e) { window.pvShowToast && window.pvShowToast('Download failed: ' + e.message, 'error'); }); } window._downloadEditorAsPdf = function() { var text = window._quill ? window._quill.getText().trim() : ''; if (!text || text.length === 0) { window.pvShowToast && window.pvShowToast('The document is empty — add some content first', 'warning'); return; } if (typeof window.jspdf === 'undefined' || typeof html2canvas === 'undefined') { window.pvShowToast && window.pvShowToast('PDF library not loaded yet — please try again in a moment', 'warning'); return; } var btn = document.getElementById('quillDownloadPdfBtn'); if (btn) { btn.disabled = true; btn.textContent = '⏳ Generating…'; } window.pvShowToast && window.pvShowToast('Generating PDF…', 'info'); var A4_PX_W = 794; var MARGIN = 60; var wrapper = document.createElement('div'); wrapper.style.cssText = [ 'position:fixed', 'top:0', 'left:0', 'width:' + A4_PX_W + 'px', 'background:#ffffff', 'padding:' + MARGIN + 'px', 'box-sizing:border-box', 'font-family:Arial,Helvetica,sans-serif', 'font-size:11pt', 'line-height:1.65', 'color:#1a1a1a', 'z-index:-1', 'pointer-events:none' ].join(';'); var inner = document.createElement('div'); inner.innerHTML = window._editorHtml(); var styleEl = document.createElement('style'); styleEl.textContent = [ 'h1{font-size:20pt;color:#2B6B8C;margin:0 0 10px;font-weight:800;line-height:1.2;}', 'h2{font-size:14pt;color:#2B6B8C;margin:18px 0 6px;font-weight:700;border-bottom:2px solid #c7d8e2;padding-bottom:5px;}', 'h3{font-size:12pt;color:#1a5070;margin:14px 0 4px;font-weight:700;}', 'h4,h5,h6{font-size:11pt;color:#1a5070;margin:10px 0 3px;font-weight:700;}', 'p{margin:0 0 9px;}', 'ul,ol{margin:0 0 9px;padding-left:22px;}', 'li{margin-bottom:4px;}', 'table{border-collapse:collapse;width:100%;margin:12px 0;font-size:10pt;}', 'td,th{border:1px solid #9bb3bf;padding:7px 10px;vertical-align:top;}', 'th{background:#E8EEF3;font-weight:700;color:#2B6B8C;}', 'tr:nth-child(even) td{background:#f7fafc;}', 'img{max-width:100%;height:auto;display:block;margin:8px 0;border-radius:3px;}', 'strong,b{font-weight:700;}', 'em,i{font-style:italic;}', 'a{color:#2B6B8C;}', 'blockquote{border-left:3px solid #2B6B8C;margin:10px 0;padding:6px 14px;color:#444;background:#f4f8fb;}', 'pre,code{font-family:Consolas,monospace;font-size:9.5pt;background:#f4f6f8;padding:2px 5px;border-radius:3px;}', 'hr{border:none;border-top:1.5px solid #c7d8e2;margin:14px 0;}' ].join(''); wrapper.appendChild(styleEl); wrapper.appendChild(inner); document.body.appendChild(wrapper); var fileName = (window._importedDocxName || 'report').replace(/\.docx?$/i,'') + '.pdf'; html2canvas(wrapper, { scale: 2, useCORS: true, backgroundColor: '#ffffff', logging: false, windowWidth: A4_PX_W }).then(function(canvas) { document.body.removeChild(wrapper); var { jsPDF } = window.jspdf; var pdf = new jsPDF({ unit: 'mm', format: 'a4', orientation: 'portrait' }); var PAGE_W_MM = 210; var PAGE_H_MM = 297; var MARGIN_MM = 15; var CONTENT_W = PAGE_W_MM - MARGIN_MM * 2; var imgW = canvas.width; var imgH = canvas.height; var scale = CONTENT_W / (imgW * 0.2646); var totalH_mm = imgH * 0.2646 * scale; var contentH_mm = PAGE_H_MM - MARGIN_MM * 2; var imgData = canvas.toDataURL('image/jpeg', 0.97); var pages = Math.ceil(totalH_mm / contentH_mm); for (var i = 0; i < pages; i++) { if (i > 0) pdf.addPage(); var srcY = i * contentH_mm / (0.2646 * scale); var sliceH = Math.min(contentH_mm / (0.2646 * scale), imgH - srcY); var sliceCanvas = document.createElement('canvas'); sliceCanvas.width = imgW; sliceCanvas.height = Math.ceil(sliceH); var ctx = sliceCanvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, sliceCanvas.width, sliceCanvas.height); ctx.drawImage(canvas, 0, srcY, imgW, sliceH, 0, 0, imgW, sliceH); var sliceData = sliceCanvas.toDataURL('image/jpeg', 0.97); var sliceH_mm = sliceH * 0.2646 * scale; pdf.addImage(sliceData, 'JPEG', MARGIN_MM, MARGIN_MM, CONTENT_W, sliceH_mm, '', 'FAST'); } pdf.save(fileName); window.pvShowToast && window.pvShowToast('Downloaded: ' + fileName, 'success'); }).catch(function(err) { document.body.removeChild(wrapper); console.error('PDF generation error:', err); window.pvShowToast && window.pvShowToast('PDF generation failed: ' + err.message, 'error'); }).finally(function() { if (btn) { btn.disabled = false; btn.textContent = '↓ .pdf'; } }); }; // ── PDF that matches the editor page for page: the document's own page size and margins, the same // page flow (nothing cut through a photo or line), and the imported footer on every page. // Falls back to the older single-image export if anything goes wrong. window._downloadEditorAsPdfLegacy = window._downloadEditorAsPdf; window._downloadEditorAsPdf = async function() { var q = window._quill; var text = q ? q.getText().trim() : ''; if (!text) { window.pvShowToast && window.pvShowToast('The document is empty — add some content first', 'warning'); return; } if (typeof window.jspdf === 'undefined' || typeof html2canvas === 'undefined') { window.pvShowToast && window.pvShowToast('PDF library not loaded yet — please try again in a moment', 'warning'); return; } if (!window.EchoPaginate) return window._downloadEditorAsPdfLegacy(); var btn = document.getElementById('quillDownloadPdfBtn'); if (btn) { btn.disabled = true; btn.textContent = '⏳ Generating…'; } window.pvShowToast && window.pvShowToast('Generating PDF…', 'info'); var host = null; try { var cs = getComputedStyle(q.root); var H = parseFloat(cs.getPropertyValue('--fp-doc-page-height')) || 1056; var W = parseFloat(cs.getPropertyValue('--fp-doc-page-width')) || 816; host = document.createElement('div'); host.style.cssText = 'position:fixed;left:-20000px;top:0;width:' + W + 'px;z-index:-1;pointer-events:none;background:#fff;'; host.innerHTML = '
'; var ed = host.querySelector('.ql-editor'); ed.setAttribute('style', q.root.getAttribute('style') || ''); // the document's own fonts / margins / footer vars ['height:auto', 'min-height:0', 'overflow:visible', 'box-shadow:none', 'margin:0', 'position:relative', 'background:#fff', 'width:' + W + 'px', 'max-width:none', 'color:#000', 'box-sizing:border-box'].forEach(function(d) { var i = d.indexOf(':'); ed.style.setProperty(d.slice(0, i), d.slice(i + 1), 'important'); }); ed.innerHTML = window._editorHtml(); document.body.appendChild(host); var imgs = Array.prototype.slice.call(ed.querySelectorAll('img')); await Promise.all(imgs.map(function(im) { return im.complete ? 0 : new Promise(function(r) { im.onload = im.onerror = r; }); })); if (document.fonts && document.fonts.ready) await document.fonts.ready; // same page-flow maths as the editor, applied as inline styles on this throw-away copy var g = window.EchoPaginate.geometry(ed); var res = window.EchoPaginate.layout(ed, g, function(fits) { fits.forEach(function(im) { im.style.setProperty('max-height', Math.max(80, Math.floor(g.body - 6)) + 'px', 'important'); im.style.setProperty('width', 'auto', 'important'); im.style.setProperty('height', 'auto', 'important'); im.style.setProperty('max-width', '100%', 'important'); }); }); var _splitCss = ''; res.pushes.forEach(function(p, pi) { if (p.split) { // paragraph cut line by line at the page edge (a ::before float) p.el.setAttribute('data-pgs', String(pi)); _splitCss += '[data-pgs="' + pi + '"]::before{content:"";display:block;float:left;width:100%;height:' + (p.m + p.d).toFixed(2) + 'px;shape-outside:inset(' + p.m + 'px 0 0 0);margin:0;padding:0;border:0;}\n'; return; } if (window.EchoPaginate.applyInline(p)) return; // table rows moved apart (a table cut between rows) if (p.img) p.el.style.setProperty('margin-top', p.d + 'px', 'important'); else { p.el.style.setProperty('border-top', p.d + 'px solid transparent', 'important'); p.el.style.setProperty('background-clip', 'padding-box', 'important'); } }); if (_splitCss) { var _st = document.createElement('style'); _st.textContent = _splitCss; host.appendChild(_st); } var pages = Math.max(res.pages, Math.ceil(ed.scrollHeight / H - 0.02), 1); ed.style.setProperty('min-height', (pages * H) + 'px', 'important'); // the imported footer on every page var ft = window.EchoFooter && window.EchoFooter.read(ed); if (ft && (ft.html || ft.headerHtml)) { var fl = document.createElement('div'); fl.style.cssText = 'position:absolute;left:0;top:0;width:' + W + 'px;height:' + (pages * H) + 'px;pointer-events:none;color:#222;text-align:left;'; window.EchoFooter.fillAll(fl, ft, { pages: pages, H: H, W: W, mL: ft.mL, mR: ft.mR }); ed.appendChild(fl); } var wmm = W * 25.4 / 96, hmm = H * 25.4 / 96; var pdf = new window.jspdf.jsPDF({ unit: 'mm', format: [wmm, hmm], orientation: wmm > hmm ? 'landscape' : 'portrait' }); for (var k = 0; k < pages; k++) { var canvas = await html2canvas(ed, { scale: 2, useCORS: true, backgroundColor: '#ffffff', logging: false, x: 0, y: k * H, width: W, height: H, windowWidth: W, scrollX: 0, scrollY: 0 }); if (k > 0) pdf.addPage([wmm, hmm], wmm > hmm ? 'landscape' : 'portrait'); pdf.addImage(canvas.toDataURL('image/jpeg', 0.95), 'JPEG', 0, 0, wmm, hmm, '', 'FAST'); } var fileName = (window._importedDocxName || 'report').replace(/\.docx?$/i, '') + '.pdf'; pdf.save(fileName); window.pvShowToast && window.pvShowToast('Downloaded: ' + fileName, 'success'); } catch (err) { console.warn('[pdf] page-by-page export failed, using the simple export:', err); if (host && host.parentNode) host.parentNode.removeChild(host); host = null; if (btn) { btn.disabled = false; btn.textContent = '↓ .pdf'; } return window._downloadEditorAsPdfLegacy(); } finally { if (host && host.parentNode) host.parentNode.removeChild(host); if (btn) { btn.disabled = false; btn.textContent = '↓ .pdf'; } } }; // ── SYNC MOBILE TOPBAR ACCOUNT AVATAR ───────────────────────── // Mirror avatar emoji from main account button to mobile topbar button (function() { function _syncMobileAvatar() { var src = document.getElementById('accountBtnAvatar'); var dest = document.getElementById('mobileAccountAvatar'); /* the phone shows a fixed person icon now */ } // Poll to stay in sync after login setInterval(_syncMobileAvatar, 2000); document.addEventListener('DOMContentLoaded', function(){ setTimeout(_syncMobileAvatar, 1000); }); })(); // ── SETTINGS MODAL ─────────────────────────────────────────── window.openSettingsModal = function() { var modal = document.getElementById('settingsModal'); if (!modal) return; // Sync DB status var dot = document.getElementById('dbStatusDot'); var label = document.getElementById('dbStatusLabel'); var sDot = document.getElementById('settingsDbDot'); var sLbl = document.getElementById('settingsDbLabel'); if (dot && sDot) sDot.className = dot.className; if (label && sLbl) sLbl.textContent = label.textContent; // Sync timer var timerEl = document.getElementById('appTimerDisplay'); var sTimer = document.getElementById('settingsTimerDisplay'); if (timerEl && sTimer) sTimer.textContent = timerEl.textContent; // Sync plan var planEl = document.getElementById('accountDropdownPlan'); var sPlan = document.getElementById('settingsPlanDesc'); if (planEl && sPlan) sPlan.textContent = planEl.textContent; // Sync theme button updateSettingsThemeBtn(); modal.classList.add('open'); }; window.closeSettingsModal = function() { var modal = document.getElementById('settingsModal'); if (modal) modal.classList.remove('open'); }; // ── SYNC DB STATUS to subtab indicator ───────────────────── // Mirror the hidden appDbStatus dot/label to the visible subtabDbStatus (function() { function _syncSubtabDb() { var dot = document.getElementById('dbStatusDot'); var lbl = document.getElementById('dbStatusLabel'); var sDot = document.getElementById('subtabDbDot'); var sLbl = document.getElementById('subtabDbLabel'); var sWrap = document.getElementById('subtabDbStatus'); var srcWrap = document.getElementById('appDbStatus'); if (!dot || !sDot) return; // Copy class (connected/connecting/error) sDot.className = dot.className; if (sLbl && lbl) sLbl.textContent = lbl.textContent; if (sWrap && srcWrap) sWrap.className = srcWrap.className.replace('app-db-status', 'subtab-db-status'); } // Poll every 2s to stay in sync setInterval(_syncSubtabDb, 2000); // Also sync on DOMContentLoaded document.addEventListener('DOMContentLoaded', function() { setTimeout(_syncSubtabDb, 1000); }); window._syncSubtabDb = _syncSubtabDb; })(); window.updateSettingsThemeBtn = function() { var btn = document.getElementById('settingsThemeBtn'); if (!btn) return; var isLight = document.getElementById('appView') && document.getElementById('appView').classList.contains('light-mode'); btn.textContent = isLight ? '☀ Light' : '🌙 Dark'; }; // ── PRICING MODAL ───────────────────────────────────────────── window.openPricingModal = function() { var modal = document.getElementById('pricingModal'); if (modal) modal.classList.add('open'); }; window.closePricingModal = function() { var modal = document.getElementById('pricingModal'); if (modal) modal.classList.remove('open'); }; // ── SAVE AS TEMPLATE ────────────────────────────────────────── window._saveAsTemplate = async function() { var name = await edPrompt('Template name:', window._importedDocxName ? window._importedDocxName.replace(/\.docx?$/i,'') : 'My Template'); if (!name) return; var content = window._editorHtml(); if (!content || content === '


') { window.pvShowToast && window.pvShowToast('Nothing to save', 'warning'); return; } var tpl = { id:'custom_'+Date.now(), name:name, html:content, delta:JSON.stringify(window._quill.getContents()), savedAt:Date.now() }; try { var saved = JSON.parse(localStorage.getItem('vr_custom_templates') || '[]'); saved.push(tpl); localStorage.setItem('vr_custom_templates', JSON.stringify(saved)); } catch(e) {} window.pvShowToast && window.pvShowToast('\u2705 Saved as template: ' + name, 'success'); window.switchAppTab && window.switchAppTab('templates'); }; window._loadCustomTemplate = function(htmlContent, deltaStr) { if (deltaStr) { try { window._quill.setContents(JSON.parse(deltaStr), 'silent'); window._quill.setSelection(0, 0); } catch(e) { window._quill.clipboard.dangerouslyPasteHTML(0, htmlContent); } } else { window._quill.clipboard.dangerouslyPasteHTML(0, htmlContent); } window.switchAppTab && (window._edShowEditor ? window._edShowEditor() : window.switchAppTab('editor')); window.pvShowToast && window.pvShowToast('\u2705 Template loaded', 'success'); setTimeout(_makeTablesEditable, 200); }; });