feat(code-runner): CodeRunner 挂载 CodeMirror 编辑器,支持双向绑定
兑现组件一直承诺但无人实现的「调用方挂载编辑器」契约——此前容器 div 只 输出 id,admin/runner 沙箱与文章正文 runnable 块都只能提交写死的初始源码, 作者/读者无法编辑。 - CodeRunner 在 WASM 端按自身 container_id 调 codemirror_bridge.create 挂载编辑器,onChange 回写内部 source_signal(编辑器内容 = 唯一真源) - run_code 改读 source_signal 当前值,提交编辑后内容而非初始快照 - 主题切换经 use_resolved_theme 订阅,热切换编辑器主题 - use_drop 销毁 EditorHandle,释放 CodeMirror 实例与 closure 槽位 - source prop 外部变更(admin 切换语言重置示例)同步进 signal + 编辑器 - admin/runner 页面 overrides 解析挪进 use_memo,避免 render 体写状态 范式镜像 SQL 控制台(system.rs)与 Tiptap 编辑器(write.rs)。
This commit is contained in:
parent
58f1a981d3
commit
575b6b533e
@ -1,4 +1,9 @@
|
|||||||
//! CodeRunner 组件实现:源码 + 运行按钮 + 轮询 + 输出区。
|
//! CodeRunner 组件实现:源码 + 运行按钮 + 轮询 + 输出区。
|
||||||
|
//!
|
||||||
|
//! 编辑器挂载:组件在 WASM 端按自身 `container_id` 调用
|
||||||
|
//! [`crate::codemirror_bridge::get_module().create`] 挂载 CodeMirror,`onChange`
|
||||||
|
//! 回写到内部 `source_signal`,`use_drop` 时销毁实例。范式镜像 SQL 控制台
|
||||||
|
//!(`src/pages/admin/system.rs`)与 Tiptap 编辑器(`src/pages/admin/write.rs`)。
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
@ -15,7 +20,7 @@ const MAX_POLLS: u32 = 240; // 500ms * 240 = 120s 上限
|
|||||||
/// 代码运行器组件。
|
/// 代码运行器组件。
|
||||||
///
|
///
|
||||||
/// Props:
|
/// Props:
|
||||||
/// - `source`:初始源码(用于展示与提交)。
|
/// - `source`:初始源码(首次挂载用于初始化编辑器;之后编辑器内容是唯一真源)。
|
||||||
/// - `language`:语言标识(python / node 等)。
|
/// - `language`:语言标识(python / node 等)。
|
||||||
/// - `overrides`:可选资源限制覆盖。
|
/// - `overrides`:可选资源限制覆盖。
|
||||||
///
|
///
|
||||||
@ -34,21 +39,98 @@ pub fn CodeRunner(
|
|||||||
let mut exit_info = use_signal(String::new);
|
let mut exit_info = use_signal(String::new);
|
||||||
let mut error_msg = use_signal(String::new);
|
let mut error_msg = use_signal(String::new);
|
||||||
|
|
||||||
// 为每个实例生成稳定的容器 id(CodeMirror 容器,由调用方在 WASM 端挂载)。
|
// 编辑器内容的唯一真源;初始化为 prop 值。
|
||||||
// use_id 在 Dioxus 0.7 提供 scope 内稳定的 id;退回 use_hook 保证只算一次。
|
let mut source_signal = use_signal(|| source.clone());
|
||||||
let container_id = use_hook(|| {
|
|
||||||
format!(
|
// 为每个实例生成稳定的容器 id(CodeMirror 容器,由本组件在 WASM 端挂载)。
|
||||||
"code-runner-{}",
|
// use_hook 保证只算一次;后缀用时间戳+原子计数避免同页多实例 id 冲突。
|
||||||
// 简单用一次性随机后缀;非安全场景,避免额外依赖。
|
let container_id = use_hook(|| format!("code-runner-{}", now_pseudo_unique()));
|
||||||
now_pseudo_unique()
|
|
||||||
)
|
// —— CodeMirror 挂载(仅 WASM)——
|
||||||
|
// 范式镜像 src/pages/admin/system.rs 的 SQL 控制台与 src/pages/admin/write.rs 的 Tiptap。
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
use crate::codemirror_bridge;
|
||||||
|
use crate::theme::{use_resolved_theme, ResolvedTheme};
|
||||||
|
use wasm_bindgen::closure::Closure;
|
||||||
|
|
||||||
|
let mut editor_handle: Signal<Option<codemirror_bridge::EditorHandle>> =
|
||||||
|
use_signal(|| None);
|
||||||
|
|
||||||
|
// 首次挂载:构造 closure + options,create 后存进 editor_handle。
|
||||||
|
// 用 resolved() 读取主题作为初始值(同时订阅,但主题切换由下方独立 effect 处理)。
|
||||||
|
let mount_language = language.clone();
|
||||||
|
let mount_container_id = container_id.clone();
|
||||||
|
use_effect(move || {
|
||||||
|
if editor_handle.read().is_some() {
|
||||||
|
return; // 防重复 init
|
||||||
|
}
|
||||||
|
|
||||||
|
// onChange 回写到 source_signal(编辑器内容 = 唯一真源)。
|
||||||
|
let on_change = Closure::new({
|
||||||
|
let mut sig = source_signal;
|
||||||
|
move |v: String| sig.set(v)
|
||||||
|
});
|
||||||
|
let on_ready = Closure::new(|| {});
|
||||||
|
|
||||||
|
let resolved = use_resolved_theme();
|
||||||
|
let theme_name = if resolved() == ResolvedTheme::Dark {
|
||||||
|
"dark"
|
||||||
|
} else {
|
||||||
|
"light"
|
||||||
|
};
|
||||||
|
|
||||||
|
let opts = codemirror_bridge::EditorOptions::new();
|
||||||
|
opts.set_language(&mount_language);
|
||||||
|
opts.set_theme(theme_name);
|
||||||
|
opts.set_value(&source_signal.read());
|
||||||
|
opts.set_on_change(&on_change);
|
||||||
|
opts.set_on_ready(&on_ready);
|
||||||
|
|
||||||
|
if let Ok(Some(inst)) =
|
||||||
|
codemirror_bridge::get_module().create(&mount_container_id, &opts)
|
||||||
|
{
|
||||||
|
let handle =
|
||||||
|
codemirror_bridge::EditorHandle::new(inst, on_change, on_ready);
|
||||||
|
editor_handle.set(Some(handle));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 提前克隆一份给 run 闭包使用,避免 move 闭包抢走 rsx! 仍要读的 source/language。
|
// 主题切换(含 System 模式下系统偏好变化)时同步编辑器主题。
|
||||||
let run_source = source.clone();
|
use_effect(move || {
|
||||||
|
let r = use_resolved_theme();
|
||||||
|
if let Some(h) = editor_handle.read().as_ref() {
|
||||||
|
h.instance()
|
||||||
|
.set_theme(if r() == ResolvedTheme::Dark {
|
||||||
|
"dark"
|
||||||
|
} else {
|
||||||
|
"light"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// source prop 外部变更(如 admin 页面切换语言重置示例代码)同步到
|
||||||
|
// signal + 编辑器。读取 source() 订阅其变化;编辑器用户输入经 on_change
|
||||||
|
// 写回 signal,但不会改变 prop(单向),故不会形成回环。
|
||||||
|
let sync_source = source.clone();
|
||||||
|
use_effect(move || {
|
||||||
|
if sync_source != *source_signal.read() {
|
||||||
|
source_signal.set(sync_source.clone());
|
||||||
|
if let Some(h) = editor_handle.read().as_ref() {
|
||||||
|
h.instance().set_value(&sync_source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组件卸载时销毁 CodeMirror 实例(EditorHandle::drop → instance.destroy)。
|
||||||
|
use_drop(move || {
|
||||||
|
editor_handle.set(None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— run_code:同步段取 signal 当前值,move 进 spawn ——
|
||||||
let run_language = language.clone();
|
let run_language = language.clone();
|
||||||
let run_overrides = overrides.clone();
|
let run_overrides = overrides.clone();
|
||||||
|
|
||||||
let run_code = move |_| {
|
let run_code = move |_| {
|
||||||
if running() {
|
if running() {
|
||||||
return;
|
return;
|
||||||
@ -59,9 +141,11 @@ pub fn CodeRunner(
|
|||||||
exit_info.set(String::new());
|
exit_info.set(String::new());
|
||||||
error_msg.set(String::new());
|
error_msg.set(String::new());
|
||||||
|
|
||||||
|
// 不能跨 await 持有 signal borrow,先 clone 出 owned 值。
|
||||||
|
let run_source = source_signal.read().clone();
|
||||||
let req = ExecRequest {
|
let req = ExecRequest {
|
||||||
language: run_language.clone(),
|
language: run_language.clone(),
|
||||||
source: run_source.clone(),
|
source: run_source,
|
||||||
overrides: run_overrides.clone(),
|
overrides: run_overrides.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -141,13 +225,11 @@ pub fn CodeRunner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// CodeMirror 容器:调用方(阅读器扫描 / 后台试运行)在 WASM 端按此 id 挂载编辑器。
|
// CodeMirror 容器:组件自身在 WASM 端按此 id 挂载编辑器。
|
||||||
// 服务端渲染时仅展示源码文本,避免在 SSR 阶段拉起 JS 编辑器。
|
// 服务端渲染时仅展示空容器,避免在 SSR 阶段拉起 JS 编辑器。
|
||||||
div {
|
div {
|
||||||
id: "{container_id}",
|
id: "{container_id}",
|
||||||
class: "code-runner-editor min-h-[100px] font-mono text-sm",
|
class: "code-runner-editor min-h-[100px] font-mono text-sm",
|
||||||
// 源码以 data 属性承载,供挂载脚本读取初始化(避免 SSR 时把源码当 HTML 解析)。
|
|
||||||
"data-source": "{source}",
|
|
||||||
}
|
}
|
||||||
if !output().is_empty() {
|
if !output().is_empty() {
|
||||||
div { class: "output-area bg-neutral text-neutral-content p-3 rounded-lg m-2 font-mono text-xs whitespace-pre-wrap break-all",
|
div { class: "output-area bg-neutral text-neutral-content p-3 rounded-lg m-2 font-mono text-xs whitespace-pre-wrap break-all",
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 作者在写作时可在此沙箱快速试运行代码(验证围栏 ` ```lang runnable ` 的预期输出),
|
//! 作者在写作时可在此沙箱快速试运行代码(验证围栏 ` ```lang runnable ` 的预期输出),
|
||||||
//! 而无需进入文章渲染后才能运行。沙箱使用与读者相同的 StartExec / GetExecResult
|
//! 而无需进入文章渲染后才能运行。沙箱使用与读者相同的 StartExec / GetExecResult
|
||||||
//! 接口,受同一套资源钳制与速率限制约束。
|
//! 接口,受同一套资源钳制约束(admin 跳过速率限制,见 `start_exec`)。
|
||||||
//!
|
//!
|
||||||
//! 仅 WASM 前端交互;语言在受支持集合内切换。
|
//! 仅 WASM 前端交互;语言在受支持集合内切换。
|
||||||
|
|
||||||
@ -32,34 +32,30 @@ pub fn Runner() -> Element {
|
|||||||
// 语言切换时刷新示例源码(首次进入也有默认值)。
|
// 语言切换时刷新示例源码(首次进入也有默认值)。
|
||||||
let mut source = use_signal(|| default_source("python"));
|
let mut source = use_signal(|| default_source("python"));
|
||||||
let mut overrides_json = use_signal(String::new);
|
let mut overrides_json = use_signal(String::new);
|
||||||
let mut override_error = use_signal(String::new);
|
|
||||||
|
|
||||||
let current_source = source();
|
// overrides 解析用 use_memo 承载:render 体只读不写(Dioxus render purity),
|
||||||
let current_lang = lang();
|
// 避免 render 期间 .set() override_error。畸形 JSON 标记在 memo 返回值里。
|
||||||
let overrides = match serde_json::from_str::<ResourceLimits>(overrides_json().trim()) {
|
let parsed = use_memo(move || {
|
||||||
Ok(o) => {
|
let raw = overrides_json();
|
||||||
// 解析成功时清掉错误。
|
match serde_json::from_str::<ResourceLimits>(raw.trim()) {
|
||||||
override_error.set(String::new());
|
Ok(o) => (Some(o), String::new()),
|
||||||
Some(o)
|
|
||||||
}
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// 空字符串视为 None(不报错);非空且畸形才提示。
|
if raw.trim().is_empty() {
|
||||||
if overrides_json().trim().is_empty() {
|
(None, String::new())
|
||||||
override_error.set(String::new());
|
|
||||||
None
|
|
||||||
} else {
|
} else {
|
||||||
override_error.set("overrides JSON 格式错误,已忽略".to_string());
|
(None, "overrides JSON 格式错误,已忽略".to_string())
|
||||||
None
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
});
|
||||||
|
let (overrides, override_error) = (parsed.read().0.clone(), parsed.read().1.clone());
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "flex flex-col gap-6 max-w-4xl mx-auto w-full",
|
div { class: "flex flex-col gap-6 max-w-4xl mx-auto w-full",
|
||||||
div { class: "flex flex-col gap-2",
|
div { class: "flex flex-col gap-2",
|
||||||
h1 { class: "text-3xl font-extrabold tracking-tight", "代码试运行沙箱" }
|
h1 { class: "text-3xl font-extrabold tracking-tight", "代码试运行沙箱" }
|
||||||
p { class: "text-base text-base-content/60",
|
p { class: "text-base text-base-content/60",
|
||||||
"在此快速试运行代码,验证文章中可运行代码块的预期输出。受同一套资源钳制与速率限制约束。"
|
"在此快速试运行代码,验证文章中可运行代码块的预期输出。资源钳制与读者侧一致,速率限制对 admin 放行。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,15 +92,15 @@ pub fn Runner() -> Element {
|
|||||||
value: "{overrides_json()}",
|
value: "{overrides_json()}",
|
||||||
oninput: move |e| overrides_json.set(e.value()),
|
oninput: move |e| overrides_json.set(e.value()),
|
||||||
}
|
}
|
||||||
if !override_error().is_empty() {
|
if !override_error.is_empty() {
|
||||||
span { class: "text-xs text-error", "{override_error()}" }
|
span { class: "text-xs text-error", "{override_error}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CodeRunner {
|
CodeRunner {
|
||||||
source: current_source,
|
source: source(),
|
||||||
language: current_lang,
|
language: lang(),
|
||||||
overrides: overrides,
|
overrides: overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user