const state = { doc: null, page: 0, selectedId: null, selectedIds: new Set(), regionFilter: null, search: '', zoom: 1, pageWidth: 0, busy: false, history: [], historyIndex: -1, historyTimer: null, drag: null, suppressClick: false, ignoreClickId: null, availableFonts: [], objectScope: 'all', creationTool: null, pendingImage: null, showGrid: false, showOriginalPositions: false, fontSearch: '', previewPageKey: '', baseImageUrl: '', previewBlobUrl: null, previewLoadingUrl: null, previewTimer: null, previewBusy: false, previewDirty: false, previewRevision: 0, previewSnapshotApplied: '', previewPendingSnapshot: '', }; const $ = (id) => document.getElementById(id); const elements = { file: $('file-input'), welcomeFile: $('welcome-file-input'), export: $('export-button'), analyze: $('analyze-button'), pageSelect: $('page-select'), prevPage: $('prev-page'), nextPage: $('next-page'), pageTotal: $('page-total'), objectList: $('object-list'), objectCount: $('object-count'), selectionCount: $('selection-count'), selectVisible: $('select-visible'), regionList: $('region-list'), regionCount: $('region-count'), search: $('text-search'), docName: $('doc-name'), docMeta: $('doc-meta'), objectScope: $('object-scope'), addText: $('add-text-button'), addImage: $('add-image-button'), imageUpload: $('insert-image-input'), addBehind: $('add-behind-content'), placementHint: $('placement-hint'), gridToggle: $('grid-toggle'), originalToggle: $('original-toggle'), gridOverlay: $('grid-overlay'), originalOverlay: $('original-overlay'), crumbName: $('crumb-name'), status: $('status'), modelStatus: $('model-status'), welcome: $('welcome'), canvas: $('canvas-area'), pageWrap: $('page-wrap'), pageImage: $('page-image'), previewState: $('preview-state'), overlay: $('overlay'), regionOverlay: $('region-overlay'), inspector: $('inspector'), emptyInspector: $('empty-inspector'), editControls: $('edit-controls'), inspectorTitle: $('inspector-title'), inspectorHelp: $('inspector-help'), selectionBadge: $('selection-badge'), source: $('selected-source'), text: $('text-value'), clearSelection: $('clear-selection'), objectKind: $('object-kind'), textControls: $('text-controls'), imageControls: $('image-controls'), fontInfo: $('font-info'), preserveImageAspect: $('preserve-image-aspect'), geometry: $('object-geometry'), multiControls: $('multi-controls'), multiSelectionLabel: $('multi-selection-label'), deleteSelection: $('delete-selection-button'), fontFamily: $('font-family'), fontSearch: $('font-search'), x: $('object-x'), y: $('object-y'), width: $('object-width'), height: $('object-height'), size: $('object-size'), color: $('object-color'), rotation: $('object-rotation'), rotationPresets: [...document.querySelectorAll('.rotation-presets [data-rotation]')], bold: $('object-bold'), italic: $('object-italic'), confidence: $('confidence-label'), regionLabel: $('region-label'), delete: $('delete-button'), replaceFind: $('replace-find'), replaceWith: $('replace-with'), replace: $('replace-button'), zoomIn: $('zoom-in'), zoomOut: $('zoom-out'), zoomLabel: $('zoom-label'), undo: $('undo-button'), redo: $('redo-button'), }; function setStatus(message, kind = '') { elements.status.textContent = message; elements.status.className = `status-message ${kind}`; } function activePage() { return state.doc?.pages[state.page] || null; } function currentObject() { return state.doc?.objects.find((item) => item.id === state.selectedId) || null; } function pageObjects() { return state.doc?.objects.filter((item) => item.page === state.page) || []; } function originalGeometryChanged(item) { return item.deleted || Math.abs(item.x - (item.originalX ?? item.x)) > 0.01 || Math.abs(item.y - (item.originalY ?? item.y)) > 0.01 || Math.abs(item.width - (item.originalWidth ?? item.width)) > 0.01 || Math.abs(item.height - (item.originalHeight ?? item.height)) > 0.01; } function esc(text) { return String(text ?? '').replace(/[&<>"']/g, (ch) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); } function visibleObjects() { const query = state.search.trim().toLocaleLowerCase(); return pageObjects().filter((item) => { const inRegion = !state.regionFilter || item.regionId === state.regionFilter; const isText = item.type === 'text'; const isImage = item.type === 'image'; const scope = state.objectScope; const inScope = scope === 'all' || (scope === 'text' && isText) || (scope === 'images' && isImage); const matches = !query || (item.type === 'image' ? 'image' : String(item.text)).toLocaleLowerCase().includes(query); return inRegion && inScope && matches; }); } function currentRegion() { return activePage()?.regions?.find((region) => region.id === state.regionFilter) || null; } function exportState() { return { objects: state.doc.objects }; } function cloneObjects(objects) { return objects.map((item) => ({ ...item })); } function cloneEditorSnapshot() { return { objects: cloneObjects(state.doc?.objects || []) }; } function updateHistoryButtons() { elements.undo.disabled = state.historyIndex <= 0; elements.redo.disabled = state.historyIndex < 0 || state.historyIndex >= state.history.length - 1; } function resetHistory() { clearTimeout(state.historyTimer); state.history = state.doc ? [cloneEditorSnapshot()] : []; state.historyIndex = state.history.length - 1; updateHistoryButtons(); } function commitHistory() { if (!state.doc) return; clearTimeout(state.historyTimer); const snapshot = cloneEditorSnapshot(); if (JSON.stringify(snapshot) === JSON.stringify(state.history[state.historyIndex])) return; state.history = state.history.slice(0, state.historyIndex + 1); state.history.push(snapshot); if (state.history.length > 60) state.history.shift(); state.historyIndex = state.history.length - 1; updateHistoryButtons(); } function scheduleHistory() { clearTimeout(state.historyTimer); state.historyTimer = setTimeout(commitHistory, 350); } function restoreHistory(index) { if (!state.doc || index < 0 || index >= state.history.length || index === state.historyIndex) return; clearTimeout(state.historyTimer); state.historyIndex = index; state.doc.objects = cloneObjects(state.history[index].objects); if (state.selectedId && !state.doc.objects.some((item) => item.id === state.selectedId)) state.selectedId = null; state.selectedIds = new Set([...state.selectedIds].filter((id) => state.doc.objects.some((item) => item.id === id))); updateHistoryButtons(); render(); setStatus(index < state.history.length - 1 ? 'Edit undone.' : 'Edit restored.', ''); } async function uploadFile(file) { if (!file) return; state.busy = true; setStatus('Opening and reading the PDF…', 'busy'); const form = new FormData(); form.append('file', file); try { const response = await fetch('/api/documents', { method: 'POST', body: form }); const body = await response.json(); if (!response.ok) throw new Error(body.detail || 'Could not open PDF'); // Release the previous document's browser-side preview before switching to the new one. if (state.previewBlobUrl) URL.revokeObjectURL(state.previewBlobUrl); if (state.previewLoadingUrl) URL.revokeObjectURL(state.previewLoadingUrl); state.previewBlobUrl = null; state.previewLoadingUrl = null; state.previewPageKey = ''; state.previewSnapshotApplied = ''; state.previewPendingSnapshot = ''; state.previewDirty = false; state.doc = body; state.page = 0; state.selectedId = null; state.selectedIds.clear(); state.regionFilter = null; state.search = ''; state.objectScope = 'all'; state.creationTool = null; state.pendingImage = null; resetHistory(); elements.docName.textContent = body.filename; elements.crumbName.textContent = body.filename; elements.docMeta.textContent = `${body.pages.length} page${body.pages.length === 1 ? '' : 's'} · PDF`; elements.pageSelect.innerHTML = body.pages.map((page) => ``).join(''); elements.pageSelect.disabled = false; elements.export.disabled = false; elements.analyze.disabled = false; elements.prevPage.disabled = false; elements.nextPage.disabled = false; elements.zoomIn.disabled = false; elements.zoomOut.disabled = false; elements.replaceFind.disabled = false; elements.replaceWith.disabled = false; elements.welcome.classList.add('hidden'); elements.pageWrap.classList.remove('hidden'); elements.search.value = ''; render(); setStatus(`Opened ${body.filename}. Select Analyze page to detect text and page structure.`, ''); } catch (error) { setStatus(error.message, 'error'); } finally { state.busy = false; if (state.doc) render(); } } function updateToolControls() { if (!elements.objectScope) return; elements.objectScope.value = state.objectScope; elements.objectScope.disabled = !state.doc || state.busy; elements.addText.disabled = !state.doc || state.busy; elements.addImage.disabled = !state.doc || state.busy; elements.gridToggle.disabled = !state.doc || state.busy; elements.originalToggle.disabled = !state.doc || state.busy; elements.addText.classList.toggle('active', state.creationTool === 'text'); elements.addImage.classList.toggle('active', state.creationTool === 'image'); elements.pageWrap.classList.toggle('place-text-mode', state.creationTool === 'text'); elements.pageWrap.classList.toggle('place-image-mode', state.creationTool === 'image'); elements.gridToggle.classList.toggle('active', state.showGrid); elements.originalToggle.classList.toggle('active', state.showOriginalPositions); elements.gridToggle.setAttribute('aria-pressed', String(state.showGrid)); elements.originalToggle.setAttribute('aria-pressed', String(state.showOriginalPositions)); if (state.creationTool === 'text') elements.placementHint.textContent = 'Click the page to place text, then edit it in the inspector.'; else if (state.creationTool === 'image') elements.placementHint.textContent = 'Click the page to place the uploaded image.'; else elements.placementHint.textContent = 'Choose text or images to narrow the list. Add tools place content on the page.'; } async function uploadEditorImage(file) { if (!file || !state.doc) return; if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) { setStatus('Choose a PNG, JPEG, or WebP image.', 'error'); return; } state.busy = true; updateToolControls(); setStatus('Uploading and checking the image…', 'busy'); try { const form = new FormData(); form.append('file', file); const response = await fetch(`/api/documents/${state.doc.id}/images`, { method: 'POST', body: form }); const body = await response.json(); if (!response.ok) throw new Error(body.detail || 'Could not add this image'); state.pendingImage = body; state.creationTool = 'image'; state.objectScope = 'all'; render(); setStatus('Image ready. Click the page where you want to place it.', ''); } catch (error) { setStatus(error.message, 'error'); } finally { state.busy = false; if (state.doc) render(); } } function placeNewObject(event) { if (!state.creationTool || !state.doc || state.busy || event.button !== 0) return; event.preventDefault(); event.stopImmediatePropagation(); const page = activePage(); const bounds = elements.pageWrap.getBoundingClientRect(); const pointX = Math.min(page.width, Math.max(0, (event.clientX - bounds.left) / bounds.width * page.width)); const pointY = Math.min(page.height, Math.max(0, (event.clientY - bounds.top) / bounds.height * page.height)); const id = crypto.randomUUID(); const behind = elements.addBehind.checked; let item; if (state.creationTool === 'text') { const fontSize = 12; const width = Math.min(150, page.width); const height = 20; const x = Math.min(pointX, Math.max(0, page.width - width)); const y = Math.min(pointY, Math.max(0, page.height - height)); item = { id, page: state.page, type: 'text', text: 'New text', originalText: 'New text', x, y, width, height, originalX: x, originalY: y, originalWidth: width, originalHeight: height, fontSize, originalFontSize: fontSize, font: 'DejaVu Sans', fontFamily: 'sans', originalFontFamily: 'sans', fontFamilyHint: 'sans', rotation: 0, originalRotation: 0, fontSizeSource: 'New text · 12 pt', color: '#202020', originalColor: '#202020', bold: false, italic: false, deleted: false, source: 'inserted', confidence: 1, regionId: null, isNew: true, isBackground: behind, isBackgroundText: behind }; } else if (state.pendingImage) { const ratio = state.pendingImage.width / Math.max(1, state.pendingImage.height); const width = Math.min(180, page.width * 0.45, page.height * 0.45 * ratio); const height = width / ratio; const x = Math.min(Math.max(0, pointX - width / 2), Math.max(0, page.width - width)); const y = Math.min(Math.max(0, pointY - height / 2), Math.max(0, page.height - height)); item = { id, page: state.page, type: 'image', text: '', originalText: '', x, y, width, height, originalX: x, originalY: y, originalWidth: width, originalHeight: height, fontSize: 1, font: '', fontFamily: 'original', color: '#000000', rotation: 0, originalRotation: 0, bold: false, italic: false, deleted: false, source: 'inserted', confidence: 1, regionId: null, assetId: state.pendingImage.assetId, assetExt: state.pendingImage.assetExt, isNew: true, isBackground: behind }; } else return; state.doc.objects.push(item); state.selectedId = id; state.selectedIds = new Set([id]); state.regionFilter = null; state.objectScope = 'all'; state.creationTool = null; state.pendingImage = null; commitHistory(); render(); setStatus(`${item.type === 'image' ? 'Image' : 'Text'} added. Edit it in the inspector.`); } function render() { if (!state.doc) return; elements.export.disabled = state.busy; const page = activePage(); const pageKey = `${state.doc.id}:${state.page}`; const imageUrl = `/api/documents/${state.doc.id}/pages/${state.page}/image`; if (state.previewPageKey !== pageKey) { clearTimeout(state.previewTimer); state.previewRevision += 1; state.previewPageKey = pageKey; state.previewSnapshotApplied = ''; state.previewPendingSnapshot = ''; state.previewDirty = false; if (state.previewBlobUrl) URL.revokeObjectURL(state.previewBlobUrl); if (state.previewLoadingUrl) URL.revokeObjectURL(state.previewLoadingUrl); state.previewBlobUrl = null; state.previewLoadingUrl = null; state.baseImageUrl = imageUrl; elements.previewState.classList.add('hidden'); elements.pageImage.onload = () => { fitPage(); drawOverlays(); }; elements.pageImage.onerror = () => { elements.pageImage.dataset.source = ''; setStatus('Could not render this page preview.', 'error'); }; elements.pageImage.dataset.source = imageUrl; elements.pageImage.src = imageUrl; } else { fitPage(); } elements.pageTotal.textContent = `${state.page + 1} / ${state.doc.pages.length}`; elements.pageSelect.value = String(state.page); elements.pageSelect.disabled = state.busy; elements.prevPage.disabled = state.page === 0 || state.busy; elements.nextPage.disabled = state.page >= state.doc.pages.length - 1 || state.busy; elements.analyze.textContent = page.analyzed ? '✦ Re-analyze page' : '✦ Analyze page'; elements.analyze.disabled = state.busy; elements.zoomLabel.textContent = `${Math.round(state.zoom * 100)}%`; renderRegions(); updateToolControls(); updateGridOverlay(); renderList(); drawOverlays(); syncInspector(); scheduleLivePreview(); } function updateGridOverlay() { if (!elements.gridOverlay) return; const page = activePage(); elements.gridOverlay.classList.toggle('hidden', !state.showGrid); if (page && state.pageWidth) { const step = Math.max(12, state.pageWidth / page.width * 36); elements.gridOverlay.style.setProperty('--grid-step', `${step}px`); } } function renderRegions() { const page = activePage(); const regions = page?.regions || []; elements.regionCount.textContent = page?.analyzed ? `${regions.length} found` : 'Not analyzed'; if (!page?.analyzed) { elements.regionList.innerHTML = '

Analyze this page to find text blocks and page areas.

'; return; } if (!regions.length) { elements.regionList.innerHTML = '

No page regions were detected. Select text below to edit it.

'; return; } const counts = new Map(); pageObjects().forEach((item) => { if (item.regionId) counts.set(item.regionId, (counts.get(item.regionId) || 0) + 1); }); elements.regionList.innerHTML = `` + regions.map((region) => { const label = region.label.replaceAll('_', ' '); return ``; }).join(''); elements.regionList.querySelectorAll('.region-item').forEach((button) => button.addEventListener('click', () => { state.regionFilter = button.dataset.region === 'all' ? null : button.dataset.region; state.selectedId = null; state.selectedIds.clear(); render(); })); } function renderList() { const all = pageObjects(); const visible = visibleObjects(); elements.objectCount.textContent = `${all.length}`; elements.selectionCount.textContent = `${state.selectedIds.size} selected`; elements.selectVisible.disabled = !visible.length; elements.selectVisible.checked = visible.length > 0 && visible.every((item) => state.selectedIds.has(item.id)); elements.replace.disabled = !state.selectedIds.size; if (!visible.length) { const message = state.search || state.regionFilter || state.objectScope !== 'all' ? 'No items match this filter.' : 'No selectable objects yet. Analyze the page to detect text.'; elements.objectList.innerHTML = `
${esc(message)}
`; return; } elements.objectList.innerHTML = visible.map((item, index) => { const objectLabel = item.type === 'image' ? (item.isBackground ? 'Background image' : 'Image') : (item.text || '(empty text)'); return `
`; }).join(''); elements.objectList.querySelectorAll('.object-check').forEach((checkbox) => checkbox.addEventListener('change', () => { const id = checkbox.dataset.id; if (checkbox.checked) { state.selectedIds.add(id); state.selectedId = id; } else { state.selectedIds.delete(id); if (state.selectedId === id) state.selectedId = state.selectedIds.values().next().value || null; } renderList(); drawOverlays(); syncInspector(); })); elements.objectList.querySelectorAll('.object-item').forEach((button) => button.addEventListener('click', (event) => { const id = button.dataset.id; if (event.ctrlKey || event.metaKey) { state.selectedIds.has(id) ? state.selectedIds.delete(id) : state.selectedIds.add(id); state.selectedId = id; } else { state.selectedId = id; state.selectedIds = new Set([id]); } renderList(); drawOverlays(); syncInspector(); })); } function fitPage() { if (!state.doc) return; const available = Math.max(320, elements.canvas.clientWidth - 64); state.pageWidth = Math.min(920, available) * state.zoom; elements.pageWrap.style.width = `${state.pageWidth}px`; updateGridOverlay(); drawOverlays(); } function drawOriginalPositions() { if (!state.doc || !elements.originalOverlay) return; const page = activePage(); const moved = state.showOriginalPositions ? visibleObjects().filter((item) => !item.isNew && originalGeometryChanged(item)) : []; elements.originalOverlay.innerHTML = moved.map((item) => { const style = `left:${(item.originalX ?? item.x) / page.width * 100}%;top:${(item.originalY ?? item.y) / page.height * 100}%;width:${Math.max(.6, (item.originalWidth ?? item.width) / page.width * 100)}%;height:${Math.max(.35, (item.originalHeight ?? item.height) / page.height * 100)}%;`; const label = item.type === 'image' ? 'Original image position' : 'Original text position'; return `
`; }).join(''); } function drawOverlays() { if (!state.doc) return; const page = activePage(); const overlayObjects = visibleObjects().slice().sort((a, b) => { const layer = (item) => item.type === 'image' && item.isBackground ? 0 : item.type === 'image' ? 1 : item.source === 'ocr' ? 3 : 2; return layer(a) - layer(b); }); drawOriginalPositions(); elements.overlay.innerHTML = overlayObjects.map((item) => { const rotation = item.type === 'text' ? Number(item.rotation || 0) : 0; const style = `left:${item.x / page.width * 100}%;top:${item.y / page.height * 100}%;width:${Math.max(.6, item.width / page.width * 100)}%;height:${Math.max(.35, item.height / page.height * 100)}%;transform:rotate(${rotation}deg);`; const active = item.id === state.selectedId || state.selectedIds.has(item.id); const label = item.type === 'image' ? 'image' : `text: ${item.text}`; return ``; }).join(''); elements.overlay.querySelectorAll('.text-hitbox').forEach((box) => { box.addEventListener('pointerdown', (event) => beginDrag(event, box)); box.addEventListener('click', (event) => { if (state.suppressClick || state.ignoreClickId === box.dataset.id) { state.ignoreClickId = null; event.preventDefault(); return; } const id = box.dataset.id; if (event.ctrlKey || event.metaKey) { state.selectedIds.has(id) ? state.selectedIds.delete(id) : state.selectedIds.add(id); state.selectedId = id; } else { state.selectedId = id; state.selectedIds = new Set([id]); } renderList(); drawOverlays(); syncInspector(); }); }); const region = currentRegion(); if (!region) elements.regionOverlay.innerHTML = ''; else { const style = `left:${region.x / page.width * 100}%;top:${region.y / page.height * 100}%;width:${region.width / page.width * 100}%;height:${region.height / page.height * 100}%;`; elements.regionOverlay.innerHTML = `
`; } } function previewChanged(item) { if (item.isNew) return true; const originalX = item.originalX ?? item.x; const originalY = item.originalY ?? item.y; const originalWidth = item.originalWidth ?? item.width; const originalHeight = item.originalHeight ?? item.height; const changedGeometry = Math.abs(item.x - originalX) > 0.01 || Math.abs(item.y - originalY) > 0.01 || Math.abs(item.width - originalWidth) > 0.01 || Math.abs(item.height - originalHeight) > 0.01; if (item.deleted || changedGeometry || Math.abs(Number(item.rotation || 0) - Number(item.originalRotation || 0)) > 0.01) return true; if (item.type === 'image') return false; return item.text !== (item.originalText ?? item.text) || item.fontSize !== (item.originalFontSize ?? item.fontSize) || item.color !== (item.originalColor ?? item.color) || Boolean(item.bold) !== Boolean(item.originalBold ?? item.bold) || Boolean(item.italic) !== Boolean(item.originalItalic ?? item.italic) || item.fontFamily !== (item.originalFontFamily ?? item.fontFamily ?? 'original'); } function livePreviewSnapshot() { return JSON.stringify({ objects: pageObjects().map((item) => ({ id: item.id, type: item.type, text: item.text, x: item.x, y: item.y, width: item.width, height: item.height, fontSize: item.fontSize, fontFamily: item.fontFamily, color: item.color, bold: item.bold, italic: item.italic, rotation: Number(item.rotation || 0), deleted: item.deleted, assetId: item.assetId, isNew: item.isNew, isBackground: item.isBackground, })) }); } function setLivePreviewLabel(message = '', visible = false) { elements.previewState.textContent = message; elements.previewState.classList.toggle('hidden', !visible); } function restoreBasePreview() { clearTimeout(state.previewTimer); const oldUrl = state.previewBlobUrl; if (state.previewLoadingUrl) URL.revokeObjectURL(state.previewLoadingUrl); state.previewLoadingUrl = null; state.previewBlobUrl = null; state.previewSnapshotApplied = ''; state.previewPendingSnapshot = ''; state.previewDirty = false; state.previewRevision += 1; setLivePreviewLabel(); if (oldUrl && state.baseImageUrl) { const baseUrl = state.baseImageUrl; elements.pageImage.onload = () => { URL.revokeObjectURL(oldUrl); fitPage(); drawOverlays(); }; elements.pageImage.src = baseUrl; } else if (oldUrl) URL.revokeObjectURL(oldUrl); } function scheduleLivePreview() { if (!state.doc || !state.baseImageUrl) return; const changed = pageObjects().some(previewChanged); if (!changed) { if (state.previewSnapshotApplied || state.previewPendingSnapshot) restoreBasePreview(); else setLivePreviewLabel(); return; } const snapshot = livePreviewSnapshot(); if (snapshot === state.previewSnapshotApplied || snapshot === state.previewPendingSnapshot) return; state.previewRevision += 1; state.previewDirty = true; setLivePreviewLabel('Updating live page…', true); clearTimeout(state.previewTimer); state.previewTimer = setTimeout(runLivePreview, 180); } async function runLivePreview() { if (!state.doc || state.previewBusy) { state.previewDirty = true; return; } const snapshot = livePreviewSnapshot(); if (!pageObjects().some(previewChanged)) { restoreBasePreview(); return; } if (snapshot === state.previewSnapshotApplied) { state.previewDirty = false; return; } const documentId = state.doc.id; const pageNumber = state.page; const pageKey = `${documentId}:${pageNumber}`; const revision = state.previewRevision; state.previewBusy = true; state.previewDirty = false; state.previewPendingSnapshot = snapshot; try { const response = await fetch(`/api/documents/${documentId}/pages/${pageNumber}/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(exportState()), }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.detail || 'Could not render live preview'); } const blob = await response.blob(); if (revision !== state.previewRevision || pageKey !== state.previewPageKey || snapshot !== livePreviewSnapshot()) return; const nextUrl = URL.createObjectURL(blob); if (state.previewLoadingUrl) URL.revokeObjectURL(state.previewLoadingUrl); state.previewLoadingUrl = nextUrl; const previousUrl = state.previewBlobUrl; elements.pageImage.onload = () => { if (state.previewLoadingUrl === nextUrl) state.previewLoadingUrl = null; if (revision !== state.previewRevision || pageKey !== state.previewPageKey) { URL.revokeObjectURL(nextUrl); const fallbackUrl = pageKey === state.previewPageKey ? previousUrl || state.baseImageUrl : state.baseImageUrl; if (elements.pageImage.src === nextUrl) elements.pageImage.src = fallbackUrl; return; } state.previewBlobUrl = nextUrl; state.previewSnapshotApplied = snapshot; setLivePreviewLabel('Live preview · matches PDF output', true); if (previousUrl && previousUrl !== nextUrl) URL.revokeObjectURL(previousUrl); fitPage(); drawOverlays(); }; elements.pageImage.src = nextUrl; } catch (error) { if (revision === state.previewRevision && pageKey === state.previewPageKey) { setLivePreviewLabel('Live preview could not render', true); setStatus(`${error.message}. Your edits are still available for export.`, 'error'); } } finally { state.previewBusy = false; if (state.previewPendingSnapshot === snapshot) state.previewPendingSnapshot = ''; const hasNewSnapshot = state.doc && pageKey === state.previewPageKey && livePreviewSnapshot() !== snapshot; if (state.previewDirty || hasNewSnapshot) { state.previewTimer = setTimeout(runLivePreview, 80); } } } function syncInspector() { const item = currentObject(); const count = state.selectedIds.size; const hasSelection = Boolean(item); const multi = hasSelection && count > 1; elements.emptyInspector.classList.toggle('hidden', hasSelection); elements.editControls.classList.toggle('hidden', !hasSelection || multi); elements.multiControls.classList.toggle('hidden', !multi); elements.inspectorTitle.textContent = multi ? 'Edit selected objects' : item?.type === 'image' ? 'Edit selected image' : 'Edit selected text'; elements.inspectorHelp.textContent = multi ? 'Drag the selection together, or delete all selected objects.' : hasSelection ? 'Drag to move. A click only selects; use Position and size for exact placement.' : 'Select text or an image from the page or list to see its properties.'; elements.selectionBadge.textContent = count ? `${count} selected` : 'Nothing selected'; elements.selectionBadge.classList.toggle('has-selection', count > 0); elements.inspector.classList.toggle('empty-inspector', !hasSelection); elements.deleteSelection.disabled = !multi; elements.multiSelectionLabel.textContent = `${count} objects selected`; if (!hasSelection) return; elements.x.value = Number(item.x).toFixed(1); elements.y.value = Number(item.y).toFixed(1); elements.width.value = Number(item.width).toFixed(1); elements.height.value = Number(item.height).toFixed(1); const isImage = item.type === 'image'; elements.objectKind.textContent = isImage ? (item.isBackground ? 'BACKGROUND IMAGE' : 'SELECTED IMAGE') : item.isBackgroundText ? 'BACKGROUND TEXT' : 'SELECTED TEXT'; elements.source.textContent = isImage ? (item.isBackground ? (item.backgroundImageReason || 'Background PDF image') : 'PDF image') : item.isBackgroundText ? (item.backgroundTextReason || 'Background PDF text') : item.source === 'ocr' ? 'OCR text' : 'PDF text'; elements.textControls.classList.toggle('hidden', isImage); elements.imageControls.classList.toggle('hidden', !isImage); elements.geometry.open = isImage || elements.geometry.open; const protectedBackgroundText = item.isBackgroundText && item.source === 'native'; elements.inspectorHelp.textContent = protectedBackgroundText ? 'This covered native PDF text is locked to protect foreground objects. OCR text in a backing scan can be edited independently.' : item.isBackgroundText ? (item.source === 'ocr' && item.backgroundImageId ? 'Edits patch only the backing scan image; foreground PDF text and images stay above it.' : 'This text is behind another PDF layer. Foreground text is protected; unsafe overlapping text edits are blocked.') : isImage && item.isBackground ? 'This image stays behind foreground page content when you move, resize, or remove it.' : multi ? 'Drag the selection together, or delete all selected objects.' : hasSelection ? 'Drag to move. A click only selects; use Position and size for exact placement.' : 'Select text or an image from the page or list to see its properties.'; const textControls = [elements.text, elements.fontSearch, elements.fontFamily, elements.size, elements.color, elements.rotation, elements.bold, elements.italic, ...elements.rotationPresets]; textControls.forEach((control) => { control.disabled = isImage || multi || protectedBackgroundText; }); [elements.x, elements.y, elements.width, elements.height, elements.delete].forEach((control) => { control.disabled = multi || protectedBackgroundText; }); if (!isImage) { elements.text.value = item.text; const selectedFamily = item.fontFamily || 'original'; const originalLabel = item.source === 'native' ? `Keep exact PDF font · ${item.font || 'source face'}` : item.fontGuess ? `Use detected match · ${item.fontGuess}` : 'Use detected font match'; const familyOptions = [ ['original', originalLabel], ['sans', 'Sans serif'], ['serif', 'Serif'], ['mono', 'Monospace'], ]; (item.fontCandidates || []).forEach((candidate) => { if (!candidate.family) return; const value = candidate.value || `fontid:${candidate.family}`; if (familyOptions.some(([existing]) => existing === value)) return; const rawScore = candidate.visualScore ?? candidate.confidence; const score = rawScore == null ? null : Math.round(rawScore * 100); const label = candidate.source === 'exact' ? `Exact PDF font · ${candidate.family}` : candidate.source === 'document' ? score == null ? `Embedded PDF font: ${candidate.family} (supports this text)` : `Embedded PDF font: ${candidate.family} (${score}% glyph match)` : candidate.source === 'classifier' ? `FontID category: ${candidate.family}` : `Visual match: ${candidate.family} (${score}% glyph match)`; familyOptions.push([value, label]); }); state.availableFonts.forEach((family) => { const value = `google:${family}`; if (!familyOptions.some(([existing]) => existing === value)) familyOptions.push([value, `Google Fonts · ${family}`]); }); if ((selectedFamily.startsWith('fontid:') || selectedFamily.startsWith('document:')) && !familyOptions.some(([value]) => value === selectedFamily)) { familyOptions.push([selectedFamily, `Font: ${selectedFamily.replace(/^(fontid|document):/, '')}`]); } const fontQuery = state.fontSearch.trim().toLocaleLowerCase(); const visibleFontOptions = fontQuery ? familyOptions.filter(([value, label]) => `${value} ${label}`.toLocaleLowerCase().includes(fontQuery)) : familyOptions; if (fontQuery && !visibleFontOptions.some(([value]) => value === selectedFamily)) { visibleFontOptions.unshift([selectedFamily, `Current choice: ${selectedFamily.replace(/^(fontid|document|installed):/, '')}`]); } if (!visibleFontOptions.length) visibleFontOptions.push(['', 'No matching fonts']); elements.fontFamily.replaceChildren(...visibleFontOptions.map(([value, label]) => { const option = new Option(label, value); option.disabled = !value; return option; })); elements.fontFamily.value = selectedFamily; elements.fontSearch.value = state.fontSearch; elements.size.value = Number(item.fontSize).toFixed(1); elements.color.value = item.color; elements.rotation.value = String(Math.round(((Number(item.rotation || 0) % 360) + 360) % 360)); elements.bold.checked = Boolean(item.bold); elements.italic.checked = Boolean(item.italic); const fontSource = item.fontSizeSource || (item.source === 'ocr' ? 'OCR glyph-height estimate' : 'PDF font metadata'); const confidence = item.fontSizeConfidence == null ? '' : ` · ${Math.round(item.fontSizeConfidence * 100)}% estimate confidence`; const familyConfidence = item.fontGuessConfidence == null ? '' : ` · ${Math.round(item.fontGuessConfidence * 100)}% glyph match score`; const renderedFont = item.fontRenderNote || (item.fontRenderName ? `Rendered with ${item.fontRenderName}` : ''); const styleConfidence = item.fontStyleConfidence == null ? '' : ` · ${Math.round(item.fontStyleConfidence * 100)}% style estimate`; const colorSource = item.fontColorSource ? ` · ${item.fontColorSource}` : ''; const sourceFace = item.source === 'native' ? `Exact PDF font: ${item.font || 'Unknown'} · ${Number(item.fontSize).toFixed(1)} pt` : `Closest match: ${item.fontGuess || item.font || 'unknown'} · ${Number(item.fontSize).toFixed(1)} pt (estimate)`; elements.fontInfo.classList.toggle('font-estimated', item.source !== 'native'); elements.fontInfo.textContent = `${sourceFace} · ${fontSource}${familyConfidence}${confidence}${styleConfidence}${renderedFont ? ` · ${renderedFont}` : ''}${colorSource}`; elements.confidence.textContent = item.source === 'ocr' ? `OCR confidence ${item.confidence == null ? '—' : `${Math.round(item.confidence * 100)}%`}` : 'Extracted from PDF'; } const region = activePage()?.regions?.find((entry) => entry.id === item.regionId); elements.regionLabel.textContent = region ? `Area: ${region.label.replaceAll('_', ' ')}` : 'No detected area'; elements.delete.textContent = item.deleted ? `Restore selected ${isImage ? 'image' : 'text'}` : `Delete selected ${isImage ? 'image' : 'text'}`; } function updateSelected(field, value) { const item = currentObject(); if (!item || state.selectedIds.size > 1) return; if (item.isBackgroundText && item.source === 'native') return; if (['x', 'y', 'width', 'height'].includes(field)) item.manualPosition = true; if (field === 'rotation') { const numeric = Number(value); if (!Number.isFinite(numeric)) return; value = ((numeric % 360) + 360) % 360; } item[field] = value; if (field === 'width' && item.type === 'image' && elements.preserveImageAspect.checked) { item.height = Math.max(1, item.width / Math.max(0.01, item.originalWidth / item.originalHeight)); elements.height.value = Number(item.height).toFixed(1); } else if (field === 'height' && item.type === 'image' && elements.preserveImageAspect.checked) { item.width = Math.max(1, item.height * item.originalWidth / Math.max(0.01, item.originalHeight)); elements.width.value = Number(item.width).toFixed(1); } if (field === 'text' || field === 'deleted') reflowTextLayout(); scheduleHistory(); renderList(); drawOverlays(); if (field === 'text') { elements.x.value = Number(item.x).toFixed(1); elements.y.value = Number(item.y).toFixed(1); elements.width.value = Number(item.width).toFixed(1); elements.height.value = Number(item.height).toFixed(1); } else syncInspector(); scheduleLivePreview(); } function estimatedTextWidth(item, text) { const source = Array.from(String(item.originalText ?? '')); const target = Array.from(String(text ?? '')); const advances = Array.isArray(item.glyphAdvances) ? item.glyphAdvances : []; const originalWidth = Number(item.originalWidth ?? item.width) || 1; if (!target.length) return 1; if (!source.length || source.length !== advances.length) { return Math.max(1, originalWidth * target.length / Math.max(1, source.length || target.length)); } const rows = source.length + 1; const columns = target.length + 1; const matches = []; if (rows * columns <= 180000) { const table = Array.from({ length: rows }, () => new Uint16Array(columns)); for (let i = source.length - 1; i >= 0; i -= 1) { for (let j = target.length - 1; j >= 0; j -= 1) { table[i][j] = source[i] === target[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]); } } let i = 0; let j = 0; while (i < source.length && j < target.length) { if (source[i] === target[j]) { matches.push([i, j]); i += 1; j += 1; } else if (table[i + 1][j] >= table[i][j + 1]) i += 1; else j += 1; } } else { let sourceIndex = 0; target.forEach((character, targetIndex) => { const found = source.indexOf(character, sourceIndex); if (found >= 0) { matches.push([found, targetIndex]); sourceIndex = found + 1; } }); } const widthsByKind = new Map(); source.forEach((character, index) => { const kind = /\s/.test(character) ? 'space' : /[\p{L}]/u.test(character) ? 'letter' : /[\p{N}]/u.test(character) ? 'number' : 'other'; if (!widthsByKind.has(kind)) widthsByKind.set(kind, []); if (Number(advances[index]) > 0) widthsByKind.get(kind).push(Number(advances[index])); }); const median = (values) => { const sorted = [...values].sort((a, b) => a - b); return sorted.length ? sorted[Math.floor(sorted.length / 2)] : originalWidth / Math.max(1, source.length); }; const usedTarget = new Set(matches.map(([, targetIndex]) => targetIndex)); let width = matches.reduce((sum, [sourceIndex]) => sum + Math.max(0, Number(advances[sourceIndex]) || 0), 0); target.forEach((character, index) => { if (usedTarget.has(index)) return; const kind = /\s/.test(character) ? 'space' : /[\p{L}]/u.test(character) ? 'letter' : /[\p{N}]/u.test(character) ? 'number' : 'other'; width += median(widthsByKind.get(kind) || widthsByKind.get('letter') || []); }); return Math.max(1, Math.min(originalWidth * 4, width || originalWidth * target.length / Math.max(1, source.length))); } function reflowTextLayout() { if (!state.doc) return; const pageItems = pageObjects().filter((item) => item.type === 'text' && item.lineId && !item.isBackgroundText); const lines = new Map(); pageItems.forEach((item) => { if (!lines.has(item.lineId)) lines.set(item.lineId, []); lines.get(item.lineId).push(item); }); const records = [...lines.entries()].map(([id, items]) => { const sorted = items.sort((a, b) => (a.originalX ?? a.x) - (b.originalX ?? b.x)); const left = Math.min(...sorted.map((item) => item.originalX ?? item.x)); const right = Math.max(...sorted.map((item) => (item.originalX ?? item.x) + (item.originalWidth ?? item.width))); return { id, items: sorted, blockId: sorted[0].blockId, lineIndex: sorted[0].lineIndex ?? 0, regionId: sorted[0].regionId, top: sorted[0].lineTop ?? Math.min(...sorted.map((item) => item.originalY ?? item.y)), left, right, step: sorted[0].lineStep ?? Math.max(...sorted.map((item) => item.originalHeight ?? item.height)) * 1.25, manual: sorted.some((item) => item.manualPosition), empty: sorted.every((item) => item.deleted || !String(item.text || '').trim()), }; }); for (const line of records) { if (line.manual) continue; let removedWidth = 0; line.items.forEach((item, index) => { const originalX = item.originalX ?? item.x; const originalWidth = item.originalWidth ?? item.width; item.x = Math.max(0, originalX - removedWidth); if (item.deleted) { const next = line.items[index + 1]; const nextX = next ? (next.originalX ?? next.x) : originalX + originalWidth; removedWidth += Math.max(originalWidth, nextX - originalX); } else if (item.text !== (item.originalText ?? item.text)) { const newWidth = estimatedTextWidth(item, item.text); item.width = newWidth; removedWidth += Math.max(0, originalWidth - newWidth); } else { item.width = originalWidth; } }); } const removedLines = records.filter((line) => line.empty && !line.manual); for (const line of records) { if (line.manual || line.empty) continue; const removedHeight = removedLines.filter((removed) => { if (removed.top >= line.top) return false; const sameFlow = (removed.regionId && removed.regionId === line.regionId) || (removed.blockId && removed.blockId === line.blockId); if (!sameFlow) return false; const alignedColumn = Math.abs(removed.left - line.left) <= Math.max(18, Math.min(42, removed.step * 1.4)); const overlap = Math.max(0, Math.min(removed.right, line.right) - Math.max(removed.left, line.left)); return alignedColumn || overlap >= Math.min(removed.right - removed.left, line.right - line.left) * 0.35; }).reduce((sum, removed) => sum + removed.step, 0); if (removedHeight > 0) { line.items.forEach((item) => { item.y = Math.max(0, (item.originalY ?? item.y) - removedHeight); }); } else { line.items.forEach((item) => { item.y = item.originalY ?? item.y; }); } } } function beginDrag(event, box) { if (event.button !== 0 || state.busy) return; const hit = state.doc.objects.find((item) => item.id === box.dataset.id); if (hit?.isBackgroundText && hit.source === 'native') { setStatus('This covered native text is locked to protect foreground objects.'); return; } const hitBounds = box.getBoundingClientRect(); if (hit?.type === 'image' && hit.id === state.selectedId && event.clientX >= hitBounds.right - 14 && event.clientY >= hitBounds.bottom - 14) { beginResize(event, box); return; } const id = box.dataset.id; const existing = state.doc.objects.find((item) => item.id === id); if (!existing || existing.deleted) return; if (event.ctrlKey || event.metaKey) { state.selectedIds.has(id) ? state.selectedIds.delete(id) : state.selectedIds.add(id); state.selectedId = state.selectedIds.has(id) ? id : (state.selectedIds.values().next().value || null); } else if (!state.selectedIds.has(id)) { state.selectedId = id; state.selectedIds = new Set([id]); } else state.selectedId = id; if ((event.ctrlKey || event.metaKey) && !state.selectedIds.has(id)) { state.ignoreClickId = id; setTimeout(() => { if (state.ignoreClickId === id) state.ignoreClickId = null; }, 100); renderList(); syncInspector(); return; } renderList(); elements.overlay.querySelectorAll('.text-hitbox').forEach((hitbox) => { const selected = hitbox.dataset.id === state.selectedId || state.selectedIds.has(hitbox.dataset.id); hitbox.classList.toggle('active', selected); }); syncInspector(); const selected = [...state.selectedIds].map((selectedId) => state.doc.objects.find((item) => item.id === selectedId)).filter(Boolean); const pageRect = elements.pageWrap.getBoundingClientRect(); state.drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, width: pageRect.width, height: pageRect.height, targetId: id, didMove: false, positions: selected.map((item) => ({ item, x: item.x, y: item.y })) }; box.setPointerCapture?.(event.pointerId); } function beginResize(event, handle) { if (event.button !== 0 || state.busy) return; event.preventDefault(); event.stopPropagation(); const box = handle; const item = state.doc.objects.find((entry) => entry.id === box.dataset.id); if (!item || item.type !== 'image') return; state.selectedId = item.id; state.selectedIds = new Set([item.id]); renderList(); syncInspector(); const pageRect = elements.pageWrap.getBoundingClientRect(); state.drag = { kind: 'resize', pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, width: pageRect.width, height: pageRect.height, targetId: item.id, didMove: false, positions: [{ item, x: item.x, y: item.y, width: item.width, height: item.height }] }; box.setPointerCapture?.(event.pointerId); } function moveDrag(event) { if (!state.drag || event.pointerId !== state.drag.pointerId) return; const drag = state.drag; const screenDx = event.clientX - drag.startX; const screenDy = event.clientY - drag.startY; // Pointer jitter during a click must never turn a selection into a PDF edit. if (!drag.didMove && Math.hypot(screenDx, screenDy) < 4) return; drag.didMove = true; state.suppressClick = true; const dx = screenDx / Math.max(1, drag.width) * activePage().width; const dy = screenDy / Math.max(1, drag.height) * activePage().height; const page = activePage(); if (drag.kind === 'resize') { const { item, x, y, width, height } = drag.positions[0]; const ratio = width / Math.max(0.01, height); const maxWidth = Math.max(1, page.width - x); const maxHeight = Math.max(1, page.height - y); if (elements.preserveImageAspect.checked) { // Let either handle axis resize a locked image; otherwise a mostly vertical // corner drag appears to do nothing. Use the axis with the larger // aspect-adjusted movement to keep pointer control predictable. const widthDelta = Math.abs(dx) >= Math.abs(dy * ratio) ? dx : dy * ratio; item.width = Math.min(maxWidth, maxHeight * ratio, Math.max(1, width + widthDelta)); item.height = item.width / ratio; } else { item.width = Math.min(maxWidth, Math.max(1, width + dx)); item.height = Math.min(maxHeight, Math.max(1, height + dy)); } item.manualPosition = true; const box = elements.overlay.querySelector(`.text-hitbox[data-id="${item.id}"]`); if (box) { box.style.width = `${item.width / page.width * 100}%`; box.style.height = `${item.height / page.height * 100}%`; } elements.width.value = Number(item.width).toFixed(1); elements.height.value = Number(item.height).toFixed(1); drawOriginalPositions(); scheduleLivePreview(); syncInspector(); return; } drag.positions.forEach(({ item, x, y }) => { const backgroundImage = item.isBackgroundText && item.backgroundImageId ? state.doc.objects.find((entry) => entry.id === item.backgroundImageId && entry.type === 'image') : null; const minX = backgroundImage?.x ?? 0; const minY = backgroundImage?.y ?? 0; const maxX = backgroundImage ? backgroundImage.x + backgroundImage.width - item.width : page.width - item.width; const maxY = backgroundImage ? backgroundImage.y + backgroundImage.height - item.height : page.height - item.height; item.x = Math.min(Math.max(minX, x + dx), Math.max(minX, maxX)); item.y = Math.min(Math.max(minY, y + dy), Math.max(minY, maxY)); item.manualPosition = true; }); elements.overlay.querySelectorAll('.text-hitbox').forEach((box) => { const item = state.doc.objects.find((entry) => entry.id === box.dataset.id); if (!item) return; box.style.left = `${item.x / page.width * 100}%`; box.style.top = `${item.y / page.height * 100}%`; }); drawOriginalPositions(); scheduleLivePreview(); syncInspector(); } function endDrag(event) { if (!state.drag || event.pointerId !== state.drag.pointerId) return; const drag = state.drag; state.drag = null; if (drag.didMove) { state.ignoreClickId = drag.targetId; setTimeout(() => { if (state.ignoreClickId === drag.targetId) state.ignoreClickId = null; }, 100); commitHistory(); renderList(); syncInspector(); setTimeout(() => { state.suppressClick = false; }, 0); } else state.suppressClick = false; } function deleteSelection() { const selected = state.doc?.objects.filter((item) => state.selectedIds.has(item.id)) || []; if (!selected.length) return; if (selected.some((item) => item.isBackgroundText && item.source === 'native')) { setStatus('Covered native text is locked so its foreground layers stay unchanged.', 'error'); return; } selected.forEach((item) => { item.deleted = !selected.every((entry) => entry.deleted); }); reflowTextLayout(); commitHistory(); render(); setStatus(selected[0].deleted ? 'Selected objects marked for deletion.' : 'Selected objects restored.', ''); } async function analyzePage(options = {}) { if (!state.doc || state.busy) return; state.busy = true; elements.analyze.disabled = true; setStatus('Reading page text and matching fonts with local models. First use can take a few minutes.', 'busy'); elements.modelStatus.textContent = 'Loading local open models…'; try { const pageBefore = activePage(); const refresh = options.refresh ?? Boolean(pageBefore?.analyzed); const query = new URLSearchParams({ refresh: String(refresh) }); const response = await fetch(`/api/documents/${state.doc.id}/pages/${state.page}/analyze?${query}`, { method: 'POST' }); const body = await response.json(); if (!response.ok) throw new Error(body.detail || 'Page analysis failed'); const page = activePage(); page.analyzed = true; page.regions = body.regions; const localObjects = new Map(state.doc.objects.filter((item) => item.page === state.page).map((item) => [item.id, item])); const analyzedObjects = body.objects.map((serverItem) => { const localItem = localObjects.get(serverItem.id); return localItem ? { ...serverItem, ...localItem, regionId: serverItem.regionId } : serverItem; }); state.doc.objects = state.doc.objects.filter((item) => item.page !== state.page).concat(analyzedObjects); state.selectedId = null; state.selectedIds.clear(); state.regionFilter = null; resetHistory(); render(); const nativeCount = body.objects.filter((item) => item.type === 'text' && item.source === 'native').length; const ocrCount = body.objects.filter((item) => item.type === 'text' && item.source === 'ocr').length; const ocrMessage = body.ocrRan ? `OCR checked ${body.ocrDetections} text areas; kept ${ocrCount} supplemental OCR items${nativeCount ? ` and ${nativeCount} native PDF items` : ''}.` : `Using saved OCR (${ocrCount} supplemental items)${nativeCount ? ` and ${nativeCount} native PDF items` : ''}.`; const fontMessage = body.fontModelError ? ` Font match unavailable; using measured glyph metrics (${body.fontModelError}).` : ` Font analysis: ${body.fontModel}.`; setStatus(`${ocrMessage} Found ${body.regions.length} page areas.${fontMessage} Review items before export.`, ''); elements.modelStatus.textContent = `${body.layoutModel} · ${body.ocrModel} · ${body.fontModel}`; elements.modelStatus.title = elements.modelStatus.textContent; } catch (error) { setStatus(error.message, 'error'); elements.modelStatus.textContent = 'Model unavailable'; } finally { state.busy = false; render(); } } function switchPage(pageNumber) { if (!state.doc || state.busy) return; state.page = Math.max(0, Math.min(state.doc.pages.length - 1, pageNumber)); state.selectedId = null; state.selectedIds.clear(); state.regionFilter = null; state.search = ''; state.creationTool = null; state.pendingImage = null; elements.search.value = ''; render(); } function replaceSelected() { const find = elements.replaceFind.value; if (!find || !state.selectedIds.size) return; if (state.doc.objects.some((item) => state.selectedIds.has(item.id) && item.isBackgroundText && item.source === 'native')) { setStatus('Covered native text is locked so its foreground layers stay unchanged.', 'error'); return; } let changed = 0; for (const item of state.doc.objects) { if (state.selectedIds.has(item.id) && item.type !== 'image' && item.text.includes(find)) { item.text = item.text.split(find).join(elements.replaceWith.value); changed += 1; } } if (changed) { reflowTextLayout(); commitHistory(); } render(); setStatus(changed ? `Replaced text in ${changed} item${changed === 1 ? '' : 's'}.` : 'No matching text found in the selected items.', changed ? '' : ''); } async function exportPdf() { if (!state.doc || state.busy) return; state.busy = true; elements.export.disabled = true; setStatus('Applying edits and preparing your PDF…', 'busy'); try { const response = await fetch(`/api/documents/${state.doc.id}/export`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(exportState()), }); if (!response.ok) { const body = await response.json(); throw new Error(body.detail || 'Could not export PDF'); } const blob = await response.blob(); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${state.doc.filename.replace(/\.pdf$/i, '')}-edited.pdf`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); setStatus('Export complete. Your edited PDF has downloaded.', ''); } catch (error) { setStatus(error.message, 'error'); } finally { state.busy = false; elements.export.disabled = false; } } elements.file.addEventListener('change', (event) => uploadFile(event.target.files[0])); elements.welcomeFile.addEventListener('change', (event) => uploadFile(event.target.files[0])); elements.objectScope.addEventListener('change', () => { state.objectScope = elements.objectScope.value; state.selectedId = null; state.selectedIds.clear(); state.regionFilter = null; render(); }); elements.addText.addEventListener('click', () => { state.creationTool = state.creationTool === 'text' ? null : 'text'; state.pendingImage = null; if (state.creationTool) { state.objectScope = 'all'; state.regionFilter = null; } render(); setStatus(state.creationTool ? 'Click the page where you want the new text.' : 'Text placement cancelled.'); }); elements.addImage.addEventListener('click', () => { if (!state.doc || state.busy) return; elements.imageUpload.click(); }); elements.imageUpload.addEventListener('change', (event) => { const file = event.target.files[0]; event.target.value = ''; uploadEditorImage(file); }); elements.pageWrap.addEventListener('pointerdown', placeNewObject, true); elements.gridToggle.addEventListener('click', () => { state.showGrid = !state.showGrid; updateToolControls(); updateGridOverlay(); setStatus(state.showGrid ? 'Alignment grid shown.' : 'Alignment grid hidden.'); }); elements.originalToggle.addEventListener('click', () => { state.showOriginalPositions = !state.showOriginalPositions; updateToolControls(); drawOriginalPositions(); setStatus(state.showOriginalPositions ? 'Original positions shown for moved items.' : 'Original position guides hidden.'); }); elements.pageSelect.addEventListener('change', (event) => switchPage(Number(event.target.value))); elements.prevPage.addEventListener('click', () => switchPage(state.page - 1)); elements.nextPage.addEventListener('click', () => switchPage(state.page + 1)); elements.export.addEventListener('click', exportPdf); elements.analyze.addEventListener('click', analyzePage); elements.undo.addEventListener('click', () => restoreHistory(state.historyIndex - 1)); elements.redo.addEventListener('click', () => restoreHistory(state.historyIndex + 1)); elements.search.addEventListener('input', () => { state.search = elements.search.value; renderList(); drawOverlays(); }); elements.selectVisible.addEventListener('change', () => { const visible = visibleObjects(); if (elements.selectVisible.checked) visible.forEach((item) => state.selectedIds.add(item.id)); else visible.forEach((item) => state.selectedIds.delete(item.id)); if (!state.selectedIds.has(state.selectedId)) state.selectedId = state.selectedIds.values().next().value || null; renderList(); drawOverlays(); syncInspector(); }); elements.text.addEventListener('input', (event) => updateSelected('text', event.target.value)); elements.fontFamily.addEventListener('change', (event) => updateSelected('fontFamily', event.target.value)); elements.fontSearch.addEventListener('input', (event) => { state.fontSearch = event.target.value; syncInspector(); }); function changeGeometry(field, rawValue) { const item = currentObject(); const page = activePage(); if (!item || !page) return; const value = Number(rawValue); if (!Number.isFinite(value)) return; const ratio = (item.originalWidth || item.width) / Math.max(0.01, item.originalHeight || item.height); const limits = { x: [0, Math.max(0, page.width - item.width)], y: [0, Math.max(0, page.height - item.height)], width: [1, Math.max(1, Math.min(page.width - item.x, item.type === 'image' && elements.preserveImageAspect.checked ? (page.height - item.y) * ratio : page.width))], height: [1, Math.max(1, Math.min(page.height - item.y, item.type === 'image' && elements.preserveImageAspect.checked ? (page.width - item.x) / ratio : page.height))], }; updateSelected(field, Math.min(limits[field][1], Math.max(limits[field][0], value))); } elements.x.addEventListener('input', (event) => changeGeometry('x', event.target.value)); elements.y.addEventListener('input', (event) => changeGeometry('y', event.target.value)); elements.width.addEventListener('input', (event) => changeGeometry('width', event.target.value)); elements.height.addEventListener('input', (event) => changeGeometry('height', event.target.value)); elements.size.addEventListener('input', (event) => updateSelected('fontSize', Math.min(144, Math.max(1, Number(event.target.value) || 1)))); elements.color.addEventListener('input', (event) => updateSelected('color', event.target.value)); elements.rotation.addEventListener('input', (event) => updateSelected('rotation', event.target.value)); elements.rotationPresets.forEach((button) => button.addEventListener('click', () => updateSelected('rotation', Number(button.dataset.rotation)))); elements.bold.addEventListener('change', (event) => updateSelected('bold', event.target.checked)); elements.italic.addEventListener('change', (event) => updateSelected('italic', event.target.checked)); elements.delete.addEventListener('click', () => { const item = currentObject(); if (!item || state.selectedIds.size > 1) return; if (item.isBackgroundText && item.source === 'native') { setStatus('Covered native text is locked so its foreground layers stay unchanged.', 'error'); return; } item.deleted = !item.deleted; reflowTextLayout(); commitHistory(); setStatus(item.deleted ? 'Object marked for deletion.' : 'Object restored.', ''); syncInspector(); renderList(); drawOverlays(); scheduleLivePreview(); }); elements.deleteSelection.addEventListener('click', deleteSelection); elements.replace.addEventListener('click', replaceSelected); elements.clearSelection.addEventListener('click', () => { state.selectedId = null; state.selectedIds.clear(); renderList(); drawOverlays(); syncInspector(); }); elements.zoomIn.addEventListener('click', () => { state.zoom = Math.min(1.6, state.zoom + .1); fitPage(); elements.zoomLabel.textContent = `${Math.round(state.zoom * 100)}%`; }); elements.zoomOut.addEventListener('click', () => { state.zoom = Math.max(.5, state.zoom - .1); fitPage(); elements.zoomLabel.textContent = `${Math.round(state.zoom * 100)}%`; }); window.addEventListener('resize', fitPage); window.addEventListener('pointermove', moveDrag); window.addEventListener('pointerup', endDrag); window.addEventListener('pointercancel', endDrag); window.addEventListener('keydown', (event) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a' && !['INPUT','TEXTAREA'].includes(document.activeElement.tagName)) { event.preventDefault(); elements.selectVisible.checked = true; elements.selectVisible.dispatchEvent(new Event('change')); } if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)) { event.preventDefault(); restoreHistory(state.historyIndex + (event.shiftKey ? 1 : -1)); } if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)) { event.preventDefault(); restoreHistory(state.historyIndex + 1); } if (event.key === 'Escape') { state.selectedId = null; state.selectedIds.clear(); renderList(); drawOverlays(); syncInspector(); } }); fetch('/api/fonts').then((response) => response.ok ? response.json() : { families: [] }).then((body) => { state.availableFonts = Array.isArray(body.families) ? body.families : []; if (currentObject()) syncInspector(); }).catch(() => { state.availableFonts = []; });