Skip to content

Commit bfa8593

Browse files
committed
Merge branch 'main' of github.com:element-hq/matrix-rich-text-editor into renovate/web
2 parents 7ad90b0 + f822b68 commit bfa8593

File tree

20 files changed

+1514
-351
lines changed

20 files changed

+1514
-351
lines changed

bindings/wysiwyg-wasm/src/lib.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,17 @@ impl ComposerModel {
187187
)
188188
}
189189

190+
pub fn replace_html(
191+
&mut self,
192+
new_html: &str,
193+
external_source: HtmlSource,
194+
) -> ComposerUpdate {
195+
ComposerUpdate::from(self.inner.replace_html(
196+
Utf16String::from_str(new_html),
197+
external_source.into(),
198+
))
199+
}
200+
190201
pub fn replace_text_suggestion(
191202
&mut self,
192203
new_text: &str,
@@ -914,6 +925,24 @@ impl From<wysiwyg::LinkAction<Utf16String>> for LinkAction {
914925
}
915926
}
916927

928+
#[wasm_bindgen]
929+
#[derive(Clone)]
930+
pub enum HtmlSource {
931+
Matrix,
932+
GoogleDoc,
933+
UnknownExternal,
934+
}
935+
936+
impl From<HtmlSource> for wysiwyg::HtmlSource {
937+
fn from(source: HtmlSource) -> Self {
938+
match source {
939+
HtmlSource::Matrix => Self::Matrix,
940+
HtmlSource::GoogleDoc => Self::GoogleDoc,
941+
HtmlSource::UnknownExternal => Self::UnknownExternal,
942+
}
943+
}
944+
}
945+
917946
#[cfg(test)]
918947
mod test {
919948
use super::ComposerModel;

crates/wysiwyg/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ strum = "0.27"
2626
strum_macros = "0.27"
2727
unicode-segmentation = "1.7.1"
2828
wasm-bindgen = { version = "0.2.83", default-features = false, optional = true }
29-
web-sys = { version = "0.3.60", default-features = false, features = ["Document", "DomParser", "HtmlElement", "Node", "NodeList", "SupportedType"], optional = true }
29+
web-sys = { version = "0.3.60", default-features = false, features = ["Document", "DomParser", "HtmlElement", "Node", "NodeList", "SupportedType", "CssStyleDeclaration"], optional = true }
3030
widestring = "1.0.2"
3131
indoc = "2.0"
3232
url="2.3.1"

crates/wysiwyg/src/composer_model.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub mod menu_action;
1717
pub mod menu_state;
1818
pub mod new_lines;
1919
pub mod quotes;
20+
pub mod replace_html;
2021
pub mod replace_text;
2122
pub mod selection;
2223
pub mod undo_redo;

crates/wysiwyg/src/composer_model/example_format.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,7 @@ mod test {
903903
#[test]
904904
fn selection_across_lists_roundtrips() {
905905
assert_that!(
906-
"<ol><li>1{1</li><li>22</li></ol><ol><li>33</li><li>4}|4</li></ol>"
906+
"<ol><li>1{1</li><li>22</li></ol><p>a</p><ol><li>33</li><li>4}|4</li></ol>"
907907
)
908908
.roundtrips();
909909
}
@@ -915,6 +915,7 @@ mod test {
915915
<li>1{1</li>\
916916
<li>22</li>\
917917
</ol>\
918+
<p>a</p>\
918919
<ol>\
919920
<li>33</li>\
920921
<li>4}|4</li>\
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
// Copyright 2024 New Vector Ltd.
2+
// Copyright 2022 The Matrix.org Foundation C.I.C.
3+
//
4+
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
// Please see LICENSE in the repository root for full details.
6+
7+
use regex::Regex;
8+
9+
use crate::dom::html_source::HtmlSource;
10+
use crate::dom::nodes::ContainerNode;
11+
use crate::dom::parser::parse_from_source;
12+
use crate::{ComposerModel, ComposerUpdate, DomNode, Location, UnicodeString}; // Import the trait for to_tree
13+
14+
impl<S> ComposerModel<S>
15+
where
16+
S: UnicodeString,
17+
{
18+
/// Replaces text in the current selection with new_html.
19+
/// Treats its input as html that is parsed into a DomNode and inserted into
20+
/// the document at the cursor.
21+
pub fn replace_html(
22+
&mut self,
23+
new_html: S,
24+
external_source: HtmlSource,
25+
) -> ComposerUpdate<S> {
26+
self.push_state_to_history();
27+
if self.has_selection() {
28+
self.do_replace_text(S::default());
29+
}
30+
// Remove meta tags from the HTML which caused errors in html5ever
31+
let meta_regex = Regex::new(r"<meta[^>]*>").unwrap();
32+
let mut cleaned_html = meta_regex
33+
.replace_all(&new_html.to_string(), "")
34+
.to_string();
35+
36+
if external_source == HtmlSource::GoogleDoc {
37+
// Strip outer b tag that google docs adds
38+
let b_regex = Regex::new(r"<b[^>]*>(.*)<\/b>").unwrap();
39+
cleaned_html = b_regex.replace(&cleaned_html, "$1").to_string();
40+
}
41+
42+
let result =
43+
parse_from_source(&cleaned_html.to_string(), external_source);
44+
45+
let doc_node = result.unwrap().into_document_node();
46+
let (start, end) = self.safe_selection();
47+
let range = self.state.dom.find_range(start, end);
48+
49+
// We should only have 1 dom node, so add the children under a paragraph to take advantage of the exisitng
50+
// insert_node_at_cursor api and then delete the paragraph node promoting it's the children up a level.
51+
let new_children = doc_node.into_container().unwrap().take_children();
52+
let child_count = new_children.len();
53+
let p = DomNode::Container(ContainerNode::new_paragraph(new_children));
54+
55+
let handle = self.state.dom.insert_node_at_cursor(&range, p);
56+
self.state.dom.replace_node_with_its_children(&handle);
57+
self.state.dom.wrap_inline_nodes_into_paragraphs_if_needed(
58+
&self.state.dom.parent(&handle).handle(),
59+
);
60+
61+
// Track the index of the last inserted node for placing the cursor
62+
let last_index = handle.index_in_parent() + child_count - 1;
63+
let last_handle = handle.parent_handle().child_handle(last_index);
64+
let location = self.state.dom.location_for_node(&last_handle);
65+
66+
self.state.start =
67+
Location::from(location.position + location.length - 1);
68+
self.state.end = self.state.start;
69+
// add a trailing space in cases when we do not have a next sibling
70+
self.create_update_replace_all()
71+
}
72+
}
73+
74+
#[cfg(test)]
75+
mod test {
76+
use crate::dom::html_source::HtmlSource;
77+
use crate::dom::parser::{
78+
GOOGLE_DOC_HTML_PASTEBOARD, MS_DOC_HTML_PASTEBOARD,
79+
};
80+
use crate::tests::testutils_composer_model::cm;
81+
82+
#[test]
83+
fn test_replace_html_strips_meta_tags_google_docs() {
84+
let mut model = cm("|");
85+
86+
// This html was copied directly from google docs and we are including the meta and bold tags that google docs adds.
87+
let html = format!(
88+
r#"<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-bec65465-7fff-9422-b4bc-8e35d97b3ccb">{}</b>"#,
89+
GOOGLE_DOC_HTML_PASTEBOARD
90+
);
91+
92+
let _ = model.replace_html(html.into(), HtmlSource::GoogleDoc);
93+
94+
// Verify the HTML doesn't contain meta or the outer b tag
95+
let html = model.get_content_as_html();
96+
let html_str = html.to_string();
97+
assert!(!html_str.contains("<meta"));
98+
assert!(!html_str.contains("docs-internal-guid"));
99+
assert_eq!(html_str, "<ol><li><p><i>Italic</i></p></li><li><p><b>Bold</b></p></li><li><p>Unformatted</p></li><li><p><del>Strikethrough</del></p></li><li><p><u>Underlined</u></p></li><li><p><a style=\"text-decoration:none;\" href=\"http://matrix.org\"><u>Linked</u></a></p><ul><li><p>Nested</p></li></ul></li></ol>");
100+
}
101+
102+
#[test]
103+
fn test_replace_html_strips_only_meta_tags_ms_docs() {
104+
let mut model = cm("|");
105+
106+
// This html was copied directly from ms docs and we are including the meta and bold tags that ms docs adds.
107+
let html =
108+
format!(r#"<meta charset='utf-8'>{}"#, MS_DOC_HTML_PASTEBOARD);
109+
110+
let _ = model.replace_html(html.into(), HtmlSource::UnknownExternal);
111+
112+
let html = model.get_content_as_html();
113+
let html_str = html.to_string();
114+
assert!(!html_str.contains("<meta"));
115+
assert_eq!(html_str, "<ol start=\"1\"><li><p><i>Italic</i></p></li><li><p><b>Bold</b></p></li><li><p>Unformatted</p></li><li><p><del>Strikethrough</del></p></li><li><p><u>Underlined</u></p></li><li><p><a class=\"Hyperlink SCXW204127278 BCX0\" target=\"_blank\" rel=\"noreferrer noopener\" style=\"-webkit-user-drag: none; -webkit-tap-highlight-color: transparent; margin: 0px; padding: 0px; user-select: text; cursor: text; text-decoration: none; color: inherit;\" href=\"https://matrix.org/\"><u>Linked</u></a></p></li></ol><ul><li><p>Nested</p></li></ul>");
116+
}
117+
118+
#[test]
119+
fn test_replace_html_matrix_html_unchanged() {
120+
let mut model = cm("|");
121+
let matrix_html = "<p><strong>test</strong></p>";
122+
123+
let _ = model.replace_html(matrix_html.into(), HtmlSource::Matrix);
124+
125+
let html = model.get_content_as_html();
126+
let html_str = html.to_string();
127+
assert_eq!(html_str, "<p><strong>test</strong></p>");
128+
}
129+
130+
#[test]
131+
fn test_replace_html_with_existing_selection() {
132+
let mut model = cm("Hello{world}|test");
133+
let new_html = "<p><em>replacement</em></p>";
134+
135+
let _ =
136+
model.replace_html(new_html.into(), HtmlSource::UnknownExternal);
137+
138+
let html = model.get_content_as_html();
139+
let html_str = html.to_string();
140+
assert_eq!(
141+
html_str,
142+
"<p>Hello</p><p><em>replacement</em></p><p>test</p>"
143+
);
144+
}
145+
146+
#[test]
147+
fn test_replace_html_cursor_position_after_insert() {
148+
let mut model = cm("Start|");
149+
let new_html = "<strong>Bold text</strong>";
150+
let _ = model.replace_html(new_html.into(), HtmlSource::Matrix);
151+
// Cursor should be positioned after the inserted content
152+
let (start, end) = model.safe_selection();
153+
assert_eq!(start, end); // No selection, just cursor
154+
model.bold();
155+
model.enter();
156+
// Insert more text to verify cursor position
157+
let _ = model.replace_text("End".into());
158+
let html = model.get_content_as_html();
159+
let html_str = html.to_string();
160+
assert_eq!(
161+
html_str,
162+
"<p>Start</p><p><strong>Bold text</strong></p><p>End</p>"
163+
);
164+
}
165+
166+
#[test]
167+
fn test_replace_html_multiple_meta_tags() {
168+
let mut model = cm("|");
169+
let html_with_multiple_metas = r#"<meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta http-equiv="X-UA-Compatible" content="IE=edge"><p>Content after metas</p>"#;
170+
171+
let _ = model.replace_html(
172+
html_with_multiple_metas.into(),
173+
HtmlSource::UnknownExternal,
174+
);
175+
176+
let html = model.get_content_as_html();
177+
let html_str = html.to_string();
178+
assert!(!html_str.contains("<meta"));
179+
assert_eq!(html_str, "<p>Content after metas</p>");
180+
}
181+
182+
#[test]
183+
fn test_replace_html_empty_content() {
184+
let mut model = cm("Existing content|");
185+
let empty_html = "";
186+
187+
let _ = model.replace_html(empty_html.into(), HtmlSource::Matrix);
188+
189+
let html = model.get_content_as_html();
190+
let html_str = html.to_string();
191+
assert_eq!(html_str, "<p>Existing content</p>");
192+
}
193+
194+
#[test]
195+
fn test_insert_list_item_without_list_parent() {
196+
let mut model = cm("hello|");
197+
let html = "<li>list item</li>";
198+
199+
let _ = model.replace_html(html.into(), HtmlSource::UnknownExternal);
200+
201+
let html = model.get_content_as_html();
202+
let html_str = html.to_string();
203+
assert_eq!(html_str, "<p>hello</p><p>list item</p>");
204+
}
205+
}
206+
207+
#[cfg(all(test, target_arch = "wasm32"))]
208+
mod wasm_tests {
209+
use crate::dom::html_source::HtmlSource;
210+
use crate::tests::testutils_composer_model::cm;
211+
use wasm_bindgen_test::*;
212+
213+
wasm_bindgen_test_configure!(run_in_browser);
214+
215+
#[wasm_bindgen_test]
216+
fn test_replace_html_with_existing_selection() {
217+
let mut model = cm("Hello{world}|test");
218+
let new_html = "<p><em>replacement</em></p>";
219+
220+
let _ =
221+
model.replace_html(new_html.into(), HtmlSource::UnknownExternal);
222+
223+
let html = model.get_content_as_html();
224+
let html_str = html.to_string();
225+
assert_eq!(
226+
html_str,
227+
"<p>Hello</p><p><em>replacement</em></p><p>test</p>"
228+
);
229+
}
230+
231+
#[wasm_bindgen_test]
232+
fn test_replace_html_cursor_position_after_insert() {
233+
let mut model = cm("Start|");
234+
let new_html = "<strong>Bold text</strong>";
235+
let _ = model.replace_html(new_html.into(), HtmlSource::Matrix);
236+
// Cursor should be positioned after the inserted content
237+
let (start, end) = model.safe_selection();
238+
assert_eq!(start, end); // No selection, just cursor
239+
model.bold();
240+
model.enter();
241+
// Insert more text to verify cursor position
242+
let _ = model.replace_text("End".into());
243+
let html = model.get_content_as_html();
244+
let html_str = html.to_string();
245+
assert_eq!(
246+
html_str,
247+
"<p>Start</p><p><strong>Bold text</strong></p><p>End</p>"
248+
);
249+
}
250+
251+
#[wasm_bindgen_test]
252+
fn test_replace_html_multiple_meta_tags() {
253+
let mut model = cm("|");
254+
let html_with_multiple_metas = r#"<meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta http-equiv="X-UA-Compatible" content="IE=edge"><p>Content after metas</p>"#;
255+
256+
let _ = model.replace_html(
257+
html_with_multiple_metas.into(),
258+
HtmlSource::UnknownExternal,
259+
);
260+
261+
let html = model.get_content_as_html();
262+
let html_str = html.to_string();
263+
assert!(!html_str.contains("<meta"));
264+
assert_eq!(html_str, "<p>Content after metas</p>");
265+
}
266+
267+
#[wasm_bindgen_test]
268+
fn test_replace_html_empty_content() {
269+
let mut model = cm("Existing content|");
270+
let empty_html = "";
271+
272+
let _ = model.replace_html(empty_html.into(), HtmlSource::Matrix);
273+
274+
let html = model.get_content_as_html();
275+
let html_str = html.to_string();
276+
assert_eq!(html_str, "<p>Existing content</p>");
277+
}
278+
279+
#[wasm_bindgen_test]
280+
fn test_insert_list_item_without_list_parent() {
281+
let mut model = cm("hello|");
282+
let html = "<li>list item</li>";
283+
284+
let _ = model.replace_html(html.into(), HtmlSource::UnknownExternal);
285+
286+
let html = model.get_content_as_html();
287+
let html_str = html.to_string();
288+
assert_eq!(html_str, "<p>hello</p><p>list item</p>");
289+
}
290+
}

crates/wysiwyg/src/dom.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub mod dom_struct;
1515
pub mod find_extended_range;
1616
pub mod find_range;
1717
pub mod find_result;
18+
pub mod html_source;
1819
pub mod insert_node_at_cursor;
1920
pub mod insert_parent;
2021
pub mod iter;
@@ -35,6 +36,7 @@ pub use dom_creation_error::MarkdownParseError;
3536
pub use dom_handle::DomHandle;
3637
pub use dom_struct::Dom;
3738
pub use find_result::FindResult;
39+
pub use html_source::HtmlSource;
3840
pub use range::DomLocation;
3941
pub use range::Range;
4042
pub use to_html::ToHtml;

crates/wysiwyg/src/dom/dom_methods.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,7 @@ where
698698
}
699699
}
700700

701-
fn merge_text_nodes_around(&mut self, handle: &DomHandle) {
701+
pub fn merge_text_nodes_around(&mut self, handle: &DomHandle) {
702702
// TODO: make this method not public because it is used to make
703703
// the invariants true, instead of assuming they are true at the
704704
// beginning!

0 commit comments

Comments
 (0)