mirror of
https://github.com/servo/servo.git
synced 2025-08-04 21:20:23 +01:00
Auto merge of #20329 - gterzian:before_unload, r=cbrewster
Implement beforeunload event and infrastructure <!-- Please describe your changes on the following line: --> Implementation of: 1. https://html.spec.whatwg.org/multipage/#prompt-to-unload-a-document, and 2. https://html.spec.whatwg.org/multipage/#unload-a-document --- <!-- Thank you for contributing to Servo! Please replace each `[ ]` by `[X]` when the step is complete, and replace `__` with appropriate data: --> - [ ] `./mach build -d` does not report any errors - [ ] `./mach test-tidy` does not report any errors - [ ] These changes fix #10787 and fix #20485 and fix #20588 and fix #20496 (github issue number if applicable). <!-- Either: --> - [ ] There are tests for these changes OR - [ ] These changes do not require tests because _____ <!-- Also, please make sure that "Allow edits from maintainers" checkbox is checked, so that we can help you if you get stuck somewhere along the way.--> <!-- Pull requests that do not address these steps are welcome, but they will require additional verification as part of the review process. --> <!-- Reviewable:start --> --- This change is [<img src="https://reviewable.io/review_button.svg" height="34" align="absmiddle" alt="Reviewable"/>](https://reviewable.io/reviews/servo/servo/20329) <!-- Reviewable:end -->
This commit is contained in:
commit
1d8283e010
37 changed files with 341 additions and 113 deletions
|
@ -48,6 +48,7 @@ none
|
||||||
number
|
number
|
||||||
onchange
|
onchange
|
||||||
open
|
open
|
||||||
|
pagehide
|
||||||
pageshow
|
pageshow
|
||||||
password
|
password
|
||||||
pause
|
pause
|
||||||
|
@ -77,6 +78,7 @@ time
|
||||||
timeupdate
|
timeupdate
|
||||||
toggle
|
toggle
|
||||||
transitionend
|
transitionend
|
||||||
|
unload
|
||||||
url
|
url
|
||||||
waiting
|
waiting
|
||||||
webglcontextcreationerror
|
webglcontextcreationerror
|
||||||
|
|
|
@ -1033,6 +1033,9 @@ impl<Message, LTF, STF> Constellation<Message, LTF, STF>
|
||||||
FromScriptMsg::PipelineExited => {
|
FromScriptMsg::PipelineExited => {
|
||||||
self.handle_pipeline_exited(source_pipeline_id);
|
self.handle_pipeline_exited(source_pipeline_id);
|
||||||
}
|
}
|
||||||
|
FromScriptMsg::DiscardDocument => {
|
||||||
|
self.handle_discard_document(source_top_ctx_id, source_pipeline_id);
|
||||||
|
}
|
||||||
FromScriptMsg::InitiateNavigateRequest(req_init, cancel_chan) => {
|
FromScriptMsg::InitiateNavigateRequest(req_init, cancel_chan) => {
|
||||||
debug!("constellation got initiate navigate request message");
|
debug!("constellation got initiate navigate request message");
|
||||||
self.handle_navigate_request(source_pipeline_id, req_init, cancel_chan);
|
self.handle_navigate_request(source_pipeline_id, req_init, cancel_chan);
|
||||||
|
@ -2544,6 +2547,8 @@ impl<Message, LTF, STF> Constellation<Message, LTF, STF>
|
||||||
self.notify_history_changed(change.top_level_browsing_context_id);
|
self.notify_history_changed(change.top_level_browsing_context_id);
|
||||||
},
|
},
|
||||||
Some(old_pipeline_id) => {
|
Some(old_pipeline_id) => {
|
||||||
|
// https://html.spec.whatwg.org/multipage/#unload-a-document
|
||||||
|
self.unload_document(old_pipeline_id);
|
||||||
// Deactivate the old pipeline, and activate the new one.
|
// Deactivate the old pipeline, and activate the new one.
|
||||||
let (pipelines_to_close, states_to_close) = if let Some(replace_reloader) = change.replace {
|
let (pipelines_to_close, states_to_close) = if let Some(replace_reloader) = change.replace {
|
||||||
let session_history = self.joint_session_histories
|
let session_history = self.joint_session_histories
|
||||||
|
@ -2994,6 +2999,28 @@ impl<Message, LTF, STF> Constellation<Message, LTF, STF>
|
||||||
debug!("Closed browsing context children {}.", browsing_context_id);
|
debug!("Closed browsing context children {}.", browsing_context_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discard the pipeline for a given document, udpdate the joint session history.
|
||||||
|
fn handle_discard_document(&mut self,
|
||||||
|
top_level_browsing_context_id: TopLevelBrowsingContextId,
|
||||||
|
pipeline_id: PipelineId) {
|
||||||
|
let load_data = match self.pipelines.get(&pipeline_id) {
|
||||||
|
Some(pipeline) => pipeline.load_data.clone(),
|
||||||
|
None => return
|
||||||
|
};
|
||||||
|
self.joint_session_histories
|
||||||
|
.entry(top_level_browsing_context_id).or_insert(JointSessionHistory::new())
|
||||||
|
.replace_reloader(NeedsToReload::No(pipeline_id), NeedsToReload::Yes(pipeline_id, load_data));
|
||||||
|
self.close_pipeline(pipeline_id, DiscardBrowsingContext::No, ExitPipelineMode::Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a message to script requesting the document associated with this pipeline runs the 'unload' algorithm.
|
||||||
|
fn unload_document(&self, pipeline_id: PipelineId) {
|
||||||
|
if let Some(pipeline) = self.pipelines.get(&pipeline_id) {
|
||||||
|
let msg = ConstellationControlMsg::UnloadDocument(pipeline_id);
|
||||||
|
let _ = pipeline.event_loop.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Close all pipelines at and beneath a given browsing context
|
// Close all pipelines at and beneath a given browsing context
|
||||||
fn close_pipeline(&mut self, pipeline_id: PipelineId, dbc: DiscardBrowsingContext, exit_mode: ExitPipelineMode) {
|
fn close_pipeline(&mut self, pipeline_id: PipelineId, dbc: DiscardBrowsingContext, exit_mode: ExitPipelineMode) {
|
||||||
debug!("Closing pipeline {:?}.", pipeline_id);
|
debug!("Closing pipeline {:?}.", pipeline_id);
|
||||||
|
|
|
@ -63,7 +63,8 @@ use dom::keyboardevent::KeyboardEvent;
|
||||||
use dom::location::Location;
|
use dom::location::Location;
|
||||||
use dom::messageevent::MessageEvent;
|
use dom::messageevent::MessageEvent;
|
||||||
use dom::mouseevent::MouseEvent;
|
use dom::mouseevent::MouseEvent;
|
||||||
use dom::node::{self, CloneChildrenFlag, Node, NodeDamage, window_from_node, NodeFlags, LayoutNodeHelpers};
|
use dom::node::{self, CloneChildrenFlag, document_from_node, window_from_node};
|
||||||
|
use dom::node::{Node, NodeDamage, NodeFlags, LayoutNodeHelpers};
|
||||||
use dom::node::VecPreOrderInsertionHelper;
|
use dom::node::VecPreOrderInsertionHelper;
|
||||||
use dom::nodeiterator::NodeIterator;
|
use dom::nodeiterator::NodeIterator;
|
||||||
use dom::nodelist::NodeList;
|
use dom::nodelist::NodeList;
|
||||||
|
@ -348,6 +349,8 @@ pub struct Document {
|
||||||
last_click_info: DomRefCell<Option<(Instant, Point2D<f32>)>>,
|
last_click_info: DomRefCell<Option<(Instant, Point2D<f32>)>>,
|
||||||
/// <https://html.spec.whatwg.org/multipage/#ignore-destructive-writes-counter>
|
/// <https://html.spec.whatwg.org/multipage/#ignore-destructive-writes-counter>
|
||||||
ignore_destructive_writes_counter: Cell<u32>,
|
ignore_destructive_writes_counter: Cell<u32>,
|
||||||
|
/// <https://html.spec.whatwg.org/multipage/#ignore-opens-during-unload-counter>
|
||||||
|
ignore_opens_during_unload_counter: Cell<u32>,
|
||||||
/// The number of spurious `requestAnimationFrame()` requests we've received.
|
/// The number of spurious `requestAnimationFrame()` requests we've received.
|
||||||
///
|
///
|
||||||
/// A rAF request is considered spurious if nothing was actually reflowed.
|
/// A rAF request is considered spurious if nothing was actually reflowed.
|
||||||
|
@ -375,6 +378,10 @@ pub struct Document {
|
||||||
throw_on_dynamic_markup_insertion_counter: Cell<u64>,
|
throw_on_dynamic_markup_insertion_counter: Cell<u64>,
|
||||||
/// https://html.spec.whatwg.org/multipage/#page-showing
|
/// https://html.spec.whatwg.org/multipage/#page-showing
|
||||||
page_showing: Cell<bool>,
|
page_showing: Cell<bool>,
|
||||||
|
/// Whether the document is salvageable.
|
||||||
|
salvageable: Cell<bool>,
|
||||||
|
/// Whether the unload event has already been fired.
|
||||||
|
fired_unload: Cell<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(JSTraceable, MallocSizeOf)]
|
#[derive(JSTraceable, MallocSizeOf)]
|
||||||
|
@ -483,6 +490,39 @@ impl Document {
|
||||||
self.dirty_all_nodes();
|
self.dirty_all_nodes();
|
||||||
self.window().reflow(ReflowGoal::Full, ReflowReason::CachedPageNeededReflow);
|
self.window().reflow(ReflowGoal::Full, ReflowReason::CachedPageNeededReflow);
|
||||||
self.window().resume();
|
self.window().resume();
|
||||||
|
// html.spec.whatwg.org/multipage/#history-traversal
|
||||||
|
// Step 4.6
|
||||||
|
if self.ready_state.get() == DocumentReadyState::Complete {
|
||||||
|
let document = Trusted::new(self);
|
||||||
|
self.window.dom_manipulation_task_source().queue(
|
||||||
|
task!(fire_pageshow_event: move || {
|
||||||
|
let document = document.root();
|
||||||
|
let window = document.window();
|
||||||
|
// Step 4.6.1
|
||||||
|
if document.page_showing.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Step 4.6.2
|
||||||
|
document.page_showing.set(true);
|
||||||
|
// Step 4.6.4
|
||||||
|
let event = PageTransitionEvent::new(
|
||||||
|
window,
|
||||||
|
atom!("pageshow"),
|
||||||
|
false, // bubbles
|
||||||
|
false, // cancelable
|
||||||
|
true, // persisted
|
||||||
|
);
|
||||||
|
let event = event.upcast::<Event>();
|
||||||
|
event.set_trusted(true);
|
||||||
|
// FIXME(nox): Why are errors silenced here?
|
||||||
|
let _ = window.upcast::<EventTarget>().dispatch_event_with_target(
|
||||||
|
document.upcast(),
|
||||||
|
&event,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
self.window.upcast(),
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.window().suspend();
|
self.window().suspend();
|
||||||
}
|
}
|
||||||
|
@ -1593,6 +1633,113 @@ impl Document {
|
||||||
ScriptThread::mark_document_with_no_blocked_loads(self);
|
ScriptThread::mark_document_with_no_blocked_loads(self);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://html.spec.whatwg.org/multipage/#prompt-to-unload-a-document
|
||||||
|
pub fn prompt_to_unload(&self, recursive_flag: bool) -> bool {
|
||||||
|
// TODO: Step 1, increase the event loop's termination nesting level by 1.
|
||||||
|
// Step 2
|
||||||
|
self.incr_ignore_opens_during_unload_counter();
|
||||||
|
//Step 3-5.
|
||||||
|
let document = Trusted::new(self);
|
||||||
|
let beforeunload_event = BeforeUnloadEvent::new(&self.window,
|
||||||
|
atom!("beforeunload"),
|
||||||
|
EventBubbles::Bubbles,
|
||||||
|
EventCancelable::Cancelable);
|
||||||
|
let event = beforeunload_event.upcast::<Event>();
|
||||||
|
event.set_trusted(true);
|
||||||
|
let event_target = self.window.upcast::<EventTarget>();
|
||||||
|
let has_listeners = event.has_listeners_for(&event_target, &atom!("beforeunload"));
|
||||||
|
event_target.dispatch_event_with_target(
|
||||||
|
document.root().upcast(),
|
||||||
|
&event,
|
||||||
|
);
|
||||||
|
// TODO: Step 6, decrease the event loop's termination nesting level by 1.
|
||||||
|
// Step 7
|
||||||
|
self.salvageable.set(!has_listeners);
|
||||||
|
let mut can_unload = true;
|
||||||
|
// TODO: Step 8 send a message to embedder to prompt user.
|
||||||
|
// Step 9
|
||||||
|
if !recursive_flag {
|
||||||
|
for iframe in self.iter_iframes() {
|
||||||
|
// TODO: handle the case of cross origin iframes.
|
||||||
|
let document = document_from_node(&*iframe);
|
||||||
|
if !document.prompt_to_unload(true) {
|
||||||
|
self.salvageable.set(document.salvageable());
|
||||||
|
can_unload = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Step 10
|
||||||
|
self.decr_ignore_opens_during_unload_counter();
|
||||||
|
can_unload
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://html.spec.whatwg.org/multipage/#unload-a-document
|
||||||
|
pub fn unload(&self, recursive_flag: bool, recycle: bool) {
|
||||||
|
// TODO: Step 1, increase the event loop's termination nesting level by 1.
|
||||||
|
// Step 2
|
||||||
|
self.incr_ignore_opens_during_unload_counter();
|
||||||
|
let document = Trusted::new(self);
|
||||||
|
// Step 3-6
|
||||||
|
if self.page_showing.get() {
|
||||||
|
self.page_showing.set(false);
|
||||||
|
let event = PageTransitionEvent::new(
|
||||||
|
&self.window,
|
||||||
|
atom!("pagehide"),
|
||||||
|
false, // bubbles
|
||||||
|
false, // cancelable
|
||||||
|
self.salvageable.get(), // persisted
|
||||||
|
);
|
||||||
|
let event = event.upcast::<Event>();
|
||||||
|
event.set_trusted(true);
|
||||||
|
let _ = self.window.upcast::<EventTarget>().dispatch_event_with_target(
|
||||||
|
document.root().upcast(),
|
||||||
|
&event,
|
||||||
|
);
|
||||||
|
// TODO Step 6, document visibility steps.
|
||||||
|
}
|
||||||
|
// Step 7
|
||||||
|
if !self.fired_unload.get() {
|
||||||
|
let event = Event::new(
|
||||||
|
&self.window.upcast(),
|
||||||
|
atom!("unload"),
|
||||||
|
EventBubbles::Bubbles,
|
||||||
|
EventCancelable::Cancelable,
|
||||||
|
);
|
||||||
|
event.set_trusted(true);
|
||||||
|
let event_target = self.window.upcast::<EventTarget>();
|
||||||
|
let has_listeners = event.has_listeners_for(&event_target, &atom!("unload"));
|
||||||
|
let _ = event_target.dispatch_event_with_target(
|
||||||
|
document.root().upcast(),
|
||||||
|
&event,
|
||||||
|
);
|
||||||
|
self.fired_unload.set(true);
|
||||||
|
// Step 9
|
||||||
|
self.salvageable.set(!has_listeners);
|
||||||
|
}
|
||||||
|
// TODO: Step 8, decrease the event loop's termination nesting level by 1.
|
||||||
|
|
||||||
|
// Step 13
|
||||||
|
if !recursive_flag {
|
||||||
|
for iframe in self.iter_iframes() {
|
||||||
|
// TODO: handle the case of cross origin iframes.
|
||||||
|
let document = document_from_node(&*iframe);
|
||||||
|
document.unload(true, recycle);
|
||||||
|
if !document.salvageable() {
|
||||||
|
self.salvageable.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Step 10, 14
|
||||||
|
if !self.salvageable.get() {
|
||||||
|
// https://html.spec.whatwg.org/multipage/#unloading-document-cleanup-steps
|
||||||
|
let msg = ScriptMsg::DiscardDocument;
|
||||||
|
let _ = self.window.upcast::<GlobalScope>().script_to_constellation_chan().send(msg);
|
||||||
|
}
|
||||||
|
// Step 15, End
|
||||||
|
self.decr_ignore_opens_during_unload_counter();
|
||||||
|
}
|
||||||
|
|
||||||
// https://html.spec.whatwg.org/multipage/#the-end
|
// https://html.spec.whatwg.org/multipage/#the-end
|
||||||
pub fn maybe_queue_document_completion(&self) {
|
pub fn maybe_queue_document_completion(&self) {
|
||||||
if self.loader.borrow().is_blocked() {
|
if self.loader.borrow().is_blocked() {
|
||||||
|
@ -2267,6 +2414,7 @@ impl Document {
|
||||||
target_element: MutNullableDom::new(None),
|
target_element: MutNullableDom::new(None),
|
||||||
last_click_info: DomRefCell::new(None),
|
last_click_info: DomRefCell::new(None),
|
||||||
ignore_destructive_writes_counter: Default::default(),
|
ignore_destructive_writes_counter: Default::default(),
|
||||||
|
ignore_opens_during_unload_counter: Default::default(),
|
||||||
spurious_animation_frames: Cell::new(0),
|
spurious_animation_frames: Cell::new(0),
|
||||||
dom_count: Cell::new(1),
|
dom_count: Cell::new(1),
|
||||||
fullscreen_element: MutNullableDom::new(None),
|
fullscreen_element: MutNullableDom::new(None),
|
||||||
|
@ -2276,6 +2424,8 @@ impl Document {
|
||||||
canceller: canceller,
|
canceller: canceller,
|
||||||
throw_on_dynamic_markup_insertion_counter: Cell::new(0),
|
throw_on_dynamic_markup_insertion_counter: Cell::new(0),
|
||||||
page_showing: Cell::new(false),
|
page_showing: Cell::new(false),
|
||||||
|
salvageable: Cell::new(true),
|
||||||
|
fired_unload: Cell::new(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2455,6 +2605,10 @@ impl Document {
|
||||||
self.stylesheets.borrow().len()
|
self.stylesheets.borrow().len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn salvageable(&self) -> bool {
|
||||||
|
self.salvageable.get()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stylesheet_at(&self, index: usize) -> Option<DomRoot<CSSStyleSheet>> {
|
pub fn stylesheet_at(&self, index: usize) -> Option<DomRoot<CSSStyleSheet>> {
|
||||||
let stylesheets = self.stylesheets.borrow();
|
let stylesheets = self.stylesheets.borrow();
|
||||||
|
|
||||||
|
@ -2578,6 +2732,20 @@ impl Document {
|
||||||
self.ignore_destructive_writes_counter.get() - 1);
|
self.ignore_destructive_writes_counter.get() - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_prompting_or_unloading(&self) -> bool {
|
||||||
|
self.ignore_opens_during_unload_counter.get() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn incr_ignore_opens_during_unload_counter(&self) {
|
||||||
|
self.ignore_opens_during_unload_counter.set(
|
||||||
|
self.ignore_opens_during_unload_counter.get() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decr_ignore_opens_during_unload_counter(&self) {
|
||||||
|
self.ignore_opens_during_unload_counter.set(
|
||||||
|
self.ignore_opens_during_unload_counter.get() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether we've seen so many spurious animation frames (i.e. animation frames that didn't
|
/// Whether we've seen so many spurious animation frames (i.e. animation frames that didn't
|
||||||
/// mutate the DOM) that we've decided to fall back to fake ones.
|
/// mutate the DOM) that we've decided to fall back to fake ones.
|
||||||
fn is_faking_animation_frames(&self) -> bool {
|
fn is_faking_animation_frames(&self) -> bool {
|
||||||
|
|
|
@ -11,7 +11,7 @@ use dom::bindings::error::Fallible;
|
||||||
use dom::bindings::inheritance::Castable;
|
use dom::bindings::inheritance::Castable;
|
||||||
use dom::bindings::refcounted::Trusted;
|
use dom::bindings::refcounted::Trusted;
|
||||||
use dom::bindings::reflector::{DomObject, Reflector, reflect_dom_object};
|
use dom::bindings::reflector::{DomObject, Reflector, reflect_dom_object};
|
||||||
use dom::bindings::root::{Dom, DomRoot, MutNullableDom, RootedReference};
|
use dom::bindings::root::{DomRoot, MutNullableDom, RootedReference};
|
||||||
use dom::bindings::str::DOMString;
|
use dom::bindings::str::DOMString;
|
||||||
use dom::document::Document;
|
use dom::document::Document;
|
||||||
use dom::eventtarget::{CompiledEventListener, EventTarget, ListenerPhase};
|
use dom::eventtarget::{CompiledEventListener, EventTarget, ListenerPhase};
|
||||||
|
@ -103,6 +103,36 @@ impl Event {
|
||||||
self.cancelable.set(cancelable);
|
self.cancelable.set(cancelable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine if there are any listeners for a given target and type.
|
||||||
|
// See https://github.com/whatwg/dom/issues/453
|
||||||
|
pub fn has_listeners_for(&self, target: &EventTarget, type_: &Atom) -> bool {
|
||||||
|
// TODO: take 'removed' into account? Not implemented in Servo yet.
|
||||||
|
// https://dom.spec.whatwg.org/#event-listener-removed
|
||||||
|
let mut event_path = self.construct_event_path(&target);
|
||||||
|
event_path.push(DomRoot::from_ref(target));
|
||||||
|
event_path.iter().any(|target| target.has_listeners_for(type_))
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://dom.spec.whatwg.org/#event-path
|
||||||
|
fn construct_event_path(&self, target: &EventTarget) -> Vec<DomRoot<EventTarget>> {
|
||||||
|
let mut event_path = vec![];
|
||||||
|
// The "invoke" algorithm is only used on `target` separately,
|
||||||
|
// so we don't put it in the path.
|
||||||
|
if let Some(target_node) = target.downcast::<Node>() {
|
||||||
|
for ancestor in target_node.ancestors() {
|
||||||
|
event_path.push(DomRoot::from_ref(ancestor.upcast::<EventTarget>()));
|
||||||
|
}
|
||||||
|
let top_most_ancestor_or_target =
|
||||||
|
event_path.last().cloned().unwrap_or(DomRoot::from_ref(target));
|
||||||
|
if let Some(document) = DomRoot::downcast::<Document>(top_most_ancestor_or_target) {
|
||||||
|
if self.type_() != atom!("load") && document.browsing_context().is_some() {
|
||||||
|
event_path.push(DomRoot::from_ref(document.window().upcast()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event_path
|
||||||
|
}
|
||||||
|
|
||||||
// https://dom.spec.whatwg.org/#concept-event-dispatch
|
// https://dom.spec.whatwg.org/#concept-event-dispatch
|
||||||
pub fn dispatch(&self,
|
pub fn dispatch(&self,
|
||||||
target: &EventTarget,
|
target: &EventTarget,
|
||||||
|
@ -130,24 +160,9 @@ impl Event {
|
||||||
return self.status();
|
return self.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3. The "invoke" algorithm is only used on `target` separately,
|
// Step 3-4.
|
||||||
// so we don't put it in the path.
|
let path = self.construct_event_path(&target);
|
||||||
rooted_vec!(let mut event_path);
|
rooted_vec!(let event_path <- path.into_iter());
|
||||||
|
|
||||||
// Step 4.
|
|
||||||
if let Some(target_node) = target.downcast::<Node>() {
|
|
||||||
for ancestor in target_node.ancestors() {
|
|
||||||
event_path.push(Dom::from_ref(ancestor.upcast::<EventTarget>()));
|
|
||||||
}
|
|
||||||
let top_most_ancestor_or_target =
|
|
||||||
DomRoot::from_ref(event_path.r().last().cloned().unwrap_or(target));
|
|
||||||
if let Some(document) = DomRoot::downcast::<Document>(top_most_ancestor_or_target) {
|
|
||||||
if self.type_() != atom!("load") && document.browsing_context().is_some() {
|
|
||||||
event_path.push(Dom::from_ref(document.window().upcast()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Steps 5-9. In a separate function to short-circuit various things easily.
|
// Steps 5-9. In a separate function to short-circuit various things easily.
|
||||||
dispatch_to_listeners(self, target, event_path.r());
|
dispatch_to_listeners(self, target, event_path.r());
|
||||||
|
|
||||||
|
|
|
@ -187,22 +187,21 @@ impl CompiledEventListener {
|
||||||
|
|
||||||
CommonEventHandler::BeforeUnloadEventHandler(ref handler) => {
|
CommonEventHandler::BeforeUnloadEventHandler(ref handler) => {
|
||||||
if let Some(event) = event.downcast::<BeforeUnloadEvent>() {
|
if let Some(event) = event.downcast::<BeforeUnloadEvent>() {
|
||||||
let rv = event.ReturnValue();
|
// Step 5
|
||||||
|
|
||||||
if let Ok(value) = handler.Call_(object,
|
if let Ok(value) = handler.Call_(object,
|
||||||
event.upcast::<Event>(),
|
event.upcast::<Event>(),
|
||||||
exception_handle) {
|
exception_handle) {
|
||||||
match value {
|
let rv = event.ReturnValue();
|
||||||
Some(value) => {
|
if let Some(v) = value {
|
||||||
if rv.is_empty() {
|
if rv.is_empty() {
|
||||||
event.SetReturnValue(value);
|
event.SetReturnValue(v);
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
event.upcast::<Event>().PreventDefault();
|
|
||||||
}
|
}
|
||||||
|
event.upcast::<Event>().PreventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Step 5, "Otherwise" clause
|
||||||
|
let _ = handler.Call_(object, event.upcast::<Event>(), exception_handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -278,6 +277,12 @@ impl EventListeners {
|
||||||
}
|
}
|
||||||
}).collect()
|
}).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_listeners(&self) -> bool {
|
||||||
|
// TODO: add, and take into account, a 'removed' field?
|
||||||
|
// https://dom.spec.whatwg.org/#event-listener-removed
|
||||||
|
self.0.len() > 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[dom_struct]
|
#[dom_struct]
|
||||||
|
@ -304,6 +309,15 @@ impl EventTarget {
|
||||||
Ok(EventTarget::new(global))
|
Ok(EventTarget::new(global))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_listeners_for(&self,
|
||||||
|
type_: &Atom)
|
||||||
|
-> bool {
|
||||||
|
match self.handlers.borrow().get(type_) {
|
||||||
|
Some(listeners) => listeners.has_listeners(),
|
||||||
|
None => false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_listeners_for(&self,
|
pub fn get_listeners_for(&self,
|
||||||
type_: &Atom,
|
type_: &Atom,
|
||||||
specific_phase: Option<ListenerPhase>)
|
specific_phase: Option<ListenerPhase>)
|
||||||
|
|
|
@ -83,7 +83,7 @@ dictionary ElementCreationOptions {
|
||||||
// [OverrideBuiltins]
|
// [OverrideBuiltins]
|
||||||
partial /*sealed*/ interface Document {
|
partial /*sealed*/ interface Document {
|
||||||
// resource metadata management
|
// resource metadata management
|
||||||
[/*PutForwards=href, */Unforgeable]
|
[PutForwards=href, Unforgeable]
|
||||||
readonly attribute Location? location;
|
readonly attribute Location? location;
|
||||||
[SetterThrows] attribute DOMString domain;
|
[SetterThrows] attribute DOMString domain;
|
||||||
readonly attribute DOMString referrer;
|
readonly attribute DOMString referrer;
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
attribute DOMString name;
|
attribute DOMString name;
|
||||||
|
|
||||||
[/*PutForwards=href, */Unforgeable] readonly attribute Location location;
|
[PutForwards=href, Unforgeable] readonly attribute Location location;
|
||||||
readonly attribute History history;
|
readonly attribute History history;
|
||||||
[Pref="dom.customelements.enabled"]
|
[Pref="dom.customelements.enabled"]
|
||||||
readonly attribute CustomElementRegistry customElements;
|
readonly attribute CustomElementRegistry customElements;
|
||||||
|
|
|
@ -1550,7 +1550,7 @@ impl Window {
|
||||||
// https://html.spec.whatwg.org/multipage/#navigating-across-documents
|
// https://html.spec.whatwg.org/multipage/#navigating-across-documents
|
||||||
if !force_reload && url.as_url()[..Position::AfterQuery] ==
|
if !force_reload && url.as_url()[..Position::AfterQuery] ==
|
||||||
doc.url().as_url()[..Position::AfterQuery] {
|
doc.url().as_url()[..Position::AfterQuery] {
|
||||||
// Step 5
|
// Step 6
|
||||||
if let Some(fragment) = url.fragment() {
|
if let Some(fragment) = url.fragment() {
|
||||||
doc.check_and_scroll_fragment(fragment);
|
doc.check_and_scroll_fragment(fragment);
|
||||||
doc.set_url(url.clone());
|
doc.set_url(url.clone());
|
||||||
|
@ -1559,9 +1559,24 @@ impl Window {
|
||||||
}
|
}
|
||||||
|
|
||||||
let pipeline_id = self.upcast::<GlobalScope>().pipeline_id();
|
let pipeline_id = self.upcast::<GlobalScope>().pipeline_id();
|
||||||
self.main_thread_script_chan().send(
|
|
||||||
MainThreadScriptMsg::Navigate(pipeline_id,
|
// Step 4
|
||||||
LoadData::new(url, Some(pipeline_id), referrer_policy, Some(doc.url())), replace)).unwrap();
|
let window_proxy = self.window_proxy();
|
||||||
|
if let Some(active) = window_proxy.currently_active() {
|
||||||
|
if pipeline_id == active {
|
||||||
|
if doc.is_prompting_or_unloading() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7
|
||||||
|
if doc.prompt_to_unload(false) {
|
||||||
|
self.main_thread_script_chan().send(
|
||||||
|
MainThreadScriptMsg::Navigate(pipeline_id,
|
||||||
|
LoadData::new(url, Some(pipeline_id), referrer_policy, Some(doc.url())), replace)).unwrap();
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_fire_timer(&self, timer_id: TimerEventId) {
|
pub fn handle_fire_timer(&self, timer_id: TimerEventId) {
|
||||||
|
|
|
@ -1157,6 +1157,7 @@ impl ScriptThread {
|
||||||
AttachLayout(ref new_layout_info) => Some(new_layout_info.new_pipeline_id),
|
AttachLayout(ref new_layout_info) => Some(new_layout_info.new_pipeline_id),
|
||||||
Resize(id, ..) => Some(id),
|
Resize(id, ..) => Some(id),
|
||||||
ResizeInactive(id, ..) => Some(id),
|
ResizeInactive(id, ..) => Some(id),
|
||||||
|
UnloadDocument(id) => Some(id),
|
||||||
ExitPipeline(id, ..) => Some(id),
|
ExitPipeline(id, ..) => Some(id),
|
||||||
ExitScriptThread => None,
|
ExitScriptThread => None,
|
||||||
SendEvent(id, ..) => Some(id),
|
SendEvent(id, ..) => Some(id),
|
||||||
|
@ -1275,6 +1276,8 @@ impl ScriptThread {
|
||||||
},
|
},
|
||||||
ConstellationControlMsg::Navigate(parent_pipeline_id, browsing_context_id, load_data, replace) =>
|
ConstellationControlMsg::Navigate(parent_pipeline_id, browsing_context_id, load_data, replace) =>
|
||||||
self.handle_navigate(parent_pipeline_id, Some(browsing_context_id), load_data, replace),
|
self.handle_navigate(parent_pipeline_id, Some(browsing_context_id), load_data, replace),
|
||||||
|
ConstellationControlMsg::UnloadDocument(pipeline_id) =>
|
||||||
|
self.handle_unload_document(pipeline_id),
|
||||||
ConstellationControlMsg::SendEvent(id, event) =>
|
ConstellationControlMsg::SendEvent(id, event) =>
|
||||||
self.handle_event(id, event),
|
self.handle_event(id, event),
|
||||||
ConstellationControlMsg::ResizeInactive(id, new_size) =>
|
ConstellationControlMsg::ResizeInactive(id, new_size) =>
|
||||||
|
@ -1668,6 +1671,13 @@ impl ScriptThread {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_unload_document(&self, pipeline_id: PipelineId) {
|
||||||
|
let document = self.documents.borrow().find_document(pipeline_id);
|
||||||
|
if let Some(document) = document {
|
||||||
|
document.unload(false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_update_pipeline_id(&self,
|
fn handle_update_pipeline_id(&self,
|
||||||
parent_pipeline_id: PipelineId,
|
parent_pipeline_id: PipelineId,
|
||||||
browsing_context_id: BrowsingContextId,
|
browsing_context_id: BrowsingContextId,
|
||||||
|
|
|
@ -241,7 +241,7 @@ impl OneshotTimers {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resume(&self) {
|
pub fn resume(&self) {
|
||||||
// Suspend is idempotent: do nothing if the timers are already suspended.
|
// Resume is idempotent: do nothing if the timers are already resumed.
|
||||||
let additional_offset = match self.suspended_since.get() {
|
let additional_offset = match self.suspended_since.get() {
|
||||||
Some(suspended_since) => precise_time_ms() - suspended_since,
|
Some(suspended_since) => precise_time_ms() - suspended_since,
|
||||||
None => return warn!("Resuming an already resumed timer."),
|
None => return warn!("Resuming an already resumed timer."),
|
||||||
|
|
|
@ -262,6 +262,8 @@ pub enum ConstellationControlMsg {
|
||||||
Resize(PipelineId, WindowSizeData, WindowSizeType),
|
Resize(PipelineId, WindowSizeData, WindowSizeType),
|
||||||
/// Notifies script that window has been resized but to not take immediate action.
|
/// Notifies script that window has been resized but to not take immediate action.
|
||||||
ResizeInactive(PipelineId, WindowSizeData),
|
ResizeInactive(PipelineId, WindowSizeData),
|
||||||
|
/// Notifies the script that the document associated with this pipeline should 'unload'.
|
||||||
|
UnloadDocument(PipelineId),
|
||||||
/// Notifies the script that a pipeline should be closed.
|
/// Notifies the script that a pipeline should be closed.
|
||||||
ExitPipeline(PipelineId, DiscardBrowsingContext),
|
ExitPipeline(PipelineId, DiscardBrowsingContext),
|
||||||
/// Notifies the script that the whole thread should be closed.
|
/// Notifies the script that the whole thread should be closed.
|
||||||
|
@ -335,6 +337,7 @@ impl fmt::Debug for ConstellationControlMsg {
|
||||||
AttachLayout(..) => "AttachLayout",
|
AttachLayout(..) => "AttachLayout",
|
||||||
Resize(..) => "Resize",
|
Resize(..) => "Resize",
|
||||||
ResizeInactive(..) => "ResizeInactive",
|
ResizeInactive(..) => "ResizeInactive",
|
||||||
|
UnloadDocument(..) => "UnloadDocument",
|
||||||
ExitPipeline(..) => "ExitPipeline",
|
ExitPipeline(..) => "ExitPipeline",
|
||||||
ExitScriptThread => "ExitScriptThread",
|
ExitScriptThread => "ExitScriptThread",
|
||||||
SendEvent(..) => "SendEvent",
|
SendEvent(..) => "SendEvent",
|
||||||
|
|
|
@ -145,6 +145,8 @@ pub enum ScriptMsg {
|
||||||
TouchEventProcessed(EventResult),
|
TouchEventProcessed(EventResult),
|
||||||
/// A log entry, with the top-level browsing context id and thread name
|
/// A log entry, with the top-level browsing context id and thread name
|
||||||
LogEntry(Option<String>, LogEntry),
|
LogEntry(Option<String>, LogEntry),
|
||||||
|
/// Discard the document.
|
||||||
|
DiscardDocument,
|
||||||
/// Notifies the constellation that this pipeline has exited.
|
/// Notifies the constellation that this pipeline has exited.
|
||||||
PipelineExited,
|
PipelineExited,
|
||||||
/// Send messages from postMessage calls from serviceworker
|
/// Send messages from postMessage calls from serviceworker
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
[url-in-tags-revoke.window.html]
|
[url-in-tags-revoke.window.html]
|
||||||
expected: TIMEOUT
|
|
||||||
[Fetching a blob URL immediately before revoking it works in an iframe.]
|
[Fetching a blob URL immediately before revoking it works in an iframe.]
|
||||||
expected: FAIL
|
expected: FAIL
|
||||||
|
|
||||||
[Fetching a blob URL immediately before revoking it works in <script> tags.]
|
|
||||||
expected: TIMEOUT
|
|
||||||
|
|
||||||
[Fetching a blob URL immediately before revoking it works in an iframe navigation.]
|
[Fetching a blob URL immediately before revoking it works in an iframe navigation.]
|
||||||
expected: TIMEOUT
|
expected: TIMEOUT
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
[001.html]
|
|
||||||
type: testharness
|
|
||||||
expected: TIMEOUT
|
|
||||||
[pageshow event from traversal]
|
|
||||||
expected: TIMEOUT
|
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
[window-name-after-cross-origin-main-frame-navigation.sub.html]
|
[window-name-after-cross-origin-main-frame-navigation.sub.html]
|
||||||
type: testharness
|
type: testharness
|
||||||
expected: TIMEOUT
|
expected: ERROR
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
[window-name-after-cross-origin-sub-frame-navigation.sub.html]
|
|
||||||
type: testharness
|
|
||||||
expected: TIMEOUT
|
|
||||||
[Test that the window name is correct]
|
|
||||||
expected: NOTRUN
|
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
[window-name-after-same-origin-main-frame-navigation.sub.html]
|
[window-name-after-same-origin-main-frame-navigation.sub.html]
|
||||||
type: testharness
|
type: testharness
|
||||||
expected: TIMEOUT
|
expected: ERROR
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
[window-name-after-same-origin-sub-frame-navigation.sub.html]
|
|
||||||
type: testharness
|
|
||||||
expected: TIMEOUT
|
|
||||||
[Test that the window name is correct]
|
|
||||||
expected: NOTRUN
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
[007.html]
|
[007.html]
|
||||||
type: testharness
|
type: testharness
|
||||||
expected: TIMEOUT
|
[Link with onclick javascript url and href navigation ]
|
||||||
|
expected: FAIL
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
[child_navigates_parent_location.html]
|
|
||||||
type: testharness
|
|
||||||
expected: TIMEOUT
|
|
||||||
[Child document navigating parent via location ]
|
|
||||||
expected: TIMEOUT
|
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
[001.html]
|
||||||
|
type: testharness
|
||||||
|
[document.open in unload]
|
||||||
|
expected: FAIL
|
|
@ -0,0 +1,4 @@
|
||||||
|
[002.html]
|
||||||
|
type: testharness
|
||||||
|
[document.open in unload]
|
||||||
|
expected: FAIL
|
|
@ -0,0 +1,4 @@
|
||||||
|
[003.html]
|
||||||
|
type: testharness
|
||||||
|
[document.open in beforeunload with link]
|
||||||
|
expected: FAIL
|
|
@ -0,0 +1,4 @@
|
||||||
|
[004.html]
|
||||||
|
type: testharness
|
||||||
|
[document.open in beforeunload with button]
|
||||||
|
expected: FAIL
|
|
@ -0,0 +1,4 @@
|
||||||
|
[005.html]
|
||||||
|
type: testharness
|
||||||
|
[document.open in pagehide in iframe]
|
||||||
|
expected: FAIL
|
|
@ -1 +0,0 @@
|
||||||
disabled: for now
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
[beforeunload-on-history-back.html]
|
||||||
|
type: testharness
|
||||||
|
[beforeunload event fires on history navigation back]
|
||||||
|
expected: FAIL
|
|
@ -0,0 +1,6 @@
|
||||||
|
[beforeunload-on-navigation-of-parent.html]
|
||||||
|
type: testharness
|
||||||
|
[Triggering navigation from within beforeunload event]
|
||||||
|
expected: FAIL
|
||||||
|
[beforeunload in iframe on navigation of parent]
|
||||||
|
expected: FAIL
|
|
@ -0,0 +1,4 @@
|
||||||
|
[navigation-within-beforeunload.html]
|
||||||
|
type: testharness
|
||||||
|
[Triggering navigation from within beforeunload event]
|
||||||
|
expected: FAIL
|
|
@ -0,0 +1,4 @@
|
||||||
|
[003.html]
|
||||||
|
type: testharness
|
||||||
|
[unload event properties]
|
||||||
|
expected: FAIL
|
|
@ -0,0 +1,5 @@
|
||||||
|
[006.html]
|
||||||
|
type: testharness
|
||||||
|
expected: TIMEOUT
|
||||||
|
[salvagable state of document after setting pagehide listener]
|
||||||
|
expected: TIMEOUT
|
|
@ -1,6 +1,4 @@
|
||||||
[assign_after_load.html]
|
[assign_after_load.html]
|
||||||
type: testharness
|
type: testharness
|
||||||
expected: TIMEOUT
|
|
||||||
[Assignment to location after document is completely loaded]
|
[Assignment to location after document is completely loaded]
|
||||||
expected: TIMEOUT
|
expected: FAIL
|
||||||
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
[assign_before_load.html]
|
|
||||||
type: testharness
|
|
||||||
expected: TIMEOUT
|
|
||||||
[Assignment to location before document is completely loaded]
|
|
||||||
expected: TIMEOUT
|
|
||||||
|
|
|
@ -17,19 +17,3 @@
|
||||||
|
|
||||||
[Set data URL frame location.protocol to http+x]
|
[Set data URL frame location.protocol to http+x]
|
||||||
expected: FAIL
|
expected: FAIL
|
||||||
|
|
||||||
[Set HTTP URL frame location.protocol to x]
|
|
||||||
expected: FAIL
|
|
||||||
|
|
||||||
[Set HTTP URL frame location.protocol to data]
|
|
||||||
expected: FAIL
|
|
||||||
|
|
||||||
[Set HTTP URL frame location.protocol to ftp]
|
|
||||||
expected: FAIL
|
|
||||||
|
|
||||||
[Set HTTP URL frame location.protocol to gopher]
|
|
||||||
expected: FAIL
|
|
||||||
|
|
||||||
[Set HTTP URL frame location.protocol to http+x]
|
|
||||||
expected: FAIL
|
|
||||||
|
|
||||||
|
|
|
@ -32,9 +32,6 @@
|
||||||
[Window attribute: onmousewheel]
|
[Window attribute: onmousewheel]
|
||||||
expected: FAIL
|
expected: FAIL
|
||||||
|
|
||||||
[Window unforgeable attribute: location]
|
|
||||||
expected: FAIL
|
|
||||||
|
|
||||||
[Window replaceable attribute: locationbar]
|
[Window replaceable attribute: locationbar]
|
||||||
expected: FAIL
|
expected: FAIL
|
||||||
|
|
||||||
|
|
|
@ -44,9 +44,6 @@
|
||||||
[Document interface: attribute onsecuritypolicyviolation]
|
[Document interface: attribute onsecuritypolicyviolation]
|
||||||
expected: FAIL
|
expected: FAIL
|
||||||
|
|
||||||
[Document interface: iframe.contentDocument must have own property "location"]
|
|
||||||
expected: FAIL
|
|
||||||
|
|
||||||
[Document interface: iframe.contentDocument must inherit property "dir" with the proper type]
|
[Document interface: iframe.contentDocument must inherit property "dir" with the proper type]
|
||||||
expected: FAIL
|
expected: FAIL
|
||||||
|
|
||||||
|
@ -113,9 +110,6 @@
|
||||||
[Document interface: iframe.contentDocument must inherit property "onsecuritypolicyviolation" with the proper type]
|
[Document interface: iframe.contentDocument must inherit property "onsecuritypolicyviolation" with the proper type]
|
||||||
expected: FAIL
|
expected: FAIL
|
||||||
|
|
||||||
[Document interface: new Document() must have own property "location"]
|
|
||||||
expected: FAIL
|
|
||||||
|
|
||||||
[Document interface: new Document() must inherit property "dir" with the proper type]
|
[Document interface: new Document() must inherit property "dir" with the proper type]
|
||||||
expected: FAIL
|
expected: FAIL
|
||||||
|
|
||||||
|
@ -179,9 +173,6 @@
|
||||||
[Document interface: new Document() must inherit property "onsecuritypolicyviolation" with the proper type]
|
[Document interface: new Document() must inherit property "onsecuritypolicyviolation" with the proper type]
|
||||||
expected: FAIL
|
expected: FAIL
|
||||||
|
|
||||||
[Document interface: document.implementation.createDocument(null, "", null) must have own property "location"]
|
|
||||||
expected: FAIL
|
|
||||||
|
|
||||||
[Document interface: document.implementation.createDocument(null, "", null) must inherit property "dir" with the proper type]
|
[Document interface: document.implementation.createDocument(null, "", null) must inherit property "dir" with the proper type]
|
||||||
expected: FAIL
|
expected: FAIL
|
||||||
|
|
||||||
|
@ -4991,9 +4982,6 @@
|
||||||
[Window interface: window must inherit property "self" with the proper type]
|
[Window interface: window must inherit property "self" with the proper type]
|
||||||
expected: FAIL
|
expected: FAIL
|
||||||
|
|
||||||
[Window interface: window must have own property "location"]
|
|
||||||
expected: FAIL
|
|
||||||
|
|
||||||
[Window interface: window must inherit property "locationbar" with the proper type]
|
[Window interface: window must inherit property "locationbar" with the proper type]
|
||||||
expected: FAIL
|
expected: FAIL
|
||||||
|
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
[079.html]
|
|
||||||
type: testharness
|
|
||||||
[ setting location to javascript URL from event handler ]
|
|
||||||
expected: FAIL
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue