// "Therefore those skilled at the unorthodox // are infinite as heaven and earth, // inexhaustible as the great rivers. // When they come to an end, // they begin again, // like the days and months; // they die and are reborn, // like the four seasons." // // - Sun Tsu, // "The Art of War" using System; using TheArtOfDev.HtmlRenderer.Adapters; using TheArtOfDev.HtmlRenderer.Adapters.Entities; using TheArtOfDev.HtmlRenderer.Core.Dom; using TheArtOfDev.HtmlRenderer.Core.Entities; using TheArtOfDev.HtmlRenderer.Core.Utils; namespace TheArtOfDev.HtmlRenderer.Core.Handlers { /// /// Handler for text selection in the html. /// internal sealed class SelectionHandler : IDisposable { #region Fields and Consts /// /// the root of the handled html tree /// private readonly CssBox _root; /// /// handler for showing context menu on right click /// private readonly ContextMenuHandler _contextMenuHandler; /// /// the mouse location when selection started used to ignore small selections /// private RPoint _selectionStartPoint; /// /// the starting word of html selection
/// where the user started the selection, if the selection is backwards then it will be the last selected word. ///
private CssRect _selectionStart; /// /// the ending word of html selection
/// where the user ended the selection, if the selection is backwards then it will be the first selected word. ///
private CssRect _selectionEnd; /// /// the selection start index if the first selected word is partially selected (-1 if not selected or fully selected) /// private int _selectionStartIndex = -1; /// /// the selection end index if the last selected word is partially selected (-1 if not selected or fully selected) /// private int _selectionEndIndex = -1; /// /// the selection start offset if the first selected word is partially selected (-1 if not selected or fully selected) /// private double _selectionStartOffset = -1; /// /// the selection end offset if the last selected word is partially selected (-1 if not selected or fully selected) /// private double _selectionEndOffset = -1; /// /// is the selection goes backward in the html, the starting word comes after the ending word in DFS traversing.
///
private bool _backwardSelection; /// /// used to ignore mouse up after selection /// private bool _inSelection; /// /// current selection process is after double click (full word selection) /// private bool _isDoubleClickSelect; /// /// used to know if selection is in the control or started outside so it needs to be ignored /// private bool _mouseDownInControl; /// /// used to handle drag and drop /// private bool _mouseDownOnSelectedWord; /// /// is the cursor on the control has been changed by the selection handler /// private bool _cursorChanged; /// /// used to know if double click selection is requested /// private DateTime _lastMouseDown; /// /// used to know if drag and drop was already started not to execute the same operation over /// private object _dragDropData; #endregion /// /// Init. /// /// the root of the handled html tree public SelectionHandler(CssBox root) { ArgChecker.AssertArgNotNull(root, "root"); _root = root; _contextMenuHandler = new ContextMenuHandler(this, root.HtmlContainer); } /// /// Select all the words in the html. /// /// the control hosting the html to invalidate public void SelectAll(RControl control) { if (_root.HtmlContainer.IsSelectionEnabled) { ClearSelection(); SelectAllWords(_root); control.Invalidate(); } } /// /// Select the word at the given location if found. /// /// the control hosting the html to invalidate /// the location to select word at public void SelectWord(RControl control, RPoint loc) { if (_root.HtmlContainer.IsSelectionEnabled) { var word = DomUtils.GetCssBoxWord(_root, loc); if (word != null) { word.Selection = this; _selectionStartPoint = loc; _selectionStart = _selectionEnd = word; control.Invalidate(); } } } /// /// Handle mouse down to handle selection. /// /// the control hosting the html to invalidate /// the location of the mouse on the html /// public void HandleMouseDown(RControl parent, RPoint loc, bool isMouseInContainer) { bool clear = !isMouseInContainer; if (isMouseInContainer) { _mouseDownInControl = true; _isDoubleClickSelect = (DateTime.Now - _lastMouseDown).TotalMilliseconds < 400; _lastMouseDown = DateTime.Now; _mouseDownOnSelectedWord = false; if (_root.HtmlContainer.IsSelectionEnabled && parent.LeftMouseButton) { var word = DomUtils.GetCssBoxWord(_root, loc); if (word != null && word.Selected) { _mouseDownOnSelectedWord = true; } else { clear = true; } } else if (parent.RightMouseButton) { var rect = DomUtils.GetCssBoxWord(_root, loc); var link = DomUtils.GetLinkBox(_root, loc); if (_root.HtmlContainer.IsContextMenuEnabled) { _contextMenuHandler.ShowContextMenu(parent, rect, link); } clear = rect == null || !rect.Selected; } } if (clear) { ClearSelection(); parent.Invalidate(); } } /// /// Handle mouse up to handle selection and link click. /// /// the control hosting the html to invalidate /// is the left mouse button has been released /// is the mouse up should be ignored public bool HandleMouseUp(RControl parent, bool leftMouseButton) { bool ignore = false; _mouseDownInControl = false; if (_root.HtmlContainer.IsSelectionEnabled) { ignore = _inSelection; if (!_inSelection && leftMouseButton && _mouseDownOnSelectedWord) { ClearSelection(); parent.Invalidate(); } _mouseDownOnSelectedWord = false; _inSelection = false; } ignore = ignore || (DateTime.Now - _lastMouseDown > TimeSpan.FromSeconds(1)); return ignore; } /// /// Handle mouse move to handle hover cursor and text selection. /// /// the control hosting the html to set cursor and invalidate /// the location of the mouse on the html public void HandleMouseMove(RControl parent, RPoint loc) { if (_root.HtmlContainer.IsSelectionEnabled && _mouseDownInControl && parent.LeftMouseButton) { if (_mouseDownOnSelectedWord) { // make sure not to start drag-drop on click but when it actually moves as it fucks mouse-up if ((DateTime.Now - _lastMouseDown).TotalMilliseconds > 200) StartDragDrop(parent); } else { HandleSelection(parent, loc, !_isDoubleClickSelect); _inSelection = _selectionStart != null && _selectionEnd != null && (_selectionStart != _selectionEnd || _selectionStartIndex != _selectionEndIndex); } } else { // Handle mouse hover over the html to change the cursor depending if hovering word, link of other. var link = DomUtils.GetLinkBox(_root, loc); if (link != null) { _cursorChanged = true; parent.SetCursorHand(); } else if (_root.HtmlContainer.IsSelectionEnabled) { var word = DomUtils.GetCssBoxWord(_root, loc); _cursorChanged = word != null && !word.IsImage && !(word.Selected && (word.SelectedStartIndex < 0 || word.Left + word.SelectedStartOffset <= loc.X) && (word.SelectedEndOffset < 0 || word.Left + word.SelectedEndOffset >= loc.X)); if (_cursorChanged) parent.SetCursorIBeam(); else parent.SetCursorDefault(); } else if (_cursorChanged) { parent.SetCursorDefault(); } } } /// /// On mouse leave change the cursor back to default. /// /// the control hosting the html to set cursor and invalidate public void HandleMouseLeave(RControl parent) { if (_cursorChanged) { _cursorChanged = false; parent.SetCursorDefault(); } } /// /// Copy the currently selected html segment to clipboard.
/// Copy rich html text and plain text. ///
public void CopySelectedHtml() { if (_root.HtmlContainer.IsSelectionEnabled) { var html = DomUtils.GenerateHtml(_root, HtmlGenerationStyle.Inline, true); var plainText = DomUtils.GetSelectedPlainText(_root); if (!string.IsNullOrEmpty(plainText)) _root.HtmlContainer.Adapter.SetToClipboard(html, plainText); } } /// /// Get the currently selected text segment in the html.
///
public string GetSelectedText() { return _root.HtmlContainer.IsSelectionEnabled ? DomUtils.GetSelectedPlainText(_root) : null; } /// /// Copy the currently selected html segment with style.
///
public string GetSelectedHtml() { return _root.HtmlContainer.IsSelectionEnabled ? DomUtils.GenerateHtml(_root, HtmlGenerationStyle.Inline, true) : null; } /// /// The selection start index if the first selected word is partially selected (-1 if not selected or fully selected)
/// if the given word is not starting or ending selection word -1 is returned as full word selection is in place. ///
/// /// Handles backward selecting by returning the selection end data instead of start. /// /// the word to return the selection start index for /// data value or -1 if not applicable public int GetSelectingStartIndex(CssRect word) { return word == (_backwardSelection ? _selectionEnd : _selectionStart) ? (_backwardSelection ? _selectionEndIndex : _selectionStartIndex) : -1; } /// /// The selection end index if the last selected word is partially selected (-1 if not selected or fully selected)
/// if the given word is not starting or ending selection word -1 is returned as full word selection is in place. ///
/// /// Handles backward selecting by returning the selection end data instead of start. /// /// the word to return the selection end index for public int GetSelectedEndIndexOffset(CssRect word) { return word == (_backwardSelection ? _selectionStart : _selectionEnd) ? (_backwardSelection ? _selectionStartIndex : _selectionEndIndex) : -1; } /// /// The selection start offset if the first selected word is partially selected (-1 if not selected or fully selected)
/// if the given word is not starting or ending selection word -1 is returned as full word selection is in place. ///
/// /// Handles backward selecting by returning the selection end data instead of start. /// /// the word to return the selection start offset for public double GetSelectedStartOffset(CssRect word) { return word == (_backwardSelection ? _selectionEnd : _selectionStart) ? (_backwardSelection ? _selectionEndOffset : _selectionStartOffset) : -1; } /// /// The selection end offset if the last selected word is partially selected (-1 if not selected or fully selected)
/// if the given word is not starting or ending selection word -1 is returned as full word selection is in place. ///
/// /// Handles backward selecting by returning the selection end data instead of start. /// /// the word to return the selection end offset for public double GetSelectedEndOffset(CssRect word) { return word == (_backwardSelection ? _selectionStart : _selectionEnd) ? (_backwardSelection ? _selectionStartOffset : _selectionEndOffset) : -1; } /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// /// 2 public void Dispose() { _contextMenuHandler.Dispose(); } #region Private methods /// /// Handle html text selection by mouse move over the html with left mouse button pressed.
/// Calculate the words in the selected range and set their selected property. ///
/// the control hosting the html to invalidate /// the mouse location /// true - partial word selection allowed, false - only full words selection private void HandleSelection(RControl control, RPoint loc, bool allowPartialSelect) { // get the line under the mouse or nearest from the top var lineBox = DomUtils.GetCssLineBox(_root, loc); if (lineBox != null) { // get the word under the mouse var word = DomUtils.GetCssBoxWord(lineBox, loc); // if no word found under the mouse use the last or the first word in the line if (word == null && lineBox.Words.Count > 0) { if (loc.Y > lineBox.LineBottom) { // under the line word = lineBox.Words[lineBox.Words.Count - 1]; } else if (loc.X < lineBox.Words[0].Left) { // before the line word = lineBox.Words[0]; } else if (loc.X > lineBox.Words[lineBox.Words.Count - 1].Right) { // at the end of the line word = lineBox.Words[lineBox.Words.Count - 1]; } } // if there is matching word if (word != null) { if (_selectionStart == null) { // on start set the selection start word _selectionStartPoint = loc; _selectionStart = word; if (allowPartialSelect) CalculateWordCharIndexAndOffset(control, word, loc, true); } // always set selection end word _selectionEnd = word; if (allowPartialSelect) CalculateWordCharIndexAndOffset(control, word, loc, false); ClearSelection(_root); if (CheckNonEmptySelection(loc, allowPartialSelect)) { CheckSelectionDirection(); SelectWordsInRange(_root, _backwardSelection ? _selectionEnd : _selectionStart, _backwardSelection ? _selectionStart : _selectionEnd); } else { _selectionEnd = null; } _cursorChanged = true; control.SetCursorIBeam(); control.Invalidate(); } } } /// /// Clear the current selection. /// private void ClearSelection() { // clear drag and drop _dragDropData = null; ClearSelection(_root); _selectionStartOffset = -1; _selectionStartIndex = -1; _selectionEndOffset = -1; _selectionEndIndex = -1; _selectionStartPoint = RPoint.Empty; _selectionStart = null; _selectionEnd = null; } /// /// Clear the selection from all the words in the css box recursively. /// /// the css box to selectionStart clear at private static void ClearSelection(CssBox box) { foreach (var word in box.Words) { word.Selection = null; } foreach (var childBox in box.Boxes) { ClearSelection(childBox); } } /// /// Start drag and drop operation on the currently selected html segment. /// /// the control to start the drag and drop on private void StartDragDrop(RControl control) { if (_dragDropData == null) { var html = DomUtils.GenerateHtml(_root, HtmlGenerationStyle.Inline, true); var plainText = DomUtils.GetSelectedPlainText(_root); _dragDropData = control.Adapter.GetClipboardDataObject(html, plainText); } control.DoDragDropCopy(_dragDropData); } /// /// Select all the words that are under DOM hierarchy.
///
/// the box to start select all at public void SelectAllWords(CssBox box) { foreach (var word in box.Words) { word.Selection = this; } foreach (var childBox in box.Boxes) { SelectAllWords(childBox); } } /// /// Check if the current selection is non empty, has some selection data. /// /// /// true - partial word selection allowed, false - only full words selection /// true - is non empty selection, false - empty selection private bool CheckNonEmptySelection(RPoint loc, bool allowPartialSelect) { // full word selection is never empty if (!allowPartialSelect) return true; // if end selection location is near starting location then the selection is empty if (Math.Abs(_selectionStartPoint.X - loc.X) <= 1 && Math.Abs(_selectionStartPoint.Y - loc.Y) < 5) return false; // selection is empty if on same word and same index return _selectionStart != _selectionEnd || _selectionStartIndex != _selectionEndIndex; } /// /// Select all the words that are between word and word in the DOM hierarchy.
///
/// the root of the DOM sub-tree the selection is in /// selection start word limit /// selection end word limit private void SelectWordsInRange(CssBox root, CssRect selectionStart, CssRect selectionEnd) { bool inSelection = false; SelectWordsInRange(root, selectionStart, selectionEnd, ref inSelection); } /// /// Select all the words that are between word and word in the DOM hierarchy. /// /// the current traversal node /// selection start word limit /// selection end word limit /// used to know the traversal is currently in selected range /// private bool SelectWordsInRange(CssBox box, CssRect selectionStart, CssRect selectionEnd, ref bool inSelection) { foreach (var boxWord in box.Words) { if (!inSelection && boxWord == selectionStart) { inSelection = true; } if (inSelection) { boxWord.Selection = this; if (selectionStart == selectionEnd || boxWord == selectionEnd) { return true; } } } foreach (var childBox in box.Boxes) { if (SelectWordsInRange(childBox, selectionStart, selectionEnd, ref inSelection)) { return true; } } return false; } /// /// Calculate the character index and offset by characters for the given word and given offset.
/// . ///
/// used to create graphics to measure string /// the word to calculate its index and offset /// the location to calculate for /// to set the starting or ending char and offset data private void CalculateWordCharIndexAndOffset(RControl control, CssRect word, RPoint loc, bool selectionStart) { int selectionIndex; double selectionOffset; CalculateWordCharIndexAndOffset(control, word, loc, selectionStart, out selectionIndex, out selectionOffset); if (selectionStart) { _selectionStartIndex = selectionIndex; _selectionStartOffset = selectionOffset; } else { _selectionEndIndex = selectionIndex; _selectionEndOffset = selectionOffset; } } /// /// Calculate the character index and offset by characters for the given word and given offset.
/// If the location is below the word line then set the selection to the end.
/// If the location is to the right of the word then set the selection to the end.
/// If the offset is to the left of the word set the selection to the beginning.
/// Otherwise calculate the width of each substring to find the char the location is on. ///
/// used to create graphics to measure string /// the word to calculate its index and offset /// the location to calculate for /// is to include the first character in the calculation /// return the index of the char under the location /// return the offset of the char under the location private static void CalculateWordCharIndexAndOffset(RControl control, CssRect word, RPoint loc, bool inclusive, out int selectionIndex, out double selectionOffset) { selectionIndex = 0; selectionOffset = 0f; var offset = loc.X - word.Left; if (word.Text == null) { // not a text word - set full selection selectionIndex = -1; selectionOffset = -1; } else if (offset > word.Width - word.OwnerBox.ActualWordSpacing || loc.Y > DomUtils.GetCssLineBoxByWord(word).LineBottom) { // mouse under the line, to the right of the word - set to the end of the word selectionIndex = word.Text.Length; selectionOffset = word.Width; } else if (offset > 0) { // calculate partial word selection int charFit; double charFitWidth; var maxWidth = offset + (inclusive ? 0 : 1.5f * word.LeftGlyphPadding); control.MeasureString(word.Text, word.OwnerBox.ActualFont, maxWidth, out charFit, out charFitWidth); selectionIndex = charFit; selectionOffset = charFitWidth; } } /// /// Check if the selection direction is forward or backward.
/// Is the selection start word is before the selection end word in DFS traversal. ///
private void CheckSelectionDirection() { if (_selectionStart == _selectionEnd) { _backwardSelection = _selectionStartIndex > _selectionEndIndex; } else if (DomUtils.GetCssLineBoxByWord(_selectionStart) == DomUtils.GetCssLineBoxByWord(_selectionEnd)) { _backwardSelection = _selectionStart.Left > _selectionEnd.Left; } else { _backwardSelection = _selectionStart.Top >= _selectionEnd.Bottom; } } #endregion } }