render: support modern framework content and geometry
This commit is contained in:
1 parent
9acd74dbea
commit
228a5723ce
12 files changed
+843
-31
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||||
use obscura_dom::{parse_html, DomTree};
|
use obscura_dom::{parse_html, DomTree, NodeData, NodeId};
|
||||||
use obscura_js::runtime::ObscuraJsRuntime;
|
use obscura_js::runtime::ObscuraJsRuntime;
|
||||||
use obscura_net::{CallbackRegistry, ObscuraHttpClient, ObscuraNetError, RequestCallback, Response, ResponseCallback};
|
use obscura_net::{CallbackRegistry, ObscuraHttpClient, ObscuraNetError, RequestCallback, Response, ResponseCallback};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
@@ -312,6 +312,127 @@ fn parse_import_url(stmt: &str) -> Option<String> {
|
|||||||
Some(url)
|
Some(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
struct HydrationStats {
|
||||||
|
elements: usize,
|
||||||
|
text_chars: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HydrationSnapshot {
|
||||||
|
body_html: String,
|
||||||
|
stats: HydrationStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_hydration_snapshot(js: &ObscuraJsRuntime) -> Option<HydrationSnapshot> {
|
||||||
|
js.with_dom(|dom| {
|
||||||
|
let body = dom.query_selector("body").ok().flatten()?;
|
||||||
|
Some(HydrationSnapshot {
|
||||||
|
body_html: dom.inner_html(body),
|
||||||
|
stats: hydration_stats(dom, body),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_hydration_stats(js: &ObscuraJsRuntime) -> Option<HydrationStats> {
|
||||||
|
js.with_dom(|dom| {
|
||||||
|
let body = dom.query_selector("body").ok().flatten()?;
|
||||||
|
Some(hydration_stats(dom, body))
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hydration_stats(dom: &DomTree, body: NodeId) -> HydrationStats {
|
||||||
|
let mut elements = 0;
|
||||||
|
let mut text_chars = 0;
|
||||||
|
|
||||||
|
for node_id in dom.descendants(body) {
|
||||||
|
let Some(node) = dom.get_node(node_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match &node.data {
|
||||||
|
NodeData::Element { name, .. } => {
|
||||||
|
if !matches!(
|
||||||
|
name.local.as_ref(),
|
||||||
|
"script" | "style" | "link" | "meta" | "template" | "noscript"
|
||||||
|
) {
|
||||||
|
elements += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NodeData::Text { contents } => {
|
||||||
|
if !has_non_content_ancestor(dom, node.parent, body) {
|
||||||
|
text_chars += contents.chars().filter(|c| !c.is_whitespace()).count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HydrationStats {
|
||||||
|
elements,
|
||||||
|
text_chars,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_non_content_ancestor(
|
||||||
|
dom: &DomTree,
|
||||||
|
mut current: Option<NodeId>,
|
||||||
|
body: NodeId,
|
||||||
|
) -> bool {
|
||||||
|
while let Some(node_id) = current {
|
||||||
|
if node_id == body {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(node) = dom.get_node(node_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if node
|
||||||
|
.as_element()
|
||||||
|
.is_some_and(|name| matches!(name.local.as_ref(), "script" | "style" | "template" | "noscript"))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = node.parent;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server-rendered body should only be restored for an unmistakable wipe:
|
||||||
|
/// a substantial document lost at least seven eighths of its real elements
|
||||||
|
/// and visible text, leaving almost no content. This deliberately does not
|
||||||
|
/// second-guess ordinary framework rerenders or client-only application shells.
|
||||||
|
fn should_restore_hydration_snapshot(
|
||||||
|
before: HydrationStats,
|
||||||
|
after: HydrationStats,
|
||||||
|
) -> bool {
|
||||||
|
before.elements >= 40
|
||||||
|
&& before.text_chars >= 200
|
||||||
|
&& after.elements <= 12
|
||||||
|
&& after.text_chars <= 128
|
||||||
|
&& after.elements.saturating_mul(8) < before.elements
|
||||||
|
&& after.text_chars.saturating_mul(8) < before.text_chars
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_hydration_snapshot_if_needed(
|
||||||
|
js: &ObscuraJsRuntime,
|
||||||
|
before: HydrationSnapshot,
|
||||||
|
) -> bool {
|
||||||
|
let Some(after) = capture_hydration_stats(js) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !should_restore_hydration_snapshot(before.stats, after) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
before_elements = before.stats.elements,
|
||||||
|
after_elements = after.elements,
|
||||||
|
before_text_chars = before.stats.text_chars,
|
||||||
|
after_text_chars = after.text_chars,
|
||||||
|
"restoring server-rendered body after catastrophic hydration wipe"
|
||||||
|
);
|
||||||
|
js.replace_body_inner_html(&before.body_html)
|
||||||
|
}
|
||||||
|
|
||||||
impl Page {
|
impl Page {
|
||||||
pub fn new(id: String, context: Arc<BrowserContext>) -> Self {
|
pub fn new(id: String, context: Arc<BrowserContext>) -> Self {
|
||||||
let http_client = context.http_client.clone();
|
let http_client = context.http_client.clone();
|
||||||
@@ -500,7 +621,13 @@ impl Page {
|
|||||||
let all_links = match &self.js {
|
let all_links = match &self.js {
|
||||||
Some(js) => {
|
Some(js) => {
|
||||||
js.with_dom(|dom| {
|
js.with_dom(|dom| {
|
||||||
let link_ids = dom.query_selector_all("link[rel=\"stylesheet\"]").unwrap_or_default();
|
// `rel` is a space-separated token list. VitePress and
|
||||||
|
// other modern generators combine preload and stylesheet
|
||||||
|
// (`rel="preload stylesheet"`); exact-value matching
|
||||||
|
// silently skipped their entire CSS payload.
|
||||||
|
let link_ids = dom
|
||||||
|
.query_selector_all("link[rel~=\"stylesheet\"]")
|
||||||
|
.unwrap_or_default();
|
||||||
let mut links = Vec::new();
|
let mut links = Vec::new();
|
||||||
for lid in link_ids {
|
for lid in link_ids {
|
||||||
if let Some(node) = dom.get_node(lid) {
|
if let Some(node) = dom.get_node(lid) {
|
||||||
@@ -1091,12 +1218,17 @@ impl Page {
|
|||||||
if max_ms == 0 {
|
if max_ms == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let hydration_snapshot = self.js.as_ref().and_then(capture_hydration_snapshot);
|
||||||
if let Some(js) = &mut self.js {
|
if let Some(js) = &mut self.js {
|
||||||
// Bounded against both async idle and synchronous microtask storms:
|
// Bounded against both async idle and synchronous microtask storms:
|
||||||
// a plain tokio timeout cannot preempt a page that pins the thread
|
// a plain tokio timeout cannot preempt a page that pins the thread
|
||||||
// inside V8 (the real-world SPA hang), so settle drives the loop
|
// inside V8 (the real-world SPA hang), so settle drives the loop
|
||||||
// through the watchdog-guarded path.
|
// through the watchdog-guarded path.
|
||||||
let _ = js.run_event_loop_bounded(max_ms).await;
|
let _ = js.run_event_loop_bounded(max_ms).await;
|
||||||
|
|
||||||
|
if let Some(before) = hydration_snapshot {
|
||||||
|
let _ = restore_hydration_snapshot_if_needed(js, before);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1399,7 +1531,11 @@ impl Page {
|
|||||||
// page.click() handlers were no-ops. Now scripts run regardless
|
// page.click() handlers were no-ops. Now scripts run regardless
|
||||||
// of waitUntil and DCL means "DOM parsed AND scripts executed".
|
// of waitUntil and DCL means "DOM parsed AND scripts executed".
|
||||||
self.fetch_stylesheets().await;
|
self.fetch_stylesheets().await;
|
||||||
|
let hydration_snapshot = self.js.as_ref().and_then(capture_hydration_snapshot);
|
||||||
self.execute_scripts().await;
|
self.execute_scripts().await;
|
||||||
|
if let (Some(js), Some(before)) = (&self.js, hydration_snapshot) {
|
||||||
|
let _ = restore_hydration_snapshot_if_needed(js, before);
|
||||||
|
}
|
||||||
|
|
||||||
self.lifecycle = LifecycleState::DomContentLoaded;
|
self.lifecycle = LifecycleState::DomContentLoaded;
|
||||||
|
|
||||||
@@ -2002,8 +2138,10 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
parse_import_url, split_css_imports, truncate_on_char_boundary, url_matches_cdp_pattern,
|
parse_import_url, should_restore_hydration_snapshot, split_css_imports,
|
||||||
|
truncate_on_char_boundary, url_matches_cdp_pattern, HydrationStats,
|
||||||
};
|
};
|
||||||
|
use obscura_dom::parse_html;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn truncate_never_splits_a_multibyte_char() {
|
fn truncate_never_splits_a_multibyte_char() {
|
||||||
@@ -2052,6 +2190,61 @@ mod tests {
|
|||||||
assert_eq!(stripped, css);
|
assert_eq!(stripped, css);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stylesheet_rel_token_selector_includes_preloaded_stylesheets() {
|
||||||
|
let dom = parse_html(
|
||||||
|
r#"<link rel="preload stylesheet" href="app.css">
|
||||||
|
<link rel="preload" href="font.woff2">"#,
|
||||||
|
);
|
||||||
|
let links = dom
|
||||||
|
.query_selector_all(r#"link[rel~="stylesheet"]"#)
|
||||||
|
.expect("valid selector");
|
||||||
|
assert_eq!(links.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
dom.get_node(links[0])
|
||||||
|
.and_then(|node| node.get_attribute("href").map(str::to_owned)),
|
||||||
|
Some("app.css".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hydration_recovery_requires_a_catastrophic_content_wipe() {
|
||||||
|
assert!(should_restore_hydration_snapshot(
|
||||||
|
HydrationStats {
|
||||||
|
elements: 520,
|
||||||
|
text_chars: 8_000,
|
||||||
|
},
|
||||||
|
HydrationStats {
|
||||||
|
elements: 4,
|
||||||
|
text_chars: 0,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hydration_recovery_does_not_override_normal_rerenders() {
|
||||||
|
assert!(!should_restore_hydration_snapshot(
|
||||||
|
HydrationStats {
|
||||||
|
elements: 520,
|
||||||
|
text_chars: 8_000,
|
||||||
|
},
|
||||||
|
HydrationStats {
|
||||||
|
elements: 180,
|
||||||
|
text_chars: 3_000,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
assert!(!should_restore_hydration_snapshot(
|
||||||
|
HydrationStats {
|
||||||
|
elements: 12,
|
||||||
|
text_chars: 80,
|
||||||
|
},
|
||||||
|
HydrationStats {
|
||||||
|
elements: 1,
|
||||||
|
text_chars: 0,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn url_matches_cdp_pattern_handles_wildcards_across_url_parts() {
|
fn url_matches_cdp_pattern_handles_wildcards_across_url_parts() {
|
||||||
assert!(url_matches_cdp_pattern(
|
assert!(url_matches_cdp_pattern(
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ thiserror = { workspace = true }
|
|||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
base64 = "0.22"
|
|
||||||
obscura-net = { workspace = true }
|
obscura-net = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_env = "gnu")'.dependencies]
|
[target.'cfg(target_env = "gnu")'.dependencies]
|
||||||
|
|||||||
@@ -250,10 +250,15 @@ pub fn parse_fragment(html: &str) -> DomTree {
|
|||||||
parse_fragment_with_context(html, context_name)
|
parse_fragment_with_context(html, context_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse an HTML fragment using the supplied context element.
|
||||||
|
///
|
||||||
|
/// The tree builder's insertion mode depends on this context. Treating every
|
||||||
|
/// `innerHTML` assignment as body content drops table-only elements such as a
|
||||||
|
/// top-level `<tr>` and mis-parses select/template fragments. Browsers instead
|
||||||
|
/// use the receiver element as the fragment parsing context.
|
||||||
pub fn parse_fragment_with_context(html: &str, context_name: QualName) -> DomTree {
|
pub fn parse_fragment_with_context(html: &str, context_name: QualName) -> DomTree {
|
||||||
use html5ever::tendril::TendrilSink;
|
use html5ever::tendril::TendrilSink;
|
||||||
use html5ever::{parse_fragment, ParseOpts};
|
use html5ever::{parse_fragment, ParseOpts};
|
||||||
|
|
||||||
let tree = DomTree::new();
|
let tree = DomTree::new();
|
||||||
parse_fragment(tree, ParseOpts::default(), context_name, vec![])
|
parse_fragment(tree, ParseOpts::default(), context_name, vec![])
|
||||||
.from_utf8()
|
.from_utf8()
|
||||||
@@ -327,4 +332,15 @@ mod tests {
|
|||||||
assert!(text.contains("Hello"));
|
assert!(text.contains("Hello"));
|
||||||
assert!(text.contains("World"));
|
assert!(text.contains("World"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_fragment_uses_table_context() {
|
||||||
|
let context_name = QualName::new(None, ns!(html), local_name!("template"));
|
||||||
|
let tree = parse_fragment_with_context("<tr><td>cell</td></tr>", context_name);
|
||||||
|
let row = tree
|
||||||
|
.query_selector("tr")
|
||||||
|
.expect("valid selector")
|
||||||
|
.expect("template context preserves the row");
|
||||||
|
assert_eq!(tree.text_content(row), "cell");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Vendored
+200
-6
@@ -1542,9 +1542,14 @@ class Element extends Node {
|
|||||||
// Cache by node id so `.content` keeps a stable identity across reads —
|
// Cache by node id so `.content` keeps a stable identity across reads —
|
||||||
// frameworks stash the fragment and compare it later.
|
// frameworks stash the fragment and compare it later.
|
||||||
if (!_cache.has(nid)) _cache.set(nid, new DocumentFragment(nid));
|
if (!_cache.has(nid)) _cache.set(nid, new DocumentFragment(nid));
|
||||||
return _cache.get(nid);
|
const content = _cache.get(nid);
|
||||||
|
content._fragmentContext = 'template';
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
if (!this._templateContent) {
|
||||||
|
this._templateContent = document.createDocumentFragment();
|
||||||
|
this._templateContent._fragmentContext = 'template';
|
||||||
}
|
}
|
||||||
if (!this._templateContent) this._templateContent = document.createDocumentFragment();
|
|
||||||
return this._templateContent;
|
return this._templateContent;
|
||||||
}
|
}
|
||||||
if (tag === 'meta') return this.getAttribute('content') || '';
|
if (tag === 'meta') return this.getAttribute('content') || '';
|
||||||
@@ -2774,6 +2779,13 @@ function _makeXPathResult(type, nodes) {
|
|||||||
|
|
||||||
class Document extends Node {
|
class Document extends Node {
|
||||||
get documentElement() { return _wrapEl(+_dom("document_element")); }
|
get documentElement() { return _wrapEl(+_dom("document_element")); }
|
||||||
|
get children() {
|
||||||
|
const root = this.documentElement;
|
||||||
|
return HTMLCollection._from(root ? [root] : []);
|
||||||
|
}
|
||||||
|
get childElementCount() { return this.documentElement ? 1 : 0; }
|
||||||
|
get firstElementChild() { return this.documentElement; }
|
||||||
|
get lastElementChild() { return this.documentElement; }
|
||||||
get head() { return this.querySelector("head"); }
|
get head() { return this.querySelector("head"); }
|
||||||
get body() { return this.querySelector("body"); }
|
get body() { return this.querySelector("body"); }
|
||||||
get doctype() {
|
get doctype() {
|
||||||
@@ -2850,6 +2862,7 @@ class Document extends Node {
|
|||||||
const el = _wrapEl(+_dom("create_element", t.toLowerCase()));
|
const el = _wrapEl(+_dom("create_element", t.toLowerCase()));
|
||||||
if (el && t.toLowerCase() === 'template') {
|
if (el && t.toLowerCase() === 'template') {
|
||||||
el._templateContent = this.createDocumentFragment();
|
el._templateContent = this.createDocumentFragment();
|
||||||
|
el._templateContent._fragmentContext = 'template';
|
||||||
}
|
}
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
@@ -3258,7 +3271,14 @@ class DocumentFragment extends Node {
|
|||||||
get nodeType() { return 11; }
|
get nodeType() { return 11; }
|
||||||
get nodeName() { return "#document-fragment"; }
|
get nodeName() { return "#document-fragment"; }
|
||||||
get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; }
|
get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; }
|
||||||
set innerHTML(v) { _dom("set_inner_html", this._nid, String(v ?? "")); }
|
set innerHTML(v) {
|
||||||
|
const html = String(v ?? "");
|
||||||
|
if (this._fragmentContext) {
|
||||||
|
_dom("set_inner_html_context", this._nid, this._fragmentContext + "\0" + html);
|
||||||
|
} else {
|
||||||
|
_dom("set_inner_html", this._nid, html);
|
||||||
|
}
|
||||||
|
}
|
||||||
querySelector(s) { return _wrapEl(+_dom("query_selector_scoped", this._nid, s)); }
|
querySelector(s) { return _wrapEl(+_dom("query_selector_scoped", this._nid, s)); }
|
||||||
querySelectorAll(s) {
|
querySelectorAll(s) {
|
||||||
const ids = _domParse("query_selector_all_scoped", this._nid, s) || [];
|
const ids = _domParse("query_selector_all_scoped", this._nid, s) || [];
|
||||||
@@ -4736,11 +4756,13 @@ globalThis.CSSStyleSheet = class CSSStyleSheet {
|
|||||||
const idx = index ?? this._rules.length;
|
const idx = index ?? this._rules.length;
|
||||||
this._rules.splice(idx, 0, { cssText: rule, type: 1 });
|
this._rules.splice(idx, 0, { cssText: rule, type: 1 });
|
||||||
this.cssRules = this._rules;
|
this.cssRules = this._rules;
|
||||||
|
_syncAdoptedStyleSheet(this);
|
||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
deleteRule(index) {
|
deleteRule(index) {
|
||||||
this._rules.splice(index, 1);
|
this._rules.splice(index, 1);
|
||||||
this.cssRules = this._rules;
|
this.cssRules = this._rules;
|
||||||
|
_syncAdoptedStyleSheet(this);
|
||||||
}
|
}
|
||||||
addRule(selector, style, index) {
|
addRule(selector, style, index) {
|
||||||
return this.insertRule(selector + '{' + style + '}', index);
|
return this.insertRule(selector + '{' + style + '}', index);
|
||||||
@@ -4749,17 +4771,73 @@ globalThis.CSSStyleSheet = class CSSStyleSheet {
|
|||||||
replace(text) {
|
replace(text) {
|
||||||
this._rules = [{ cssText: text, type: 1 }];
|
this._rules = [{ cssText: text, type: 1 }];
|
||||||
this.cssRules = this._rules;
|
this.cssRules = this._rules;
|
||||||
|
_syncAdoptedStyleSheet(this);
|
||||||
return Promise.resolve(this);
|
return Promise.resolve(this);
|
||||||
}
|
}
|
||||||
replaceSync(text) {
|
replaceSync(text) {
|
||||||
this._rules = [{ cssText: text, type: 1 }];
|
this._rules = [{ cssText: text, type: 1 }];
|
||||||
this.cssRules = this._rules;
|
this.cssRules = this._rules;
|
||||||
|
_syncAdoptedStyleSheet(this);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function _syncAdoptedStyleSheet(sheet) {
|
||||||
|
const doc = globalThis.document;
|
||||||
|
if (doc && doc._adoptedStyleSheets?.includes(sheet)) {
|
||||||
|
_syncDocumentAdoptedStyles(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _syncDocumentAdoptedStyles(doc) {
|
||||||
|
const sheets = doc._adoptedStyleSheets || [];
|
||||||
|
const nodes = doc._adoptedStyleNodes || (doc._adoptedStyleNodes = new Map());
|
||||||
|
for (const [sheet, node] of Array.from(nodes.entries())) {
|
||||||
|
if (!sheets.includes(sheet)) {
|
||||||
|
node.remove();
|
||||||
|
nodes.delete(sheet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const sheet of sheets) {
|
||||||
|
if (!(sheet instanceof CSSStyleSheet)) continue;
|
||||||
|
let node = nodes.get(sheet);
|
||||||
|
if (!node || !node.parentNode) {
|
||||||
|
node = doc.createElement("style");
|
||||||
|
node.setAttribute("data-obscura-adopted", "");
|
||||||
|
(doc.head || doc.documentElement).appendChild(node);
|
||||||
|
nodes.set(sheet, node);
|
||||||
|
}
|
||||||
|
const css = Array.from(sheet.cssRules || [], rule => rule.cssText || "").join("\n");
|
||||||
|
if (node.textContent !== css) node.textContent = css;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _makeAdoptedSheetList(doc, values) {
|
||||||
|
const target = Array.from(values || []);
|
||||||
|
return new Proxy(target, {
|
||||||
|
set(array, property, value) {
|
||||||
|
Reflect.set(array, property, value);
|
||||||
|
_syncDocumentAdoptedStyles(doc);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
deleteProperty(array, property) {
|
||||||
|
Reflect.deleteProperty(array, property);
|
||||||
|
_syncDocumentAdoptedStyles(doc);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Object.defineProperty(Document.prototype, 'adoptedStyleSheets', {
|
Object.defineProperty(Document.prototype, 'adoptedStyleSheets', {
|
||||||
get() { return this._adoptedStyleSheets || []; },
|
get() {
|
||||||
set(sheets) { this._adoptedStyleSheets = sheets; },
|
if (!this._adoptedStyleSheets) {
|
||||||
|
this._adoptedStyleSheets = _makeAdoptedSheetList(this, []);
|
||||||
|
}
|
||||||
|
return this._adoptedStyleSheets;
|
||||||
|
},
|
||||||
|
set(sheets) {
|
||||||
|
this._adoptedStyleSheets = _makeAdoptedSheetList(this, sheets);
|
||||||
|
_syncDocumentAdoptedStyles(this);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
globalThis.__mutationObservers = [];
|
globalThis.__mutationObservers = [];
|
||||||
@@ -6014,7 +6092,24 @@ globalThis.localStorage = _mkStore();
|
|||||||
globalThis.sessionStorage = _mkStore();
|
globalThis.sessionStorage = _mkStore();
|
||||||
|
|
||||||
globalThis.btoa = globalThis.btoa || ((s) => { const b = new TextEncoder().encode(s); const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let r=""; for(let i=0;i<b.length;i+=3){const a=b[i],bb=b[i+1]??0,cc=b[i+2]??0; r+=c[a>>2]+c[((a&3)<<4)|(bb>>4)]+(i+1<b.length?c[((bb&15)<<2)|(cc>>6)]:"=")+(i+2<b.length?c[cc&63]:"=");} return r; });
|
globalThis.btoa = globalThis.btoa || ((s) => { const b = new TextEncoder().encode(s); const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let r=""; for(let i=0;i<b.length;i+=3){const a=b[i],bb=b[i+1]??0,cc=b[i+2]??0; r+=c[a>>2]+c[((a&3)<<4)|(bb>>4)]+(i+1<b.length?c[((bb&15)<<2)|(cc>>6)]:"=")+(i+2<b.length?c[cc&63]:"=");} return r; });
|
||||||
globalThis.atob = globalThis.atob || ((s) => { const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let r=[]; for(let i=0;i<s.length;i+=4){const a=c.indexOf(s[i]),b=c.indexOf(s[i+1]),cc=c.indexOf(s[i+2]),d=c.indexOf(s[i+3]); r.push((a<<2)|(b>>4)); if(cc>=0)r.push(((b&15)<<4)|(cc>>2)); if(d>=0)r.push(((cc&3)<<6)|d);} return String.fromCharCode(...r); });
|
globalThis.atob = globalThis.atob || ((s) => {
|
||||||
|
const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
const r=[];
|
||||||
|
s=String(s).replace(/[\t\n\f\r ]/g,"");
|
||||||
|
for(let i=0;i<s.length;i+=4){
|
||||||
|
const a=c.indexOf(s[i]),b=c.indexOf(s[i+1]),cc=c.indexOf(s[i+2]),d=c.indexOf(s[i+3]);
|
||||||
|
r.push((a<<2)|(b>>4));
|
||||||
|
if(cc>=0)r.push(((b&15)<<4)|(cc>>2));
|
||||||
|
if(d>=0)r.push(((cc&3)<<6)|d);
|
||||||
|
}
|
||||||
|
// Spreading a large decoded payload into one call overflows V8's argument
|
||||||
|
// stack. Angular and other SSR frameworks routinely decode blobs large
|
||||||
|
// enough to hit that ceiling.
|
||||||
|
let out="";
|
||||||
|
const chunk=0x8000;
|
||||||
|
for(let i=0;i<r.length;i+=chunk) out+=String.fromCharCode(...r.slice(i,i+chunk));
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
// Functional History API. The earlier stub returned constant state and was a
|
// Functional History API. The earlier stub returned constant state and was a
|
||||||
// no-op on push/replace, so any SPA that tried to update its URL (Next.js
|
// no-op on push/replace, so any SPA that tried to update its URL (Next.js
|
||||||
@@ -6093,6 +6188,105 @@ globalThis.atob = globalThis.atob || ((s) => { const c="ABCDEFGHIJKLMNOPQRSTUVWX
|
|||||||
forward() { this.go(1); },
|
forward() { this.go(1); },
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Navigation API. New framework routers increasingly prefer `navigation`
|
||||||
|
// over popstate/history. Keep it backed by the functional History API above
|
||||||
|
// so both surfaces agree about the current URL and state.
|
||||||
|
(() => {
|
||||||
|
const listeners = Object.create(null);
|
||||||
|
const nav = {
|
||||||
|
addEventListener(type, callback) {
|
||||||
|
if (typeof callback !== "function") return;
|
||||||
|
(listeners[String(type)] ||= []).push(callback);
|
||||||
|
},
|
||||||
|
removeEventListener(type, callback) {
|
||||||
|
const list = listeners[String(type)];
|
||||||
|
if (!list) return;
|
||||||
|
const index = list.indexOf(callback);
|
||||||
|
if (index >= 0) list.splice(index, 1);
|
||||||
|
},
|
||||||
|
dispatchEvent(event) {
|
||||||
|
if (!event || !event.type) return true;
|
||||||
|
const list = (listeners[String(event.type)] || []).slice();
|
||||||
|
for (const callback of list) {
|
||||||
|
try { callback.call(nav, event); } catch (error) { console.error(error); }
|
||||||
|
}
|
||||||
|
return !event.defaultPrevented;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let serial = 0;
|
||||||
|
const makeEntry = () => {
|
||||||
|
const key = "obscura-" + serial;
|
||||||
|
const state = history.state;
|
||||||
|
return {
|
||||||
|
id: key,
|
||||||
|
key,
|
||||||
|
index: Math.max(0, history.length - 1),
|
||||||
|
sameDocument: true,
|
||||||
|
url: __currentUrl(),
|
||||||
|
getState() { return state; },
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let entry = makeEntry();
|
||||||
|
const changed = (from) => {
|
||||||
|
const old = from || entry;
|
||||||
|
serial++;
|
||||||
|
entry = makeEntry();
|
||||||
|
try {
|
||||||
|
const ev = new Event("currententrychange");
|
||||||
|
ev.from = old;
|
||||||
|
nav.dispatchEvent(ev);
|
||||||
|
} catch {}
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
Object.defineProperties(nav, {
|
||||||
|
currentEntry: { configurable: true, enumerable: true, get: () => entry },
|
||||||
|
canGoBack: { configurable: true, enumerable: true, get: () => history.length > 1 },
|
||||||
|
canGoForward: { configurable: true, enumerable: true, get: () => false },
|
||||||
|
transition: { configurable: true, enumerable: true, get: () => null },
|
||||||
|
activation: { configurable: true, enumerable: true, get: () => null },
|
||||||
|
});
|
||||||
|
nav.entries = () => [entry];
|
||||||
|
nav.updateCurrentEntry = (options) => {
|
||||||
|
const old = entry;
|
||||||
|
const state = options && Object.prototype.hasOwnProperty.call(options, "state")
|
||||||
|
? options.state : history.state;
|
||||||
|
history.replaceState(state, "", __currentUrl());
|
||||||
|
return changed(old);
|
||||||
|
};
|
||||||
|
nav.navigate = (url, options) => {
|
||||||
|
const old = entry;
|
||||||
|
const state = options && Object.prototype.hasOwnProperty.call(options, "state")
|
||||||
|
? options.state : null;
|
||||||
|
if (options && options.history === "replace") history.replaceState(state, "", url);
|
||||||
|
else history.pushState(state, "", url);
|
||||||
|
const next = changed(old);
|
||||||
|
const done = Promise.resolve(next);
|
||||||
|
return { committed: done, finished: done };
|
||||||
|
};
|
||||||
|
nav.reload = () => {
|
||||||
|
const done = Promise.resolve(entry);
|
||||||
|
return { committed: done, finished: done };
|
||||||
|
};
|
||||||
|
nav.traverseTo = () => {
|
||||||
|
const done = Promise.resolve(entry);
|
||||||
|
return { committed: done, finished: done };
|
||||||
|
};
|
||||||
|
nav.back = () => {
|
||||||
|
history.back();
|
||||||
|
const done = Promise.resolve(changed());
|
||||||
|
return { committed: done, finished: done };
|
||||||
|
};
|
||||||
|
nav.forward = () => {
|
||||||
|
history.forward();
|
||||||
|
const done = Promise.resolve(changed());
|
||||||
|
return { committed: done, finished: done };
|
||||||
|
};
|
||||||
|
globalThis.navigation = nav;
|
||||||
|
})();
|
||||||
|
|
||||||
globalThis.screenX = 0; globalThis.screenY = 0;
|
globalThis.screenX = 0; globalThis.screenY = 0;
|
||||||
globalThis.screenLeft = 0; globalThis.screenTop = 0;
|
globalThis.screenLeft = 0; globalThis.screenTop = 0;
|
||||||
globalThis.pageXOffset = 0; globalThis.pageYOffset = 0;
|
globalThis.pageXOffset = 0; globalThis.pageYOffset = 0;
|
||||||
|
|||||||
@@ -449,6 +449,30 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
|
|||||||
}
|
}
|
||||||
"true".into()
|
"true".into()
|
||||||
}
|
}
|
||||||
|
"set_inner_html_context" => {
|
||||||
|
let nid = match arg1.parse::<u32>() {
|
||||||
|
Ok(n) if n > 0 => n,
|
||||||
|
_ => return "false".into(),
|
||||||
|
};
|
||||||
|
let target = NodeId::new(nid);
|
||||||
|
let (context, html) = arg2
|
||||||
|
.split_once('\0')
|
||||||
|
.unwrap_or(("body", arg2.as_str()));
|
||||||
|
for child in dom.children(target) {
|
||||||
|
dom.detach(child);
|
||||||
|
}
|
||||||
|
if !html.is_empty() {
|
||||||
|
let context_name = html5ever::QualName::new(
|
||||||
|
None,
|
||||||
|
html5ever::ns!(html),
|
||||||
|
html5ever::LocalName::from(context.to_ascii_lowercase()),
|
||||||
|
);
|
||||||
|
let fragment = obscura_dom::parse_fragment_with_context(html, context_name);
|
||||||
|
let import_root = fragment.fragment_root();
|
||||||
|
dom.import_children_from(target, &fragment, import_root);
|
||||||
|
}
|
||||||
|
"true".into()
|
||||||
|
}
|
||||||
"set_text_content" => {
|
"set_text_content" => {
|
||||||
let nid = arg1.parse::<u32>().unwrap_or(0);
|
let nid = arg1.parse::<u32>().unwrap_or(0);
|
||||||
dom.with_node_mut(NodeId::new(nid), |n| {
|
dom.with_node_mut(NodeId::new(nid), |n| {
|
||||||
|
|||||||
@@ -1015,6 +1015,40 @@ impl ObscuraJsRuntime {
|
|||||||
state.dom.as_ref().map(f)
|
state.dom.as_ref().map(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the body's children while preserving the body node itself.
|
||||||
|
///
|
||||||
|
/// Keeping the document/body node IDs stable matters because JavaScript
|
||||||
|
/// wrappers cache those IDs. This is used by the browser's narrowly
|
||||||
|
/// guarded hydration recovery path after a framework catastrophically
|
||||||
|
/// clears an otherwise complete server-rendered document.
|
||||||
|
pub fn replace_body_inner_html(&self, html: &str) -> bool {
|
||||||
|
#[allow(unused_mut)]
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
let Some(dom) = state.dom.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(Some(body)) = dom.query_selector("body") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for child in dom.children(body) {
|
||||||
|
// Detach instead of recycling the node slot: wrappers held by a
|
||||||
|
// failed hydration attempt must not start referring to a newly
|
||||||
|
// restored, unrelated node that reused the same ID.
|
||||||
|
dom.detach(child);
|
||||||
|
}
|
||||||
|
if !html.is_empty() {
|
||||||
|
let fragment = obscura_dom::parse_fragment(html);
|
||||||
|
let root = fragment.fragment_root();
|
||||||
|
dom.import_children_from(body, &fragment, root);
|
||||||
|
}
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
{
|
||||||
|
state.layout_cache = None;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Absolute URLs the page requested via fetch()/XHR, in request order
|
/// Absolute URLs the page requested via fetch()/XHR, in request order
|
||||||
/// (issue #301). Backs `--dump assets`.
|
/// (issue #301). Backs `--dump assets`.
|
||||||
pub fn fetched_urls(&self) -> Vec<String> {
|
pub fn fetched_urls(&self) -> Vec<String> {
|
||||||
@@ -2657,6 +2691,109 @@ mod tests {
|
|||||||
assert!(html.as_str().unwrap().contains("<p>"));
|
assert!(html.as_str().unwrap().contains("<p>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_inner_html_preserves_table_fragments() {
|
||||||
|
let mut rt = setup_runtime("<html><body></body></html>");
|
||||||
|
let result = rt
|
||||||
|
.evaluate(
|
||||||
|
r#"
|
||||||
|
const template = document.createElement('template');
|
||||||
|
template.innerHTML = '<tr><td>first</td><td>second</td></tr>';
|
||||||
|
const clone = template.content.firstChild.cloneNode(true);
|
||||||
|
return [
|
||||||
|
clone.tagName,
|
||||||
|
clone.firstElementChild.tagName,
|
||||||
|
clone.firstElementChild.children.length,
|
||||||
|
clone.textContent,
|
||||||
|
];
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, serde_json::json!(["TR", "TD", 0, "firstsecond"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_exposes_parent_node_element_children_api() {
|
||||||
|
let mut rt = setup_runtime("<html><head></head><body></body></html>");
|
||||||
|
let result = rt
|
||||||
|
.evaluate(
|
||||||
|
"return [document.firstElementChild === document.documentElement,\
|
||||||
|
document.lastElementChild === document.documentElement,\
|
||||||
|
document.children.length, document.childElementCount];",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, serde_json::json!([true, true, 1, 1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atob_decodes_large_payload_without_argument_stack_overflow() {
|
||||||
|
let mut rt = setup_runtime("<html><body></body></html>");
|
||||||
|
// 60k four-character groups decode to 180k bytes, comfortably above
|
||||||
|
// V8's maximum argument count for a single fromCharCode(...bytes).
|
||||||
|
let encoded = "QUFB".repeat(60_000);
|
||||||
|
let result = rt
|
||||||
|
.evaluate(&format!("atob('{}').length", encoded))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.as_f64().unwrap() as usize, 180_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn navigation_api_updates_current_entry_state() {
|
||||||
|
let mut rt = setup_runtime("<html><body></body></html>");
|
||||||
|
let result = rt
|
||||||
|
.evaluate(
|
||||||
|
r#"
|
||||||
|
(() => {
|
||||||
|
navigation.updateCurrentEntry({state: {route: 'home'}});
|
||||||
|
const first = navigation.currentEntry;
|
||||||
|
navigation.navigate('/docs', {state: {route: 'docs'}});
|
||||||
|
return [
|
||||||
|
typeof navigation.updateCurrentEntry,
|
||||||
|
first.getState().route,
|
||||||
|
navigation.currentEntry.getState().route,
|
||||||
|
navigation.currentEntry.url,
|
||||||
|
];
|
||||||
|
})()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
serde_json::json!([
|
||||||
|
"function",
|
||||||
|
"home",
|
||||||
|
"docs",
|
||||||
|
"http://example.com/docs"
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adopted_stylesheets_materialize_into_the_document() {
|
||||||
|
let mut rt = setup_runtime("<html><head></head><body><div class=\"card\"></div></body></html>");
|
||||||
|
let result = rt
|
||||||
|
.evaluate(
|
||||||
|
r#"
|
||||||
|
(() => {
|
||||||
|
const sheet = new CSSStyleSheet();
|
||||||
|
document.adoptedStyleSheets.push(sheet);
|
||||||
|
sheet.insertRule('.card { display: flex; color: red; }', 0);
|
||||||
|
const node = document.querySelector('style[data-obscura-adopted]');
|
||||||
|
const inserted = node.textContent;
|
||||||
|
sheet.deleteRule(0);
|
||||||
|
return [
|
||||||
|
document.adoptedStyleSheets.length,
|
||||||
|
document.querySelectorAll('style[data-obscura-adopted]').length,
|
||||||
|
inserted.includes('display: flex'),
|
||||||
|
node.textContent,
|
||||||
|
];
|
||||||
|
})()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, serde_json::json!([1, 1, true, ""]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_script_execution() {
|
fn test_script_execution() {
|
||||||
let mut rt = setup_runtime("<ul><li>A</li><li>B</li></ul>");
|
let mut rt = setup_runtime("<ul><li>A</li><li>B</li></ul>");
|
||||||
|
|||||||
@@ -466,6 +466,11 @@ pub fn layout_dom_with_images(
|
|||||||
/// taffy layout. Not a CSS-inherited property; it is recomputed to
|
/// taffy layout. Not a CSS-inherited property; it is recomputed to
|
||||||
/// the element's own content width for its children.
|
/// the element's own content width for its children.
|
||||||
cb_width: f32,
|
cb_width: f32,
|
||||||
|
/// Whether the containing block has a definite height. Percentage
|
||||||
|
/// heights in ordinary flow compute to auto when this is false;
|
||||||
|
/// resolving them against a synthetic zero height collapses
|
||||||
|
/// content-heavy modern UI wrappers such as code editors.
|
||||||
|
cb_height_definite: bool,
|
||||||
}
|
}
|
||||||
impl Default for Inherited {
|
impl Default for Inherited {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -487,6 +492,7 @@ pub fn layout_dom_with_images(
|
|||||||
border_collapse: false,
|
border_collapse: false,
|
||||||
table_vertical_align: None,
|
table_vertical_align: None,
|
||||||
cb_width: 0.0,
|
cb_width: 0.0,
|
||||||
|
cb_height_definite: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -513,11 +519,13 @@ pub fn layout_dom_with_images(
|
|||||||
// i.e. the viewport width.
|
// i.e. the viewport width.
|
||||||
let mut root_inh = Inherited::default();
|
let mut root_inh = Inherited::default();
|
||||||
root_inh.cb_width = viewport.0;
|
root_inh.cb_width = viewport.0;
|
||||||
|
root_inh.cb_height_definite = true;
|
||||||
let mut queue = vec![(root_id, root_inh)];
|
let mut queue = vec![(root_id, root_inh)];
|
||||||
while let Some((id, mut inh)) = queue.pop() {
|
while let Some((id, mut inh)) = queue.pop() {
|
||||||
// Default the child containing-block width to this element's own
|
// Default the child containing-block width to this element's own
|
||||||
// (updated to its content width inside the block below).
|
// (updated to its content width inside the block below).
|
||||||
let mut child_cb_width = inh.cb_width;
|
let mut child_cb_width = inh.cb_width;
|
||||||
|
let mut child_cb_height_definite = false;
|
||||||
if let Some(style) = styles.get_mut(&id) {
|
if let Some(style) = styles.get_mut(&id) {
|
||||||
match style.color { Some(c) => inh.color = Some(c), None => style.color = inh.color }
|
match style.color { Some(c) => inh.color = Some(c), None => style.color = inh.color }
|
||||||
// Resolve a relative font-size against the PARENT (em/%) or
|
// Resolve a relative font-size against the PARENT (em/%) or
|
||||||
@@ -575,6 +583,16 @@ pub fn layout_dom_with_images(
|
|||||||
style.max_width = style.max_width.resolve(em_px, root_fs, vw, vh);
|
style.max_width = style.max_width.resolve(em_px, root_fs, vw, vh);
|
||||||
style.max_height = style.max_height.resolve(em_px, root_fs, vw, vh);
|
style.max_height = style.max_height.resolve(em_px, root_fs, vw, vh);
|
||||||
style.flex_basis = style.flex_basis.resolve(em_px, root_fs, vw, vh);
|
style.flex_basis = style.flex_basis.resolve(em_px, root_fs, vw, vh);
|
||||||
|
if matches!(style.height, crate::Dimension::Percent(_))
|
||||||
|
&& !inh.cb_height_definite
|
||||||
|
&& !matches!(style.position, Some(taffy::Position::Absolute))
|
||||||
|
{
|
||||||
|
style.height = crate::Dimension::Auto;
|
||||||
|
}
|
||||||
|
child_cb_height_definite = matches!(
|
||||||
|
style.height,
|
||||||
|
crate::Dimension::Px(_) | crate::Dimension::Percent(_)
|
||||||
|
);
|
||||||
for i in style.inset.iter_mut() {
|
for i in style.inset.iter_mut() {
|
||||||
if let Some(d) = i {
|
if let Some(d) = i {
|
||||||
*i = Some(d.resolve(em_px, root_fs, vw, vh));
|
*i = Some(d.resolve(em_px, root_fs, vw, vh));
|
||||||
@@ -748,6 +766,7 @@ pub fn layout_dom_with_images(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
inh.cb_width = child_cb_width;
|
inh.cb_width = child_cb_width;
|
||||||
|
inh.cb_height_definite = child_cb_height_definite;
|
||||||
for cid in tree.children(id).into_iter().rev() {
|
for cid in tree.children(id).into_iter().rev() {
|
||||||
queue.push((cid, inh.clone()));
|
queue.push((cid, inh.clone()));
|
||||||
}
|
}
|
||||||
@@ -847,29 +866,70 @@ pub fn layout_dom_with_images(
|
|||||||
|
|
||||||
resolve_grid_areas(tree, root_id, &mut styles);
|
resolve_grid_areas(tree, root_id, &mut styles);
|
||||||
|
|
||||||
// Blockify grid items (CSS Display 3): a direct child of a grid
|
// Blockify flex/grid items (CSS Display 3): a direct inline child of a
|
||||||
// container is blockified, so an inline `<a>`/`<time>`/`<span>` becomes
|
// genuine flex or grid container gets a blockified outer display while
|
||||||
// a block-level grid item and lays its text out as its own box instead
|
// retaining its own inner formatting context. Without the flex half,
|
||||||
// of being flattened into loose words in the container's cell (which
|
// `<pre class=flex><code>...</code></pre>` collapses the code item and
|
||||||
// shattered grid lists like jvns.ca's `.article-list{display:grid}` of
|
// navigation bars treat direct `<a>` children as wrapping inline
|
||||||
// alternating `<time>`/`<a>` into one word per cell). Only grid is done
|
// containers instead of flex items.
|
||||||
// here, not flex: obscura also uses flex-column as the UA stand-in for
|
//
|
||||||
// block containers like `<td>`, whose inline text must stay inline.
|
// `internal_flex_container` is deliberately excluded: table cells use
|
||||||
let grid_parents: Vec<NodeId> = styles
|
// a flex column only as our block-layout stand-in, and their ordinary
|
||||||
|
// inline text must continue to form an inline formatting context.
|
||||||
|
let item_parents: Vec<NodeId> = styles
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, s)| s.display == crate::Display::Grid)
|
.filter(|(_, s)| {
|
||||||
|
s.display == crate::Display::Grid
|
||||||
|
|| (s.display == crate::Display::Flex
|
||||||
|
&& !s.internal_flex_container)
|
||||||
|
})
|
||||||
.map(|(&id, _)| id)
|
.map(|(&id, _)| id)
|
||||||
.collect();
|
.collect();
|
||||||
for pid in grid_parents {
|
for pid in item_parents {
|
||||||
for cid in tree.children(pid) {
|
for cid in tree.children(pid) {
|
||||||
if let Some(cs) = styles.get_mut(&cid) {
|
if let Some(cs) = styles.get_mut(&cid) {
|
||||||
if cs.display == crate::Display::Inline && !cs.is_inline_block {
|
if cs.display == crate::Display::Inline {
|
||||||
cs.display = crate::Display::Block;
|
cs.display = crate::Display::Block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In an auto-height column flex container, a zero flex basis still
|
||||||
|
// participates in intrinsic main-size calculation through the item's
|
||||||
|
// automatic minimum size. Taffy otherwise treats `flex: 1 1` plus
|
||||||
|
// overflow:auto as an unconditional zero contribution, collapsing the
|
||||||
|
// entire ancestor chain even when the item contains many lines. Use
|
||||||
|
// an auto basis for this indefinite-size phase; definite-height flex
|
||||||
|
// containers keep the authored zero basis and normal free-space math.
|
||||||
|
let intrinsic_column_flex_parents: Vec<NodeId> = styles
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, style)| {
|
||||||
|
style.display == crate::Display::Flex
|
||||||
|
&& style.flex_direction == Some(taffy::FlexDirection::Column)
|
||||||
|
&& style.height == crate::Dimension::Auto
|
||||||
|
})
|
||||||
|
.map(|(&id, _)| id)
|
||||||
|
.collect();
|
||||||
|
for parent in intrinsic_column_flex_parents {
|
||||||
|
for child in tree.children(parent) {
|
||||||
|
let Some(style) = styles.get_mut(&child) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let zero_basis = matches!(
|
||||||
|
style.flex_basis,
|
||||||
|
crate::Dimension::Px(value) | crate::Dimension::Percent(value)
|
||||||
|
if value == 0.0
|
||||||
|
);
|
||||||
|
if zero_basis
|
||||||
|
&& style.height == crate::Dimension::Auto
|
||||||
|
&& style.min_height == crate::Dimension::Auto
|
||||||
|
{
|
||||||
|
style.flex_basis = crate::Dimension::Auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CSS Display blockification: absolute/fixed boxes and floats compute
|
// CSS Display blockification: absolute/fixed boxes and floats compute
|
||||||
// an inline outside display to block. This affects how their own mixed
|
// an inline outside display to block. This affects how their own mixed
|
||||||
// children form lines and blocks; they remain shrink-to-fit where the
|
// children form lines and blocks; they remain shrink-to-fit where the
|
||||||
@@ -3179,6 +3239,17 @@ fn build(
|
|||||||
has_inline_content(tree, id, styles) || style.before_content.is_some() || style.after_content.is_some();
|
has_inline_content(tree, id, styles) || style.before_content.is_some() || style.after_content.is_some();
|
||||||
|
|
||||||
let mut dom_children = tree.children(id);
|
let mut dom_children = tree.children(id);
|
||||||
|
let internal_mixed_block_flow = style.internal_flex_container
|
||||||
|
&& dom_children.iter().any(|cid| {
|
||||||
|
styles.get(cid).map_or(false, |child| {
|
||||||
|
child.display == crate::Display::Block
|
||||||
|
&& child.float.is_none()
|
||||||
|
&& !matches!(
|
||||||
|
child.position,
|
||||||
|
Some(taffy::Position::Absolute)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
// In flex and grid formatting contexts, collapsible whitespace-only text
|
// In flex and grid formatting contexts, collapsible whitespace-only text
|
||||||
// between items does not generate an anonymous item. Taffy places each
|
// between items does not generate an anonymous item. Taffy places each
|
||||||
// stray whitespace leaf in the item sequence, shifting every real child.
|
// stray whitespace leaf in the item sequence, shifting every real child.
|
||||||
@@ -3188,7 +3259,9 @@ fn build(
|
|||||||
// Flatten display:contents first because its children participate as direct
|
// Flatten display:contents first because its children participate as direct
|
||||||
// flex/grid items. Otherwise formatting whitespace inside the transparent
|
// flex/grid items. Otherwise formatting whitespace inside the transparent
|
||||||
// wrapper would survive this filter and become an item in `build_any`.
|
// wrapper would survive this filter and become an item in `build_any`.
|
||||||
if matches!(style.display, crate::Display::Flex | crate::Display::Grid) {
|
if matches!(style.display, crate::Display::Flex | crate::Display::Grid)
|
||||||
|
&& !internal_mixed_block_flow
|
||||||
|
{
|
||||||
let mut flat = Vec::new();
|
let mut flat = Vec::new();
|
||||||
flatten_contents_children(tree, &dom_children, styles, &mut flat);
|
flatten_contents_children(tree, &dom_children, styles, &mut flat);
|
||||||
dom_children = flat;
|
dom_children = flat;
|
||||||
@@ -3219,8 +3292,7 @@ fn build(
|
|||||||
// approximation corrupts flex sizing and percentage-margin placement.
|
// approximation corrupts flex sizing and percentage-margin placement.
|
||||||
let has_float_child = style.display == crate::Display::Block
|
let has_float_child = style.display == crate::Display::Block
|
||||||
&& dom_children.iter().any(|&cid| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false));
|
&& dom_children.iter().any(|&cid| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false));
|
||||||
let has_in_flow_block_child = style.display == crate::Display::Block
|
let has_in_flow_block_child = dom_children.iter().any(|cid| {
|
||||||
&& dom_children.iter().any(|cid| {
|
|
||||||
styles.get(cid).map_or(false, |child| {
|
styles.get(cid).map_or(false, |child| {
|
||||||
child.display == crate::Display::Block
|
child.display == crate::Display::Block
|
||||||
&& child.float.is_none()
|
&& child.float.is_none()
|
||||||
@@ -3240,6 +3312,10 @@ fn build(
|
|||||||
&& !matches!(child.position, Some(taffy::Position::Absolute))
|
&& !matches!(child.position, Some(taffy::Position::Absolute))
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
let is_native_table_cell = node.as_element().map_or(false, |element| {
|
||||||
|
matches!(element.local.as_ref(), "td" | "th")
|
||||||
|
&& style.internal_flex_container
|
||||||
|
});
|
||||||
|
|
||||||
// `text-align` affects inline content, never the used width or placement
|
// `text-align` affects inline content, never the used width or placement
|
||||||
// of an in-flow block child. `to_taffy_style` promotes a centered/right
|
// of an in-flow block child. `to_taffy_style` promotes a centered/right
|
||||||
@@ -3265,7 +3341,11 @@ fn build(
|
|||||||
// block children (e.g. a `position:relative` hero wrapper whose only
|
// block children (e.g. a `position:relative` hero wrapper whose only
|
||||||
// child is out-of-flow) to width 0 and let text and blocks share lines.
|
// child is out-of-flow) to width 0 and let text and blocks share lines.
|
||||||
// Floats still take the legacy zone path below.
|
// Floats still take the legacy zone path below.
|
||||||
if style.display == crate::Display::Block && has_inline_ish_content && !has_float_child {
|
if (style.display == crate::Display::Block
|
||||||
|
|| (is_native_table_cell && has_in_flow_block_child))
|
||||||
|
&& has_inline_ish_content
|
||||||
|
&& !has_float_child
|
||||||
|
{
|
||||||
return build_mixed_block(tree, id, style, taffy_style, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles);
|
return build_mixed_block(tree, id, style, taffy_style, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3496,6 +3576,14 @@ fn build_mixed_block(
|
|||||||
child_ids.extend(built);
|
child_ids.extend(built);
|
||||||
}
|
}
|
||||||
Seg::Run(run) => {
|
Seg::Run(run) => {
|
||||||
|
let has_text_strut = run.iter().any(|&cid| {
|
||||||
|
tree.get_node(cid).map_or(false, |node| {
|
||||||
|
matches!(
|
||||||
|
node.data,
|
||||||
|
obscura_dom::tree::NodeData::Text { .. }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
// Collapsible source formatting at the start/end of an inline
|
// Collapsible source formatting at the start/end of an inline
|
||||||
// run does not create line width. Preserve whitespace between
|
// run does not create line width. Preserve whitespace between
|
||||||
// inline siblings, but trim indentation adjacent to block
|
// inline siblings, but trim indentation adjacent to block
|
||||||
@@ -3541,18 +3629,27 @@ fn build_mixed_block(
|
|||||||
// Whitespace-only run between blocks: no anonymous box.
|
// Whitespace-only run between blocks: no anonymous box.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let wrapper = taffy_tree.new_with_children(run_wrapper_style(style), &atoms).ok()?;
|
let wrapper = taffy_tree
|
||||||
|
.new_with_children(
|
||||||
|
run_wrapper_style(style, has_text_strut),
|
||||||
|
&atoms,
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
child_ids.push(wrapper);
|
child_ids.push(wrapper);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pseudo content that found no adjacent run to join.
|
// Pseudo content that found no adjacent run to join.
|
||||||
if before_pending {
|
if before_pending {
|
||||||
let wrapper = taffy_tree.new_with_children(run_wrapper_style(style), &before_leaves).ok()?;
|
let wrapper = taffy_tree
|
||||||
|
.new_with_children(run_wrapper_style(style, true), &before_leaves)
|
||||||
|
.ok()?;
|
||||||
child_ids.insert(0, wrapper);
|
child_ids.insert(0, wrapper);
|
||||||
}
|
}
|
||||||
if after_pending {
|
if after_pending {
|
||||||
let wrapper = taffy_tree.new_with_children(run_wrapper_style(style), &after_leaves).ok()?;
|
let wrapper = taffy_tree
|
||||||
|
.new_with_children(run_wrapper_style(style, true), &after_leaves)
|
||||||
|
.ok()?;
|
||||||
child_ids.push(wrapper);
|
child_ids.push(wrapper);
|
||||||
}
|
}
|
||||||
for cid in out_of_flow {
|
for cid in out_of_flow {
|
||||||
@@ -3601,12 +3698,20 @@ fn run_leaf_style() -> taffy::Style {
|
|||||||
/// context. The parent's `text-align` moves the run's line content via
|
/// context. The parent's `text-align` moves the run's line content via
|
||||||
/// justify-content, exactly as the old whole-container
|
/// justify-content, exactly as the old whole-container
|
||||||
/// promotion did, but scoped to the run so sibling blocks stay full width.
|
/// promotion did, but scoped to the run so sibling blocks stay full width.
|
||||||
fn run_wrapper_style(parent: &crate::LayoutStyle) -> taffy::Style {
|
fn run_wrapper_style(
|
||||||
|
parent: &crate::LayoutStyle,
|
||||||
|
has_text_strut: bool,
|
||||||
|
) -> taffy::Style {
|
||||||
let justify = match parent.text_align {
|
let justify = match parent.text_align {
|
||||||
Some(taffy::AlignItems::FLEX_END) => Some(taffy::JustifyContent::FLEX_END),
|
Some(taffy::AlignItems::FLEX_END) => Some(taffy::JustifyContent::FLEX_END),
|
||||||
Some(taffy::AlignItems::CENTER) => Some(taffy::JustifyContent::CENTER),
|
Some(taffy::AlignItems::CENTER) => Some(taffy::JustifyContent::CENTER),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
let line_height = if has_text_strut {
|
||||||
|
crate::inline::used_line_height(parent).max(0.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
taffy::Style {
|
taffy::Style {
|
||||||
display: taffy::style::Display::Flex,
|
display: taffy::style::Display::Flex,
|
||||||
flex_direction: taffy::FlexDirection::Row,
|
flex_direction: taffy::FlexDirection::Row,
|
||||||
@@ -3614,6 +3719,12 @@ fn run_wrapper_style(parent: &crate::LayoutStyle) -> taffy::Style {
|
|||||||
align_items: Some(taffy::AlignItems::FLEX_START),
|
align_items: Some(taffy::AlignItems::FLEX_START),
|
||||||
justify_content: justify,
|
justify_content: justify,
|
||||||
size: taffy::Size { width: taffy::style::Dimension::percent(1.0), height: taffy::style::Dimension::auto() },
|
size: taffy::Size { width: taffy::style::Dimension::percent(1.0), height: taffy::style::Dimension::auto() },
|
||||||
|
// Every CSS line box starts with the parent's font/line-height strut,
|
||||||
|
// even when its only atomic inline is shorter (or zero-sized).
|
||||||
|
min_size: taffy::Size {
|
||||||
|
width: taffy::style::Dimension::auto(),
|
||||||
|
height: taffy::style::Dimension::length(line_height),
|
||||||
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,16 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
|
|||||||
style.font_weight = Some("bold".to_string());
|
style.font_weight = Some("bold".to_string());
|
||||||
}
|
}
|
||||||
} else if tag == "img" || tag == "figure" {
|
} else if tag == "img" || tag == "figure" {
|
||||||
|
if tag == "img" {
|
||||||
|
// Images are inline-level replaced elements by default. Model the
|
||||||
|
// atomic inner box with the same inline-block marker used by
|
||||||
|
// native controls, so an icon between text fragments participates
|
||||||
|
// in their line instead of splitting the parent into block runs.
|
||||||
|
// An authored display declaration clears this marker in the
|
||||||
|
// cascade before applying its requested display.
|
||||||
|
style.display = Display::Inline;
|
||||||
|
style.is_inline_block = true;
|
||||||
|
}
|
||||||
// Near-universal reset: images fit their container instead of
|
// Near-universal reset: images fit their container instead of
|
||||||
// overflowing. Wikipedia (and most sites) set `img{max-width:100%}`;
|
// overflowing. Wikipedia (and most sites) set `img{max-width:100%}`;
|
||||||
// making it a UA default prevents a fixed-width thumbnail (e.g. a
|
// making it a UA default prevents a fixed-width thumbnail (e.g. a
|
||||||
@@ -2857,6 +2867,14 @@ mod tests {
|
|||||||
|
|
||||||
let invalid = compute_style("td", Some("display:bogus"));
|
let invalid = compute_style("td", Some("display:bogus"));
|
||||||
assert!(invalid.internal_flex_container);
|
assert!(invalid.internal_flex_container);
|
||||||
|
|
||||||
|
let native_image = compute_style("img", None);
|
||||||
|
assert_eq!(native_image.display, Display::Inline);
|
||||||
|
assert!(native_image.is_inline_block);
|
||||||
|
|
||||||
|
let block_image = compute_style("img", Some("display:block"));
|
||||||
|
assert_eq!(block_image.display, Display::Block);
|
||||||
|
assert!(!block_image.is_inline_block);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -701,6 +701,21 @@ fn inline_block_flex_items_keep_block_inner_flow() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percentage_height_under_indefinite_parent_uses_content_height() {
|
||||||
|
let tree = parse_html(include_str!(
|
||||||
|
"../../../render-repros/percentage-height-indefinite-parent.html"
|
||||||
|
));
|
||||||
|
let layout = layout_dom(&tree, (900.0, 1000.0));
|
||||||
|
for id in ["wrapper", "stack", "editor", "pre", "code"] {
|
||||||
|
let rect = layout.rects[&tree.get_element_by_id(id).unwrap()];
|
||||||
|
assert!(
|
||||||
|
rect.height >= 24.0,
|
||||||
|
"{id} collapsed despite its indefinite percentage height: {rect:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn item_self_alignment_places_flex_and_grid_items() {
|
fn item_self_alignment_places_flex_and_grid_items() {
|
||||||
let tree = parse_html(include_str!("../../../render-repros/item-self-alignment.html"));
|
let tree = parse_html(include_str!("../../../render-repros/item-self-alignment.html"));
|
||||||
|
|||||||
@@ -121,6 +121,14 @@
|
|||||||
{"name": "inline block list shrink wraps and stacks children", "color": "#1971c2", "x": 525, "y": 0, "width": 90, "height": 60},
|
{"name": "inline block list shrink wraps and stacks children", "color": "#1971c2", "x": 525, "y": 0, "width": 90, "height": 60},
|
||||||
{"name": "table stand-in does not blockify inline-block list", "color": "#c2255c", "x": 0, "y": 140, "width": 90, "height": 20}
|
{"name": "table stand-in does not blockify inline-block list", "color": "#c2255c", "x": 0, "y": 140, "width": 90, "height": 20}
|
||||||
],
|
],
|
||||||
|
"inline-replaced-flow": [
|
||||||
|
{"name": "default image stays in its inline formatting context", "color": "#1971c2", "x": 60, "y": 0, "width": 40, "height": 20},
|
||||||
|
{"name": "following inline content remains on the image line", "color": "#e8590c", "x": 100, "y": 0, "width": 70, "height": 20},
|
||||||
|
{"name": "authored block image still starts a block row", "color": "#1971c2", "x": 0, "y": 80, "width": 40, "height": 20},
|
||||||
|
{"name": "content after authored block image follows below", "color": "#e8590c", "x": 0, "y": 100, "width": 70, "height": 20},
|
||||||
|
{"name": "atomic inline run retains its line-height strut", "color": "#7048e8", "x": 0, "y": 178, "width": 300, "height": 4},
|
||||||
|
{"name": "mixed cell keeps block child full width and centered", "color": "#2b8a3e", "x": 100, "y": 182, "width": 100, "height": 20}
|
||||||
|
],
|
||||||
"item-self-alignment": [
|
"item-self-alignment": [
|
||||||
{"name": "flex item centers itself on cross axis", "color": "#087f5b", "x": 0, "y": 40, "width": 40, "height": 20},
|
{"name": "flex item centers itself on cross axis", "color": "#087f5b", "x": 0, "y": 40, "width": 40, "height": 20},
|
||||||
{"name": "flex item aligns itself to cross end", "color": "#1971c2", "x": 40, "y": 70, "width": 50, "height": 30},
|
{"name": "flex item aligns itself to cross end", "color": "#1971c2", "x": 40, "y": 70, "width": 50, "height": 30},
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>inline replaced flow</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; }
|
||||||
|
.case {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 300px;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.atomic {
|
||||||
|
display: inline-block;
|
||||||
|
height: 20px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.lead {
|
||||||
|
width: 60px;
|
||||||
|
background: #087f5b;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
vertical-align: top;
|
||||||
|
background: #1971c2;
|
||||||
|
}
|
||||||
|
.tail {
|
||||||
|
width: 70px;
|
||||||
|
background: #e8590c;
|
||||||
|
}
|
||||||
|
#default-inline { top: 0; }
|
||||||
|
#authored-block { top: 60px; }
|
||||||
|
#authored-block img { display: block; }
|
||||||
|
|
||||||
|
#mixed-cell {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 160px;
|
||||||
|
width: 300px;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
#mixed-cell table {
|
||||||
|
width: 100%;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
#mixed-cell td { padding: 0; }
|
||||||
|
#mixed-cell .zero {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
#mixed-cell .rule {
|
||||||
|
height: 4px;
|
||||||
|
background: #7048e8;
|
||||||
|
}
|
||||||
|
#mixed-cell center { height: 20px; }
|
||||||
|
#mixed-cell .centered {
|
||||||
|
width: 100px;
|
||||||
|
background: #2b8a3e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="default-inline" class="case"><span class="atomic lead"></span><img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSIyMCI+PHJlY3Qgd2lkdGg9IjQwIiBoZWlnaHQ9IjIwIiBmaWxsPSIjMTk3MWMyIi8+PC9zdmc+" alt=""><span class="atomic tail"></span></div>
|
||||||
|
<div id="authored-block" class="case"><span class="atomic lead"></span><img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSIyMCI+PHJlY3Qgd2lkdGg9IjQwIiBoZWlnaHQ9IjIwIiBmaWxsPSIjMTk3MWMyIi8+PC9zdmc+" alt=""><span class="atomic tail"></span></div>
|
||||||
|
<table id="mixed-cell">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img class="zero" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSIyMCI+PHJlY3Qgd2lkdGg9IjQwIiBoZWlnaHQ9IjIwIiBmaWxsPSIjMTk3MWMyIi8+PC9zdmc+" alt="">
|
||||||
|
<table><tr><td class="rule"></td></tr></table>
|
||||||
|
<center><span class="atomic centered"></span></center>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
#outer { width: 400px; }
|
||||||
|
#wrapper, #pre { height: 100%; }
|
||||||
|
#stack { display: flex; flex-direction: column; width: 100%; position: relative; }
|
||||||
|
#editor { flex: 1 1; position: relative; overflow: auto; }
|
||||||
|
#pre { display: flex; }
|
||||||
|
#code { display: block; line-height: 24px; }
|
||||||
|
</style>
|
||||||
|
<div id="outer">
|
||||||
|
<div id="wrapper">
|
||||||
|
<div id="stack">
|
||||||
|
<div id="editor">
|
||||||
|
<pre id="pre"><code id="code">content must give an indefinite percentage-height chain its height</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in new issue
Block a user