RaUI/Source/ryControls/HtmlRenderer/Core/Handlers/SelectionHandler.cs
zilinsoft 3262955f2f ### 2023-11-07更新
------
#### RaUIV4    V4.0.2311.0701
- *.[全新]整合了MyDb、ryControls、MyDb_MySQL等dll文件到RaUI一个项目。
- *.[新增]新增ApkOp类,可以轻松获取APK信息。
- *.[新增]新增JsonExt扩展类,让Json操作更简单。
- *.[新增]新增WebP类,可以支持webp格式的图片。
- *.[改进]ryQuickSQL中的AddField方法改为自动替换已存在的同名值。
- *.[修复]ryQuickSQL中的AddFieldCalc方法无法正常计算的BUG。
2023-11-07 16:37:53 +08:00

693 lines
28 KiB
C#

// "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
{
/// <summary>
/// Handler for text selection in the html.
/// </summary>
internal sealed class SelectionHandler : IDisposable
{
#region Fields and Consts
/// <summary>
/// the root of the handled html tree
/// </summary>
private readonly CssBox _root;
/// <summary>
/// handler for showing context menu on right click
/// </summary>
private readonly ContextMenuHandler _contextMenuHandler;
/// <summary>
/// the mouse location when selection started used to ignore small selections
/// </summary>
private RPoint _selectionStartPoint;
/// <summary>
/// the starting word of html selection<br/>
/// where the user started the selection, if the selection is backwards then it will be the last selected word.
/// </summary>
private CssRect _selectionStart;
/// <summary>
/// the ending word of html selection<br/>
/// where the user ended the selection, if the selection is backwards then it will be the first selected word.
/// </summary>
private CssRect _selectionEnd;
/// <summary>
/// the selection start index if the first selected word is partially selected (-1 if not selected or fully selected)
/// </summary>
private int _selectionStartIndex = -1;
/// <summary>
/// the selection end index if the last selected word is partially selected (-1 if not selected or fully selected)
/// </summary>
private int _selectionEndIndex = -1;
/// <summary>
/// the selection start offset if the first selected word is partially selected (-1 if not selected or fully selected)
/// </summary>
private double _selectionStartOffset = -1;
/// <summary>
/// the selection end offset if the last selected word is partially selected (-1 if not selected or fully selected)
/// </summary>
private double _selectionEndOffset = -1;
/// <summary>
/// is the selection goes backward in the html, the starting word comes after the ending word in DFS traversing.<br/>
/// </summary>
private bool _backwardSelection;
/// <summary>
/// used to ignore mouse up after selection
/// </summary>
private bool _inSelection;
/// <summary>
/// current selection process is after double click (full word selection)
/// </summary>
private bool _isDoubleClickSelect;
/// <summary>
/// used to know if selection is in the control or started outside so it needs to be ignored
/// </summary>
private bool _mouseDownInControl;
/// <summary>
/// used to handle drag and drop
/// </summary>
private bool _mouseDownOnSelectedWord;
/// <summary>
/// is the cursor on the control has been changed by the selection handler
/// </summary>
private bool _cursorChanged;
/// <summary>
/// used to know if double click selection is requested
/// </summary>
private DateTime _lastMouseDown;
/// <summary>
/// used to know if drag and drop was already started not to execute the same operation over
/// </summary>
private object _dragDropData;
#endregion
/// <summary>
/// Init.
/// </summary>
/// <param name="root">the root of the handled html tree</param>
public SelectionHandler(CssBox root)
{
ArgChecker.AssertArgNotNull(root, "root");
_root = root;
_contextMenuHandler = new ContextMenuHandler(this, root.HtmlContainer);
}
/// <summary>
/// Select all the words in the html.
/// </summary>
/// <param name="control">the control hosting the html to invalidate</param>
public void SelectAll(RControl control)
{
if (_root.HtmlContainer.IsSelectionEnabled)
{
ClearSelection();
SelectAllWords(_root);
control.Invalidate();
}
}
/// <summary>
/// Select the word at the given location if found.
/// </summary>
/// <param name="control">the control hosting the html to invalidate</param>
/// <param name="loc">the location to select word at</param>
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();
}
}
}
/// <summary>
/// Handle mouse down to handle selection.
/// </summary>
/// <param name="parent">the control hosting the html to invalidate</param>
/// <param name="loc">the location of the mouse on the html</param>
/// <param name="isMouseInContainer"> </param>
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();
}
}
/// <summary>
/// Handle mouse up to handle selection and link click.
/// </summary>
/// <param name="parent">the control hosting the html to invalidate</param>
/// <param name="leftMouseButton">is the left mouse button has been released</param>
/// <returns>is the mouse up should be ignored</returns>
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;
}
/// <summary>
/// Handle mouse move to handle hover cursor and text selection.
/// </summary>
/// <param name="parent">the control hosting the html to set cursor and invalidate</param>
/// <param name="loc">the location of the mouse on the html</param>
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();
}
}
}
/// <summary>
/// On mouse leave change the cursor back to default.
/// </summary>
/// <param name="parent">the control hosting the html to set cursor and invalidate</param>
public void HandleMouseLeave(RControl parent)
{
if (_cursorChanged)
{
_cursorChanged = false;
parent.SetCursorDefault();
}
}
/// <summary>
/// Copy the currently selected html segment to clipboard.<br/>
/// Copy rich html text and plain text.
/// </summary>
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);
}
}
/// <summary>
/// Get the currently selected text segment in the html.<br/>
/// </summary>
public string GetSelectedText()
{
return _root.HtmlContainer.IsSelectionEnabled ? DomUtils.GetSelectedPlainText(_root) : null;
}
/// <summary>
/// Copy the currently selected html segment with style.<br/>
/// </summary>
public string GetSelectedHtml()
{
return _root.HtmlContainer.IsSelectionEnabled ? DomUtils.GenerateHtml(_root, HtmlGenerationStyle.Inline, true) : null;
}
/// <summary>
/// The selection start index if the first selected word is partially selected (-1 if not selected or fully selected)<br/>
/// if the given word is not starting or ending selection word -1 is returned as full word selection is in place.
/// </summary>
/// <remarks>
/// Handles backward selecting by returning the selection end data instead of start.
/// </remarks>
/// <param name="word">the word to return the selection start index for</param>
/// <returns>data value or -1 if not applicable</returns>
public int GetSelectingStartIndex(CssRect word)
{
return word == (_backwardSelection ? _selectionEnd : _selectionStart) ? (_backwardSelection ? _selectionEndIndex : _selectionStartIndex) : -1;
}
/// <summary>
/// The selection end index if the last selected word is partially selected (-1 if not selected or fully selected)<br/>
/// if the given word is not starting or ending selection word -1 is returned as full word selection is in place.
/// </summary>
/// <remarks>
/// Handles backward selecting by returning the selection end data instead of start.
/// </remarks>
/// <param name="word">the word to return the selection end index for</param>
public int GetSelectedEndIndexOffset(CssRect word)
{
return word == (_backwardSelection ? _selectionStart : _selectionEnd) ? (_backwardSelection ? _selectionStartIndex : _selectionEndIndex) : -1;
}
/// <summary>
/// The selection start offset if the first selected word is partially selected (-1 if not selected or fully selected)<br/>
/// if the given word is not starting or ending selection word -1 is returned as full word selection is in place.
/// </summary>
/// <remarks>
/// Handles backward selecting by returning the selection end data instead of start.
/// </remarks>
/// <param name="word">the word to return the selection start offset for</param>
public double GetSelectedStartOffset(CssRect word)
{
return word == (_backwardSelection ? _selectionEnd : _selectionStart) ? (_backwardSelection ? _selectionEndOffset : _selectionStartOffset) : -1;
}
/// <summary>
/// The selection end offset if the last selected word is partially selected (-1 if not selected or fully selected)<br/>
/// if the given word is not starting or ending selection word -1 is returned as full word selection is in place.
/// </summary>
/// <remarks>
/// Handles backward selecting by returning the selection end data instead of start.
/// </remarks>
/// <param name="word">the word to return the selection end offset for</param>
public double GetSelectedEndOffset(CssRect word)
{
return word == (_backwardSelection ? _selectionStart : _selectionEnd) ? (_backwardSelection ? _selectionStartOffset : _selectionEndOffset) : -1;
}
/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
/// </summary>
/// <filterpriority>2</filterpriority>
public void Dispose()
{
_contextMenuHandler.Dispose();
}
#region Private methods
/// <summary>
/// Handle html text selection by mouse move over the html with left mouse button pressed.<br/>
/// Calculate the words in the selected range and set their selected property.
/// </summary>
/// <param name="control">the control hosting the html to invalidate</param>
/// <param name="loc">the mouse location</param>
/// <param name="allowPartialSelect">true - partial word selection allowed, false - only full words selection</param>
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();
}
}
}
/// <summary>
/// Clear the current selection.
/// </summary>
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;
}
/// <summary>
/// Clear the selection from all the words in the css box recursively.
/// </summary>
/// <param name="box">the css box to selectionStart clear at</param>
private static void ClearSelection(CssBox box)
{
foreach (var word in box.Words)
{
word.Selection = null;
}
foreach (var childBox in box.Boxes)
{
ClearSelection(childBox);
}
}
/// <summary>
/// Start drag and drop operation on the currently selected html segment.
/// </summary>
/// <param name="control">the control to start the drag and drop on</param>
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);
}
/// <summary>
/// Select all the words that are under <paramref name="box"/> DOM hierarchy.<br/>
/// </summary>
/// <param name="box">the box to start select all at</param>
public void SelectAllWords(CssBox box)
{
foreach (var word in box.Words)
{
word.Selection = this;
}
foreach (var childBox in box.Boxes)
{
SelectAllWords(childBox);
}
}
/// <summary>
/// Check if the current selection is non empty, has some selection data.
/// </summary>
/// <param name="loc"></param>
/// <param name="allowPartialSelect">true - partial word selection allowed, false - only full words selection</param>
/// <returns>true - is non empty selection, false - empty selection</returns>
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;
}
/// <summary>
/// Select all the words that are between <paramref name="selectionStart"/> word and <paramref name="selectionEnd"/> word in the DOM hierarchy.<br/>
/// </summary>
/// <param name="root">the root of the DOM sub-tree the selection is in</param>
/// <param name="selectionStart">selection start word limit</param>
/// <param name="selectionEnd">selection end word limit</param>
private void SelectWordsInRange(CssBox root, CssRect selectionStart, CssRect selectionEnd)
{
bool inSelection = false;
SelectWordsInRange(root, selectionStart, selectionEnd, ref inSelection);
}
/// <summary>
/// Select all the words that are between <paramref name="selectionStart"/> word and <paramref name="selectionEnd"/> word in the DOM hierarchy.
/// </summary>
/// <param name="box">the current traversal node</param>
/// <param name="selectionStart">selection start word limit</param>
/// <param name="selectionEnd">selection end word limit</param>
/// <param name="inSelection">used to know the traversal is currently in selected range</param>
/// <returns></returns>
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;
}
/// <summary>
/// Calculate the character index and offset by characters for the given word and given offset.<br/>
/// <seealso cref="CalculateWordCharIndexAndOffset(RControl,HtmlRenderer.Core.Dom.CssRect,RPoint,bool)"/>.
/// </summary>
/// <param name="control">used to create graphics to measure string</param>
/// <param name="word">the word to calculate its index and offset</param>
/// <param name="loc">the location to calculate for</param>
/// <param name="selectionStart">to set the starting or ending char and offset data</param>
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;
}
}
/// <summary>
/// Calculate the character index and offset by characters for the given word and given offset.<br/>
/// If the location is below the word line then set the selection to the end.<br/>
/// If the location is to the right of the word then set the selection to the end.<br/>
/// If the offset is to the left of the word set the selection to the beginning.<br/>
/// Otherwise calculate the width of each substring to find the char the location is on.
/// </summary>
/// <param name="control">used to create graphics to measure string</param>
/// <param name="word">the word to calculate its index and offset</param>
/// <param name="loc">the location to calculate for</param>
/// <param name="inclusive">is to include the first character in the calculation</param>
/// <param name="selectionIndex">return the index of the char under the location</param>
/// <param name="selectionOffset">return the offset of the char under the location</param>
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;
}
}
/// <summary>
/// Check if the selection direction is forward or backward.<br/>
/// Is the selection start word is before the selection end word in DFS traversal.
/// </summary>
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
}
}