mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
Merge 89b44e8ec4 into 3529849bca
This commit is contained in:
commit
57a983238c
@ -6160,7 +6160,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."
|
||||
@ -6189,6 +6189,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"
|
||||
@ -6207,6 +6209,24 @@ 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.tooltip.header]
|
||||
title = "Preview Limitations"
|
||||
|
||||
[pdfTextEditor.tooltip.textFocus]
|
||||
title = "Text and Image Focus"
|
||||
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."
|
||||
|
||||
[pdfTextEditor.tooltip.previewVariance]
|
||||
title = "Preview Variance"
|
||||
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."
|
||||
|
||||
[pdfTextEditor.tooltip.alpha]
|
||||
title = "Alpha Viewer"
|
||||
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."
|
||||
|
||||
[pdfTextEditor.manual]
|
||||
mergeTooltip = "Merge selected boxes"
|
||||
merge = "Merge selection"
|
||||
@ -6226,8 +6246,8 @@ alpha = "This alpha viewer is still evolving—certain fonts, colours, transpare
|
||||
[pdfTextEditor.empty]
|
||||
title = "No document loaded"
|
||||
subtitle = "Load a PDF or JSON file to begin editing text content."
|
||||
dropzone = "Drag and drop a PDF or JSON file here, or click to browse"
|
||||
dropzoneWithFiles = "Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse"
|
||||
dropzone = "Drag and drop a PDF here, or click to browse"
|
||||
dropzoneWithFiles = "Select a file from the Files tab, or drag and drop a PDF here, or click to browse"
|
||||
|
||||
[pdfTextEditor.welcomeBanner]
|
||||
title = "Welcome to PDF Text Editor (Early Access)"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,340 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} 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 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;
|
||||
@ -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;
|
||||
@ -313,8 +304,6 @@ const analyzePageContentType = (groups: TextGroup[], pageWidth: number): boolean
|
||||
return isParagraphPage;
|
||||
};
|
||||
|
||||
type GroupingMode = 'auto' | 'paragraph' | 'singleLine';
|
||||
|
||||
const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { activeFiles } = useFileContext();
|
||||
@ -327,9 +316,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());
|
||||
@ -378,27 +365,27 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
|
||||
dirtyPages,
|
||||
hasDocument,
|
||||
hasVectorPreview,
|
||||
fileName,
|
||||
fileName: _fileName,
|
||||
errorMessage,
|
||||
isGeneratingPdf,
|
||||
isSavingToWorkbench,
|
||||
isGeneratingPdf: _isGeneratingPdf,
|
||||
isSavingToWorkbench: _isSavingToWorkbench,
|
||||
isConverting,
|
||||
conversionProgress,
|
||||
hasChanges,
|
||||
forceSingleTextElement,
|
||||
hasChanges: _hasChanges,
|
||||
forceSingleTextElement: _forceSingleTextElement,
|
||||
groupingMode: externalGroupingMode,
|
||||
autoScaleText,
|
||||
requestPagePreview,
|
||||
onSelectPage,
|
||||
onGroupEdit,
|
||||
onGroupDelete,
|
||||
onImageTransform,
|
||||
onImageReset,
|
||||
onReset,
|
||||
onDownloadJson,
|
||||
onGeneratePdf,
|
||||
onReset: _onReset,
|
||||
onGeneratePdf: _onGeneratePdf,
|
||||
onSaveToWorkbench,
|
||||
onForceSingleTextElementChange,
|
||||
onGroupingModeChange,
|
||||
onForceSingleTextElementChange: _onForceSingleTextElementChange,
|
||||
onGroupingModeChange: _onGroupingModeChange,
|
||||
onMergeGroups,
|
||||
onUngroupGroup,
|
||||
onLoadFile,
|
||||
@ -428,27 +415,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;
|
||||
@ -1448,223 +1414,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>
|
||||
@ -1674,7 +1433,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) => {
|
||||
@ -1704,8 +1463,8 @@ const selectionToolbarPosition = useMemo(() => {
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={420}>
|
||||
{activeFiles.length > 0
|
||||
? t('pdfTextEditor.empty.dropzoneWithFiles', 'Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse')
|
||||
: t('pdfTextEditor.empty.dropzone', 'Drag and drop a PDF or JSON file here, or click to browse')}
|
||||
? t('pdfTextEditor.empty.dropzoneWithFiles', 'Select a file from the Files tab, or drag and drop a PDF here, or click to browse')
|
||||
: t('pdfTextEditor.empty.dropzone', 'Drag and drop a PDF here, or click to browse')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Dropzone>
|
||||
@ -1713,7 +1472,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 }}>
|
||||
@ -1748,10 +1507,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',
|
||||
}}
|
||||
>
|
||||
@ -1788,65 +1545,208 @@ const selectionToolbarPosition = useMemo(() => {
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
<Text fw={600}>{t('pdfTextEditor.welcomeBanner.title', 'Welcome to PDF Text Editor (Early Access)')}</Text>
|
||||
<Text fw={600}>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.title',
|
||||
'Welcome to PDF Text Editor (Early Access)',
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
size="lg"
|
||||
scrollAreaComponent={Box}
|
||||
>
|
||||
<ScrollArea style={{ maxHeight: '70vh' }} offsetScrollbars>
|
||||
<Stack gap="sm">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '70vh',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Header (fixed) */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<Alert color="orange" variant="light" radius="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('pdfTextEditor.welcomeBanner.experimental', 'This is an experimental feature in active development. Expect some instability and issues during use.')}
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.experimental',
|
||||
'This is an experimental feature in active development. Expect some instability and issues during use.',
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
<Text size="sm">
|
||||
{t('pdfTextEditor.welcomeBanner.howItWorks', 'This tool converts your PDF to an editable format where you can modify text content and reposition images. Changes are saved back as a new PDF.')}
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.howItWorks',
|
||||
'This tool converts your PDF to an editable format where you can modify text content and reposition images. Changes are saved back as a new PDF.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Body (scrollable) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
paddingRight: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<Divider />
|
||||
<Text size="sm" fw={500} c="green.7">
|
||||
{t('pdfTextEditor.welcomeBanner.bestFor', 'Works Best With:')}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
component="ul"
|
||||
style={{ marginLeft: '1rem', marginTop: '0.25rem' }}
|
||||
>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.bestFor1',
|
||||
'Simple PDFs containing primarily text and images',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.bestFor2',
|
||||
'Documents with standard paragraph formatting',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.bestFor3',
|
||||
'Letters, essays, reports, and basic documents',
|
||||
)}
|
||||
</li>
|
||||
</Text>
|
||||
<Divider />
|
||||
<Text size="sm" fw={500} c="orange.7">
|
||||
{t('pdfTextEditor.welcomeBanner.notIdealFor', 'Not Ideal For:')}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
component="ul"
|
||||
style={{ marginLeft: '1rem', marginTop: '0.25rem' }}
|
||||
>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.notIdealFor1',
|
||||
'PDFs with special formatting like bullet points, tables, or multi-column layouts',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.notIdealFor2',
|
||||
'Magazines, brochures, or heavily designed documents',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.notIdealFor3',
|
||||
'Instruction manuals with complex layouts',
|
||||
)}
|
||||
</li>
|
||||
</Text>
|
||||
<Divider />
|
||||
<Text size="sm" fw={500}>
|
||||
{t('pdfTextEditor.welcomeBanner.limitations', 'Current Limitations:')}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
component="ul"
|
||||
style={{ marginLeft: '1rem', marginTop: '0.25rem' }}
|
||||
>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.limitation1',
|
||||
'Font rendering may differ slightly from the original PDF',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.limitation2',
|
||||
'Complex graphics, form fields, and annotations are preserved but not editable',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.limitation3',
|
||||
'Large files may take time to convert and process',
|
||||
)}
|
||||
</li>
|
||||
</Text>
|
||||
<Divider />
|
||||
<Text size="sm" fw={500}>
|
||||
{t('pdfTextEditor.welcomeBanner.knownIssues', 'Known Issues (Being Fixed):')}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
component="ul"
|
||||
style={{ marginLeft: '1rem', marginTop: '0.25rem' }}
|
||||
>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.issue1',
|
||||
'Text colour is not currently preserved (will be added soon)',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.issue2',
|
||||
'Paragraph mode has more alignment and spacing issues - Single Line mode recommended',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.issue3',
|
||||
'The preview display differs from the exported PDF - exported PDFs are closer to the original',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.issue4',
|
||||
'Rotated text alignment may need manual adjustment',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.issue5',
|
||||
'Transparency and layering effects may vary from original',
|
||||
)}
|
||||
</li>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer (fixed) */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<Divider />
|
||||
<Text size="sm" fw={500} c="green.7">
|
||||
{t('pdfTextEditor.welcomeBanner.bestFor', 'Works Best With:')}
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{t(
|
||||
'pdfTextEditor.welcomeBanner.feedback',
|
||||
'This is an early access feature. Please report any issues you encounter to help us improve!',
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" component="ul" style={{ marginLeft: '1rem', marginTop: '0.25rem' }}>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.bestFor1', 'Simple PDFs containing primarily text and images')}</li>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.bestFor2', 'Documents with standard paragraph formatting')}</li>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.bestFor3', 'Letters, essays, reports, and basic documents')}</li>
|
||||
</Text>
|
||||
<Divider />
|
||||
<Text size="sm" fw={500} c="orange.7">
|
||||
{t('pdfTextEditor.welcomeBanner.notIdealFor', 'Not Ideal For:')}
|
||||
</Text>
|
||||
<Text size="sm" component="ul" style={{ marginLeft: '1rem', marginTop: '0.25rem' }}>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.notIdealFor1', 'PDFs with special formatting like bullet points, tables, or multi-column layouts')}</li>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.notIdealFor2', 'Magazines, brochures, or heavily designed documents')}</li>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.notIdealFor3', 'Instruction manuals with complex layouts')}</li>
|
||||
</Text>
|
||||
<Divider />
|
||||
<Text size="sm" fw={500}>
|
||||
{t('pdfTextEditor.welcomeBanner.limitations', 'Current Limitations:')}
|
||||
</Text>
|
||||
<Text size="sm" component="ul" style={{ marginLeft: '1rem', marginTop: '0.25rem' }}>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.limitation1', 'Font rendering may differ slightly from the original PDF')}</li>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.limitation2', 'Complex graphics, form fields, and annotations are preserved but not editable')}</li>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.limitation3', 'Large files may take time to convert and process')}</li>
|
||||
</Text>
|
||||
<Divider />
|
||||
<Text size="sm" fw={500}>
|
||||
{t('pdfTextEditor.welcomeBanner.knownIssues', 'Known Issues (Being Fixed):')}
|
||||
</Text>
|
||||
<Text size="sm" component="ul" style={{ marginLeft: '1rem', marginTop: '0.25rem' }}>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.issue1', 'Text colour is not currently preserved (will be added soon)')}</li>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.issue2', 'Paragraph mode has more alignment and spacing issues - Single Line mode recommended')}</li>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.issue3', 'The preview display differs from the exported PDF - exported PDFs are closer to the original')}</li>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.issue4', 'Rotated text alignment may need manual adjustment')}</li>
|
||||
<li>{t('pdfTextEditor.welcomeBanner.issue5', 'Transparency and layering effects may vary from original')}</li>
|
||||
</Text>
|
||||
<Divider />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('pdfTextEditor.welcomeBanner.feedback', 'This is an early access feature. Please report any issues you encounter to help us improve!')}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm" mt="md">
|
||||
<Group justify="flex-end" gap="sm" mt="xs">
|
||||
<Button variant="default" onClick={handleDismissWelcomeBanner}>
|
||||
{t('pdfTextEditor.welcomeBanner.gotIt', 'Got it')}
|
||||
</Button>
|
||||
@ -1854,8 +1754,8 @@ const selectionToolbarPosition = useMemo(() => {
|
||||
{t('pdfTextEditor.welcomeBanner.dontShowAgain', "Don't show again")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Card
|
||||
@ -2485,31 +2385,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}
|
||||
|
||||
@ -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.'
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@ -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';
|
||||
@ -228,6 +229,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);
|
||||
@ -1565,6 +1567,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
hasChanges,
|
||||
forceSingleTextElement,
|
||||
groupingMode,
|
||||
autoScaleText,
|
||||
onAutoScaleTextChange: setAutoScaleText,
|
||||
requestPagePreview,
|
||||
onSelectPage: handleSelectPage,
|
||||
onGroupEdit: handleGroupTextChange,
|
||||
@ -1613,6 +1617,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
selectedPage,
|
||||
forceSingleTextElement,
|
||||
groupingMode,
|
||||
autoScaleText,
|
||||
requestPagePreview,
|
||||
setForceSingleTextElement,
|
||||
handleLoadFileFromDropzone,
|
||||
@ -1683,12 +1688,17 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
icon: <DescriptionIcon fontSize="small" />,
|
||||
component: PdfTextEditorView,
|
||||
});
|
||||
setLeftPanelView('hidden');
|
||||
setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, latestViewDataRef.current);
|
||||
|
||||
return () => {
|
||||
// Clear backend cache if we were using lazy loading
|
||||
clearCachedJob(cachedJobIdRef.current);
|
||||
clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID);
|
||||
unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID);
|
||||
};
|
||||
}, [
|
||||
registerCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
setLeftPanelView,
|
||||
viewLabel,
|
||||
]);
|
||||
|
||||
@ -1723,12 +1733,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 = () => ({
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user