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<String>? 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<PdfView> createState() => _PdfViewState();
}

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

  int _currentPage = 1;
  int _savedPageBeforeRotation = 1;
  Uint8List? _currentPdfBytes;
  bool _isRotating = false;
  PdfViewerController? _activeController;
  int _rebuiltCounter = 0;
  final _markers = <int, List<Marker>>{};
  
  /// 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<void> _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<void> _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<void> _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

    wait for responseWait for the issue author (or some user) response

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions