最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

javascript - How can I restrict text selection to the current page in a Flutter WebView EPUB reader (paginated mode)? - Stack Ov

programmeradmin1浏览0评论

I'm developing an EPUB reader in Flutter as a learning exercise. I’m using the locally downloaded flutter_epub_viewer package (version 1.2.0) with Flutter SDK ^3.6.0. My goal is to use the default paginated mode for page transitions, but I’ve encountered an issue with text selection. Problem: When the EPUB content is rendered inside the WebView, if the user selects text and the mobile default text selection handles (the bubble-like controls) reach the edge of a page, the selection unexpectedly extends into the adjacent page. This causes layout issues and an undesirable selection behavior. This is my epub_reader_screen.dart

    import 'package:flutter/material.dart';
import 'package:flutter_epub_viewer/flutter_epub_viewer.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'dart:async';
import '../controllers/home_controller.dart';
import '../screens/epub_searching_screen.dart';
import '../database/highlight_db.dart';
import '../screens/highlight_viewer_screen.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../widgets/chapter_drawer.dart';

class EpubReaderScreen extends StatefulWidget {
  final String novelId;
  final String title;
  final String cover;

  const EpubReaderScreen({
    required this.novelId,
    required this.title,
    required this.cover,
    super.key,
  });

  @override
  _EpubReaderScreenState createState() => _EpubReaderScreenState();
}

class _EpubReaderScreenState extends State<EpubReaderScreen> {
  final EpubController _epubController = EpubController();
  bool _isLoading = true;
  String? _epubFilePath;
  // Using page-based variables instead of percentage-based progress
  int _totalPages = 0;
  int _currentPage = 1;
  String? _lastSavedCfi;
  bool _isAppBarVisible = false; // Manage app bar and bottom bar visibility
  final HomeController _homeController = HomeController();
  late Future<void> _loadEpubFuture;
  double? _lastSavedProgress;
  Timer? _sliderDebounce;
  final HighlightDB _highlightDB = HighlightDB();
  Color highlightColor = Color(0xFFFFFF00);
  var textSelectionCfi = '';
  String textSelection = '';
  double highlightopacity = 0.5;
  bool _isTextSelectionActive = false;
  double _progress = 0.0;
  double _currentFontSize = 16.0;
  double _currentLineHeight = 30.0;

  @override
  void initState() {
    super.initState();
    _loadEpubFuture = _loadEpub();
  }

  Future<void> _loadEpub() async {
    try {
      final directory = await getApplicationDocumentsDirectory();
      final filePath = '${directory.path}/epub/${widget.title}.epub';
      final file = File(filePath);

      if (!file.existsSync()) {
        _epubFilePath = null;
        return;
      }

      // Load saved CFI
      _lastSavedCfi = await _homeController.loadLastReadCfi(widget.novelId);
      _epubFilePath = filePath;
    } catch (e) {
      _epubFilePath = null;
    }
  }

  Future<void> _addHighlight(String cfi, Color color, double opacity, String selectedText) async {
    try {
      await _highlightDB.saveHighlight(widget.novelId, cfi, color.value, opacity, selectedText);
      _epubController.addHighlight(cfi: cfi, color: color, opacity: opacity);
    } catch (e) {
      // Handle error silently
    }
  }

  Future<void> _loadHighlights() async {
    try {
      List<Map<String, dynamic>> highlights = await _highlightDB.loadHighlights(widget.novelId);
      for (var highlight in highlights) {
        String cfi = highlight['cfi'];
        _epubController.addHighlight(cfi: cfi, color: highlightColor, opacity: 0.5);
      }
    } catch (e) {
      // Handle error silently
    }
  }

  Future<void> _saveCfiProgress() async {
    try {
      EpubLocation? location = await _epubController.getCurrentLocation();
      String? cfi = location?.startCfi;
      if (cfi != null && cfi != _lastSavedCfi) {
        _lastSavedCfi = cfi;
        await _homeController.saveLastReadCfi(widget.novelId, cfi);
      }
    } catch (e) {
      // Handle error silently
    }
  }

  Future<void> _jumpToSavedCfi() async {
    try {
      String? lastReadCfi = await _homeController.loadLastReadCfi(widget.novelId);
      if (lastReadCfi != null && lastReadCfi.isNotEmpty) {
        await _epubController.display(cfi: lastReadCfi);
      }
      EpubLocation? location = await _epubController.getCurrentLocation();
      setState(() {
        // Update page number based on progress
      });
    } catch (e) {
      // Handle error silently
    }
  }

  Future<void> _toggleAppBar() async {
    setState(() {
      _isAppBarVisible = !_isAppBarVisible;
    });
  }
  
  void _onSliderChanged(double value) {
    setState(() => _progress = value);
    _sliderDebounce?.cancel();
    _sliderDebounce = Timer(Duration(milliseconds: 300), () {
      _epubController.toProgressPercentage(value / 100);
    });
  }

  Future<void> _updatePageInfo(EpubLocation location) async {
    setState(() {
      _progress = location.progress * 100;
      _currentPage = (_progress / 100 * _totalPages).ceil();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      drawer: ChapterDrawer(controller: _epubController),
      backgroundColor: Colors.grey[200],
      body: FutureBuilder<void>(
        future: _loadEpubFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return Center(child: CircularProgressIndicator());
          } else if (_epubFilePath == null) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text("EPUB file not found.", style: TextStyle(fontSize: 16)),
                  SizedBox(height: 10),
                  ElevatedButton(
                    onPressed: () {
                      setState(() {
                        _loadEpubFuture = _loadEpub();
                      });
                    },
                    child: Text("Retry"),
                  ),
                ],
              ),
            );
          } else {
            return Stack(
              children: [
                Positioned.fill(
                  child: EpubViewer(
                    epubSource: EpubSource.fromFile(File(_epubFilePath!)),
                    epubController: _epubController,
                    displaySettings: EpubDisplaySettings(
                      flow: EpubFlow.paginated,
                      snap: true,
                      theme: EpubTheme.light(),
                      spread: EpubSpread.auto,
                      allowScriptedContent: true,
                    ),
                    selectionContextMenu: ContextMenu(
                      settings: ContextMenuSettings(hideDefaultSystemContextMenuItems: false),
                      menuItems: [
                        ContextMenuItem(
                          title: "highlight",
                          id: 0,
                          action: () async {
                            _addHighlight(textSelectionCfi, highlightColor, highlightopacity, textSelection);
                          },
                        ),
                      ],
                    ),
                    initialCfi: _lastSavedCfi,
                    onTextSelected: (epubTextSelection) {
                      textSelectionCfi = epubTextSelection.selectionCfi;
                      textSelection = epubTextSelection.selectedText;
                    },
                    onEpubLoaded: () async {
                      _isTextSelectionActive = true;
                      await _loadHighlights();
                    },
                    onRelocated: (relocation) async {
                      double? newProgress = relocation.progress;
                      _lastSavedProgress = newProgress;
                      setState(() {
                        _progress = newProgress * 100;
                      });
                      await _saveCfiProgress();
                      await _updatePageInfo(relocation);
                    },
                  ),
                ),
                Positioned(
                  top: _isAppBarVisible ? kToolbarHeight : 0,
                  left: 0,
                  right: 0,
                  height: 10,
                  child: AbsorbPointer(
                    absorbing: true,
                    child: Container(color: Colors.transparent),
                  ),
                ),
                Positioned(
                  bottom: 10,
                  left: 0,
                  right: 0,
                  height: 10,
                  child: AbsorbPointer(
                    absorbing: true,
                    child: Container(color: Colors.transparent),
                  ),
                ),
                Positioned(
                  top: _isAppBarVisible ? kToolbarHeight : 0,
                  bottom: 10,
                  left: 0,
                  width: 10,
                  child: AbsorbPointer(
                    absorbing: true,
                    child: Container(color: Colors.transparent),
                  ),
                ),
                Positioned(
                  top: _isAppBarVisible ? kToolbarHeight : 0,
                  bottom: 10,
                  right: 0,
                  width: 10,
                  child: AbsorbPointer(
                    absorbing: true,
                    child: Container(color: Colors.transparent),
                  ),
                ),
                Align(
                  alignment: Alignment.center,
                  child: GestureDetector(
                    onTap: _toggleAppBar,
                    child: Container(width: 140, height: 175, color: Colors.transparent),
                  ),
                ),
                if (_isAppBarVisible) ...[
                  Positioned(
                    top: 0,
                    left: 0,
                    right: 0,
                    child: AppBar(
                      title: Text(widget.title),
                      leading: IconButton(
                        icon: Icon(Icons.arrow_back),
                        onPressed: () => Navigator.pop(context),
                      ),
                      actions: [
                        IconButton(
                          icon: SvgPicture.asset(
                            'assets/icons/search_icon.svg',
                            width: 24,
                            height: 24,
                            colorFilter: ColorFilter.mode(Colors.blue, BlendMode.srcIn),
                          ),
                          onPressed: () {
                            showModalBottomSheet(
                              context: context,
                              isScrollControlled: true,
                              builder: (context) => EpubSearchingScreen(epubController: _epubController),
                            );
                          },
                        ),
                        IconButton(
                          icon: Icon(Icons.highlight),
                          onPressed: () async {
                            await Navigator.push(
                              context,
                              MaterialPageRoute(
                                builder: (context) => HighlightViewerScreen(
                                  novelId: widget.novelId,
                                  onHighlightSelected: (String cfi) async {
                                    await _epubController.display(cfi: cfi);
                                  },
                                  onHighlightDeleted: (String cfi) async {
                                    _epubController.removeHighlight(cfi: cfi);
                                  },
                                ),
                              ),
                            );
                          },
                        ),
                        IconButton(
                          icon: Icon(Icons.text_fields),
                          onPressed: _showFontPicker,
                        ),
                      ],
                    ),
                  ),
                  Positioned(
                    bottom: 0,
                    left: 0,
                    right: 0,
                    child: _buildBottomBar(),
                  ),
                ],
              ],
            );
          }
        },
      ),
    );
  }

  Widget _buildBottomBar() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      color: Colors.grey[200],
      child: Row(
        children: [
          Builder(
            builder: (context) {
              return IconButton(
                icon: const Icon(Icons.menu),
                onPressed: () {
                  Scaffold.of(context).openDrawer();
                },
              );
            },
          ),
          const SizedBox(width: 8),
          Expanded(
            child: Slider(
              value: _progress,
              min: 0,
              max: 100,
              onChanged: _onSliderChanged,
            ),
          ),
          Text(
            "${_progress.toStringAsFixed(1)}%",
            style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
          ),
        ],
      ),
    );
  }

  void _showFontPicker() {
    showModalBottomSheet(
      context: context,
      builder: (context) {
        return StatefulBuilder(
          builder: (BuildContext context, StateSetter modalSetState) {
            return Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                ListTile(
                  title: const Text("Font Size"),
                  trailing: DropdownButton<double>(
                    value: _currentFontSize,
                    items: [12.0, 14.0, 16.0, 18.0, 20.0, 22.0]
                        .map((size) => DropdownMenuItem(
                              value: size,
                              child: Text(size.toString()),
                            ))
                        .toList(),
                    onChanged: (value) {
                      modalSetState(() {});
                      setState(() {
                        _currentFontSize = value!;
                        _epubController.setFontSize(fontSize: _currentFontSize);
                      });
                    },
                  ),
                ),
                ListTile(
                  title: const Text("Line Height"),
                  trailing: DropdownButton<double>(
                    value: _currentLineHeight,
                    items: [10.0, 20.0, 30.0, 40.0, 100.0]
                        .map((value) => DropdownMenuItem<double>(
                              value: value,
                              child: Text("$value"),
                            ))
                        .toList(),
                    onChanged: (value) {
                      modalSetState(() {});
                      setState(() {
                        _currentLineHeight = value!;
                        _epubController.setLineHeight(lineHeight: "${_currentLineHeight}px");
                      });
                    },
                  ),
                ),
              ],
            );
          },
        );
      },
    );
  }
}

1.CSS Multi-Column Layout: I injected CSS into the swipe.html file’s <style> section

`body {
  display: flex;
  -webkit-align-items: center;
  -webkit-justify-content: center;
}

#viewer {
  width: 100%;
  height: 100%;
  /\* width: 400px;
  height: 580px; \*/
  /\* box-shadow: 0 0 4px #ccc; \*/
  /\* padding: 10px 10px 0px 10px; \*/
  margin: 5px 5px;
  background: white;
  padding: 0px 0px;
  overflow: hidden;
  column-width: 400px;
  column-gap: 20px;
}


@media only screen
  and (min-device-width : 320px)
  and (max-device-width : 667px) {
    #viewer {
      height: 100vh
    }
    #viewer iframe {
      
    }
    .arrow {
      position: inherit;
      display: none;
    }
}

`

This approach visually divides the content into multiple "pages." However, it only affects the visual layout; the browser still treats the content as a continuous flow, so the selection extends into adjacent columns pages. 2.DOM Splitting via epub.js Hook: I attempted to split the content into separate DOM containers by using epub.js’s hook. `rendition.hooks.content.register((contents) => {

    contents.document.body.style.webkitColumnWidth = 'auto';
    contents.document.body.style.columnWidth = 'auto';

   
    var style = contents.document.createElement('style');
    style.type = "text/css";
    style.innerHTML = `
    
      .page-container {
         width: 100vw;
         height: 100vh;
         overflow: hidden;
         display: inline-block;
         vertical-align: top;
         box-sizing: border-box;
         margin: 0;
         padding: 10px;
         white-space: normal; 
      }
      
      .page-container * {
         white-space: normal !important;
         word-wrap: break-word;
         overflow-wrap: break-word;
         word-break: break-all;
      }
     
      #pages-wrapper {
         width: 100vw;
         overflow-x: hidden;
         font-size: inherit;
         white-space: nowrap;
      }
    `;
    contents.document.head.appendChild(style);


    var viewportHeight = window.innerHeight;


    var pagesWrapper = contents.document.createElement('div');
    pagesWrapper.id = 'pages-wrapper';


    var bodyChildren = Array.from(contents.document.body.childNodes);


    contents.document.body.innerHTML = '';

 
    var currentPage = contents.document.createElement('div');
    currentPage.className = 'page-container';
    pagesWrapper.appendChild(currentPage);


    bodyChildren.forEach(function(child) {
      currentPage.appendChild(child);

    
      if (currentPage.scrollHeight > viewportHeight) {`your text`
        
          currentPage.removeChild(child);

          
          currentPage = contents.document.createElement('div');
          currentPage.className = 'page-container';
          currentPage.appendChild(child);
          pagesWrapper.appendChild(currentPage);
      }
    });


    contents.document.body.appendChild(pagesWrapper);

    if (useCustomSwipe) {
      const el = contents.document.documentElement;
      if (el) {
        detectSwipe(el, function(el, direction){
          if(direction === 'l'){
            rendition.next();
          }
          if(direction === 'r'){
            rendition.prev();
          }
        });
      }
    }
  });`

However, this method does not feel as natural as the default paginated mode and makes the reading experience uncomfortable. Even with this approach, when the user selects text on mobile, the selection handles still extend beyond the boundaries of the current page into the adjacent page.

My Question:

1.How can I confine text selection to the current page in a Flutter WebView-based EPUB reader while still using the default paginated mode? 2.Is there a way to override or control the default text selection handles' behavior (or implement custom text selection handles) so that the selection does not span adjacent pages? 3.Are there any recommended approaches or open source libraries that demonstrate a similar solution for handling text selection boundaries in a paginated view?

Additional Information:

Development Environment: Flutter SDK: ^3.6.0 Locally downloaded flutter_epub_viewer version: 1.2.0 Target platforms: Android and iOS Reproduction: The EPUB content is rendered in paginated mode within a WebView. When text selection handles (default mobile controls) reach the edge of a page, the selection extends into the adjacent page, causing layout and usability issues. Goal: I want the text selection to remain confined to the current page, preventing it from extending into adjacent pages, while keeping the natural feel of the default paginated mode.

与本文相关的文章

发布评论

评论列表(0)

  1. 暂无评论