fix(dnd): enforce drag data store modes and event lifetime

This commit is contained in:
ldm0
2026-10-11 18:06:46 +08:00
parent 9fc7ae3981
commit bd6f8d7ea4
21 changed files with 876 additions and 231 deletions
+10 -2
View File
@@ -1888,10 +1888,13 @@ async fn coordinate_drag_event_completes_through_pending_layout_dispatch() {
<script>
window.__dragPending = [];
document.getElementById('target').addEventListener('dragenter', event => {
window.__savedDragTransfer = event.dataTransfer;
window.__dragPending.push([
event.type,
event instanceof DragEvent,
event.dataTransfer.getData('text/plain')
event.dataTransfer.getData('text/plain'),
event.dataTransfer.types.join(','),
event.dataTransfer.items[0].kind
].join('|'));
});
</script>
@@ -1933,7 +1936,12 @@ async fn coordinate_drag_event_completes_through_pending_layout_dispatch() {
assert_eq!(messages, vec![json!({ "id": 4107, "result": {} })]);
assert_eq!(
evaluate_string(&mut ctx, "JSON.stringify(window.__dragPending)").await,
r#"["dragenter|true|drag-pending"]"#
r#"["dragenter|true||text/plain|string"]"#
);
assert_eq!(
evaluate_string(&mut ctx, "JSON.stringify([__savedDragTransfer.items.length,__savedDragTransfer.types.length,__savedDragTransfer.getData('text/plain')])").await,
r#"[0,0,""]"#,
"the saved event view must be disabled when the protocol command completes"
);
}
+4 -3
View File
@@ -332,9 +332,10 @@ pub(crate) use self::file_api::{
DirectoryReaderCallbackTaskEffect, FileEntryFileCallbackTask, FileEntryFileCallbackTaskEffect,
};
pub(crate) use self::file_api::{
allowed_drag_drop_effect, apply_drag_modifier_drop_effect, build_clipboard_data_transfer,
build_data_transfer_object, clipboard_data_transfer_contents, disable_clipboard_data_transfer,
initialize_native_drag_data_transfer, prepare_drag_drop_effect, set_drag_drop_effect,
DragDataStore, allowed_drag_drop_effect, apply_drag_modifier_drop_effect,
build_clipboard_data_transfer, clipboard_data_transfer_contents,
disable_clipboard_data_transfer, initialize_native_drag_data_transfer,
prepare_drag_drop_effect, set_drag_drop_effect,
};
pub(crate) use self::file_api::{
build_file_list_object, build_file_object, file_list_files_from_object,
@@ -207,7 +207,7 @@ struct WorkerTemplateMethodsDeclaration {
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::DataTransfer, enumerable)]
#[webapi(interface = web_api_interfaces::DataTransfer, enumerable, receiver)]
struct DataTransferTemplateMethodsDeclaration {
#[webapi(
method = "getData",
@@ -232,7 +232,7 @@ struct DataTransferTemplateMethodsDeclaration {
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::DataTransferItem, enumerable)]
#[webapi(interface = web_api_interfaces::DataTransferItem, enumerable, receiver)]
struct DataTransferItemTemplateMethodsDeclaration {
#[webapi(
method = "getAsFile",
@@ -257,7 +257,7 @@ struct DataTransferItemTemplateMethodsDeclaration {
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::DataTransferItemList, enumerable)]
#[webapi(interface = web_api_interfaces::DataTransferItemList, enumerable, receiver)]
struct DataTransferItemListTemplateMethodsDeclaration {
#[webapi(
intrinsic_data_property = v8::Intrinsic::ArrayProtoValues,
@@ -12,10 +12,10 @@ mod install;
pub(super) use data_transfer::data_transfer_constructor_callback;
pub(crate) use data_transfer::{
allowed_drag_drop_effect, apply_drag_modifier_drop_effect, build_clipboard_data_transfer,
build_data_transfer_object, clipboard_data_transfer_contents, disable_clipboard_data_transfer,
initialize_native_drag_data_transfer, is_branded_data_transfer_object,
prepare_drag_drop_effect, set_drag_drop_effect,
DragDataStore, allowed_drag_drop_effect, apply_drag_modifier_drop_effect,
build_clipboard_data_transfer, clipboard_data_transfer_contents,
disable_clipboard_data_transfer, initialize_native_drag_data_transfer,
is_branded_data_transfer_object, prepare_drag_drop_effect, set_drag_drop_effect,
};
pub(crate) use data_transfer::{
data_transfer_clear_data_callback, data_transfer_get_data_callback,
@@ -14,10 +14,12 @@ use moli_file_api::data_transfer::{
use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject};
mod clipboard;
mod drag_store;
pub(crate) use clipboard::{
build_clipboard_data_transfer, clipboard_data_transfer_contents,
disable_clipboard_data_transfer,
};
pub(crate) use drag_store::DragDataStore;
const DATA_TRANSFER_FILES_SLOT: &str = "__lmDataTransferFiles";
const DATA_TRANSFER_ITEMS_SLOT: &str = "__lmDataTransferItems";
@@ -26,14 +28,18 @@ const DATA_TRANSFER_DROP_EFFECT_SLOT: &str = "__lmDataTransferDropEffect";
const DATA_TRANSFER_EFFECT_ALLOWED_SLOT: &str = "__lmDataTransferEffectAllowed";
const DATA_TRANSFER_MODE_SLOT: &str = "__lmDataTransferMode";
const DATA_TRANSFER_CLIPBOARD_SLOT: &str = "__lmDataTransferClipboard";
const DATA_TRANSFER_STORE_SLOT: &str = "__lmDataTransferStore";
const DATA_TRANSFER_ITEM_LIST_ARRAY_SLOT: &str = "__lmDataTransferItemArray";
const DATA_TRANSFER_ITEM_LIST_OWNER_SLOT: &str = "__lmDataTransferOwner";
const DATA_TRANSFER_ITEM_LIST_VIEWS_SLOT: &str = "__lmDataTransferItemViews";
const DATA_TRANSFER_ITEM_LIST_INDEXED_LENGTH_SLOT: &str = "__lmDataTransferItemListIndexedLength";
const DATA_TRANSFER_ITEM_KIND_SLOT: &str = "__lmDataTransferItemKind";
const DATA_TRANSFER_ITEM_TYPE_SLOT: &str = "__lmDataTransferItemType";
const DATA_TRANSFER_ITEM_FILE_SLOT: &str = "__lmDataTransferItemFile";
const DATA_TRANSFER_ITEM_ENTRY_SLOT: &str = "__lmDataTransferItemEntry";
const DATA_TRANSFER_ITEM_STRING_SLOT: &str = "__lmDataTransferItemString";
const DATA_TRANSFER_ITEM_SOURCE_SLOT: &str = "__lmDataTransferItemSource";
const DATA_TRANSFER_ITEM_OWNER_SLOT: &str = "__lmDataTransferItemOwner";
const FILE_SYSTEM_ENTRY_FILESYSTEM_SLOT: &str = "__lmFileSystemEntryFilesystem";
const FILE_SYSTEM_ENTRY_FULL_PATH_SLOT: &str = "__lmFileSystemEntryFullPath";
const FILE_SYSTEM_ENTRY_IS_DIRECTORY_SLOT: &str = "__lmFileSystemEntryIsDirectory";
@@ -99,10 +105,18 @@ struct DataTransferShellDeclaration {
}
#[derive(Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
enum DataTransferMode {
ReadWrite,
ReadOnly,
Disabled,
ReadWrite = 0,
ReadOnly = 1,
Disabled = 2,
Protected = 3,
}
impl DataTransferMode {
fn can_read(self) -> bool {
matches!(self, Self::ReadWrite | Self::ReadOnly)
}
}
fn data_transfer_mode<'s>(
@@ -112,6 +126,7 @@ fn data_transfer_mode<'s>(
match private_number_property(scope, object, DATA_TRANSFER_MODE_SLOT) {
Some(1.0) => DataTransferMode::ReadOnly,
Some(2.0) => DataTransferMode::Disabled,
Some(3.0) => DataTransferMode::Protected,
_ => DataTransferMode::ReadWrite,
}
}
@@ -125,7 +140,7 @@ fn item_list_is_writable<'s>(
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::DataTransfer)]
#[webapi(interface = web_api_interfaces::DataTransfer, receiver)]
struct DataTransferPrototypeAccessorsDeclaration {
#[webapi(accessor_property, getter = data_transfer_files_getter, enumerable)]
files: (),
@@ -159,6 +174,8 @@ struct DataTransferItemListObjectDeclaration<'s> {
items: Vec<v8::Local<'s, v8::Value>>,
#[webapi(slot = DATA_TRANSFER_ITEM_LIST_OWNER_SLOT)]
owner: v8::Local<'s, v8::Object>,
#[webapi(slot = DATA_TRANSFER_ITEM_LIST_VIEWS_SLOT)]
views: v8::Local<'s, v8::Map>,
#[webapi(
slot = DATA_TRANSFER_ITEM_LIST_INDEXED_LENGTH_SLOT,
constructor_default = 0.0
@@ -167,7 +184,7 @@ struct DataTransferItemListObjectDeclaration<'s> {
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::DataTransferItemList)]
#[webapi(interface = web_api_interfaces::DataTransferItemList, receiver)]
struct DataTransferItemListPrototypeAccessorsDeclaration {
#[webapi(accessor_property, getter = data_transfer_item_list_length_getter, enumerable)]
length: (),
@@ -208,8 +225,19 @@ struct DataTransferStringItemObjectDeclaration<'item_type, 'data> {
data: &'data str,
}
// Item payloads belong to the store; each item list publishes its own stable
// views whose access follows the associated DataTransfer's lifetime and mode.
#[derive(WebApiObject)]
#[webapi(interface = web_api_interfaces::DataTransferItem, require_prototype)]
struct DataTransferItemViewDeclaration<'s> {
#[webapi(slot = DATA_TRANSFER_ITEM_SOURCE_SLOT)]
source: v8::Local<'s, v8::Object>,
#[webapi(slot = DATA_TRANSFER_ITEM_OWNER_SLOT)]
owner: v8::Local<'s, v8::Object>,
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::DataTransferItem)]
#[webapi(interface = web_api_interfaces::DataTransferItem, receiver)]
struct DataTransferItemPrototypeAccessorsDeclaration {
#[webapi(accessor_property, getter = data_transfer_item_kind_getter, enumerable)]
kind: (),
@@ -542,6 +570,63 @@ fn item_list_owner<'s>(
get_private_object(scope, item_list, DATA_TRANSFER_ITEM_LIST_OWNER_SLOT)
}
fn item_views<'s>(
scope: &mut v8::PinScope<'s, '_>,
item_list: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Map>> {
get_private_value(scope, item_list, DATA_TRANSFER_ITEM_LIST_VIEWS_SLOT)
.and_then(|value| v8::Local::<v8::Map>::try_from(value).ok())
}
fn data_transfer_item_view<'s>(
scope: &mut v8::PinScope<'s, '_>,
item_list: v8::Local<'s, v8::Object>,
source: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Object>> {
let views = item_views(scope, item_list)?;
if let Some(value) = views.get(scope, source.into())
&& let Ok(view) = v8::Local::<v8::Object>::try_from(value)
{
return Some(view);
}
let owner = item_list_owner(scope, item_list)?;
let view = DataTransferItemViewDeclaration::new(source, owner)
.bind(scope)
.ok()?;
views.set(scope, source.into(), view.into())?;
Some(view)
}
fn item_data_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
item: v8::Local<'s, v8::Object>,
) -> v8::Local<'s, v8::Object> {
get_private_object(scope, item, DATA_TRANSFER_ITEM_SOURCE_SLOT).unwrap_or(item)
}
fn item_is_associated<'s>(
scope: &mut v8::PinScope<'s, '_>,
item: v8::Local<'s, v8::Object>,
) -> bool {
if let Some(owner) = get_private_object(scope, item, DATA_TRANSFER_ITEM_OWNER_SLOT) {
data_transfer_mode(scope, owner) != DataTransferMode::Disabled
&& get_private_object(scope, item, DATA_TRANSFER_ITEM_SOURCE_SLOT).is_some()
} else {
// Canonical records never escape the store; removed records have empty metadata.
private_string_property(scope, item, DATA_TRANSFER_ITEM_KIND_SLOT)
.is_some_and(|kind| !kind.is_empty())
}
}
pub(super) fn item_is_readable<'s>(
scope: &mut v8::PinScope<'s, '_>,
item: v8::Local<'s, v8::Object>,
) -> bool {
item_is_associated(scope, item)
&& get_private_object(scope, item, DATA_TRANSFER_ITEM_OWNER_SLOT)
.is_none_or(|owner| data_transfer_mode(scope, owner).can_read())
}
// Every write to the canonical item array runs inside DataTransferItemStore::mutate. The
// outcome controls the single observer notification and, consequently, the cached `types`
// identity required by Web IDL's CachedAttribute semantics.
@@ -581,6 +666,21 @@ impl<'s> DataTransferItemStore<'s> {
DataTransferItemListMutation::Changed(value) => (value, true),
};
if changed {
// Native event views share the backing item array. Array replacements
// must also reach the session before another event opens its view.
if let Some(owner) = item_list_owner(scope, self.item_list)
&& let Some(backing) = get_private_object(scope, owner, DATA_TRANSFER_STORE_SLOT)
&& let Some(backing_items) = Self::for_owner(scope, backing)
&& let Some(array) = item_list_array(scope, self.item_list)
{
set_private_value(
scope,
backing_items.item_list,
DATA_TRANSFER_ITEM_LIST_ARRAY_SLOT,
array.into(),
);
data_transfer_item_list_did_change(scope, backing_items.item_list);
}
data_transfer_item_list_did_change(scope, self.item_list);
}
Some(value)
@@ -623,14 +723,16 @@ impl<'s> DataTransferItemStore<'s> {
scope: &mut v8::PinScope<'s, '_>,
item: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Object>> {
self.mutate(scope, |scope, item_array| {
if append_item(scope, item_array, item).is_some() {
DataTransferItemListMutation::Changed(Some(item))
} else {
DataTransferItemListMutation::Unchanged(None)
}
})
.flatten()
let added = self
.mutate(scope, |scope, item_array| {
if append_item(scope, item_array, item).is_some() {
DataTransferItemListMutation::Changed(Some(item))
} else {
DataTransferItemListMutation::Unchanged(None)
}
})
.flatten()?;
data_transfer_item_view(scope, self.item_list, added)
}
fn set_string_data(self, scope: &mut v8::PinScope<'s, '_>, mime_type: &str, data: &str) {
@@ -751,35 +853,46 @@ pub(super) fn item_kind<'s>(
scope: &mut v8::PinScope<'s, '_>,
item: v8::Local<'s, v8::Object>,
) -> Option<String> {
private_string_property(scope, item, DATA_TRANSFER_ITEM_KIND_SLOT)
if !item_is_associated(scope, item) {
return Some(String::new());
}
let source = item_data_object(scope, item);
private_string_property(scope, source, DATA_TRANSFER_ITEM_KIND_SLOT)
}
fn item_type<'s>(
scope: &mut v8::PinScope<'s, '_>,
item: v8::Local<'s, v8::Object>,
) -> Option<String> {
private_string_property(scope, item, DATA_TRANSFER_ITEM_TYPE_SLOT)
if !item_is_associated(scope, item) {
return Some(String::new());
}
let source = item_data_object(scope, item);
private_string_property(scope, source, DATA_TRANSFER_ITEM_TYPE_SLOT)
}
pub(super) fn item_string_value<'s>(
scope: &mut v8::PinScope<'s, '_>,
item: v8::Local<'s, v8::Object>,
) -> Option<String> {
private_string_property(scope, item, DATA_TRANSFER_ITEM_STRING_SLOT)
let source = item_data_object(scope, item);
private_string_property(scope, source, DATA_TRANSFER_ITEM_STRING_SLOT)
}
fn item_file_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
item: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Object>> {
get_private_object(scope, item, DATA_TRANSFER_ITEM_FILE_SLOT)
let source = item_data_object(scope, item);
get_private_object(scope, source, DATA_TRANSFER_ITEM_FILE_SLOT)
}
fn item_entry_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
item: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Object>> {
get_private_object(scope, item, DATA_TRANSFER_ITEM_ENTRY_SLOT)
let source = item_data_object(scope, item);
get_private_object(scope, source, DATA_TRANSFER_ITEM_ENTRY_SLOT)
}
fn item_summary<'s>(
@@ -882,7 +995,8 @@ fn build_data_transfer_item_list_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Object>> {
DataTransferItemListObjectDeclaration::new(owner)
let views = v8::Map::new(scope);
DataTransferItemListObjectDeclaration::new(owner, views)
.bind(scope)
.ok()
}
@@ -1078,6 +1192,39 @@ fn data_transfer_item_list_did_change<'s>(
DATA_TRANSFER_TYPES_SLOT,
v8::undefined(scope).into(),
);
let mode = data_transfer_mode(scope, owner);
// Removed items and retired event views must not retain payload records in
// the identity cache. Disassociation never clears the shared store itself.
if let Some(views) = item_views(scope, item_list) {
let entries = views.as_array(scope);
let null = v8::null(scope);
for index in (0..entries.length()).step_by(2) {
let Some(key) = entries.get_index(scope, index) else {
continue;
};
let Ok(source) = v8::Local::<v8::Object>::try_from(key) else {
continue;
};
if mode != DataTransferMode::Disabled && item_is_associated(scope, source) {
continue;
}
if let Some(value) = entries.get_index(scope, index + 1)
&& let Ok(view) = v8::Local::<v8::Object>::try_from(value)
{
set_private_value(scope, view, DATA_TRANSFER_ITEM_SOURCE_SLOT, null.into());
}
let _ = views.delete(scope, key);
}
}
if mode == DataTransferMode::Disabled {
let empty = v8::Array::new(scope, 0);
set_private_value(
scope,
item_list,
DATA_TRANSFER_ITEM_LIST_ARRAY_SLOT,
empty.into(),
);
}
let Some(item_array) = item_list_array(scope, item_list) else {
return;
};
@@ -1099,8 +1246,11 @@ fn data_transfer_item_list_did_change<'s>(
let Ok(item) = v8::Local::<v8::Object>::try_from(value) else {
continue;
};
let _ = item_list.set_index(scope, index, item.into());
if item_kind(scope, item).as_deref() == Some("file")
if let Some(view) = data_transfer_item_view(scope, item_list, item) {
let _ = item_list.set_index(scope, index, view.into());
}
if mode.can_read()
&& item_kind(scope, item).as_deref() == Some("file")
&& let Some(file) = item_file_object(scope, item)
{
files.push(file);
@@ -1198,8 +1348,7 @@ fn data_transfer_drop_effect_setter<'s>(
return;
}
};
if data_transfer_mode(scope, args.this()) != DataTransferMode::Disabled
&& !private_bool_property(scope, args.this(), DATA_TRANSFER_CLIPBOARD_SLOT).unwrap_or(false)
if !private_bool_property(scope, args.this(), DATA_TRANSFER_CLIPBOARD_SLOT).unwrap_or(false)
&& valid_drop_effect(&value)
{
set_private_string(scope, args.this(), DATA_TRANSFER_DROP_EFFECT_SLOT, &value);
@@ -1251,7 +1400,7 @@ fn data_transfer_item_kind_getter<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let value = private_string_property(scope, args.this(), DATA_TRANSFER_ITEM_KIND_SLOT)
let value = item_kind(scope, args.this())
.and_then(|value| v8_string(scope, &value).map(Into::into))
.unwrap_or_else(|| v8str(scope, "").into());
rv.set(value);
@@ -1262,7 +1411,7 @@ fn data_transfer_item_type_getter<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let value = private_string_property(scope, args.this(), DATA_TRANSFER_ITEM_TYPE_SLOT)
let value = item_type(scope, args.this())
.and_then(|value| v8_string(scope, &value).map(Into::into))
.unwrap_or_else(|| v8str(scope, "").into());
rv.set(value);
@@ -1340,6 +1489,10 @@ pub(crate) fn data_transfer_get_data_callback<'s>(
let Some(parsed) = webidl::parse_args::<DataTransferGetDataArgs>(scope, &args) else {
return;
};
if !data_transfer_mode(scope, args.this()).can_read() {
rv.set(v8str(scope, "").into());
return;
}
let normalized_type = normalize_drag_data_type(&parsed.format);
let Some(item_list) = get_private_object(scope, args.this(), DATA_TRANSFER_ITEMS_SLOT) else {
rv.set(v8str(scope, "").into());
@@ -1532,7 +1685,9 @@ pub(crate) fn data_transfer_item_get_as_file_callback<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
if let Some(file) = item_file_object(scope, args.this()) {
if item_is_readable(scope, args.this())
&& let Some(file) = item_file_object(scope, args.this())
{
rv.set(file.into());
} else {
rv.set(v8::null(scope).into());
@@ -1544,6 +1699,10 @@ pub(crate) fn data_transfer_item_webkit_get_as_entry_callback<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s, v8::Value>,
) {
if !item_is_readable(scope, args.this()) {
rv.set(v8::null(scope).into());
return;
}
if let Some(entry) = item_entry_object(scope, args.this()) {
rv.set(entry.into());
return;
@@ -0,0 +1,98 @@
use super::*;
/// The session's backing transfer is never exposed to script. Native events
/// publish separate DataTransfer/item views with event-local access rights.
pub(crate) struct DragDataStore {
backing: v8::Global<v8::Object>,
}
impl DragDataStore {
pub(crate) fn new(scope: &mut v8::PinScope<'_, '_>, data: &RendererDragData) -> Option<Self> {
let backing = build_data_transfer_object(scope, data)?;
Some(Self {
backing: v8::Global::new(scope, backing),
})
}
pub(crate) fn backing_transfer<'s>(
&self,
scope: &mut v8::PinScope<'s, '_>,
) -> v8::Local<'s, v8::Object> {
v8::Local::new(scope, &self.backing)
}
pub(crate) fn open_event<'s>(
&self,
scope: &mut v8::PinScope<'s, '_>,
event_name: &str,
) -> Option<DragDataTransferEvent<'s>> {
let backing = self.backing_transfer(scope);
let transfer = DataTransferShellDeclaration::default().bind(scope).ok()?;
let items = initialize_data_transfer_object(scope, transfer)?;
let backing_items = DataTransferItemStore::for_owner(scope, backing)?;
let array = item_list_array(scope, backing_items.item_list)?;
set_private_value(
scope,
items,
DATA_TRANSFER_ITEM_LIST_ARRAY_SLOT,
array.into(),
);
set_private_value(scope, transfer, DATA_TRANSFER_STORE_SLOT, backing.into());
let mode = match event_name {
"dragstart" => DataTransferMode::ReadWrite,
"drop" => DataTransferMode::ReadOnly,
_ => DataTransferMode::Protected,
};
set_private_number(
scope,
transfer,
DATA_TRANSFER_MODE_SLOT,
f64::from(mode as u8),
);
for slot in [
DATA_TRANSFER_DROP_EFFECT_SLOT,
DATA_TRANSFER_EFFECT_ALLOWED_SLOT,
] {
if let Some(value) = get_private_value(scope, backing, slot) {
set_private_value(scope, transfer, slot, value);
}
}
data_transfer_item_list_did_change(scope, items);
Some(DragDataTransferEvent { backing, transfer })
}
}
pub(crate) struct DragDataTransferEvent<'s> {
backing: v8::Local<'s, v8::Object>,
transfer: v8::Local<'s, v8::Object>,
}
impl<'s> DragDataTransferEvent<'s> {
pub(crate) fn object(&self) -> v8::Local<'s, v8::Object> {
self.transfer
}
/// Preserve the negotiated effects before invalidating every live view.
/// Payloads stay with the backing store for later events and UA defaults.
pub(crate) fn finish(self, scope: &mut v8::PinScope<'s, '_>) {
for slot in [
DATA_TRANSFER_DROP_EFFECT_SLOT,
DATA_TRANSFER_EFFECT_ALLOWED_SLOT,
] {
if let Some(value) = get_private_value(scope, self.transfer, slot) {
set_private_value(scope, self.backing, slot, value);
}
}
set_private_number(
scope,
self.transfer,
DATA_TRANSFER_MODE_SLOT,
f64::from(DataTransferMode::Disabled as u8),
);
let null = v8::null(scope);
set_private_value(scope, self.transfer, DATA_TRANSFER_STORE_SLOT, null.into());
if let Some(items) = DataTransferItemStore::for_owner(scope, self.transfer) {
data_transfer_item_list_did_change(scope, items.item_list);
}
}
}
@@ -20,7 +20,7 @@ use crate::{
};
use moli_webidl_callback::WebIdlCallbackFunction;
use super::data_transfer::{item_kind, item_string_value};
use super::data_transfer::{item_is_readable, item_kind, item_string_value};
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "DataTransferItem.getAsString")]
@@ -112,7 +112,9 @@ pub(crate) fn data_transfer_item_get_as_string_callback<'s>(
let Some(callback) = parsed.callback else {
return;
};
if item_kind(scope, args.this()).as_deref() != Some("string") {
if !item_is_readable(scope, args.this())
|| item_kind(scope, args.this()).as_deref() != Some("string")
{
return;
}
let Some(value) = item_string_value(scope, args.this()) else {
@@ -285,8 +285,8 @@ pub(crate) use events::construct_focus_event;
pub(crate) use events::{
NodePublicEventDispatchOutcome, TextEditInputType, TouchEventPoint,
construct_activation_pointer_event, construct_clipboard_event, construct_command_event,
construct_drag_event, construct_drag_event_with_related_target, construct_form_data_event,
construct_input_event, construct_interest_event, construct_keyboard_event,
construct_drag_event_with_related_target, construct_form_data_event, construct_input_event,
construct_interest_event, construct_keyboard_event,
construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_modifiers,
construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event,
construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target,
@@ -12,12 +12,12 @@ pub(super) use self::constructors::construct_click_event;
pub(crate) use self::constructors::construct_focus_event;
pub(crate) use self::constructors::{
TextEditInputType, TouchEventPoint, construct_activation_pointer_event,
construct_clipboard_event, construct_command_event, construct_drag_event,
construct_drag_event_with_related_target, construct_form_data_event, construct_input_event,
construct_interest_event, construct_keyboard_event,
construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_modifiers,
construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event,
construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target,
construct_clipboard_event, construct_command_event, construct_drag_event_with_related_target,
construct_form_data_event, construct_input_event, construct_interest_event,
construct_keyboard_event, construct_mouse_event_with_detail_and_modifiers,
construct_mouse_event_with_modifiers, construct_mouse_event_with_related_target_and_modifiers,
construct_pointer_event, construct_pointer_event_with_modifiers,
construct_pointer_event_with_related_target,
construct_pointer_event_with_related_target_and_modifiers, construct_simple_event,
construct_simple_event_for_target, construct_submit_event, construct_toggle_event,
construct_touch_event, construct_touch_event_with_points, construct_wheel_event,
@@ -698,27 +698,6 @@ pub(crate) fn construct_pointer_event_with_related_target_and_modifiers<'s>(
construct_event(scope, "PointerEvent", event_type, init)
}
pub(crate) fn construct_drag_event<'s>(
scope: &mut v8::PinScope<'s, '_>,
event_type: &str,
x: f64,
y: f64,
buttons: i32,
data_transfer: v8::Local<'s, v8::Value>,
modifiers: u8,
) -> Option<v8::Local<'s, v8::Object>> {
construct_drag_event_with_related_target(
scope,
event_type,
x,
y,
buttons,
data_transfer,
modifiers,
None,
)
}
pub(crate) fn construct_drag_event_with_related_target<'s>(
scope: &mut v8::PinScope<'s, '_>,
event_type: &str,
+1 -1
View File
@@ -1185,7 +1185,7 @@ pub(super) struct ActiveTouchPoint {
}
pub(super) struct ActiveDragSession {
pub data_transfer: v8::Global<v8::Object>,
pub data_store: crate::context_bootstrap::DragDataStore,
pub drop_allowed: bool,
native: Option<input_drag::NativeDragSource>,
target: Option<crate::native_bridge::element::InputHit>,
@@ -22,10 +22,10 @@ use crate::native_bridge::element::{
activate_handle_after_pointer_release, activate_handle_via_click,
activate_handle_via_click_with_detail_and_modifiers, cache_input_files_from_selected_files,
captured_frame_input_surface_hit_test, construct_activation_pointer_event,
construct_drag_event, construct_keyboard_event,
construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_modifiers,
construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event,
construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target,
construct_keyboard_event, construct_mouse_event_with_detail_and_modifiers,
construct_mouse_event_with_modifiers, construct_mouse_event_with_related_target_and_modifiers,
construct_pointer_event, construct_pointer_event_with_modifiers,
construct_pointer_event_with_related_target,
construct_pointer_event_with_related_target_and_modifiers, construct_simple_event,
construct_touch_event, construct_touch_event_with_points, construct_wheel_event,
contenteditable_editing_host, dispatch_public_event, drag_start_frame_input_surface_hit_test,
@@ -1220,26 +1220,24 @@ impl ScriptVm {
directories: Vec::new(),
drag_operations_mask: 1,
};
if let Some(data_transfer) =
crate::context_bootstrap::build_data_transfer_object(scope, &empty_drag_data)
if let Some(data_store) =
crate::context_bootstrap::DragDataStore::new(scope, &empty_drag_data)
{
crate::context_bootstrap::initialize_native_drag_data_transfer(
scope,
data_transfer,
);
if let Some(event) = construct_drag_event(
let backing = data_store.backing_transfer(scope);
crate::context_bootstrap::initialize_native_drag_data_transfer(scope, backing);
if super::input_drag::fire_drag_event(
scope,
runtime_ptr,
drag_start_handle,
"dragstart",
f64::from(drag_position.x),
f64::from(drag_position.y),
drag_position,
buttons,
data_transfer.into(),
modifiers,
) && dispatch_public_event(scope, runtime_ptr, drag_start_handle, event)
.allows_default()
{
&data_store,
None,
) {
*active_drag_session = Some(ActiveDragSession {
data_transfer: v8::Global::new(scope, data_transfer),
data_store,
drop_allowed: false,
native: Some(super::input_drag::NativeDragSource {
handle: drag_start_handle,
@@ -2009,16 +2007,19 @@ impl ScriptVm {
data: RendererDragData,
modifiers: u8,
) -> Result<RendererInputDispatchOutcome> {
let Some(handle) = observable_input_hit_test(
let position = moli_layout::LayoutPoint::new(x as f32, y as f32);
let Some(hit) = observable_input_hit_test(
&self._context_host.borrow(),
self.document_runtime.document_handle(),
moli_layout::LayoutPoint::new(x as f32, y as f32),
position,
)?
.map(|hit| hit.handle) else {
else {
return Ok(input_dispatch_outcome(false));
};
let handle = hit.handle;
let incoming_drag_data_empty = data.items.is_empty() && data.files.is_empty();
let incoming_drag_data_empty =
data.items.is_empty() && data.files.is_empty() && data.directories.is_empty();
let replace_active_drag_data_transfer = event_name == "dragenter"
&& (!incoming_drag_data_empty || self.active_drag_session.is_none());
let clear_active_drag_data_transfer = event_name == "drop";
@@ -2030,48 +2031,38 @@ impl ScriptVm {
// remains exclusively borrowed, so the field pointer stays valid for the
// duration of this callback and is not aliased elsewhere.
let active_drag_session = unsafe { &mut *active_drag_session };
let data_transfer =
if replace_active_drag_data_transfer || active_drag_session.is_none() {
let Some(data_transfer) =
crate::context_bootstrap::build_data_transfer_object(scope, &data)
else {
return Ok(input_dispatch_outcome(false));
};
*active_drag_session = Some(ActiveDragSession {
data_transfer: v8::Global::new(scope, data_transfer),
drop_allowed: false,
native: None,
target: None,
});
data_transfer
} else {
v8::Local::new(
scope,
&active_drag_session
.as_ref()
.expect("active drag session should exist when reusing DataTransfer")
.data_transfer,
)
if replace_active_drag_data_transfer || active_drag_session.is_none() {
let Some(data_store) = crate::context_bootstrap::DragDataStore::new(scope, &data)
else {
return Ok(input_dispatch_outcome(false));
};
*active_drag_session = Some(ActiveDragSession {
data_store,
drop_allowed: false,
native: None,
target: None,
});
}
let session = active_drag_session
.as_ref()
.expect("active drag session exists");
let data_transfer = session.data_store.backing_transfer(scope);
crate::context_bootstrap::apply_drag_modifier_drop_effect(
scope,
data_transfer,
modifiers,
);
let data_transfer_value: v8::Local<'_, v8::Value> = data_transfer.into();
let allows_default = if let Some(event) = construct_drag_event(
let allows_default = super::input_drag::fire_drag_event(
scope,
runtime_ptr,
handle,
event_name,
x,
y,
hit.root_to_frame.map_point(position),
buttons,
data_transfer_value,
modifiers,
) {
dispatch_public_event(scope, runtime_ptr, handle, event).allows_default()
} else {
false
};
&session.data_store,
None,
);
if event_name == "dragover"
&& let Some(session) = active_drag_session.as_mut()
{
+60 -20
View File
@@ -6,7 +6,7 @@ use super::input_dispatch::{
};
use super::{ActiveDragSession, ScriptVm, input_dispatch_outcome};
use crate::context_bootstrap::{
allowed_drag_drop_effect, prepare_drag_drop_effect, set_drag_drop_effect,
DragDataStore, allowed_drag_drop_effect, prepare_drag_drop_effect, set_drag_drop_effect,
};
use crate::document_runtime::DomHandle;
use crate::native_bridge::JsContextHost;
@@ -77,27 +77,67 @@ fn dispatch_drag(
position: LayoutPoint,
buttons: i32,
modifiers: u8,
transfer: &v8::Global<v8::Object>,
store: &DragDataStore,
related: Option<DomHandle>,
) -> bool {
let transfer = v8::Local::new(scope, transfer);
let point = target.root_to_frame.map_point(position);
fire_drag_event(
scope,
runtime,
target.handle,
event_name,
point,
buttons,
modifiers,
store,
related,
)
}
/// A native drag event owns one temporary view of the session's data store.
/// Construct it in the target Document's realm, then retire it after dispatch.
pub(super) fn fire_drag_event(
scope: &mut v8::PinScope<'_, '_>,
runtime: *mut JsContextHost,
target: DomHandle,
event_name: &str,
position: LayoutPoint,
buttons: i32,
modifiers: u8,
store: &DragDataStore,
related: Option<DomHandle>,
) -> bool {
let host = unsafe { &mut *runtime };
let Some(context) = host
.owner_dispatch_scope_for_node(target)
.and_then(|target| {
let owner = host.current_window_execution_context_owner(target)?;
host.window_execution_context(scope, owner, target)
.map(|(_, context)| context)
})
else {
return false;
};
let scope = &mut v8::ContextScope::new(scope, context);
let Some(view) = store.open_event(scope, event_name) else {
return false;
};
let related = related
.and_then(|handle| node_wrapper_from_handle(scope, handle))
.map(Into::into);
let point = target.root_to_frame.map_point(position);
construct_drag_event_with_related_target(
let allows_default = construct_drag_event_with_related_target(
scope,
event_name,
f64::from(point.x),
f64::from(point.y),
f64::from(position.x),
f64::from(position.y),
buttons,
transfer.into(),
view.object().into(),
modifiers,
related,
)
.is_some_and(|event| {
dispatch_public_event(scope, runtime, target.handle, event).allows_default()
})
.is_some_and(|event| dispatch_public_event(scope, runtime, target, event).allows_default());
view.finish(scope);
allows_default
}
fn end_native_drag(
@@ -112,7 +152,7 @@ fn end_native_drag(
let Some(source) = session.native else {
return;
};
let transfer = v8::Local::new(scope, &session.data_transfer);
let transfer = session.data_store.backing_transfer(scope);
let mut effect = if allow_drop && session.drop_allowed {
allowed_drag_drop_effect(scope, transfer)
} else {
@@ -129,7 +169,7 @@ fn end_native_drag(
position,
buttons,
modifiers,
&session.data_transfer,
&session.data_store,
None,
);
if allows_default && !perform_drop_default_action(scope, runtime, target.handle, transfer) {
@@ -147,7 +187,7 @@ fn end_native_drag(
position,
buttons,
modifiers,
&session.data_transfer,
&session.data_store,
None,
);
}
@@ -163,7 +203,7 @@ fn end_native_drag(
position,
0,
modifiers,
&session.data_transfer,
&session.data_store,
None,
);
}
@@ -224,7 +264,7 @@ impl ScriptVm {
);
finished = true;
} else if event_name == "mousemove" {
let transfer = v8::Local::new(scope, &session.data_transfer);
let transfer = session.data_store.backing_transfer(scope);
set_drag_drop_effect(scope, transfer, "none");
if !dispatch_drag(
scope,
@@ -237,7 +277,7 @@ impl ScriptVm {
position,
buttons,
modifiers,
&session.data_transfer,
&session.data_store,
None,
) {
end_native_drag(scope, runtime, &session, position, 0, modifiers, false);
@@ -257,7 +297,7 @@ impl ScriptVm {
position,
buttons,
modifiers,
&session.data_transfer,
&session.data_store,
previous.map(|h| h.handle),
);
}
@@ -271,7 +311,7 @@ impl ScriptVm {
position,
buttons,
modifiers,
&session.data_transfer,
&session.data_store,
hit.map(|h| h.handle),
);
}
@@ -287,7 +327,7 @@ impl ScriptVm {
position,
buttons,
modifiers,
&session.data_transfer,
&session.data_store,
None,
) && allowed_drag_drop_effect(scope, transfer) != "none"
} else {
@@ -0,0 +1,123 @@
globalThis.__installDragDataModeProbe = function(mode, capture, modifiers) {
document.body.innerHTML = '<style>body{margin:0;user-select:none}#source{position:absolute;left:40px;top:40px;width:140px;height:140px}#a{position:absolute;left:300px;top:40px;width:140px;height:140px}#b{position:absolute;left:500px;top:40px;width:140px;height:140px}</style><div id="source" draggable="true">source</div><div id="a">A</div><div id="b">B</div>';
const source = document.getElementById('source');
const file = new File(['private-file-contents'], 'private.txt', {type:'text/plain'});
const rows = [], transfers = [], callbacks = [], microtasks = [], trace = [];
const expectedTypes = ['text/plain', 'text/html', 'application/test', 'Files'];
const checks = [];
const check = (name, actual, expected) => checks.push({name, actual, expected, pass:JSON.stringify(actual)===JSON.stringify(expected)});
const exceptionName = action => { try { action(); return 'none'; } catch (error) { return error.name; } };
let starts = 0, drops = 0;
function retain(dt, event) {
const row = {dt, event, items:dt.items, files:dt.files, types:dt.types, string:dt.items[0], file:dt.items[3]};
transfers.push(row);
return row;
}
function disabled(row, label) {
const prefix = label + ':' + row.event + ':' + transfers.indexOf(row);
const dt = row.dt;
check(prefix+':empty getData', dt.getData('text/plain'), '');
check(prefix+':empty types', Array.from(dt.types), []);
check(prefix+':new frozen types snapshot', dt.types !== row.types && Object.isFrozen(dt.types), true);
check(prefix+':saved types unchanged', Array.from(row.types), expectedTypes);
check(prefix+':items same object', dt.items === row.items, true);
check(prefix+':items empty', row.items.length, 0);
check(prefix+':indexed item absent', row.items[0] === undefined, true);
check(prefix+':files same object', dt.files === row.files, true);
check(prefix+':saved files empty', row.files.length, 0);
if (row.string) {
check(prefix+':saved string metadata disabled', [row.string.kind,row.string.type], ['','']);
row.string.getAsString(value => callbacks.push({phase:'disabled',value}));
}
if (row.file) {
check(prefix+':saved file metadata disabled', [row.file.kind,row.file.type], ['','']);
check(prefix+':saved file unavailable', row.file.getAsFile(), null);
check(prefix+':saved entry unavailable', row.file.webkitGetAsEntry(), null);
}
check(prefix+':add blocked', row.items.add('no','application/blocked'), null);
check(prefix+':remove disabled throws', exceptionName(()=>row.items.remove(999)), 'InvalidStateError');
const allowed = dt.effectAllowed;
dt.effectAllowed = 'none';
check(prefix+':effectAllowed immutable', dt.effectAllowed, allowed);
dt.setData('text/plain','escaped');
dt.clearData();
row.items.clear();
check(prefix+':store cannot be repopulated', [dt.getData('text/plain'),row.items.length,dt.types.length], ['',0,0]);
}
for (const type of ['dragstart','drag','dragenter','dragover','dragleave','drop','dragend']) {
document.addEventListener(type, event => {
const dt = event.dataTransfer;
const prefix = type+':'+rows.length;
check(prefix+':fresh event DataTransfer', transfers.every(row=>row.dt!==dt), true);
for (const row of transfers) disabled(row, prefix+':previous');
if (type==='dragstart') {
starts++;
dt.setData('text/plain','payload');
dt.setData('text/html','<b>payload</b>');
dt.items.add('extra','application/test');
const added = dt.items.add(file);
check(prefix+':added file is indexed view', added===dt.items[3], true);
dt.effectAllowed='all';
}
const row = retain(dt,type);
check(prefix+':same items object', dt.items===dt.items, true);
check(prefix+':same files object', dt.files===dt.files, true);
check(prefix+':stable frozen types', dt.types===row.types && Object.isFrozen(row.types), true);
check(prefix+':metadata enumerable', Array.from(dt.types), expectedTypes);
check(prefix+':indexed metadata', Array.from(dt.items, item=>[item.kind,item.type]), [['string','text/plain'],['string','text/html'],['string','application/test'],['file','text/plain']]);
const readable = type==='dragstart' || type==='drop';
check(prefix+':payload read mode', dt.getData('text/plain'), readable?'payload':'');
check(prefix+':file list read mode', dt.files.length, readable?1:0);
if (row.file) {
check(prefix+':file item read mode', readable?row.file.getAsFile()===file:row.file.getAsFile()===null, true);
check(prefix+':entry read mode', !!row.file.webkitGetAsEntry(), readable);
}
if (row.string) {
row.string.getAsString(value=>callbacks.push({phase:type,value}));
check(prefix+':callback conversion before mode', exceptionName(()=>row.string.getAsString({})), 'TypeError');
}
let conversions=0;
check(prefix+':format conversion still runs', dt.getData({toString(){conversions++;return 'text/plain'}}), readable?'payload':'');
check(prefix+':format conversion count', conversions, 1);
const marker={};
let caught;
try { dt.getData({toString(){throw marker;}}); } catch (error) {caught=error;}
check(prefix+':format conversion exception propagates', caught===marker, true);
Promise.resolve().then(()=>microtasks.push({phase:type,data:dt.getData('text/plain'),items:dt.items.length}));
if (type!=='dragstart') {
const before=Array.from(dt.types), allowed=dt.effectAllowed;
dt.setData('text/plain','tampered');
dt.clearData('text/html');
check(prefix+':string add blocked', dt.items.add('no','application/blocked'), null);
check(prefix+':file add blocked', dt.items.add(file), null);
check(prefix+':remove readonly/protected throws', exceptionName(()=>dt.items.remove(999)), 'InvalidStateError');
dt.items.clear();
dt.effectAllowed='none';
check(prefix+':readonly/protected mutations ignored', Array.from(dt.types), before);
check(prefix+':effectAllowed cannot change', dt.effectAllowed, allowed);
check(prefix+':data remains unmodified', dt.getData('text/plain'), readable?'payload':'');
}
if (type==='dragenter' || type==='dragover') {
dt.dropEffect='copy';
check(prefix+':dropEffect writable', dt.dropEffect, 'copy');
if (mode==='drop') event.preventDefault();
}
if (type==='drop') {drops++;event.preventDefault();}
if (type==='dragstart' && mode==='cancel') event.preventDefault();
rows.push({entryAvailable:!!row.file?.webkitGetAsEntry(),type,data:dt.getData('text/plain'),items:dt.items.length,files:dt.files.length,types:Array.from(dt.types),dropEffect:dt.dropEffect,effectAllowed:dt.effectAllowed});
trace.push(type);
});
}
if (capture) source.addEventListener('pointerdown', event=>source.setPointerCapture(event.pointerId));
return {
afterDispatch(label) {for(const row of transfers) disabled(row,label);},
finish() {
for(const row of transfers) disabled(row,'finish');
check('one native dragstart', starts, 1);
check('drop iff accepted', drops, mode==='drop'?1:0);
check('only readable events schedule string callbacks', callbacks.filter(row=>!['dragstart','drop'].includes(row.phase)), []);
check('captured callback payload survives disassociation', callbacks.filter(row=>['dragstart','drop'].includes(row.phase)).map(row=>row.value), mode==='drop'?['payload','payload']:['payload']);
return {mode,capture,modifiers,checks,rows,callbacks,microtasks,trace,passed:checks.filter(c=>c.pass).length,total:checks.length,complete:checks.every(c=>c.pass)};
}
};
};
@@ -0,0 +1,234 @@
use super::*;
fn mode_input(vm: &mut ScriptVm, pointer_type: &str, input: (&str, f64, i32, u8)) {
let (event, x, buttons, modifiers) = input;
vm.dispatch_mouse_event_at_point_with_pointer_and_modifiers(
x,
80.0,
event,
if event == "mousemove" { -1 } else { 0 },
Some(buttons),
i32::from(event != "mousemove"),
0.0,
0.0,
crate::runtime::RendererPointerEventProperties {
pointer_id: 17,
pointer_type: pointer_type.to_owned(),
pressure: if buttons == 0 { 0.0 } else { 0.5 },
..Default::default()
},
modifiers,
)
.unwrap();
}
#[tokio::test]
async fn native_drag_data_modes_invalidate_views_and_preserve_queued_strings() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap();
for pointer in ["mouse", "pen"] {
for capture in [false, true] {
for mode in ["cancel", "drop", "reject", "escape"] {
for modifiers in [0, 8, 15] {
let mut vm = new_storage_page_task_executor_test_vm_with_loader(
"https://drag-data-modes.test/",
&loader,
);
vm.eval("if (!document.documentElement) document.appendChild(document.createElement('html')); if (!document.body) document.documentElement.appendChild(document.createElement('body')); 'ready'").unwrap();
vm.eval(&format!("{}; globalThis.__dragResults=__installDragDataModeProbe('{mode}',{capture},{modifiers}); 'ready'", include_str!("drag_data_modes.js"))).unwrap();
vm.publish_layout_for_test().unwrap();
for input in [
("mousemove", 80.0, 0, modifiers),
("mousedown", 80.0, 1, modifiers),
("mousemove", 100.0, 1, modifiers),
] {
mode_input(&mut vm, pointer, input);
}
if mode == "escape" {
vm.dispatch_key_event("keydown", "Escape", "Escape", "", 0, false, false)
.unwrap();
} else {
for x in [320.0, 520.0] {
mode_input(&mut vm, pointer, ("mousemove", x, 1, modifiers ^ 8));
}
}
vm.eval("__dragResults.afterDispatch('before-release'); 'ready'")
.unwrap();
mode_input(
&mut vm,
pointer,
(
"mouseup",
if mode == "escape" { 100.0 } else { 520.0 },
0,
modifiers ^ 8,
),
);
vm.eval("__dragResults.afterDispatch('after-release'); 'ready'")
.unwrap();
let mut tasks = 0;
while vm
.run_one_user_interaction_executor_turn(&loader)
.await
.unwrap()
{
tasks += 1;
assert!(
tasks <= 2,
"protected/disabled items must not schedule callbacks"
);
}
assert_eq!(tasks, if mode == "drop" { 2 } else { 1 });
assert_eq!(vm.eval("globalThis.__dragFinished=__dragResults.finish(); __dragFinished.complete").unwrap(), "true", "{pointer}, {mode}, capture {capture}, modifiers {modifiers}: {}", vm.eval("JSON.stringify(__dragFinished.checks.filter(c=>!c.pass))").unwrap());
assert_eq!(vm.eval("__dragFinished.microtasks.length===__dragFinished.rows.length && __dragFinished.microtasks.every(row=>row.items===4 && row.data===(['dragstart','drop'].includes(row.phase)?'payload':''))").unwrap(), "true", "listener cleanup must run while the event view is still associated");
}
}
}
}
}
#[test]
fn constructed_transfer_remains_writable_after_synthetic_event_dispatch() {
let mut vm = new_storage_test_vm("https://synthetic-transfer.test/");
assert_eq!(
vm.eval(
r#"
globalThis.dt=new DataTransfer(); const item=dt.items.add('before','text/plain');
document.dispatchEvent(new DragEvent('drop',{dataTransfer:dt}));
dt.setData('text/plain','after');
JSON.stringify([dt.getData('text/plain'),dt.items[0]===item,item.kind,dt.items.length])
"#
)
.unwrap(),
r#"["after",true,"string",1]"#
);
}
#[test]
fn transfer_receiver_validation_precedes_argument_conversion() {
let mut vm = new_storage_test_vm("https://transfer-receiver.test/");
assert_eq!(vm.eval(r#"
const dt=new DataTransfer(); dt.setData('text/plain','value');
const classes=[DataTransfer,DataTransferItemList,DataTransferItem];
const objects=[dt,dt.items,dt.items[0]];
let conversions=0; const format={toString(){conversions++; return 'text/plain';}};
const failures=[];
for(let i=0;i<classes.length;i++) {
const proto=classes[i].prototype;
for(const receiver of [{},Object.create(objects[i]),new Proxy(objects[i],{})]) {
const method=i===0?()=>proto.getData.call(receiver,format):i===1?()=>proto.remove.call(receiver,{valueOf(){conversions++;return 0;}}):()=>proto.getAsString.call(receiver,{});
try {method(); failures.push(false);} catch(e) {failures.push(e instanceof TypeError);}
}
}
JSON.stringify([failures.every(Boolean),conversions])
"#).unwrap(), "[true,0]");
}
#[test]
fn external_drag_views_hide_payload_until_drop_and_keep_text_default_action() {
use crate::runtime::{RendererDragData, RendererDragDataItem};
let mut vm = new_rendered_test_vm(
"https://external-drag.test/",
"<html><body style='margin:0'><input id='target' style='width:200px;height:100px'></body></html>",
);
vm.eval(r#"
globalThis.transfers=[]; globalThis.observations=[];
for (const type of ['dragenter','dragover','drop']) target.addEventListener(type,event=>{
const dt=event.dataTransfer;
observations.push([type,dt.getData('text/plain'),dt.items.length,Array.from(dt.types)]);
dt.setData('text/plain','tampered'); dt.clearData();
observations.push([dt.getData('text/plain'),transfers.every(old=>old.items.length===0 && old.getData('text/plain')==='')]);
transfers.push(dt);
}); 'ready'
"#).unwrap();
let data = RendererDragData {
items: vec![RendererDragDataItem {
mime_type: "text/plain".to_owned(),
data: "external".to_owned(),
title: None,
base_url: None,
}],
files: vec![],
directories: vec![],
drag_operations_mask: 1,
};
for event in ["dragenter", "dragover", "drop"] {
vm.dispatch_drag_event_at_point(10.0, 10.0, event, data.clone(), 0)
.unwrap();
}
assert_eq!(vm.eval("JSON.stringify([observations,target.value,transfers.every(dt=>dt.items.length===0 && dt.types.length===0 && dt.getData('text/plain')==='')])").unwrap(), r#"[[["dragenter","",1,["text/plain"]],["",true],["dragover","",1,["text/plain"]],["",true],["drop","external",1,["text/plain"]],["external",true]],"external",true]"#);
}
#[test]
fn external_file_drop_uses_backing_store_after_event_view_is_disabled() {
use crate::runtime::{RendererDragData, RendererDraggedFile};
let mut vm = new_rendered_test_vm(
"https://external-file-drag.test/",
"<html><body style='margin:0'><input id='target' type='file' style='width:200px;height:100px'></body></html>",
);
vm.eval(r#"
globalThis.saved=null;globalThis.payload=null;globalThis.events=[];
target.addEventListener('drop',event=>{
saved=event.dataTransfer; payload=saved.items[0];
events.push([saved.files.length,payload.getAsFile()===saved.files[0],payload.getAsFile().name]);
saved.items.clear(); saved.setData('text/plain','tampered');
});
target.addEventListener('input',()=>events.push(['input',saved.files.length,saved.items.length]));
'ready'
"#).unwrap();
let data = RendererDragData {
items: vec![],
files: vec![RendererDraggedFile {
bytes: b"contents".to_vec(),
mime_type: "text/plain".to_owned(),
name: "upload.txt".to_owned(),
last_modified: 7.0,
}],
directories: vec![],
drag_operations_mask: 1,
};
vm.dispatch_drag_event_at_point(10.0, 10.0, "drop", data, 0)
.unwrap();
assert_eq!(vm.eval("JSON.stringify([events,target.files[0].name,target.files[0].size,saved.files.length,payload.kind,payload.getAsFile()])").unwrap(),r#"[[[1,true,"upload.txt"],["input",0,0]],"upload.txt",8,0,"",null]"#);
}
#[test]
fn native_drag_event_views_use_each_target_documents_realm() {
for origin in ["parent", "child"] {
let mut vm = new_rendered_test_vm(
"https://drag-realms.test/",
"<html><body style='margin:0'><div id='outside' style='position:absolute;left:40px;top:40px;width:120px;height:120px'></div><iframe id='frame' style='position:absolute;left:300px;top:40px;width:160px;height:160px;border:0'></iframe></body></html>",
);
vm.eval(&format!(r#"
globalThis.child=frame.contentWindow;
child.document.body.innerHTML='<div id="inside" style="position:absolute;left:0;top:0;width:160px;height:160px"></div>';
const source={source};source.draggable=true;
globalThis.rows=[];globalThis.saved=[];
for(const [win,name] of [[window,'parent'],[child,'child']]) {{
for(const type of ['dragstart','dragenter','dragover','drop','dragend']) win.document.addEventListener(type,event=>{{
const dt=event.dataTransfer;
if(type==='dragstart') {{dt.setData('text/plain','realm-payload');dt.effectAllowed='copy';}}
rows.push([name,type,event instanceof win.DragEvent,dt instanceof win.DataTransfer,dt.items instanceof win.DataTransferItemList,dt.items[0] instanceof win.DataTransferItem,event.view===win,saved.every(old=>old.items.length===0 && old.types.length===0),dt.getData('text/plain')]);
saved.push(dt);
if(type==='dragenter'||type==='dragover') {{dt.dropEffect='copy';event.preventDefault();}}
if(type==='drop') event.preventDefault();
}});
}} 'ready'
"#,source=if origin=="parent" {"outside"} else {"child.document.getElementById('inside')"})).unwrap();
vm.publish_layout_for_test().unwrap();
let (start, end) = if origin == "parent" {
(80.0, 320.0)
} else {
(320.0, 80.0)
};
for input in [
("mousemove", start, 0, 0),
("mousedown", start, 1, 0),
("mousemove", start + 20.0, 1, 0),
("mousemove", end, 1, 0),
("mouseup", end, 0, 0),
] {
mode_input(&mut vm, "mouse", input);
}
assert_eq!(vm.eval("rows.every(row=>row.slice(2,8).every(Boolean) && row[8]===(['dragstart','drop'].includes(row[1])?'realm-payload':'')) && rows.filter(row=>row[1]==='drop').length===1 && saved.every(dt=>dt.items.length===0)").unwrap(),"true","{origin}: {}",vm.eval("JSON.stringify(rows)").unwrap());
}
}
@@ -26,6 +26,7 @@ mod document_domain_setter;
mod document_open_navigation_abort;
mod document_open_origin;
mod dom_event_worlds;
mod drag_data_modes;
mod drag_init;
mod drag_lifecycle;
mod error_event_init;
@@ -93,7 +93,7 @@ fn native_event_producers_bypass_author_constructors_dictionaries_and_indexed_se
0,
),
element::construct_pointer_event(scope, "pointerdown", 11.0, 12.0, 0, 1, &pointer),
element::construct_drag_event(
element::construct_drag_event_with_related_target(
scope,
"dragstart",
11.0,
@@ -101,6 +101,7 @@ fn native_event_producers_bypass_author_constructors_dictionaries_and_indexed_se
1,
transfer.into(),
0,
None,
),
element::construct_wheel_event(scope, "wheel", 11.0, 12.0, 4.0, 5.0, 0, 1, 0),
element::construct_touch_event_with_points(
@@ -503,6 +503,9 @@ fn data_transfer_declared_slots_ignore_prototype_spoofing() {
entry.__lmFileSystemEntryIsFile = false;
entry.__lmFileSystemEntryIsDirectory = true;
const receiverError = action => {
try { action(); return "no error"; } catch (error) { return error.name; }
};
return JSON.stringify({
ownNamesBefore,
descriptors,
@@ -521,14 +524,14 @@ fn data_transfer_declared_slots_ignore_prototype_spoofing() {
entry.isDirectory
].join('|'),
fake: [
DataTransfer.prototype.getData.call(fakeDataTransfer, 'text/plain'),
fakeDataTransfer.types === undefined ? 'undefined' : fakeDataTransfer.types.join(','),
fakeDataTransfer.dropEffect,
fakeDataTransfer.effectAllowed,
fakeList.length,
fakeItem.kind,
fakeItem.type,
fakeItem.getAsFile(),
receiverError(() => DataTransfer.prototype.getData.call(fakeDataTransfer, 'text/plain')),
receiverError(() => fakeDataTransfer.types === undefined ? 'undefined' : fakeDataTransfer.types.join(',')),
receiverError(() => fakeDataTransfer.dropEffect),
receiverError(() => fakeDataTransfer.effectAllowed),
receiverError(() => fakeList.length),
receiverError(() => fakeItem.kind),
receiverError(() => fakeItem.type),
receiverError(() => fakeItem.getAsFile()),
entryNameGetter.call(fakeEntry),
entryPathGetter.call(fakeEntry)
].map(value => value === null ? 'null' : String(value)).join('|')
@@ -540,7 +543,7 @@ fn data_transfer_declared_slots_ignore_prototype_spoofing() {
assert_eq!(
result,
r#"{"ownNamesBefore":{"dataTransfer":[],"itemList":[],"item":[],"entry":[]},"descriptors":{"dataTransfer":["files:function:get files:0:undefined:none:none:true:true","items:function:get items:0:undefined:none:none:true:true","types:function:get types:0:undefined:none:none:true:true","dropEffect:function:get dropEffect:0:function:set dropEffect:1:true:true","effectAllowed:function:get effectAllowed:0:function:set effectAllowed:1:true:true"],"itemList":["length:function:get length:0:undefined:none:none:true:true"],"item":["kind:function:get kind:0:undefined:none:none:true:true","type:function:get type:0:undefined:none:none:true:true"],"entry":["filesystem:function:get filesystem:0:undefined:none:none:true:true","fullPath:function:get fullPath:0:undefined:none:none:true:true","isDirectory:function:get isDirectory:0:undefined:none:none:true:true","isFile:function:get isFile:0:undefined:none:none:true:true","name:function:get name:0:undefined:none:none:true:true"]},"real":"alpha|text/plain,Files|none|none|2|file|text/plain|note.txt|note.txt|/note.txt|true|false","fake":"|undefined|none|uninitialized|0|||null||"}"#
r#"{"ownNamesBefore":{"dataTransfer":[],"itemList":[],"item":[],"entry":[]},"descriptors":{"dataTransfer":["files:function:get files:0:undefined:none:none:true:true","items:function:get items:0:undefined:none:none:true:true","types:function:get types:0:undefined:none:none:true:true","dropEffect:function:get dropEffect:0:function:set dropEffect:1:true:true","effectAllowed:function:get effectAllowed:0:function:set effectAllowed:1:true:true"],"itemList":["length:function:get length:0:undefined:none:none:true:true"],"item":["kind:function:get kind:0:undefined:none:none:true:true","type:function:get type:0:undefined:none:none:true:true"],"entry":["filesystem:function:get filesystem:0:undefined:none:none:true:true","fullPath:function:get fullPath:0:undefined:none:none:true:true","isDirectory:function:get isDirectory:0:undefined:none:none:true:true","isFile:function:get isFile:0:undefined:none:none:true:true","name:function:get name:0:undefined:none:none:true:true"]},"real":"alpha|text/plain,Files|none|none|2|file|text/plain|note.txt|note.txt|/note.txt|true|false","fake":"TypeError|TypeError|TypeError|TypeError|TypeError|TypeError|TypeError|TypeError||"}"#
);
}
@@ -47,11 +47,14 @@ const dropped = new Promise(function (resolve) {
promise_test(async function () {
await dropped;
assert_equals(firstDataTransfer.items.length, 0, "saved dragenter view is disabled");
assert_equals(firstDataTransfer.types.length, 0, "cancel does not reactivate the old view");
assert_equals(firstDataTransfer.getData("text"), "", "old payload stays inaccessible");
assert_array_equals(
events,
[
"dragenter:initial:true",
"dragover:mutated-before-cancel:true",
"dragenter::true",
"dragover::false",
"drop:fresh-after-cancel:false",
],
"dragCancel should clear the active DataTransfer without dispatching a DOM event",
@@ -71,16 +71,18 @@ const dropped = new Promise(function (resolve) {
promise_test(async function () {
await dropped;
assert_equals(firstDataTransfer.items.length, 0, "saved event item list is disabled");
assert_equals(firstDataTransfer.getData("text"), "", "saved event cannot read the store");
assert_array_equals(
events,
[
"dragenter:true:initial-text:copy:copyMove",
"dragover-before:true:initial-text",
"dragover-after:mutated-in-over:move",
"drop:true:mutated-in-over:move:hello world",
"input:hello mutated-in-over:21:21",
"dragenter:true::copy:copyMove",
"dragover-before:false:",
"dragover-after::move",
"drop:false:initial-text:move:hello world",
"input:hello initial-text:18:18",
],
"drag lifecycle should reuse live DataTransfer and run text drop default action",
"drag lifecycle should use temporary protected views and preserve data for the text drop default action",
);
}, "Input.dispatchDragEvent drives live DataTransfer reuse and text input default drop");
}, "Input.dispatchDragEvent protects event views and preserves text input default drop");
</script>
@@ -6,90 +6,90 @@
<script src="/wpt/resources/moli-wpt-adapter.js"></script>
<script>
const dropzone = document.getElementById("dropzone");
let dropped = null;
let resolveDrop = null;
const dropPromise = new Promise(function (resolve) {
let resolveDrop, rejectDrop;
const dropPromise = new Promise((resolve, reject) => {
resolveDrop = resolve;
rejectDrop = reject;
});
function item_string(item) {
return new Promise(function (resolve) {
item.getAsString(resolve);
});
return new Promise(resolve => item.getAsString(resolve));
}
function entry_file(entry) {
return new Promise(function (resolve) {
entry.file(resolve);
});
return new Promise(resolve => entry.file(resolve));
}
function read_entries(reader) {
return new Promise(function (resolve) {
reader.readEntries(resolve);
});
return new Promise(resolve => reader.readEntries(resolve));
}
dropzone.addEventListener("dragover", function (event) {
dropzone.addEventListener("dragover", event => event.preventDefault());
dropzone.addEventListener("drop", event => {
event.preventDefault();
try {
const dropped = event.dataTransfer;
assert_equals(dropped.items.length, 4, "mixed payload exposes all item entries during drop");
assert_array_equals(Array.from(dropped.types), ["text/plain", "text/html", "Files"],
"types include string MIME types followed by Files");
assert_equals(dropped.files.length, 1, "only real file payloads appear in files");
const file = dropped.files[0];
assert_equals(file.name, "report.txt", "FileList file name");
assert_equals(file.type, "text/plain", "FileList file type");
assert_equals(file.lastModified, 21, "FileList file lastModified");
const [plain, html, fileItem, directoryItem] = Array.from(dropped.items);
assert_equals(plain.kind, "string", "plain item kind");
assert_equals(plain.type, "text/plain", "plain item type");
assert_equals(plain.getAsFile(), null, "string item does not expose a File");
assert_equals(plain.webkitGetAsEntry(), null, "string item does not expose an entry");
assert_equals(html.kind, "string", "HTML item kind");
assert_equals(html.type, "text/html", "HTML item type");
assert_equals(fileItem.kind, "file", "file item kind");
assert_equals(fileItem.type, "text/plain", "file item type");
assert_equals(fileItem.getAsFile(), file, "file item shares the FileList wrapper");
const fileEntry = fileItem.webkitGetAsEntry();
assert_true(fileEntry instanceof FileSystemFileEntry, "file item exposes a file entry");
assert_equals(fileEntry.fullPath, "/report.txt", "file entry fullPath");
assert_equals(directoryItem.kind, "file", "directory item kind");
assert_equals(directoryItem.type, "", "directory item has empty MIME type");
assert_equals(directoryItem.getAsFile(), null, "directory item does not expose a File");
const directoryEntry = directoryItem.webkitGetAsEntry();
assert_true(directoryEntry instanceof FileSystemDirectoryEntry,
"directory item exposes a directory entry");
assert_equals(directoryEntry.fullPath, "/bundle", "directory entry fullPath");
// Capture eligible callbacks and durable File/Entry objects during dispatch.
resolveDrop({dropped, file, fileEntry, directoryEntry,
items: [plain, html, fileItem, directoryItem],
plainText: item_string(plain), htmlText: item_string(html)});
} catch (error) {
rejectDrop(error);
}
});
dropzone.addEventListener("drop", function (event) {
event.preventDefault();
dropped = event.dataTransfer;
resolveDrop();
});
promise_test(async function () {
await dropPromise;
assert_equals(dropped.items.length, 4, "mixed payload exposes all DataTransferItem entries");
assert_array_equals(
Array.from(dropped.types),
["text/plain", "text/html", "Files"],
"types include string MIME types followed by Files",
);
assert_equals(dropped.files.length, 1, "only real file payloads appear in DataTransfer.files");
assert_equals(dropped.files[0].name, "report.txt", "FileList file name");
assert_equals(dropped.files[0].type, "text/plain", "FileList file type");
assert_equals(dropped.files[0].lastModified, 21, "FileList file lastModified");
assert_equals(await dropped.files[0].text(), "file payload", "FileList file bytes");
const plain = dropped.items[0];
assert_equals(plain.kind, "string", "plain item kind");
assert_equals(plain.type, "text/plain", "plain item type");
assert_equals(await item_string(plain), "plain payload", "plain string payload");
assert_equals(plain.getAsFile(), null, "string item does not expose a File");
assert_equals(plain.webkitGetAsEntry(), null, "string item does not expose an entry");
const html = dropped.items[1];
assert_equals(html.kind, "string", "HTML item kind");
assert_equals(html.type, "text/html", "HTML item type");
assert_equals(await item_string(html), "<b>html payload</b>", "HTML string payload");
const fileItem = dropped.items[2];
assert_equals(fileItem.kind, "file", "file item kind");
assert_equals(fileItem.type, "text/plain", "file item type");
assert_equals(fileItem.getAsFile(), dropped.files[0], "file item shares the FileList wrapper");
const fileEntry = fileItem.webkitGetAsEntry();
assert_true(fileEntry instanceof FileSystemFileEntry, "file item exposes a file entry");
assert_equals(fileEntry.fullPath, "/report.txt", "file entry fullPath");
const entryBackedFile = await entry_file(fileEntry);
promise_test(async () => {
const payload = await dropPromise;
const [plain, html] = await Promise.all([payload.plainText, payload.htmlText]);
assert_equals(plain, "plain payload", "captured plain string survives disassociation");
assert_equals(html, "<b>html payload</b>", "captured HTML string survives disassociation");
assert_equals(await payload.file.text(), "file payload", "captured File preserves bytes");
const entryBackedFile = await entry_file(payload.fileEntry);
assert_equals(await entryBackedFile.text(), "file payload", "file entry materializes the same bytes");
const directoryItem = dropped.items[3];
assert_equals(directoryItem.kind, "file", "directory item kind");
assert_equals(directoryItem.type, "", "directory item has empty MIME type");
assert_equals(directoryItem.getAsFile(), null, "directory item does not expose a File");
const directoryEntry = directoryItem.webkitGetAsEntry();
assert_true(directoryEntry instanceof FileSystemDirectoryEntry, "directory item exposes a directory entry");
assert_equals(directoryEntry.fullPath, "/bundle", "directory entry fullPath");
const children = await read_entries(directoryEntry.createReader());
const children = await read_entries(payload.directoryEntry.createReader());
assert_equals(children.length, 1, "directory entry exposes its child file");
assert_equals(children[0].name, "inside.txt", "directory child file name");
assert_equals(children[0].fullPath, "/bundle/inside.txt", "directory child fullPath");
const childFile = await entry_file(children[0]);
assert_equals(await childFile.text(), "inside payload", "directory child file bytes");
}, "Input.dispatchDragEvent preserves mixed string, file, and directory payload surfaces");
assert_equals(payload.dropped.items.length, 0, "saved item list is disabled after drop");
assert_equals(payload.dropped.files.length, 0, "saved FileList becomes empty");
assert_equals(payload.dropped.types.length, 0, "saved transfer has no current types");
assert_equals(payload.dropped.getData("text/plain"), "", "saved transfer cannot read data");
for (const item of payload.items) {
assert_equals(item.kind, "", "saved item metadata is disabled");
assert_equals(item.type, "", "saved item type is disabled");
assert_equals(item.getAsFile(), null, "saved item cannot reveal a File");
assert_equals(item.webkitGetAsEntry(), null, "saved item cannot reveal an Entry");
}
}, "Input.dispatchDragEvent preserves captured mixed payloads and retires event views");
</script>