// "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
}
}