editor revamp, complete change

This commit is contained in:
Anthony Stirling
2025-11-02 21:00:03 +00:00
parent ec0ae36a82
commit bbcb23ca11
25 changed files with 3747 additions and 1021 deletions

View File

@@ -11,8 +11,10 @@ import {
FileButton,
Group,
Pagination,
Progress,
ScrollArea,
Stack,
Switch,
Text,
Title,
} from '@mantine/core';
@@ -32,6 +34,7 @@ import {
PdfJsonEditorViewData,
PdfJsonFont,
PdfJsonPage,
ConversionProgress,
} from '@app/tools/pdfJsonEditor/pdfJsonEditorTypes';
import { getImageBounds, pageDimensions } from '@app/tools/pdfJsonEditor/pdfJsonEditorUtils';
@@ -205,6 +208,9 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
const [activeImageId, setActiveImageId] = useState<string | null>(null);
const [fontFamilies, setFontFamilies] = useState<Map<string, string>>(new Map());
const [textGroupsExpanded, setTextGroupsExpanded] = useState(false);
const [autoScaleText, setAutoScaleText] = useState(true);
const [textScales, setTextScales] = useState<Map<string, number>>(new Map());
const measurementKeyRef = useRef<string>('');
const containerRef = useRef<HTMLDivElement | null>(null);
const editorRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const caretOffsetsRef = useRef<Map<string, number>>(new Map());
@@ -220,6 +226,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
errorMessage,
isGeneratingPdf,
isConverting,
conversionProgress,
hasChanges,
onLoadJson,
onSelectPage,
@@ -562,8 +569,73 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
setActiveGroupId(null);
setEditingGroupId(null);
setActiveImageId(null);
setTextScales(new Map());
measurementKeyRef.current = '';
}, [selectedPage]);
// Measure text widths once per page/configuration and apply static scaling
useLayoutEffect(() => {
if (!autoScaleText || visibleGroups.length === 0) {
return;
}
// Create a stable key for this measurement configuration
const currentKey = `${selectedPage}-${fontFamilies.size}-${autoScaleText}`;
// Skip if we've already measured for this configuration
if (measurementKeyRef.current === currentKey) {
return;
}
const measureTextScales = () => {
const newScales = new Map<string, number>();
visibleGroups.forEach((group) => {
// Skip groups that are being edited
if (editingGroupId === group.id) {
return;
}
const element = document.querySelector<HTMLElement>(`[data-text-group="${group.id}"]`);
if (!element) {
return;
}
const textSpan = element.querySelector<HTMLSpanElement>('span[data-text-content]');
if (!textSpan) {
return;
}
// Temporarily remove any existing transform to get natural width
const originalTransform = textSpan.style.transform;
textSpan.style.transform = 'none';
const bounds = toCssBounds(currentPage, pageHeight, scale, group.bounds);
const containerWidth = bounds.width;
const textWidth = textSpan.getBoundingClientRect().width;
// Restore original transform
textSpan.style.transform = originalTransform;
// Only scale if text overflows by more than 2%
if (textWidth > 0 && textWidth > containerWidth * 1.02) {
const scaleX = Math.max(containerWidth / textWidth, 0.5); // Min 50% scale
newScales.set(group.id, scaleX);
} else {
newScales.set(group.id, 1);
}
});
// Mark this configuration as measured
measurementKeyRef.current = currentKey;
setTextScales(newScales);
};
// Delay measurement to ensure fonts and layout are ready
const timer = setTimeout(measureTextScales, 150);
return () => clearTimeout(timer);
}, [autoScaleText, visibleGroups, editingGroupId, currentPage, pageHeight, scale, fontFamilies.size, selectedPage]);
useLayoutEffect(() => {
if (!editingGroupId) {
return;
@@ -726,6 +798,27 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
{t('pdfJsonEditor.currentFile', 'Current file: {{name}}', { name: fileName })}
</Text>
)}
<Divider my="sm" />
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t('pdfJsonEditor.options.autoScaleText.title', 'Auto-scale text to fit boxes')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
'pdfJsonEditor.options.autoScaleText.description',
'Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF.'
)}
</Text>
</div>
<Switch
size="md"
checked={autoScaleText}
onChange={(event) => setAutoScaleText(event.currentTarget.checked)}
/>
</Group>
</Stack>
</Card>
@@ -782,10 +875,39 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
{isConverting && (
<Card withBorder radius="md" padding="xl">
<Stack align="center" gap="md">
<AutorenewIcon sx={{ fontSize: 48 }} className="animate-spin" />
<Text size="lg" fw={600}>
{t('pdfJsonEditor.converting', 'Converting PDF to editable format...')}
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<div style={{ flex: 1 }}>
<Text size="lg" fw={600} mb="xs">
{conversionProgress
? conversionProgress.message
: t('pdfJsonEditor.converting', 'Converting PDF to editable format...')}
</Text>
{conversionProgress && (
<Group gap="xs">
<Text size="sm" c="dimmed" tt="capitalize">
{t(`pdfJsonEditor.stages.${conversionProgress.stage}`, conversionProgress.stage)}
</Text>
{conversionProgress.current !== undefined &&
conversionProgress.total !== undefined && (
<Text size="sm" c="dimmed">
Page {conversionProgress.current} of {conversionProgress.total}
</Text>
)}
</Group>
)}
</div>
<AutorenewIcon sx={{ fontSize: 36 }} className="animate-spin" />
</Group>
<Progress
value={conversionProgress?.percent || 0}
size="lg"
radius="md"
animated
striped
/>
<Text size="sm" c="dimmed" ta="right">
{conversionProgress?.percent || 0}% complete
</Text>
</Stack>
</Card>
@@ -1105,6 +1227,9 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
);
}
const textScale = textScales.get(group.id) ?? 1;
const shouldScale = autoScaleText && textScale < 0.98;
return (
<Box key={group.id} style={containerStyle}>
{renderGroupContainer(
@@ -1112,6 +1237,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
isActive,
changed,
<div
data-text-group={group.id}
style={{
width: '100%',
minHeight: '100%',
@@ -1127,7 +1253,17 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
overflow: 'visible',
}}
>
<span style={{ pointerEvents: 'none' }}>{group.text || '\u00A0'}</span>
<span
data-text-content
style={{
pointerEvents: 'none',
display: 'inline-block',
transform: shouldScale ? `scaleX(${textScale})` : undefined,
transformOrigin: 'left center',
}}
>
{group.text || '\u00A0'}
</span>
</div>,
() => {
setEditingGroupId(group.id);

View File

@@ -27,8 +27,8 @@ export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
"home.pdfJsonEditor.desc",
"Review and edit Stirling PDF JSON exports with grouped text editing and PDF regeneration"
),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
workbench: "custom:pdfJsonEditor",
endpoints: ["json-pdf"],
synonyms: getSynonyms(t, "pdfJsonEditor"),

View File

@@ -13,6 +13,7 @@ import { getFilenameFromHeaders } from '@app/utils/fileResponseUtils';
import {
PdfJsonDocument,
PdfJsonImageElement,
PdfJsonPage,
TextGroup,
PdfJsonEditorViewData,
} from './pdfJsonEditorTypes';
@@ -68,11 +69,39 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [isConverting, setIsConverting] = useState(false);
const [conversionProgress, setConversionProgress] = useState<{
percent: number;
stage: string;
message: string;
} | null>(null);
// Lazy loading state
const [isLazyMode, setIsLazyMode] = useState(false);
const [cachedJobId, setCachedJobId] = useState<string | null>(null);
const [loadedImagePages, setLoadedImagePages] = useState<Set<number>>(new Set());
const [loadingImagePages, setLoadingImagePages] = useState<Set<number>>(new Set());
const originalImagesRef = useRef<PdfJsonImageElement[][]>([]);
const imagesByPageRef = useRef<PdfJsonImageElement[][]>([]);
const autoLoadKeyRef = useRef<string | null>(null);
const loadRequestIdRef = useRef(0);
const latestPdfRequestIdRef = useRef<number | null>(null);
const loadedDocumentRef = useRef<PdfJsonDocument | null>(null);
const loadedImagePagesRef = useRef<Set<number>>(new Set());
const loadingImagePagesRef = useRef<Set<number>>(new Set());
// Keep ref in sync with state for access in async callbacks
useEffect(() => {
loadedDocumentRef.current = loadedDocument;
}, [loadedDocument]);
useEffect(() => {
loadedImagePagesRef.current = new Set(loadedImagePages);
}, [loadedImagePages]);
useEffect(() => {
loadingImagePagesRef.current = new Set(loadingImagePages);
}, [loadingImagePages]);
const dirtyPages = useMemo(
() => getDirtyPages(groupsByPage, imagesByPage, originalImagesRef.current),
@@ -88,18 +117,134 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
setGroupsByPage([]);
setImagesByPage([]);
originalImagesRef.current = [];
imagesByPageRef.current = [];
setLoadedImagePages(new Set());
setLoadingImagePages(new Set());
loadedImagePagesRef.current = new Set();
loadingImagePagesRef.current = new Set();
setSelectedPage(0);
return;
}
const cloned = deepCloneDocument(document);
const groups = groupDocumentText(cloned);
const images = extractDocumentImages(cloned);
originalImagesRef.current = images.map((page) => page.map(cloneImageElement));
const originalImages = images.map((page) => page.map(cloneImageElement));
originalImagesRef.current = originalImages;
imagesByPageRef.current = images.map((page) => page.map(cloneImageElement));
const initialLoaded = new Set<number>();
originalImages.forEach((pageImages, index) => {
if (pageImages.length > 0) {
initialLoaded.add(index);
}
});
setGroupsByPage(groups);
setImagesByPage(images);
setLoadedImagePages(initialLoaded);
setLoadingImagePages(new Set());
loadedImagePagesRef.current = new Set(initialLoaded);
loadingImagePagesRef.current = new Set();
setSelectedPage(0);
}, []);
// Load images for a page in lazy mode
const loadImagesForPage = useCallback(
async (pageIndex: number) => {
if (!isLazyMode) {
return;
}
if (!cachedJobId) {
console.log('[loadImagesForPage] No cached jobId, skipping');
return;
}
if (
loadedImagePagesRef.current.has(pageIndex) ||
loadingImagePagesRef.current.has(pageIndex)
) {
return;
}
loadingImagePagesRef.current.add(pageIndex);
setLoadingImagePages((prev) => {
const next = new Set(prev);
next.add(pageIndex);
return next;
});
const pageNumber = pageIndex + 1;
const start = performance.now();
try {
const response = await apiClient.get(
`/api/v1/convert/pdf/json/page/${cachedJobId}/${pageNumber}`,
{
responseType: 'json',
},
);
const pageData = response.data as PdfJsonPage;
const normalizedImages = (pageData.imageElements ?? []).map(cloneImageElement);
if (imagesByPageRef.current.length <= pageIndex) {
imagesByPageRef.current.length = pageIndex + 1;
}
imagesByPageRef.current[pageIndex] = normalizedImages.map(cloneImageElement);
setLoadedDocument((prevDoc) => {
if (!prevDoc || !prevDoc.pages) {
return prevDoc;
}
const nextPages = [...prevDoc.pages];
const existingPage = nextPages[pageIndex] ?? {};
nextPages[pageIndex] = {
...existingPage,
imageElements: normalizedImages.map(cloneImageElement),
};
return {
...prevDoc,
pages: nextPages,
};
});
setImagesByPage((prev) => {
const next = [...prev];
while (next.length <= pageIndex) {
next.push([]);
}
next[pageIndex] = normalizedImages.map(cloneImageElement);
return next;
});
if (originalImagesRef.current.length <= pageIndex) {
originalImagesRef.current.length = pageIndex + 1;
}
originalImagesRef.current[pageIndex] = normalizedImages.map(cloneImageElement);
setLoadedImagePages((prev) => {
const next = new Set(prev);
next.add(pageIndex);
return next;
});
loadedImagePagesRef.current.add(pageIndex);
console.log(
`[loadImagesForPage] Loaded ${normalizedImages.length} images for page ${pageNumber} in ${(
performance.now() - start
).toFixed(2)}ms`,
);
} catch (error) {
console.error(`[loadImagesForPage] Failed to load images for page ${pageNumber}:`, error);
} finally {
loadingImagePagesRef.current.delete(pageIndex);
setLoadingImagePages((prev) => {
const next = new Set(prev);
next.delete(pageIndex);
return next;
});
}
},
[isLazyMode, cachedJobId],
);
const handleLoadFile = useCallback(
async (file: File | null) => {
if (!file) {
@@ -113,39 +258,200 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
try {
let parsed: PdfJsonDocument;
let parsed: PdfJsonDocument | null = null;
let shouldUseLazyMode = false;
let pendingJobId: string | null = null;
setErrorMessage(null);
if (isPdf) {
latestPdfRequestIdRef.current = requestId;
setIsConverting(true);
setConversionProgress({
percent: 0,
stage: 'uploading',
message: 'Uploading PDF file to server...',
});
const formData = new FormData();
formData.append('fileInput', file);
const response = await apiClient.post(CONVERSION_ENDPOINTS['pdf-json'], formData, {
responseType: 'blob',
console.log('Sending conversion request with async=true');
const response = await apiClient.post(
`${CONVERSION_ENDPOINTS['pdf-json']}?async=true`,
formData,
{
responseType: 'json',
},
);
console.log('Conversion response:', response.data);
const jobId = response.data.jobId;
if (!jobId) {
console.error('No job ID in response:', response.data);
throw new Error('No job ID received from server');
}
pendingJobId = jobId;
console.log('Got job ID:', jobId);
setConversionProgress({
percent: 3,
stage: 'processing',
message: 'Starting conversion...',
});
const jsonText = await response.data.text();
parsed = JSON.parse(jsonText) as PdfJsonDocument;
let jobComplete = false;
let attempts = 0;
const maxAttempts = 600;
while (!jobComplete && attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 1000));
attempts += 1;
try {
const statusResponse = await apiClient.get(`/api/v1/general/job/${jobId}`);
const jobStatus = statusResponse.data;
console.log(`Job status (attempt ${attempts}):`, jobStatus);
if (jobStatus.notes && jobStatus.notes.length > 0) {
const lastNote = jobStatus.notes[jobStatus.notes.length - 1];
console.log('Latest note:', lastNote);
const matchWithCount = lastNote.match(
/\[(\d+)%\]\s+(\w+):\s+(.+?)\s+\((\d+)\/(\d+)\)/,
);
if (matchWithCount) {
const percent = parseInt(matchWithCount[1], 10);
const stage = matchWithCount[2];
const message = matchWithCount[3];
const current = parseInt(matchWithCount[4], 10);
const total = parseInt(matchWithCount[5], 10);
setConversionProgress({
percent,
stage,
message,
current,
total,
});
} else {
const match = lastNote.match(/\[(\d+)%\]\s+(\w+):\s+(.+)/);
if (match) {
const percent = parseInt(match[1], 10);
const stage = match[2];
const message = match[3];
setConversionProgress({
percent,
stage,
message,
});
}
}
} else if (jobStatus.progress !== undefined) {
const percent = Math.min(Math.max(jobStatus.progress, 0), 100);
setConversionProgress({
percent,
stage: jobStatus.stage || 'processing',
message: jobStatus.note || 'Converting PDF to JSON...',
});
}
if (jobStatus.complete) {
if (jobStatus.error) {
console.error('Job failed:', jobStatus.error);
throw new Error(jobStatus.error);
}
console.log('Job completed, retrieving JSON result...');
jobComplete = true;
const resultResponse = await apiClient.get(
`/api/v1/general/job/${jobId}/result`,
{
responseType: 'blob',
},
);
const jsonText = await resultResponse.data.text();
const result = JSON.parse(jsonText);
if (!Array.isArray(result.pages)) {
console.error('Conversion result missing page array:', result);
throw new Error(
'PDF conversion result did not include page data. Please update the server.',
);
}
const docResult = result as PdfJsonDocument;
parsed = {
...docResult,
pages: docResult.pages ?? [],
};
shouldUseLazyMode = Boolean(docResult.lazyImages);
pendingJobId = shouldUseLazyMode ? jobId : null;
setConversionProgress(null);
} else {
console.log('Job not complete yet, continuing to poll...');
}
} catch (pollError: any) {
console.error('Error polling job status:', pollError);
console.error('Poll error details:', {
status: pollError?.response?.status,
data: pollError?.response?.data,
message: pollError?.message,
});
if (pollError?.response?.status === 404) {
throw new Error('Job not found on server');
}
}
}
if (!jobComplete) {
throw new Error('Conversion timed out');
}
if (!parsed) {
throw new Error('Conversion did not return JSON content');
}
} else {
const content = await file.text();
parsed = JSON.parse(content) as PdfJsonDocument;
const docResult = JSON.parse(content) as PdfJsonDocument;
parsed = {
...docResult,
pages: docResult.pages ?? [],
};
shouldUseLazyMode = false;
pendingJobId = null;
}
setConversionProgress(null);
if (loadRequestIdRef.current !== requestId) {
return;
}
if (!parsed) {
throw new Error('Failed to parse PDF JSON document');
}
console.log(
`[PdfJsonEditor] Document loaded. Lazy image mode: ${shouldUseLazyMode}, Pages: ${
parsed.pages?.length || 0
}`,
);
setLoadedDocument(parsed);
resetToDocument(parsed);
setIsLazyMode(shouldUseLazyMode);
setCachedJobId(shouldUseLazyMode ? pendingJobId : null);
setFileName(file.name);
setErrorMessage(null);
autoLoadKeyRef.current = fileKey;
} catch (error) {
} catch (error: any) {
console.error('Failed to load file', error);
console.error('Error details:', {
message: error?.message,
response: error?.response?.data,
stack: error?.stack,
});
if (loadRequestIdRef.current !== requestId) {
return;
@@ -155,15 +461,17 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
resetToDocument(null);
if (isPdf) {
setErrorMessage(
t('pdfJsonEditor.conversionFailed', 'Failed to convert PDF. Please try again.')
);
const errorMsg =
error?.message ||
t('pdfJsonEditor.conversionFailed', 'Failed to convert PDF. Please try again.');
setErrorMessage(errorMsg);
console.error('Setting error message:', errorMsg);
} else {
setErrorMessage(
t(
'pdfJsonEditor.errors.invalidJson',
'Unable to read the JSON file. Ensure it was generated by the PDF to JSON tool.'
)
'Unable to read the JSON file. Ensure it was generated by the PDF to JSON tool.',
),
);
}
} finally {
@@ -172,12 +480,16 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
}
}
},
[resetToDocument, t]
[resetToDocument, t],
);
const handleSelectPage = useCallback((pageIndex: number) => {
setSelectedPage(pageIndex);
}, []);
// Trigger lazy loading for images on the selected page
if (isLazyMode) {
void loadImagesForPage(pageIndex);
}
}, [isLazyMode, loadImagesForPage]);
const handleGroupTextChange = useCallback((pageIndex: number, groupId: string, value: string) => {
setGroupsByPage((previous) =>
@@ -195,55 +507,63 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
imageId: string,
next: { left: number; bottom: number; width: number; height: number; transform: number[] },
) => {
setImagesByPage((previous) =>
previous.map((images, idx) => {
if (idx !== pageIndex) {
return images;
setImagesByPage((previous) => {
const current = previous[pageIndex] ?? [];
let changed = false;
const updatedPage = current.map((image) => {
if ((image.id ?? '') !== imageId) {
return image;
}
let changed = false;
const updated = images.map((image) => {
if ((image.id ?? '') !== imageId) {
return image;
}
const originalTransform = image.transform ?? originalImagesRef.current[idx]?.find((base) => (base.id ?? '') === imageId)?.transform;
const scaleXSign = originalTransform && originalTransform.length >= 6 ? Math.sign(originalTransform[0]) || 1 : 1;
const scaleYSign = originalTransform && originalTransform.length >= 6 ? Math.sign(originalTransform[3]) || 1 : 1;
const right = next.left + next.width;
const top = next.bottom + next.height;
const updatedImage: PdfJsonImageElement = {
...image,
x: next.left,
y: next.bottom,
left: next.left,
bottom: next.bottom,
right,
top,
width: next.width,
height: next.height,
transform: scaleXSign < 0 || scaleYSign < 0 ? [
next.width * scaleXSign,
0,
0,
next.height * scaleYSign,
next.left,
scaleYSign >= 0 ? next.bottom : next.bottom + next.height,
] : null,
};
const originalTransform = image.transform ?? originalImagesRef.current[pageIndex]?.find((base) => (base.id ?? '') === imageId)?.transform;
const scaleXSign = originalTransform && originalTransform.length >= 6 ? Math.sign(originalTransform[0]) || 1 : 1;
const scaleYSign = originalTransform && originalTransform.length >= 6 ? Math.sign(originalTransform[3]) || 1 : 1;
const right = next.left + next.width;
const top = next.bottom + next.height;
const updatedImage: PdfJsonImageElement = {
...image,
x: next.left,
y: next.bottom,
left: next.left,
bottom: next.bottom,
right,
top,
width: next.width,
height: next.height,
transform: scaleXSign < 0 || scaleYSign < 0
? [
next.width * scaleXSign,
0,
0,
next.height * scaleYSign,
next.left,
scaleYSign >= 0 ? next.bottom : next.bottom + next.height,
]
: null,
};
const isSame =
Math.abs(valueOr(image.left, 0) - next.left) < 1e-4 &&
Math.abs(valueOr(image.bottom, 0) - next.bottom) < 1e-4 &&
Math.abs(valueOr(image.width, 0) - next.width) < 1e-4 &&
Math.abs(valueOr(image.height, 0) - next.height) < 1e-4;
const isSame =
Math.abs(valueOr(image.left, 0) - next.left) < 1e-4 &&
Math.abs(valueOr(image.bottom, 0) - next.bottom) < 1e-4 &&
Math.abs(valueOr(image.width, 0) - next.width) < 1e-4 &&
Math.abs(valueOr(image.height, 0) - next.height) < 1e-4;
if (!isSame) {
changed = true;
}
return updatedImage;
});
return changed ? updated : images;
}),
);
if (!isSame) {
changed = true;
}
return updatedImage;
});
if (!changed) {
return previous;
}
const nextImages = previous.map((images, idx) => (idx === pageIndex ? updatedPage : images));
if (imagesByPageRef.current.length <= pageIndex) {
imagesByPageRef.current.length = pageIndex + 1;
}
imagesByPageRef.current[pageIndex] = updatedPage.map(cloneImageElement);
return nextImages;
});
},
[],
);
@@ -253,14 +573,28 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
if (!baseline) {
return;
}
setImagesByPage((previous) =>
previous.map((images, idx) => {
if (idx !== pageIndex) {
return images;
setImagesByPage((previous) => {
const current = previous[pageIndex] ?? [];
let changed = false;
const updatedPage = current.map((image) => {
if ((image.id ?? '') !== imageId) {
return image;
}
return images.map((image) => ((image.id ?? '') === imageId ? cloneImageElement(baseline) : image));
}),
);
changed = true;
return cloneImageElement(baseline);
});
if (!changed) {
return previous;
}
const nextImages = previous.map((images, idx) => (idx === pageIndex ? updatedPage : images));
if (imagesByPageRef.current.length <= pageIndex) {
imagesByPageRef.current.length = pageIndex + 1;
}
imagesByPageRef.current[pageIndex] = updatedPage.map(cloneImageElement);
return nextImages;
});
}, []);
const handleResetEdits = useCallback(() => {
@@ -279,7 +613,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
const updatedDocument = restoreGlyphElements(
loadedDocument,
groupsByPage,
imagesByPage,
imagesByPageRef.current,
originalImagesRef.current,
);
const baseName = sanitizeBaseName(fileName || loadedDocument.metadata?.title || undefined);
@@ -287,7 +621,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
document: updatedDocument,
filename: `${baseName}.json`,
};
}, [fileName, groupsByPage, imagesByPage, loadedDocument]);
}, [fileName, groupsByPage, loadedDocument]);
const handleDownloadJson = useCallback(() => {
const payload = buildPayload();
@@ -306,20 +640,129 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
}, [buildPayload, onComplete]);
const handleGeneratePdf = useCallback(async () => {
const payload = buildPayload();
if (!payload) {
return;
}
const { document, filename } = payload;
const serialized = JSON.stringify(document, null, 2);
const jsonFile = new File([serialized], filename, { type: 'application/json' });
const formData = new FormData();
formData.append('fileInput', jsonFile);
try {
setIsGeneratingPdf(true);
const ensureImagesForPages = async (pageIndices: number[]) => {
const uniqueIndices = Array.from(new Set(pageIndices)).filter((index) => index >= 0);
if (uniqueIndices.length === 0) {
return;
}
for (const index of uniqueIndices) {
if (!loadedImagePagesRef.current.has(index)) {
await loadImagesForPage(index);
}
}
const maxWaitTime = 15000;
const pollInterval = 150;
const startWait = Date.now();
while (Date.now() - startWait < maxWaitTime) {
const allLoaded = uniqueIndices.every(
(index) =>
loadedImagePagesRef.current.has(index) &&
imagesByPageRef.current[index] !== undefined,
);
const anyLoading = uniqueIndices.some((index) =>
loadingImagePagesRef.current.has(index),
);
if (allLoaded && !anyLoading) {
return;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
const missing = uniqueIndices.filter(
(index) => !loadedImagePagesRef.current.has(index),
);
if (missing.length > 0) {
throw new Error(
`Failed to load images for pages ${missing.map((i) => i + 1).join(', ')}`,
);
}
};
const currentDoc = loadedDocumentRef.current;
const totalPages = currentDoc?.pages?.length ?? 0;
const dirtyPageIndices = dirtyPages
.map((isDirty, index) => (isDirty ? index : -1))
.filter((index) => index >= 0);
const canUseIncremental =
isLazyMode &&
cachedJobId &&
dirtyPageIndices.length > 0 &&
dirtyPageIndices.length < totalPages;
if (canUseIncremental) {
await ensureImagesForPages(dirtyPageIndices);
try {
const payload = buildPayload();
if (!payload) {
return;
}
const { document, filename } = payload;
const dirtyPageSet = new Set(dirtyPageIndices);
const partialPages =
document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? [];
const partialDocument: PdfJsonDocument = {
metadata: document.metadata,
xmpMetadata: document.xmpMetadata,
fonts: document.fonts,
lazyImages: true,
pages: partialPages,
};
const baseName = sanitizeBaseName(filename).replace(/-edited$/u, '');
const expectedName = `${baseName || 'document'}.pdf`;
const response = await apiClient.post(
`/api/v1/convert/pdf/json/partial/${cachedJobId}?filename=${encodeURIComponent(expectedName)}`,
partialDocument,
{
responseType: 'blob',
},
);
const contentDisposition = response.headers?.['content-disposition'] ?? '';
const detectedName = getFilenameFromHeaders(contentDisposition);
const downloadName = detectedName || expectedName;
downloadBlob(response.data, downloadName);
if (onComplete) {
const pdfFile = new File([response.data], downloadName, { type: 'application/pdf' });
onComplete([pdfFile]);
}
setErrorMessage(null);
return;
} catch (incrementalError) {
console.warn(
'[handleGeneratePdf] Incremental export failed, falling back to full export',
incrementalError,
);
}
}
if (isLazyMode && totalPages > 0) {
const allPageIndices = Array.from({ length: totalPages }, (_, index) => index);
await ensureImagesForPages(allPageIndices);
}
const payload = buildPayload();
if (!payload) {
return;
}
const { document, filename } = payload;
const serialized = JSON.stringify(document, null, 2);
const jsonFile = new File([serialized], filename, { type: 'application/json' });
const formData = new FormData();
formData.append('fileInput', jsonFile);
const response = await apiClient.post(CONVERSION_ENDPOINTS['json-pdf'], formData, {
responseType: 'blob',
});
@@ -350,7 +793,16 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
} finally {
setIsGeneratingPdf(false);
}
}, [buildPayload, onComplete, onError, t]);
}, [
buildPayload,
cachedJobId,
dirtyPages,
isLazyMode,
loadImagesForPage,
onComplete,
onError,
t,
]);
const viewData = useMemo<PdfJsonEditorViewData>(() => ({
document: loadedDocument,
@@ -363,6 +815,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
errorMessage,
isGeneratingPdf,
isConverting,
conversionProgress,
hasChanges,
onLoadJson: handleLoadFile,
onSelectPage: handleSelectPage,
@@ -390,6 +843,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
hasDocument,
isGeneratingPdf,
isConverting,
conversionProgress,
loadedDocument,
selectedPage,
]);
@@ -397,6 +851,13 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
const latestViewDataRef = useRef<PdfJsonEditorViewData>(viewData);
latestViewDataRef.current = viewData;
// Trigger initial image loading in lazy mode
useEffect(() => {
if (isLazyMode && loadedDocument) {
void loadImagesForPage(selectedPage);
}
}, [isLazyMode, loadedDocument, selectedPage, loadImagesForPage]);
useEffect(() => {
if (selectedFiles.length === 0) {
autoLoadKeyRef.current = null;
@@ -433,11 +894,20 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
setCustomWorkbenchViewData(VIEW_ID, latestViewDataRef.current);
return () => {
// Clear backend cache if we were using lazy loading
if (cachedJobId) {
console.log(`[PdfJsonEditor] Cleaning up cached document for jobId: ${cachedJobId}`);
apiClient.post(`/api/v1/convert/pdf/json/clear-cache/${cachedJobId}`).catch((error) => {
console.warn('[PdfJsonEditor] Failed to clear cache:', error);
});
}
clearCustomWorkbenchViewData(VIEW_ID);
unregisterCustomWorkbenchView(VIEW_ID);
setLeftPanelView('toolPicker');
};
}, [
cachedJobId,
clearCustomWorkbenchViewData,
registerCustomWorkbenchView,
setCustomWorkbenchViewData,

View File

@@ -122,6 +122,23 @@ export interface PdfJsonDocument {
xmpMetadata?: string | null;
fonts?: PdfJsonFont[] | null;
pages?: PdfJsonPage[] | null;
lazyImages?: boolean | null;
}
export interface PdfJsonPageDimension {
pageNumber?: number | null;
width?: number | null;
height?: number | null;
rotation?: number | null;
}
export interface PdfJsonDocumentMetadata {
metadata?: PdfJsonMetadata | null;
xmpMetadata?: string | null;
fonts?: PdfJsonFont[] | null;
pageDimensions?: PdfJsonPageDimension[] | null;
formFields?: unknown[] | null;
lazyImages?: boolean | null;
}
export interface BoundingBox {
@@ -153,6 +170,14 @@ export interface TextGroup {
export const DEFAULT_PAGE_WIDTH = 612;
export const DEFAULT_PAGE_HEIGHT = 792;
export interface ConversionProgress {
percent: number;
stage: string;
message: string;
current?: number;
total?: number;
}
export interface PdfJsonEditorViewData {
document: PdfJsonDocument | null;
groupsByPage: TextGroup[][];
@@ -164,6 +189,7 @@ export interface PdfJsonEditorViewData {
errorMessage: string | null;
isGeneratingPdf: boolean;
isConverting: boolean;
conversionProgress: ConversionProgress | null;
hasChanges: boolean;
onLoadJson: (file: File | null) => Promise<void> | void;
onSelectPage: (pageIndex: number) => void;