diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp index 2f7ecd39..d40d9cfa 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp @@ -2637,6 +2637,46 @@ void webscene_download_request_release_v1( const webscene_download_request_v1* request) { delete reinterpret_cast(request); } +const webscene_outbound_drag_request_v1* +webscene_engine_take_outbound_drag_request_v1(webscene_engine* engine) { + if (!engine) return nullptr; + auto request = engine->take_outbound_drag_request(); + if (!request) return nullptr; + request->bind(); + return &request.release()->view; +} +void webscene_outbound_drag_request_release_v1( + const webscene_outbound_drag_request_v1* request) { + delete reinterpret_cast< + const webscene_native::native_outbound_drag_request*>(request); +} +uint8_t webscene_engine_complete_outbound_drag_v1( + webscene_engine* engine, + const webscene_outbound_drag_completion_v1* completion) { + constexpr uint32_t allowed_operations = + WEBSCENE_OUTBOUND_DRAG_OPERATION_COPY_V1 + | WEBSCENE_OUTBOUND_DRAG_OPERATION_LINK_V1 + | WEBSCENE_OUTBOUND_DRAG_OPERATION_MOVE_V1; + constexpr uint32_t allowed_modifiers = + WEBSCENE_INPUT_POINTER_MODIFIER_SHIFT + | WEBSCENE_INPUT_POINTER_MODIFIER_CONTROL + | WEBSCENE_INPUT_POINTER_MODIFIER_ALT + | WEBSCENE_INPUT_POINTER_MODIFIER_META; + if (engine == nullptr || completion == nullptr + || completion->struct_size < sizeof(*completion) + || completion->version != 1U || completion->request_id == 0U + || completion->document_generation == 0U + || completion->status < WEBSCENE_OUTBOUND_DRAG_COMPLETED_V1 + || completion->status > WEBSCENE_OUTBOUND_DRAG_FAILED_V1 + || (completion->selected_operation & ~allowed_operations) != 0U + || (completion->modifiers & ~allowed_modifiers) != 0U + || !std::isfinite(completion->x) || !std::isfinite(completion->y) + || (completion->status != WEBSCENE_OUTBOUND_DRAG_COMPLETED_V1 + && completion->selected_operation != 0U)) return 0U; + webscene_native::native_outbound_drag_completion owned; + owned.value = *completion; + return engine->complete_outbound_drag(std::move(owned)) ? 1U : 0U; +} uint8_t webscene_engine_complete_file_request_v1(webscene_engine* engine, uint64_t id, uint32_t status, const webscene_file_data_v1* files, size_t count, const char* error) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index 087801f3..d5dbbaa3 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -1870,6 +1870,73 @@ webscene_engine_take_download_request_v1(webscene_engine* engine); WEBSCENE_API void webscene_download_request_release_v1( const webscene_download_request_v1* request); +/* Immutable desktop-drag handoff. WebScene publishes only browser-admitted + * metadata; native objects, paths, destination policy, and grant resolution + * remain host-owned. A lease remains valid until release, including after + * engine teardown. Completion is copied and delivered on the script worker. + * Limits: 8 pending requests, 32 items, 1 MiB aggregate UTF-8 metadata, + * 8 KiB per URI, 256-byte MIME types, and 1 KiB opaque grant references. */ +enum { + WEBSCENE_OUTBOUND_DRAG_OPERATION_COPY_V1 = 1U << 0U, + WEBSCENE_OUTBOUND_DRAG_OPERATION_LINK_V1 = 1U << 1U, + WEBSCENE_OUTBOUND_DRAG_OPERATION_MOVE_V1 = 1U << 2U, + WEBSCENE_OUTBOUND_DRAG_ITEM_TEXT_V1 = 1, + WEBSCENE_OUTBOUND_DRAG_ITEM_URI_V1 = 2, + WEBSCENE_OUTBOUND_DRAG_ITEM_IMAGE_V1 = 3, + WEBSCENE_OUTBOUND_DRAG_ITEM_FILE_GRANT_V1 = 4, + WEBSCENE_OUTBOUND_DRAG_COMPLETED_V1 = 1, + WEBSCENE_OUTBOUND_DRAG_CANCELLED_V1 = 2, + WEBSCENE_OUTBOUND_DRAG_FAILED_V1 = 3 +}; +typedef struct webscene_outbound_drag_item_v1 { + uint32_t struct_size, version; + uint32_t kind, reserved; + const char* mime_type; + size_t mime_type_length; + const char* value; + size_t value_length; + const char* name; + size_t name_length; + const char* grant_reference; + size_t grant_reference_length; + uint64_t byte_size; +} webscene_outbound_drag_item_v1; +typedef struct webscene_outbound_drag_request_v1 { + uint32_t struct_size, version; + uint64_t request_id; + uint64_t document_generation; + uint64_t node_generation; + uint64_t frame_generation; + uint64_t source_node_id; + uint64_t frame_owner_node_id; + uint32_t allowed_operations; + uint32_t item_count; + const webscene_outbound_drag_item_v1* items; + float pointer_x, pointer_y; + uint64_t drag_image_node_id; + float drag_image_hotspot_x, drag_image_hotspot_y; + float drag_image_width, drag_image_height; + const char* source_origin; + size_t source_origin_length; +} webscene_outbound_drag_request_v1; +typedef struct webscene_outbound_drag_completion_v1 { + uint32_t struct_size, version; + uint64_t request_id; + uint64_t document_generation; + uint32_t status; + uint32_t selected_operation; + double x, y; + uint32_t modifiers; + uint32_t reserved; +} webscene_outbound_drag_completion_v1; +WEBSCENE_API const webscene_outbound_drag_request_v1* +webscene_engine_take_outbound_drag_request_v1(webscene_engine* engine); +WEBSCENE_API void webscene_outbound_drag_request_release_v1( + const webscene_outbound_drag_request_v1* request); +WEBSCENE_API uint8_t webscene_engine_complete_outbound_drag_v1( + webscene_engine* engine, + const webscene_outbound_drag_completion_v1* completion); + /* Native file panel transport v2. This queue is independent of the v1 * byte-copy service. Requests contain presentation metadata and an optional * opaque initial-location token. Successful results contain only bounded diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_interop_types.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_interop_types.inc index efeb438b..31689212 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_interop_types.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_interop_types.inc @@ -436,6 +436,7 @@ using script_work_request = webscene_native::file_grant_remove_completion_data_v2, webscene_native::native_host_completion, webscene_native::native_drag_event, + webscene_native::native_outbound_drag_completion, url_request, interop_evaluate_work_v3, interop_invoke_work_v3, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc index 4b2e6394..4f622c76 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc @@ -187,6 +187,19 @@ signal_worker(); return true; } + bool complete_outbound_drag( + webscene_native::native_outbound_drag_completion completion) { +#if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) + std::lock_guard lock(script_mutex_); + if (script_work_.size() >= 1024U) return false; + script_work_.emplace_back(std::move(completion)); + signal_worker(); + return true; +#else + static_cast(completion); + return false; +#endif + } bool dispatch_drag(webscene_native::native_drag_event event) { #if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) std::lock_guard lock(script_mutex_); @@ -301,6 +314,14 @@ #if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) std::lock_guard lock(file_runtime_mutex_); if (file_runtime_ready_) return runtime_->take_download_request(); +#endif + return {}; + } + std::unique_ptr + take_outbound_drag_request() { +#if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) + std::lock_guard lock(file_runtime_mutex_); + if (file_runtime_ready_) return runtime_->take_outbound_drag_request(); #endif return {}; } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc index df460561..0cb37d76 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc @@ -1207,6 +1207,12 @@ changed = true; continue; } + if (auto* drag = std::get_if< + webscene_native::native_outbound_drag_completion>(&request)) { + if (runtime_) runtime_->complete_outbound_drag(*drag); + changed = true; + continue; + } if (auto* panel = std::get_if< webscene_native::file_panel_completion_data_v2>(&request)) { if (runtime_) runtime_->complete_file_panel_request(*panel); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index 91bd24e0..15f0990e 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -5800,7 +5800,7 @@ struct v8_dom_runtime::implementation final { return Object.freeze(list); }; class WebSceneDataTransfer { - constructor(records = []) { + constructor(records = [], writable = false) { const strings = Object.create(null); const files = []; const items = []; @@ -5879,6 +5879,10 @@ struct v8_dom_runtime::implementation final { this._items = indexedList(items); this.effectAllowed = 'all'; this._dropEffect = 'none'; + this._writable = writable; + this._dragImageElement = null; + this._dragImageX = 0; + this._dragImageY = 0; } get types() { const types = Object.keys(this._strings); @@ -5896,9 +5900,22 @@ struct v8_dom_runtime::implementation final { this._dropEffect = value; } getData(type) { return this._strings[String(type).toLowerCase()] || ''; } - setData() {} - clearData() {} - setDragImage() {} + setData(type, value) { + if (!this._writable) return; + type = String(type).toLowerCase(); + if (type) this._strings[type] = String(value); + } + clearData(type) { + if (!this._writable) return; + if (arguments.length === 0) this._strings = Object.create(null); + else delete this._strings[String(type).toLowerCase()]; + } + setDragImage(element, x, y) { + if (!this._writable || !element || element.nodeType !== 1) return; + this._dragImageElement = element; + this._dragImageX = Number.isFinite(Number(x)) ? Number(x) : 0; + this._dragImageY = Number.isFinite(Number(y)) ? Number(y) : 0; + } } Object.defineProperties(globalThis, { DataTransfer: { value: WebSceneDataTransfer, configurable: true }, @@ -5907,7 +5924,9 @@ struct v8_dom_runtime::implementation final { }, __webSceneCreateDragDataTransfer: { configurable: true, - value(records) { return new WebSceneDataTransfer(records); } + value(records, writable = false) { + return new WebSceneDataTransfer(records, writable); + } } }); })(); @@ -6492,6 +6511,7 @@ struct v8_dom_runtime::implementation final { // Keep these fragments in one translation unit: their order and direct // visibility preserve the runtime's existing release code generation. #include "webscene_v8_runtime_interop.inc" +#include "webscene_v8_runtime_outbound_drag.inc" #include "webscene_v8_runtime_tasks.inc" #include "webscene_v8_runtime_resources.inc" #include "webscene_v8_runtime_dom_core.inc" @@ -8720,4 +8740,16 @@ std::unique_ptr v8_dom_runtime::take_typed_host_request() { std::unique_ptr v8_dom_runtime::take_download_request() { return impl_->take_download_request(); } +std::unique_ptr +v8_dom_runtime::take_outbound_drag_request() { + return impl_->take_outbound_drag_request(); +} +void v8_dom_runtime::complete_outbound_drag( + native_outbound_drag_completion& completion) { + if (impl_ == nullptr) return; + v8::Locker locker(impl_->isolate); + v8::Isolate::Scope isolate_scope(impl_->isolate); + v8::HandleScope handles(impl_->isolate); + impl_->complete_outbound_drag(completion); +} } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h index 6d200b4c..4475fab5 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h @@ -101,6 +101,43 @@ struct native_drag_event { double y{}; std::vector items; }; +struct native_outbound_drag_item { + webscene_outbound_drag_item_v1 view{}; + std::string mime_type; + std::string value; + std::string name; + std::string grant_reference; + void bind() { + view.struct_size = sizeof(view); view.version = 1; + view.mime_type = mime_type.empty() ? nullptr : mime_type.data(); + view.mime_type_length = mime_type.size(); + view.value = value.empty() ? nullptr : value.data(); + view.value_length = value.size(); + view.name = name.empty() ? nullptr : name.data(); + view.name_length = name.size(); + view.grant_reference = grant_reference.empty() + ? nullptr : grant_reference.data(); + view.grant_reference_length = grant_reference.size(); + } +}; +struct native_outbound_drag_request { + webscene_outbound_drag_request_v1 view{}; + std::string source_origin; + std::vector owned_items; + std::vector item_views; + void bind() { + item_views.clear(); item_views.reserve(owned_items.size()); + for (auto& item : owned_items) { item.bind(); item_views.push_back(item.view); } + view.struct_size = sizeof(view); view.version = 1; + view.item_count = static_cast(item_views.size()); + view.items = item_views.empty() ? nullptr : item_views.data(); + view.source_origin = source_origin.empty() ? nullptr : source_origin.data(); + view.source_origin_length = source_origin.size(); + } +}; +struct native_outbound_drag_completion { + webscene_outbound_drag_completion_v1 value{}; +}; class native_document; struct dom_node; @@ -413,6 +450,8 @@ class v8_dom_runtime final { void set_native_media_policy(uint32_t flags); std::unique_ptr take_file_request(); std::unique_ptr take_download_request(); + std::unique_ptr take_outbound_drag_request(); + void complete_outbound_drag(native_outbound_drag_completion& completion); void complete_file_request(native_file_completion& completion); void complete_file_panel_request(file_panel_completion_data_v2& completion); void complete_file_grant_same_entry_request( diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc index 34042f0e..5137d27a 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc @@ -5855,6 +5855,18 @@ } void clear_selections_containing(const dom_node &subtree) { + if (active_outbound_drag.has_value()) { + auto* source = document.find_by_native_id( + active_outbound_drag->source_node_id); + if (source != nullptr && subtree_contains(subtree, source)) { + active_outbound_drag.reset(); + std::lock_guard lock(host_request_mutex); + std::erase_if(outbound_drag_requests, [&](const auto& request) { + return request != nullptr + && request->view.source_node_id == source->id; + }); + } + } for (auto &[root_id, selection] : window_selections) { static_cast(root_id); selection.text_generation = std::numeric_limits::max(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc index aff57af4..22f1d808 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc @@ -74,6 +74,8 @@ void retire_document_realm(v8::Local previous_context) { active_drag_session.reset(); + active_outbound_drag.reset(); + outbound_drag_threshold_crossed = false; std::vector> retired_contexts{previous_context}; retired_contexts.reserve(actual_frame_contexts.size() + 1U); for (auto& [id, frame] : actual_frame_contexts) { @@ -143,6 +145,7 @@ { std::lock_guard lock(host_request_mutex); host_requests.clear(); } { std::lock_guard lock(host_request_mutex); typed_host_requests.clear(); } { std::lock_guard lock(host_request_mutex); download_requests.clear(); } + { std::lock_guard lock(host_request_mutex); outbound_drag_requests.clear(); } object_url_file_data.clear(); release_owned_binary_object_urls(); object_urls.clear(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_outbound_drag.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_outbound_drag.inc new file mode 100644 index 00000000..1bb691dd --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_outbound_drag.inc @@ -0,0 +1,356 @@ + static constexpr size_t maximum_outbound_drag_requests = 8U; + static constexpr size_t maximum_outbound_drag_items = 32U; + static constexpr size_t maximum_outbound_drag_metadata = 1024U * 1024U; + + std::unique_ptr take_outbound_drag_request() + { + std::lock_guard lock(host_request_mutex); + if (outbound_drag_requests.empty()) return {}; + auto request = std::move(outbound_drag_requests.front()); + outbound_drag_requests.pop_front(); + return request; + } + + v8::Local create_outbound_drag_data_transfer(dom_node& target) + { + auto local_context = context_for_node(target); + v8::Context::Scope scope(local_context); + v8::Local factory; + v8::Local result; + v8::Local arguments[]{ + v8::Array::New(isolate), v8::True(isolate)}; + if (!local_context->Global()->Get(local_context, + js_string(isolate, "__webSceneCreateDragDataTransfer")) + .ToLocal(&factory) + || !factory->IsFunction() + || !factory.As()->Call( + local_context, local_context->Global(), 2, arguments) + .ToLocal(&result) + || !result->IsObject()) return v8::Object::New(isolate); + return result.As(); + } + + static uint32_t outbound_operations_for_effect(std::string_view effect) + { + if (effect == "none") return 0U; + if (effect == "copy") return WEBSCENE_OUTBOUND_DRAG_OPERATION_COPY_V1; + if (effect == "link") return WEBSCENE_OUTBOUND_DRAG_OPERATION_LINK_V1; + if (effect == "move") return WEBSCENE_OUTBOUND_DRAG_OPERATION_MOVE_V1; + if (effect == "copyLink") return WEBSCENE_OUTBOUND_DRAG_OPERATION_COPY_V1 + | WEBSCENE_OUTBOUND_DRAG_OPERATION_LINK_V1; + if (effect == "copyMove") return WEBSCENE_OUTBOUND_DRAG_OPERATION_COPY_V1 + | WEBSCENE_OUTBOUND_DRAG_OPERATION_MOVE_V1; + if (effect == "linkMove") return WEBSCENE_OUTBOUND_DRAG_OPERATION_LINK_V1 + | WEBSCENE_OUTBOUND_DRAG_OPERATION_MOVE_V1; + return WEBSCENE_OUTBOUND_DRAG_OPERATION_COPY_V1 + | WEBSCENE_OUTBOUND_DRAG_OPERATION_LINK_V1 + | WEBSCENE_OUTBOUND_DRAG_OPERATION_MOVE_V1; + } + + dom_node* outbound_drag_candidate(dom_node* hit) + { + for (auto* candidate = hit; candidate != nullptr; + candidate = candidate->parent) { + const auto draggable = candidate->attributes.find("draggable"); + if (draggable != candidate->attributes.end()) { + if (draggable->second == "false") return nullptr; + if (draggable->second == "true") return candidate; + } + if (candidate->tag == "a" && candidate->attributes.contains("href")) + return candidate; + if (candidate->tag == "img" && candidate->attributes.contains("src")) + return candidate; + } + if (hit == nullptr) return nullptr; + auto* root = css_cascade_root_for_node(hit); + const auto root_id = root == nullptr ? document.body().id : root->id; + if (const auto known = window_selections.find(root_id); + known != window_selections.end() && validate_selection(known->second) + && (known->second.anchor_node_id != known->second.focus_node_id + || known->second.anchor_offset != known->second.focus_offset)) + return hit; + return nullptr; + } + + std::string outbound_selection_text(dom_node& source) + { + auto* root = css_cascade_root_for_node(&source); + const auto root_id = root == nullptr ? document.body().id : root->id; + const auto known = window_selections.find(root_id); + if (known == window_selections.end() || !validate_selection(known->second) + || known->second.object.IsEmpty()) return {}; + auto local_context = context_for_node(source); + v8::Context::Scope scope(local_context); + auto selection = known->second.object.Get(isolate); + v8::Local function; + v8::Local result; + if (!selection->Get(local_context, js_string(isolate, "toString")) + .ToLocal(&function) + || !function->IsFunction() + || !function.As()->Call( + local_context, selection, 0, nullptr).ToLocal(&result)) return {}; + return to_utf8(isolate, result); + } + + bool add_outbound_item(native_outbound_drag_request& request, + uint32_t kind, std::string mime, std::string value, + std::string name = {}, std::string grant = {}, uint64_t byte_size = 0U) + { + if (request.owned_items.size() >= maximum_outbound_drag_items) return false; + const auto bytes = mime.size() + value.size() + name.size() + grant.size(); + size_t current = request.source_origin.size(); + for (const auto& item : request.owned_items) + current += item.mime_type.size() + item.value.size() + + item.name.size() + item.grant_reference.size(); + if (current + bytes > maximum_outbound_drag_metadata + || mime.size() > 256U || grant.size() > 1024U + || (kind == WEBSCENE_OUTBOUND_DRAG_ITEM_URI_V1 + && value.size() > 8192U)) return false; + native_outbound_drag_item item; + item.view.kind = kind; + item.view.byte_size = byte_size; + item.mime_type = std::move(mime); + item.value = std::move(value); + item.name = std::move(name); + item.grant_reference = std::move(grant); + request.owned_items.push_back(std::move(item)); + return true; + } + + bool collect_outbound_script_items(dom_node& source, + v8::Local transfer, native_outbound_drag_request& request) + { + auto local_context = context_for_node(source); + v8::Context::Scope scope(local_context); + v8::Local strings_value; + if (!transfer->Get(local_context, js_string(isolate, "_strings")) + .ToLocal(&strings_value) + || !strings_value->IsObject()) return true; + auto strings = strings_value.As(); + v8::Local names; + if (!strings->GetOwnPropertyNames(local_context).ToLocal(&names)) return false; + const auto count = std::min( + names->Length(), static_cast(maximum_outbound_drag_items)); + for (uint32_t index = 0U; index < count; ++index) { + v8::Local name; + v8::Local value; + if (!names->Get(local_context, index).ToLocal(&name) + || !strings->Get(local_context, name).ToLocal(&value)) continue; + auto mime = to_utf8(isolate, name); + auto text = to_utf8(isolate, value); + const auto uri = mime == "text/uri-list"; + if (!add_outbound_item(request, + uri ? WEBSCENE_OUTBOUND_DRAG_ITEM_URI_V1 + : WEBSCENE_OUTBOUND_DRAG_ITEM_TEXT_V1, + std::move(mime), std::move(text))) return false; + } + return true; + } + + bool begin_outbound_drag(const webscene_input_event& input, dom_node& source) + { + constexpr auto activation_lifetime = std::chrono::seconds(5); + const auto visibly_connected = [&] { + if (!is_connected(source) || document.is_inert(source)) return false; + for (auto* node = &source; node != nullptr; node = node->parent) { + if (!node->visible || node->attributes.contains("hidden") + || node->style.display == display_mode::none) return false; + } + return source.layout.width > 0.0F && source.layout.height > 0.0F; + }; + if (active_outbound_drag.has_value() || !visibly_connected() + || last_user_activation.time_since_epoch().count() == 0 + || std::chrono::steady_clock::now() - last_user_activation + > activation_lifetime) return true; + + auto transfer = create_outbound_drag_data_transfer(source); + active_outbound_drag.emplace(); + active_outbound_drag->source_node_id = source.id; + active_outbound_drag->transfer.Reset(isolate, transfer); + bool prevented = false; + if (!dispatch_input_event_type(input, "dragstart", source, &prevented)) { + active_outbound_drag.reset(); + return false; + } + if (prevented || !visibly_connected()) { + active_outbound_drag.reset(); + return true; + } + + auto request = std::make_unique(); + request->view.request_id = ++next_host_request_id; + request->view.document_generation = + document_generation.load(std::memory_order_acquire); + request->view.node_generation = document.scene_generation(); + request->view.source_node_id = source.id; + request->view.pointer_x = static_cast(input.x); + request->view.pointer_y = static_cast(input.y); + request->source_origin = security_origin_for_realm(context_for_node(source)); + if (request->source_origin.size() > 4096U) { + active_outbound_drag.reset(); return true; + } + for (auto* ancestor = source.parent; ancestor != nullptr; + ancestor = ancestor->parent) { + if (ancestor->tag != "iframe") continue; + request->view.frame_owner_node_id = ancestor->id; + request->view.frame_generation = + current_iframe_navigation_generation(ancestor->id); + break; + } + + if (source.tag == "a") { + const auto href = source.attributes.find("href"); + if (href != source.attributes.end()) { + auto uri = resolve_resource_url( + href->second, effective_document_base(css_cascade_root_for_node(&source))); + if (!uri.empty() && !add_outbound_item(*request, + WEBSCENE_OUTBOUND_DRAG_ITEM_URI_V1, + "text/uri-list", std::move(uri))) { + active_outbound_drag.reset(); return true; + } + } + } + if (source.tag == "img") { + auto uri = source.replaced_image().resolved_source; + if (uri.empty()) uri = resolve_resource_url( + source.attributes["src"], + effective_document_base(css_cascade_root_for_node(&source))); + std::string name; + if (const auto alt = source.attributes.find("alt"); + alt != source.attributes.end()) name = alt->second; + std::string mime = "application/octet-stream"; + if (uri.starts_with("data:image/")) { + if (const auto separator = uri.find_first_of(";,"); + separator != std::string::npos) + mime = uri.substr(5U, separator - 5U); + } + if (!uri.empty() && !add_outbound_item(*request, + WEBSCENE_OUTBOUND_DRAG_ITEM_IMAGE_V1, + std::move(mime), std::move(uri), std::move(name))) { + active_outbound_drag.reset(); return true; + } + } + if (!collect_outbound_script_items(source, transfer, *request)) { + active_outbound_drag.reset(); return true; + } + if (request->owned_items.empty()) { + auto text = outbound_selection_text(source); + if (text.empty()) append_node_text(source, text); + if (text.empty() || !add_outbound_item(*request, + WEBSCENE_OUTBOUND_DRAG_ITEM_TEXT_V1, + "text/plain", std::move(text))) { + active_outbound_drag.reset(); return true; + } + } + + v8::Local effect; + request->view.allowed_operations = + transfer->Get(context_for_node(source), js_string(isolate, "effectAllowed")) + .ToLocal(&effect) + ? outbound_operations_for_effect(to_utf8(isolate, effect)) + : WEBSCENE_OUTBOUND_DRAG_OPERATION_COPY_V1; + if (request->view.allowed_operations == 0U) { + active_outbound_drag.reset(); return true; + } + v8::Local image_value; + if (transfer->Get(context_for_node(source), + js_string(isolate, "_dragImageElement")).ToLocal(&image_value) + && image_value->IsObject()) { + if (auto* image = unwrap_node(image_value.As()); + image != nullptr && is_connected(*image) && image->visible + && !document.is_inert(*image)) { + request->view.drag_image_node_id = image->id; + request->view.drag_image_width = std::clamp(image->layout.width, 0.0F, 4096.0F); + request->view.drag_image_height = std::clamp(image->layout.height, 0.0F, 4096.0F); + v8::Local x; + v8::Local y; + if (transfer->Get(context_for_node(source), js_string(isolate, "_dragImageX")).ToLocal(&x)) + request->view.drag_image_hotspot_x = std::clamp( + static_cast(x->NumberValue(context_for_node(source)).FromMaybe(0)), + -4096.0F, 4096.0F); + if (transfer->Get(context_for_node(source), js_string(isolate, "_dragImageY")).ToLocal(&y)) + request->view.drag_image_hotspot_y = std::clamp( + static_cast(y->NumberValue(context_for_node(source)).FromMaybe(0)), + -4096.0F, 4096.0F); + } + } + if (request->view.drag_image_node_id == 0U) { + request->view.drag_image_node_id = source.id; + request->view.drag_image_width = std::clamp(source.layout.width, 0.0F, 4096.0F); + request->view.drag_image_height = std::clamp(source.layout.height, 0.0F, 4096.0F); + request->view.drag_image_hotspot_x = std::clamp( + static_cast(input.x - source.layout.x), -4096.0F, 4096.0F); + request->view.drag_image_hotspot_y = std::clamp( + static_cast(input.y - source.layout.y), -4096.0F, 4096.0F); + } + + active_outbound_drag->request_id = request->view.request_id; + active_outbound_drag->document_generation = request->view.document_generation; + active_outbound_drag->node_generation = request->view.node_generation; + active_outbound_drag->frame_generation = request->view.frame_generation; + active_outbound_drag->frame_owner_id = + static_cast(request->view.frame_owner_node_id); + active_outbound_drag->allowed_operations = request->view.allowed_operations; + { + std::lock_guard lock(host_request_mutex); + if (outbound_drag_requests.size() >= maximum_outbound_drag_requests) { + active_outbound_drag.reset(); return true; + } + outbound_drag_requests.push_back(std::move(request)); + } + if (host_request_available) host_request_available(); + return true; + } + + bool maybe_begin_outbound_drag(const webscene_input_event& input) + { + if (input.kind != WEBSCENE_INPUT_POINTER_MOVE || pointer_down_target == nullptr + || outbound_drag_threshold_crossed + || std::hypot(input.x - pointer_down_x, input.y - pointer_down_y) <= 4.0) + return true; + outbound_drag_threshold_crossed = true; + auto* candidate = outbound_drag_candidate(pointer_down_target); + return candidate == nullptr ? true : begin_outbound_drag(input, *candidate); + } + + void complete_outbound_drag(native_outbound_drag_completion& completion) + { + if (!active_outbound_drag.has_value() + || active_outbound_drag->request_id != completion.value.request_id + || active_outbound_drag->document_generation + != completion.value.document_generation) return; + if (completion.value.status == WEBSCENE_OUTBOUND_DRAG_COMPLETED_V1 + && (completion.value.selected_operation == 0U + || (completion.value.selected_operation + & (completion.value.selected_operation - 1U)) != 0U + || (completion.value.selected_operation + & active_outbound_drag->allowed_operations) == 0U)) return; + auto state = std::move(*active_outbound_drag); + active_outbound_drag.reset(); + auto* source = document.find_by_native_id(state.source_node_id); + if (source == nullptr || !is_connected(*source) + || document_generation.load(std::memory_order_acquire) + != state.document_generation + || document.scene_generation() != state.node_generation + || (state.frame_owner_id != 0U + && current_iframe_navigation_generation(state.frame_owner_id) + != state.frame_generation)) return; + active_outbound_drag.emplace(std::move(state)); + auto local_context = context_for_node(*source); + v8::Context::Scope context_scope(local_context); + const char* effect = completion.value.selected_operation + == WEBSCENE_OUTBOUND_DRAG_OPERATION_COPY_V1 ? "copy" + : completion.value.selected_operation + == WEBSCENE_OUTBOUND_DRAG_OPERATION_LINK_V1 ? "link" + : completion.value.selected_operation + == WEBSCENE_OUTBOUND_DRAG_OPERATION_MOVE_V1 ? "move" + : "none"; + active_outbound_drag->transfer.Get(isolate)->Set( + local_context, js_string(isolate, "dropEffect"), + js_string(isolate, effect)).Check(); + auto input = webscene_input_event{WEBSCENE_INPUT_POINTER_MOVE, + completion.value.modifiers, 0U, completion.value.x, + completion.value.y, 0.0, 0.0}; + static_cast(dispatch_input_event_type(input, "dragend", *source)); + active_outbound_drag.reset(); + } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc index b0984d86..27fc2dd7 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -210,6 +210,17 @@ std::unordered_map> transfers; }; std::optional active_drag_session; + struct outbound_drag_state final { + uint64_t request_id{}; + uint64_t document_generation{}; + uint64_t node_generation{}; + uint64_t frame_generation{}; + uint32_t source_node_id{}; + uint32_t frame_owner_id{}; + uint32_t allowed_operations{}; + v8::Global transfer; + }; + std::optional active_outbound_drag; #if defined(WEBSCENE_NATIVE_ENGINE_CERTIFICATION) std::unordered_map event_dispatch_counts; std::unordered_map> @@ -253,6 +264,8 @@ std::deque host_requests; std::deque> typed_host_requests; std::deque> download_requests; + std::deque> + outbound_drag_requests; struct host_promise_target { std::string operation; uint32_t target_node_id{}; @@ -626,6 +639,7 @@ uint64_t input_callback_invocation_count{0}; dom_node* pointer_capture_target{nullptr}; dom_node* pointer_down_target{nullptr}; + bool outbound_drag_threshold_crossed{false}; dom_node* active_pointer_target{nullptr}; dom_node* scrollbar_drag_target{nullptr}; bool scrollbar_drag_vertical{false}; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc index 198bf858..34a168e4 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -1796,7 +1796,8 @@ #endif const auto callback_count_before = input_callback_invocation_count; const auto type_view = std::string_view(type); - const auto drag_event = type_view == "dragenter" + const auto drag_event = type_view == "dragstart" || type_view == "dragend" + || type_view == "dragenter" || type_view == "dragover" || type_view == "dragleave" || type_view == "drop"; auto event = create_event_instance( @@ -1838,6 +1839,12 @@ local_context, js_string(isolate, "dataTransfer"), drag_data_transfer_for(target)).Check(); + } else if (drag_event && active_outbound_drag.has_value() + && !active_outbound_drag->transfer.IsEmpty()) { + event->Set( + local_context, + js_string(isolate, "dataTransfer"), + active_outbound_drag->transfer.Get(isolate)).Check(); } event->Set( local_context, @@ -3942,6 +3949,7 @@ "active-pointer-down"); } pointer_down_target = target; + outbound_drag_threshold_crossed = false; pointer_down_x = input.x; pointer_down_y = input.y; types[type_count++] = "pointerdown"; @@ -4028,6 +4036,12 @@ || event_default_prevented; } } + if (!maybe_begin_outbound_drag(input)) { + last_error = "Native outbound drag initiation failed: " + + describe_reported_exception(try_catch, local_context); + report_input_exception(); + return false; + } if (input.kind == WEBSCENE_INPUT_WHEEL && !wheel_default_prevented) { const auto discrete_wheel = (input.flags & WEBSCENE_INPUT_WHEEL_DISCRETE) != 0U; @@ -4253,6 +4267,7 @@ if (input.kind == WEBSCENE_INPUT_POINTER_UP) { pointer_capture_target = nullptr; pointer_down_target = nullptr; + outbound_drag_threshold_crossed = false; if (pointer_outside) return leave_host(); } if (input.kind == WEBSCENE_INPUT_POINTER_MOVE diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_outbound_drag_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_outbound_drag_tests.inc new file mode 100644 index 00000000..387cf892 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_outbound_drag_tests.inc @@ -0,0 +1,166 @@ +struct outbound_drag_snapshot final { + uint64_t id{}; + uint64_t document_generation{}; + uint64_t node_generation{}; + uint64_t frame_generation{}; + uint64_t source_node_id{}; + uint32_t operations{}; + std::vector> items; + uint64_t drag_image_node_id{}; + float image_width{}; + std::string origin; +}; + +outbound_drag_snapshot take_outbound_drag(webscene_engine* engine) +{ + const webscene_outbound_drag_request_v1* request = nullptr; + for (auto attempt = 0; attempt < 250 && request == nullptr; ++attempt) { + request = webscene_engine_take_outbound_drag_request_v1(engine); + if (request == nullptr) std::this_thread::sleep_for( + std::chrono::milliseconds(2)); + } + require(request != nullptr, "outbound drag request was not published"); + require(request->struct_size == sizeof(*request) && request->version == 1U, + "outbound drag request ABI metadata changed"); + require(request->item_count <= 32U, + "outbound drag item cap was exceeded"); + outbound_drag_snapshot result{ + request->request_id, request->document_generation, + request->node_generation, request->frame_generation, + request->source_node_id, request->allowed_operations}; + result.drag_image_node_id = request->drag_image_node_id; + result.image_width = request->drag_image_width; + result.origin.assign(request->source_origin == nullptr ? "" : request->source_origin, + request->source_origin_length); + for (uint32_t index = 0U; index < request->item_count; ++index) { + const auto& item = request->items[index]; + require(item.struct_size == sizeof(item) && item.version == 1U, + "outbound drag item ABI metadata changed"); + result.items.emplace_back(item.kind, + std::string(item.value == nullptr ? "" : item.value, + item.value_length)); + require(item.grant_reference_length == 0U, + "DOM drag invented a native file grant"); + } + webscene_outbound_drag_request_release_v1(request); + return result; +} + +void complete_outbound_drag(webscene_engine* engine, + const outbound_drag_snapshot& request, uint32_t status, + uint32_t operation = 0U) +{ + webscene_outbound_drag_completion_v1 completion{}; + completion.struct_size = sizeof(completion); + completion.version = 1U; + completion.request_id = request.id; + completion.document_generation = request.document_generation; + completion.status = status; + completion.selected_operation = operation; + completion.x = 50; + completion.y = 20; + require(webscene_engine_complete_outbound_drag_v1(engine, &completion) != 0U, + "outbound drag completion was rejected"); +} + +void test_outbound_drag_browser_contract_and_retirement(webscene_engine* engine) +{ + execute_and_wait(engine, R"JS( + document.body.innerHTML = ` + link +
selected text
+ preview +
cancel
`; + globalThis.__outboundDrag = []; + link.addEventListener('dragstart', event => { + event.dataTransfer.setData('text/plain', 'link label'); + event.dataTransfer.effectAllowed = 'copyLink'; + event.dataTransfer.setDragImage(text, 3, 4); + }); + for (const node of [link,text,image]) node.addEventListener('dragend', event => { + __outboundDrag.push([node.id,event.type,event.isTrusted, + event.dataTransfer === null ? 'missing' : 'present']); + }); + cancel.addEventListener('dragstart', event => event.preventDefault()); + )JS", "outbound-drag-setup.js"); + + uint64_t sequence = 61'000U; + const auto start = [&](double x, double y) { + pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, x, y, sequence++, true); + pointer_move(engine, x + 8, y, sequence++, true); + }; + start(10, 15); + auto link = take_outbound_drag(engine); + require(link.document_generation != 0U && link.node_generation != 0U + && link.origin == "null" + && link.operations == (WEBSCENE_OUTBOUND_DRAG_OPERATION_COPY_V1 + | WEBSCENE_OUTBOUND_DRAG_OPERATION_LINK_V1) + && link.items.size() == 2U + && link.items[0].first == WEBSCENE_OUTBOUND_DRAG_ITEM_URI_V1 + && link.items[0].second.ends_with("/target") + && link.items[1] == std::pair{ + WEBSCENE_OUTBOUND_DRAG_ITEM_TEXT_V1, "link label"} + && link.drag_image_node_id != link.source_node_id + && link.image_width > 0, + "anchor, script DataTransfer, operations, or drag-image metadata diverged"); + complete_outbound_drag(engine, link, WEBSCENE_OUTBOUND_DRAG_COMPLETED_V1, + WEBSCENE_OUTBOUND_DRAG_OPERATION_LINK_V1); + static_cast(evaluate(engine, "__outboundDrag.length", "outbound-drag-end.js")); + + pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 18, 15, sequence++, false); + execute_and_wait(engine, R"JS( + const selection=getSelection(); selection.removeAllRanges(); + const range=document.createRange(); range.selectNodeContents(text.firstChild); + selection.addRange(range); + text.removeAttribute('draggable'); + )JS", "outbound-selection-setup.js"); + start(10, 55); + auto selection = take_outbound_drag(engine); + require(selection.items.size() == 1U + && selection.items[0] == std::pair{ + WEBSCENE_OUTBOUND_DRAG_ITEM_TEXT_V1, "selected text"}, + "selection drag did not publish bounded plain text"); + complete_outbound_drag(engine, selection, WEBSCENE_OUTBOUND_DRAG_CANCELLED_V1); + + pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 18, 55, sequence++, false); + start(10, 90); + auto image = take_outbound_drag(engine); + require(image.items.size() == 1U + && image.items[0].first == WEBSCENE_OUTBOUND_DRAG_ITEM_IMAGE_V1 + && image.items[0].second.starts_with("data:image/png"), + "image drag did not publish image URI metadata"); + complete_outbound_drag(engine, image, WEBSCENE_OUTBOUND_DRAG_FAILED_V1); + + pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 18, 90, sequence++, false); + start(10, 135); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + require(webscene_engine_take_outbound_drag_request_v1(engine) == nullptr, + "cancelled dragstart published a native request"); + + // A completion from a retired document is accepted into the worker queue + // but cannot dispatch dragend into the replacement realm. + pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 18, 135, sequence++, false); + execute_and_wait(engine, "text.draggable=true", "outbound-retire-setup.js"); + start(10, 55); + auto retired = take_outbound_drag(engine); + execute_and_wait(engine, "document.body.replaceChildren()", + "outbound-node-retirement.js"); + complete_outbound_drag(engine, retired, WEBSCENE_OUTBOUND_DRAG_CANCELLED_V1); + + // Completion input is bounded and validates status/operation combinations. + webscene_outbound_drag_completion_v1 invalid{ + sizeof(webscene_outbound_drag_completion_v1), 1U, 1U, 1U, + WEBSCENE_OUTBOUND_DRAG_CANCELLED_V1, + WEBSCENE_OUTBOUND_DRAG_OPERATION_COPY_V1, 0, 0, 0U, 0U}; + require(webscene_engine_complete_outbound_drag_v1(engine, &invalid) == 0U, + "cancelled outbound drag accepted a selected operation"); + + const auto started = std::chrono::steady_clock::now(); + for (uint32_t index = 0U; index < 10'000U; ++index) { + const auto* empty = webscene_engine_take_outbound_drag_request_v1(engine); + require(empty == nullptr, "retired outbound drag remained queued"); + } + require(std::chrono::steady_clock::now() - started < std::chrono::seconds(2), + "empty outbound drag polling exceeded the performance gate"); +} diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp index c5989943..00b947dc 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -101,6 +101,7 @@ uint8_t measure_baseline_fixture_text( #include "native_table_cell_copy_tests.inc" #include "native_v8_runtime_resource_tests.inc" #include "native_v8_runtime_drag_drop_tests.inc" +#include "native_v8_runtime_outbound_drag_tests.inc" #include "native_v8_runtime_service_worker_tests.inc" #include "native_v8_runtime_stream_fetch_tests.inc" #include "native_v8_runtime_css_mask_resource_tests.inc" @@ -210,6 +211,13 @@ int main() test_nested_drag_drop_routing_and_retirement(); return 0; } + if (selected == "outbound-drag") { + auto* engine = webscene_engine_create(0); + require(engine != nullptr, "outbound drag engine creation failed"); + test_outbound_drag_browser_contract_and_retirement(engine); + webscene_engine_destroy(engine); + return 0; + } if (selected == "nested-keyboard-handoff") { test_nested_keyboard_handoff_and_focus_traversal(); return 0; @@ -1400,6 +1408,7 @@ int main() test_pointer_hit_targets_and_related_targets_are_elements(engine); test_pointer_cursor_and_external_anchor_host_handoff(engine); test_nested_drag_drop_routing_and_retirement(); + test_outbound_drag_browser_contract_and_retirement(engine); test_typed_window_host_request_performance(engine); { auto* close_engine = webscene_engine_create(0);