From 4013973110c6c85cdd17709aedc9de17b00968d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Thu, 4 Dec 2025 17:00:08 +0100 Subject: [PATCH] Add text selection mixin This mixin leverages HTML callbacks that can be set by WebView editor to send text selection event to the composer so the composer will receive text selection event necessary to display buttons related to text selection. --- core/lib/utils/html/html_utils.dart | 74 ++++++++++++++++++ .../widgets/mixins/text_selection_mixin.dart | 77 +++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 lib/features/composer/presentation/widgets/mixins/text_selection_mixin.dart diff --git a/core/lib/utils/html/html_utils.dart b/core/lib/utils/html/html_utils.dart index 8c83d4503..656b2a994 100644 --- a/core/lib/utils/html/html_utils.dart +++ b/core/lib/utils/html/html_utils.dart @@ -48,6 +48,80 @@ class HtmlUtils { editor.parentNode.replaceChild(newEditor, editor);''', name: 'unregisterDropListener'); + static const registerSelectionChangeListener = ( + script: ''' + let lastSelectedText = ''; + + document.addEventListener('selectionchange', function() { + const selection = window.getSelection(); + const selectedText = selection ? selection.toString().trim() : ''; + + if (selectedText === lastSelectedText) { + return; + } + lastSelectedText = selectedText; + + if (selectedText.length > 0 && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const rects = range.getClientRects(); + + if (rects.length > 0) { + const rect = rects[rects.length - 1]; + + // When iframe + if (window.parent) { + window.parent.postMessage( + JSON.stringify({ + name: 'onSelectionChange', + hasSelection: true, + selectedText: selectedText, + coordinates: { + x: rect.right, + y: rect.bottom, + width: rect.width, + height: rect.height + } + }), + "*" + ) + } + + // When WebView + if (window.flutter_inappwebview) { + window.flutter_inappwebview.callHandler('onSelectionChange', { + hasSelection: true, + selectedText: selectedText, + coordinates: { + x: rect.right, + y: rect.bottom, + width: rect.width, + height: rect.height + } + }); + } + } + } else { + // When iframe + if (window.parent) { + window.parent.postMessage( + JSON.stringify({ + name: 'onSelectionChange', + hasSelection: false + }), + "*" + ) + } + + // When WebView + if (window.flutter_inappwebview) { + window.flutter_inappwebview.callHandler('onSelectionChange', { + hasSelection: false + }); + } + } + });''', + name: 'onSelectionChange'); + static recalculateEditorHeight({double? maxHeight}) => ( script: ''' const editable = document.querySelector('.note-editable'); diff --git a/lib/features/composer/presentation/widgets/mixins/text_selection_mixin.dart b/lib/features/composer/presentation/widgets/mixins/text_selection_mixin.dart new file mode 100644 index 000000000..da97d1d77 --- /dev/null +++ b/lib/features/composer/presentation/widgets/mixins/text_selection_mixin.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +class TextSelectionData { + final bool hasSelection; + final String? selectedText; + final TextSelectionCoordinates? coordinates; + + const TextSelectionData({ + required this.hasSelection, + this.selectedText, + this.coordinates, + }); + + factory TextSelectionData.empty() { + return const TextSelectionData(hasSelection: false); + } + + factory TextSelectionData.fromMap(Map data) { + try { + final hasSelection = data['hasSelection'] as bool? ?? false; + + if (!hasSelection) { + return TextSelectionData.empty(); + } + + final selectedText = data['selectedText'] as String?; + final coordinatesData = data['coordinates']; + + if (coordinatesData != null && selectedText != null) { + final coordinates = TextSelectionCoordinates.fromMap(coordinatesData); + return TextSelectionData( + hasSelection: true, + selectedText: selectedText, + coordinates: coordinates, + ); + } + + return TextSelectionData.empty(); + } catch (e) { + return TextSelectionData.empty(); + } + } +} + +class TextSelectionCoordinates { + final double x; + final double y; + final double width; + final double height; + + const TextSelectionCoordinates({ + required this.x, + required this.y, + required this.width, + required this.height, + }); + + factory TextSelectionCoordinates.fromMap(Map data) { + return TextSelectionCoordinates( + x: (data['x'] as num?)?.toDouble() ?? 0.0, + y: (data['y'] as num?)?.toDouble() ?? 0.0, + width: (data['width'] as num?)?.toDouble() ?? 0.0, + height: (data['height'] as num?)?.toDouble() ?? 0.0, + ); + } + + Offset get position => Offset(x, y); +} + +mixin TextSelectionMixin on State { + void Function(TextSelectionData?)? get onSelectionChanged => null; + + void handleSelectionChange(Map data) { + final selectionData = TextSelectionData.fromMap(data); + onSelectionChanged?.call(selectionData); + } +}