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 = []; });