[Cocoa] [WebKit2] Add support for replacing find-in-page text matches
authorwenson_hsieh@apple.com <wenson_hsieh@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 22 Nov 2018 05:03:59 +0000 (05:03 +0000)
committerwenson_hsieh@apple.com <wenson_hsieh@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 22 Nov 2018 05:03:59 +0000 (05:03 +0000)
https://bugs.webkit.org/show_bug.cgi?id=191786
<rdar://problem/45813871>

Reviewed by Ryosuke Niwa.

Source/WebCore:

Add support for replacing Find-in-Page matches. See below for details. Covered by new layout tests as well as a
new API test.

Tests: editing/find/find-and-replace-adjacent-words.html
       editing/find/find-and-replace-at-editing-boundary.html
       editing/find/find-and-replace-basic.html
       editing/find/find-and-replace-in-subframes.html
       editing/find/find-and-replace-no-matches.html
       editing/find/find-and-replace-noneditable-matches.html
       editing/find/find-and-replace-replacement-text-input-events.html

API test: WebKit.FindAndReplace

* page/Page.cpp:
(WebCore::replaceRanges):
(WebCore::Page::replaceRangesWithText):

Add a helper that, given a list of Ranges, replaces each range with the given text. To do this, we first map
each Range to editing offsets within the topmost editable root for each Range. This results in a map of editable
root to list of editing offsets we need to replace. To apply the replacements, for each editable root in the
map, we iterate over each replacement range (i.e. an offset and length), set the current selection to contain
that replacement range, and use `Editor::replaceSelectionWithText`. To prevent prior text replacements from
clobbering the offsets of latter text replacement ranges, we also iterate backwards through text replacement
ranges when performing each replacement.

Likewise, we also apply text replacement to each editing container in backwards order: for nodes in the same
frame, we compare their position in the document, and for nodes in different frames, we instead compare their
frames in frame tree traversal order.

We map Ranges to editing offsets and back when performing text replacement because each text replacement may
split or merge text nodes, which causes adjacent Ranges to shrink or extend while replacing text. In an earlier
attempt to implement this, I simply iterated over each Range to replace and carried out text replacement for
each Range. This led to incorrect behavior in some cases, such as replacing adjacent matches. Thus, by computing
the set of text replacement offsets prior to replacing any text, we're able to target the correct ranges for
replacement.

(WebCore::Page::replaceSelectionWithText):

Add a helper method on Page to replace the current selection with some text. This simply calls out to
`Editor::replaceSelectionWithText`.

* page/Page.h:

Source/WebCore/PAL:

Add `-replaceMatches:withString:inSelectionOnly:resultCollector:`.

* pal/spi/mac/NSTextFinderSPI.h:

Source/WebKit:

* UIProcess/API/Cocoa/WKWebView.mm:
(-[WKWebView replaceMatches:withString:inSelectionOnly:resultCollector:]):
* UIProcess/WebPageProxy.cpp:
(WebKit::WebPageProxy::replaceMatches):
* UIProcess/WebPageProxy.h:
* UIProcess/mac/WKTextFinderClient.mm:
(-[WKTextFinderClient replaceMatches:withString:inSelectionOnly:resultCollector:]):

Implement this method to opt in to "Replaceā€¦" UI on macOS in the find bar. In this API, we're given a list of
matches to replace. We propagate the indices of each match to the web process, where FindController maps them to
corresponding replacement ranges. Currently, the given list of matches is only ever a list containing the first
match, or a list containing all matches.

* WebProcess/InjectedBundle/API/c/WKBundlePage.cpp:
(WKBundlePageFindStringMatches):
(WKBundlePageReplaceStringMatches):
* WebProcess/InjectedBundle/API/c/WKBundlePage.h:
* WebProcess/WebCoreSupport/WebEditorClient.cpp:
* WebProcess/WebPage/FindController.cpp:
(WebKit::FindController::replaceMatches):

Map match indices to Ranges, and then call into WebCore::Page to do the heavy lifting (see WebCore ChangeLog for
more details). Additionally add a hard find-and-replace limit here to prevent the web process from spinning
indefinitely if there are an enormous number of find matches.

* WebProcess/WebPage/FindController.h:
* WebProcess/WebPage/WebPage.cpp:
(WebKit::WebPage::findStringMatchesFromInjectedBundle):
(WebKit::WebPage::replaceStringMatchesFromInjectedBundle):

Add helpers to exercise find and replace in WebKit2.

(WebKit::WebPage::replaceMatches):
* WebProcess/WebPage/WebPage.h:
* WebProcess/WebPage/WebPage.messages.in:

Tools:

* MiniBrowser/mac/WK2BrowserWindowController.m:
(-[WK2BrowserWindowController setFindBarView:]):

Fix a bug in MiniBrowser that prevents AppKit from displaying the "All" button in the find bar after checking
the "Replace" option.

* TestWebKitAPI/Tests/WebKitCocoa/FindInPage.mm:

Add an API test to exercise find-and-replace API using WKWebView.

(replaceMatches):
(TEST):
* WebKitTestRunner/InjectedBundle/Bindings/TestRunner.idl:
* WebKitTestRunner/InjectedBundle/TestRunner.cpp:
(WTR::findOptionsFromArray):
(WTR::TestRunner::findString):
(WTR::TestRunner::findStringMatchesInPage):
(WTR::TestRunner::replaceFindMatchesAtIndices):

Add TestRunner hooks to simulate find-in-page and replace.

* WebKitTestRunner/InjectedBundle/TestRunner.h:

LayoutTests:

Introduce a `LayoutTests/editing/find` directory to contain tests around `FindController`, and add 7 new layout
tests. These are currently enabled only for WebKit2 on macOS and iOS.

* TestExpectations:
* editing/find/find-and-replace-adjacent-words-expected.txt: Added.
* editing/find/find-and-replace-adjacent-words.html: Added.

Test find-and-replace with adjacent words.

* editing/find/find-and-replace-at-editing-boundary-expected.txt: Added.
* editing/find/find-and-replace-at-editing-boundary.html: Added.

Test find-and-replace when one of the find matches straddles an editing boundary. In this case, we verify that
the replacement does not occur, since only part of the word would be replaced.

* editing/find/find-and-replace-basic-expected.txt: Added.
* editing/find/find-and-replace-basic.html: Added.

Add a basic test that exercises a single text replacement, and "replace all".

* editing/find/find-and-replace-in-subframes-expected.txt: Added.
* editing/find/find-and-replace-in-subframes.html: Added.

Test find-and-replace when some of the matches are in editable content in subframes. This test additionally
contains matches in shadow content (in this case, text fields) within both the main document and the subframe,
and verifies that text replacement reaches these elements as well.

* editing/find/find-and-replace-no-matches-expected.txt: Added.
* editing/find/find-and-replace-no-matches.html: Added.

Test find-and-replace when no replacement matches are specified. In this case, we fall back to inserting the
replacement text at the current selection.

* editing/find/find-and-replace-noneditable-matches-expected.txt: Added.
* editing/find/find-and-replace-noneditable-matches.html: Added.

Test find-and-replace when some of the matches to replace are noneditable, others are editable, and others are
editable but are nested within noneditable elements (i.e. `contenteditable=false`). In this case, "replace all"
should still replace all fully editable matches.

* editing/find/find-and-replace-replacement-text-input-events-expected.txt: Added.
* editing/find/find-and-replace-replacement-text-input-events.html: Added.

Tests that find-and-replace emits input events of `inputType` "insertReplacementText", except when inserting
replacement text at a caret selection.

* platform/ios-wk2/TestExpectations:
* platform/mac-wk2/TestExpectations:

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@238438 268f45cc-cd09-0410-ab3c-d52691b4dbfc

41 files changed:
LayoutTests/ChangeLog
LayoutTests/TestExpectations
LayoutTests/editing/find/find-and-replace-adjacent-words-expected.txt [new file with mode: 0644]
LayoutTests/editing/find/find-and-replace-adjacent-words.html [new file with mode: 0644]
LayoutTests/editing/find/find-and-replace-at-editing-boundary-expected.txt [new file with mode: 0644]
LayoutTests/editing/find/find-and-replace-at-editing-boundary.html [new file with mode: 0644]
LayoutTests/editing/find/find-and-replace-basic-expected.txt [new file with mode: 0644]
LayoutTests/editing/find/find-and-replace-basic.html [new file with mode: 0644]
LayoutTests/editing/find/find-and-replace-in-subframes-expected.txt [new file with mode: 0644]
LayoutTests/editing/find/find-and-replace-in-subframes.html [new file with mode: 0644]
LayoutTests/editing/find/find-and-replace-no-matches-expected.txt [new file with mode: 0644]
LayoutTests/editing/find/find-and-replace-no-matches.html [new file with mode: 0644]
LayoutTests/editing/find/find-and-replace-noneditable-matches-expected.txt [new file with mode: 0644]
LayoutTests/editing/find/find-and-replace-noneditable-matches.html [new file with mode: 0644]
LayoutTests/editing/find/find-and-replace-replacement-text-input-events-expected.txt [new file with mode: 0644]
LayoutTests/editing/find/find-and-replace-replacement-text-input-events.html [new file with mode: 0644]
LayoutTests/platform/ios-wk2/TestExpectations
LayoutTests/platform/mac-wk2/TestExpectations
Source/WebCore/ChangeLog
Source/WebCore/PAL/ChangeLog
Source/WebCore/PAL/pal/spi/mac/NSTextFinderSPI.h
Source/WebCore/page/Page.cpp
Source/WebCore/page/Page.h
Source/WebKit/ChangeLog
Source/WebKit/UIProcess/API/Cocoa/WKWebView.mm
Source/WebKit/UIProcess/WebPageProxy.cpp
Source/WebKit/UIProcess/WebPageProxy.h
Source/WebKit/UIProcess/mac/WKTextFinderClient.mm
Source/WebKit/WebProcess/InjectedBundle/API/c/WKBundlePage.cpp
Source/WebKit/WebProcess/InjectedBundle/API/c/WKBundlePage.h
Source/WebKit/WebProcess/WebPage/FindController.cpp
Source/WebKit/WebProcess/WebPage/FindController.h
Source/WebKit/WebProcess/WebPage/WebPage.cpp
Source/WebKit/WebProcess/WebPage/WebPage.h
Source/WebKit/WebProcess/WebPage/WebPage.messages.in
Tools/ChangeLog
Tools/MiniBrowser/mac/WK2BrowserWindowController.m
Tools/TestWebKitAPI/Tests/WebKitCocoa/FindInPage.mm
Tools/WebKitTestRunner/InjectedBundle/Bindings/TestRunner.idl
Tools/WebKitTestRunner/InjectedBundle/TestRunner.cpp
Tools/WebKitTestRunner/InjectedBundle/TestRunner.h

index d1a46ce..114ebdd 100644 (file)
@@ -1,3 +1,60 @@
+2018-11-21  Wenson Hsieh  <wenson_hsieh@apple.com>
+
+        [Cocoa] [WebKit2] Add support for replacing find-in-page text matches
+        https://bugs.webkit.org/show_bug.cgi?id=191786
+        <rdar://problem/45813871>
+
+        Reviewed by Ryosuke Niwa.
+
+        Introduce a `LayoutTests/editing/find` directory to contain tests around `FindController`, and add 7 new layout
+        tests. These are currently enabled only for WebKit2 on macOS and iOS.
+
+        * TestExpectations:
+        * editing/find/find-and-replace-adjacent-words-expected.txt: Added.
+        * editing/find/find-and-replace-adjacent-words.html: Added.
+
+        Test find-and-replace with adjacent words.
+
+        * editing/find/find-and-replace-at-editing-boundary-expected.txt: Added.
+        * editing/find/find-and-replace-at-editing-boundary.html: Added.
+
+        Test find-and-replace when one of the find matches straddles an editing boundary. In this case, we verify that
+        the replacement does not occur, since only part of the word would be replaced.
+
+        * editing/find/find-and-replace-basic-expected.txt: Added.
+        * editing/find/find-and-replace-basic.html: Added.
+
+        Add a basic test that exercises a single text replacement, and "replace all".
+
+        * editing/find/find-and-replace-in-subframes-expected.txt: Added.
+        * editing/find/find-and-replace-in-subframes.html: Added.
+
+        Test find-and-replace when some of the matches are in editable content in subframes. This test additionally
+        contains matches in shadow content (in this case, text fields) within both the main document and the subframe,
+        and verifies that text replacement reaches these elements as well.
+
+        * editing/find/find-and-replace-no-matches-expected.txt: Added.
+        * editing/find/find-and-replace-no-matches.html: Added.
+
+        Test find-and-replace when no replacement matches are specified. In this case, we fall back to inserting the
+        replacement text at the current selection.
+
+        * editing/find/find-and-replace-noneditable-matches-expected.txt: Added.
+        * editing/find/find-and-replace-noneditable-matches.html: Added.
+
+        Test find-and-replace when some of the matches to replace are noneditable, others are editable, and others are
+        editable but are nested within noneditable elements (i.e. `contenteditable=false`). In this case, "replace all"
+        should still replace all fully editable matches.
+
+        * editing/find/find-and-replace-replacement-text-input-events-expected.txt: Added.
+        * editing/find/find-and-replace-replacement-text-input-events.html: Added.
+
+        Tests that find-and-replace emits input events of `inputType` "insertReplacementText", except when inserting
+        replacement text at a caret selection.
+
+        * platform/ios-wk2/TestExpectations:
+        * platform/mac-wk2/TestExpectations:
+
 2018-11-21  Zalan Bujtas  <zalan@apple.com>
 
         [LFC][IFC] Horizontal margins should be considered as non-breakable space
index c7f89a3..41ffe54 100644 (file)
@@ -15,6 +15,7 @@ accessibility/win [ Skip ]
 displaylists [ Skip ]
 editing/mac [ Skip ]
 editing/caret/ios [ Skip ]
+editing/find [ Skip ]
 editing/pasteboard/gtk [ Skip ]
 editing/selection/ios [ Skip ]
 tiled-drawing [ Skip ]
diff --git a/LayoutTests/editing/find/find-and-replace-adjacent-words-expected.txt b/LayoutTests/editing/find/find-and-replace-adjacent-words-expected.txt
new file mode 100644 (file)
index 0000000..c84eb91
--- /dev/null
@@ -0,0 +1,11 @@
+Verifies that find and replace can be used to replace adjacent words in an editable area. This test requires WebKitTestRunner.
+
+After replacing 'apple' with an empty string:
+| <#selection-caret>
+| <br>
+
+After replacing 'apple' with 'appleapple':
+| "<#selection-anchor>appleapple<#selection-focus>appleappleappleapple"
+
+After replacing 'apple' with 'APPLE':
+| "<#selection-anchor>APPLE<#selection-focus>APPLEAPPLE"
diff --git a/LayoutTests/editing/find/find-and-replace-adjacent-words.html b/LayoutTests/editing/find/find-and-replace-adjacent-words.html
new file mode 100644 (file)
index 0000000..4072cde
--- /dev/null
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="../../resources/dump-as-markup.js"></script>
+</head>
+<body>
+    <div id="editor" contenteditable>appleappleapple</div>
+</body>
+<script>
+function reset() {
+    editor.textContent = "appleappleapple";
+}
+
+Markup.description("Verifies that find and replace can be used to replace adjacent words in an editable area. This test requires WebKitTestRunner.");
+
+testRunner.findStringMatchesInPage("apple", []);
+testRunner.replaceFindMatchesAtIndices([0, 1, 2], "", false);
+Markup.dump("editor", "After replacing 'apple' with an empty string");
+
+reset();
+
+testRunner.findStringMatchesInPage("apple", []);
+testRunner.replaceFindMatchesAtIndices([0, 1, 2], "appleapple", false);
+Markup.dump("editor", "After replacing 'apple' with 'appleapple'");
+
+reset();
+
+testRunner.findStringMatchesInPage("apple", []);
+testRunner.replaceFindMatchesAtIndices([0, 1, 2], "APPLE", false);
+Markup.dump("editor", "After replacing 'apple' with 'APPLE'");
+</script>
+</html>
diff --git a/LayoutTests/editing/find/find-and-replace-at-editing-boundary-expected.txt b/LayoutTests/editing/find/find-and-replace-at-editing-boundary-expected.txt
new file mode 100644 (file)
index 0000000..b0d41b1
--- /dev/null
@@ -0,0 +1,12 @@
+Verifies that find and replace ignores matches that span editing boundaries. This test requires WebKitTestRunner.
+
+After replacing 'apple' with 'pear':
+| "
+        "
+| <span>
+|   contenteditable=""
+|   "<#selection-anchor>pear<#selection-focus> ap"
+| <span>
+|   "ple apple"
+| "
+    "
diff --git a/LayoutTests/editing/find/find-and-replace-at-editing-boundary.html b/LayoutTests/editing/find/find-and-replace-at-editing-boundary.html
new file mode 100644 (file)
index 0000000..685562f
--- /dev/null
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="../../resources/dump-as-markup.js"></script>
+<style>
+    p[contenteditable] {
+        border: 1px solid red;
+    }
+</style>
+</head>
+<body>
+    <div id="container">
+        <span contenteditable>apple ap</span><span>ple apple</span>
+    </div>
+</body>
+<script>
+Markup.description("Verifies that find and replace ignores matches that span editing boundaries. This test requires WebKitTestRunner.");
+testRunner.findStringMatchesInPage("apple", []);
+testRunner.replaceFindMatchesAtIndices([0], "pear", false);
+Markup.dump("container", "After replacing 'apple' with 'pear'");
+</script>
+</html>
diff --git a/LayoutTests/editing/find/find-and-replace-basic-expected.txt b/LayoutTests/editing/find/find-and-replace-basic-expected.txt
new file mode 100644 (file)
index 0000000..cf48fca
--- /dev/null
@@ -0,0 +1,25 @@
+Verifies that find and replace can be used to replace words in an editable area. This test requires WebKitTestRunner.
+
+After replacing 'orange' with 'apricot':
+| "
+        "
+| <p>
+|   "Apple banana <#selection-anchor>apricot<#selection-focus>."
+| "
+        "
+| <p>
+|   "Kiwi banana pear."
+| "
+    "
+
+After replacing 'banana' with 'watermelon':
+| "
+        "
+| <p>
+|   "Apple <#selection-anchor>watermelon<#selection-focus> apricot."
+| "
+        "
+| <p>
+|   "Kiwi watermelon pear."
+| "
+    "
diff --git a/LayoutTests/editing/find/find-and-replace-basic.html b/LayoutTests/editing/find/find-and-replace-basic.html
new file mode 100644 (file)
index 0000000..e5797ba
--- /dev/null
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="../../resources/dump-as-markup.js"></script>
+</head>
+<body>
+    <div id="editor" contenteditable>
+        <p>Apple banana orange.</p>
+        <p>Kiwi banana pear.</p>
+    </div>
+</body>
+<script>
+Markup.description("Verifies that find and replace can be used to replace words in an editable area. This test requires WebKitTestRunner.");
+
+testRunner.findStringMatchesInPage("orange", []);
+testRunner.replaceFindMatchesAtIndices([0], "apricot", false);
+Markup.dump("editor", "After replacing 'orange' with 'apricot'");
+
+testRunner.findStringMatchesInPage("banana", []);
+testRunner.replaceFindMatchesAtIndices([0, 1], "watermelon", false);
+Markup.dump("editor", "After replacing 'banana' with 'watermelon'");
+</script>
+</html>
diff --git a/LayoutTests/editing/find/find-and-replace-in-subframes-expected.txt b/LayoutTests/editing/find/find-and-replace-in-subframes-expected.txt
new file mode 100644 (file)
index 0000000..081f4b2
--- /dev/null
@@ -0,0 +1,139 @@
+Verifies that find and replace can be used to replace words in different frames on the same page, as well as inside text fields. This test requires WebKitTestRunner.
+
+After replacing 'bar' with 'hello':
+| "
+        "
+| <p>
+|   "foo <#selection-anchor>hello<#selection-focus> baz"
+| "
+        "
+| <iframe>
+|   srcdoc="<body contenteditable>foo bar baz
+            <iframe srcdoc='<body contenteditable>bar</body>'></iframe>
+            <input value='foo bar baz'></input></body>"
+| "
+        "
+| <iframe>
+|   srcdoc="<iframe srcdoc='<body contenteditable>bar</body>'></iframe>
+            <iframe srcdoc='<input value=bar>'></iframe>
+            <div contenteditable>foo bar bar</div><textarea>foo bar bar</textarea>"
+| "
+        "
+| <input>
+|   value="foo bar baz"
+|   this.value="foo hello baz"
+|   <shadow:root>
+|     <div>
+|       contenteditable="plaintext-only"
+|       "foo hello baz"
+| "
+    "
+
+FRAME 0:
+| <head>
+| <body>
+|   contenteditable=""
+|   "foo <#selection-anchor>hello<#selection-focus> baz
+            "
+|   <iframe>
+|     srcdoc="<body contenteditable>bar</body>"
+|   "
+            "
+|   <input>
+|     value="foo bar baz"
+|     this.value="foo hello baz"
+|     <shadow:root>
+|       <div>
+|         contenteditable="plaintext-only"
+|         "foo hello baz"
+
+FRAME 1:
+| <head>
+| <body>
+|   <iframe>
+|     srcdoc="<body contenteditable>bar</body>"
+|   "
+            "
+|   <iframe>
+|     srcdoc="<input value=bar>"
+|   "
+            "
+|   <div>
+|     contenteditable=""
+|     "foo <#selection-anchor>hello<#selection-focus> hello"
+|   <textarea>
+|     this.value="foo hello hello"
+|     "foo bar bar"
+|     <shadow:root>
+|       <div>
+|         contenteditable="plaintext-only"
+|         "foo hello hello"
+
+After replacing the first occurrence of 'foo' with 'world':
+| "
+        "
+| <p>
+|   "<#selection-anchor>world<#selection-focus> hello baz"
+| "
+        "
+| <iframe>
+|   srcdoc="<body contenteditable>foo bar baz
+            <iframe srcdoc='<body contenteditable>bar</body>'></iframe>
+            <input value='foo bar baz'></input></body>"
+| "
+        "
+| <iframe>
+|   srcdoc="<iframe srcdoc='<body contenteditable>bar</body>'></iframe>
+            <iframe srcdoc='<input value=bar>'></iframe>
+            <div contenteditable>foo bar bar</div><textarea>foo bar bar</textarea>"
+| "
+        "
+| <input>
+|   value="foo bar baz"
+|   this.value="foo hello baz"
+|   <shadow:root>
+|     <div>
+|       contenteditable="plaintext-only"
+|       "foo hello baz"
+| "
+    "
+
+FRAME 0:
+| <head>
+| <body>
+|   contenteditable=""
+|   "foo <#selection-anchor>hello<#selection-focus> baz
+            "
+|   <iframe>
+|     srcdoc="<body contenteditable>bar</body>"
+|   "
+            "
+|   <input>
+|     value="foo bar baz"
+|     this.value="foo hello baz"
+|     <shadow:root>
+|       <div>
+|         contenteditable="plaintext-only"
+|         "foo hello baz"
+
+FRAME 1:
+| <head>
+| <body>
+|   <iframe>
+|     srcdoc="<body contenteditable>bar</body>"
+|   "
+            "
+|   <iframe>
+|     srcdoc="<input value=bar>"
+|   "
+            "
+|   <div>
+|     contenteditable=""
+|     "foo <#selection-anchor>hello<#selection-focus> hello"
+|   <textarea>
+|     this.value="foo hello hello"
+|     "foo bar bar"
+|     <shadow:root>
+|       <div>
+|         contenteditable="plaintext-only"
+|         "foo hello hello"
diff --git a/LayoutTests/editing/find/find-and-replace-in-subframes.html b/LayoutTests/editing/find/find-and-replace-in-subframes.html
new file mode 100644 (file)
index 0000000..f20fe6e
--- /dev/null
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="../../resources/dump-as-markup.js"></script>
+</head>
+<body>
+    <div contenteditable id="editor">
+        <p>foo bar baz</p>
+        <iframe srcdoc="<body contenteditable>foo bar baz
+            <iframe srcdoc='<body contenteditable>bar</body>'></iframe>
+            <input value='foo bar baz'></input></body>"></iframe>
+        <iframe srcdoc="<iframe srcdoc='<body contenteditable>bar</body>'></iframe>
+            <iframe srcdoc='<input value=bar>'></iframe>
+            <div contenteditable>foo bar bar</div><textarea>foo bar bar</textarea>"></iframe>
+        <input value='foo bar baz'>
+    </div>
+</body>
+<script>
+Markup.waitUntilDone();
+Markup.description("Verifies that find and replace can be used to replace words in different frames on the same page, as well as inside text fields. This test requires WebKitTestRunner.");
+
+addEventListener("load", () => {
+    testRunner.findStringMatchesInPage("bar", []);
+    testRunner.replaceFindMatchesAtIndices([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "hello", false);
+    Markup.dump("editor", "After replacing 'bar' with 'hello'");
+
+    testRunner.findStringMatchesInPage("foo", []);
+    testRunner.replaceFindMatchesAtIndices([0], "world", false);
+    Markup.dump("editor", "After replacing the first occurrence of 'foo' with 'world'");
+
+    Markup.notifyDone();
+});
+</script>
+</html>
diff --git a/LayoutTests/editing/find/find-and-replace-no-matches-expected.txt b/LayoutTests/editing/find/find-and-replace-no-matches-expected.txt
new file mode 100644 (file)
index 0000000..f7f2694
--- /dev/null
@@ -0,0 +1,29 @@
+Verifies that find and replace will insert the replacement text at the selection, if no matches are specified. This test requires WebKitTestRunner.
+
+After replacing 'banana' with 'pear':
+| "
+        "
+| <p>
+|   id="p1"
+|   "Apple <#selection-anchor>pear<#selection-focus> orange."
+| "
+        "
+| <p>
+|   id="p2"
+|   "Kiwi banana pear."
+| "
+    "
+
+After inserting 'watermelon' after 'Kiwi':
+| "
+        "
+| <p>
+|   id="p1"
+|   "Apple pear orange."
+| "
+        "
+| <p>
+|   id="p2"
+|   "Kiwi<#selection-anchor>watermelon<#selection-focus> banana pear."
+| "
+    "
diff --git a/LayoutTests/editing/find/find-and-replace-no-matches.html b/LayoutTests/editing/find/find-and-replace-no-matches.html
new file mode 100644 (file)
index 0000000..35d0e35
--- /dev/null
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="../../resources/dump-as-markup.js"></script>
+</head>
+<body>
+    <div id="editor" contenteditable>
+        <p id="p1">Apple banana orange.</p>
+        <p id="p2">Kiwi banana pear.</p>
+    </div>
+</body>
+<script>
+Markup.description("Verifies that find and replace will insert the replacement text at the selection, if no matches are specified. This test requires WebKitTestRunner.");
+
+getSelection().setBaseAndExtent(p1.childNodes[0], 6, p1.childNodes[0], 12);
+testRunner.replaceFindMatchesAtIndices([], "pear", false);
+Markup.dump("editor", "After replacing 'banana' with 'pear'");
+
+getSelection().setPosition(p2.childNodes[0], 4);
+testRunner.replaceFindMatchesAtIndices([], "watermelon", false);
+Markup.dump("editor", "After inserting 'watermelon' after 'Kiwi'");
+</script>
+</html>
diff --git a/LayoutTests/editing/find/find-and-replace-noneditable-matches-expected.txt b/LayoutTests/editing/find/find-and-replace-noneditable-matches-expected.txt
new file mode 100644 (file)
index 0000000..41c59df
--- /dev/null
@@ -0,0 +1,33 @@
+Verifies that find and replace does not change matches in noneditable content. This test requires WebKitTestRunner.
+
+After replacing 'eta' with '_eta_':
+| "
+        "
+| <p>
+|   contenteditable="false"
+|   "alpha beta."
+| "
+        "
+| <p>
+|   "gamma b<#selection-anchor>_eta_<#selection-focus> phi."
+| "
+        "
+| <div>
+|   contenteditable="false"
+|   "
+            "
+|   <p>
+|     "alpha kappa eta."
+|   "
+            "
+|   <p>
+|     contenteditable="true"
+|     "_eta_ kappa nu."
+|   "
+        "
+| "
+        "
+| <p>
+|   "b_eta_ phi delta."
+| "
+    "
diff --git a/LayoutTests/editing/find/find-and-replace-noneditable-matches.html b/LayoutTests/editing/find/find-and-replace-noneditable-matches.html
new file mode 100644 (file)
index 0000000..365f3f5
--- /dev/null
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="../../resources/dump-as-markup.js"></script>
+<style>
+    *[contenteditable="true"] {
+        border: 3px green solid;
+    }
+
+    *[contenteditable="false"] {
+        border: 1px red dashed;
+        background-color: rgba(200, 0, 0, 0.25);
+    }
+</style>
+</head>
+<body>
+    <div contenteditable="true" id="editor">
+        <p contenteditable="false">alpha beta.</p>
+        <p>gamma beta phi.</p>
+        <div contenteditable="false">
+            <p>alpha kappa eta.</p>
+            <p contenteditable="true">eta kappa nu.</p>
+        </div>
+        <p>beta phi delta.</p>
+    </div>
+</body>
+<script>
+Markup.description("Verifies that find and replace does not change matches in noneditable content. This test requires WebKitTestRunner.");
+testRunner.findStringMatchesInPage("eta", []);
+testRunner.replaceFindMatchesAtIndices([0, 1, 2, 3, 4], "_eta_", false);
+Markup.dump("editor", "After replacing 'eta' with '_eta_'");
+</script>
+</html>
diff --git a/LayoutTests/editing/find/find-and-replace-replacement-text-input-events-expected.txt b/LayoutTests/editing/find/find-and-replace-replacement-text-input-events-expected.txt
new file mode 100644 (file)
index 0000000..735fde9
--- /dev/null
@@ -0,0 +1,70 @@
+Verifies that find and replace fires input events of type "insertReplacementText". This test requires WebKitTestRunner.
+
+Pineapple
+
+Kiwi watermelon pear.
+
+
+(field):
+        type=beforeinput,
+        inputType=insertReplacementText,
+        range=null,
+        data=watermelon,
+        dataTransfer=null
+(field):
+        type=input,
+        inputType=insertReplacementText,
+        range=null,
+        data=watermelon,
+        dataTransfer=null
+(editor):
+        type=beforeinput,
+        inputType=insertReplacementText,
+        range=([object Text]#5,[object Text]#11),
+        data=null,
+        dataTransfer=[object DataTransfer]
+(editor):
+        type=input,
+        inputType=insertReplacementText,
+        range=null,
+        data=null,
+        dataTransfer=[object DataTransfer]
+(editor):
+        type=beforeinput,
+        inputType=insertReplacementText,
+        range=([object Text]#6,[object Text]#12),
+        data=null,
+        dataTransfer=[object DataTransfer]
+(editor):
+        type=input,
+        inputType=insertReplacementText,
+        range=null,
+        data=null,
+        dataTransfer=[object DataTransfer]
+---
+(editor):
+        type=beforeinput,
+        inputType=insertReplacementText,
+        range=([object Text]#0,[object Text]#24),
+        data=null,
+        dataTransfer=[object DataTransfer]
+(editor):
+        type=input,
+        inputType=insertReplacementText,
+        range=null,
+        data=null,
+        dataTransfer=[object DataTransfer]
+---
+(field):
+        type=beforeinput,
+        inputType=insertText,
+        range=null,
+        data=Guava,
+        dataTransfer=null
+(field):
+        type=input,
+        inputType=insertText,
+        range=null,
+        data=Guava,
+        dataTransfer=null
+
diff --git a/LayoutTests/editing/find/find-and-replace-replacement-text-input-events.html b/LayoutTests/editing/find/find-and-replace-replacement-text-input-events.html
new file mode 100644 (file)
index 0000000..c400523
--- /dev/null
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html>
+<body>
+    <p>Verifies that find and replace fires input events of type "insertReplacementText". This test requires WebKitTestRunner.</p>
+    <div id="editor" contenteditable>
+        <p>Apple banana orange.</p>
+        <p>Kiwi banana pear.</p>
+    </div>
+    <input type="text" id="field" value="Apple banana orange.">
+    <pre id="output"></pre>
+</body>
+<script>
+let write = s => output.innerHTML += s + "<br>";
+
+function toString(range) {
+    if (!range)
+        return "null";
+
+    return `(${range.startContainer}#${range.startOffset},${range.endContainer}#${range.endOffset})`;
+}
+
+function logInputEvent(event) {
+    write(`(${event.target.id}):
+        type=${event.type},
+        inputType=${event.inputType},
+        range=${toString(event.getTargetRanges()[0])},
+        data=${event.data},
+        dataTransfer=${event.dataTransfer}`);
+}
+
+editor.addEventListener("beforeinput", logInputEvent);
+editor.addEventListener("input", logInputEvent);
+field.addEventListener("beforeinput", logInputEvent);
+field.addEventListener("input", logInputEvent);
+
+if (window.testRunner) {
+    testRunner.dumpAsText();
+    testRunner.findStringMatchesInPage("banana", []);
+    testRunner.replaceFindMatchesAtIndices([0, 1, 2], "watermelon", false);
+
+    write("---");
+
+    getSelection().setBaseAndExtent(editor.children[0], 0, editor.children[0], 1);
+    testRunner.replaceFindMatchesAtIndices([], "Pineapple", false);
+
+    write("---");
+
+    field.focus();
+    field.setSelectionRange(0, 0);
+    testRunner.replaceFindMatchesAtIndices([], "Guava", false);
+}
+</script>
+</html>
index 3f81902..987c02b 100644 (file)
@@ -14,6 +14,7 @@ fast/visual-viewport/ios/ [ Pass ]
 scrollingcoordinator/ios [ Pass ]
 tiled-drawing/ios [ Pass ]
 fast/web-share [ Pass ]
+editing/find [ Pass ]
 
 editing/selection/character-granularity-rect.html [ Failure ]
 
index 9b2fbed..13ab52e 100644 (file)
@@ -10,6 +10,7 @@ tiled-drawing/ios [ Skip ]
 fast/visual-viewport/tiled-drawing [ Pass ]
 swipe [ Pass ]
 fast/web-share [ Pass ]
+editing/find [ Pass ]
 
 fast/events/autoscroll-when-zoomed.html [ Pass ]
 fast/events/autoscroll-main-document.html [ Pass ]
index 1a396dd..4a0c820 100644 (file)
@@ -1,3 +1,54 @@
+2018-11-21  Wenson Hsieh  <wenson_hsieh@apple.com>
+
+        [Cocoa] [WebKit2] Add support for replacing find-in-page text matches
+        https://bugs.webkit.org/show_bug.cgi?id=191786
+        <rdar://problem/45813871>
+
+        Reviewed by Ryosuke Niwa.
+
+        Add support for replacing Find-in-Page matches. See below for details. Covered by new layout tests as well as a
+        new API test.
+
+        Tests: editing/find/find-and-replace-adjacent-words.html
+               editing/find/find-and-replace-at-editing-boundary.html
+               editing/find/find-and-replace-basic.html
+               editing/find/find-and-replace-in-subframes.html
+               editing/find/find-and-replace-no-matches.html
+               editing/find/find-and-replace-noneditable-matches.html
+               editing/find/find-and-replace-replacement-text-input-events.html
+
+        API test: WebKit.FindAndReplace
+
+        * page/Page.cpp:
+        (WebCore::replaceRanges):
+        (WebCore::Page::replaceRangesWithText):
+
+        Add a helper that, given a list of Ranges, replaces each range with the given text. To do this, we first map
+        each Range to editing offsets within the topmost editable root for each Range. This results in a map of editable
+        root to list of editing offsets we need to replace. To apply the replacements, for each editable root in the
+        map, we iterate over each replacement range (i.e. an offset and length), set the current selection to contain
+        that replacement range, and use `Editor::replaceSelectionWithText`. To prevent prior text replacements from
+        clobbering the offsets of latter text replacement ranges, we also iterate backwards through text replacement
+        ranges when performing each replacement.
+
+        Likewise, we also apply text replacement to each editing container in backwards order: for nodes in the same
+        frame, we compare their position in the document, and for nodes in different frames, we instead compare their
+        frames in frame tree traversal order.
+
+        We map Ranges to editing offsets and back when performing text replacement because each text replacement may
+        split or merge text nodes, which causes adjacent Ranges to shrink or extend while replacing text. In an earlier
+        attempt to implement this, I simply iterated over each Range to replace and carried out text replacement for
+        each Range. This led to incorrect behavior in some cases, such as replacing adjacent matches. Thus, by computing
+        the set of text replacement offsets prior to replacing any text, we're able to target the correct ranges for
+        replacement.
+
+        (WebCore::Page::replaceSelectionWithText):
+
+        Add a helper method on Page to replace the current selection with some text. This simply calls out to
+        `Editor::replaceSelectionWithText`.
+
+        * page/Page.h:
+
 2018-11-21  Andy Estes  <aestes@apple.com>
 
         [Cocoa] Create a soft-linking file for PassKit
index bbb6491..2eb0431 100644 (file)
@@ -1,3 +1,15 @@
+2018-11-21  Wenson Hsieh  <wenson_hsieh@apple.com>
+
+        [Cocoa] [WebKit2] Add support for replacing find-in-page text matches
+        https://bugs.webkit.org/show_bug.cgi?id=191786
+        <rdar://problem/45813871>
+
+        Reviewed by Ryosuke Niwa.
+
+        Add `-replaceMatches:withString:inSelectionOnly:resultCollector:`.
+
+        * pal/spi/mac/NSTextFinderSPI.h:
+
 2018-11-21  Andy Estes  <aestes@apple.com>
 
         [Cocoa] Create a soft-linking file for PassKit
index 4ede28a..4fd8090 100644 (file)
@@ -51,6 +51,7 @@ typedef enum : NSUInteger {
 - (NSView *)documentContainerView;
 - (void)getSelectedText:(void (^)(NSString *selectedTextString))completionHandler;
 - (void)selectFindMatch:(id <NSTextFinderAsynchronousDocumentFindMatch>)findMatch completionHandler:(void (^)(void))completionHandler;
+- (void)replaceMatches:(NSArray *)matches withString:(NSString *)replacementString inSelectionOnly:(BOOL)selectionOnly resultCollector:(void (^)(NSUInteger replacementCount))resultCollector;
 
 @end
 
index 950bae0..c9a60e5 100644 (file)
@@ -43,6 +43,7 @@
 #include "DocumentMarkerController.h"
 #include "DocumentTimeline.h"
 #include "DragController.h"
+#include "Editing.h"
 #include "Editor.h"
 #include "EditorClient.h"
 #include "EmptyClients.h"
 #include "StyleResolver.h"
 #include "StyleScope.h"
 #include "SubframeLoader.h"
+#include "TextIterator.h"
 #include "TextResourceDecoder.h"
 #include "UserContentProvider.h"
 #include "UserInputBridge.h"
@@ -764,6 +766,122 @@ unsigned Page::countFindMatches(const String& target, FindOptions options, unsig
     return findMatchesForText(target, options, maxMatchCount, DoNotHighlightMatches, DoNotMarkMatches);
 }
 
+struct FindReplacementRange {
+    RefPtr<ContainerNode> root;
+    size_t location { notFound };
+    size_t length { 0 };
+};
+
+static void replaceRanges(Page& page, Vector<FindReplacementRange>&& ranges, const String& replacementText)
+{
+    HashMap<RefPtr<ContainerNode>, Vector<FindReplacementRange>> rangesByContainerNode;
+    for (auto& range : ranges) {
+        auto& rangeList = rangesByContainerNode.ensure(range.root, [] {
+            return Vector<FindReplacementRange> { };
+        }).iterator->value;
+
+        // Ensure that ranges are sorted by their end offsets, per editing container.
+        auto endOffsetForRange = range.location + range.length;
+        auto insertionIndex = rangeList.size();
+        for (auto iterator = rangeList.rbegin(); iterator != rangeList.rend(); ++iterator) {
+            auto endOffsetBeforeInsertionIndex = iterator->location + iterator->length;
+            if (endOffsetForRange >= endOffsetBeforeInsertionIndex)
+                break;
+            insertionIndex--;
+        }
+        rangeList.insert(insertionIndex, range);
+    }
+
+    HashMap<RefPtr<Frame>, unsigned> frameToTraversalIndexMap;
+    unsigned currentFrameTraversalIndex = 0;
+    for (Frame* frame = &page.mainFrame(); frame; frame = frame->tree().traverseNext())
+        frameToTraversalIndexMap.set(frame, currentFrameTraversalIndex++);
+
+    // Likewise, iterate backwards (in document and frame order) through editing containers that contain text matches,
+    // so that we're consistent with our backwards iteration behavior per editing container when replacing text.
+    auto containerNodesInOrderOfReplacement = copyToVector(rangesByContainerNode.keys());
+    std::sort(containerNodesInOrderOfReplacement.begin(), containerNodesInOrderOfReplacement.end(), [frameToTraversalIndexMap] (auto& firstNode, auto& secondNode) {
+        if (firstNode == secondNode)
+            return false;
+
+        auto firstFrame = makeRefPtr(firstNode->document().frame());
+        if (!firstFrame)
+            return true;
+
+        auto secondFrame = makeRefPtr(secondNode->document().frame());
+        if (!secondFrame)
+            return false;
+
+        if (firstFrame == secondFrame) {
+            // comparePositions is used here instead of Node::compareDocumentPosition because some editing roots may exist inside shadow roots.
+            return comparePositions({ firstNode.get(), Position::PositionIsBeforeChildren }, { secondNode.get(), Position::PositionIsBeforeChildren }) > 0;
+        }
+        return frameToTraversalIndexMap.get(firstFrame) > frameToTraversalIndexMap.get(secondFrame);
+    });
+
+    for (auto container : containerNodesInOrderOfReplacement) {
+        auto frame = makeRefPtr(container->document().frame());
+        if (!frame)
+            continue;
+
+        // Iterate backwards through ranges when replacing text, such that earlier text replacements don't clobber replacement ranges later on.
+        auto& ranges = rangesByContainerNode.find(container)->value;
+        for (auto iterator = ranges.rbegin(); iterator != ranges.rend(); ++iterator) {
+            auto range = TextIterator::rangeFromLocationAndLength(container.get(), iterator->location, iterator->length);
+            if (!range || range->collapsed())
+                continue;
+
+            frame->selection().setSelectedRange(range.get(), DOWNSTREAM, true);
+            frame->editor().replaceSelectionWithText(replacementText, true, false, EditAction::InsertReplacement);
+        }
+    }
+}
+
+uint32_t Page::replaceRangesWithText(Vector<Ref<Range>>&& rangesToReplace, const String& replacementText, bool selectionOnly)
+{
+    // FIXME: In the future, we should respect the `selectionOnly` flag by checking whether each range being replaced is
+    // contained within its frame's selection.
+    UNUSED_PARAM(selectionOnly);
+
+    Vector<FindReplacementRange> replacementRanges;
+    replacementRanges.reserveInitialCapacity(rangesToReplace.size());
+
+    for (auto& range : rangesToReplace) {
+        auto highestRoot = makeRefPtr(highestEditableRoot(range->startPosition()));
+        if (!highestRoot || highestRoot != highestEditableRoot(range->endPosition()))
+            continue;
+
+        auto frame = makeRefPtr(highestRoot->document().frame());
+        if (!frame)
+            continue;
+
+        size_t replacementLocation = notFound;
+        size_t replacementLength = 0;
+        if (!TextIterator::getLocationAndLengthFromRange(highestRoot.get(), range.ptr(), replacementLocation, replacementLength))
+            continue;
+
+        if (replacementLocation == notFound || !replacementLength)
+            continue;
+
+        replacementRanges.append({ WTFMove(highestRoot), replacementLocation, replacementLength });
+    }
+
+    replaceRanges(*this, WTFMove(replacementRanges), replacementText);
+    return rangesToReplace.size();
+}
+
+uint32_t Page::replaceSelectionWithText(const String& replacementText)
+{
+    auto frame = makeRef(focusController().focusedOrMainFrame());
+    auto selection = frame->selection().selection();
+    if (!selection.isContentEditable())
+        return 0;
+
+    auto editAction = selection.isRange() ? EditAction::InsertReplacement : EditAction::Insert;
+    frame->editor().replaceSelectionWithText(replacementText, true, false, editAction);
+    return 1;
+}
+
 void Page::unmarkAllTextMatches()
 {
     Frame* frame = &mainFrame();
index f89400a..f840d11 100644 (file)
@@ -278,6 +278,8 @@ public:
     bool tabKeyCyclesThroughElements() const { return m_tabKeyCyclesThroughElements; }
 
     WEBCORE_EXPORT bool findString(const String&, FindOptions, DidWrap* = nullptr);
+    WEBCORE_EXPORT uint32_t replaceRangesWithText(Vector<Ref<Range>>&& rangesToReplace, const String& replacementText, bool selectionOnly);
+    WEBCORE_EXPORT uint32_t replaceSelectionWithText(const String& replacementText);
 
     WEBCORE_EXPORT RefPtr<Range> rangeOfString(const String&, Range*, FindOptions);
 
index e15c296..263b25b 100644 (file)
@@ -1,3 +1,47 @@
+2018-11-21  Wenson Hsieh  <wenson_hsieh@apple.com>
+
+        [Cocoa] [WebKit2] Add support for replacing find-in-page text matches
+        https://bugs.webkit.org/show_bug.cgi?id=191786
+        <rdar://problem/45813871>
+
+        Reviewed by Ryosuke Niwa.
+
+        * UIProcess/API/Cocoa/WKWebView.mm:
+        (-[WKWebView replaceMatches:withString:inSelectionOnly:resultCollector:]):
+        * UIProcess/WebPageProxy.cpp:
+        (WebKit::WebPageProxy::replaceMatches):
+        * UIProcess/WebPageProxy.h:
+        * UIProcess/mac/WKTextFinderClient.mm:
+        (-[WKTextFinderClient replaceMatches:withString:inSelectionOnly:resultCollector:]):
+
+        Implement this method to opt in to "Replaceā€¦" UI on macOS in the find bar. In this API, we're given a list of
+        matches to replace. We propagate the indices of each match to the web process, where FindController maps them to
+        corresponding replacement ranges. Currently, the given list of matches is only ever a list containing the first
+        match, or a list containing all matches.
+
+        * WebProcess/InjectedBundle/API/c/WKBundlePage.cpp:
+        (WKBundlePageFindStringMatches):
+        (WKBundlePageReplaceStringMatches):
+        * WebProcess/InjectedBundle/API/c/WKBundlePage.h:
+        * WebProcess/WebCoreSupport/WebEditorClient.cpp:
+        * WebProcess/WebPage/FindController.cpp:
+        (WebKit::FindController::replaceMatches):
+
+        Map match indices to Ranges, and then call into WebCore::Page to do the heavy lifting (see WebCore ChangeLog for
+        more details). Additionally add a hard find-and-replace limit here to prevent the web process from spinning
+        indefinitely if there are an enormous number of find matches.
+
+        * WebProcess/WebPage/FindController.h:
+        * WebProcess/WebPage/WebPage.cpp:
+        (WebKit::WebPage::findStringMatchesFromInjectedBundle):
+        (WebKit::WebPage::replaceStringMatchesFromInjectedBundle):
+
+        Add helpers to exercise find and replace in WebKit2.
+
+        (WebKit::WebPage::replaceMatches):
+        * WebProcess/WebPage/WebPage.h:
+        * WebProcess/WebPage/WebPage.messages.in:
+
 2018-11-21  Andy Estes  <aestes@apple.com>
 
         [Cocoa] Create a soft-linking file for PassKit
index ee855bf..8369e33 100644 (file)
@@ -4089,6 +4089,11 @@ IGNORE_WARNINGS_END
     [[self _ensureTextFinderClient] findMatchesForString:targetString relativeToMatch:relativeMatch findOptions:findOptions maxResults:maxResults resultCollector:resultCollector];
 }
 
+- (void)replaceMatches:(NSArray *)matches withString:(NSString *)replacementString inSelectionOnly:(BOOL)selectionOnly resultCollector:(void (^)(NSUInteger replacementCount))resultCollector
+{
+    [[self _ensureTextFinderClient] replaceMatches:matches withString:replacementString inSelectionOnly:selectionOnly resultCollector:resultCollector];
+}
+
 - (NSView *)documentContainerView
 {
     return self;
index 80ec01a..11109dd 100644 (file)
@@ -3308,6 +3308,17 @@ void WebPageProxy::countStringMatches(const String& string, FindOptions options,
     m_process->send(Messages::WebPage::CountStringMatches(string, options, maxMatchCount), m_pageID);
 }
 
+void WebPageProxy::replaceMatches(Vector<uint32_t>&& matchIndices, const String& replacementText, bool selectionOnly, Function<void(uint64_t, CallbackBase::Error)>&& callback)
+{
+    if (!isValid()) {
+        callback(0, CallbackBase::Error::Unknown);
+        return;
+    }
+
+    auto callbackID = m_callbacks.put(WTFMove(callback), m_process->throttler().backgroundActivityToken());
+    m_process->send(Messages::WebPage::ReplaceMatches(WTFMove(matchIndices), replacementText, selectionOnly, callbackID), m_pageID);
+}
+
 void WebPageProxy::runJavaScriptInMainFrame(const String& script, bool forceUserGesture, WTF::Function<void (API::SerializedScriptValue*, bool hadException, const ExceptionDetails&, CallbackBase::Error)>&& callbackFunction)
 {
     if (!isValid()) {
index d11a051..071772c 100644 (file)
@@ -913,6 +913,7 @@ public:
     void didGetImageForFindMatch(const ShareableBitmap::Handle& contentImageHandle, uint32_t matchIndex);
     void hideFindUI();
     void countStringMatches(const String&, FindOptions, unsigned maxMatchCount);
+    void replaceMatches(Vector<uint32_t>&& matchIndices, const String& replacementText, bool selectionOnly, Function<void(uint64_t, CallbackBase::Error)>&&);
     void didCountStringMatches(const String&, uint32_t matchCount);
     void setTextIndicator(const WebCore::TextIndicatorData&, uint64_t /* WebCore::TextIndicatorWindowLifetime */ lifetime = 0 /* Permanent */);
     void setTextIndicatorAnimationProgress(float);
index 23fc4a6..48e11ac 100644 (file)
@@ -34,9 +34,9 @@
 #import "WebPageProxy.h"
 #import <algorithm>
 #import <pal/spi/mac/NSTextFinderSPI.h>
+#import <wtf/BlockPtr.h>
 #import <wtf/Deque.h>
 
-// FIXME: Implement support for replace.
 // FIXME: Implement scrollFindMatchToVisible.
 // FIXME: The NSTextFinder overlay doesn't move with scrolling; we should have a mode where we manage the overlay.
 
@@ -171,6 +171,19 @@ private:
 
 #pragma mark - NSTextFinderClient SPI
 
+- (void)replaceMatches:(NSArray *)matches withString:(NSString *)replacementText inSelectionOnly:(BOOL)selectionOnly resultCollector:(void (^)(NSUInteger replacementCount))resultCollector
+{
+    Vector<uint32_t> matchIndices;
+    matchIndices.reserveCapacity(matches.count);
+    for (id match in matches) {
+        if ([match isKindOfClass:WKTextFinderMatch.class])
+            matchIndices.uncheckedAppend([(WKTextFinderMatch *)match index]);
+    }
+    _page->replaceMatches(WTFMove(matchIndices), replacementText, selectionOnly, [collector = makeBlockPtr(resultCollector)] (uint64_t numberOfReplacements, auto error) {
+        collector(error == WebKit::CallbackBase::Error::None ? numberOfReplacements : 0);
+    });
+}
+
 - (void)findMatchesForString:(NSString *)targetString relativeToMatch:(id <NSTextFinderAsynchronousDocumentFindMatch>)relativeMatch findOptions:(NSTextFinderAsynchronousDocumentFindOptions)findOptions maxResults:(NSUInteger)maxResults resultCollector:(void (^)(NSArray *matches, BOOL didWrap))resultCollector
 {
     // Limit the number of results, for performance reasons; NSTextFinder always
index bcce48a..8bf86ae 100644 (file)
@@ -449,6 +449,25 @@ bool WKBundlePageFindString(WKBundlePageRef pageRef, WKStringRef target, WKFindO
     return toImpl(pageRef)->findStringFromInjectedBundle(toWTFString(target), toFindOptions(findOptions));
 }
 
+void WKBundlePageFindStringMatches(WKBundlePageRef pageRef, WKStringRef target, WKFindOptions findOptions)
+{
+    toImpl(pageRef)->findStringMatchesFromInjectedBundle(toWTFString(target), toFindOptions(findOptions));
+}
+
+void WKBundlePageReplaceStringMatches(WKBundlePageRef pageRef, WKArrayRef matchIndicesRef, WKStringRef replacementText, bool selectionOnly)
+{
+    auto* matchIndices = toImpl(matchIndicesRef);
+
+    Vector<uint32_t> indices;
+    indices.reserveInitialCapacity(matchIndices->size());
+
+    for (size_t arrayIndex = 0; arrayIndex < matchIndices->size(); ++arrayIndex) {
+        if (auto* indexAsObject = matchIndices->at<API::UInt64>(arrayIndex))
+            indices.uncheckedAppend(indexAsObject->value());
+    }
+    toImpl(pageRef)->replaceStringMatchesFromInjectedBundle(WTFMove(indices), toWTFString(replacementText), selectionOnly);
+}
+
 WKImageRef WKBundlePageCreateSnapshotWithOptions(WKBundlePageRef pageRef, WKRect rect, WKSnapshotOptions options)
 {
     RefPtr<WebImage> webImage = toImpl(pageRef)->scaledSnapshotWithOptions(toIntRect(rect), 1, toSnapshotOptions(options));
index 12bafc9..f8563e1 100644 (file)
@@ -92,6 +92,8 @@ WK_EXPORT bool WKBundlePageHasLocalDataForURL(WKBundlePageRef page, WKURLRef url
 WK_EXPORT bool WKBundlePageCanHandleRequest(WKURLRequestRef request);
 
 WK_EXPORT bool WKBundlePageFindString(WKBundlePageRef page, WKStringRef target, WKFindOptions findOptions);
+WK_EXPORT void WKBundlePageFindStringMatches(WKBundlePageRef page, WKStringRef target, WKFindOptions findOptions);
+WK_EXPORT void WKBundlePageReplaceStringMatches(WKBundlePageRef page, WKArrayRef matchIndices, WKStringRef replacementText, bool selectionOnly);
 
 WK_EXPORT WKImageRef WKBundlePageCreateSnapshotWithOptions(WKBundlePageRef page, WKRect rect, WKSnapshotOptions options);
 
index 5a30b35..d797c2e 100644 (file)
@@ -99,6 +99,27 @@ void FindController::countStringMatches(const String& string, FindOptions option
     m_webPage->send(Messages::WebPageProxy::DidCountStringMatches(string, matchCount));
 }
 
+uint32_t FindController::replaceMatches(Vector<uint32_t>&& matchIndices, const String& replacementText, bool selectionOnly)
+{
+    if (matchIndices.isEmpty())
+        return m_webPage->corePage()->replaceSelectionWithText(replacementText);
+
+    // FIXME: This is an arbitrary cap on the maximum number of matches to try and replace, to prevent the web process from
+    // hanging while replacing an enormous amount of matches. In the future, we should handle replacement in batches, and
+    // periodically update an NSProgress in the UI process when a batch of find-in-page matches are replaced.
+    const uint32_t maximumNumberOfMatchesToReplace = 1000;
+
+    Vector<Ref<Range>> rangesToReplace;
+    rangesToReplace.reserveCapacity(std::min<uint32_t>(maximumNumberOfMatchesToReplace, matchIndices.size()));
+    for (auto index : matchIndices) {
+        if (index < m_findMatches.size())
+            rangesToReplace.uncheckedAppend(*m_findMatches[index]);
+        if (rangesToReplace.size() >= maximumNumberOfMatchesToReplace)
+            break;
+    }
+    return m_webPage->corePage()->replaceRangesWithText(WTFMove(rangesToReplace), replacementText, selectionOnly);
+}
+
 static Frame* frameWithSelection(Page* page)
 {
     for (Frame* frame = &page->mainFrame(); frame; frame = frame->tree().traverseNext()) {
index 3e161b2..8aef205 100644 (file)
@@ -61,6 +61,7 @@ public:
     void selectFindMatch(uint32_t matchIndex);
     void hideFindUI();
     void countStringMatches(const String&, FindOptions, unsigned maxMatchCount);
+    uint32_t replaceMatches(Vector<uint32_t>&& matchIndices, const String& replacementText, bool selectionOnly);
     
     void hideFindIndicator();
     void showFindIndicatorInSelection();
index 4eb1b48..74b06dc 100644 (file)
@@ -3791,6 +3791,16 @@ bool WebPage::findStringFromInjectedBundle(const String& target, FindOptions opt
     return m_page->findString(target, core(options));
 }
 
+void WebPage::findStringMatchesFromInjectedBundle(const String& target, FindOptions options)
+{
+    findController().findStringMatches(target, options, 0);
+}
+
+void WebPage::replaceStringMatchesFromInjectedBundle(Vector<uint32_t>&& matchIndices, const String& replacementText, bool selectionOnly)
+{
+    findController().replaceMatches(WTFMove(matchIndices), replacementText, selectionOnly);
+}
+
 void WebPage::findString(const String& string, uint32_t options, uint32_t maxMatchCount)
 {
     findController().findString(string, static_cast<FindOptions>(options), maxMatchCount);
@@ -3821,6 +3831,12 @@ void WebPage::countStringMatches(const String& string, uint32_t options, uint32_
     findController().countStringMatches(string, static_cast<FindOptions>(options), maxMatchCount);
 }
 
+void WebPage::replaceMatches(Vector<uint32_t>&& matchIndices, const String& replacementText, bool selectionOnly, CallbackID callbackID)
+{
+    auto numberOfReplacements = findController().replaceMatches(WTFMove(matchIndices), replacementText, selectionOnly);
+    send(Messages::WebPageProxy::UnsignedCallback(numberOfReplacements, callbackID));
+}
+
 void WebPage::didChangeSelectedIndexForActivePopupMenu(int32_t newIndex)
 {
     changeSelectedIndex(newIndex);
index a2da4aa..911a2f8 100644 (file)
@@ -408,6 +408,8 @@ public:
 #endif
 
     bool findStringFromInjectedBundle(const String&, FindOptions);
+    void findStringMatchesFromInjectedBundle(const String&, FindOptions);
+    void replaceStringMatchesFromInjectedBundle(Vector<uint32_t>&& matchIndices, const String& replacementText, bool selectionOnly);
 
     WebFrame* mainWebFrame() const { return m_mainFrame.get(); }
 
@@ -1304,6 +1306,7 @@ private:
     void selectFindMatch(uint32_t matchIndex);
     void hideFindUI();
     void countStringMatches(const String&, uint32_t findOptions, uint32_t maxMatchCount);
+    void replaceMatches(Vector<uint32_t>&& matchIndices, const String& replacementText, bool selectionOnly, CallbackID);
 
 #if USE(COORDINATED_GRAPHICS)
     void sendViewportAttributesChanged(const WebCore::ViewportArguments&);
index fe479f5..8b296ce 100644 (file)
@@ -266,6 +266,7 @@ messages -> WebPage LegacyReceiver {
     SelectFindMatch(uint32_t matchIndex)
     HideFindUI()
     CountStringMatches(String string, uint32_t findOptions, unsigned maxMatchCount)
+    ReplaceMatches(Vector<uint32_t> matchIndices, String replacementText, bool selectionOnly, WebKit::CallbackID callbackID)
     
     AddMIMETypeWithCustomContentProvider(String mimeType)
 
index 314bf3f..8332c09 100644 (file)
@@ -1,3 +1,34 @@
+2018-11-21  Wenson Hsieh  <wenson_hsieh@apple.com>
+
+        [Cocoa] [WebKit2] Add support for replacing find-in-page text matches
+        https://bugs.webkit.org/show_bug.cgi?id=191786
+        <rdar://problem/45813871>
+
+        Reviewed by Ryosuke Niwa.
+
+        * MiniBrowser/mac/WK2BrowserWindowController.m:
+        (-[WK2BrowserWindowController setFindBarView:]):
+
+        Fix a bug in MiniBrowser that prevents AppKit from displaying the "All" button in the find bar after checking
+        the "Replace" option.
+
+        * TestWebKitAPI/Tests/WebKitCocoa/FindInPage.mm:
+
+        Add an API test to exercise find-and-replace API using WKWebView.
+
+        (replaceMatches):
+        (TEST):
+        * WebKitTestRunner/InjectedBundle/Bindings/TestRunner.idl:
+        * WebKitTestRunner/InjectedBundle/TestRunner.cpp:
+        (WTR::findOptionsFromArray):
+        (WTR::TestRunner::findString):
+        (WTR::TestRunner::findStringMatchesInPage):
+        (WTR::TestRunner::replaceFindMatchesAtIndices):
+
+        Add TestRunner hooks to simulate find-in-page and replace.
+
+        * WebKitTestRunner/InjectedBundle/TestRunner.h:
+
 2018-11-21  Zalan Bujtas  <zalan@apple.com>
 
         [LFC][IFC] Horizontal margins should be considered as non-breakable space
index 836c476..3e739d7 100644 (file)
@@ -763,11 +763,8 @@ static NSSet *dataTypes()
 
 - (void)setFindBarView:(NSView *)findBarView
 {
-    if (_textFindBarView)
-        [_textFindBarView removeFromSuperview];
     _textFindBarView = findBarView;
     _findBarVisible = YES;
-    [containerView addSubview:_textFindBarView];
     [_textFindBarView setFrame:NSMakeRect(0, 0, containerView.bounds.size.width, _textFindBarView.frame.size.height)];
 }
 
index 105ccd2..b553c00 100644 (file)
@@ -27,6 +27,7 @@
 
 #import "PlatformUtilities.h"
 #import "TestNavigationDelegate.h"
+#import "TestWKWebView.h"
 #import <WebKit/WKWebViewPrivate.h>
 #import <wtf/RetainPtr.h>
 
@@ -52,6 +53,7 @@ typedef id <NSTextFinderAsynchronousDocumentFindMatch> FindMatch;
 @interface WKWebView (NSTextFinderSupport)
 
 - (void)findMatchesForString:(NSString *)targetString relativeToMatch:(FindMatch)relativeMatch findOptions:(NSTextFinderAsynchronousDocumentFindOptions)findOptions maxResults:(NSUInteger)maxResults resultCollector:(void (^)(NSArray *matches, BOOL didWrap))resultCollector;
+- (void)replaceMatches:(NSArray<FindMatch> *)matches withString:(NSString *)replacementString inSelectionOnly:(BOOL)selectionOnly resultCollector:(void (^)(NSUInteger replacementCount))resultCollector;
 
 @end
 
@@ -76,6 +78,20 @@ static FindResult findMatches(WKWebView *webView, NSString *findString, NSTextFi
     return result;
 }
 
+static NSUInteger replaceMatches(WKWebView *webView, NSArray<FindMatch> *matchesToReplace, NSString *replacementText)
+{
+    __block NSUInteger result;
+    __block bool done = false;
+
+    [webView replaceMatches:matchesToReplace withString:replacementText inSelectionOnly:NO resultCollector:^(NSUInteger replacementCount) {
+        result = replacementCount;
+        done = true;
+    }];
+
+    TestWebKitAPI::Util::run(&done);
+    return result;
+}
+
 TEST(WebKit, FindInPage)
 {
     RetainPtr<WKWebView> webView = adoptNS([[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 200, 200)]);
@@ -205,4 +221,31 @@ TEST(WebKit, FindInPageWrappingSubframe)
     EXPECT_TRUE(result.didWrap);
 }
 
-#endif
+TEST(WebKit, FindAndReplace)
+{
+    auto webView = adoptNS([[TestWKWebView alloc] initWithFrame:NSMakeRect(0, 0, 400, 400)]);
+    [webView synchronouslyLoadHTMLString:@"<body contenteditable><input id='first' value='hello'>hello world<input id='second' value='world'></body>"];
+
+    auto result = findMatches(webView.get(), @"hello");
+    EXPECT_EQ(2U, [result.matches count]);
+    EXPECT_EQ(2U, replaceMatches(webView.get(), result.matches.get(), @"hi"));
+    EXPECT_WK_STREQ("hi", [webView stringByEvaluatingJavaScript:@"first.value"]);
+    EXPECT_WK_STREQ("world", [webView stringByEvaluatingJavaScript:@"second.value"]);
+    EXPECT_WK_STREQ("hi world", [webView stringByEvaluatingJavaScript:@"document.body.textContent"]);
+
+    result = findMatches(webView.get(), @"world");
+    EXPECT_EQ(2U, [result.matches count]);
+    EXPECT_EQ(1U, replaceMatches(webView.get(), @[ [result.matches firstObject] ], @"hi"));
+    EXPECT_WK_STREQ("hi", [webView stringByEvaluatingJavaScript:@"first.value"]);
+    EXPECT_WK_STREQ("world", [webView stringByEvaluatingJavaScript:@"second.value"]);
+    EXPECT_WK_STREQ("hi hi", [webView stringByEvaluatingJavaScript:@"document.body.textContent"]);
+
+    result = findMatches(webView.get(), @"world");
+    EXPECT_EQ(1U, [result.matches count]);
+    EXPECT_EQ(1U, replaceMatches(webView.get(), @[ [result.matches firstObject] ], @"hi"));
+    EXPECT_WK_STREQ("hi", [webView stringByEvaluatingJavaScript:@"first.value"]);
+    EXPECT_WK_STREQ("hi", [webView stringByEvaluatingJavaScript:@"second.value"]);
+    EXPECT_WK_STREQ("hi hi", [webView stringByEvaluatingJavaScript:@"document.body.textContent"]);
+}
+
+#endif // WK_API_ENABLED && !PLATFORM(IOS_FAMILY)
index 8db5690..5b26467 100644 (file)
@@ -146,6 +146,8 @@ interface TestRunner {
 
     // Text search testing.
     boolean findString(DOMString target, object optionsArray);
+    void findStringMatchesInPage(DOMString target, object optionsArray);
+    void replaceFindMatchesAtIndices(object matchIndicesArray, DOMString replacementText, boolean selectionOnly);
 
     // Evaluating script in a special context.
     [PassContext] void evaluateScriptInIsolatedWorld(unsigned long worldID, DOMString script);
index 5da546a..19021d8 100644 (file)
 #include <WebKit/WKBundlePrivate.h>
 #include <WebKit/WKBundleScriptWorld.h>
 #include <WebKit/WKData.h>
+#include <WebKit/WKNumber.h>
 #include <WebKit/WKPagePrivate.h>
 #include <WebKit/WKRetainPtr.h>
 #include <WebKit/WKSerializedScriptValue.h>
 #include <WebKit/WebKit2_C.h>
 #include <wtf/HashMap.h>
+#include <wtf/Optional.h>
 #include <wtf/StdLibExtras.h>
 #include <wtf/text/CString.h>
 #include <wtf/text/StringBuilder.h>
@@ -299,10 +301,8 @@ void TestRunner::execCommand(JSStringRef name, JSStringRef showUI, JSStringRef v
     WKBundlePageExecuteEditingCommand(InjectedBundle::singleton().page()->page(), toWK(name).get(), toWK(value).get());
 }
 
-bool TestRunner::findString(JSStringRef target, JSValueRef optionsArrayAsValue)
+static std::optional<WKFindOptions> findOptionsFromArray(JSValueRef optionsArrayAsValue)
 {
-    WKFindOptions options = 0;
-
     auto& injectedBundle = InjectedBundle::singleton();
     WKBundleFrameRef mainFrame = WKBundlePageGetMainFrame(injectedBundle.page()->page());
     JSContextRef context = WKBundleFrameGetJavaScriptContext(mainFrame);
@@ -310,8 +310,9 @@ bool TestRunner::findString(JSStringRef target, JSValueRef optionsArrayAsValue)
     JSObjectRef optionsArray = JSValueToObject(context, optionsArrayAsValue, 0);
     JSValueRef lengthValue = JSObjectGetProperty(context, optionsArray, lengthPropertyName.get(), 0);
     if (!JSValueIsNumber(context, lengthValue))
-        return false;
+        return std::nullopt;
 
+    WKFindOptions options = 0;
     size_t length = static_cast<size_t>(JSValueToNumber(context, lengthValue, 0));
     for (size_t i = 0; i < length; ++i) {
         JSValueRef value = JSObjectGetPropertyAtIndex(context, optionsArray, i, 0);
@@ -334,8 +335,45 @@ bool TestRunner::findString(JSStringRef target, JSValueRef optionsArrayAsValue)
             // FIXME: No kWKFindOptionsStartInSelection.
         }
     }
+    return options;
+}
+
+bool TestRunner::findString(JSStringRef target, JSValueRef optionsArrayAsValue)
+{
+    if (auto options = findOptionsFromArray(optionsArrayAsValue))
+        return WKBundlePageFindString(InjectedBundle::singleton().page()->page(), toWK(target).get(), *options);
+
+    return false;
+}
+
+void TestRunner::findStringMatchesInPage(JSStringRef target, JSValueRef optionsArrayAsValue)
+{
+    if (auto options = findOptionsFromArray(optionsArrayAsValue))
+        return WKBundlePageFindStringMatches(InjectedBundle::singleton().page()->page(), toWK(target).get(), *options);
+}
 
-    return WKBundlePageFindString(injectedBundle.page()->page(), toWK(target).get(), options);
+void TestRunner::replaceFindMatchesAtIndices(JSValueRef matchIndicesAsValue, JSStringRef replacementText, bool selectionOnly)
+{
+    auto& bundle = InjectedBundle::singleton();
+    auto mainFrame = WKBundlePageGetMainFrame(bundle.page()->page());
+    auto context = WKBundleFrameGetJavaScriptContext(mainFrame);
+    auto lengthPropertyName = adopt(JSStringCreateWithUTF8CString("length"));
+    auto matchIndicesObject = JSValueToObject(context, matchIndicesAsValue, 0);
+    auto lengthValue = JSObjectGetProperty(context, matchIndicesObject, lengthPropertyName.get(), 0);
+    if (!JSValueIsNumber(context, lengthValue))
+        return;
+
+    auto indices = adoptWK(WKMutableArrayCreate());
+    auto length = static_cast<size_t>(JSValueToNumber(context, lengthValue, 0));
+    for (size_t i = 0; i < length; ++i) {
+        auto value = JSObjectGetPropertyAtIndex(context, matchIndicesObject, i, 0);
+        if (!JSValueIsNumber(context, value))
+            continue;
+
+        auto index = adoptWK(WKUInt64Create(std::round(JSValueToNumber(context, value, nullptr))));
+        WKArrayAppendItem(indices.get(), index.get());
+    }
+    WKBundlePageReplaceStringMatches(bundle.page()->page(), indices.get(), toWK(replacementText).get(), selectionOnly);
 }
 
 void TestRunner::clearAllDatabases()
index e8ba97f..7cd476e 100644 (file)
@@ -154,6 +154,8 @@ public:
 
     // Text search testing.
     bool findString(JSStringRef, JSValueRef optionsArray);
+    void findStringMatchesInPage(JSStringRef, JSValueRef optionsArray);
+    void replaceFindMatchesAtIndices(JSValueRef matchIndices, JSStringRef replacementText, bool selectionOnly);
 
     // Local storage
     void clearAllDatabases();