LibWeb+UI/AppKit: Implement macOS IME support
This makes macOS IME input in web content work as expected. Fixes https://github.com/LadybirdBrowser/ladybird/issues/9712
This commit is contained in:
parent
42a6253d64
commit
a82c7939d9
18 changed files with 511 additions and 10 deletions
|
|
@ -8384,6 +8384,63 @@ GC::Ptr<DOM::Position> Document::cursor_position() const
|
|||
return nullptr;
|
||||
}
|
||||
|
||||
Optional<CSSPixelRect> Document::current_caret_rect()
|
||||
{
|
||||
// Returns the bounds of the current text caret in viewport-relative CSS pixels. Used to position platform overlays
|
||||
// such as the IME candidate window. Returns nothing when no editable element is focused or when layout isn't ready.
|
||||
auto position = cursor_position();
|
||||
if (!position)
|
||||
return {};
|
||||
auto& dom_node = *position->node();
|
||||
|
||||
update_layout(UpdateLayoutReason::InputCaretRect);
|
||||
|
||||
auto* layout_node = dom_node.layout_node();
|
||||
if (!layout_node)
|
||||
return {};
|
||||
|
||||
// The caret rects computed here are document-relative (absolute). Platform IME overlays are positioned relative to
|
||||
// the viewport — so translate by scroll offset and map through any containing navigables to the top-level viewport.
|
||||
auto to_viewport_rect = [this](CSSPixelRect rect) -> CSSPixelRect {
|
||||
auto navigable = this->navigable();
|
||||
if (!navigable)
|
||||
return rect;
|
||||
auto scroll = navigable->viewport_scroll_offset();
|
||||
CSSPixelRect viewport_rect { rect.x() - scroll.x(), rect.y() - scroll.y(), rect.width(), rect.height() };
|
||||
return navigable->to_top_level_rect(viewport_rect);
|
||||
};
|
||||
|
||||
// Walk up to the nearest PaintableWithLines, which is where text fragments live.
|
||||
Painting::PaintableWithLines const* paintable_with_lines = nullptr;
|
||||
for (auto paintable = layout_node->first_paintable(); paintable; paintable = paintable->parent()) {
|
||||
if (auto const* with_lines = as_if<Painting::PaintableWithLines>(*paintable)) {
|
||||
paintable_with_lines = with_lines;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (paintable_with_lines) {
|
||||
for (auto const& fragment : paintable_with_lines->fragments()) {
|
||||
if (fragment.layout_node().dom_node() != &dom_node)
|
||||
continue;
|
||||
auto const offset = position->offset();
|
||||
if (offset < fragment.dom_start_offset_in_node() || offset > fragment.dom_end_offset_in_node())
|
||||
continue;
|
||||
return to_viewport_rect(fragment.range_rect(Painting::Paintable::SelectionState::StartAndEnd, offset, offset));
|
||||
}
|
||||
}
|
||||
|
||||
// Empty editable elements have no fragments; fall back to the padding-box corner.
|
||||
if (auto* node_with_style = as_if<Layout::NodeWithStyleAndBoxModelMetrics>(*layout_node)) {
|
||||
auto paintable = node_with_style->first_paintable();
|
||||
if (auto const* box = as_if<Painting::PaintableBox>(paintable.ptr())) {
|
||||
auto content_box = box->absolute_padding_box_rect();
|
||||
return to_viewport_rect(CSSPixelRect { content_box.x(), content_box.y(), 1, node_with_style->computed_values().line_height() });
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void Document::reset_cursor_blink_cycle()
|
||||
{
|
||||
m_cursor_blink_state = true;
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ enum class InvalidateLayoutTreeReason {
|
|||
X(InspectAccessibilityTree) \
|
||||
X(InspectDOMTree) \
|
||||
X(InspectDevToolsLayoutData) \
|
||||
X(InputCaretRect) \
|
||||
X(InternalsHitTest) \
|
||||
X(MediaQueryListMatches) \
|
||||
X(NavigableSelectedText) \
|
||||
|
|
@ -979,6 +980,7 @@ public:
|
|||
InputEventsTarget* active_input_events_target(DOM::Node const* for_node = nullptr);
|
||||
GC::Ptr<DOM::Position> cursor_position() const;
|
||||
void set_cursor_position_needs_repaint();
|
||||
Optional<CSSPixelRect> current_caret_rect();
|
||||
|
||||
bool cursor_blink_state() const { return m_cursor_blink_state; }
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
#include <LibWeb/DOM/DocumentLoading.h>
|
||||
#include <LibWeb/DOM/Element.h>
|
||||
#include <LibWeb/DOM/Event.h>
|
||||
#include <LibWeb/DOM/Position.h>
|
||||
#include <LibWeb/DOM/Range.h>
|
||||
#include <LibWeb/DOM/Text.h>
|
||||
#include <LibWeb/Fetch/Fetching/Fetching.h>
|
||||
|
|
@ -327,6 +328,7 @@ void Navigable::visit_edges(Cell::Visitor& visitor)
|
|||
visitor.visit(m_page);
|
||||
visitor.visit(m_parent);
|
||||
visitor.visit(m_active_document);
|
||||
visitor.visit(m_input_method_composition_node);
|
||||
visitor.visit(m_container);
|
||||
m_event_handler.visit_edges(visitor);
|
||||
|
||||
|
|
@ -3353,6 +3355,75 @@ void Navigable::paste(Utf16String const& text)
|
|||
m_event_handler.handle_paste(text);
|
||||
}
|
||||
|
||||
void Navigable::set_marked_text_from_input_method(Utf16String const& text)
|
||||
{
|
||||
// Platform input methods call this on each composition update, with the current marked/preedit text. LibWeb owns
|
||||
// the marked-text range – so each update replaces the previously-marked text. The UI doesn't track the preedit
|
||||
// extent or pass a replacement length. An empty marked string means there's no preedit: so, clear any text marked
|
||||
// thus far, and end the composition — rather than starting or keeping a composition that has no marked text.
|
||||
if (text.is_empty()) {
|
||||
if (m_input_method_composition_node)
|
||||
replace_input_method_marked_text(text);
|
||||
m_input_method_composition_node = nullptr;
|
||||
return;
|
||||
}
|
||||
replace_input_method_marked_text(text);
|
||||
}
|
||||
|
||||
void Navigable::commit_text_from_input_method(Utf16String const& text)
|
||||
{
|
||||
// The input method has committed text and finished the composition. Replace the marked text with the committed
|
||||
// text, then end the composition — so the text becomes ordinary editable content.
|
||||
replace_input_method_marked_text(text);
|
||||
m_input_method_composition_node = nullptr;
|
||||
}
|
||||
|
||||
void Navigable::unmark_text_from_input_method()
|
||||
{
|
||||
// The input method has finished the composition — leaving the current marked text in place. End the composition
|
||||
// without altering the content.
|
||||
m_input_method_composition_node = nullptr;
|
||||
}
|
||||
|
||||
void Navigable::replace_input_method_marked_text(Utf16String const& text)
|
||||
{
|
||||
// Insert text from a platform input method into the currently-focused editable, via the same input-events target
|
||||
// that keyboard typing uses — so observers see the correct InputEvent.inputType.
|
||||
auto document = active_document();
|
||||
if (!document || !document->is_fully_active()) {
|
||||
m_input_method_composition_node = nullptr;
|
||||
return;
|
||||
}
|
||||
auto* target = document->active_input_events_target();
|
||||
if (!target) {
|
||||
m_input_method_composition_node = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop a stale composition start (for example, if the editable content was replaced out from under us).
|
||||
if (m_input_method_composition_node && !m_input_method_composition_node->is_connected())
|
||||
m_input_method_composition_node = nullptr;
|
||||
|
||||
// The caret is the end of the marked text. Read it while the selection is still collapsed. Forming the marked-text
|
||||
// selection below would otherwise make cursor_position() return null for form controls.
|
||||
auto caret = document->cursor_position();
|
||||
if (!caret)
|
||||
return;
|
||||
|
||||
if (m_input_method_composition_node) {
|
||||
// A composition is already in progress. Select the existing marked text [composition start, caret] — so that
|
||||
// the insertion below replaces it.
|
||||
target->set_selection_anchor(*m_input_method_composition_node, m_input_method_composition_offset);
|
||||
target->set_selection_focus(caret->node(), caret->offset());
|
||||
} else {
|
||||
// Begin a new composition at the caret. The marked text spans from here to the caret as it is updated.
|
||||
m_input_method_composition_node = caret->node();
|
||||
m_input_method_composition_offset = caret->offset();
|
||||
}
|
||||
|
||||
target->handle_insert(UIEvents::InputTypes::insertText, text);
|
||||
}
|
||||
|
||||
// https://drafts.csswg.org/css-view-transitions-1/#snapshot-containing-block
|
||||
CSSPixelRect Navigable::snapshot_containing_block()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -220,6 +220,9 @@ public:
|
|||
String cut_selected_text() const;
|
||||
void select_all();
|
||||
void paste(Utf16String const&);
|
||||
void set_marked_text_from_input_method(Utf16String const& text);
|
||||
void commit_text_from_input_method(Utf16String const& text);
|
||||
void unmark_text_from_input_method();
|
||||
|
||||
Web::EventHandler& event_handler() { return m_event_handler; }
|
||||
Web::EventHandler const& event_handler() const { return m_event_handler; }
|
||||
|
|
@ -322,6 +325,13 @@ private:
|
|||
// This is the authoritative source for active_document().
|
||||
GC::Ptr<DOM::Document> m_active_document;
|
||||
|
||||
// AD-HOC: Active IME composition state. While a composition is in progress, m_input_method_composition_node and
|
||||
// m_input_method_composition_offset record the start of the marked (preedit) text; the marked text spans
|
||||
// from there to the caret. A null node means no composition is in progress.
|
||||
void replace_input_method_marked_text(Utf16String const& text);
|
||||
GC::Ptr<DOM::Node> m_input_method_composition_node;
|
||||
size_t m_input_method_composition_offset { 0 };
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/document-sequences.html#is-closing
|
||||
bool m_closing { false };
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
#include <LibWeb/DOMURL/DOMURL.h>
|
||||
#include <LibWeb/Dump.h>
|
||||
#include <LibWeb/Fetch/Fetching/Fetching.h>
|
||||
#include <LibWeb/Geometry/DOMRect.h>
|
||||
#include <LibWeb/HTML/BrowsingContext.h>
|
||||
#include <LibWeb/HTML/EventLoop/EventLoop.h>
|
||||
#include <LibWeb/HTML/EventLoop/TaskQueue.h>
|
||||
|
|
@ -418,6 +419,30 @@ String Internals::selected_text_for_clipboard()
|
|||
return page().focused_navigable().selected_text();
|
||||
}
|
||||
|
||||
void Internals::set_marked_text_from_input_method(Utf16String const& text)
|
||||
{
|
||||
page().focused_navigable().set_marked_text_from_input_method(text);
|
||||
}
|
||||
|
||||
void Internals::commit_text_from_input_method(Utf16String const& text)
|
||||
{
|
||||
page().focused_navigable().commit_text_from_input_method(text);
|
||||
}
|
||||
|
||||
void Internals::unmark_text_from_input_method()
|
||||
{
|
||||
page().focused_navigable().unmark_text_from_input_method();
|
||||
}
|
||||
|
||||
GC::Ptr<Geometry::DOMRect> Internals::current_caret_rect()
|
||||
{
|
||||
auto& active_document = window().associated_document();
|
||||
auto rect = active_document.current_caret_rect();
|
||||
if (!rect.has_value())
|
||||
return nullptr;
|
||||
return MUST(Geometry::DOMRect::construct_impl(realm(), static_cast<double>(rect->x()), static_cast<double>(rect->y()), static_cast<double>(rect->width()), static_cast<double>(rect->height())));
|
||||
}
|
||||
|
||||
WebIDL::ExceptionOr<bool> Internals::dispatch_user_activated_event(DOM::EventTarget& target, DOM::Event& event)
|
||||
{
|
||||
event.set_is_trusted(true);
|
||||
|
|
|
|||
|
|
@ -7,17 +7,12 @@
|
|||
#pragma once
|
||||
|
||||
#include <LibWeb/Export.h>
|
||||
#include <LibWeb/Forward.h>
|
||||
#include <LibWeb/Internals/InternalAnimationTimeline.h>
|
||||
#include <LibWeb/Internals/InternalsBase.h>
|
||||
#include <LibWeb/UIEvents/MouseButton.h>
|
||||
#include <LibWeb/WebIDL/Types.h>
|
||||
|
||||
namespace Web::CSS {
|
||||
|
||||
class CSSStyleSheet;
|
||||
|
||||
}
|
||||
|
||||
namespace Web::Internals {
|
||||
|
||||
class WEB_API Internals final : public InternalsBase {
|
||||
|
|
@ -72,6 +67,11 @@ public:
|
|||
|
||||
String selected_text_for_clipboard();
|
||||
|
||||
void set_marked_text_from_input_method(Utf16String const& text);
|
||||
void commit_text_from_input_method(Utf16String const& text);
|
||||
void unmark_text_from_input_method();
|
||||
GC::Ptr<Geometry::DOMRect> current_caret_rect();
|
||||
|
||||
WebIDL::ExceptionOr<bool> dispatch_user_activated_event(DOM::EventTarget&, DOM::Event& event);
|
||||
|
||||
void spoof_current_url(String const& url);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,18 @@ interface Internals {
|
|||
// Excludes nodes whose used value of user-select is 'none' — mirroring what browsers actually copy.
|
||||
DOMString selectedTextForClipboard();
|
||||
|
||||
// Drive marked/preedit-text composition via the same path platform input methods use. setMarkedTextFromInputMethod
|
||||
// updates the inline preedit — replacing any previous preedit. commitText finalizes the composition with the given
|
||||
// text. unmarkText finalizes the composition — leaving the current preedit in place. Each fires an InputEvent with
|
||||
// inputType = "insertText" on the focused editable. All are no-ops if nothing editable is focused.
|
||||
undefined setMarkedTextFromInputMethod(Utf16DOMString text);
|
||||
undefined commitTextFromInputMethod(Utf16DOMString text);
|
||||
undefined unmarkTextFromInputMethod();
|
||||
|
||||
// Returns the bounds of the current text caret in viewport-relative CSS pixels — or null if no editable element is
|
||||
// focused. Used for verifying the data the platform UI feeds into IME overlay positioning.
|
||||
DOMRect? currentCaretRect();
|
||||
|
||||
boolean dispatchUserActivatedEvent(EventTarget target, Event event);
|
||||
undefined spoofCurrentURL(USVString url);
|
||||
|
||||
|
|
|
|||
|
|
@ -791,6 +791,34 @@ void ViewImplementation::paste_text_from_clipboard()
|
|||
client().async_paste(page_id(), Application::the().clipboard_text());
|
||||
}
|
||||
|
||||
void ViewImplementation::set_marked_text_from_input_method(Utf16String const& text)
|
||||
{
|
||||
client().async_set_marked_text_from_input_method(page_id(), text);
|
||||
}
|
||||
|
||||
void ViewImplementation::commit_text_from_input_method(Utf16String const& text)
|
||||
{
|
||||
client().async_commit_text_from_input_method(page_id(), text);
|
||||
}
|
||||
|
||||
void ViewImplementation::unmark_text_from_input_method()
|
||||
{
|
||||
client().async_unmark_text_from_input_method(page_id());
|
||||
}
|
||||
|
||||
Optional<Web::DevicePixelRect> ViewImplementation::get_input_caret_rect()
|
||||
{
|
||||
// Returns the most-recent caret position pushed by WebContent (see set_input_caret_rect). Deliberately makes no
|
||||
// synchronous IPC request: This is read from inside AppKit text-input callbacks — where blocking can re-enter the
|
||||
// run loop and deadlock the input method.
|
||||
return m_input_caret_rect;
|
||||
}
|
||||
|
||||
void ViewImplementation::set_input_caret_rect(Badge<WebContentClient>, Optional<Web::DevicePixelRect> rect)
|
||||
{
|
||||
m_input_caret_rect = rect;
|
||||
}
|
||||
|
||||
void ViewImplementation::retrieved_clipboard_entries(u64 request_id, ReadonlySpan<Web::Clipboard::SystemClipboardItem> items)
|
||||
{
|
||||
client().async_retrieved_clipboard_entries(page_id(), request_id, items);
|
||||
|
|
|
|||
|
|
@ -183,6 +183,14 @@ public:
|
|||
void paste_text_from_clipboard();
|
||||
void retrieved_clipboard_entries(u64 request_id, ReadonlySpan<Web::Clipboard::SystemClipboardItem>);
|
||||
|
||||
// Used by platform input methods to drive marked/preedit-text composition, and to query the on-screen caret
|
||||
// position for placing IME overlays.
|
||||
void set_marked_text_from_input_method(Utf16String const& text);
|
||||
void commit_text_from_input_method(Utf16String const& text);
|
||||
void unmark_text_from_input_method();
|
||||
Optional<Web::DevicePixelRect> get_input_caret_rect();
|
||||
void set_input_caret_rect(Badge<WebContentClient>, Optional<Web::DevicePixelRect>);
|
||||
|
||||
Web::HTML::MuteState page_mute_state() const { return m_mute_state; }
|
||||
void toggle_page_mute_state();
|
||||
|
||||
|
|
@ -444,6 +452,9 @@ protected:
|
|||
|
||||
Web::HTML::MuteState m_mute_state { Web::HTML::MuteState::Unmuted };
|
||||
|
||||
// Most recent caret position pushed by WebContent, Used for placing platform IME overlays without a sync IPC.
|
||||
Optional<Web::DevicePixelRect> m_input_caret_rect;
|
||||
|
||||
Web::ViewportIsFullscreen m_is_fullscreen { Web::ViewportIsFullscreen::No };
|
||||
|
||||
Core::AnonymousBuffer m_document_cookie_version_buffer;
|
||||
|
|
|
|||
|
|
@ -1142,6 +1142,12 @@ void WebContentClient::did_finish_handling_input_event(u64 page_id, Web::EventRe
|
|||
view->did_finish_handling_input_event({}, event_result);
|
||||
}
|
||||
|
||||
void WebContentClient::did_update_input_caret_rect(u64 page_id, Optional<Web::DevicePixelRect> rect)
|
||||
{
|
||||
if (auto view = view_for_page_id(page_id); view.has_value())
|
||||
view->set_input_caret_rect({}, rect);
|
||||
}
|
||||
|
||||
void WebContentClient::did_change_theme_color(u64 page_id, Gfx::Color color)
|
||||
{
|
||||
if (auto view = view_for_page_id(page_id); view.has_value()) {
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ private:
|
|||
virtual void did_request_file_picker(u64 page_id, Web::HTML::FileFilter accepted_file_types, Web::HTML::AllowMultipleFiles) override;
|
||||
virtual void did_request_select_dropdown(u64 page_id, Gfx::IntPoint content_position, i32 minimum_width, Vector<Web::HTML::SelectItem> items) override;
|
||||
virtual void did_finish_handling_input_event(u64 page_id, Web::EventResult event_result) override;
|
||||
virtual void did_update_input_caret_rect(u64 page_id, Optional<Web::DevicePixelRect> rect) override;
|
||||
virtual void did_finish_test(u64 page_id, String text) override;
|
||||
virtual void did_set_test_timeout(u64 page_id, double milliseconds) override;
|
||||
virtual void did_receive_reference_test_metadata(u64 page_id, JsonValue) override;
|
||||
|
|
|
|||
|
|
@ -1683,6 +1683,43 @@ void ConnectionFromClient::paste(u64 page_id, Utf16String text)
|
|||
page->page().focused_navigable().paste(text);
|
||||
}
|
||||
|
||||
void ConnectionFromClient::set_marked_text_from_input_method(u64 page_id, Utf16String text)
|
||||
{
|
||||
if (auto page = this->page(page_id); page.has_value())
|
||||
page->page().focused_navigable().set_marked_text_from_input_method(text);
|
||||
update_input_method_caret_rect(page_id);
|
||||
}
|
||||
|
||||
void ConnectionFromClient::commit_text_from_input_method(u64 page_id, Utf16String text)
|
||||
{
|
||||
if (auto page = this->page(page_id); page.has_value())
|
||||
page->page().focused_navigable().commit_text_from_input_method(text);
|
||||
update_input_method_caret_rect(page_id);
|
||||
}
|
||||
|
||||
void ConnectionFromClient::unmark_text_from_input_method(u64 page_id)
|
||||
{
|
||||
if (auto page = this->page(page_id); page.has_value())
|
||||
page->page().focused_navigable().unmark_text_from_input_method();
|
||||
}
|
||||
|
||||
void ConnectionFromClient::update_input_method_caret_rect(u64 page_id)
|
||||
{
|
||||
auto page = this->page(page_id);
|
||||
if (!page.has_value())
|
||||
return;
|
||||
|
||||
// Push the updated caret position to the UI — so platform input methods can place their overlays. We deliberately
|
||||
// push this asynchronously — rather than answering a synchronous request from inside an AppKit text-input callback.
|
||||
// Blocking there re-enters the run loop — and can deadlock input-method server <-> UI <-> WebContent message flow.
|
||||
Optional<Web::DevicePixelRect> caret_rect;
|
||||
if (auto document = page->page().focused_navigable().active_document()) {
|
||||
if (auto rect = document->current_caret_rect(); rect.has_value())
|
||||
caret_rect = page->page().enclosing_device_rect(*rect);
|
||||
}
|
||||
async_did_update_input_caret_rect(page_id, caret_rect);
|
||||
}
|
||||
|
||||
void ConnectionFromClient::set_content_blockers(u64 page_id, Core::AnonymousBuffer patterns_buffer)
|
||||
{
|
||||
auto& blocker = Web::ContentBlocker::the();
|
||||
|
|
|
|||
|
|
@ -182,6 +182,10 @@ private:
|
|||
virtual void find_in_page_previous_match(u64 page_id) override;
|
||||
|
||||
virtual void paste(u64 page_id, Utf16String text) override;
|
||||
virtual void set_marked_text_from_input_method(u64 page_id, Utf16String text) override;
|
||||
virtual void commit_text_from_input_method(u64 page_id, Utf16String text) override;
|
||||
virtual void unmark_text_from_input_method(u64 page_id) override;
|
||||
void update_input_method_caret_rect(u64 page_id);
|
||||
|
||||
virtual void system_time_zone_changed() override;
|
||||
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ endpoint WebContentClient
|
|||
did_request_file_picker(u64 page_id, Web::HTML::FileFilter accepted_file_types, Web::HTML::AllowMultipleFiles allow_multiple_files) =|
|
||||
did_request_select_dropdown(u64 page_id, Gfx::IntPoint content_position, i32 minimum_width, Vector<Web::HTML::SelectItem> items) =|
|
||||
did_finish_handling_input_event(u64 page_id, Web::EventResult event_result) =|
|
||||
did_update_input_caret_rect(u64 page_id, Optional<Web::DevicePixelRect> rect) =|
|
||||
did_change_theme_color(u64 page_id, Gfx::Color color) =|
|
||||
did_change_background_color(u64 page_id, Gfx::Color color) =|
|
||||
|
||||
|
|
|
|||
|
|
@ -103,6 +103,9 @@ endpoint WebContentServer
|
|||
cut_selected_text(u64 page_id) => (ByteString selection)
|
||||
select_all(u64 page_id) =|
|
||||
paste(u64 page_id, Utf16String text) =|
|
||||
set_marked_text_from_input_method(u64 page_id, Utf16String text) =|
|
||||
commit_text_from_input_method(u64 page_id, Utf16String text) =|
|
||||
unmark_text_from_input_method(u64 page_id) =|
|
||||
|
||||
find_in_page(u64 page_id, String query, AK::CaseSensitivity case_sensitivity) =|
|
||||
find_in_page_next_match(u64 page_id) =|
|
||||
|
|
|
|||
22
Tests/LibWeb/Text/expected/input-method-insert-text.txt
Normal file
22
Tests/LibWeb/Text/expected/input-method-insert-text.txt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
input.value after IME commit: "abcdef"
|
||||
input event on <input>: {"inputType":"insertText","data":"def"}
|
||||
textarea.value after IME commit: "xyzyyy"
|
||||
input event on <textarea>: {"inputType":"insertText","data":"yyy"}
|
||||
editable.textContent after IME commit: "qwerty"
|
||||
input event on contenteditable: {"inputType":"insertText","data":"rty"}
|
||||
unfocused commit threw: false
|
||||
readonly value unchanged: true
|
||||
focused-input caret rect non-null: true
|
||||
focused-input caret rect has positive height: true
|
||||
unfocused caret rect: null
|
||||
empty-input caret rect non-null: true
|
||||
empty-input caret rect has positive height: true
|
||||
after first preedit keystroke: "n"
|
||||
after extending preedit: "ni"
|
||||
after candidate replaces preedit: "你"
|
||||
after commit: "你"
|
||||
after second composition commit: "你好"
|
||||
after unmark keeps preedit: "你好?"
|
||||
after abort clears preedit: "你好?"
|
||||
after abort, fresh preedit at moved caret: "你z好?"
|
||||
caret rect is viewport-relative (moves with scroll): true
|
||||
141
Tests/LibWeb/Text/input/input-method-insert-text.html
Normal file
141
Tests/LibWeb/Text/input/input-method-insert-text.html
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<!DOCTYPE html>
|
||||
<script src="include.js"></script>
|
||||
<input id="input" value="abc">
|
||||
<textarea id="textarea">xyz</textarea>
|
||||
<div id="editable" contenteditable="true">qwe</div>
|
||||
<input id="readonly_input" value="ro" readonly>
|
||||
<input id="preedit_input" value="">
|
||||
<script>
|
||||
asyncTest(async done => {
|
||||
// Spec for <input>/<textarea> fires 'input' via a queued element task. Wait for it.
|
||||
const waitForEvent = (target, type) => new Promise(resolve => {
|
||||
target.addEventListener(type, e => resolve({ inputType: e.inputType, data: e.data }), { once: true });
|
||||
});
|
||||
|
||||
// <input>: committed IME text is inserted via commit and eventually fires an input event.
|
||||
const input = document.getElementById("input");
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
let event_promise = waitForEvent(input, "input");
|
||||
internals.commitTextFromInputMethod("def");
|
||||
let event = await event_promise;
|
||||
println(`input.value after IME commit: ${JSON.stringify(input.value)}`);
|
||||
println(`input event on <input>: ${JSON.stringify(event)}`);
|
||||
|
||||
// <textarea>: same path.
|
||||
const textarea = document.getElementById("textarea");
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
event_promise = waitForEvent(textarea, "input");
|
||||
internals.commitTextFromInputMethod("yyy");
|
||||
event = await event_promise;
|
||||
println(`textarea.value after IME commit: ${JSON.stringify(textarea.value)}`);
|
||||
println(`input event on <textarea>: ${JSON.stringify(event)}`);
|
||||
|
||||
// contenteditable: fires input synchronously through the editing pipeline.
|
||||
const editable = document.getElementById("editable");
|
||||
editable.focus();
|
||||
getSelection().selectAllChildren(editable);
|
||||
getSelection().collapseToEnd();
|
||||
const collected_editable_events = [];
|
||||
editable.addEventListener("input", e => collected_editable_events.push({ inputType: e.inputType, data: e.data }), { once: true });
|
||||
internals.commitTextFromInputMethod("rty");
|
||||
println(`editable.textContent after IME commit: ${JSON.stringify(editable.textContent)}`);
|
||||
println(`input event on contenteditable: ${JSON.stringify(collected_editable_events[0])}`);
|
||||
|
||||
// No focus on any editable: must be a no-op (no exception, no event).
|
||||
document.activeElement?.blur();
|
||||
let no_focus_threw = false;
|
||||
try {
|
||||
internals.commitTextFromInputMethod("ignored");
|
||||
} catch (e) {
|
||||
no_focus_threw = true;
|
||||
}
|
||||
println(`unfocused commit threw: ${no_focus_threw}`);
|
||||
|
||||
// <input readonly>: not editable, IME commit must not change the value.
|
||||
const ro = document.getElementById("readonly_input");
|
||||
ro.focus();
|
||||
internals.commitTextFromInputMethod("nope");
|
||||
println(`readonly value unchanged: ${ro.value === "ro"}`);
|
||||
|
||||
// currentCaretRect returns a sensible rect on a focused editable, null otherwise.
|
||||
input.focus();
|
||||
input.setSelectionRange(0, 0);
|
||||
const rect = internals.currentCaretRect();
|
||||
println(`focused-input caret rect non-null: ${rect !== null}`);
|
||||
println(`focused-input caret rect has positive height: ${rect && rect.height > 0}`);
|
||||
|
||||
document.activeElement?.blur();
|
||||
const no_focus_rect = internals.currentCaretRect();
|
||||
println(`unfocused caret rect: ${no_focus_rect === null ? "null" : "non-null"}`);
|
||||
|
||||
// Empty editable: the caret rect must still be sensible.
|
||||
const empty = document.getElementById("preedit_input"); // value is ""
|
||||
empty.focus();
|
||||
empty.setSelectionRange(0, 0);
|
||||
const empty_rect = internals.currentCaretRect();
|
||||
println(`empty-input caret rect non-null: ${empty_rect !== null}`);
|
||||
println(`empty-input caret rect has positive height: ${empty_rect && empty_rect.height > 0}`);
|
||||
|
||||
// Marked-text (preedit) composition. LibWeb owns the marked-text range: each setMarkedText replaces the
|
||||
// previous preedit, commitText finalizes it, and unmarkText finalizes leaving the preedit in place.
|
||||
const preedit = document.getElementById("preedit_input");
|
||||
preedit.value = "";
|
||||
preedit.focus();
|
||||
preedit.setSelectionRange(0, 0);
|
||||
|
||||
// Build a preedit one character at a time; each update replaces the previous marked text.
|
||||
internals.setMarkedTextFromInputMethod("n");
|
||||
println(`after first preedit keystroke: ${JSON.stringify(preedit.value)}`);
|
||||
internals.setMarkedTextFromInputMethod("ni");
|
||||
println(`after extending preedit: ${JSON.stringify(preedit.value)}`);
|
||||
internals.setMarkedTextFromInputMethod("你");
|
||||
println(`after candidate replaces preedit: ${JSON.stringify(preedit.value)}`);
|
||||
|
||||
// Commit the candidate; the composition ends.
|
||||
internals.commitTextFromInputMethod("你");
|
||||
println(`after commit: ${JSON.stringify(preedit.value)}`);
|
||||
|
||||
// A fresh composition appends after the committed text.
|
||||
internals.setMarkedTextFromInputMethod("h");
|
||||
internals.setMarkedTextFromInputMethod("ha");
|
||||
internals.commitTextFromInputMethod("好");
|
||||
println(`after second composition commit: ${JSON.stringify(preedit.value)}`);
|
||||
|
||||
// unmarkText finalizes the current preedit in place (keeps it).
|
||||
internals.setMarkedTextFromInputMethod("?");
|
||||
internals.unmarkTextFromInputMethod();
|
||||
println(`after unmark keeps preedit: ${JSON.stringify(preedit.value)}`);
|
||||
|
||||
// Aborting a composition: the IME clears the preedit with an empty marked text.
|
||||
internals.setMarkedTextFromInputMethod("xx");
|
||||
internals.setMarkedTextFromInputMethod("");
|
||||
println(`after abort clears preedit: ${JSON.stringify(preedit.value)}`);
|
||||
|
||||
// After an abort, a fresh composition must start at the current caret — not reuse the old marked-text start.
|
||||
preedit.setSelectionRange(1, 1);
|
||||
internals.setMarkedTextFromInputMethod("z");
|
||||
println(`after abort, fresh preedit at moved caret: ${JSON.stringify(preedit.value)}`);
|
||||
|
||||
// #5: currentCaretRect is viewport-relative, so scrolling the page moves the caret rect by the scroll delta.
|
||||
const tall = document.createElement("div");
|
||||
tall.style.height = "3000px";
|
||||
document.body.appendChild(tall);
|
||||
const scrolled = document.createElement("input");
|
||||
scrolled.value = "scroll";
|
||||
document.body.appendChild(scrolled);
|
||||
scrolled.focus();
|
||||
scrolled.setSelectionRange(0, 0);
|
||||
window.scrollTo(0, 0);
|
||||
const caret_at_0 = internals.currentCaretRect();
|
||||
window.scrollTo(0, 400);
|
||||
const scroll_y = window.scrollY;
|
||||
const caret_scrolled = internals.currentCaretRect();
|
||||
window.scrollTo(0, 0);
|
||||
const moved = (caret_at_0 && caret_scrolled) ? caret_at_0.y - caret_scrolled.y : null;
|
||||
println(`caret rect is viewport-relative (moves with scroll): ${moved !== null && scroll_y > 0 && Math.abs(moved - scroll_y) < 1}`);
|
||||
|
||||
done();
|
||||
});
|
||||
</script>
|
||||
|
|
@ -120,6 +120,10 @@ static Web::DevicePixelPoint node_picker_position_for(Ladybird::WebViewBridge co
|
|||
// To handle key events after dead key processing, we need to hold onto the originating key-down event.
|
||||
@property (nonatomic, strong) NSEvent* current_key_down_event;
|
||||
|
||||
// Length of the marked text (input-method preedit) currently shown in the focused editable. LibWeb owns the marked-text
|
||||
// range and replaces the preedit itself. The UI keeps this length only to answer the NSTextInputClient range queries.
|
||||
@property (nonatomic, assign) NSUInteger marked_text_length;
|
||||
|
||||
@end
|
||||
|
||||
@implementation LadybirdWebView
|
||||
|
|
@ -135,6 +139,11 @@ static Web::DevicePixelPoint node_picker_position_for(Ladybird::WebViewBridge co
|
|||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
- (instancetype)initAsChild:(id<LadybirdWebViewObserver>)observer
|
||||
parent:(LadybirdWebView*)parent
|
||||
pageIndex:(u64)page_index
|
||||
|
|
@ -174,6 +183,11 @@ static Web::DevicePixelPoint node_picker_position_for(Ladybird::WebViewBridge co
|
|||
m_web_view_bridge = MUST(Ladybird::WebViewBridge::create(move(screen_rects), device_pixel_ratio, maximum_frames_per_second, display_id));
|
||||
[self setWebViewCallbacks];
|
||||
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(inputSourceDidChange:)
|
||||
name:NSTextInputContextKeyboardSelectionDidChangeNotification
|
||||
object:nil];
|
||||
|
||||
self.page_context_menu = Ladybird::create_context_menu(self, [self view].page_context_menu());
|
||||
self.link_context_menu = Ladybird::create_context_menu(self, [self view].link_context_menu());
|
||||
self.image_context_menu = Ladybird::create_context_menu(self, [self view].image_context_menu());
|
||||
|
|
@ -1396,6 +1410,23 @@ static Web::DevicePixelPoint node_picker_position_for(Ladybird::WebViewBridge co
|
|||
|
||||
- (void)insertText:(id)string replacementRange:(NSRange)replacementRange
|
||||
{
|
||||
// macOS calls this for both regular typing (the typed character routed through interpretKeyEvents:) and for CJK
|
||||
// input methods committing a composition.
|
||||
//
|
||||
// For regular typing, the original NSEvent in current_key_down_event has the same character. Forward the NSEvent —
|
||||
// so JS sees keydown/keypress/input events (the existing path). For IME commits, the committed text differs from
|
||||
// the trigger key (or there is no current event) — so, route the committed text directly into the focused element.
|
||||
NSString* committed = [string isKindOfClass:[NSAttributedString class]] ? [(NSAttributedString*)string string] : (NSString*)string;
|
||||
NSEvent* event = self.current_key_down_event;
|
||||
bool matches_current_key_event = event && committed.length > 0 && [committed isEqualToString:event.characters];
|
||||
bool has_marked_text = self.marked_text_length > 0;
|
||||
if ((!matches_current_key_event && committed.length > 0) || has_marked_text) {
|
||||
auto utf8 = ByteString { committed.length > 0 ? [committed UTF8String] : "" };
|
||||
m_web_view_bridge->commit_text_from_input_method(Utf16String::from_utf8(utf8));
|
||||
self.marked_text_length = 0;
|
||||
self.current_key_down_event = nil;
|
||||
return;
|
||||
}
|
||||
[self handleCurrentKeyDownEvent:YES];
|
||||
}
|
||||
|
||||
|
|
@ -1406,25 +1437,49 @@ static Web::DevicePixelPoint node_picker_position_for(Ladybird::WebViewBridge co
|
|||
|
||||
- (BOOL)hasMarkedText
|
||||
{
|
||||
return NO;
|
||||
return self.marked_text_length > 0;
|
||||
}
|
||||
|
||||
- (NSRange)markedRange
|
||||
{
|
||||
return NSMakeRange(NSNotFound, 0);
|
||||
return self.marked_text_length > 0 ? NSMakeRange(0, self.marked_text_length) : NSMakeRange(NSNotFound, 0);
|
||||
}
|
||||
|
||||
- (NSRange)selectedRange
|
||||
{
|
||||
return NSMakeRange(NSNotFound, 0);
|
||||
// We present the input method with a virtual text model whose marked text occupies [0, marked_text_length) with
|
||||
// the caret at its end; keep selectedRange consistent with markedRange, so the IME's range bookkeeping is coherent.
|
||||
return NSMakeRange(self.marked_text_length, 0);
|
||||
}
|
||||
|
||||
- (void)setMarkedText:(id)string selectedRange:(NSRange)selectedRange replacementRange:(NSRange)replacementRange
|
||||
{
|
||||
// Called by the input method as the user types each composing character. We present inline preedit by inserting the
|
||||
// marked text into the focused editable. LibWeb owns the marked-text range and replaces the previous preedit on
|
||||
// each subsequent setMarkedText, or on commit (insertText) or abort (unmarkText). We keep marked_text_length only
|
||||
// to answer the NSTextInputClient range queries (markedRange/selectedRange/hasMarkedText).
|
||||
NSString* preedit = [string isKindOfClass:[NSAttributedString class]] ? [(NSAttributedString*)string string] : (NSString*)string;
|
||||
auto utf8 = ByteString { preedit.length > 0 ? [preedit UTF8String] : "" };
|
||||
m_web_view_bridge->set_marked_text_from_input_method(Utf16String::from_utf8(utf8));
|
||||
self.marked_text_length = preedit.length;
|
||||
}
|
||||
|
||||
- (void)unmarkText
|
||||
{
|
||||
// Per the NSTextInputClient contract, unmarkText finalizes (commits) the marked text in place. Tell LibWeb to end
|
||||
// the composition — keeping the inserted text, and forgetting our marked-text length.
|
||||
m_web_view_bridge->unmark_text_from_input_method();
|
||||
self.marked_text_length = 0;
|
||||
}
|
||||
|
||||
- (void)inputSourceDidChange:(NSNotification*)notification
|
||||
{
|
||||
// When the keyboard input source changes mid-composition, the input method may not send a commit/abort callback for
|
||||
// our pending preedit. Finalize it in place: end the composition in LibWeb (keeping the text), and forget the
|
||||
// marked-text length — so a later composition doesn't replace the wrong characters.
|
||||
(void)notification;
|
||||
m_web_view_bridge->unmark_text_from_input_method();
|
||||
self.marked_text_length = 0;
|
||||
}
|
||||
|
||||
- (NSArray<NSAttributedStringKey>*)validAttributesForMarkedText
|
||||
|
|
@ -1444,7 +1499,22 @@ static Web::DevicePixelPoint node_picker_position_for(Ladybird::WebViewBridge co
|
|||
|
||||
- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actualRange
|
||||
{
|
||||
return NSZeroRect;
|
||||
// Tells macOS where to anchor IME overlays (candidate window, accent menu, etc.). Otherwise, without this, the
|
||||
// overlays appear in the bottom-left corner of the screen — since NSZeroRect anchors at screen origin.
|
||||
auto caret_rect = m_web_view_bridge->get_input_caret_rect();
|
||||
if (!caret_rect.has_value())
|
||||
return NSZeroRect;
|
||||
|
||||
auto dpr = m_web_view_bridge->device_pixel_ratio();
|
||||
NSRect view_rect = NSMakeRect(
|
||||
caret_rect->x().value() / dpr,
|
||||
caret_rect->y().value() / dpr,
|
||||
caret_rect->width().value() / dpr,
|
||||
caret_rect->height().value() / dpr);
|
||||
|
||||
// Convert: view coords (flipped, top-left origin) → window coords → screen coords.
|
||||
NSRect window_rect = [self convertRect:view_rect toView:nil];
|
||||
return [[self window] convertRectToScreen:window_rect];
|
||||
}
|
||||
|
||||
#pragma mark - NSDraggingDestination
|
||||
|
|
|
|||
Loading…
Reference in a new issue