styling changes to text editor

This commit is contained in:
EthanHealy01 2025-12-04 20:12:01 +00:00
parent 7459463a3c
commit 92a0f3df9d
7 changed files with 472 additions and 336 deletions

View File

@ -5963,7 +5963,7 @@ title = "Authentication Failed"
message = "Authentication was not successful. You can close this window and try again."
[pdfTextEditor]
title = "PDF JSON Editor"
title = "PDF Text Editor"
viewLabel = "PDF Editor"
converting = "Converting PDF to editable format..."
conversionFailed = "Failed to convert PDF. Please try again."
@ -5992,6 +5992,8 @@ reset = "Reset Changes"
downloadJson = "Download JSON"
generatePdf = "Generate PDF"
saveChanges = "Save Changes"
applyChanges = "Apply Changes"
downloadCopy = "Download Copy"
[pdfTextEditor.options.autoScaleText]
title = "Auto-scale text to fit boxes"
@ -6010,6 +6012,9 @@ descriptionInline = "Tip: Hold Ctrl (Cmd) or Shift to multi-select text boxes. A
title = "Lock edited text to a single PDF element"
description = "When enabled, the editor exports each edited text box as one PDF text element to avoid overlapping glyphs or mixed fonts."
[pdfTextEditor.options.advanced]
title = "Advanced Settings"
[pdfTextEditor.manual]
mergeTooltip = "Merge selected boxes"
merge = "Merge selection"

View File

@ -1,10 +1,11 @@
import React, { useMemo, useState } from 'react';
import {
Accordion,
Badge,
Box,
Code,
Collapse,
Divider,
Flex,
Group,
List,
Paper,
@ -29,10 +30,14 @@ import {
getFontStatusColor,
getFontStatusDescription,
} from '@app/tools/pdfTextEditor/fontAnalysis';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip as CustomTooltip } from '@app/components/shared/Tooltip';
interface FontStatusPanelProps {
document: PdfJsonDocument | null;
pageIndex?: number;
isCollapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
}
const FontStatusBadge = ({ analysis }: { analysis: FontAnalysis }) => {
@ -74,25 +79,31 @@ const FontDetailItem = ({ analysis }: { analysis: FontAnalysis }) => {
const [expanded, setExpanded] = useState(false);
return (
<Paper withBorder p="xs" style={{ cursor: 'pointer' }} onClick={() => setExpanded(!expanded)}>
<Paper withBorder px="sm" py="md" style={{ cursor: 'pointer' }} onClick={() => setExpanded(!expanded)}>
<Stack gap={4}>
<Group justify="space-between">
<Group gap={4}>
<FontDownloadIcon sx={{ fontSize: 16 }} />
<Text size="xs" fw={500} lineClamp={1}>
{analysis.baseName}
</Text>
<Flex align="center" justify="space-between" wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<FontDownloadIcon sx={{ fontSize: 16, flexShrink: 0 }} />
<CustomTooltip
sidebarTooltip={false}
content={analysis.baseName}
position="top"
>
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{analysis.baseName}
</Text>
</CustomTooltip>
{analysis.isSubset && (
<Badge size="xs" color="gray" variant="outline">
<Badge size="xs" color="gray" variant="outline" style={{ flexShrink: 0 }}>
subset
</Badge>
)}
</Group>
<Group gap={4}>
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
<FontStatusBadge analysis={analysis} />
{expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
</Group>
</Group>
</Flex>
<Collapse in={expanded}>
<Stack gap={4} mt={4}>
@ -165,7 +176,12 @@ const FontDetailItem = ({ analysis }: { analysis: FontAnalysis }) => {
);
};
const FontStatusPanel: React.FC<FontStatusPanelProps> = ({ document, pageIndex }) => {
const FontStatusPanel: React.FC<FontStatusPanelProps> = ({
document,
pageIndex,
isCollapsed = false,
onCollapsedChange
}) => {
const { t } = useTranslation();
const fontAnalysis: DocumentFontAnalysis = useMemo(
@ -175,16 +191,6 @@ const FontStatusPanel: React.FC<FontStatusPanelProps> = ({ document, pageIndex }
const { canReproducePerfectly, hasWarnings, summary, fonts } = fontAnalysis;
const statusIcon = useMemo(() => {
if (canReproducePerfectly) {
return <CheckCircleIcon sx={{ fontSize: 16 }} />;
}
if (hasWarnings) {
return <WarningIcon sx={{ fontSize: 16 }} />;
}
return <InfoIcon sx={{ fontSize: 16 }} />;
}, [canReproducePerfectly, hasWarnings]);
// Early return AFTER all hooks are declared
if (!document || fontAnalysis.fonts.length === 0) {
return null;
@ -197,37 +203,46 @@ const FontStatusPanel: React.FC<FontStatusPanelProps> = ({ document, pageIndex }
: t('pdfTextEditor.fontAnalysis.allFonts', 'All fonts');
return (
<Accordion variant="contained" defaultValue={hasWarnings ? 'fonts' : undefined}>
<Accordion.Item value="fonts">
<Accordion.Control>
<Group gap="xs" wrap="wrap" style={{ flex: 1 }}>
<Group gap="xs" wrap="nowrap">
{statusIcon}
<Text size="sm" fw={500}>
{pageLabel}
</Text>
<Badge size="xs" color={statusColor} variant="dot">
{fonts.length}
</Badge>
</Group>
<div>
<div
style={{
padding: '0.5rem',
opacity: isCollapsed ? 0.8 : 1,
color: isCollapsed ? 'var(--mantine-color-dimmed)' : 'inherit',
transition: 'opacity 0.2s ease, color 0.2s ease'
}}
>
{/* Header - matches ToolStep style */}
<Flex
align="center"
justify="space-between"
mb={isCollapsed ? 0 : 'sm'}
style={{ cursor: 'pointer' }}
onClick={() => onCollapsedChange?.(!isCollapsed)}
>
<Flex align="center" gap="xs">
<Text fw={500} size="sm">
{pageLabel}
</Text>
<Badge size="xs" color={statusColor} variant="dot">
{fonts.length}
</Badge>
</Flex>
{/* Warning badges BEFORE expansion */}
<Group gap={4} wrap="wrap">
{summary.systemFallback > 0 && (
<Badge size="xs" color="yellow" variant="filled" leftSection={<WarningIcon sx={{ fontSize: 12 }} />}>
{summary.systemFallback} {t('pdfTextEditor.fontAnalysis.fallback', 'fallback')}
</Badge>
)}
{summary.missing > 0 && (
<Badge size="xs" color="red" variant="filled" leftSection={<ErrorIcon sx={{ fontSize: 12 }} />}>
{summary.missing} {t('pdfTextEditor.fontAnalysis.missing', 'missing')}
</Badge>
)}
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{isCollapsed ? (
<LocalIcon icon="chevron-right-rounded" width="1.2rem" height="1.2rem" style={{
color: 'var(--mantine-color-dimmed)'
}} />
) : (
<LocalIcon icon="expand-more-rounded" width="1.2rem" height="1.2rem" style={{
color: 'var(--mantine-color-dimmed)'
}} />
)}
</Flex>
{/* Content */}
{!isCollapsed && (
<Stack gap="xs" pl="sm">
{/* Overall Status Message */}
<Text size="xs" c="dimmed">
{canReproducePerfectly
@ -277,9 +292,10 @@ const FontStatusPanel: React.FC<FontStatusPanelProps> = ({ document, pageIndex }
))}
</Stack>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</div>
<Divider style={{ color: '#E2E8F0', marginLeft: '1rem', marginRight: '-0.5rem' }} />
</div>
);
};

View File

@ -0,0 +1,342 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Divider,
Flex,
Group,
Menu,
Modal,
ScrollArea,
SegmentedControl,
Stack,
Switch,
Text,
Title,
Tooltip as MantineTooltip,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import AutorenewIcon from '@mui/icons-material/Autorenew';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import FileDownloadIcon from '@mui/icons-material/FileDownloadOutlined';
import { PdfTextEditorViewData, TextGroup } from '@app/tools/pdfTextEditor/pdfTextEditorTypes';
import { pageDimensions } from '@app/tools/pdfTextEditor/pdfTextEditorUtils';
import FontStatusPanel from '@app/components/tools/pdfTextEditor/FontStatusPanel';
import ToolStep, { ToolStepProvider } from '@app/components/tools/shared/ToolStep';
import { usePdfTextEditorTips } from '@app/components/tooltips/usePdfTextEditorTips';
import { Tooltip } from '@app/components/shared/Tooltip';
import LocalIcon from '@app/components/shared/LocalIcon';
type GroupingMode = 'auto' | 'paragraph' | 'singleLine';
interface PdfTextEditorSidebarProps {
data: PdfTextEditorViewData;
}
// Analyze page content to determine if it's paragraph-heavy
const analyzePageContentType = (
groups: TextGroup[],
pageWidth: number,
): boolean => {
if (groups.length < 3) {
return false;
}
const widths = groups.map((g) => Math.max(g.bounds.right - g.bounds.left, 1));
const avgWidth = widths.reduce((sum, w) => sum + w, 0) / widths.length;
const stdDev = Math.sqrt(
widths.reduce((sum, w) => sum + Math.pow(w - avgWidth, 2), 0) / widths.length,
);
const coefficientOfVariation = avgWidth > 0 ? stdDev / avgWidth : 0;
const fullWidthRatio = widths.filter((w) => w > pageWidth * 0.65).length / widths.length;
const criterion1 = groups.length >= 3;
const criterion2 = avgWidth > pageWidth * 0.3;
const criterion3 = coefficientOfVariation > 0.5 || fullWidthRatio > 0.6;
return criterion1 && criterion2 && criterion3;
};
const PdfTextEditorSidebar = ({ data }: PdfTextEditorSidebarProps) => {
const { t } = useTranslation();
const [pendingModeChange, setPendingModeChange] = useState<GroupingMode | null>(null);
const [advancedSettingsCollapsed, setAdvancedSettingsCollapsed] = useState(false);
const [fontsCollapsed, setFontsCollapsed] = useState(false);
const pdfTextEditorTips = usePdfTextEditorTips();
const {
document: pdfDocument,
groupsByPage,
hasDocument,
hasChanges,
fileName,
isGeneratingPdf,
isSavingToWorkbench,
isConverting,
forceSingleTextElement,
groupingMode: externalGroupingMode,
autoScaleText,
selectedPage,
onReset,
onGeneratePdf,
onSaveToWorkbench,
onForceSingleTextElementChange,
onGroupingModeChange,
onAutoScaleTextChange,
} = data;
// Get page dimensions
const pages = pdfDocument?.pages ?? [];
const currentPage = pages[selectedPage] ?? null;
const { width: pageWidth } = pageDimensions(currentPage);
const pageGroups = groupsByPage[selectedPage] ?? [];
// Detect if current page contains paragraph-heavy content
const isParagraphPage = useMemo(() => {
return analyzePageContentType(pageGroups, pageWidth);
}, [pageGroups, pageWidth]);
const handleModeChangeRequest = useCallback((newMode: GroupingMode) => {
if (hasChanges && newMode !== externalGroupingMode) {
setPendingModeChange(newMode);
} else {
onGroupingModeChange(newMode);
}
}, [hasChanges, externalGroupingMode, onGroupingModeChange]);
const handleConfirmModeChange = useCallback(() => {
if (pendingModeChange) {
onGroupingModeChange(pendingModeChange);
setPendingModeChange(null);
}
}, [pendingModeChange, onGroupingModeChange]);
const handleCancelModeChange = useCallback(() => {
setPendingModeChange(null);
}, []);
return (
<>
<Stack style={{ height: '100%', display: 'flex' }} gap={0}>
<ScrollArea style={{ flex: 1 }} offsetScrollbars>
<Stack gap="md">
<Stack gap="xs" pl="md" pr={0} pt="md">
{/* Title row with ALPHA badge and info tooltip */}
<Flex align="center" justify="space-between">
<Flex align="center" gap="xs">
<Text fw={600} size="sm">
{t('pdfTextEditor.title', 'PDF Text Editor')}
</Text>
<Badge size="xs" variant="light" color="orange">
{t('toolPanel.alpha', 'Alpha')}
</Badge>
</Flex>
<Tooltip
sidebarTooltip={true}
tips={pdfTextEditorTips.tips}
header={pdfTextEditorTips.header}
pinOnClick
>
<ActionIcon variant="subtle" color="blue" size="sm">
<LocalIcon icon="info-outline-rounded" width="1.25rem" height="1.25rem" />
</ActionIcon>
</Tooltip>
</Flex>
{fileName && (
<Text size="sm" c="dimmed">
{t('pdfTextEditor.currentFile', 'Current file: {{name}}', { name: fileName })}
</Text>
)}
</Stack>
<ToolStep
title={t('pdfTextEditor.options.advanced.title', 'Advanced Settings')}
isCollapsed={advancedSettingsCollapsed}
onCollapsedClick={() => setAdvancedSettingsCollapsed(!advancedSettingsCollapsed)}
>
<Stack gap="md">
<Divider />
<Group justify="space-between" align="center">
<Group gap={4} align="center" style={{ flex: 1, minWidth: 0 }}>
<Tooltip
sidebarTooltip={false}
content={t(
'pdfTextEditor.options.autoScaleText.description',
'Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF.'
)}
position="top"
>
<ActionIcon variant="subtle" color="gray" size="sm" style={{ flexShrink: 0 }}>
<InfoOutlinedIcon fontSize="small" />
</ActionIcon>
</Tooltip>
<Text fw={500} size="sm" style={{ flex: 1 }}>
{t('pdfTextEditor.options.autoScaleText.title', 'Auto-scale text to fit boxes')}
</Text>
</Group>
<Switch
size="md"
checked={autoScaleText}
onChange={(event) => onAutoScaleTextChange(event.currentTarget.checked)}
/>
</Group>
<Divider />
<Stack gap="xs">
<Group gap={4} align="center">
<Text fw={500} size="sm">
{t('pdfTextEditor.options.groupingMode.title', 'Text Grouping Mode')}
</Text>
{externalGroupingMode === 'auto' && isParagraphPage && (
<Badge size="xs" color="blue" variant="light" key={`para-${selectedPage}`}>
{t('pdfTextEditor.pageType.paragraph', 'Paragraph page')}
</Badge>
)}
{externalGroupingMode === 'auto' && !isParagraphPage && hasDocument && (
<Badge size="xs" color="gray" variant="light" key={`sparse-${selectedPage}`}>
{t('pdfTextEditor.pageType.sparse', 'Sparse text')}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
{externalGroupingMode === 'auto'
? t(
'pdfTextEditor.options.groupingMode.autoDescription',
'Automatically detects page type and groups text appropriately.'
)
: externalGroupingMode === 'paragraph'
? t(
'pdfTextEditor.options.groupingMode.paragraphDescription',
'Groups aligned lines into multi-line paragraph text boxes.'
)
: t(
'pdfTextEditor.options.groupingMode.singleLineDescription',
'Keeps each PDF text line as a separate text box.'
)}
</Text>
<SegmentedControl
value={externalGroupingMode}
onChange={(value) => handleModeChangeRequest(value as GroupingMode)}
data={[
{ label: t('pdfTextEditor.groupingMode.auto', 'Auto'), value: 'auto' },
{ label: t('pdfTextEditor.groupingMode.paragraph', 'Paragraph'), value: 'paragraph' },
{ label: t('pdfTextEditor.groupingMode.singleLine', 'Single Line'), value: 'singleLine' },
]}
fullWidth
/>
</Stack>
<Divider />
<Group justify="space-between" align="center">
<Group gap={4} align="center" style={{ flex: 1, minWidth: 0 }}>
<Tooltip
sidebarTooltip={false}
content={t(
'pdfTextEditor.options.forceSingleElement.description',
'When enabled, the editor exports each edited text box as one PDF text element to avoid overlapping glyphs or mixed fonts.'
)}
position="top"
>
<ActionIcon variant="subtle" color="gray" size="sm" style={{ flexShrink: 0 }}>
<InfoOutlinedIcon fontSize="small" />
</ActionIcon>
</Tooltip>
<Text fw={500} size="sm" style={{ flex: 1 }}>
{t('pdfTextEditor.options.forceSingleElement.title', 'Lock edited text to a single PDF element')}
</Text>
</Group>
<Switch
size="md"
checked={forceSingleTextElement}
onChange={(event) => onForceSingleTextElementChange(event.currentTarget.checked)}
/>
</Group>
</Stack>
</ToolStep>
{hasDocument && (
<FontStatusPanel
document={pdfDocument}
pageIndex={selectedPage}
isCollapsed={fontsCollapsed}
onCollapsedChange={setFontsCollapsed}
/>
)}
</Stack>
</ScrollArea>
<Group gap="xs" wrap="nowrap" p="md">
<Button
variant="filled"
onClick={onSaveToWorkbench}
loading={isSavingToWorkbench}
disabled={!hasDocument || !hasChanges || isConverting}
style={{ flex: 1 }}
>
{t('pdfTextEditor.actions.applyChanges', 'Apply Changes')}
</Button>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="default"
size="lg"
disabled={!hasDocument || isConverting}
>
<MoreHorizIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<FileDownloadIcon fontSize="small" />}
onClick={() => onGeneratePdf()}
disabled={!hasChanges || isGeneratingPdf}
>
{t('pdfTextEditor.actions.downloadCopy', 'Download Copy')}
</Menu.Item>
<Menu.Item
leftSection={<AutorenewIcon fontSize="small" />}
onClick={onReset}
color="red"
>
{t('pdfTextEditor.actions.reset', 'Reset Changes')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Stack>
{/* Mode Change Confirmation Modal */}
<Modal
opened={pendingModeChange !== null}
onClose={handleCancelModeChange}
title={t('pdfTextEditor.modeChange.title', 'Confirm Mode Change')}
centered
>
<Stack gap="md">
<Text>
{t(
'pdfTextEditor.modeChange.warning',
'Changing the text grouping mode will reset all unsaved changes. Are you sure you want to continue?'
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleCancelModeChange}>
{t('pdfTextEditor.modeChange.cancel', 'Cancel')}
</Button>
<Button color="red" onClick={handleConfirmModeChange}>
{t('pdfTextEditor.modeChange.confirm', 'Reset and Change Mode')}
</Button>
</Group>
</Stack>
</Modal>
</>
);
};
export default PdfTextEditorSidebar;

View File

@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
Accordion,
ActionIcon,
Alert,
Badge,
@ -14,18 +13,12 @@ import {
Pagination,
Progress,
ScrollArea,
SegmentedControl,
Stack,
Switch,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import DescriptionIcon from '@mui/icons-material/DescriptionOutlined';
import FileDownloadIcon from '@mui/icons-material/FileDownloadOutlined';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdfOutlined';
import AutorenewIcon from '@mui/icons-material/Autorenew';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
@ -34,7 +27,6 @@ import MergeTypeIcon from '@mui/icons-material/MergeType';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import UploadFileIcon from '@mui/icons-material/UploadFileOutlined';
import SaveIcon from '@mui/icons-material/SaveOutlined';
import { Rnd } from 'react-rnd';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
@ -46,7 +38,6 @@ import {
TextGroup,
} from '@app/tools/pdfTextEditor/pdfTextEditorTypes';
import { getImageBounds, pageDimensions } from '@app/tools/pdfTextEditor/pdfTextEditorUtils';
import FontStatusPanel from '@app/components/tools/pdfTextEditor/FontStatusPanel';
const MAX_RENDER_WIDTH = 820;
const MIN_BOX_SIZE = 18;
@ -327,9 +318,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
const rndRefs = useRef<Map<string, any>>(new Map());
const pendingDragUpdateRef = useRef<number | null>(null);
const [fontFamilies, setFontFamilies] = useState<Map<string, string>>(new Map());
const [autoScaleText, setAutoScaleText] = useState(true);
const [textScales, setTextScales] = useState<Map<string, number>>(new Map());
const [pendingModeChange, setPendingModeChange] = useState<GroupingMode | null>(null);
const measurementKeyRef = useRef<string>('');
const containerRef = useRef<HTMLDivElement | null>(null);
const editorRefs = useRef<Map<string, HTMLDivElement>>(new Map());
@ -386,6 +375,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
hasChanges,
forceSingleTextElement,
groupingMode: externalGroupingMode,
autoScaleText,
requestPagePreview,
onSelectPage,
onGroupEdit,
@ -393,7 +383,6 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
onImageTransform,
onImageReset,
onReset,
onDownloadJson,
onGeneratePdf,
onSaveToWorkbench,
onForceSingleTextElementChange,
@ -427,27 +416,6 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
} : null,
});
const handleModeChangeRequest = useCallback((newMode: GroupingMode) => {
if (hasChanges && newMode !== externalGroupingMode) {
// Show confirmation dialog
setPendingModeChange(newMode);
} else {
// No changes, switch immediately
onGroupingModeChange(newMode);
}
}, [hasChanges, externalGroupingMode, onGroupingModeChange]);
const handleConfirmModeChange = useCallback(() => {
if (pendingModeChange) {
onGroupingModeChange(pendingModeChange);
setPendingModeChange(null);
}
}, [pendingModeChange, onGroupingModeChange]);
const handleCancelModeChange = useCallback(() => {
setPendingModeChange(null);
}, []);
const clearSelection = useCallback(() => {
setSelectedGroupIds(new Set());
lastSelectedGroupIdRef.current = null;
@ -1435,223 +1403,16 @@ const selectionToolbarPosition = useMemo(() => {
padding: '1.5rem',
overflow: 'hidden',
height: '100%',
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 320px',
gridTemplateRows: '1fr',
alignItems: hasDocument ? 'start' : 'stretch',
gap: '1.5rem',
display: 'flex',
flexDirection: 'column',
}}
>
<Card
withBorder
radius="md"
shadow="xs"
padding="md"
style={{
gridColumn: '2 / 3',
gridRow: 1,
maxHeight: 'calc(100vh - 3rem)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
<ScrollArea style={{ flex: 1 }} offsetScrollbars>
<Stack gap="sm" pb="md">
<Group justify="space-between" align="center">
<Group gap="xs" align="center">
<DescriptionIcon fontSize="small" />
<Title order={3}>{t('pdfTextEditor.title', 'PDF JSON Editor')}</Title>
{hasChanges && <Badge color="orange" variant="light" size="sm">{t('pdfTextEditor.badges.unsaved', 'Edited')}</Badge>}
</Group>
</Group>
<Stack gap="xs">
<Button
variant="subtle"
leftSection={<AutorenewIcon fontSize="small" />}
onClick={onReset}
disabled={!hasDocument || isConverting}
fullWidth
>
{t('pdfTextEditor.actions.reset', 'Reset Changes')}
</Button>
<Button
variant="default"
leftSection={<FileDownloadIcon fontSize="small" />}
onClick={onDownloadJson}
disabled={!hasDocument || isConverting}
fullWidth
>
{t('pdfTextEditor.actions.downloadJson', 'Download JSON')}
</Button>
<Button
leftSection={<PictureAsPdfIcon fontSize="small" />}
onClick={onGeneratePdf}
loading={isGeneratingPdf}
disabled={!hasDocument || !hasChanges || isConverting}
fullWidth
>
{t('pdfTextEditor.actions.generatePdf', 'Generate PDF')}
</Button>
<Button
variant="filled"
color="green"
leftSection={<SaveIcon fontSize="small" />}
onClick={onSaveToWorkbench}
loading={isSavingToWorkbench}
disabled={!hasDocument || !hasChanges || isConverting}
fullWidth
>
{t('pdfTextEditor.actions.saveChanges', 'Save Changes')}
</Button>
</Stack>
{fileName && (
<Text size="sm" c="dimmed">
{t('pdfTextEditor.currentFile', 'Current file: {{name}}', { name: fileName })}
</Text>
)}
<Divider my="xs" />
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t('pdfTextEditor.options.autoScaleText.title', 'Auto-scale text to fit boxes')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
'pdfTextEditor.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 gap="xs">
<Group gap={4} align="center">
<Text fw={500} size="sm">
{t('pdfTextEditor.options.groupingMode.title', 'Text Grouping Mode')}
</Text>
{externalGroupingMode === 'auto' && isParagraphPage && (
<Badge size="xs" color="blue" variant="light" key={`para-${selectedPage}`}>
{t('pdfTextEditor.pageType.paragraph', 'Paragraph page')}
</Badge>
)}
{externalGroupingMode === 'auto' && !isParagraphPage && hasDocument && (
<Badge size="xs" color="gray" variant="light" key={`sparse-${selectedPage}`}>
{t('pdfTextEditor.pageType.sparse', 'Sparse text')}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
{externalGroupingMode === 'auto'
? t(
'pdfTextEditor.options.groupingMode.autoDescription',
'Automatically detects page type and groups text appropriately.'
)
: externalGroupingMode === 'paragraph'
? t(
'pdfTextEditor.options.groupingMode.paragraphDescription',
'Groups aligned lines into multi-line paragraph text boxes.'
)
: t(
'pdfTextEditor.options.groupingMode.singleLineDescription',
'Keeps each PDF text line as a separate text box.'
)}
</Text>
<SegmentedControl
value={externalGroupingMode}
onChange={(value) => handleModeChangeRequest(value as GroupingMode)}
data={[
{ label: t('pdfTextEditor.groupingMode.auto', 'Auto'), value: 'auto' },
{ label: t('pdfTextEditor.groupingMode.paragraph', 'Paragraph'), value: 'paragraph' },
{ label: t('pdfTextEditor.groupingMode.singleLine', 'Single Line'), value: 'singleLine' },
]}
fullWidth
/>
</Stack>
<Text size="xs" c="dimmed">
{t(
'pdfTextEditor.options.manualGrouping.descriptionInline',
'Tip: Hold Ctrl (Cmd) or Shift to multi-select text boxes. A floating toolbar will appear above the selection so you can merge, ungroup, or adjust widths.',
)}
</Text>
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t('pdfTextEditor.options.forceSingleElement.title', 'Lock edited text to a single PDF element')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
'pdfTextEditor.options.forceSingleElement.description',
'When enabled, the editor exports each edited text box as one PDF text element to avoid overlapping glyphs or mixed fonts.'
)}
</Text>
</div>
<Switch
size="md"
checked={forceSingleTextElement}
onChange={(event) => onForceSingleTextElementChange(event.currentTarget.checked)}
/>
</Group>
<Divider my="xs" />
<Accordion variant="contained">
<Accordion.Item value="disclaimer">
<Accordion.Control>
<Group gap="xs" wrap="nowrap">
<InfoOutlinedIcon fontSize="small" />
<Text size="sm" fw={500}>
{t('pdfTextEditor.disclaimer.heading', 'Preview Limitations')}
</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap={4}>
<Text size="xs">
{t(
'pdfTextEditor.disclaimer.textFocus',
'This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here.'
)}
</Text>
<Text size="xs">
{t(
'pdfTextEditor.disclaimer.previewVariance',
'Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible.'
)}
</Text>
<Text size="xs">
{t(
'pdfTextEditor.disclaimer.alpha',
'This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing.'
)}
</Text>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{hasDocument && <FontStatusPanel document={pdfDocument} pageIndex={selectedPage} />}
</Stack>
</ScrollArea>
</Card>
{errorMessage && (
<Alert
icon={<WarningAmberIcon fontSize="small" />}
color="red"
radius="md"
style={{ gridColumn: '2 / 3' }}
mb="md"
>
{errorMessage}
</Alert>
@ -1661,7 +1422,7 @@ const selectionToolbarPosition = useMemo(() => {
<Stack
align="center"
justify="center"
style={{ gridColumn: '1 / 2', gridRow: 1, height: '100%' }}
style={{ flex: 1, height: '100%' }}
>
<Dropzone
onDrop={(files) => {
@ -1700,7 +1461,7 @@ const selectionToolbarPosition = useMemo(() => {
)}
{isConverting && (
<Card withBorder radius="md" padding="xl" style={{ gridColumn: '1 / 2', gridRow: 1 }}>
<Card withBorder radius="md" padding="xl" style={{ flex: 1 }}>
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<div style={{ flex: 1 }}>
@ -1735,10 +1496,8 @@ const selectionToolbarPosition = useMemo(() => {
gap="lg"
className="flex-1"
style={{
gridColumn: '1 / 2',
gridRow: 1,
minHeight: 0,
height: 'calc(100vh - 3rem)',
flex: 1,
overflow: 'hidden',
}}
>
@ -2464,31 +2223,6 @@ const selectionToolbarPosition = useMemo(() => {
</Stack>
)}
{/* Mode Change Confirmation Modal */}
<Modal
opened={pendingModeChange !== null}
onClose={handleCancelModeChange}
title={t('pdfTextEditor.modeChange.title', 'Confirm Mode Change')}
centered
>
<Stack gap="md">
<Text>
{t(
'pdfTextEditor.modeChange.warning',
'Changing the text grouping mode will reset all unsaved changes. Are you sure you want to continue?'
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleCancelModeChange}>
{t('pdfTextEditor.modeChange.cancel', 'Cancel')}
</Button>
<Button color="red" onClick={handleConfirmModeChange}>
{t('pdfTextEditor.modeChange.confirm', 'Reset and Change Mode')}
</Button>
</Group>
</Stack>
</Modal>
{/* Navigation Warning Modal */}
<NavigationWarningModal
onApplyAndContinue={onSaveToWorkbench}

View File

@ -0,0 +1,36 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '@app/types/tips';
export const usePdfTextEditorTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t('pdfTextEditor.tooltip.header.title', 'Preview Limitations'),
},
tips: [
{
title: t('pdfTextEditor.tooltip.textFocus.title', 'Text and Image Focus'),
description: t(
'pdfTextEditor.tooltip.textFocus.text',
'This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here.'
),
},
{
title: t('pdfTextEditor.tooltip.previewVariance.title', 'Preview Variance'),
description: t(
'pdfTextEditor.tooltip.previewVariance.text',
'Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible.'
),
},
{
title: t('pdfTextEditor.tooltip.alpha.title', 'Alpha Viewer'),
description: t(
'pdfTextEditor.tooltip.alpha.text',
'This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing.'
),
},
],
};
};

View File

@ -34,6 +34,7 @@ import {
valueOr,
} from '@app/tools/pdfTextEditor/pdfTextEditorUtils';
import PdfTextEditorView from '@app/components/tools/pdfTextEditor/PdfTextEditorView';
import PdfTextEditorSidebar from '@app/components/tools/pdfTextEditor/PdfTextEditorSidebar';
import type { PDFDocumentProxy } from 'pdfjs-dist';
const WORKBENCH_VIEW_ID = 'pdfTextEditorWorkbench';
@ -206,7 +207,6 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
unregisterCustomWorkbenchView,
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
setLeftPanelView,
} = useToolWorkflow();
const { actions: navigationActions } = useNavigationActions();
const navigationState = useNavigationState();
@ -228,6 +228,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const [groupingMode, setGroupingMode] = useState<'auto' | 'paragraph' | 'singleLine'>('auto');
const [hasVectorPreview, setHasVectorPreview] = useState(false);
const [pagePreviews, setPagePreviews] = useState<Map<number, string>>(new Map());
const [autoScaleText, setAutoScaleText] = useState(true);
// Lazy loading state
const [isLazyMode, setIsLazyMode] = useState(false);
@ -1527,6 +1528,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
hasChanges,
forceSingleTextElement,
groupingMode,
autoScaleText,
onAutoScaleTextChange: setAutoScaleText,
requestPagePreview,
onSelectPage: handleSelectPage,
onGroupEdit: handleGroupTextChange,
@ -1575,6 +1578,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
selectedPage,
forceSingleTextElement,
groupingMode,
autoScaleText,
requestPagePreview,
setForceSingleTextElement,
handleLoadFileFromDropzone,
@ -1644,7 +1648,6 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
icon: <DescriptionIcon fontSize="small" />,
component: PdfTextEditorView,
});
setLeftPanelView('hidden');
setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, latestViewDataRef.current);
return () => {
@ -1652,14 +1655,12 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
clearCachedJob(cachedJobIdRef.current);
clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID);
unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID);
setLeftPanelView('toolPicker');
};
}, [
clearCachedJob,
clearCustomWorkbenchViewData,
registerCustomWorkbenchView,
setCustomWorkbenchViewData,
setLeftPanelView,
viewLabel,
unregisterCustomWorkbenchView,
]);
@ -1678,12 +1679,12 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, viewData);
}, [setCustomWorkbenchViewData, viewData]);
// All editing happens in the custom workbench view.
return null;
// Render the sidebar with settings while editing happens in the custom workbench view.
return <PdfTextEditorSidebar data={viewData} />;
};
(PdfTextEditor as ToolComponent).tool = () => {
throw new Error('PDF JSON Editor does not support automation operations.');
throw new Error('PDF Text Editor does not support automation operations.');
};
(PdfTextEditor as ToolComponent).getDefaultParameters = () => ({

View File

@ -201,6 +201,8 @@ export interface PdfTextEditorViewData {
hasChanges: boolean;
forceSingleTextElement: boolean;
groupingMode: 'auto' | 'paragraph' | 'singleLine';
autoScaleText: boolean;
onAutoScaleTextChange: (value: boolean) => void;
requestPagePreview: (pageIndex: number, scale: number) => void;
onSelectPage: (pageIndex: number) => void;
onGroupEdit: (pageIndex: number, groupId: string, value: string) => void;