2025-10-23 16:38:06 +01:00
import React , { useCallback , useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react' ;
2025-10-23 11:20:16 +01:00
import {
2025-11-11 15:37:29 +00:00
Accordion ,
2025-10-23 23:36:53 +01:00
ActionIcon ,
2025-10-23 11:20:16 +01:00
Alert ,
Badge ,
Box ,
Button ,
Card ,
Divider ,
FileButton ,
Group ,
2025-11-12 00:28:41 +00:00
Modal ,
2025-10-23 11:20:16 +01:00
Pagination ,
2025-11-02 21:00:03 +00:00
Progress ,
2025-10-23 11:20:16 +01:00
ScrollArea ,
2025-11-11 12:09:40 +00:00
SegmentedControl ,
2025-10-23 11:20:16 +01:00
Stack ,
2025-11-02 21:00:03 +00:00
Switch ,
2025-10-23 11:20:16 +01:00
Text ,
Title ,
} from '@mantine/core' ;
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' ;
2025-11-01 12:44:24 +00:00
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' ;
2025-10-23 11:20:16 +01:00
import UploadIcon from '@mui/icons-material/Upload' ;
2025-11-10 18:11:57 +00:00
import CloseIcon from '@mui/icons-material/Close' ;
2025-10-23 16:38:06 +01:00
import { Rnd } from 'react-rnd' ;
2025-10-23 11:20:16 +01:00
import {
2025-11-12 10:16:36 +00:00
PdfTextEditorViewData ,
2025-10-23 23:36:53 +01:00
PdfJsonFont ,
2025-10-23 11:20:16 +01:00
PdfJsonPage ,
2025-11-02 21:00:03 +00:00
ConversionProgress ,
2025-11-12 10:16:36 +00:00
} from '@app/tools/pdfTextEditor/pdfTextEditorTypes' ;
import { getImageBounds , pageDimensions } from '@app/tools/pdfTextEditor/pdfTextEditorUtils' ;
import FontStatusPanel from '@app/components/tools/pdfTextEditor/FontStatusPanel' ;
2025-10-23 11:20:16 +01:00
const MAX_RENDER_WIDTH = 820 ;
const MIN_BOX_SIZE = 18 ;
2025-10-23 23:36:53 +01:00
const normalizeFontFormat = ( format? : string | null ) : string => {
if ( ! format ) {
return 'ttf' ;
}
const lower = format . toLowerCase ();
if ( lower . includes ( 'woff2' )) {
return 'woff2' ;
}
if ( lower . includes ( 'woff' )) {
return 'woff' ;
}
if ( lower . includes ( 'otf' )) {
return 'otf' ;
}
if ( lower . includes ( 'cff' )) {
return 'otf' ;
}
return 'ttf' ;
};
const getFontMimeType = ( format : string ) : string => {
switch ( format ) {
case 'woff2' :
return 'font/woff2' ;
case 'woff' :
return 'font/woff' ;
case 'otf' :
return 'font/otf' ;
default :
return 'font/ttf' ;
}
};
const getFontFormatHint = ( format : string ) : string | null => {
switch ( format ) {
case 'woff2' :
return 'woff2' ;
case 'woff' :
return 'woff' ;
case 'otf' :
return 'opentype' ;
case 'ttf' :
return 'truetype' ;
default :
return null ;
}
};
const decodeBase64ToUint8Array = ( value : string ) : Uint8Array => {
const binary = window . atob ( value );
const bytes = new Uint8Array ( binary . length );
for ( let index = 0 ; index < binary . length ; index += 1 ) {
bytes [ index ] = binary . charCodeAt ( index );
}
return bytes ;
};
const buildFontFamilyName = ( font : PdfJsonFont ) : string => {
2025-10-24 15:34:35 +01:00
const preferred = ( font . baseName ?? '' ). trim ();
const identifier = preferred . length > 0 ? preferred : ( font . uid ?? font . id ?? 'font' ). toString ();
return `pdf-font- ${ identifier . replace ( /[^a-zA-Z0-9_-]/g , '' ) } ` ;
2025-10-23 23:36:53 +01:00
};
2025-10-23 16:38:06 +01:00
const getCaretOffset = ( element : HTMLElement ) : number => {
const selection = window . getSelection ();
if ( ! selection || selection . rangeCount === 0 || ! element . contains ( selection . focusNode )) {
return element . innerText . length ;
}
const range = selection . getRangeAt ( 0 ). cloneRange ();
range . selectNodeContents ( element );
range . setEnd ( selection . focusNode as Node , selection . focusOffset );
return range . toString (). length ;
};
const setCaretOffset = ( element : HTMLElement , offset : number ) : void => {
const selection = window . getSelection ();
if ( ! selection ) {
return ;
}
const targetOffset = Math . max ( 0 , Math . min ( offset , element . innerText . length ));
const range = document . createRange ();
let remaining = targetOffset ;
const walker = document . createTreeWalker ( element , NodeFilter . SHOW_TEXT );
let node = walker . nextNode ();
while ( node ) {
const textNode = node as Text ;
const length = textNode . length ;
if ( remaining <= length ) {
range . setStart ( textNode , remaining );
range . collapse ( true );
selection . removeAllRanges ();
selection . addRange ( range );
return ;
}
remaining -= length ;
node = walker . nextNode ();
}
range . selectNodeContents ( element );
range . collapse ( false );
selection . removeAllRanges ();
selection . addRange ( range );
};
2025-11-11 22:06:30 +00:00
const extractTextWithSoftBreaks = ( element : HTMLElement ) : { text : string ; insertedBreaks : boolean } => {
const normalized = element . innerText . replace ( /\u00A0/g , ' ' );
if ( ! element . isConnected ) {
return { text : normalized , insertedBreaks : false };
}
const walker = document . createTreeWalker ( element , NodeFilter . SHOW_TEXT , null );
const range = document . createRange ();
let result = '' ;
let previousTop : number | null = null ;
let insertedBreaks = false ;
while ( walker . nextNode ()) {
const node = walker . currentNode as Text ;
const nodeText = node . textContent ?? '' ;
for ( let index = 0 ; index < nodeText . length ; index += 1 ) {
const char = nodeText [ index ];
range . setStart ( node , index );
range . setEnd ( node , index + 1 );
const rect = range . getClientRects ()[ 0 ];
if ( previousTop !== null && rect && Math . abs ( rect . top - previousTop ) > 0.5 && result [ result . length - 1 ] !== '\n' ) {
result += '\n' ;
insertedBreaks = true ;
}
result += char ;
if ( rect ) {
previousTop = rect . top ;
}
if ( char === '\n' ) {
previousTop = null ;
}
}
}
return {
text : result.replace ( /\u00A0/g , ' ' ),
insertedBreaks ,
};
};
2025-11-12 10:16:36 +00:00
interface PdfTextEditorViewProps {
data : PdfTextEditorViewData ;
2025-10-23 11:20:16 +01:00
}
const toCssBounds = (
2025-10-23 12:50:30 +01:00
_page : PdfJsonPage | null | undefined ,
2025-10-23 11:20:16 +01:00
pageHeight : number ,
scale : number ,
bounds : { left : number ; right : number ; top : number ; bottom : number },
) => {
const width = Math . max ( bounds . right - bounds . left , 1 );
2025-10-23 23:36:53 +01:00
// Note: This codebase uses inverted naming where bounds.bottom > bounds.top
// bounds.bottom = visually upper edge (larger Y in PDF coords)
// bounds.top = visually lower edge (smaller Y in PDF coords)
2025-10-23 11:20:16 +01:00
const height = Math . max ( bounds . bottom - bounds . top , 1 );
2025-10-23 23:36:53 +01:00
const scaledWidth = Math . max ( width * scale , MIN_BOX_SIZE );
2025-10-23 11:20:16 +01:00
const scaledHeight = Math . max ( height * scale , MIN_BOX_SIZE / 2 );
2025-10-23 23:36:53 +01:00
// Convert PDF's visually upper edge (bounds.bottom) to CSS top
2025-10-23 11:20:16 +01:00
const top = Math . max ( pageHeight - bounds . bottom , 0 ) * scale ;
return {
left : bounds.left * scale ,
top ,
width : scaledWidth ,
height : scaledHeight ,
};
};
2025-11-01 10:10:49 +00:00
const normalizePageNumber = ( pageIndex : number | null | undefined ) : number | null => {
if ( pageIndex === null || pageIndex === undefined || Number . isNaN ( pageIndex )) {
return null ;
}
return pageIndex + 1 ;
};
const buildFontLookupKeys = (
fontId : string ,
font : PdfJsonFont | null | undefined ,
pageIndex : number | null | undefined ,
) : string [] => {
const keys : string [] = [];
const pageNumber = normalizePageNumber ( pageIndex );
if ( pageNumber !== null ) {
keys . push ( ` ${ pageNumber } : ${ fontId } ` );
}
if ( font ? . uid ) {
keys . push ( font . uid );
}
if ( font ? . pageNumber !== null && font ? . pageNumber !== undefined && font ? . id ) {
keys . push ( ` ${ font . pageNumber } : ${ font . id } ` );
}
keys . push ( fontId );
return Array . from ( new Set ( keys . filter (( value ) => value && value . length > 0 )));
};
2025-11-11 12:09:40 +00:00
/**
* Analyzes text groups on a page to determine if it's paragraph-heavy or sparse.
* Returns true if the page appears to be document-like with substantial text content.
*/
const analyzePageContentType = ( groups : TextGroup []) : boolean => {
if ( groups . length === 0 ) return false ;
let multiLineGroups = 0 ;
let totalWords = 0 ;
let longTextGroups = 0 ;
let totalGroups = 0 ;
const groupDetails : Array < {
id : string ;
lines : number ;
words : number ;
chars : number ;
text : string ;
} > = [];
groups . forEach (( group ) => {
const text = ( group . text || '' ). trim ();
if ( text . length === 0 ) return ;
totalGroups ++ ;
const lines = text . split ( '\n' );
const lineCount = lines . length ;
const wordCount = text . split ( /\s+/ ). filter (( w ) => w . length > 0 ). length ;
totalWords += wordCount ;
// Count multi-line paragraphs
if ( lineCount > 1 ) {
multiLineGroups ++ ;
}
// Count text groups with substantial content (more than a few words)
if ( wordCount >= 5 || text . length >= 30 ) {
longTextGroups ++ ;
}
groupDetails . push ({
id : group.id ,
lines : lineCount ,
words : wordCount ,
chars : text.length ,
text : text.substring ( 0 , 50 ) + ( text . length > 50 ? '...' : '' ),
});
});
if ( totalGroups === 0 ) return false ;
// Heuristics for paragraph mode:
// 1. Has multiple substantial multi-line groups (2+) AND decent average words
// 2. Average words per group > 12 (strong indicator of document text)
// 3. More than 40% of groups have substantial text (typical of documents)
const avgWordsPerGroup = totalWords / totalGroups ;
const longTextRatio = longTextGroups / totalGroups ;
const isParagraphPage =
( multiLineGroups >= 2 && avgWordsPerGroup > 8 ) ||
avgWordsPerGroup > 12 ||
longTextRatio > 0.4 ;
return isParagraphPage ;
};
type GroupingMode = 'auto' | 'paragraph' | 'singleLine' ;
2025-11-12 10:16:36 +00:00
const PdfTextEditorView = ({ data } : PdfTextEditorViewProps ) => {
2025-10-23 11:20:16 +01:00
const { t } = useTranslation ();
const [ activeGroupId , setActiveGroupId ] = useState < string | null >( null );
const [ editingGroupId , setEditingGroupId ] = useState < string | null >( null );
2025-10-23 16:38:06 +01:00
const [ activeImageId , setActiveImageId ] = useState < string | null >( null );
2025-11-13 00:10:09 +00:00
const draggingImageRef = useRef < string | null >( null );
const rndRefs = useRef < Map < string , any >>( new Map ());
const pendingDragUpdateRef = useRef < number | null >( null );
2025-10-23 23:36:53 +01:00
const [ fontFamilies , setFontFamilies ] = useState < Map < string , string >>( new Map ());
2025-11-02 21:00:03 +00:00
const [ autoScaleText , setAutoScaleText ] = useState ( true );
const [ textScales , setTextScales ] = useState < Map < string , number >>( new Map ());
2025-11-12 00:28:41 +00:00
const [ pendingModeChange , setPendingModeChange ] = useState < GroupingMode | null >( null );
2025-11-02 21:00:03 +00:00
const measurementKeyRef = useRef < string >( '' );
2025-10-23 16:38:06 +01:00
const containerRef = useRef < HTMLDivElement | null >( null );
const editorRefs = useRef < Map < string , HTMLDivElement >>( new Map ());
const caretOffsetsRef = useRef < Map < string , number >>( new Map ());
2025-10-23 11:20:16 +01:00
const {
document : pdfDocument ,
groupsByPage ,
2025-10-23 16:38:06 +01:00
imagesByPage ,
2025-11-06 00:16:23 +00:00
pagePreviews ,
2025-10-23 11:20:16 +01:00
selectedPage ,
dirtyPages ,
hasDocument ,
2025-11-06 00:16:23 +00:00
hasVectorPreview ,
2025-10-23 11:20:16 +01:00
fileName ,
errorMessage ,
isGeneratingPdf ,
2025-10-23 12:50:30 +01:00
isConverting ,
2025-11-02 21:00:03 +00:00
conversionProgress ,
2025-10-23 11:20:16 +01:00
hasChanges ,
2025-11-02 23:31:04 +00:00
forceSingleTextElement ,
2025-11-11 12:09:40 +00:00
groupingMode : externalGroupingMode ,
2025-11-06 00:16:23 +00:00
requestPagePreview ,
2025-10-23 11:20:16 +01:00
onLoadJson ,
onSelectPage ,
onGroupEdit ,
2025-11-10 18:11:57 +00:00
onGroupDelete ,
2025-10-23 16:38:06 +01:00
onImageTransform ,
onImageReset ,
2025-10-23 11:20:16 +01:00
onReset ,
onDownloadJson ,
onGeneratePdf ,
2025-11-02 23:31:04 +00:00
onForceSingleTextElementChange ,
2025-11-11 12:09:40 +00:00
onGroupingModeChange ,
2025-10-23 11:20:16 +01:00
} = data ;
2025-11-12 00:28:41 +00:00
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 );
}, []);
2025-11-01 10:10:49 +00:00
const resolveFont = ( fontId : string | null | undefined , pageIndex : number | null | undefined ) : PdfJsonFont | null => {
2025-10-23 12:50:30 +01:00
if ( ! fontId || ! pdfDocument ? . fonts ) {
2025-11-01 10:10:49 +00:00
return null ;
}
const fonts = pdfDocument . fonts ;
const pageNumber = normalizePageNumber ( pageIndex );
if ( pageNumber !== null ) {
const pageMatch = fonts . find (( font ) => font ? . id === fontId && font ? . pageNumber === pageNumber );
if ( pageMatch ) {
return pageMatch ;
}
const uidKey = ` ${ pageNumber } : ${ fontId } ` ;
const uidMatch = fonts . find (( font ) => font ? . uid === uidKey );
if ( uidMatch ) {
return uidMatch ;
}
2025-10-23 12:50:30 +01:00
}
2025-11-01 10:10:49 +00:00
const directUid = fonts . find (( font ) => font ? . uid === fontId );
if ( directUid ) {
return directUid ;
2025-10-23 23:36:53 +01:00
}
2025-11-01 10:10:49 +00:00
return fonts . find (( font ) => font ? . id === fontId ) ?? null ;
};
const getFontFamily = ( fontId : string | null | undefined , pageIndex : number | null | undefined ) : string => {
if ( ! fontId ) {
2025-10-23 12:50:30 +01:00
return 'sans-serif' ;
}
2025-11-01 10:10:49 +00:00
const font = resolveFont ( fontId , pageIndex );
const lookupKeys = buildFontLookupKeys ( fontId , font ?? undefined , pageIndex );
for ( const key of lookupKeys ) {
const loadedFamily = fontFamilies . get ( key );
if ( loadedFamily ) {
return `' ${ loadedFamily } ', sans-serif` ;
}
}
const fontName = font ? . standard14Name || font ? . baseName || '' ;
2025-10-23 12:50:30 +01:00
const lowerName = fontName . toLowerCase ();
if ( lowerName . includes ( 'times' )) {
return '"Times New Roman", Times, serif' ;
}
if ( lowerName . includes ( 'helvetica' ) || lowerName . includes ( 'arial' )) {
return 'Arial, Helvetica, sans-serif' ;
}
if ( lowerName . includes ( 'courier' )) {
return '"Courier New", Courier, monospace' ;
}
if ( lowerName . includes ( 'symbol' )) {
return 'Symbol, serif' ;
}
return 'Arial, Helvetica, sans-serif' ;
};
2025-11-01 10:10:49 +00:00
const getFontMetricsFor = (
fontId : string | null | undefined ,
pageIndex : number | null | undefined ,
) : { unitsPerEm : number ; ascent : number ; descent : number } | undefined => {
if ( ! fontId ) {
return undefined ;
}
const font = resolveFont ( fontId , pageIndex );
const lookupKeys = buildFontLookupKeys ( fontId , font ?? undefined , pageIndex );
for ( const key of lookupKeys ) {
const metrics = fontMetrics . get ( key );
if ( metrics ) {
return metrics ;
}
}
return undefined ;
};
const getLineHeightPx = (
fontId : string | null | undefined ,
pageIndex : number | null | undefined ,
fontSizePx : number ,
) : number => {
2025-10-23 23:36:53 +01:00
if ( fontSizePx <= 0 ) {
return fontSizePx ;
}
2025-11-01 10:10:49 +00:00
const metrics = getFontMetricsFor ( fontId , pageIndex );
2025-10-23 23:36:53 +01:00
if ( ! metrics || metrics . unitsPerEm <= 0 ) {
return fontSizePx * 1.2 ;
}
2025-11-01 10:10:49 +00:00
const unitsPerEm = metrics . unitsPerEm > 0 ? metrics.unitsPerEm : 1000 ;
const ascentUnits = metrics . ascent ?? unitsPerEm ;
const descentUnits = Math . abs ( metrics . descent ?? - ( unitsPerEm * 0.2 ));
const totalUnits = Math . max ( unitsPerEm , ascentUnits + descentUnits );
2025-10-23 23:36:53 +01:00
if ( totalUnits <= 0 ) {
return fontSizePx * 1.2 ;
}
2025-11-01 10:10:49 +00:00
const lineHeight = ( totalUnits / unitsPerEm ) * fontSizePx ;
2025-10-23 23:36:53 +01:00
return Math . max ( lineHeight , fontSizePx * 1.05 );
};
2025-11-01 10:10:49 +00:00
const getFontGeometry = (
fontId : string | null | undefined ,
pageIndex : number | null | undefined ,
) : {
unitsPerEm : number ;
ascentUnits : number ;
descentUnits : number ;
totalUnits : number ;
ascentRatio : number ;
descentRatio : number ;
} | undefined => {
const metrics = getFontMetricsFor ( fontId , pageIndex );
if ( ! metrics ) {
return undefined ;
}
const unitsPerEm = metrics . unitsPerEm > 0 ? metrics.unitsPerEm : 1000 ;
const rawAscent = metrics . ascent ?? unitsPerEm ;
const rawDescent = metrics . descent ?? - ( unitsPerEm * 0.2 );
const ascentUnits = Number . isFinite ( rawAscent ) ? rawAscent : unitsPerEm ;
const descentUnits = Number . isFinite ( rawDescent ) ? Math . abs ( rawDescent ) : unitsPerEm * 0.2 ;
const totalUnits = Math . max ( unitsPerEm , ascentUnits + descentUnits );
if ( totalUnits <= 0 || ! Number . isFinite ( totalUnits )) {
return undefined ;
}
return {
unitsPerEm ,
ascentUnits ,
descentUnits ,
totalUnits ,
ascentRatio : ascentUnits / totalUnits ,
descentRatio : descentUnits / totalUnits ,
};
};
const getFontWeight = (
fontId : string | null | undefined ,
pageIndex : number | null | undefined ,
) : number | 'normal' | 'bold' => {
if ( ! fontId ) {
2025-10-23 23:36:53 +01:00
return 'normal' ;
}
2025-11-01 10:10:49 +00:00
const font = resolveFont ( fontId , pageIndex );
2025-10-23 23:36:53 +01:00
if ( ! font || ! font . fontDescriptorFlags ) {
return 'normal' ;
}
// PDF font descriptor flag bit 18 (value 262144 = 0x40000) indicates ForceBold
const FORCE_BOLD_FLAG = 262144 ;
if (( font . fontDescriptorFlags & FORCE_BOLD_FLAG ) !== 0 ) {
return 'bold' ;
}
// Also check if font name contains "Bold"
const fontName = font . standard14Name || font . baseName || '' ;
if ( fontName . toLowerCase (). includes ( 'bold' )) {
return 'bold' ;
}
return 'normal' ;
};
2025-10-23 11:20:16 +01:00
const pages = pdfDocument ? . pages ?? [];
const currentPage = pages [ selectedPage ] ?? null ;
const pageGroups = groupsByPage [ selectedPage ] ?? [];
2025-10-23 16:38:06 +01:00
const pageImages = imagesByPage [ selectedPage ] ?? [];
2025-11-06 00:16:23 +00:00
const pagePreview = pagePreviews . get ( selectedPage );
2025-10-23 23:36:53 +01:00
2025-11-11 12:09:40 +00:00
// Detect if current page contains paragraph-heavy content
const isParagraphPage = useMemo (() => analyzePageContentType ( pageGroups ), [ pageGroups ]);
2025-11-11 22:06:30 +00:00
const isParagraphLayout =
externalGroupingMode === 'paragraph' || ( externalGroupingMode === 'auto' && isParagraphPage );
const paragraphWhiteSpace = isParagraphLayout ? 'pre-wrap' : 'pre' ;
const paragraphWordBreak = isParagraphLayout ? 'break-word' : 'normal' ;
const paragraphOverflowWrap = isParagraphLayout ? 'break-word' : 'normal' ;
const syncEditorValue = useCallback (
(
element : HTMLElement ,
pageIndex : number ,
groupId : string ,
options ?: { skipCaretRestore? : boolean },
) => {
const { text : value } = extractTextWithSoftBreaks ( element );
const offset = getCaretOffset ( element );
caretOffsetsRef . current . set ( groupId , offset );
onGroupEdit ( pageIndex , groupId , value );
if ( options ? . skipCaretRestore ) {
return ;
}
requestAnimationFrame (() => {
if ( editingGroupId !== groupId ) {
return ;
}
const editor = editorRefs . current . get ( groupId );
if ( editor ) {
const savedOffset = caretOffsetsRef . current . get ( groupId ) ?? editor . innerText . length ;
setCaretOffset ( editor , savedOffset );
}
});
},
[ editingGroupId , onGroupEdit ],
);
2025-11-11 12:09:40 +00:00
2025-11-02 23:31:04 +00:00
const extractPreferredFontId = useCallback (( target? : TextGroup | null ) => {
if ( ! target ) {
return undefined ;
}
if ( target . fontId ) {
return target . fontId ;
}
for ( const element of target . originalElements ?? []) {
if ( element . fontId ) {
return element . fontId ;
}
}
for ( const element of target . elements ?? []) {
if ( element . fontId ) {
return element . fontId ;
}
}
return undefined ;
}, []);
const resolveFontIdForIndex = useCallback (
( index : number ) : string | null | undefined => {
if ( index < 0 || index >= pageGroups . length ) {
return undefined ;
}
const direct = extractPreferredFontId ( pageGroups [ index ]);
if ( direct ) {
return direct ;
}
for ( let offset = 1 ; offset < pageGroups . length ; offset += 1 ) {
const prevIndex = index - offset ;
if ( prevIndex >= 0 ) {
const candidate = extractPreferredFontId ( pageGroups [ prevIndex ]);
if ( candidate ) {
return candidate ;
}
}
const nextIndex = index + offset ;
if ( nextIndex < pageGroups . length ) {
const candidate = extractPreferredFontId ( pageGroups [ nextIndex ]);
if ( candidate ) {
return candidate ;
}
}
}
return undefined ;
},
[ extractPreferredFontId , pageGroups ],
);
2025-10-23 23:36:53 +01:00
const fontMetrics = useMemo (() => {
const metrics = new Map < string , { unitsPerEm : number ; ascent : number ; descent : number }>();
pdfDocument ? . fonts ? . forEach (( font ) => {
if ( ! font ? . id ) {
return ;
}
const unitsPerEm = font . unitsPerEm && font . unitsPerEm > 0 ? font.unitsPerEm : 1000 ;
const ascent = font . ascent ?? unitsPerEm ;
const descent = font . descent ?? - ( unitsPerEm * 0.2 );
2025-11-01 10:10:49 +00:00
const metric = { unitsPerEm , ascent , descent };
metrics . set ( font . id , metric );
if ( font . uid ) {
metrics . set ( font . uid , metric );
}
if ( font . pageNumber !== null && font . pageNumber !== undefined ) {
metrics . set ( ` ${ font . pageNumber } : ${ font . id } ` , metric );
}
2025-10-23 23:36:53 +01:00
});
return metrics ;
}, [ pdfDocument ? . fonts ]);
useEffect (() => {
if ( typeof FontFace === 'undefined' ) {
setFontFamilies ( new Map ());
return undefined ;
}
let disposed = false ;
const active : { fontFace : FontFace ; url? : string }[] = [];
const registerFonts = async () => {
const fonts = pdfDocument ? . fonts ?? [];
if ( fonts . length === 0 ) {
setFontFamilies ( new Map ());
return ;
}
const next = new Map < string , string >();
2025-11-01 10:10:49 +00:00
const pickFontSource = (
font : PdfJsonFont
) : { data : string ; format? : string | null ; source : 'pdfProgram' | 'webProgram' | 'program' } | null => {
if ( font . pdfProgram && font . pdfProgram . length > 0 ) {
return { data : font.pdfProgram , format : font.pdfProgramFormat , source : 'pdfProgram' };
}
if ( font . webProgram && font . webProgram . length > 0 ) {
return { data : font.webProgram , format : font.webProgramFormat , source : 'webProgram' };
}
if ( font . program && font . program . length > 0 ) {
return { data : font.program , format : font.programFormat , source : 'program' };
}
return null ;
};
const registerLoadedFontKeys = ( font : PdfJsonFont , familyName : string ) => {
if ( font . id ) {
next . set ( font . id , familyName );
}
if ( font . uid ) {
next . set ( font . uid , familyName );
}
if ( font . pageNumber !== null && font . pageNumber !== undefined && font . id ) {
next . set ( ` ${ font . pageNumber } : ${ font . id } ` , familyName );
}
};
2025-10-23 23:36:53 +01:00
for ( const font of fonts ) {
2025-11-01 10:10:49 +00:00
if ( ! font || ! font . id ) {
2025-10-24 15:34:35 +01:00
continue ;
}
2025-11-01 10:10:49 +00:00
const selection = pickFontSource ( font );
if ( ! selection ) {
2025-10-23 23:36:53 +01:00
continue ;
}
try {
2025-11-01 10:10:49 +00:00
const formatSource = selection . format ;
2025-10-24 15:34:35 +01:00
const format = normalizeFontFormat ( formatSource );
2025-11-01 10:10:49 +00:00
const data = decodeBase64ToUint8Array ( selection . data );
2025-10-23 23:36:53 +01:00
const blob = new Blob ([ data as BlobPart ], { type : getFontMimeType ( format ) });
const url = URL . createObjectURL ( blob );
const formatHint = getFontFormatHint ( format );
const familyName = buildFontFamilyName ( font );
const source = formatHint ? `url( ${ url } ) format(' ${ formatHint } ')` : `url( ${ url } )` ;
const fontFace = new FontFace ( familyName , source );
2025-10-24 15:34:35 +01:00
2025-11-01 10:10:49 +00:00
console . debug ( `[FontLoader] Loading font ${ font . id } ( ${ font . baseName } ) using ${ selection . source } :` , {
2025-10-24 15:34:35 +01:00
formatSource ,
format ,
formatHint ,
familyName ,
dataLength : data.length ,
2025-11-01 10:10:49 +00:00
hasPdfProgram : !! font . pdfProgram ,
2025-10-24 15:34:35 +01:00
hasWebProgram : !! font . webProgram ,
hasProgram : !! font . program
});
2025-10-23 23:36:53 +01:00
await fontFace . load ();
if ( disposed ) {
document . fonts . delete ( fontFace );
URL . revokeObjectURL ( url );
continue ;
}
document . fonts . add ( fontFace );
active . push ({ fontFace , url });
2025-11-01 10:10:49 +00:00
registerLoadedFontKeys ( font , familyName );
2025-10-24 15:34:35 +01:00
console . debug ( `[FontLoader] Successfully loaded font ${ font . id } ` );
2025-10-23 23:36:53 +01:00
} catch ( error ) {
2025-11-01 10:10:49 +00:00
console . warn ( `[FontLoader] Failed to load font ${ font . id } ( ${ font . baseName } ) using ${ selection . source } :` , {
2025-10-24 15:34:35 +01:00
error : error instanceof Error ? error.message : String ( error ),
2025-11-01 10:10:49 +00:00
formatSource : selection.format ,
hasPdfProgram : !! font . pdfProgram ,
2025-10-24 15:34:35 +01:00
hasWebProgram : !! font . webProgram ,
hasProgram : !! font . program
});
2025-10-23 23:36:53 +01:00
// Fallback to web-safe fonts is already implemented via getFontFamily()
}
}
if ( ! disposed ) {
setFontFamilies ( next );
} else {
active . forEach (({ fontFace , url }) => {
document . fonts . delete ( fontFace );
if ( url ) {
URL . revokeObjectURL ( url );
}
});
}
};
registerFonts ();
return () => {
disposed = true ;
active . forEach (({ fontFace , url }) => {
document . fonts . delete ( fontFace );
if ( url ) {
URL . revokeObjectURL ( url );
}
});
};
}, [ pdfDocument ? . fonts ]);
2025-10-23 11:20:16 +01:00
const visibleGroups = useMemo (
() =>
2025-11-02 23:31:04 +00:00
pageGroups
. map (( group , index ) => ({ group , pageGroupIndex : index }))
. filter (({ group }) => {
const hasContent =
(( group . text ?? '' ). trim (). length > 0 ) ||
(( group . originalText ?? '' ). trim (). length > 0 );
return hasContent || editingGroupId === group . id ;
}),
[ editingGroupId , pageGroups ],
2025-10-23 11:20:16 +01:00
);
2025-10-23 16:38:06 +01:00
const orderedImages = useMemo (
() =>
[... pageImages ]. sort (
( first , second ) => ( first ? . zOrder ?? - 1 _000_000 ) - ( second ? . zOrder ?? - 1 _000_000 ),
),
[ pageImages ],
);
2025-10-23 11:20:16 +01:00
const { width : pageWidth , height : pageHeight } = pageDimensions ( currentPage );
2025-11-11 18:08:50 +00:00
const scale = useMemo (() => Math . min ( MAX_RENDER_WIDTH / pageWidth , 2.5 ), [ pageWidth ]);
2025-10-23 11:20:16 +01:00
const scaledWidth = pageWidth * scale ;
const scaledHeight = pageHeight * scale ;
2025-11-06 00:16:23 +00:00
useEffect (() => {
if ( ! hasDocument || ! hasVectorPreview ) {
return ;
}
requestPagePreview ( selectedPage , scale );
if ( selectedPage + 1 < pages . length ) {
requestPagePreview ( selectedPage + 1 , scale );
}
}, [ hasDocument , hasVectorPreview , selectedPage , scale , pages . length , requestPagePreview ]);
2025-10-23 11:20:16 +01:00
useEffect (() => {
setActiveGroupId ( null );
setEditingGroupId ( null );
2025-10-23 16:38:06 +01:00
setActiveImageId ( null );
2025-11-02 21:00:03 +00:00
setTextScales ( new Map ());
measurementKeyRef . current = '' ;
2025-10-23 11:20:16 +01:00
}, [ selectedPage ]);
2025-11-02 21:00:03 +00:00
// Measure text widths once per page/configuration and apply static scaling
useLayoutEffect (() => {
2025-11-10 22:55:16 +00:00
if ( ! autoScaleText ) {
// Clear all scales when auto-scale is disabled
setTextScales ( new Map ());
measurementKeyRef . current = '' ;
return ;
}
if ( visibleGroups . length === 0 ) {
2025-11-02 21:00:03 +00:00
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 >();
2025-11-02 23:31:04 +00:00
visibleGroups . forEach (({ group }) => {
2025-11-02 21:00:03 +00:00
// Skip groups that are being edited
if ( editingGroupId === group . id ) {
return ;
}
2025-11-10 22:55:16 +00:00
// Skip multi-line paragraphs - auto-scaling doesn't work well with wrapped text
const lineCount = ( group . text || '' ). split ( '\n' ). length ;
if ( lineCount > 1 ) {
newScales . set ( group . id , 1 );
return ;
}
2025-11-02 21:00:03 +00:00
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 );
2025-11-10 22:55:16 +00:00
}, [
autoScaleText ,
visibleGroups ,
editingGroupId ,
currentPage ,
pageHeight ,
scale ,
fontFamilies . size ,
selectedPage ,
]);
2025-11-02 21:00:03 +00:00
2025-10-23 16:38:06 +01:00
useLayoutEffect (() => {
2025-11-10 17:49:26 +00:00
// Only restore caret position during re-renders while already editing
// Don't interfere with initial click-to-position behavior
2025-10-23 16:38:06 +01:00
if ( ! editingGroupId ) {
return ;
}
const editor = editorRefs . current . get ( editingGroupId );
if ( ! editor ) {
return ;
}
2025-11-02 23:31:04 +00:00
const offset = caretOffsetsRef . current . get ( editingGroupId );
2025-11-10 17:49:26 +00:00
// Only restore if we have a saved offset (meaning user was already typing)
if ( offset === undefined || offset === 0 ) {
2025-11-02 23:31:04 +00:00
return ;
}
2025-10-23 16:38:06 +01:00
setCaretOffset ( editor , offset );
}, [ editingGroupId , groupsByPage , imagesByPage ]);
2025-10-23 11:20:16 +01:00
useEffect (() => {
if ( ! editingGroupId ) {
return ;
}
const editor = document . querySelector < HTMLElement >( `[data-editor-group=" ${ editingGroupId } "]` );
if ( editor ) {
2025-11-02 23:31:04 +00:00
if ( document . activeElement !== editor ) {
editor . focus ();
2025-10-23 11:20:16 +01:00
}
}
}, [ editingGroupId ]);
2025-11-13 00:10:09 +00:00
// Sync image positions when not dragging (handles stutters/re-renders)
useLayoutEffect (() => {
const isDragging = draggingImageRef . current !== null ;
if ( isDragging ) {
return ; // Don't sync during drag
}
pageImages . forEach (( image ) => {
if ( ! image ? . id ) return ;
const imageId = image . id ;
const rndRef = rndRefs . current . get ( imageId );
if ( ! rndRef || ! rndRef . updatePosition ) return ;
const bounds = getImageBounds ( image );
const width = Math . max ( bounds . right - bounds . left , 1 );
const height = Math . max ( bounds . top - bounds . bottom , 1 );
const cssLeft = bounds . left * scale ;
const cssTop = ( pageHeight - bounds . top ) * scale ;
// Get current position from Rnd component
const currentState = rndRef . state || {};
const currentX = currentState . x ?? 0 ;
const currentY = currentState . y ?? 0 ;
// Calculate drift
const drift = Math . abs ( currentX - cssLeft ) + Math . abs ( currentY - cssTop );
// Only sync if drift is significant (more than 3px)
if ( drift > 3 ) {
rndRef . updatePosition ({ x : cssLeft , y : cssTop });
}
});
}, [ pageImages , scale , pageHeight ]);
2025-10-23 11:20:16 +01:00
const handlePageChange = ( pageNumber : number ) => {
setActiveGroupId ( null );
setEditingGroupId ( null );
onSelectPage ( pageNumber - 1 );
};
const handleBackgroundClick = () => {
setEditingGroupId ( null );
setActiveGroupId ( null );
2025-10-23 16:38:06 +01:00
setActiveImageId ( null );
2025-10-23 11:20:16 +01:00
};
const renderGroupContainer = (
groupId : string ,
2025-11-10 18:11:57 +00:00
pageIndex : number ,
2025-10-23 11:20:16 +01:00
isActive : boolean ,
isChanged : boolean ,
content : React.ReactNode ,
onActivate ?: ( event : React.MouseEvent ) => void ,
2025-11-10 18:18:21 +00:00
onClick ?: ( event : React.MouseEvent ) => void ,
2025-10-23 11:20:16 +01:00
) => (
< Box
component = "div"
style = {{
2025-11-10 18:11:57 +00:00
width : 'calc(100% + 4px)' ,
height : 'calc(100% + 6px)' ,
marginLeft : '-2px' ,
marginTop : '-3px' ,
2025-10-23 12:50:30 +01:00
outline : isActive
2025-10-23 11:20:16 +01:00
? '2px solid var(--mantine-color-blue-5)'
: isChanged
? '1px solid var(--mantine-color-yellow-5)'
2025-10-23 12:50:30 +01:00
: 'none' ,
outlineOffset : '-1px' ,
2025-10-23 11:20:16 +01:00
borderRadius : 6 ,
backgroundColor : isChanged || isActive ? 'rgba(250,255,189,0.28)' : 'transparent' ,
2025-10-23 12:50:30 +01:00
transition : 'outline 120ms ease, background-color 120ms ease' ,
2025-10-23 11:20:16 +01:00
pointerEvents : 'auto' ,
2025-10-23 12:50:30 +01:00
overflow : 'visible' ,
2025-10-23 11:20:16 +01:00
display : 'flex' ,
alignItems : 'flex-start' ,
justifyContent : 'flex-start' ,
2025-11-10 18:11:57 +00:00
padding : '3px 2px 3px 2px' ,
position : 'relative' ,
2025-10-23 11:20:16 +01:00
}}
onClick = {( event ) => {
event . stopPropagation ();
2025-11-10 18:18:21 +00:00
if ( onClick ) {
onClick ( event );
} else {
onActivate ? .( event );
2025-10-23 11:20:16 +01:00
}
}}
>
{ content }
2025-11-10 22:55:16 +00:00
{ activeGroupId === groupId && (
2025-11-10 18:11:57 +00:00
< ActionIcon
size = "xs"
variant = "filled"
color = "red"
radius = "xl"
style = {{
position : 'absolute' ,
top : - 8 ,
right : - 8 ,
zIndex : 10 ,
cursor : 'pointer' ,
}}
onClick = {( event ) => {
event . stopPropagation ();
onGroupDelete ( pageIndex , groupId );
setActiveGroupId ( null );
setEditingGroupId ( null );
}}
>
< CloseIcon style = {{ fontSize : 12 }} />
</ ActionIcon >
)}
2025-10-23 11:20:16 +01:00
</ Box >
);
2025-10-23 16:38:06 +01:00
const emitImageTransform = useCallback (
(
imageId : string ,
leftPx : number ,
topPx : number ,
widthPx : number ,
heightPx : number ,
) => {
const rawLeft = leftPx / scale ;
const rawTop = pageHeight - topPx / scale ;
const width = Math . max ( widthPx / scale , 0.01 );
const height = Math . max ( heightPx / scale , 0.01 );
const maxLeft = Math . max ( pageWidth - width , 0 );
const left = Math . min ( Math . max ( rawLeft , 0 ), maxLeft );
const minTop = Math . min ( height , pageHeight );
const top = Math . min ( Math . max ( rawTop , minTop ), pageHeight );
const bottom = Math . max ( top - height , 0 );
onImageTransform ( selectedPage , imageId , { left , bottom , width , height , transform : [] });
},
[ onImageTransform , pageHeight , pageWidth , scale , selectedPage ],
);
2025-10-23 11:20:16 +01:00
return (
2025-11-02 23:31:04 +00:00
< Stack
gap = "xl"
className = "h-full"
style = {{
padding : '1.5rem' ,
overflow : 'hidden' ,
2025-11-11 18:08:50 +00:00
height : '100%' ,
2025-11-02 23:31:04 +00:00
display : 'grid' ,
gridTemplateColumns : 'minmax(0, 1fr) 320px' ,
alignItems : 'start' ,
gap : '1.5rem' ,
}}
>
< Card
withBorder
radius = "md"
shadow = "xs"
2025-11-11 15:37:29 +00:00
padding = "md"
style = {{
gridColumn : '2 / 3' ,
gridRow : 1 ,
maxHeight : 'calc(100vh - 3rem)' ,
display : 'flex' ,
flexDirection : 'column' ,
overflow : 'hidden'
}}
2025-11-02 23:31:04 +00:00
>
2025-11-11 15:37:29 +00:00
< ScrollArea style = {{ flex : 1 }} offsetScrollbars >
< Stack gap = "sm" pb = "md" >
< Group justify = "space-between" align = "center" >
< Group gap = "xs" align = "center" >
< DescriptionIcon fontSize = "small" />
2025-11-12 10:16:36 +00:00
< Title order = { 3 }>{ t ( 'pdfTextEditor.title' , 'PDF JSON Editor' )}</ Title >
{ hasChanges && < Badge color = "orange" variant = "light" size = "sm" >{ t ( 'pdfTextEditor.badges.unsaved' , 'Edited' )}</ Badge >}
2025-11-11 15:37:29 +00:00
</ Group >
2025-10-23 11:20:16 +01:00
</ Group >
2025-11-11 15:37:29 +00:00
< Stack gap = "xs" >
2025-10-23 12:50:30 +01:00
< FileButton onChange = { onLoadJson } accept = "application/pdf,application/json,.pdf,.json" >
2025-10-23 11:20:16 +01:00
{( props ) => (
2025-10-23 12:50:30 +01:00
< Button
variant = "light"
leftSection = {< UploadIcon fontSize = "small" />}
loading = { isConverting }
2025-11-02 23:31:04 +00:00
fullWidth
2025-10-23 12:50:30 +01:00
{ ...props }
>
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.actions.load' , 'Load File' )}
2025-10-23 11:20:16 +01:00
</ Button >
)}
</ FileButton >
< Button
variant = "subtle"
leftSection = {< AutorenewIcon fontSize = "small" />}
onClick = { onReset }
2025-10-23 12:50:30 +01:00
disabled = { ! hasDocument || isConverting }
2025-11-02 23:31:04 +00:00
fullWidth
2025-10-23 11:20:16 +01:00
>
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.actions.reset' , 'Reset Changes' )}
2025-10-23 11:20:16 +01:00
</ Button >
< Button
variant = "default"
leftSection = {< FileDownloadIcon fontSize = "small" />}
onClick = { onDownloadJson }
2025-10-23 12:50:30 +01:00
disabled = { ! hasDocument || isConverting }
2025-11-02 23:31:04 +00:00
fullWidth
2025-10-23 11:20:16 +01:00
>
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.actions.downloadJson' , 'Download JSON' )}
2025-10-23 11:20:16 +01:00
</ Button >
< Button
leftSection = {< PictureAsPdfIcon fontSize = "small" />}
onClick = { onGeneratePdf }
loading = { isGeneratingPdf }
2025-10-23 12:50:30 +01:00
disabled = { ! hasDocument || ! hasChanges || isConverting }
2025-11-02 23:31:04 +00:00
fullWidth
2025-10-23 11:20:16 +01:00
>
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.actions.generatePdf' , 'Generate PDF' )}
2025-10-23 11:20:16 +01:00
</ Button >
2025-11-02 23:31:04 +00:00
</ Stack >
2025-10-23 11:20:16 +01:00
2025-11-11 15:37:29 +00:00
{ fileName && (
< Text size = "sm" c = "dimmed" >
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.currentFile' , 'Current file: {{name}}' , { name : fileName })}
2025-11-11 15:37:29 +00:00
</ Text >
)}
< Divider my = "xs" />
2025-11-02 21:00:03 +00:00
2025-11-11 15:37:29 +00:00
< Group justify = "space-between" align = "center" >
< div >
< Text fw = { 500 } size = "sm" >
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.options.autoScaleText.title' , 'Auto-scale text to fit boxes' )}
2025-11-11 15:37:29 +00:00
</ Text >
< Text size = "xs" c = "dimmed" mt = { 4 }>
{ t (
2025-11-12 10:16:36 +00:00
'pdfTextEditor.options.autoScaleText.description' ,
2025-11-11 15:37:29 +00:00
'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 >
2025-11-02 21:00:03 +00:00
2025-11-11 15:37:29 +00:00
< Stack gap = "xs" >
< Group gap = { 4 } align = "center" >
< Text fw = { 500 } size = "sm" >
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.options.groupingMode.title' , 'Text Grouping Mode' )}
2025-11-11 15:37:29 +00:00
</ Text >
{ externalGroupingMode === 'auto' && isParagraphPage && (
< Badge size = "xs" color = "blue" variant = "light" >
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.pageType.paragraph' , 'Paragraph page' )}
2025-11-11 15:37:29 +00:00
</ Badge >
2025-11-02 21:00:03 +00:00
)}
2025-11-11 15:37:29 +00:00
{ externalGroupingMode === 'auto' && ! isParagraphPage && hasDocument && (
< Badge size = "xs" color = "gray" variant = "light" >
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.pageType.sparse' , 'Sparse text' )}
2025-11-11 15:37:29 +00:00
</ Badge >
)}
</ Group >
< Text size = "xs" c = "dimmed" >
{ externalGroupingMode === 'auto'
? t (
2025-11-12 10:16:36 +00:00
'pdfTextEditor.options.groupingMode.autoDescription' ,
2025-11-11 15:37:29 +00:00
'Automatically detects page type and groups text appropriately.'
)
: externalGroupingMode === 'paragraph'
? t (
2025-11-12 10:16:36 +00:00
'pdfTextEditor.options.groupingMode.paragraphDescription' ,
2025-11-11 15:37:29 +00:00
'Groups aligned lines into multi-line paragraph text boxes.'
)
: t (
2025-11-12 10:16:36 +00:00
'pdfTextEditor.options.groupingMode.singleLineDescription' ,
2025-11-11 15:37:29 +00:00
'Keeps each PDF text line as a separate text box.'
)}
2025-11-02 21:00:03 +00:00
</ Text >
2025-11-11 15:37:29 +00:00
< SegmentedControl
value = { externalGroupingMode }
2025-11-12 00:28:41 +00:00
onChange = {( value ) => handleModeChangeRequest ( value as GroupingMode )}
2025-11-11 15:37:29 +00:00
data = {[
2025-11-12 10:16:36 +00:00
{ 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' },
2025-11-11 15:37:29 +00:00
]}
fullWidth
/>
</ Stack >
2025-11-02 23:31:04 +00:00
2025-11-11 15:37:29 +00:00
< Group justify = "space-between" align = "center" >
< div >
< Text fw = { 500 } size = "sm" >
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.options.forceSingleElement.title' , 'Lock edited text to a single PDF element' )}
2025-11-11 15:37:29 +00:00
</ Text >
< Text size = "xs" c = "dimmed" mt = { 4 }>
{ t (
2025-11-12 10:16:36 +00:00
'pdfTextEditor.options.forceSingleElement.description' ,
2025-11-11 15:37:29 +00:00
'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 )}
/>
2025-11-11 12:09:40 +00:00
</ Group >
2025-11-11 15:37:29 +00:00
< 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 }>
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.disclaimer.heading' , 'Preview Limitations' )}
2025-11-11 15:37:29 +00:00
</ Text >
</ Group >
</ Accordion.Control >
< Accordion.Panel >
< Stack gap = { 4 }>
< Text size = "xs" >
{ t (
2025-11-12 10:16:36 +00:00
'pdfTextEditor.disclaimer.textFocus' ,
2025-11-11 15:37:29 +00:00
'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 (
2025-11-12 10:16:36 +00:00
'pdfTextEditor.disclaimer.previewVariance' ,
2025-11-11 15:37:29 +00:00
'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 (
2025-11-12 10:16:36 +00:00
'pdfTextEditor.disclaimer.alpha' ,
2025-11-11 15:37:29 +00:00
'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 >
2025-10-23 11:20:16 +01:00
</ Card >
{ errorMessage && (
2025-11-02 23:31:04 +00:00
< Alert
icon = {< WarningAmberIcon fontSize = "small" />}
color = "red"
radius = "md"
style = {{ gridColumn : '2 / 3' }}
>
2025-10-23 11:20:16 +01:00
{ errorMessage }
</ Alert >
)}
2025-10-23 12:50:30 +01:00
{ ! hasDocument && ! isConverting && (
2025-11-02 23:31:04 +00:00
< Card withBorder radius = "md" padding = "xl" style = {{ gridColumn : '1 / 2' , gridRow : 1 }}>
2025-10-23 11:20:16 +01:00
< Stack align = "center" gap = "md" >
< DescriptionIcon sx = {{ fontSize : 48 }} />
< Text size = "lg" fw = { 600 }>
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.empty.title' , 'No document loaded' )}
2025-10-23 11:20:16 +01:00
</ Text >
< Text size = "sm" c = "dimmed" ta = "center" maw = { 420 }>
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.empty.subtitle' , 'Load a PDF or JSON file to begin editing text content.' )}
2025-10-23 12:50:30 +01:00
</ Text >
</ Stack >
</ Card >
)}
{ isConverting && (
2025-11-02 23:31:04 +00:00
< Card withBorder radius = "md" padding = "xl" style = {{ gridColumn : '1 / 2' , gridRow : 1 }}>
2025-11-02 21:00:03 +00:00
< Stack gap = "md" >
< Group justify = "space-between" align = "flex-start" >
< div style = {{ flex : 1 }}>
< Text size = "lg" fw = { 600 } mb = "xs" >
{ conversionProgress
? conversionProgress.message
2025-11-12 10:16:36 +00:00
: t ( 'pdfTextEditor.converting' , 'Converting PDF to editable format...' )}
2025-11-02 21:00:03 +00:00
</ Text >
{ conversionProgress && (
< Group gap = "xs" >
< Text size = "sm" c = "dimmed" tt = "capitalize" >
2025-11-12 10:16:36 +00:00
{ t ( `pdfTextEditor.stages. ${ conversionProgress . stage } ` , conversionProgress . stage )}
2025-11-02 21:00:03 +00:00
</ 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 >
2025-11-02 23:31:04 +00:00
< Progress value = { conversionProgress ? . percent || 0 } size = "lg" radius = "md" />
2025-10-23 11:20:16 +01:00
</ Stack >
</ Card >
)}
{ hasDocument && (
2025-11-11 18:08:50 +00:00
< Stack
gap = "lg"
className = "flex-1"
style = {{
gridColumn : '1 / 2' ,
gridRow : 1 ,
minHeight : 0 ,
height : 'calc(100vh - 3rem)' ,
overflow : 'hidden' ,
}}
>
2025-10-23 11:20:16 +01:00
< Group justify = "space-between" align = "center" >
< Group gap = "sm" >
< Text fw = { 500 }>
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.pageSummary' , 'Page {{number}} of {{total}}' , {
2025-10-23 11:20:16 +01:00
number : selectedPage + 1 ,
total : pages.length ,
})}
</ Text >
{ dirtyPages [ selectedPage ] && (
< Badge color = "yellow" size = "xs" >
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.badges.modified' , 'Edited' )}
2025-10-23 11:20:16 +01:00
</ Badge >
)}
2025-11-12 00:44:39 +00:00
< Badge color = "blue" variant = "dot" size = "xs" >
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.badges.earlyAccess' , 'Early Access' )}
2025-11-12 00:44:39 +00:00
</ Badge >
2025-10-23 11:20:16 +01:00
</ Group >
{ pages . length > 1 && (
< Pagination
value = { selectedPage + 1 }
onChange = { handlePageChange }
total = { pages . length }
size = "sm"
/>
)}
</ Group >
2025-11-11 18:08:50 +00:00
< Card
withBorder
padding = "md"
radius = "md"
shadow = "xs"
style = {{ flex : 1 , minHeight : 0 , height : '100%' , overflow : 'hidden' }}
>
< ScrollArea style = {{ height : '100%' , maxHeight : '100%' }} offsetScrollbars >
2025-10-23 11:20:16 +01:00
< Box
style = {{
2025-10-23 23:36:53 +01:00
display : 'flex' ,
justifyContent : 'center' ,
alignItems : 'flex-start' ,
width : '100%' ,
minHeight : '100%' ,
2025-10-23 11:20:16 +01:00
}}
>
< Box
style = {{
2025-10-23 23:36:53 +01:00
background : '#f3f4f6' ,
padding : '0.5rem' ,
borderRadius : '0.75rem' ,
2025-10-23 11:20:16 +01:00
}}
2025-10-23 23:36:53 +01:00
onClick = { handleBackgroundClick }
2025-10-23 11:20:16 +01:00
>
2025-10-23 23:36:53 +01:00
< Box
style = {{
position : 'relative' ,
width : ` ${ scaledWidth } px` ,
height : ` ${ scaledHeight } px` ,
backgroundColor : '#ffffff' ,
boxShadow : '0 0 12px rgba(15, 23, 42, 0.12)' ,
borderRadius : '0.5rem' ,
overflow : 'hidden' ,
}}
ref = { containerRef }
>
2025-11-06 00:16:23 +00:00
{ pagePreview && (
< img
src = { pagePreview }
2025-11-12 10:16:36 +00:00
alt = { t ( 'pdfTextEditor.pagePreviewAlt' , 'Page preview' )}
2025-11-06 00:16:23 +00:00
style = {{
position : 'absolute' ,
inset : 0 ,
width : '100%' ,
height : '100%' ,
objectFit : 'contain' ,
pointerEvents : 'none' ,
userSelect : 'none' ,
zIndex : 0 ,
}}
/>
)}
{ orderedImages . map (( image , imageIndex ) => {
2025-10-23 16:38:06 +01:00
if ( ! image ? . imageData ) {
return null ;
}
const bounds = getImageBounds ( image );
const width = Math . max ( bounds . right - bounds . left , 1 );
const height = Math . max ( bounds . top - bounds . bottom , 1 );
const cssWidth = Math . max ( width * scale , 2 );
const cssHeight = Math . max ( height * scale , 2 );
const cssLeft = bounds . left * scale ;
const cssTop = ( pageHeight - bounds . top ) * scale ;
const imageId = image . id ?? `page- ${ selectedPage } -image- ${ imageIndex } ` ;
const isActive = activeImageId === imageId ;
const src = `data:image/ ${ image . imageFormat ?? 'png' } ;base64, ${ image . imageData } ` ;
const baseZIndex = ( image . zOrder ?? - 1 _000_000 ) + 1 _050_000 ;
const zIndex = isActive ? baseZIndex + 1_000_000 : baseZIndex ;
return (
< Rnd
2025-11-13 00:10:09 +00:00
ref = {( ref ) => {
if ( ref ) {
rndRefs . current . set ( imageId , ref );
} else {
rndRefs . current . delete ( imageId );
}
}}
2025-10-23 16:38:06 +01:00
key = { `image- ${ imageId } ` }
bounds = "parent"
size = {{ width : cssWidth , height : cssHeight }}
position = {{ x : cssLeft , y : cssTop }}
2025-11-13 00:10:09 +00:00
onDragStart = {( _event , data ) => {
2025-10-23 16:38:06 +01:00
setActiveGroupId ( null );
setEditingGroupId ( null );
setActiveImageId ( imageId );
2025-11-13 00:10:09 +00:00
draggingImageRef . current = imageId ;
2025-10-23 16:38:06 +01:00
}}
2025-10-23 23:36:53 +01:00
onDrag = {( _event , data ) => {
2025-11-13 00:10:09 +00:00
// Cancel any pending update
if ( pendingDragUpdateRef . current ) {
cancelAnimationFrame ( pendingDragUpdateRef . current );
}
// Schedule update on next frame to batch rapid drag events
pendingDragUpdateRef . current = requestAnimationFrame (() => {
const rndRef = rndRefs . current . get ( imageId );
if ( rndRef && rndRef . updatePosition ) {
rndRef . updatePosition ({ x : data.x , y : data.y });
}
});
2025-10-23 16:38:06 +01:00
}}
2025-10-23 23:36:53 +01:00
onDragStop = {( _event , data ) => {
2025-11-13 00:10:09 +00:00
if ( pendingDragUpdateRef . current ) {
cancelAnimationFrame ( pendingDragUpdateRef . current );
pendingDragUpdateRef . current = null ;
}
draggingImageRef . current = null ;
2025-10-23 16:38:06 +01:00
emitImageTransform (
imageId ,
data . x ,
data . y ,
cssWidth ,
cssHeight ,
);
}}
onResizeStart = {() => {
setActiveImageId ( imageId );
setActiveGroupId ( null );
setEditingGroupId ( null );
2025-11-13 00:10:09 +00:00
draggingImageRef . current = imageId ;
2025-10-23 16:38:06 +01:00
}}
2025-10-23 23:36:53 +01:00
onResizeStop = {( _event , _direction , ref , _delta , position ) => {
2025-11-13 00:10:09 +00:00
draggingImageRef . current = null ;
2025-10-23 16:38:06 +01:00
const nextWidth = parseFloat ( ref . style . width );
const nextHeight = parseFloat ( ref . style . height );
emitImageTransform (
imageId ,
position . x ,
position . y ,
nextWidth ,
nextHeight ,
);
}}
style = {{ zIndex }}
>
< Box
onMouseEnter = {() => setActiveImageId ( imageId )}
onMouseLeave = {() => {
setActiveImageId (( current ) => ( current === imageId ? null : current ));
}}
onDoubleClick = {( event ) => {
event . stopPropagation ();
onImageReset ( selectedPage , imageId );
}}
style = {{
width : '100%' ,
height : '100%' ,
cursor : isActive ? 'grabbing' : 'grab' ,
outline : isActive
? '2px solid rgba(59, 130, 246, 0.9)'
: '1px solid rgba(148, 163, 184, 0.4)' ,
outlineOffset : '-1px' ,
borderRadius : 4 ,
backgroundColor : 'rgba(255,255,255,0.04)' ,
transition : 'outline 120ms ease' ,
}}
>
< img
src = { src }
2025-11-12 10:16:36 +00:00
alt = { t ( 'pdfTextEditor.imageLabel' , 'Placed image' )}
2025-10-23 16:38:06 +01:00
style = {{
width : '100%' ,
height : '100%' ,
objectFit : 'contain' ,
pointerEvents : 'none' ,
userSelect : 'none' ,
}}
/>
</ Box >
</ Rnd >
);
})}
{ visibleGroups . length === 0 && orderedImages . length === 0 ? (
2025-10-23 11:20:16 +01:00
< Group justify = "center" align = "center" style = {{ height : '100%' }}>
< Stack gap = { 4 } align = "center" >
< Text size = "sm" c = "dimmed" >
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.noTextOnPage' , 'No editable text was detected on this page.' )}
2025-10-23 11:20:16 +01:00
</ Text >
</ Stack >
</ Group >
) : (
2025-11-02 23:31:04 +00:00
visibleGroups . map (({ group , pageGroupIndex }) => {
2025-10-23 11:20:16 +01:00
const bounds = toCssBounds ( currentPage , pageHeight , scale , group . bounds );
const changed = group . text !== group . originalText ;
const isActive = activeGroupId === group . id || editingGroupId === group . id ;
const isEditing = editingGroupId === group . id ;
2025-10-23 12:50:30 +01:00
const baseFontSize = group . fontMatrixSize ?? group . fontSize ?? 12 ;
const fontSizePx = Math . max ( baseFontSize * scale , 6 );
2025-11-02 23:31:04 +00:00
const effectiveFontId = resolveFontIdForIndex ( pageGroupIndex ) ?? group . fontId ;
const fontFamily = getFontFamily ( effectiveFontId , group . pageIndex );
let lineHeightPx = getLineHeightPx ( effectiveFontId , group . pageIndex , fontSizePx );
2025-11-01 10:10:49 +00:00
let lineHeightRatio = fontSizePx > 0 ? Math . max ( lineHeightPx / fontSizePx , 1.05 ) : 1.2 ;
2025-10-24 15:34:35 +01:00
const rotation = group . rotation ?? 0 ;
const hasRotation = Math . abs ( rotation ) > 0.5 ;
2025-10-23 23:36:53 +01:00
const baselineLength = group . baselineLength ?? Math . max ( group . bounds . right - group . bounds . left , 0 );
2025-11-02 23:31:04 +00:00
const geometry = getFontGeometry ( effectiveFontId , group . pageIndex );
2025-11-01 10:10:49 +00:00
const ascentPx = geometry ? Math . max ( fontSizePx * geometry . ascentRatio , fontSizePx * 0.7 ) : fontSizePx * 0.82 ;
const descentPx = geometry ? Math . max ( fontSizePx * geometry . descentRatio , fontSizePx * 0.2 ) : fontSizePx * 0.22 ;
lineHeightPx = Math . max ( lineHeightPx , ascentPx + descentPx );
if ( fontSizePx > 0 ) {
lineHeightRatio = Math . max ( lineHeightRatio , lineHeightPx / fontSizePx );
}
2025-11-10 22:55:16 +00:00
const detectedSpacingPx =
group . lineSpacing && group . lineSpacing > 0 ? group . lineSpacing * scale : undefined ;
if ( detectedSpacingPx && detectedSpacingPx > 0 ) {
lineHeightPx = Math . max ( lineHeightPx , detectedSpacingPx );
if ( fontSizePx > 0 ) {
lineHeightRatio = Math . max ( lineHeightRatio , detectedSpacingPx / fontSizePx );
}
}
const lineCount = Math . max ( group . text . split ( '\n' ). length , 1 );
const paragraphHeightPx =
lineCount > 1
? lineHeightPx + ( lineCount - 1 ) * ( detectedSpacingPx ?? lineHeightPx )
: lineHeightPx ;
2025-10-23 23:36:53 +01:00
let containerLeft = bounds . left ;
let containerTop = bounds . top ;
let containerWidth = Math . max ( bounds . width , fontSizePx );
2025-11-10 22:55:16 +00:00
let containerHeight = Math . max ( bounds . height , paragraphHeightPx );
2025-10-23 23:36:53 +01:00
let transform : string | undefined ;
let transformOrigin : React.CSSProperties [ 'transformOrigin' ];
if ( hasRotation ) {
const anchorX = group . anchor ? . x ?? group . bounds . left ;
const anchorY = group . anchor ? . y ?? group . bounds . bottom ;
containerLeft = anchorX * scale ;
2025-11-01 10:10:49 +00:00
const anchorTop = Math . max ( pageHeight - anchorY , 0 ) * scale ;
2025-10-23 23:36:53 +01:00
containerWidth = Math . max ( baselineLength * scale , MIN_BOX_SIZE );
containerHeight = Math . max ( lineHeightPx , fontSizePx * lineHeightRatio );
transformOrigin = 'left bottom' ;
// Negate rotation because Y-axis is flipped from PDF to web coordinates
2025-10-24 15:34:35 +01:00
transform = `rotate( ${ - rotation } deg)` ;
2025-11-01 10:10:49 +00:00
// Align the baseline (PDF anchor) with the bottom edge used as the
// transform origin. Without this adjustment rotated text appears shifted
// downward by roughly one line height.
containerTop = anchorTop - containerHeight ;
}
2025-11-10 22:55:16 +00:00
if (
lineCount === 1 &&
! hasRotation &&
group . baseline !== null &&
group . baseline !== undefined &&
geometry
) {
2025-11-01 10:10:49 +00:00
const cssBaselineTop = ( pageHeight - group . baseline ) * scale ;
containerTop = Math . max ( cssBaselineTop - ascentPx , 0 );
containerHeight = Math . max ( containerHeight , ascentPx + descentPx );
2025-10-23 23:36:53 +01:00
}
2025-10-23 11:20:16 +01:00
2025-10-23 23:36:53 +01:00
// Extract styling from group
const textColor = group . color || '#111827' ;
2025-11-02 23:31:04 +00:00
const fontWeight = group . fontWeight || getFontWeight ( effectiveFontId , group . pageIndex );
2025-10-23 11:20:16 +01:00
const containerStyle : React.CSSProperties = {
position : 'absolute' ,
2025-10-23 23:36:53 +01:00
left : ` ${ containerLeft } px` ,
top : ` ${ containerTop } px` ,
width : ` ${ containerWidth } px` ,
2025-11-10 22:55:16 +00:00
height : isEditing ? 'auto' : ` ${ containerHeight } px` ,
minHeight : ` ${ containerHeight } px` ,
2025-10-23 11:20:16 +01:00
display : 'flex' ,
alignItems : 'flex-start' ,
justifyContent : 'flex-start' ,
pointerEvents : 'auto' ,
cursor : 'text' ,
2025-10-23 16:38:06 +01:00
zIndex : 2_000_000 ,
2025-10-23 23:36:53 +01:00
transform ,
transformOrigin ,
2025-10-23 11:20:16 +01:00
};
if ( isEditing ) {
return (
2025-10-23 12:50:30 +01:00
< Box key = { group . id } style = { containerStyle }>
2025-10-23 11:20:16 +01:00
{ renderGroupContainer (
group . id ,
2025-11-10 18:11:57 +00:00
group . pageIndex ,
2025-10-23 11:20:16 +01:00
true ,
changed ,
< div
2025-10-23 16:38:06 +01:00
ref = {( node ) => {
if ( node ) {
editorRefs . current . set ( group . id , node );
} else {
editorRefs . current . delete ( group . id );
}
}}
2025-10-23 11:20:16 +01:00
contentEditable
suppressContentEditableWarning
data-editor-group = { group . id }
2025-11-02 23:31:04 +00:00
onFocus = {( event ) => {
const primaryFont = fontFamily . split ( ',' )[ 0 ] ? . replace ( /['"]/g , '' ). trim ();
if ( primaryFont && typeof document !== 'undefined' ) {
try {
if ( document . queryCommandSupported ? .( 'styleWithCSS' )) {
document . execCommand ( 'styleWithCSS' , false , true );
}
if ( document . queryCommandSupported ? .( 'fontName' )) {
document . execCommand ( 'fontName' , false , primaryFont );
}
} catch {
// ignore execCommand failures; inline style already enforces font
}
}
event . currentTarget . style . fontFamily = fontFamily ;
}}
2025-11-10 17:49:26 +00:00
onClick = {( event ) => {
// Allow click position to determine cursor placement
event . stopPropagation ();
}}
2025-10-23 11:20:16 +01:00
onBlur = {( event ) => {
2025-11-11 22:06:30 +00:00
syncEditorValue ( event . currentTarget , group . pageIndex , group . id , {
skipCaretRestore : true ,
});
2025-10-23 16:38:06 +01:00
caretOffsetsRef . current . delete ( group . id );
editorRefs . current . delete ( group . id );
setActiveGroupId ( null );
2025-10-23 11:20:16 +01:00
setEditingGroupId ( null );
}}
onInput = {( event ) => {
2025-11-10 22:55:16 +00:00
syncEditorValue ( event . currentTarget , group . pageIndex , group . id );
2025-10-23 11:20:16 +01:00
}}
style = {{
width : '100%' ,
2025-11-10 22:55:16 +00:00
minHeight : '100%' ,
height : 'auto' ,
2025-10-23 23:36:53 +01:00
padding : 0 ,
2025-10-23 11:20:16 +01:00
backgroundColor : 'rgba(255,255,255,0.95)' ,
2025-10-23 23:36:53 +01:00
color : textColor ,
2025-10-23 11:20:16 +01:00
fontSize : ` ${ fontSizePx } px` ,
2025-10-23 12:50:30 +01:00
fontFamily ,
2025-10-23 23:36:53 +01:00
fontWeight ,
lineHeight : lineHeightRatio ,
2025-10-23 11:20:16 +01:00
outline : 'none' ,
border : 'none' ,
display : 'block' ,
2025-11-11 22:06:30 +00:00
whiteSpace : paragraphWhiteSpace ,
wordBreak : paragraphWordBreak ,
overflowWrap : paragraphOverflowWrap ,
2025-10-23 11:20:16 +01:00
cursor : 'text' ,
2025-10-23 12:50:30 +01:00
overflow : 'visible' ,
2025-10-23 11:20:16 +01:00
}}
>
{ group . text || '\u00A0' }
</ div >,
)}
</ Box >
);
}
2025-11-02 21:00:03 +00:00
const textScale = textScales . get ( group . id ) ?? 1 ;
const shouldScale = autoScaleText && textScale < 0.98 ;
2025-10-23 11:20:16 +01:00
return (
2025-10-23 12:50:30 +01:00
< Box key = { group . id } style = { containerStyle }>
2025-10-23 11:20:16 +01:00
{ renderGroupContainer (
group . id ,
2025-11-10 18:11:57 +00:00
group . pageIndex ,
2025-10-23 11:20:16 +01:00
isActive ,
changed ,
< div
2025-11-02 21:00:03 +00:00
data-text-group = { group . id }
2025-10-23 11:20:16 +01:00
style = {{
width : '100%' ,
minHeight : '100%' ,
2025-10-23 23:36:53 +01:00
padding : 0 ,
2025-11-11 22:06:30 +00:00
whiteSpace : paragraphWhiteSpace ,
wordBreak : paragraphWordBreak ,
overflowWrap : paragraphOverflowWrap ,
2025-10-23 11:20:16 +01:00
fontSize : ` ${ fontSizePx } px` ,
2025-10-23 12:50:30 +01:00
fontFamily ,
2025-10-23 23:36:53 +01:00
fontWeight ,
lineHeight : lineHeightRatio ,
color : textColor ,
2025-10-23 11:20:16 +01:00
display : 'block' ,
cursor : 'text' ,
2025-11-10 22:55:16 +00:00
overflow : 'hidden' ,
2025-10-23 11:20:16 +01:00
}}
>
2025-11-02 21:00:03 +00:00
< span
data - text - content
style = {{
pointerEvents : 'none' ,
display : 'inline-block' ,
2025-11-11 12:09:40 +00:00
transform : shouldScale ? `scaleX( ${ textScale } )` : 'none' ,
2025-11-02 21:00:03 +00:00
transformOrigin : 'left center' ,
2025-11-11 22:06:30 +00:00
whiteSpace : paragraphWhiteSpace ,
2025-11-02 21:00:03 +00:00
}}
>
{ group . text || '\u00A0' }
</ span >
2025-10-23 11:20:16 +01:00
</ div >,
2025-11-10 18:18:21 +00:00
undefined ,
2025-11-10 17:49:26 +00:00
( event : React.MouseEvent ) => {
2025-11-10 22:55:16 +00:00
const clickX = event . clientX ;
const clickY = event . clientY ;
setActiveGroupId ( group . id );
setEditingGroupId ( group . id );
caretOffsetsRef . current . delete ( group . id );
requestAnimationFrame (() => {
const editor = document . querySelector < HTMLElement >( `[data-editor-group=" ${ group . id } "]` );
if ( ! editor ) return ;
editor . focus ();
setTimeout (() => {
if ( document . caretRangeFromPoint ) {
const range = document . caretRangeFromPoint ( clickX , clickY );
if ( range ) {
const selection = window . getSelection ();
if ( selection ) {
selection . removeAllRanges ();
selection . addRange ( range );
2025-11-10 17:58:49 +00:00
}
2025-11-10 22:55:16 +00:00
}
} else if (( document as any ). caretPositionFromPoint ) {
const pos = ( document as any ). caretPositionFromPoint ( clickX , clickY );
if ( pos ) {
const range = document . createRange ();
range . setStart ( pos . offsetNode , pos . offset );
range . collapse ( true );
const selection = window . getSelection ();
if ( selection ) {
selection . removeAllRanges ();
selection . addRange ( range );
2025-11-10 17:58:49 +00:00
}
2025-11-10 17:49:26 +00:00
}
2025-11-10 22:55:16 +00:00
}
}, 10 );
});
2025-10-23 11:20:16 +01:00
},
)}
</ Box >
);
})
)}
2025-10-23 23:36:53 +01:00
</ Box >
2025-10-23 11:20:16 +01:00
</ Box >
</ Box >
</ ScrollArea >
</ Card >
</ Stack >
)}
2025-11-12 00:28:41 +00:00
{ /* Mode Change Confirmation Modal */ }
< Modal
opened = { pendingModeChange !== null }
onClose = { handleCancelModeChange }
2025-11-12 10:16:36 +00:00
title = { t ( 'pdfTextEditor.modeChange.title' , 'Confirm Mode Change' )}
2025-11-12 00:28:41 +00:00
centered
>
< Stack gap = "md" >
< Text >
{ t (
2025-11-12 10:16:36 +00:00
'pdfTextEditor.modeChange.warning' ,
2025-11-12 00:28:41 +00:00
'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 }>
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.modeChange.cancel' , 'Cancel' )}
2025-11-12 00:28:41 +00:00
</ Button >
< Button color = "red" onClick = { handleConfirmModeChange }>
2025-11-12 10:16:36 +00:00
{ t ( 'pdfTextEditor.modeChange.confirm' , 'Reset and Change Mode' )}
2025-11-12 00:28:41 +00:00
</ Button >
</ Group >
</ Stack >
</ Modal >
2025-10-23 11:20:16 +01:00
</ Stack >
);
};
2025-11-12 10:16:36 +00:00
export default PdfTextEditorView ;