Commit 6f1786b8 authored by ekaramad's avatar ekaramad Committed by Commit bot

Tracking text selection on the browser side in OOPIF.

This CL adds the required logic to track the text selection information
at the browser side. The text selection information is now stored in the
WebContent's TextInputManager (outermost WebContents). All
RenderWidgetHostViews as well as RenderWidgetHostViewAura
(TextInputClient) will retrieve the information for a specific view or
the active view from TextInputManager.

BUG=578168, 602723
CQ_INCLUDE_TRYBOTS=tryserver.chromium.linux:linux_site_isolation

Review-Url: https://codereview.chromium.org/2130133004
Cr-Commit-Position: refs/heads/master@{#406937}
parent 564cf64f
......@@ -235,6 +235,36 @@ class ViewCompositionRangeChangedObserver
DISALLOW_COPY_AND_ASSIGN(ViewCompositionRangeChangedObserver);
};
// This class observes the |expected_view| for a change in the text selection
// that has a selection length of |expected_length|.
class ViewTextSelectionObserver : public TextInputManagerObserverBase {
public:
ViewTextSelectionObserver(content::WebContents* web_contents,
content::RenderWidgetHostView* expected_view,
size_t expected_selection_length)
: TextInputManagerObserverBase(web_contents),
expected_view_(expected_view),
expected_selection_length_(expected_selection_length) {
tester()->SetOnTextSelectionChangedCallback(base::Bind(
&ViewTextSelectionObserver::VerifyChange, base::Unretained(this)));
}
private:
void VerifyChange() {
if (expected_view_ == tester()->GetUpdatedView()) {
size_t selection_length;
if (tester()->GetCurrentTextSelectionLength(&selection_length) &&
expected_selection_length_ == selection_length)
OnSuccess();
}
}
const content::RenderWidgetHostView* const expected_view_;
const size_t expected_selection_length_;
DISALLOW_COPY_AND_ASSIGN(ViewTextSelectionObserver);
};
} // namespace
// Main class for all TextInputState and IME related tests.
......@@ -582,6 +612,41 @@ IN_PROC_BROWSER_TEST_F(SitePerProcessTextInputManagerTest,
send_tab_set_composition_wait_for_bounds_change(view);
}
// This test creates a page with multiple child frames and adds an <input> to
// each frame. Then, sequentially, each <input> is focused by sending a tab key.
// After focusing each input, the whole text is automatically selected and a
// ViewHostMsg_SelectionChanged IPC sent back to the browser. This test verifies
// that the browser tracks the text selection from all frames.
IN_PROC_BROWSER_TEST_F(SitePerProcessTextInputManagerTest,
TrackTextSelectionForAllFrames) {
// TODO(ekaramad): Since IME related methods in WebFrameWidgetImpl are not
// implemented yet, this test does not work on child frames. Add child frames
// to this test when IME methods in WebFramgeWidgetImpl are implemented
// (https://crbug.com/626746).
CreateIframePage("a()");
std::vector<content::RenderFrameHost*> frames{GetFrame(IndexVector{})};
std::vector<content::RenderWidgetHostView*> views;
for (auto frame : frames)
views.push_back(frame->GetView());
std::vector<std::string> input_text{"abc"};
for (size_t i = 0; i < frames.size(); ++i)
AddInputFieldToFrame(frames[i], "text", input_text[i], false);
content::WebContents* web_contents = active_contents();
auto send_tab_and_wait_for_selection_change = [&web_contents](
content::RenderFrameHost* frame, size_t expected_length) {
ViewTextSelectionObserver text_selection_observer(
web_contents, frame->GetView(), expected_length);
SimulateKeyPress(web_contents, ui::DomKey::TAB, ui::DomCode::TAB,
ui::VKEY_TAB, false, false, false, false);
text_selection_observer.Wait();
};
for (size_t i = 0; i < frames.size(); ++i)
send_tab_and_wait_for_selection_change(frames[i], input_text[i].size());
}
// TODO(ekaramad): The following tests are specifically written for Aura and are
// based on InputMethodObserver. Write similar tests for Mac/Android/Mus
// (crbug.com/602723).
......
......@@ -292,12 +292,6 @@ void RenderWidgetHostViewChildFrame::SetTooltipText(
frame_connector_->GetRootRenderWidgetHostView()->SetTooltipText(tooltip_text);
}
void RenderWidgetHostViewChildFrame::SelectionChanged(
const base::string16& text,
size_t offset,
const gfx::Range& range) {
}
void RenderWidgetHostViewChildFrame::LockCompositingSurface() {
NOTIMPLEMENTED();
}
......
......@@ -99,9 +99,6 @@ class CONTENT_EXPORT RenderWidgetHostViewChildFrame
int error_code) override;
void Destroy() override;
void SetTooltipText(const base::string16& tooltip_text) override;
void SelectionChanged(const base::string16& text,
size_t offset,
const gfx::Range& range) override;
void CopyFromCompositingSurface(
const gfx::Rect& src_subrect,
const gfx::Size& dst_size,
......
......@@ -1015,29 +1015,6 @@ void RenderWidgetHostViewAura::SetTooltipText(
}
}
void RenderWidgetHostViewAura::SelectionChanged(const base::string16& text,
size_t offset,
const gfx::Range& range) {
RenderWidgetHostViewBase::SelectionChanged(text, offset, range);
#if defined(USE_X11) && !defined(OS_CHROMEOS)
if (text.empty() || range.is_empty())
return;
size_t pos = range.GetMin() - offset;
size_t n = range.length();
DCHECK(pos + n <= text.length()) << "The text can not fully cover range.";
if (pos >= text.length()) {
NOTREACHED() << "The text can not cover range.";
return;
}
// Set the CLIPBOARD_TYPE_SELECTION to the ui::Clipboard.
ui::ScopedClipboardWriter clipboard_writer(ui::CLIPBOARD_TYPE_SELECTION);
clipboard_writer.WriteText(text.substr(pos, n));
#endif // defined(USE_X11) && !defined(OS_CHROMEOS)
}
gfx::Size RenderWidgetHostViewAura::GetRequestedRendererSize() const {
return delegated_frame_host_->GetRequestedRendererSize();
}
......@@ -1551,8 +1528,16 @@ bool RenderWidgetHostViewAura::HasCompositionText() const {
}
bool RenderWidgetHostViewAura::GetTextRange(gfx::Range* range) const {
range->set_start(selection_text_offset_);
range->set_end(selection_text_offset_ + selection_text_.length());
if (!text_input_manager_)
return false;
const TextInputManager::TextSelection* selection =
text_input_manager_->GetTextSelection();
if (!selection)
return false;
range->set_start(selection->offset);
range->set_end(selection->offset + selection->text.length());
return true;
}
......@@ -1564,8 +1549,16 @@ bool RenderWidgetHostViewAura::GetCompositionTextRange(
}
bool RenderWidgetHostViewAura::GetSelectionRange(gfx::Range* range) const {
range->set_start(selection_range_.start());
range->set_end(selection_range_.end());
if (!text_input_manager_)
return false;
const TextInputManager::TextSelection* selection =
text_input_manager_->GetTextSelection();
if (!selection)
return false;
range->set_start(selection->range.start());
range->set_end(selection->range.end());
return true;
}
......@@ -1584,8 +1577,16 @@ bool RenderWidgetHostViewAura::DeleteRange(const gfx::Range& range) {
bool RenderWidgetHostViewAura::GetTextFromRange(
const gfx::Range& range,
base::string16* text) const {
gfx::Range selection_text_range(selection_text_offset_,
selection_text_offset_ + selection_text_.length());
if (!text_input_manager_)
return false;
const TextInputManager::TextSelection* selection =
text_input_manager_->GetTextSelection();
if (!selection)
return false;
gfx::Range selection_text_range(selection->offset,
selection->offset + selection->text.length());
if (!selection_text_range.Contains(range)) {
text->clear();
......@@ -1593,11 +1594,10 @@ bool RenderWidgetHostViewAura::GetTextFromRange(
}
if (selection_text_range.EqualsIgnoringDirection(range)) {
// Avoid calling substr whose performance is low.
*text = selection_text_;
*text = selection->text;
} else {
*text = selection_text_.substr(
range.GetMin() - selection_text_offset_,
range.length());
*text = selection->text.substr(range.GetMin() - selection->offset,
range.length());
}
return true;
}
......@@ -3006,6 +3006,34 @@ void RenderWidgetHostViewAura::OnSelectionBoundsChanged(
GetInputMethod()->OnCaretBoundsChanged(this);
}
void RenderWidgetHostViewAura::OnTextSelectionChanged(
TextInputManager* text_input_manager,
RenderWidgetHostViewBase* updated_view) {
#if defined(USE_X11) && !defined(OS_CHROMEOS)
if (!GetTextInputManager() || !GetTextInputManager()->GetActiveWidget())
return;
const TextInputManager::TextSelection* text_selection =
GetTextInputManager()->GetTextSelection();
if (text_selection->text.empty() || text_selection->range.is_empty())
return;
size_t pos = text_selection->range.GetMin() - text_selection->offset;
size_t n = text_selection->range.length();
DCHECK(pos + n <= text_selection->text.length())
<< "The text can not fully cover range.";
if (pos >= text_selection->text.length()) {
NOTREACHED() << "The text can not cover range.";
return;
}
// Set the CLIPBOARD_TYPE_SELECTION to the ui::Clipboard.
ui::ScopedClipboardWriter clipboard_writer(ui::CLIPBOARD_TYPE_SELECTION);
clipboard_writer.WriteText(text_selection->text.substr(pos, n));
#endif // defined(USE_X11) && !defined(OS_CHROMEOS)
}
////////////////////////////////////////////////////////////////////////////////
// RenderWidgetHostViewBase, public:
......
......@@ -146,9 +146,6 @@ class CONTENT_EXPORT RenderWidgetHostViewAura
int error_code) override;
void Destroy() override;
void SetTooltipText(const base::string16& tooltip_text) override;
void SelectionChanged(const base::string16& text,
size_t offset,
const gfx::Range& range) override;
gfx::Size GetRequestedRendererSize() const override;
void CopyFromCompositingSurface(
const gfx::Rect& src_subrect,
......@@ -472,6 +469,8 @@ class CONTENT_EXPORT RenderWidgetHostViewAura
void OnSelectionBoundsChanged(
TextInputManager* text_input_manager,
RenderWidgetHostViewBase* updated_view) override;
void OnTextSelectionChanged(TextInputManager* text_input_mangager,
RenderWidgetHostViewBase* updated_view) override;
// cc::BeginFrameObserver implementation.
void OnBeginFrame(const cc::BeginFrameArgs& args) override;
......
......@@ -4477,4 +4477,66 @@ TEST_F(InputMethodStateAuraTest, GetCompositionCharacterBounds) {
}
}
// This test is for selected text.
TEST_F(InputMethodStateAuraTest, GetSelectedText) {
base::string16 text = base::ASCIIToUTF16("some text of length 22");
size_t offset = 0U;
gfx::Range selection_range(20, 21);
for (auto index : active_view_sequence_) {
ActivateViewForTextInputManager(views_[index], ui::TEXT_INPUT_TYPE_TEXT);
views_[index]->SelectionChanged(text, offset, selection_range);
base::string16 expected_text = text.substr(
selection_range.GetMin() - offset, selection_range.length());
EXPECT_EQ(expected_text, views_[index]->GetSelectedText());
// Changing offset to make sure that the next view has a different text
// selection.
offset++;
}
}
// This test is for text range.
TEST_F(InputMethodStateAuraTest, GetTextRange) {
base::string16 text = base::ASCIIToUTF16("some text of length 22");
size_t offset = 0U;
gfx::Range selection_range;
for (auto index : active_view_sequence_) {
ActivateViewForTextInputManager(views_[index], ui::TEXT_INPUT_TYPE_TEXT);
gfx::Range expected_range(offset, offset + text.length());
views_[index]->SelectionChanged(text, offset, selection_range);
gfx::Range range_from_client;
// For aura this always returns true.
EXPECT_TRUE(text_input_client()->GetTextRange(&range_from_client));
EXPECT_EQ(expected_range, range_from_client);
// Changing offset to make sure that the next view has a different text
// selection.
offset++;
}
}
// This test is for selection range.
TEST_F(InputMethodStateAuraTest, GetSelectionRange) {
base::string16 text;
gfx::Range expected_range(0U, 1U);
for (auto index : active_view_sequence_) {
ActivateViewForTextInputManager(views_[index], ui::TEXT_INPUT_TYPE_TEXT);
views_[index]->SelectionChanged(text, 0U, expected_range);
gfx::Range range_from_client;
// This method always returns true.
EXPECT_TRUE(text_input_client()->GetSelectionRange(&range_from_client));
EXPECT_EQ(expected_range, range_from_client);
// Changing range to make sure that the next view has a different text
// selection.
expected_range.set_end(expected_range.end() + 1U);
}
}
} // namespace content
......@@ -36,14 +36,17 @@ RenderWidgetHostViewBase::RenderWidgetHostViewBase()
background_color_(SK_ColorWHITE),
mouse_locked_(false),
showing_context_menu_(false),
#if !defined(USE_AURA)
selection_text_offset_(0),
selection_range_(gfx::Range::InvalidRange()),
#endif
current_device_scale_factor_(0),
current_display_rotation_(display::Display::ROTATE_0),
pinch_zoom_enabled_(content::IsPinchToZoomEnabled()),
text_input_manager_(nullptr),
renderer_frame_number_(0),
weak_factory_(this) {}
weak_factory_(this) {
}
RenderWidgetHostViewBase::~RenderWidgetHostViewBase() {
DCHECK(!mouse_locked_);
......@@ -111,10 +114,18 @@ void RenderWidgetHostViewBase::SelectionBoundsChanged(
void RenderWidgetHostViewBase::SelectionChanged(const base::string16& text,
size_t offset,
const gfx::Range& range) {
// TODO(ekaramad): Use TextInputManager code paths for IME on other platforms.
// Also, remove the following local variables when that happens
// (https://crbug.com/578168 and https://crbug.com/602427).
#if defined(USE_AURA)
if (GetTextInputManager())
GetTextInputManager()->SelectionChanged(this, text, offset, range);
#else
selection_text_ = text;
selection_text_offset_ = offset;
selection_range_.set_start(range.start());
selection_range_.set_end(range.end());
#endif
}
gfx::Size RenderWidgetHostViewBase::GetRequestedRendererSize() const {
......@@ -136,11 +147,28 @@ void RenderWidgetHostViewBase::SetShowingContextMenu(bool showing) {
}
base::string16 RenderWidgetHostViewBase::GetSelectedText() {
// TODO(ekaramad): Use TextInputManager code paths for IME on other platforms.
// Also, remove the following local variables when that happens
// (https://crbug.com/578168 and https://crbug.com/602427).
#if defined(USE_AURA)
if (!GetTextInputManager())
return base::string16();
const TextInputManager::TextSelection* selection =
GetTextInputManager()->GetTextSelection(this);
if (!selection || !selection->range.IsValid())
return base::string16();
return selection->text.substr(selection->range.GetMin() - selection->offset,
selection->range.length());
#else
if (!selection_range_.IsValid())
return base::string16();
return selection_text_.substr(
selection_range_.GetMin() - selection_text_offset_,
selection_range_.length());
#endif
}
bool RenderWidgetHostViewBase::IsMouseLocked() {
......
......@@ -426,6 +426,11 @@ class CONTENT_EXPORT RenderWidgetHostViewBase : public RenderWidgetHostView,
// Whether we are showing a context menu.
bool showing_context_menu_;
// TODO(ekaramad): In aura, text selection tracking for IME is done through the
// TextInputManager. We still need the following variables for other platforms.
// Remove them when tracking is done by TextInputManager on all platforms
// (https://crbug.com/578168 and https://crbug.com/602427).
#if !defined(USE_AURA)
// A buffer containing the text inside and around the current selection range.
base::string16 selection_text_;
......@@ -435,6 +440,7 @@ class CONTENT_EXPORT RenderWidgetHostViewBase : public RenderWidgetHostView,
// The current selection range relative to the start of the web page.
gfx::Range selection_range_;
#endif
// The scale factor of the display the renderer is currently on.
float current_device_scale_factor_;
......
......@@ -4,10 +4,12 @@
#include "content/browser/renderer_host/text_input_manager.h"
#include "base/strings/string16.h"
#include "content/browser/renderer_host/render_widget_host_impl.h"
#include "content/browser/renderer_host/render_widget_host_view_base.h"
#include "content/common/view_messages.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/gfx/range/range.h"
namespace content {
......@@ -72,6 +74,14 @@ const std::vector<gfx::Rect>* TextInputManager::GetCompositionCharacterBounds()
: nullptr;
}
const TextInputManager::TextSelection* TextInputManager::GetTextSelection(
RenderWidgetHostViewBase* view) const {
DCHECK(!view || IsRegistered(view));
if (!view)
view = active_view_;
return !!view ? &text_selection_map_.at(view) : nullptr;
}
void TextInputManager::UpdateTextInputState(
RenderWidgetHostViewBase* view,
const TextInputState& text_input_state) {
......@@ -174,12 +184,28 @@ void TextInputManager::ImeCompositionRangeChanged(
OnImeCompositionRangeChanged(this, view));
}
void TextInputManager::SelectionChanged(RenderWidgetHostViewBase* view,
const base::string16& text,
size_t offset,
const gfx::Range& range) {
DCHECK(IsRegistered(view));
text_selection_map_[view].text = text;
text_selection_map_[view].offset = offset;
text_selection_map_[view].range.set_start(range.start());
text_selection_map_[view].range.set_end(range.end());
FOR_EACH_OBSERVER(Observer, observer_list_,
OnTextSelectionChanged(this, view));
}
void TextInputManager::Register(RenderWidgetHostViewBase* view) {
DCHECK(!IsRegistered(view));
text_input_state_map_[view] = TextInputState();
selection_region_map_[view] = SelectionRegion();
composition_range_info_map_[view] = CompositionRangeInfo();
text_selection_map_[view] = TextSelection();
}
void TextInputManager::Unregister(RenderWidgetHostViewBase* view) {
......@@ -188,6 +214,7 @@ void TextInputManager::Unregister(RenderWidgetHostViewBase* view) {
text_input_state_map_.erase(view);
selection_region_map_.erase(view);
composition_range_info_map_.erase(view);
text_selection_map_.erase(view);
if (active_view_ == view) {
active_view_ = nullptr;
......@@ -238,4 +265,12 @@ TextInputManager::CompositionRangeInfo::CompositionRangeInfo(
TextInputManager::CompositionRangeInfo::~CompositionRangeInfo() {}
TextInputManager::TextSelection::TextSelection()
: offset(0), range(gfx::Range::InvalidRange()), text(base::string16()) {}
TextInputManager::TextSelection::TextSelection(const TextSelection& other) =
default;
TextInputManager::TextSelection::~TextSelection() {}
} // namespace content
......@@ -9,17 +9,15 @@
#include <utility>
#include "base/observer_list.h"
#include "base/strings/string16.h"
#include "content/common/content_export.h"
#include "content/common/text_input_state.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/gfx/range/range.h"
#include "ui/gfx/selection_bound.h"
struct ViewHostMsg_SelectionBounds_Params;
namespace gfx {
class Range;
}
namespace content {
class RenderWidgetHostImpl;
......@@ -58,6 +56,27 @@ class CONTENT_EXPORT TextInputManager {
virtual void OnImeCompositionRangeChanged(
TextInputManager* text_input_manager,
RenderWidgetHostViewBase* updated_view) {}
// Called when the text selection for the |updated_view_| has changed.
virtual void OnTextSelectionChanged(
TextInputManager* text_input_manager,
RenderWidgetHostViewBase* updated_view) {}
};
// This struct is used to store text selection related information for views.
struct TextSelection {
TextSelection();
TextSelection(const TextSelection& other);
~TextSelection();
// The offset of the text stored in |text| relative to the start of the web
// page.
size_t offset;
// The current selection range relative to the start of the web page.
gfx::Range range;
// The text inside and around the current selection range.
base::string16 text;
};
TextInputManager();
......@@ -84,6 +103,12 @@ class CONTENT_EXPORT TextInputManager {
// Returns a vector of rects representing the character bounds.
const std::vector<gfx::Rect>* GetCompositionCharacterBounds() const;
// The following method returns the text selection state for the given |view|.
// If |view| == nullptr, it will assume |active_view_| and return its state.
// In the case of |active_view_| == nullptr, the method will return nullptr.
const TextSelection* GetTextSelection(
RenderWidgetHostViewBase* view = nullptr) const;
// ---------------------------------------------------------------------------
// The following methods are called by RWHVs on the tab to update their IME-
// related state.
......@@ -108,6 +133,12 @@ class CONTENT_EXPORT TextInputManager {
const gfx::Range& range,
const std::vector<gfx::Rect>& character_bounds);
// Updates the new text selection information for the |view|.
void SelectionChanged(RenderWidgetHostViewBase* view,
const base::string16& text,
size_t offset,
const gfx::Range& range);
// Registers the given |view| for tracking its TextInputState. This is called
// by any view which has updates in its TextInputState (whether tab's RWHV or
// that of a child frame). The |view| must unregister itself before being
......@@ -177,6 +208,7 @@ class CONTENT_EXPORT TextInputManager {
ViewMap<TextInputState> text_input_state_map_;
ViewMap<SelectionRegion> selection_region_map_;
ViewMap<CompositionRangeInfo> composition_range_info_map_;
ViewMap<TextSelection> text_selection_map_;
base::ObserverList<Observer> observer_list_;
......
......@@ -65,6 +65,10 @@ class TextInputManagerTester::InternalObserver
on_ime_composition_range_changed_callback_ = callback;
}
void set_on_text_selection_changed_callback(const base::Closure& callback) {
on_text_selection_changed_callback_ = callback;
}
const RenderWidgetHostView* GetUpdatedView() const { return updated_view_; }
bool text_input_state_changed() const { return text_input_state_changed_; }
......@@ -99,6 +103,13 @@ class TextInputManagerTester::InternalObserver
on_ime_composition_range_changed_callback_.Run();
}
void OnTextSelectionChanged(TextInputManager* text_input_manager,
RenderWidgetHostViewBase* updated_view) override {
updated_view_ = updated_view;
if (!on_text_selection_changed_callback_.is_null())
on_text_selection_changed_callback_.Run();
}
// WebContentsObserver implementation.
void WebContentsDestroyed() override { text_input_manager_ = nullptr; }
......@@ -109,6 +120,7 @@ class TextInputManagerTester::InternalObserver
base::Closure update_text_input_state_callback_;
base::Closure on_selection_bounds_changed_callback_;
base::Closure on_ime_composition_range_changed_callback_;
base::Closure on_text_selection_changed_callback_;
DISALLOW_COPY_AND_ASSIGN(InternalObserver);
};
......@@ -284,6 +296,11 @@ void TextInputManagerTester::SetOnImeCompositionRangeChangedCallback(
observer_->set_on_ime_composition_range_changed_callback(callback);
}
void TextInputManagerTester::SetOnTextSelectionChangedCallback(
const base::Closure& callback) {
observer_->set_on_text_selection_changed_callback(callback);
}
bool TextInputManagerTester::GetTextInputType(ui::TextInputType* type) {
DCHECK(observer_->text_input_manager());
const TextInputState* state =
......@@ -313,6 +330,16 @@ const RenderWidgetHostView* TextInputManagerTester::GetUpdatedView() {
return observer_->GetUpdatedView();
}
bool TextInputManagerTester::GetCurrentTextSelectionLength(size_t* length) {
DCHECK(observer_->text_input_manager());
if (!observer_->text_input_manager()->GetActiveWidget())
return false;
*length = observer_->text_input_manager()->GetTextSelection()->text.size();
return true;
}
bool TextInputManagerTester::IsTextInputStateChanged() {
return observer_->text_input_state_changed();
}
......
......@@ -78,6 +78,10 @@ class TextInputManagerTester {
// ImeCompositionRangeChanged on the TextInputManager that is being observed.
void SetOnImeCompositionRangeChangedCallback(const base::Closure& callback);
// Sets a callback which is invoked when a RWHV calls SelectionChanged on the
// TextInputManager which is being observed.
void SetOnTextSelectionChangedCallback(const base::Closure& callback);
// Returns true if there is a focused <input> and populates |type| with
// |TextInputState.type| of the TextInputManager.
bool GetTextInputType(ui::TextInputType* type);
......@@ -86,6 +90,10 @@ class TextInputManagerTester {
// |TextInputState.value| of the TextInputManager.
bool GetTextInputValue(std::string* value);
// Returns true if there is a focused <input> and populates |length| with the
// length of the selected text range in the focused view.
bool GetCurrentTextSelectionLength(size_t* length);
// Returns the RenderWidgetHostView with a focused <input> element or nullptr
// if none exists.
const RenderWidgetHostView* GetActiveView();
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment