443 lines
18 KiB
Rust
443 lines
18 KiB
Rust
//! The built page, live: each package renders offline.
|
|
|
|
use std::{path::Path, time::Duration};
|
|
|
|
use ely_gpui_component::theme::{ActiveTheme as _, Mode, Theme};
|
|
use ely_platform::AppId;
|
|
use ely_web_content::{Event, WebView};
|
|
use gpui::{
|
|
App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
|
|
Window, div,
|
|
};
|
|
|
|
/// What the page holds, each block scrolled in and mounted.
|
|
const CHECK: &str = r#"
|
|
(async () => {
|
|
const view = document.querySelector('#document > div:not([hidden])') ?? document;
|
|
const settle = () => new Promise((done) => setTimeout(done, 700));
|
|
const seen = (node) => {
|
|
if (!node || !node.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
|
|
node.scrollIntoView({ block: 'nearest' });
|
|
const box = node.getBoundingClientRect();
|
|
if (box.width < 1 || box.height < 1) return false;
|
|
const top = document.elementFromPoint(box.x + box.width / 2, box.y + Math.min(box.height / 2, 8));
|
|
return Boolean(top && (node.contains(top) || top.contains(node)));
|
|
};
|
|
const blocks = [];
|
|
for (const block of view.querySelectorAll('.milkdown-code-block')) {
|
|
block.scrollIntoView({ block: 'center' });
|
|
await settle();
|
|
const diagram = block.querySelector('.ely-diagram svg');
|
|
const shape = diagram?.querySelector('.node rect, .node polygon');
|
|
const box = diagram?.getBoundingClientRect();
|
|
blocks.push({
|
|
language: block.querySelector('.language-button')?.textContent.trim() ?? '',
|
|
editors: block.querySelectorAll('.cm-editor').length,
|
|
editable: block.querySelector('.cm-content')?.contentEditable ?? 'none',
|
|
code: seen(block.querySelector('.cm-line')),
|
|
math: seen(block.querySelector('.preview .katex')),
|
|
diagram: box ? [Math.round(box.width), Math.round(box.height)] : [0, 0],
|
|
drawn: seen(diagram),
|
|
fill: shape ? getComputedStyle(shape).fill : '',
|
|
first: window.firstFill ?? '',
|
|
failure: seen(block.querySelector('.ely-failure')) ? block.querySelector('.ely-failure').textContent : '',
|
|
});
|
|
}
|
|
document.scrollingElement.scrollTop = 0;
|
|
await settle();
|
|
const root = document.getElementById('document');
|
|
window.elyQuery({
|
|
request: JSON.stringify({
|
|
step: 'STEP',
|
|
mode: root.dataset.mode,
|
|
editable: view.querySelector('.ProseMirror')?.contentEditable ?? 'none',
|
|
title: view.querySelector('.milkdown h1')?.textContent ?? '',
|
|
heading: seen(view.querySelector('.milkdown h1')),
|
|
inline: seen(view.querySelector('.milkdown p .katex')),
|
|
display: seen(view.querySelector('.milkdown p .katex-display')),
|
|
tasks: view.querySelectorAll('li[data-item-type="task"]').length,
|
|
boxes: [...view.querySelectorAll('li[data-item-type="task"]')].map((item) => {
|
|
const mark = getComputedStyle(item, '::before');
|
|
const range = document.createRange();
|
|
range.selectNodeContents(item.querySelector('p'));
|
|
const line = range.getClientRects()[0];
|
|
const top = item.getBoundingClientRect().top + parseFloat(mark.marginTop);
|
|
const size = parseFloat(getComputedStyle(item).fontSize);
|
|
return [parseFloat(mark.width) / size, parseFloat(mark.height) / size, top + parseFloat(mark.height) / 2 - (line.top + line.height / 2)];
|
|
}),
|
|
cells: view.querySelectorAll('.milkdown td').length,
|
|
notes: view.querySelectorAll('[data-type="footnote_definition"]').length,
|
|
blocks,
|
|
font: [...document.fonts].some((f) => f.family === 'KaTeX_Main' && f.status === 'loaded'),
|
|
source: seen(view.querySelector(':scope > .cm-editor .cm-line')),
|
|
first: view.querySelector('.cm-line')?.textContent ?? '',
|
|
editors: document.querySelectorAll('#document > div:not([hidden])').length,
|
|
text: view.textContent.slice(0, 40),
|
|
bg: getComputedStyle(document.documentElement).getPropertyValue('--ely-bg').trim(),
|
|
}),
|
|
onSuccess: () => {},
|
|
onFailure: () => {},
|
|
});
|
|
})();
|
|
"#;
|
|
|
|
const TEXTS: &str = r#"{"searchLanguage":"s","noResult":"n","copy":"c","preview":"p","loading":"l","edit":"e","hide":"h"}"#;
|
|
|
|
/// Steps in order; each reports once.
|
|
const STEPS: [&str; 13] = [
|
|
"visual", "themed", "preview", "source", "race", "language", "midtheme", "release", "asked",
|
|
"stale", "owner", "find", "look",
|
|
];
|
|
|
|
/// Empty and broken queries: errors, never CodeMirror's own panel.
|
|
const FIND: &str = r#"
|
|
(async () => {
|
|
const report = {};
|
|
for (const mode of ['source', 'visual']) {
|
|
await ely.open('a{b alpha\n', mode, TEXTS, 1, location.href);
|
|
const empty = { search: '' };
|
|
ely.find(empty);
|
|
ely.step({ spec: empty, forward: true });
|
|
ely.replace({ spec: empty, all: false });
|
|
for (const search of ['a{', '\\a']) ely.find({ search, regexp: true });
|
|
ely.unfind();
|
|
report[mode] = document.querySelectorAll('.cm-panels .cm-search').length;
|
|
await ely.open('\u{1F600} alpha\n', mode, TEXTS, 1, location.href);
|
|
ely.find({ search: '(?=.)', regexp: true });
|
|
ely.unfind();
|
|
}
|
|
await new Promise((done) => setTimeout(done, 300));
|
|
window.elyQuery({ request: JSON.stringify({ step: 'find', panels: report }), onSuccess: () => {}, onFailure: () => {} });
|
|
})();
|
|
"#;
|
|
|
|
/// The writing view: dim blocks, narrow serif column, centered caret.
|
|
const LOOK: &str = r#"
|
|
(async () => {
|
|
const paragraphs = Array.from({ length: 80 }, (_, n) => `Paragraph ${n + 1} of the look.`)
|
|
await ely.open(paragraphs.join('\n\n') + '\n', 'visual', TEXTS, 1, location.href)
|
|
ely.look({ focus: true, typewriter: true, width: 'narrow', font: 'serif', size: 120 })
|
|
ely.reveal({ line: 159, query: '' })
|
|
await new Promise((done) => setTimeout(done, 600))
|
|
const blocks = [...document.querySelectorAll('.milkdown .ProseMirror > *')]
|
|
const dim = blocks.filter((block) => getComputedStyle(block).opacity === '0.35').length
|
|
const bright = document.querySelector('.ely-current')
|
|
const caret = window.getSelection().getRangeAt(0).getBoundingClientRect()
|
|
const page = document.getElementById('document')
|
|
const report = {
|
|
step: 'look',
|
|
blocks: blocks.length,
|
|
dim,
|
|
bright: bright ? getComputedStyle(bright).opacity : 'none',
|
|
centered: Math.abs(caret.top - window.innerHeight / 2) < window.innerHeight / 4,
|
|
width: getComputedStyle(page).maxWidth,
|
|
serif: getComputedStyle(page).fontFamily.includes('serif'),
|
|
}
|
|
ely.look({ focus: false, typewriter: false, width: 'medium', font: 'sans', size: 100 })
|
|
window.elyQuery({ request: JSON.stringify(report), onSuccess: () => {}, onFailure: () => {} })
|
|
})();
|
|
"#;
|
|
|
|
/// Picks Rust in the first block's list, once mounted.
|
|
const TO_RUST: &str = r#"
|
|
(function pick() {
|
|
const block = document.querySelector('#document > div:not([hidden]) .milkdown-code-block');
|
|
const button = block?.querySelector('.language-button');
|
|
if (!button) return requestAnimationFrame(pick);
|
|
button.click();
|
|
setTimeout(() => [...block.querySelectorAll('.language-list-item')]
|
|
.find((item) => item.textContent.trim() === 'Rust').click(), 30);
|
|
})();
|
|
"#;
|
|
|
|
/// Says when a diagram waits, drawn not yet.
|
|
const PENDING: &str = r#"
|
|
(function wait() {
|
|
if (!document.querySelector('.ely-diagram:empty')) return requestAnimationFrame(wait);
|
|
new MutationObserver((_, watch) => {
|
|
const shape = document.querySelector('.ely-diagram svg .node rect');
|
|
if (!shape) return;
|
|
window.firstFill = getComputedStyle(shape).fill;
|
|
watch.disconnect();
|
|
}).observe(document.body, { childList: true, subtree: true });
|
|
window.elyQuery({ request: JSON.stringify({ kind: 'pending' }), onSuccess: () => {}, onFailure: () => {} });
|
|
})();
|
|
"#;
|
|
|
|
/// A diagram slow enough to change things mid-draw.
|
|
fn big() -> String {
|
|
let mut source = String::from("```mermaid\ngraph TD\n");
|
|
for at in 0..150 {
|
|
source += &format!(" N{at} --> N{}\n N{at} --> N{}\n", at + 1, (at * 7) % 151);
|
|
}
|
|
serde_json::to_string(&(source + "```\n")).expect("json")
|
|
}
|
|
|
|
/// Short sources, slow draws: blocks mount at once.
|
|
fn chains(blocks: usize) -> String {
|
|
let mut source = String::new();
|
|
for block in 0..blocks {
|
|
let side = |name: &str| {
|
|
let nodes: Vec<String> = (0..20).map(|at| format!("{name}{block}x{at}")).collect();
|
|
nodes.join(" & ")
|
|
};
|
|
source += &format!(
|
|
"```mermaid\ngraph LR\n{} --> {}\n```\n\n",
|
|
side("A"),
|
|
side("B")
|
|
);
|
|
}
|
|
serde_json::to_string(&source).expect("json")
|
|
}
|
|
|
|
/// Leaves for source mid-draw; counts long stalls after.
|
|
const RELEASE: &str = r#"
|
|
(async () => {
|
|
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
await ely.open(BIG, 'preview', TEXTS, 1, location.href);
|
|
await sleep(50);
|
|
await ely.open('x', 'source', TEXTS, 1, location.href);
|
|
let last = performance.now();
|
|
let long = 0;
|
|
const tick = setInterval(() => {
|
|
const now = performance.now();
|
|
if (now - last > 500) long += 1;
|
|
last = now;
|
|
}, 20);
|
|
await sleep(7000);
|
|
clearInterval(tick);
|
|
window.elyQuery({ request: JSON.stringify({ step: 'release', long }), onSuccess: () => {}, onFailure: () => {} });
|
|
})();
|
|
"#;
|
|
|
|
fn flip(cx: &mut App) {
|
|
let mode = match cx.theme().is_dark() {
|
|
true => Mode::Light,
|
|
false => Mode::Dark,
|
|
};
|
|
Theme::set_mode_now(mode, cx);
|
|
}
|
|
|
|
struct Probe {
|
|
view: Entity<WebView>,
|
|
step: usize,
|
|
/// A fresh page: Mermaid's bundle not yet loaded.
|
|
fresh: bool,
|
|
/// Find reports that carried an error, and every total.
|
|
errors: usize,
|
|
totals: Vec<u64>,
|
|
}
|
|
|
|
impl Probe {
|
|
/// Starts the current step; its check follows.
|
|
fn begin(&mut self, cx: &mut Context<Self>) {
|
|
let markdown =
|
|
serde_json::to_string(include_str!("../../../tests/fixtures/mark/extended.md"))
|
|
.expect("json");
|
|
let step = STEPS[self.step];
|
|
match step {
|
|
"themed" => {
|
|
flip(cx);
|
|
self.check(step, 2500, cx);
|
|
}
|
|
// Two quick shows: only the last may stay.
|
|
"race" => {
|
|
self.view.read(cx).run_js(&format!(
|
|
"ely.open('# OLD', 'visual', {TEXTS}, 1, location.href); \
|
|
ely.open('# NEW', 'source', {TEXTS}, 1, location.href)"
|
|
));
|
|
self.check(step, 2500, cx);
|
|
}
|
|
// Two modes asked at once: the last one shows.
|
|
"asked" => {
|
|
self.view.read(cx).run_js(&format!(
|
|
"ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \
|
|
.then(() => {{ ely.mode('visual'); ely.mode('preview') }})"
|
|
));
|
|
self.check(step, 3000, cx);
|
|
}
|
|
// A request while visual's shown is unanswered wins.
|
|
"stale" => {
|
|
self.view.read(cx).run_js(&format!(
|
|
"ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \
|
|
.then(() => {{ ely.mode('visual'); ely.mode('preview') }})"
|
|
));
|
|
self.check(step, 3000, cx);
|
|
}
|
|
// An old answer leaves a newer build's request alone.
|
|
"owner" => {
|
|
self.view.read(cx).run_js(&format!(
|
|
"ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \
|
|
.then(() => ely.mode('visual'))"
|
|
));
|
|
self.check(step, 3000, cx);
|
|
}
|
|
"find" => self.view.read(cx).run_js(&FIND.replace("TEXTS", TEXTS)),
|
|
"look" => self.view.read(cx).run_js(&LOOK.replace("TEXTS", TEXTS)),
|
|
"release" => {
|
|
let script = RELEASE.replace("BIG", &chains(3)).replace("TEXTS", TEXTS);
|
|
self.view.read(cx).run_js(&script);
|
|
}
|
|
// Changes land while the bundle still loads.
|
|
"language" | "midtheme" if !self.fresh => {
|
|
self.fresh = true;
|
|
self.view.read(cx).run_js("location.reload()");
|
|
}
|
|
"language" | "midtheme" => {
|
|
self.fresh = false;
|
|
let show = format!("ely.open({}, 'visual', {TEXTS}, 1, location.href)", big());
|
|
self.view.read(cx).run_js(&show);
|
|
}
|
|
mode => self.view.read(cx).run_js(&format!(
|
|
"ely.open({markdown}, '{mode}', {TEXTS}, 1, location.href)"
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Checks after `wait`, when drawing is done.
|
|
fn check(&self, step: &'static str, wait: u64, cx: &mut Context<Self>) {
|
|
let view = self.view.clone();
|
|
cx.spawn(async move |_, cx| {
|
|
cx.background_executor()
|
|
.timer(Duration::from_millis(wait))
|
|
.await;
|
|
view.update(cx, |view, _| view.run_js(&CHECK.replace("STEP", step)));
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn heard(
|
|
&mut self,
|
|
_: &Entity<WebView>,
|
|
event: &Event,
|
|
_: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match event {
|
|
Event::Loaded => self.begin(cx),
|
|
Event::Query(said, reply) => {
|
|
let read: serde_json::Value = serde_json::from_str(said).expect("json");
|
|
// Some answers wait; meanwhile newer modes are asked.
|
|
let (wait, then) = match (STEPS.get(self.step), read["mode"].as_str()) {
|
|
(Some(&"stale"), Some("visual")) => (300, "ely.mode('source')"),
|
|
(Some(&"owner"), Some("visual")) => {
|
|
(300, "ely.mode('preview'); ely.mode('source')")
|
|
}
|
|
(Some(&"owner"), Some("preview")) => (600, ""),
|
|
_ => (0, ""),
|
|
};
|
|
if !then.is_empty() {
|
|
self.view.read(cx).run_js(then);
|
|
}
|
|
let reply = reply.clone();
|
|
cx.spawn(async move |_, cx| {
|
|
cx.background_executor()
|
|
.timer(Duration::from_millis(wait))
|
|
.await;
|
|
reply.ok("");
|
|
})
|
|
.detach();
|
|
if read["kind"] == "shown" {
|
|
println!("mark: shown {}", read["mode"]);
|
|
let Some(&step) = STEPS.get(self.step) else {
|
|
return;
|
|
};
|
|
// Mid-draw: a new language, or a new theme.
|
|
match step {
|
|
"language" => self.view.read(cx).run_js(TO_RUST),
|
|
"midtheme" => return self.view.read(cx).run_js(PENDING),
|
|
_ if step == read["mode"] => {}
|
|
_ => return,
|
|
}
|
|
self.check(step, 5000, cx);
|
|
return;
|
|
}
|
|
if read["kind"] == "found" {
|
|
self.errors += read["error"].is_string() as usize;
|
|
self.totals.extend(read["total"].as_u64());
|
|
return;
|
|
}
|
|
// Edits report too; the session tests them.
|
|
if ["changed", "text", "outline", "place"]
|
|
.contains(&read["kind"].as_str().unwrap_or_default())
|
|
{
|
|
return;
|
|
}
|
|
if read["kind"] == "pending" {
|
|
// Its draw takes seconds; flip once it has begun.
|
|
println!("mark: pending");
|
|
cx.spawn(async move |this, cx| {
|
|
cx.background_executor()
|
|
.timer(Duration::from_millis(600))
|
|
.await;
|
|
this.update(cx, |probe, cx| {
|
|
flip(cx);
|
|
probe.check("midtheme", 6000, cx);
|
|
})
|
|
.ok();
|
|
})
|
|
.detach();
|
|
return;
|
|
}
|
|
let mut read = read;
|
|
if read["step"] == "find" {
|
|
read["errors"] = self.errors.into();
|
|
read["totals"] = self.totals.clone().into();
|
|
}
|
|
println!("mark: {read}");
|
|
self.step += 1;
|
|
if self.step == STEPS.len() {
|
|
println!("mark: done");
|
|
return cx.quit();
|
|
}
|
|
// The first view stays a while for the window's pixels.
|
|
let wait = if self.step == 1 { 3000 } else { 0 };
|
|
cx.spawn(async move |this, cx| {
|
|
cx.background_executor()
|
|
.timer(Duration::from_millis(wait))
|
|
.await;
|
|
this.update(cx, |probe, cx| probe.begin(cx)).ok();
|
|
})
|
|
.detach();
|
|
}
|
|
Event::Leave(url) => println!("mark: leave {url}"),
|
|
Event::Printed(path, ok) => println!("mark: printed {} {ok}", path.display()),
|
|
Event::Failed(why) => {
|
|
println!("mark: failed {why}");
|
|
cx.quit();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Render for Probe {
|
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
|
div().size_full().child(self.view.clone())
|
|
}
|
|
}
|
|
|
|
fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<ely_ui::Root> {
|
|
let work = tempfile::tempdir().expect("a temp dir").keep();
|
|
ely_web_content::start(&work, cx).expect("CEF starts");
|
|
let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark");
|
|
let view = WebView::open(&content.join("index.html"), &content, window, cx).expect("a view");
|
|
let probe = cx.new(|cx| {
|
|
cx.subscribe_in(&view, window, Probe::heard).detach();
|
|
Probe {
|
|
view,
|
|
step: 0,
|
|
fresh: false,
|
|
errors: 0,
|
|
totals: Vec::new(),
|
|
}
|
|
});
|
|
ely_ui::root(app, Some(probe.into()), window, cx)
|
|
}
|
|
|
|
fn main() {
|
|
if let Some(code) = ely_web_content::subprocess() {
|
|
std::process::exit(code);
|
|
}
|
|
ely_ui::run(AppId::Mark, open);
|
|
}
|