///|
#cfg(target="js")
struct VDom {
mut inode : INode
target_element : @dom.Element?
captured_link_listener : @dom.Listener
}
///|
#cfg(not(target="js"))
pub struct VDom {
mut vnode : VNode
}
///|
#cfg(target="js")
priv struct IProps {
props : Props
slots : Map[String, Ref[(Event, &Scheduler) -> Unit]]
}
///|
#cfg(target="js")
priv enum INode {
Elem(String, IProps, Children[INode], namespace_uri~ : String?, @dom.Element)
Text(String, @dom.Element)
Frag(Array[INode], @dom.Comment, @dom.Comment)
Thunk(Int, INode)
}
///|
#cfg(target="js")
fn INode::start(self : Self) -> @dom.Node {
match self {
Elem(_, _, _, element, ..) | Text(_, element) => element.as_node()
Thunk(_, node) => node.start()
Frag(_, start, _) => start.as_node()
}
}
///|
#cfg(target="js")
fn INode::end(self : Self) -> @dom.Node {
match self {
Elem(_, _, _, element, ..) | Text(_, element) => element.as_node()
Thunk(_, node) => node.end()
Frag(_, _, end) => end.as_node()
}
}
///|
#cfg(target="js")
fn INode::needs_relocation(
self : Self,
anchor : @js.Nullable[@dom.Node],
) -> Bool {
match (self.end().get_next_sibling().to_option(), anchor.to_option()) {
(None, None) => false
(Some(actual), Some(expected)) => !actual.is_same_node(expected)
_ => true
}
}
///|
#cfg(target="js")
fn INode::remove(self : Self, parent : @dom.Node) -> Unit {
match self {
Elem(_, _, _, element, ..) | Text(_, element) =>
parent.remove_child(element.as_node())
Thunk(_, node) => node.remove(parent)
Frag(_, start, end) => {
let start = start.as_node()
let end = end.as_node()
while start.get_next_sibling().to_option() is Some(node) &&
!node.is_same_node(end) {
parent.remove_child(node)
}
parent.remove_child(start)
parent.remove_child(end)
}
}
}
///|
#cfg(target="js")
fn INode::relocate(
self : Self,
parent : @dom.Node,
before : @js.Nullable[@dom.Node],
) -> Unit {
guard self.needs_relocation(before) else { return }
match self {
Elem(_, _, _, element, ..) | Text(_, element) =>
parent.insert_before(element.as_node(), before)
Thunk(_, node) => node.relocate(parent, before)
Frag(_, start, end) => {
{
guard! start.get_parent_node().to_option() is Some(a)
guard! end.get_parent_node().to_option() is Some(b)
guard! a.is_same_node(b)
}
let start = start.as_node()
let end = end.as_node()
let mut anchor = before
while end.get_previous_sibling().to_option() is Some(node) &&
!node.is_same_node(start) {
parent.insert_before(node, anchor)
anchor = nullable(node)
}
parent.insert_before(end, before)
parent.insert_before(start, anchor)
}
}
}
///|
#cfg(target="js")
fn insert_props(
element : @dom.Element,
tag : String,
props : Props,
scheduler : &Scheduler,
captured_link_listener : @dom.Listener,
) -> IProps {
let { handlers, attrs, props: properties, styles } = props
let slots : Map[String, Ref[(Event, &Scheduler) -> Unit]] = Map([])
for event, handler in handlers {
let slot = @ref.new(handler)
element.add_event_listener(event, event => (slot.val)(dom_event(event), scheduler))
slots[event] = slot
}
if is_captured_link_tag(tag) {
element.add_event_listener("click", captured_link_listener)
}
for name, value in attrs {
element.set_attribute(name, value)
}
for name, value in properties {
element.set_property(name, variant_to_js_value(value))
}
let sheet = if element.to_html_element() is Some(html) {
html.get_style()
} else if element.to_svg_element() is Some(svg) {
svg.get_style()
} else {
panic()
}
for name, value in styles {
// 类型化样式 → CSSOM。camelCase 键要转成 CSS 语法,长度要补 px。
sheet.set_property(@style.css_property_name(name), value.to_css())
}
{ props, slots }
}
///|
#cfg(target="js")
fn insert_children(
node : @dom.Node,
children : Children[VNode],
scheduler : &Scheduler,
captured_link_listener : @dom.Listener,
) -> Children[INode] {
match children {
RawHtml(html) => {
node.to_element().unwrap().set_inner_html(html)
RawHtml(html)
}
Array(children) =>
Array(
children.map(child => {
child.insert(scheduler, captured_link_listener, node, null())
}),
)
Map(children) => {
let mounted : Map[String, INode] = Map([])
for key, child in children {
mounted[key] = child.insert(
scheduler,
captured_link_listener,
node,
null(),
)
}
Map(mounted)
}
}
}
///|
#cfg(target="js")
fn VNode::insert(
self : Self,
scheduler : &Scheduler,
captured_link_listener : @dom.Listener,
parent : @dom.Node,
before : @js.Nullable[@dom.Node],
) -> INode {
match self {
Text(text) => {
let element = @dom.document().create_text_node(text)
parent.insert_before(element.as_node(), before)
Text(text, element)
}
Thunk(hash, render) => {
let node = render().insert(
scheduler, captured_link_listener, parent, before,
)
Thunk(hash, node)
}
Frag(children) => {
let document = @dom.document()
let fragment = document.create_document_fragment()
let start = document.create_comment(FRAGMENT_START_MARKER)
let end = document.create_comment(FRAGMENT_END_MARKER)
fragment.append_child(start.as_node())
let children = children.map(child => {
child.insert(
scheduler,
captured_link_listener,
fragment.as_node(),
null(),
)
})
fragment.append_child(end.as_node())
parent.insert_before(fragment.as_node(), before)
Frag(children, start, end)
}
Elem(tag, props, children, namespace_uri~) => {
let rendered_tag = rendered_tag(tag)
let document = @dom.document()
let element = match namespace_uri {
None => document.create_element(rendered_tag)
Some(namespace_uri) =>
document.create_element_ns(namespace_uri, rendered_tag)
}
let props = insert_props(
element, tag, props, scheduler, captured_link_listener,
)
let node = element.as_node()
let children : Children[INode] = if is_html_void_element(tag) {
Array([])
} else {
insert_children(node, children, scheduler, captured_link_listener)
}
parent.insert_before(node, before)
Elem(tag, props, children, namespace_uri~, element)
}
}
}
///|
#cfg(target="js")
fn INode::to_vnode(self : Self) -> VNode {
match self {
Elem(tag, mounted_props, children, namespace_uri~, _) =>
Elem(
tag,
mounted_props.props,
children.map(child => child.to_vnode()),
namespace_uri~,
)
Text(text, _) => Text(text)
Frag(children, _, _) => Frag(children.map(child => child.to_vnode()))
Thunk(_, node) => node.to_vnode()
}
}
///|
#cfg(target="js")
fn VDom::to_vnode(self : Self) -> VNode {
self.inode.to_vnode()
}
///|
#cfg(not(target="js"))
fn VDom::to_vnode(self : Self) -> VNode {
self.vnode
}
///|
pub fn VDom::to_string(self : Self) -> String {
server_side_render(() => self.to_vnode())
}
///|
#cfg(target="js")
fn[A] nullable(value : A) -> @js.Nullable[A] {
@js.Nullable::from_option(Some(value))
}
///|
#cfg(target="js")
fn[A] null() -> @js.Nullable[A] {
@js.Nullable::null()
}
///|
#cfg(target="js")
pub fn VDom::update(self : Self, root : VNode, scheduler : &Scheduler) -> Unit {
self.inode = diff_document(
self.inode,
root,
scheduler,
container?=self.target_element,
captured_link_listener=self.captured_link_listener,
)
}
///|
#cfg(not(target="js"))
pub fn VDom::update(self : Self, vnode : VNode, _ : &Scheduler) -> Unit {
self.vnode = vnode
}
///|
#cfg(target="js")
pub fn VDom::initialize_with_hydration(
vnode : VNode,
scheduler : &Scheduler,
) -> VDom {
let captured_link_listener = new_captured_link_listener(scheduler)
let inode = hydrate_root(vnode, scheduler, captured_link_listener)
{ inode, captured_link_listener, target_element: None }
}
///|
#cfg(target="js")
fn new_captured_link_listener(scheduler : &Scheduler) -> @dom.Listener {
event => {
if event.to_mouse_event() is Some(mouse_event) &&
!(mouse_event.get_meta_key() || mouse_event.get_ctrl_key()) {
event.prevent_default()
let href = event
.current_target()
.unwrap()
.to_element()
.unwrap()
.get_property("href")
.unwrap()
scheduler.add(scheduler.inject_url_request(href))
}
}
}
///|
#cfg(target="js")
pub fn VDom::initialize(
vnode : VNode,
scheduler : &Scheduler,
target_element_id? : String,
) -> VDom {
let captured_link_listener = new_captured_link_listener(scheduler)
match (target_element_id, vnode) {
(Some(id), vnode) => {
let container = @dom.document().get_element_by_id(id).unwrap()
// Clear any existing DOM so mounting does not append beside stale content.
container.set_inner_html("")
let inode = vnode.insert(
scheduler,
captured_link_listener,
container.as_node(),
null(),
)
VDom::{ target_element: Some(container), inode, captured_link_listener }
}
(
None,
Elem(
"html",
props,
Array(
[
Elem("head", head_props, head_children, ..),
Elem("body", body_props, body_children, ..),
]
),
namespace_uri=None
),
) => {
let head = {
let element = @dom.document().get_head().unwrap().as_element()
let props = insert_props(
element, "head", head_props, scheduler, captured_link_listener,
)
let children = insert_children(
element.as_node(),
head_children,
scheduler,
captured_link_listener,
)
INode::Elem("head", props, children, namespace_uri=None, element)
}
let body = {
let element = @dom.document().get_body().unwrap().as_element()
let props = insert_props(
element, "body", body_props, scheduler, captured_link_listener,
)
let children = insert_children(
element.as_node(),
body_children,
scheduler,
captured_link_listener,
)
INode::Elem("body", props, children, namespace_uri=None, element)
}
let html = @dom.document().get_document_element().unwrap()
let props = insert_props(
html, "html", props, scheduler, captured_link_listener,
)
let inode = INode::Elem(
"html",
props,
Array([head, body]),
html,
namespace_uri=None,
)
VDom::{ target_element: None, inode, captured_link_listener }
}
// VDom root invariant: the root must be either a `(Some(id), _)` or a
// `(None, Elem("html", _, Array([Elem("head", ..), Elem("body", ..)]), ..)`
(_, Elem(_) | Text(_) | Frag(_) | Thunk(_)) => panic()
}
}
///|
#cfg(not(target="js"))
pub fn VDom::initialize(
vnode : VNode,
_ : &Scheduler,
target_element_id? : String,
) -> VDom {
ignore(target_element_id)
{ vnode, }
}
///|
#cfg(target="js")
fn diff_document(
old : INode,
new : VNode,
scheduler : &Scheduler,
container? : @dom.Element,
captured_link_listener~ : @dom.Listener,
) -> INode {
match (container, old, new) {
(
None,
Elem(
"html",
old_props,
Array(
[
Elem("head", old_head_props, old_head_children, head_element, ..),
Elem("body", old_body_props, old_body_children, body_element, ..),
]
),
html_element,
namespace_uri=None
),
Elem(
"html",
new_props,
Array(
[
Elem("head", new_head_props, new_head_children, ..),
Elem("body", new_body_props, new_body_children, ..),
]
),
namespace_uri=None
),
) => {
let props = diff_props(old_props, new_props, scheduler, html_element)
let head = {
let props = diff_props(
old_head_props, new_head_props, scheduler, head_element,
)
let children = diff_children(
old_head_children,
new_head_children,
scheduler,
captured_link_listener,
head_element.as_node(),
null(),
)
INode::Elem("head", props, children, namespace_uri=None, head_element)
}
let body = {
let props = diff_props(
old_body_props, new_body_props, scheduler, body_element,
)
let children = diff_children(
old_body_children,
new_body_children,
scheduler,
captured_link_listener,
body_element.as_node(),
null(),
)
INode::Elem("body", props, children, namespace_uri=None, body_element)
}
Elem("html", props, Array([head, body]), html_element, namespace_uri=None)
}
(Some(container), old, new) =>
diff_node(
old,
new,
scheduler,
captured_link_listener,
container.as_node(),
null(),
)
// See VDom root invariant in `VDom::initialize`
_ => panic()
}
}
///|
#cfg(target="js")
fn diff_node(
old : INode,
new : VNode,
scheduler : &Scheduler,
captured_link_listener : @dom.Listener,
parent : @dom.Node,
anchor : @js.Nullable[@dom.Node],
) -> INode {
match (old, new) {
(
Elem(tag1, props1, children1, namespace_uri=ns1, element),
Elem(tag2, props2, children2, namespace_uri=ns2),
) =>
if tag1 != tag2 || ns1 != ns2 {
old.remove(parent)
new.insert(scheduler, captured_link_listener, parent, anchor)
} else {
let props = diff_props(props1, props2, scheduler, element)
let children = diff_children(
children1,
children2,
scheduler,
captured_link_listener,
element.as_node(),
null(),
)
Elem(tag2, props, children, namespace_uri=ns2, element)
}
(Text(text1, element), Text(text2)) => {
if text1 != text2 {
element.set_node_value(@js.Nullable::from_option(Some(text2)))
}
Text(text2, element)
}
(Frag(children1, start, end), Frag(children2)) => {
let children = diff_children(
Array(children1),
Array(children2),
scheduler,
captured_link_listener,
parent,
nullable(end.as_node()),
)
guard! children is Array(children)
Frag(children, start, end)
}
(Thunk(hash1, node) as old, Thunk(hash2, render)) =>
if hash1 == hash2 {
old
} else {
let vnode = render()
let node = diff_node(
node, vnode, scheduler, captured_link_listener, parent, anchor,
)
Thunk(hash2, node)
}
(_, _) => {
old.remove(parent)
new.insert(scheduler, captured_link_listener, parent, anchor)
}
}
}
///|
#cfg(target="js")
fn diff_props(
old : IProps,
new : Props,
scheduler : &Scheduler,
parent : @dom.Element,
) -> IProps {
let slots = old.slots
for event, slot in slots {
if !new.handlers.contains(event) {
slot.val = fn(_, _) { }
}
}
for event, handler in new.handlers {
if slots.get(event) is Some(slot) {
slot.val = handler
} else {
let slot = @ref.new(handler)
parent.add_event_listener(event, event => (slot.val)(dom_event(event), scheduler))
slots[event] = slot
}
}
for key, _ in old.props.attrs {
if !new.attrs.contains(key) {
parent.remove_attribute(key)
}
}
for key, value2 in new.attrs {
if old.props.attrs.get(key) is Some(value1) {
if value1 != value2 {
parent.set_attribute(key, value2)
}
} else {
parent.set_attribute(key, value2)
}
}
for key, _ in old.props.props {
if !new.props.contains(key) {
parent.remove_property(key)
}
}
for key, value2 in new.props {
if old.props.props.get(key) is Some(value1) {
if value1 != value2 {
parent.set_property(key, variant_to_js_value(value2))
}
} else {
parent.set_property(key, variant_to_js_value(value2))
}
}
let stylesheet = if parent.to_html_element() is Some(html) {
html.get_style()
} else if parent.to_svg_element() is Some(svg) {
svg.get_style()
} else {
panic()
}
for key, _ in old.props.styles {
if !new.styles.contains(key) {
stylesheet.remove_property(@style.css_property_name(key)) |> ignore
}
}
for key, value2 in new.styles {
if old.props.styles.get(key) is Some(value1) {
if value1 != value2 {
stylesheet.set_property(@style.css_property_name(key), value2.to_css())
}
} else {
stylesheet.set_property(@style.css_property_name(key), value2.to_css())
}
}
{ props: new, slots }
}
///|
#cfg(target="js")
fn diff_children(
old : Children[INode],
new : Children[VNode],
scheduler : &Scheduler,
captured_link_listener : @dom.Listener,
parent : @dom.Node,
anchor : @js.Nullable[@dom.Node],
) -> Children[INode] {
match (old, new) {
(Array(old), Array(new)) => {
let old_length = old.length()
let new_length = new.length()
let mut before = anchor
let acc = []
let length = if old_length > new_length {
for index in old_length>..new_length {
old[index].remove(parent)
}
new_length
} else {
for index in new_length>..old_length {
let node = new[index].insert(
scheduler, captured_link_listener, parent, before,
)
acc.push(node)
before = nullable(node.start())
}
old_length
}
for index in length>..0 {
let node = diff_node(
old[index],
new[index],
scheduler,
captured_link_listener,
parent,
before,
)
acc.push(node)
before = nullable(node.start())
}
Array(acc.rev())
}
(Map(old), Map(new)) => {
for key, node in old {
if !new.contains(key) {
node.remove(parent)
}
}
let order = new.to_array()
let mut before = null()
let reversed = []
for index in order.length()>..0 {
let (key, vnode) = order[index]
let node = if old.get(key) is Some(node) {
let node = diff_node(
node, vnode, scheduler, captured_link_listener, parent, before,
)
node.relocate(parent, before)
node
} else {
vnode.insert(scheduler, captured_link_listener, parent, before)
}
reversed.push((key, node))
before = nullable(node.start())
}
Map(Map::from_array(reversed))
}
(RawHtml(old), RawHtml(new)) => {
if old != new {
parent.to_element().unwrap().set_inner_html(new)
}
RawHtml(new)
}
(_, RawHtml(new)) => {
parent.to_element().unwrap().set_inner_html(new)
RawHtml(new)
}
(_, _) => {
parent.to_element().unwrap().set_inner_html("")
new.map(child => {
child.insert(scheduler, captured_link_listener, parent, null())
})
}
}
}
///|
#cfg(target="js")
fn[A, B] Children::map(self : Children[A], f : (A) -> B) -> Children[B] {
match self {
Array(children) => Array(children.map(f))
Map(children) => Map(children.map((_, child) => f(child)))
RawHtml(html) => RawHtml(html)
}
}
///|
#cfg(target="js")
fn variant_to_js_value(value : @variant.Variant) -> @js.Value {
match value {
String(value) => @js.Value::cast_from(value)
Floating(value) => @js.Value::cast_from(value)
Integer(value) => @js.Value::cast_from(value)
Boolean(value) => @js.Value::cast_from(value)
}
}