text_view: Refactor TextView to RenderOnce to easy usage. (#647)
This commit is contained in:
parent
c303b84303
commit
d54dcdf417
5 changed files with 227 additions and 114 deletions
|
|
@ -4,7 +4,6 @@ use story::Assets;
|
||||||
|
|
||||||
pub struct Example {
|
pub struct Example {
|
||||||
text_input: Entity<TextInput>,
|
text_input: Entity<TextInput>,
|
||||||
text_view: Entity<TextView>,
|
|
||||||
_subscribe: Subscription,
|
_subscribe: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,15 +17,11 @@ impl Example {
|
||||||
.rows(50)
|
.rows(50)
|
||||||
.placeholder("Input your HTML here...")
|
.placeholder("Input your HTML here...")
|
||||||
});
|
});
|
||||||
let text_view = cx.new(|cx| TextView::html(EXAMPLE, cx));
|
|
||||||
|
|
||||||
let _subscribe = cx.subscribe(
|
let _subscribe = cx.subscribe(
|
||||||
&text_input,
|
&text_input,
|
||||||
|this, _, _: &gpui_component::input::InputEvent, cx| {
|
|_, _, _: &gpui_component::input::InputEvent, cx| {
|
||||||
let new_text = this.text_input.read(cx).text();
|
cx.notify();
|
||||||
this.text_view.update(cx, |view, cx| {
|
|
||||||
view.set_text(new_text, cx);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -36,7 +31,6 @@ impl Example {
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
text_input,
|
text_input,
|
||||||
text_view,
|
|
||||||
_subscribe,
|
_subscribe,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +64,7 @@ impl Render for Example {
|
||||||
.p_5()
|
.p_5()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.overflow_y_scroll()
|
.overflow_y_scroll()
|
||||||
.child(self.text_view.clone()),
|
.child(TextView::html("preview", self.text_input.read(cx).text())),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,13 @@ use gpui::*;
|
||||||
use gpui_component::{text::TextView, ActiveTheme as _};
|
use gpui_component::{text::TextView, ActiveTheme as _};
|
||||||
use story::Assets;
|
use story::Assets;
|
||||||
|
|
||||||
pub struct Example {
|
pub struct Example {}
|
||||||
text_view: Entity<TextView>,
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXAMPLE: &str = include_str!("./markdown.md");
|
const EXAMPLE: &str = include_str!("./markdown.md");
|
||||||
|
|
||||||
impl Example {
|
impl Example {
|
||||||
pub fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(_: &mut Window, _: &mut Context<Self>) -> Self {
|
||||||
let text_view = cx.new(|cx| TextView::markdown(EXAMPLE, cx));
|
Self {}
|
||||||
|
|
||||||
Self { text_view }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||||
|
|
@ -46,7 +42,7 @@ impl Render for Example {
|
||||||
.p_5()
|
.p_5()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.overflow_y_scroll()
|
.overflow_y_scroll()
|
||||||
.child(self.text_view.clone()),
|
.child(TextView::markdown("preview", EXAMPLE)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,17 @@ use std::collections::HashMap;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, px, relative, Context, DefiniteLength, IntoElement, ParentElement as _, Render,
|
div, px, relative, AnyElement, DefiniteLength, Element, ElementId, IntoElement,
|
||||||
SharedString,
|
ParentElement as _, SharedString, Styled as _, Window,
|
||||||
};
|
};
|
||||||
use html5ever::tendril::TendrilSink;
|
use html5ever::tendril::TendrilSink;
|
||||||
use html5ever::{local_name, parse_document, LocalName, ParseOpts};
|
use html5ever::{local_name, parse_document, LocalName, ParseOpts};
|
||||||
use markup5ever_rcdom::{Node, NodeData, RcDom};
|
use markup5ever_rcdom::{Node, NodeData, RcDom};
|
||||||
|
|
||||||
|
use crate::v_flex;
|
||||||
|
|
||||||
use super::element::{
|
use super::element::{
|
||||||
self, ImageNode, InlineTextStyle, LinkMark, Paragraph, Table, TableRow, TextNode,
|
self, ImageNode, InlineTextStyle, LinkMark, Paragraph, Table, TableRow, TextNode,
|
||||||
};
|
};
|
||||||
|
|
@ -53,7 +56,7 @@ const BLOCK_ELEMENTS: [&str; 33] = [
|
||||||
"ul",
|
"ul",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub(super) fn parse_html(source: &str) -> Result<element::Node, std::io::Error> {
|
pub(super) fn parse_html(source: &str) -> Result<element::Node, SharedString> {
|
||||||
let opts = ParseOpts {
|
let opts = ParseOpts {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
@ -64,7 +67,8 @@ pub(super) fn parse_html(source: &str) -> Result<element::Node, std::io::Error>
|
||||||
// https://github.com/servo/html5ever/blob/main/rcdom/examples/print-rcdom.rs
|
// https://github.com/servo/html5ever/blob/main/rcdom/examples/print-rcdom.rs
|
||||||
let dom = parse_document(RcDom::default(), opts)
|
let dom = parse_document(RcDom::default(), opts)
|
||||||
.from_utf8()
|
.from_utf8()
|
||||||
.read_from(&mut cursor)?;
|
.read_from(&mut cursor)
|
||||||
|
.map_err(|e| SharedString::from(format!("{:?}", e)))?;
|
||||||
|
|
||||||
let mut paragraph = Paragraph::default();
|
let mut paragraph = Paragraph::default();
|
||||||
// NOTE: The outer paragraph is not used.
|
// NOTE: The outer paragraph is not used.
|
||||||
|
|
@ -74,45 +78,115 @@ pub(super) fn parse_html(source: &str) -> Result<element::Node, std::io::Error>
|
||||||
Ok(node)
|
Ok(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct HtmlView {
|
pub(super) struct HtmlElement {
|
||||||
|
id: ElementId,
|
||||||
text: SharedString,
|
text: SharedString,
|
||||||
parsed: bool,
|
|
||||||
node: Option<element::Node>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HtmlView {
|
impl HtmlElement {
|
||||||
pub fn new(raw: impl Into<SharedString>) -> Self {
|
pub(super) fn new(id: impl Into<ElementId>, raw: impl Into<SharedString>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
id: id.into(),
|
||||||
text: raw.into(),
|
text: raw.into(),
|
||||||
parsed: false,
|
|
||||||
node: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_text(&mut self, raw: impl Into<SharedString>, cx: &mut Context<Self>) {
|
/// Set the source of the markdown view.
|
||||||
|
pub(crate) fn text(mut self, raw: impl Into<SharedString>) -> Self {
|
||||||
self.text = raw.into();
|
self.text = raw.into();
|
||||||
self.parsed = false;
|
self
|
||||||
self.node = None;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_if_needed(&mut self) {
|
|
||||||
if !self.parsed {
|
|
||||||
self.node = parse_html(&self.text).ok();
|
|
||||||
self.parsed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for HtmlView {
|
#[derive(Default)]
|
||||||
fn render(&mut self, _: &mut gpui::Window, _: &mut Context<'_, Self>) -> impl IntoElement {
|
pub struct HtmlState {
|
||||||
self.parse_if_needed();
|
raw: SharedString,
|
||||||
|
root: Option<Result<element::Node, SharedString>>,
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(node) = &self.node {
|
impl HtmlState {
|
||||||
div().child(node.clone())
|
fn parse_if_needed(&mut self, new_text: SharedString) {
|
||||||
} else {
|
let is_changed = self.raw != new_text;
|
||||||
div()
|
|
||||||
|
if self.root.is_some() && !is_changed {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.raw = new_text;
|
||||||
|
self.root = Some(parse_html(&self.raw));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoElement for HtmlElement {
|
||||||
|
type Element = Self;
|
||||||
|
|
||||||
|
fn into_element(self) -> Self::Element {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for HtmlElement {
|
||||||
|
type RequestLayoutState = AnyElement;
|
||||||
|
type PrepaintState = ();
|
||||||
|
|
||||||
|
fn id(&self) -> Option<gpui::ElementId> {
|
||||||
|
Some(self.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_layout(
|
||||||
|
&mut self,
|
||||||
|
id: Option<&gpui::GlobalElementId>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut gpui::App,
|
||||||
|
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||||
|
window.with_element_state(id.unwrap(), |state, window| {
|
||||||
|
let mut state: HtmlState = state.unwrap_or_default();
|
||||||
|
state.parse_if_needed(self.text.clone());
|
||||||
|
|
||||||
|
let root = state
|
||||||
|
.root
|
||||||
|
.clone()
|
||||||
|
.expect("BUG: root should not None, maybe parse_if_needed issue.");
|
||||||
|
|
||||||
|
let mut el = div()
|
||||||
|
.map(|this| match root {
|
||||||
|
Ok(node) => this.child(node),
|
||||||
|
Err(err) => this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child("Error parsing HTML")
|
||||||
|
.child(err.to_string()),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.into_any_element();
|
||||||
|
|
||||||
|
let layout_id = el.request_layout(window, cx);
|
||||||
|
|
||||||
|
((layout_id, el), state)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepaint(
|
||||||
|
&mut self,
|
||||||
|
_: Option<&gpui::GlobalElementId>,
|
||||||
|
_: gpui::Bounds<gpui::Pixels>,
|
||||||
|
request_layout: &mut Self::RequestLayoutState,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut gpui::App,
|
||||||
|
) -> Self::PrepaintState {
|
||||||
|
request_layout.prepaint(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(
|
||||||
|
&mut self,
|
||||||
|
_: Option<&gpui::GlobalElementId>,
|
||||||
|
_: gpui::Bounds<gpui::Pixels>,
|
||||||
|
request_layout: &mut Self::RequestLayoutState,
|
||||||
|
_: &mut Self::PrepaintState,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut gpui::App,
|
||||||
|
) {
|
||||||
|
request_layout.paint(window, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, prelude::FluentBuilder as _, Context, IntoElement, ParentElement, Render, SharedString,
|
div, prelude::FluentBuilder as _, AnyElement, Element, ElementId, IntoElement, ParentElement,
|
||||||
Styled, Window,
|
SharedString, Styled, Window,
|
||||||
};
|
};
|
||||||
use markdown::{
|
use markdown::{
|
||||||
mdast::{self, Node},
|
mdast::{self, Node},
|
||||||
|
|
@ -30,56 +30,120 @@ use super::{
|
||||||
/// - As a markdown editor.
|
/// - As a markdown editor.
|
||||||
/// - Add custom markdown syntax.
|
/// - Add custom markdown syntax.
|
||||||
/// - Complex styles cumstomization.
|
/// - Complex styles cumstomization.
|
||||||
pub(super) struct MarkdownView {
|
pub(super) struct MarkdownElement {
|
||||||
|
id: ElementId,
|
||||||
text: SharedString,
|
text: SharedString,
|
||||||
parsed: bool,
|
|
||||||
root: Option<Result<element::Node, markdown::message::Message>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MarkdownView {
|
impl MarkdownElement {
|
||||||
pub(super) fn new(raw: impl Into<SharedString>) -> Self {
|
pub(super) fn new(id: impl Into<ElementId>, raw: impl Into<SharedString>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
id: id.into(),
|
||||||
text: raw.into(),
|
text: raw.into(),
|
||||||
parsed: false,
|
|
||||||
root: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the source of the markdown view.
|
/// Set the source of the markdown view.
|
||||||
pub(crate) fn set_text(&mut self, raw: impl Into<SharedString>, cx: &mut Context<Self>) {
|
pub(crate) fn text(mut self, raw: impl Into<SharedString>) -> Self {
|
||||||
self.text = raw.into();
|
self.text = raw.into();
|
||||||
self.parsed = false;
|
self
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_if_needed(&mut self) {
|
|
||||||
if self.parsed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.root = Some(markdown::to_mdast(&self.text, &ParseOptions::gfm()).map(|n| n.into()));
|
|
||||||
self.parsed = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for MarkdownView {
|
#[derive(Default)]
|
||||||
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<'_, Self>) -> impl IntoElement {
|
pub struct MarkdownState {
|
||||||
self.parse_if_needed();
|
raw: SharedString,
|
||||||
|
root: Option<Result<element::Node, SharedString>>,
|
||||||
|
}
|
||||||
|
|
||||||
let Some(root) = self.root.clone() else {
|
impl MarkdownState {
|
||||||
return div();
|
fn parse_if_needed(&mut self, new_text: SharedString) {
|
||||||
};
|
let is_changed = self.raw != new_text;
|
||||||
|
|
||||||
div().map(|this| match root {
|
if self.root.is_some() && !is_changed {
|
||||||
Ok(node) => this.child(node),
|
return;
|
||||||
Err(err) => this.child(
|
}
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
self.raw = new_text;
|
||||||
.child("Error parsing markdown")
|
self.root = Some(
|
||||||
.child(err.to_string()),
|
markdown::to_mdast(&self.raw, &ParseOptions::gfm())
|
||||||
),
|
.map(|n| n.into())
|
||||||
|
.map_err(|e| e.to_string().into()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoElement for MarkdownElement {
|
||||||
|
type Element = Self;
|
||||||
|
|
||||||
|
fn into_element(self) -> Self::Element {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for MarkdownElement {
|
||||||
|
type RequestLayoutState = AnyElement;
|
||||||
|
type PrepaintState = ();
|
||||||
|
|
||||||
|
fn id(&self) -> Option<gpui::ElementId> {
|
||||||
|
Some(self.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_layout(
|
||||||
|
&mut self,
|
||||||
|
id: Option<&gpui::GlobalElementId>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut gpui::App,
|
||||||
|
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||||
|
window.with_element_state(id.unwrap(), |state, window| {
|
||||||
|
let mut state: MarkdownState = state.unwrap_or_default();
|
||||||
|
state.parse_if_needed(self.text.clone());
|
||||||
|
|
||||||
|
let root = state
|
||||||
|
.root
|
||||||
|
.clone()
|
||||||
|
.expect("BUG: root should not None, maybe parse_if_needed issue.");
|
||||||
|
|
||||||
|
let mut el = div()
|
||||||
|
.map(|this| match root {
|
||||||
|
Ok(node) => this.child(node),
|
||||||
|
Err(err) => this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child("Error parsing Markdown")
|
||||||
|
.child(err.to_string()),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.into_any_element();
|
||||||
|
|
||||||
|
let layout_id = el.request_layout(window, cx);
|
||||||
|
|
||||||
|
((layout_id, el), state)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prepaint(
|
||||||
|
&mut self,
|
||||||
|
_: Option<&gpui::GlobalElementId>,
|
||||||
|
_: gpui::Bounds<gpui::Pixels>,
|
||||||
|
request_layout: &mut Self::RequestLayoutState,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut gpui::App,
|
||||||
|
) -> Self::PrepaintState {
|
||||||
|
request_layout.prepaint(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(
|
||||||
|
&mut self,
|
||||||
|
_: Option<&gpui::GlobalElementId>,
|
||||||
|
_: gpui::Bounds<gpui::Pixels>,
|
||||||
|
request_layout: &mut Self::RequestLayoutState,
|
||||||
|
_: &mut Self::PrepaintState,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut gpui::App,
|
||||||
|
) {
|
||||||
|
request_layout.paint(window, cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_table_row(table: &mut Table, node: &mdast::TableRow) {
|
fn parse_table_row(table: &mut Table, node: &mdast::TableRow) {
|
||||||
|
|
@ -380,16 +444,3 @@ impl From<mdast::Node> for element::Node {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::MarkdownView;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse() {
|
|
||||||
let source = include_str!("../../../story/examples/markdown.md");
|
|
||||||
let mut renderer = MarkdownView::new(source);
|
|
||||||
renderer.parse_if_needed();
|
|
||||||
// println!("{:#?}", renderer.root);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,45 @@
|
||||||
use gpui::{App, AppContext, Entity, IntoElement, Render, SharedString};
|
use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Window};
|
||||||
use html::HtmlView;
|
use html::HtmlElement;
|
||||||
use markdown::MarkdownView;
|
use markdown::MarkdownElement;
|
||||||
|
|
||||||
mod element;
|
mod element;
|
||||||
mod html;
|
mod html;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
|
/// A text view that can render Markdown or HTML.
|
||||||
#[allow(private_interfaces)]
|
#[allow(private_interfaces)]
|
||||||
|
#[derive(IntoElement)]
|
||||||
pub enum TextView {
|
pub enum TextView {
|
||||||
Markdown(Entity<MarkdownView>),
|
Markdown(MarkdownElement),
|
||||||
Html(Entity<HtmlView>),
|
Html(HtmlElement),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextView {
|
impl TextView {
|
||||||
/// Create a new markdown text view.
|
/// Create a new markdown text view.
|
||||||
pub fn markdown(raw: impl Into<SharedString>, cx: &mut App) -> Self {
|
pub fn markdown(id: impl Into<ElementId>, raw: impl Into<SharedString>) -> Self {
|
||||||
Self::Markdown(cx.new(|_| MarkdownView::new(raw)))
|
Self::Markdown(MarkdownElement::new(id, raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new html text view.
|
/// Create a new html text view.
|
||||||
pub fn html(raw: impl Into<SharedString>, cx: &mut App) -> Self {
|
pub fn html(id: impl Into<ElementId>, raw: impl Into<SharedString>) -> Self {
|
||||||
Self::Html(cx.new(|_| HtmlView::new(raw)))
|
Self::Html(HtmlElement::new(id, raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the source text of the text view.
|
/// Set the source text of the text view.
|
||||||
pub fn set_text(&mut self, raw: impl Into<SharedString>, cx: &mut App) {
|
pub fn text(self, raw: impl Into<SharedString>) -> Self {
|
||||||
match self {
|
match self {
|
||||||
Self::Markdown(view) => view.update(cx, |this, cx| this.set_text(raw, cx)),
|
Self::Markdown(el) => Self::Markdown(el.text(raw)),
|
||||||
Self::Html(view) => view.update(cx, |this, cx| this.set_text(raw, cx)),
|
Self::Html(el) => Self::Html(el.text(raw)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for TextView {
|
impl RenderOnce for TextView {
|
||||||
fn render(
|
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||||
&mut self,
|
|
||||||
_: &mut gpui::Window,
|
|
||||||
_: &mut gpui::Context<'_, Self>,
|
|
||||||
) -> impl IntoElement {
|
|
||||||
match self {
|
match self {
|
||||||
Self::Markdown(view) => view.clone().into_any_element(),
|
Self::Markdown(el) => el.into_any_element(),
|
||||||
Self::Html(view) => view.clone().into_any_element(),
|
Self::Html(el) => el.into_any_element(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue