Skip to content

Pdf text highlighting is delayed #609

@gokulreizend

Description

@gokulreizend

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:printing/printing.dart';

class Marker {
Marker(this.color, this.range);
final Color color;
final PdfPageTextRange range;
}

class PdfView extends StatefulWidget {
final Uint8List pdfBytes;
final String uniqueId;
final PdfViewerController? controller;
final bool? isPrintAllowed;

/// List of text strings to highlight in the PDF
final List? highlightTexts;

/// Page to navigate to (1-based)
final int? initialPage;

const PdfView({
super.key,
required this.pdfBytes,
required this.uniqueId,
this.controller,
this.isPrintAllowed,
this.highlightTexts,
this.initialPage,
});

@OverRide
State createState() => _PdfViewState();
}

class _PdfViewState extends State {
final pdfController = PdfViewerController();

int _currentPage = 1;
int _savedPageBeforeRotation = 1;
Uint8List? _currentPdfBytes;
bool _isRotating = false;
PdfViewerController? _activeController;
int _rebuiltCounter = 0;
final _markers = <int, List>{};

/// Text searcher for highlighting - uses web-compatible search
PdfTextSearcher? _textSearcher;

@OverRide
void initState() {
super.initState();
_currentPdfBytes = widget.pdfBytes;
_activeController = widget.controller ?? pdfController;

if (widget.controller != null) {
  widget.controller!.addListener(_onPdfLoaded);
} else {
  pdfController.addListener(_onPdfLoaded);
}

}

Future _rotateCurrentPage(String rotationType) async {
if (_isRotating || _currentPdfBytes == null) return;

final controller = _activeController;
if (controller == null) return;

final currentPageNumber = controller.pageNumber ?? _currentPage;
_savedPageBeforeRotation = currentPageNumber;

setState(() {
  _isRotating = true;
});

try {
  final doc = await PdfDocument.openData(_currentPdfBytes!);

  final selectedIndex =
      (_savedPageBeforeRotation - 1).clamp(0, doc.pages.length - 1);

  final List<PdfPage> newPages = [];
  for (int i = 0; i < doc.pages.length; i++) {
    if (i == selectedIndex) {
      switch (rotationType) {
        case 'cw90':
          newPages.add(doc.pages[i].rotatedCW90());
          break;
        case 'ccw90':
          newPages.add(doc.pages[i].rotatedCCW90());
          break;
        case '180':
          newPages.add(doc.pages[i].rotated180());
          break;
        default:
          newPages.add(doc.pages[i]);
      }
    } else {
      newPages.add(doc.pages[i]);
    }
  }

  doc.pages = newPages;
  final updatedBytes = await doc.encodePdf();
  doc.dispose();

  // Clean up old controller listeners
  if (_activeController == pdfController && widget.controller == null) {
    pdfController.removeListener(_onPdfLoaded);
    pdfController.removeListener(_onPageChanged);
  }

  // Create new controller for internal use only
  PdfViewerController? newController;
  if (widget.controller == null) {
    newController = PdfViewerController();
  }

  // Update state with new bytes and controller
  setState(() {
    _currentPdfBytes = Uint8List.fromList(updatedBytes);
    _rebuiltCounter++;

    if (widget.controller == null) {
      _activeController = newController;
    }
    // Keep _isRotating true until navigation completes
  });

  // Wait for the new PDF to initialize and then navigate
  await _waitForPdfAndNavigate(_savedPageBeforeRotation);
} catch (e) {
  setState(() {
    _isRotating = false;
  });

  if (mounted) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text('Failed to rotate page: ${e.toString()}'),
        duration: const Duration(seconds: 3),
      ),
    );
  }
}

}

Future _waitForPdfAndNavigate(int targetPage) async {
// Wait for next frame to ensure widget is built
await Future.delayed(const Duration(milliseconds: 50));

if (!mounted) {
  return;
}

final controllerToUse = _activeController;
if (controllerToUse == null) {
  if (mounted) {
    setState(() {
      _isRotating = false;
    });
  }
  return;
}

// Wait for PDF to be ready with timeout
int attempts = 0;
const maxAttempts = 100; // 10 seconds max

while (attempts < maxAttempts && mounted) {
  try {
    final pageCount = controllerToUse.pageCount;

    if (pageCount > 0) {
      break;
    }
  } catch (e) {
    // Controller not ready yet, continue waiting
  }

  await Future.delayed(const Duration(milliseconds: 100));
  attempts++;
}

if (attempts >= maxAttempts) {
  if (mounted) {
    setState(() {
      _isRotating = false;
    });
  }
  return;
}

if (!mounted) {
  return;
}

// Additional delay to ensure rendering is complete
await Future.delayed(const Duration(milliseconds: 300));

if (!mounted) {
  return;
}

try {
  final pageCount = controllerToUse.pageCount;
  final clampedPage = targetPage.clamp(1, pageCount);

  // Don't await goToPage - just fire it and continue
  // This prevents hanging if goToPage has issues
  controllerToUse.goToPage(pageNumber: clampedPage);

  // Wait a bit for navigation to take effect
  await Future.delayed(const Duration(milliseconds: 500));

  if (mounted) {
    setState(() {
      _currentPage = clampedPage;
      _isRotating = false; // Restore button state HERE
    });

    // Set up page change listener
    controllerToUse.removeListener(_onPageChanged);
    controllerToUse.addListener(_onPageChanged);

    // Verify after a delay
    Future.delayed(const Duration(milliseconds: 500), () {
      if (mounted) {
        final actualPage = controllerToUse.pageNumber ?? _currentPage;

        if (actualPage != clampedPage) {
          controllerToUse.goToPage(pageNumber: clampedPage);
        } else {}
      }
    });
  }
} catch (e) {
  if (mounted) {
    // Fallback and restore button state
    try {
      final fallbackPage = controllerToUse.pageNumber ?? 1;
      setState(() {
        _currentPage = fallbackPage;
        _isRotating = false; // Restore button state HERE too
      });
    } catch (_) {
      setState(() {
        _currentPage = 1;
        _isRotating = false; // And HERE
      });
    }
    controllerToUse.removeListener(_onPageChanged);
    controllerToUse.addListener(_onPageChanged);
  }
}

}

Timer? _scrollTimer;

void _handleKey(KeyEvent event) {
final controller = _activeController;
if (controller == null) return;

if (event is KeyDownEvent) {
  _scrollTimer?.cancel();

  if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
    _scrollTimer = Timer.periodic(const Duration(milliseconds: 800), (_) {
      try {
        final pageCount = controller.pageCount;
        if (_currentPage < pageCount) {
          _currentPage++;
          controller.goToPage(pageNumber: _currentPage);
        } else {
          _scrollTimer?.cancel();
        }
      } catch (e) {
        _scrollTimer?.cancel();
      }
    });
  } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
    _scrollTimer = Timer.periodic(const Duration(milliseconds: 800), (_) {
      try {
        if (_currentPage > 1) {
          _currentPage--;
          controller.goToPage(pageNumber: _currentPage);
        } else {
          _scrollTimer?.cancel();
        }
      } catch (e) {
        _scrollTimer?.cancel();
      }
    });
  }
} else if (event is KeyUpEvent) {
  _scrollTimer?.cancel();
}

}

void _onPdfLoaded() async {
if (!mounted) return;

final controller = widget.controller ?? pdfController;

try {
  if (controller.isReady && controller.pageCount > 0) {
    if (!mounted) return;

    controller.removeListener(_onPdfLoaded);
    controller.addListener(_onPageChanged);
  }
} catch (e) {
  // Controller not ready yet
}

}

/// Search only the target page for highlight texts - FAST single page search
Future _searchSinglePage(PdfDocument document, int targetPage, PdfViewerController controller) async {
final texts = widget.highlightTexts;
if (texts == null || texts.isEmpty) return;

final validTexts = texts.where((t) => t.trim().isNotEmpty).toList();
if (validTexts.isEmpty) return;

final pattern = RegExp(
  validTexts.map((t) => RegExp.escape(t.trim())).join('|'),
  caseSensitive: false,
);

try {
  // Load text ONLY for target page
  final page = document.pages[targetPage - 1];
  final pageText = await page.loadStructuredText();
  
  if (pageText.fullText.isEmpty) {
    // Fallback to PdfTextSearcher if direct extraction fails (web)
    _startFullSearch(controller);
    return;
  }
  
  // Fast single-page search
  final matches = await pageText.allMatches(pattern).toList();
  if (matches.isEmpty || !mounted) return;
  
  // Store as markers
  _markers[targetPage] = matches
      .map((m) => Marker(Colors.yellow, m))
      .toList();
  
  setState(() {});
  controller.invalidate();
} catch (e) {
  // Fallback to full search
  _startFullSearch(controller);
}

}

/// Fallback: Full document search using PdfTextSearcher (slower but works on web)
void _startFullSearch(PdfViewerController controller) {
final texts = widget.highlightTexts;
if (texts == null || texts.isEmpty) return;

final validTexts = texts.where((t) => t.trim().isNotEmpty).toList();
if (validTexts.isEmpty) return;

final pattern = RegExp(
  validTexts.map((t) => RegExp.escape(t.trim())).join('|'),
  caseSensitive: false,
);

_textSearcher?.dispose();
_textSearcher = PdfTextSearcher(controller);

// Invalidate on each match found
_textSearcher!.addListener(() {
  if (mounted) controller.invalidate();
});

_textSearcher!.startTextSearch(pattern, goToFirstMatch: false);

}

void _onPageChanged() {
final controller = _activeController;
if (controller == null) return;

final currentPage = controller.pageNumber ?? 1;
if (currentPage != _currentPage) {
  setState(() {
    _currentPage = currentPage;
  });
}

}

@OverRide
void dispose() {
if (widget.controller != null) {
widget.controller!.removeListener(_onPdfLoaded);
widget.controller!.removeListener(_onPageChanged);
} else {
pdfController.removeListener(_onPdfLoaded);
pdfController.removeListener(_onPageChanged);
}

if (_activeController != null &&
    _activeController != widget.controller &&
    _activeController != pdfController) {
  _activeController!.removeListener(_onPageChanged);
}

_scrollTimer?.cancel();
_textSearcher?.dispose();
super.dispose();

}

@OverRide
Widget build(BuildContext context) {
final controller =
_activeController ?? (widget.controller ?? pdfController);

return KeyboardListener(
  focusNode: FocusNode()..requestFocus(),
  autofocus: true,
  onKeyEvent: _handleKey,
  child: Scaffold(
      appBar: AppBar(
        automaticallyImplyLeading: false,
        actions: [
          if (widget.isPrintAllowed != null)
            IconButton(
              icon: const Icon(Icons.print),
              onPressed: () async {
                await Printing.layoutPdf(
                  onLayout: (_) async {
                    return _currentPdfBytes ?? widget.pdfBytes;
                  },
                );
              },
            ),
          IconButton(
            icon: _isRotating
                ? const SizedBox(
                    width: 16,
                    height: 16,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : const Icon(Icons.rotate_left),
            onPressed:
                _isRotating ? null : () => _rotateCurrentPage('ccw90'),
            tooltip: _isRotating
                ? 'Rotating...'
                : 'Rotate page $_currentPage left (90°)',
          ),
          IconButton(
            icon: _isRotating
                ? const SizedBox(
                    width: 16,
                    height: 16,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : const Icon(Icons.rotate_right),
            onPressed:
                _isRotating ? null : () => _rotateCurrentPage('cw90'),
            tooltip: _isRotating
                ? 'Rotating...'
                : 'Rotate page $_currentPage right (90°)',
          ),
          const VerticalDivider(),
          IconButton(
            icon: const Icon(Icons.zoom_in),
            onPressed: controller.zoomUp,
          ),
          IconButton(
            icon: const Icon(Icons.zoom_out),
            onPressed: controller.zoomDown,
          ),
          IconButton(
            icon: const Icon(Icons.first_page),
            onPressed: () => controller.goToPage(pageNumber: 1),
          ),
          IconButton(
            icon: const Icon(Icons.last_page),
            onPressed: () {
              try {
                final pageCount = controller.pageCount;
                if (pageCount > 0) {
                  controller.goToPage(pageNumber: pageCount);
                }
              } catch (e) {
                // Silent fail
              }
            },
          ),
        ],
      ),
      body: PdfViewer.data(
        key: ValueKey('pdf_$_rebuiltCounter'),
        _currentPdfBytes ?? widget.pdfBytes,
        sourceName: '${widget.uniqueId}_$_rebuiltCounter',
        controller: controller,
        params: PdfViewerParams(
            maxScale: 8,
            viewerOverlayBuilder: (context, size, handleLinkTap) => [
                  GestureDetector(
                    behavior: HitTestBehavior.translucent,
                    onTapUp: (details) {
                      handleLinkTap(details.localPosition);
                    },
                    onDoubleTap: () {
                      controller.zoomUp(loop: true);
                    },
                    child: IgnorePointer(
                      child:
                          SizedBox(width: size.width, height: size.height),
                    ),
                  ),
                  PdfViewerScrollThumb(
                    controller: controller,
                    orientation: ScrollbarOrientation.right,
                    thumbSize: const Size(40, 25),
                    thumbBuilder:
                        (context, thumbSize, pageNumber, controller) {
                      final currentPageFromController = pageNumber ?? 1;

                      if (currentPageFromController != _currentPage) {
                        WidgetsBinding.instance.addPostFrameCallback((_) {
                          if (mounted) {
                            setState(() {
                              _currentPage = currentPageFromController;
                            });
                          }
                        });
                      }

                      return Container(
                        color: Colors.black,
                        child: Center(
                          child: Text(
                            currentPageFromController.toString(),
                            style: const TextStyle(color: Colors.white),
                          ),
                        ),
                      );
                    },
                  ),
                  PdfViewerScrollThumb(
                    controller: controller,
                    orientation: ScrollbarOrientation.bottom,
                    thumbSize: const Size(80, 30),
                    thumbBuilder:
                        (context, thumbSize, pageNumber, controller) {
                      final currentPageFromController = pageNumber ?? 1;
                      return Container(
                        color: Colors.black,
                        child: Center(
                          child: Text(
                            currentPageFromController.toString(),
                            style: const TextStyle(color: Colors.white),
                          ),
                        ),
                      );
                    },
                  ),
                ],
            errorBannerBuilder: (context, error, stackTrace, documentRef) {
              return Container(
                width: double.infinity,
                padding: const EdgeInsets.all(16),
                color: Colors.red.shade100,
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Icon(
                      Icons.error_outline,
                      color: Colors.red.shade700,
                      size: 48,
                    ),
                    const SizedBox(height: 8),
                    Text(
                      'Failed to load PDF',
                      style: TextStyle(
                        color: Colors.red.shade700,
                        fontSize: 18,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    const SizedBox(height: 4),
                    Text(
                      'Error: ${error.toString()}',
                      style: TextStyle(
                        color: Colors.red.shade600,
                        fontSize: 14,
                      ),
                      textAlign: TextAlign.center,
                    ),
                    const SizedBox(height: 12),
                    ElevatedButton.icon(
                      onPressed: () {
                        setState(() {});
                      },
                      icon: const Icon(Icons.refresh),
                      label: const Text('Retry'),
                      style: ElevatedButton.styleFrom(
                        backgroundColor: Colors.red.shade700,
                        foregroundColor: Colors.white,
                      ),
                    ),
                  ],
                ),
              );
            },
            loadingBannerBuilder: (context, bytesDownloaded, totalBytes) =>
                Center(
                  child: CircularProgressIndicator(
                    value: totalBytes != null
                        ? bytesDownloaded / totalBytes
                        : null,
                    backgroundColor: Colors.grey,
                  ),
                ),
            pagePaintCallbacks: [
              _paintMarkers,
              _paintTextSearchHighlights,
            ],
            onDocumentChanged: (document) async {
              if (document == null) {
                _markers.clear();
                _textSearcher?.dispose();
                _textSearcher = null;
              }
            },
            onViewerReady: (document, controller) async {
              // Navigate to initial page FIRST
              final targetPage = (widget.initialPage ?? 1).clamp(1, document.pages.length);
              if (targetPage > 1) {
                controller.goToPage(pageNumber: targetPage);
              }
              
              // Search ONLY the target page immediately
              if (widget.highlightTexts != null && widget.highlightTexts!.isNotEmpty) {
                if (mounted) {
                  _searchSinglePage(document, targetPage, controller);
                }
              }
              
              controller.requestFocus();
            }),
      )),
);

}

/// Paint text search highlights using the built-in callback from PdfTextSearcher
void _paintTextSearchHighlights(Canvas canvas, Rect pageRect, PdfPage page) {
if (_textSearcher == null) return;

// Use the built-in paint callback which handles coordinates correctly
_textSearcher!.pageTextMatchPaintCallback(canvas, pageRect, page);

}

void _paintMarkers(Canvas canvas, Rect pageRect, PdfPage page) {
final markers = _markers[page.pageNumber];
if (markers == null) return;

for (final marker in markers) {
  final paint = Paint()
    ..color = marker.color.withAlpha(100)
    ..style = PaintingStyle.fill;

  final rect = marker.range.bounds.toRectInDocument(page: page, pageRect: pageRect);
  canvas.drawRect(rect, paint);
}

}
}

this is my code and when i try to highlighttext , it dont appears immediately

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions