Add `nsINode::IsInDesignMode()` to check whether the node is directly in design mode

There are a lot of check of `Document`'s editable state **with** comments. This
means that it's unclear for developers that only `Document` node is editable in
design mode.

Additionally, there are some points which use composed document rather than
uncomposed document even though the raw API uses uncomposed document. Comparing
with the other browsers, checking uncomposed document is compatible behavior,
i.e., nodes in shadow trees are not editable unless `contenteditable`.

Therefore, `nsINode` should have a method to check whether it's in design mode
or not.

Note that it may be called with a node in UA widget.  Therefore, this patch
adds new checks if it's in UA widget subtree or native anonymous subtree,
checking whether it's in design mode with its host.

Differential Revision: https://phabricator.services.mozilla.com/D126764

bugzilla-url: https://bugzilla.mozilla.org/show_bug.cgi?id=1732845
gecko-commit: 680ebfd6ee371e885e294af566f81e5bf17df6c0
gecko-reviewers: smaug
diff --git a/editing/include/editor-test-utils.js b/editing/include/editor-test-utils.js
index 3ca014a..9ce5ff1 100644
--- a/editing/include/editor-test-utils.js
+++ b/editing/include/editor-test-utils.js
@@ -73,6 +73,27 @@
     return this.sendKey(kEnd, modifier);
   }
 
+  sendSelectAllShortcutKey() {
+    return this.sendKey(
+      "a",
+      (() => {
+        // Gecko for Linux defines only Alt-A as a shortcut key for select all,
+        // although in most environment, Ctrl-A works as so too, but it depends
+        // on the OS settings.
+        if (
+          this.window.navigator.userAgent.includes("Linux") &&
+          this.window.navigator.userAgent.includes("Gecko") &&
+          !this.window.navigator.userAgent.includes("KHTML")
+        ) {
+          return this.kAlt;
+        }
+        return this.window.navigator.platform.includes("Mac")
+          ? this.kMeta
+          : this.kControl;
+      })()
+    );
+  }
+
   // Similar to `setupDiv` in editing/include/tests.js, this method sets
   // innerHTML value of this.editingHost, and sets multiple selection ranges
   // specified with the markers.
@@ -142,7 +163,7 @@
           return {
             marker: scanResult[0],
             container: textNode,
-            offset: scanResult.index + offset
+            offset: scanResult.index + offset,
           };
         };
         if (startContainer.nodeType === Node.TEXT_NODE) {
@@ -181,7 +202,7 @@
           return {
             marker: scanResult[0],
             container: textNode,
-            offset: scanResult.index + offset
+            offset: scanResult.index + offset,
           };
         };
         if (startContainer.nodeType === Node.TEXT_NODE) {
diff --git a/editing/other/editable-state-and-focus-in-shadow-dom-in-designMode.tentative.html b/editing/other/editable-state-and-focus-in-shadow-dom-in-designMode.tentative.html
new file mode 100644
index 0000000..88e6d29
--- /dev/null
+++ b/editing/other/editable-state-and-focus-in-shadow-dom-in-designMode.tentative.html
@@ -0,0 +1,252 @@
+<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>Testing editable state and focus in shadow DOM in design mode</title>
+<script src=/resources/testharness.js></script>
+<script src=/resources/testharnessreport.js></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="../include/editor-test-utils.js"></script>
+</head>
+<body>
+<h3>open</h3>
+<my-shadow data-mode="open"></my-shadow>
+<h3>closed</h3>
+<my-shadow data-mode="closed"></my-shadow>
+
+<script>
+"use strict";
+
+document.designMode = "on";
+const utils = new EditorTestUtils(document.body);
+
+class MyShadow extends HTMLElement {
+  #defaultInnerHTML =
+    "<style>:focus { outline: 3px red solid; }</style>" +
+    "<div>text" +
+      "<div contenteditable=\"\">editable</div>" +
+      "<object tabindex=\"0\">object</object>" +
+      "<p tabindex=\"0\">paragraph</p>" +
+    "</div>";
+  #shadowRoot;
+
+  constructor() {
+    super();
+    this.#shadowRoot = this.attachShadow({mode: this.getAttribute("data-mode")});
+    this.#shadowRoot.innerHTML = this.#defaultInnerHTML;
+  }
+
+  reset() {
+    this.#shadowRoot.innerHTML = this.#defaultInnerHTML;
+    this.#shadowRoot.querySelector("div").getBoundingClientRect();
+  }
+
+  focusText() {
+    this.focus();
+    const div = this.#shadowRoot.querySelector("div");
+    getSelection().collapse(div.firstChild || div, 0);
+  }
+
+  focusContentEditable() {
+    this.focus();
+    const contenteditable = this.#shadowRoot.querySelector("div[contenteditable]");
+    contenteditable.focus();
+    getSelection().collapse(contenteditable.firstChild || contenteditable, 0);
+  }
+
+  focusObject() {
+    this.focus();
+    this.#shadowRoot.querySelector("object[tabindex]").focus();
+  }
+
+  focusParagraph() {
+    this.focus();
+    const tabbableP = this.#shadowRoot.querySelector("p[tabindex]");
+    tabbableP.focus();
+    getSelection().collapse(tabbableP.firstChild || tabbableP, 0);
+  }
+
+  getInnerHTML() {
+    return this.#shadowRoot.innerHTML;
+  }
+
+  getDefaultInnerHTML() {
+    return this.#defaultInnerHTML;
+  }
+
+  getFocusedElementName() {
+    return this.#shadowRoot.querySelector(":focus")?.tagName.toLocaleLowerCase() || "";
+  }
+
+  getSelectedRange() {
+    // XXX There is no standardized way to retrieve selected ranges in
+    //     shadow trees, therefore, we use non-standardized API for now
+    //     since the main purpose of this test is checking the behavior of
+    //     selection changes in shadow trees, not checking the selection API.
+    const selection =
+      this.#shadowRoot.getSelection !== undefined
+        ? this.#shadowRoot.getSelection()
+        : getSelection();
+    return selection.getRangeAt(0);
+  }
+}
+
+customElements.define("my-shadow", MyShadow);
+
+function getRangeDescription(range) {
+  function getNodeDescription(node) {
+    if (!node) {
+      return "null";
+    }
+    switch (node.nodeType) {
+      case Node.TEXT_NODE:
+      case Node.COMMENT_NODE:
+      case Node.CDATA_SECTION_NODE:
+        return `${node.nodeName} "${node.data}"`;
+      case Node.ELEMENT_NODE:
+        return `<${node.nodeName.toLowerCase()}>`;
+      default:
+        return `${node.nodeName}`;
+    }
+  }
+  if (range === null) {
+    return "null";
+  }
+  if (range === undefined) {
+    return "undefined";
+  }
+  return range.startContainer == range.endContainer &&
+    range.startOffset == range.endOffset
+    ? `(${getNodeDescription(range.startContainer)}, ${range.startOffset})`
+    : `(${getNodeDescription(range.startContainer)}, ${
+        range.startOffset
+      }) - (${getNodeDescription(range.endContainer)}, ${range.endOffset})`;
+}
+
+promise_test(async () => {
+  await new Promise(resolve => addEventListener("load", resolve, {once: true}));
+  assert_true(true, "Load event is fired");
+}, "Waiting for load");
+
+/**
+ * The expected result of this test is based on Blink and Gecko's behavior.
+ */
+
+for (const mode of ["open", "closed"]) {
+  const host = document.querySelector(`my-shadow[data-mode=${mode}]`);
+  promise_test(async (t) => {
+    host.reset();
+    host.focusText();
+    test(() => {
+      assert_equals(
+        host.getFocusedElementName(),
+        "",
+        `No element should have focus after ${t.name}`
+      );
+    }, `Focus after ${t.name}`);
+    await utils.sendKey("A");
+    test(() => {
+      assert_equals(
+        host.getInnerHTML(),
+        host.getDefaultInnerHTML(),
+        `The shadow DOM shouldn't be modified after ${t.name}`
+      );
+    }, `Typing "A" after ${t.name}`);
+  }, `Collapse selection into text in the ${mode} shadow DOM`);
+
+  promise_test(async (t) => {
+    host.reset();
+    host.focusContentEditable();
+    test(() => {
+      assert_equals(
+        host.getFocusedElementName(),
+        "div",
+        `<div contenteditable> should have focus after ${t.name}`
+      );
+    }, `Focus after ${t.name}`);
+    await utils.sendKey("A");
+    test(() => {
+      assert_equals(
+        host.getInnerHTML(),
+        host.getDefaultInnerHTML().replace("<div contenteditable=\"\">", "<div contenteditable=\"\">A"),
+        `The shadow DOM shouldn't be modified after ${t.name}`
+      );
+    }, `Typing "A" after ${t.name}`);
+  }, `Collapse selection into text in <div contenteditable> in the ${mode} shadow DOM`);
+
+  promise_test(async (t) => {
+    host.reset();
+    host.focusObject();
+    test(() => {
+      assert_equals(
+        host.getFocusedElementName(),
+        "object",
+        `The <object> element should have focus after ${t.name}`
+      );
+    }, `Focus after ${t.name}`);
+    await utils.sendKey("A");
+    test(() => {
+      assert_equals(
+        host.getInnerHTML(),
+        host.getDefaultInnerHTML(),
+        `The shadow DOM shouldn't be modified after ${t.name}`
+      );
+    }, `Typing "A" after ${t.name}`);
+  }, `Set focus to <object> in the ${mode} shadow DOM`);
+
+  promise_test(async (t) => {
+    host.reset();
+    host.focusParagraph();
+    test(() => {
+      assert_equals(
+        host.getFocusedElementName(),
+        "p",
+        `The <p tabindex="0"> element should have focus after ${t.name}`
+      );
+    }, `Focus after ${t.name}`);
+    await utils.sendKey("A");
+    test(() => {
+      assert_equals(
+        host.getInnerHTML(),
+        host.getDefaultInnerHTML(),
+        `The shadow DOM shouldn't be modified after ${t.name}`
+      );
+    }, `Typing "A" after ${t.name}`);
+  }, `Set focus to <p tabindex="0"> in the ${mode} shadow DOM`);
+
+  promise_test(async (t) => {
+    host.reset();
+    host.focusParagraph();
+    await utils.sendSelectAllShortcutKey();
+    assert_in_array(
+      getRangeDescription(host.getSelectedRange()),
+      [
+        // Feel free to add reasonable select all result in the <my-shadow>.
+        "(#document-fragment, 0) - (#document-fragment, 2)",
+        "(#text \"text\", 0) - (#text \"paragraph\", 9)",
+      ],
+      `Only all children of the ${mode} shadow DOM should be selected`
+    );
+    getSelection().collapse(document.body, 0);
+  }, `SelectAll in the ${mode} shadow DOM`);
+
+  promise_test(async (t) => {
+    host.reset();
+    host.focusContentEditable();
+    await utils.sendSelectAllShortcutKey();
+    assert_in_array(
+      getRangeDescription(host.getSelectedRange()),
+      [
+        // Feel free to add reasonable select all result in the <div contenteditable>.
+        "(<div>, 0) - (<div>, 1)",
+        "(#text \"editable\", 0) - (#text \"editable\", 8)",
+      ]
+    );
+    getSelection().collapse(document.body, 0);
+  }, `SelectAll in the <div contenteditable> in the ${mode} shadow DOM`);
+}
+</script>
+</body>
+</html>