script/dom/document/editing.rs
1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::cell::RefCell;
6use std::rc::Rc;
7
8use embedder_traits::{EditingActionEvent, EmbedderMsg, InputEventResult};
9use js::context::{JSContext, NoGC};
10use keyboard_types::{Key, Modifiers, NamedKey};
11use script_bindings::codegen::GenericBindings::DocumentBinding::DocumentMethods;
12use script_bindings::codegen::GenericBindings::EventBinding::EventMethods;
13use script_bindings::codegen::GenericBindings::SelectionBinding::SelectionMethods;
14use script_bindings::dom::UnrootedDom;
15use script_bindings::inheritance::Castable;
16use script_bindings::root::DomRoot;
17use script_bindings::str::DOMString;
18use servo_base::generic_channel::GenericCallback;
19
20use crate::dom::clipboardevent::ClipboardEventType;
21use crate::dom::event::{EventBubbles, EventCancelable};
22use crate::dom::execcommand::execcommands::DocumentExecCommandSupport;
23use crate::dom::text_control::TextControlElement;
24use crate::dom::text_input::{InputEventType, IsComposing};
25use crate::dom::types::{
26 ClipboardEvent, DataTransfer, Event, EventTarget, HTMLInputElement, HTMLTextAreaElement,
27 KeyboardEvent,
28};
29use crate::dom::{Document, Node};
30use crate::drag::drag_data_store::{DragDataStore, Kind, Mode};
31
32impl Document {
33 pub(crate) fn editing_context(&self, no_gc: &NoGC, node: &Node) -> EditingContext {
34 if let Ok(editing_context) = EditingContext::try_from(node) {
35 return editing_context;
36 }
37
38 let mut current_node = UnrootedDom::from_ref(node, no_gc);
39 while let Some(parent) = current_node.parent_in_flat_tree(no_gc).into_parent() {
40 if let Ok(editing_context) = EditingContext::try_from(&**parent) {
41 return editing_context;
42 }
43 current_node = parent;
44 }
45
46 EditingContext::Document(DomRoot::from_ref(self))
47 }
48
49 /// <https://www.w3.org/TR/clipboard-apis/#clipboard-actions>
50 pub(crate) fn handle_editing_action(
51 &self,
52 cx: &mut JSContext,
53 node: &Node,
54 action: EditingActionEvent,
55 ) -> InputEventResult {
56 let clipboard_event_type = match action {
57 EditingActionEvent::Copy => ClipboardEventType::Copy,
58 EditingActionEvent::Cut => ClipboardEventType::Cut,
59 EditingActionEvent::Paste => ClipboardEventType::Paste,
60 };
61
62 // The script_triggered flag is set if the action runs because of a script, e.g. document.execCommand()
63 let script_triggered = false;
64
65 // The script_may_access_clipboard flag is set
66 // if action is paste and the script thread is allowed to read from clipboard or
67 // if action is copy or cut and the script thread is allowed to modify the clipboard
68 let script_may_access_clipboard = false;
69
70 // Step 1 If the script-triggered flag is set and the script-may-access-clipboard flag is unset
71 if script_triggered && !script_may_access_clipboard {
72 return InputEventResult::empty();
73 }
74
75 // Step 2 Fire a clipboard event
76 let editing_context = self.editing_context(cx.no_gc(), node);
77 let event_target = editing_context.event_target();
78 let clipboard_event = self.fire_clipboard_event(cx, &event_target, clipboard_event_type);
79
80 let event = clipboard_event.upcast::<Event>();
81 if !event.DefaultPrevented() {
82 // Step 3. If the event was not canceled, then
83 match clipboard_event.clipboard_event_type() {
84 ClipboardEventType::Copy => {
85 // Step 3.1. Copy the selected contents, if any, to the clipboard.
86 // Implementations should create alternate text/html and text/plain
87 // clipboard formats when content in a web page is selected.
88 if let Some(selection) = editing_context.selection_content(cx) {
89 self.send_to_embedder(EmbedderMsg::SetClipboardText(
90 self.webview_id(),
91 selection,
92 ));
93 }
94 // Step 3.2. Fire a clipboard event named clipboardchange
95 self.fire_clipboard_event(cx, &event_target, ClipboardEventType::Change);
96
97 // This is how `true` is returned from this function.
98 event.mark_as_handled();
99 },
100 ClipboardEventType::Cut => {
101 if let Some(selection) = editing_context.selection_content(cx) &&
102 editing_context.cutting_and_pasting_enabled()
103 {
104 // Step 3.1. If there is a selection in an editable context where
105 // cutting is enabled, then
106 // Step 3.1.1. Copy the selected contents, if any, to the clipboard.
107 // Implementations should create alternate text/html and text/plain
108 // clipboard formats when content in a web page is selected.
109 self.send_to_embedder(EmbedderMsg::SetClipboardText(
110 self.webview_id(),
111 selection,
112 ));
113
114 // Step 3.1.2. Remove the contents of the selection from the document
115 // and collapse the selection.
116 editing_context.remove_the_contents_of_the_selection(cx);
117
118 // Step 3.1.3. Fire a clipboard event named clipboardchange
119 self.fire_clipboard_event(cx, &event_target, ClipboardEventType::Change);
120
121 // Step 3.1.4. Queue tasks to fire any events that should fire due to
122 // the modification, see §5.3 Integration with other scripts and
123 // events for details.
124 editing_context.fire_cut_events();
125
126 // This is how `true` is returned from this function.
127 event.mark_as_handled();
128 }
129 },
130 ClipboardEventType::Paste => {
131 if editing_context.has_selection_or_cursor() &&
132 editing_context.cutting_and_pasting_enabled() &&
133 let Some(text_content) = clipboard_event.text_content()
134 {
135 // Step 3.1. If there is a selection or cursor in an editable context
136 // where pasting is enabled, then
137 // Step 3.1.1. Insert the most suitable content found on the
138 // clipboard, if any, into the context.
139 editing_context.insert_content(cx, &text_content);
140
141 // Step 3.1.2. Queue tasks to fire any events that should fire due to
142 // the modification, see §5.3 Integration with other scripts and
143 // events for details.
144 editing_context.fire_paste_events(&text_content);
145
146 // This is how `true` is returned from this function.
147 event.mark_as_handled();
148 }
149 },
150 _ => (),
151 }
152 } else {
153 // Step 4 If the event was canceled, then
154 match clipboard_event.clipboard_event_type() {
155 ClipboardEventType::Copy => {
156 // Step 4.1 Call the write content to the clipboard algorithm,
157 // passing on the DataTransferItemList items, a clear-was-called flag and a types-to-clear list.
158 if let Some(clipboard_data) = clipboard_event.clipboard_data() {
159 let drag_data_store =
160 clipboard_data.data_store().expect("This shouldn't fail");
161 self.write_content_to_the_clipboard(&drag_data_store);
162 }
163 },
164 ClipboardEventType::Cut => {
165 // Step 4.1 Call the write content to the clipboard algorithm,
166 // passing on the DataTransferItemList items, a clear-was-called flag and a types-to-clear list.
167 if let Some(clipboard_data) = clipboard_event.clipboard_data() {
168 let drag_data_store =
169 clipboard_data.data_store().expect("This shouldn't fail");
170 self.write_content_to_the_clipboard(&drag_data_store);
171 }
172
173 // Step 4.2 Fire a clipboard event named clipboardchange
174 self.fire_clipboard_event(cx, &event_target, ClipboardEventType::Change);
175 },
176 // Step 4.1 Return false.
177 ClipboardEventType::Paste => (),
178 _ => (),
179 }
180 }
181
182 // Step 5: Return true from the action.
183 // In this case we are returning the `InputEventResult` instead of true or false.
184 event.flags().into()
185 }
186
187 /// <https://www.w3.org/TR/clipboard-apis/#fire-a-clipboard-event>
188 fn fire_clipboard_event(
189 &self,
190 cx: &mut JSContext,
191 target: &EventTarget,
192 clipboard_event_type: ClipboardEventType,
193 ) -> DomRoot<ClipboardEvent> {
194 let clipboard_event = ClipboardEvent::new(
195 cx,
196 self.window(),
197 None,
198 clipboard_event_type,
199 EventBubbles::Bubbles,
200 EventCancelable::Cancelable,
201 None,
202 );
203
204 // Step 1 Let clear_was_called be false
205 // Step 2 Let types_to_clear an empty list
206 let mut drag_data_store = DragDataStore::new();
207
208 // Step 4 let clipboard-entry be the sequence number of clipboard content, null if the OS doesn't support it.
209
210 // Step 5 let trusted be true if the event is generated by the user agent, false otherwise
211 let trusted = true;
212
213 // Step 6 if the context is editable:
214 // Step 6.2 else TODO require Selection see https://github.com/w3c/clipboard-apis/issues/70
215 // Step 7
216 match clipboard_event.clipboard_event_type() {
217 ClipboardEventType::Copy | ClipboardEventType::Cut => {
218 // Step 7.2.1
219 drag_data_store.set_mode(Mode::ReadWrite);
220 },
221 ClipboardEventType::Paste => {
222 let (callback, receiver) =
223 GenericCallback::new_blocking().expect("Could not create callback");
224 self.send_to_embedder(EmbedderMsg::GetClipboardText(self.webview_id(), callback));
225 let text_contents = receiver
226 .recv()
227 .map(Result::unwrap_or_default)
228 .unwrap_or_default();
229
230 // Step 7.1.1
231 drag_data_store.set_mode(Mode::ReadOnly);
232 // Step 7.1.2 If trusted or the implementation gives script-generated events access to the clipboard
233 if trusted {
234 // Step 7.1.2.1 For each clipboard-part on the OS clipboard:
235
236 // Step 7.1.2.1.1 If clipboard-part contains plain text, then
237 let data = DOMString::from(text_contents);
238 let type_ = DOMString::from_static("text/plain");
239 let _ = drag_data_store.add(Kind::Text { data, type_ });
240
241 // Step 7.1.2.1.2 TODO If clipboard-part represents file references, then for each file reference
242 // Step 7.1.2.1.3 TODO If clipboard-part contains HTML- or XHTML-formatted text then
243
244 // Step 7.1.3 Update clipboard-event-data’s files to match clipboard-event-data’s items
245 // Step 7.1.4 Update clipboard-event-data’s types to match clipboard-event-data’s items
246 }
247 },
248 ClipboardEventType::Change | ClipboardEventType::Other(..) => (),
249 }
250
251 // Step 3
252 let clipboard_event_data = DataTransfer::new(
253 cx,
254 self.window(),
255 Rc::new(RefCell::new(Some(drag_data_store))),
256 );
257
258 // Step 8
259 clipboard_event.set_clipboard_data(Some(&clipboard_event_data));
260
261 // Step 9
262 let event = clipboard_event.upcast::<Event>();
263 event.set_trusted(trusted);
264
265 // Step 10 Set event’s composed to true.
266 event.set_composed(true);
267
268 // Step 11
269 event.dispatch(cx, target, false);
270
271 DomRoot::from(clipboard_event)
272 }
273
274 /// <https://www.w3.org/TR/clipboard-apis/#write-content-to-the-clipboard>
275 fn write_content_to_the_clipboard(&self, drag_data_store: &DragDataStore) {
276 // Step 1
277 if drag_data_store.list_len() > 0 {
278 // Step 1.1 Clear the clipboard.
279 self.send_to_embedder(EmbedderMsg::ClearClipboard(self.webview_id()));
280 // Step 1.2
281 for item in drag_data_store.iter_item_list() {
282 match item {
283 Kind::Text { data, .. } => {
284 // Step 1.2.1.1 Ensure encoding is correct per OS and locale conventions
285 // Step 1.2.1.2 Normalize line endings according to platform conventions
286 // Step 1.2.1.3
287 self.send_to_embedder(EmbedderMsg::SetClipboardText(
288 self.webview_id(),
289 data.to_string(),
290 ));
291 },
292 Kind::File { .. } => {
293 // Step 1.2.2 If data is of a type listed in the mandatory data types list, then
294 // Step 1.2.2.1 Place part on clipboard with the appropriate OS clipboard format description
295 // Step 1.2.3 Else this is left to the implementation
296 },
297 }
298 }
299 } else {
300 // Step 2.1
301 if drag_data_store.clear_was_called {
302 // Step 2.1.1 If types-to-clear list is empty, clear the clipboard
303 self.send_to_embedder(EmbedderMsg::ClearClipboard(self.webview_id()));
304 // Step 2.1.2 Else remove the types in the list from the clipboard
305 // As of now this can't be done with Arboard, and it's possible that will be removed from the spec
306 }
307 }
308 }
309
310 /// <https://w3c.github.io/editing/docs/execCommand/#additional-requirements>
311 pub(crate) fn maybe_perform_editing_command(
312 &self,
313 cx: &mut JSContext,
314 event: &KeyboardEvent,
315 ) -> bool {
316 if !servo_config::pref!(dom_exec_command_enabled) {
317 return false;
318 }
319 // This function does not do any checks for whether or not we are actually inside an
320 // editing host, since those checks are performed by exec_command_for_command_id either way.
321 match event.key() {
322 Key::Named(NamedKey::Enter) => {
323 // TODO: Figure out if the bit about Option+Enter works and whether or not this
324 // ends up providing the correct behavior on Mac. (i.e. whether or not
325 // Shift should be accepted in addition to Option.)
326 if event.modifiers().contains(Modifiers::SHIFT) {
327 // > When the user instructs the user agent to insert a line break inside an
328 // > editing host without breaking out of the current block, such as by
329 // > pressing Shift-Enter or Option-Enter while the cursor is in an editable
330 // > node, the user agent must call execCommand("insertlinebreak") on the
331 // > relevant document.
332 self.exec_command_for_command_id(
333 cx,
334 DOMString::from_static("insertlinebreak"),
335 DOMString::new(),
336 )
337 } else {
338 // > When the user instructs the user agent to insert a line break inside an
339 // > editing host, such as by pressing the Enter key while the cursor is in an
340 // > editable node, the user agent must call execCommand("insertparagraph") on
341 // > the relevant document.
342 self.exec_command_for_command_id(
343 cx,
344 DOMString::from_static("insertparagraph"),
345 DOMString::new(),
346 )
347 }
348 },
349 // > When the user instructs the user agent to delete the previous character inside an
350 // > editing host, such as by pressing the Backspace key while the cursor is in an
351 // > editable node, the user agent must call execCommand("delete") on the relevant
352 // > document.
353 // TODO: Gecko, Chromium and WebKit seem to delete up to the next word boundary on
354 // Ctrl+Backspace and Ctrl+Delete. We probably want that as well.
355 Key::Named(NamedKey::Backspace) => self.exec_command_for_command_id(
356 cx,
357 DOMString::from_static("delete"),
358 DOMString::new(),
359 ),
360 // > When the user instructs the user agent to delete the next character inside an
361 // > editing host, such as by pressing the Delete key while the cursor is in an
362 // > editable node, the user agent must call execCommand("forwarddelete") on the
363 // > relevant document.
364 Key::Named(NamedKey::Delete) => self.exec_command_for_command_id(
365 cx,
366 DOMString::from_static("forwarddelete"),
367 DOMString::new(),
368 ),
369 // > When the user instructs the user agent to insert text inside an editing host, such
370 // > as by typing on the keyboard while the cursor is in an editable node, the user
371 // > agent must call execCommand("inserttext", false, value) on the relevant document,
372 // > with value equal to the text the user provided. If the user inserts multiple
373 // > characters at once or in quick succession, this specification does not define
374 // > whether it is treated as one insertion or several consecutive insertions.
375 Key::Character(string) => self.exec_command_for_command_id(
376 cx,
377 DOMString::from_static("inserttext"),
378 DOMString::from(string),
379 ),
380 _ => false,
381 }
382 }
383}
384
385pub(crate) enum TextControlElementEditingContext {
386 TextArea(DomRoot<HTMLTextAreaElement>),
387 Input(DomRoot<HTMLInputElement>),
388}
389
390impl TextControlElementEditingContext {
391 fn text_control_element(&self) -> &dyn TextControlElement {
392 match self {
393 TextControlElementEditingContext::TextArea(text_area) => &**text_area,
394 TextControlElementEditingContext::Input(input) => &**input,
395 }
396 }
397}
398
399pub(crate) enum EditingContext {
400 TextControl(TextControlElementEditingContext),
401 Document(DomRoot<Document>),
402}
403
404impl TryFrom<&Node> for EditingContext {
405 type Error = ();
406
407 fn try_from(node: &Node) -> Result<Self, Self::Error> {
408 if let Some(text_area) = node.downcast::<HTMLTextAreaElement>() {
409 return Ok(EditingContext::TextControl(
410 TextControlElementEditingContext::TextArea(DomRoot::from_ref(text_area)),
411 ));
412 }
413 if let Some(input) = node.downcast::<HTMLInputElement>() &&
414 input.is_textual_or_password()
415 {
416 return Ok(EditingContext::TextControl(
417 TextControlElementEditingContext::Input(DomRoot::from_ref(input)),
418 ));
419 }
420 Err(())
421 }
422}
423
424impl EditingContext {
425 pub(crate) fn event_target(&self) -> DomRoot<EventTarget> {
426 match self {
427 EditingContext::TextControl(element) => match element {
428 TextControlElementEditingContext::TextArea(text_area) => {
429 DomRoot::from_ref(text_area.upcast())
430 },
431 TextControlElementEditingContext::Input(input) => DomRoot::from_ref(input.upcast()),
432 },
433 EditingContext::Document(document) => {
434 document.event_handler().target_for_events_following_focus()
435 },
436 }
437 }
438
439 pub(crate) fn selection_content(&self, cx: &mut JSContext) -> Option<String> {
440 match self {
441 EditingContext::TextControl(element) => element
442 .text_control_element()
443 .text_input()
444 .selection_content(),
445 EditingContext::Document(document) => document
446 .selection()
447 .map(|selection| selection.Stringifier(cx).to_string())
448 .filter(|selection| !selection.is_empty()),
449 }
450 }
451
452 pub(crate) fn has_uncollapsed_selection(&self) -> bool {
453 match self {
454 EditingContext::TextControl(element) => {
455 element.text_control_element().has_uncollapsed_selection()
456 },
457 EditingContext::Document(document) => document
458 .selection()
459 .is_some_and(|selection| !selection.collapsed()),
460 }
461 }
462
463 pub(crate) fn has_selectable_text(&self) -> bool {
464 match self {
465 EditingContext::TextControl(element) => {
466 element.text_control_element().has_selectable_text()
467 },
468 EditingContext::Document(..) => true,
469 }
470 }
471
472 pub(crate) fn has_selection_or_cursor(&self) -> bool {
473 match self {
474 EditingContext::TextControl(..) => true,
475 EditingContext::Document(document) => document
476 .selection()
477 .is_some_and(|selection| selection.RangeCount() > 0),
478 }
479 }
480
481 pub(crate) fn cutting_and_pasting_enabled(&self) -> bool {
482 match self {
483 EditingContext::TextControl(element) => {
484 !element.text_control_element().read_only_or_disabled()
485 },
486 EditingContext::Document(..) => {
487 // TODO(mrobinson): Add support for integration with contenteditable.
488 false
489 },
490 }
491 }
492
493 pub(crate) fn remove_the_contents_of_the_selection(&self, cx: &mut JSContext) {
494 match self {
495 EditingContext::TextControl(element) => {
496 element
497 .text_control_element()
498 .remove_the_contents_of_the_selection(cx);
499 },
500 EditingContext::Document(..) => {
501 // TODO(mrobinson): Add support for integration with contenteditable.
502 },
503 }
504 }
505
506 pub(crate) fn fire_cut_events(&self) {
507 match self {
508 EditingContext::TextControl(element) => {
509 element.text_control_element().queue_input_event(
510 None,
511 IsComposing::NotComposing,
512 InputEventType::DeleteByCut,
513 );
514 },
515 EditingContext::Document(..) => {},
516 }
517 }
518
519 pub(crate) fn insert_content(&self, cx: &mut JSContext, text_content: &str) {
520 match self {
521 EditingContext::TextControl(element) => {
522 element
523 .text_control_element()
524 .insert_content(cx, text_content);
525 },
526 EditingContext::Document(..) => {
527 // TODO(mrobinson): Add support for integration with contenteditable.
528 },
529 }
530 }
531
532 pub(crate) fn fire_paste_events(&self, text_content: &str) {
533 match self {
534 EditingContext::TextControl(element) => {
535 element.text_control_element().queue_input_event(
536 Some(text_content.to_owned()),
537 IsComposing::NotComposing,
538 InputEventType::InsertFromPaste,
539 );
540 },
541 EditingContext::Document(..) => {},
542 }
543 }
544
545 pub(crate) fn select_all(&self, cx: &mut JSContext) {
546 match self {
547 EditingContext::TextControl(element) => element.text_control_element().select_all(),
548 EditingContext::Document(document) => {
549 let Some(selection) = document.GetSelection(cx) else {
550 return;
551 };
552 if let Some(node) = document
553 .GetBody()
554 .map(DomRoot::upcast::<Node>)
555 .or_else(|| document.GetDocumentElement().map(DomRoot::upcast::<Node>))
556 {
557 let _ = selection.SelectAllChildren(cx, &node);
558 }
559 },
560 }
561 }
562}