Compare commits
8 Commits
ff2694c5a3
...
2f861d3e6a
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f861d3e6a | |||
| 71014aa0de | |||
| 5fc8a9aeeb | |||
| a9b194f3bf | |||
| d48b32b40e | |||
| 66d29eaa62 | |||
| 198a9b292a | |||
| 8971fbe1a2 |
@ -40,7 +40,7 @@ futures = { version = "0.3", optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "File", "FormData", "Request", "RequestInit", "Response", "Headers"] }
|
||||
web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "File", "FileList", "FormData", "Request", "RequestInit", "Response", "Headers", "ClipboardEvent", "DataTransfer", "HtmlInputElement"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
|
||||
124
input.css
124
input.css
@ -526,10 +526,13 @@
|
||||
text-underline-offset: 0.3rem;
|
||||
}
|
||||
|
||||
/* 卡片封面:blur-img 结构,固定 4:3 比例,hover 缩放 */
|
||||
.post-card-cover-blur {
|
||||
/* 卡片封面:blur-img 结构,固定 21:9 比例,hover 缩放。
|
||||
双类选择器提升特异性,可靠覆盖 .blur-img 的 aspect-ratio: var(--ar)
|
||||
(卡片不写 --ar,var(--ar) 解析为空会导致高度塌陷)。
|
||||
21:9 与后台编辑器封面区统一比例,宽屏横幅式。 */
|
||||
.blur-img.post-card-cover-blur {
|
||||
margin: 0;
|
||||
aspect-ratio: 4 / 3;
|
||||
aspect-ratio: 21 / 9;
|
||||
}
|
||||
.post-card-cover-blur img {
|
||||
width: 100%;
|
||||
@ -566,6 +569,65 @@
|
||||
.post-card-accent:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ========== Blur-up 渐进图片加载 ==========
|
||||
放在 @layer components 内,与 .blur-img.post-card-cover-blur 同层,
|
||||
这样特异性能正常生效(layer 外规则会无条件覆盖 layer 内规则,
|
||||
导致卡片的 aspect-ratio:4/3 败给全局 var(--ar))。 */
|
||||
.blur-img {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
aspect-ratio: var(--ar);
|
||||
/* 加载中灰底(占位图未加载完时) */
|
||||
background: var(--color-paper-code-bg, #f5f5f5);
|
||||
border-radius: 6px;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.blur-img-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* 重置 .md-content img 的 margin/max-width/height,避免把 absolute 图层推偏 */
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
/* 20px 占位图放大后模糊,scale 遮边缘 */
|
||||
filter: blur(20px) saturate(1.2);
|
||||
transform: scale(1.1);
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.blur-img-full {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* 重置 .md-content img 的 margin/max-width/height,避免把 absolute 图层推偏 */
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
/* full 加载完成后由 JS 直接设 opacity:1(不依赖 CSS transition,
|
||||
避免合成层重绘时机导致 opacity 卡在 0)。 */
|
||||
.blur-img-full.is-loaded,
|
||||
.blur-img.is-loaded .blur-img-full {
|
||||
opacity: 1;
|
||||
}
|
||||
/* 容器标记 loaded 后:显式隐藏占位图 + 去掉灰底,避免重绘时机导致残留/灰边 */
|
||||
.blur-img.is-loaded .blur-img-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
.blur-img.is-loaded {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 暗色模式灰底 */
|
||||
.dark .blur-img {
|
||||
background: var(--color-paper-code-bg, #2a2a2a);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@ -638,58 +700,4 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== Blur-up 渐进图片加载 ========== */
|
||||
.blur-img {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
aspect-ratio: var(--ar);
|
||||
/* 加载中灰底(占位图未加载完时) */
|
||||
background: var(--color-paper-code-bg, #f5f5f5);
|
||||
border-radius: 6px;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.blur-img-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* 重置 .md-content img 的 margin/max-width/height,避免把 absolute 图层推偏 */
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
/* 20px 占位图放大后模糊,scale 遮边缘 */
|
||||
filter: blur(20px) saturate(1.2);
|
||||
transform: scale(1.1);
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.blur-img-full {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* 重置 .md-content img 的 margin/max-width/height,避免把 absolute 图层推偏 */
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
/* full 加载完成后由 JS 直接设 opacity:1(不依赖 CSS transition,
|
||||
避免合成层重绘时机导致 opacity 卡在 0)。 */
|
||||
.blur-img-full.is-loaded,
|
||||
.blur-img.is-loaded .blur-img-full {
|
||||
opacity: 1;
|
||||
}
|
||||
/* 容器标记 loaded 后:显式隐藏占位图 + 去掉灰底,避免重绘时机导致残留/灰边 */
|
||||
.blur-img.is-loaded .blur-img-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
.blur-img.is-loaded {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 暗色模式灰底 */
|
||||
.dark .blur-img {
|
||||
background: var(--color-paper-code-bg, #2a2a2a);
|
||||
}
|
||||
/* ========== Blur-up 渐进图片加载(已移入 @layer components) ========== */
|
||||
|
||||
68
libs/lightbox/package-lock.json
generated
68
libs/lightbox/package-lock.json
generated
@ -14,29 +14,6 @@
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
@ -177,9 +154,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -197,9 +171,23 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.2.tgz",
|
||||
"integrity": "sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -217,9 +205,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -237,9 +222,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -257,9 +239,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -658,6 +637,7 @@
|
||||
"integrity": "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
@ -814,9 +794,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -838,9 +815,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -862,9 +836,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -886,9 +857,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1007,6 +975,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -1187,6 +1156,7 @@
|
||||
"integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
|
||||
@ -47,7 +47,7 @@ pub fn PostCard(post: PostListItem) -> Element {
|
||||
}
|
||||
img {
|
||||
class: "blur-img-full is-loaded",
|
||||
src: "{cover_src}?thumb=400x300",
|
||||
src: "{cover_src}?thumb=420x180",
|
||||
alt: "{post.title}",
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,14 +15,23 @@ use crate::api::posts::{
|
||||
create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse,
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::tiptap_bridge::{consume_upload_event, EditorHandle};
|
||||
use crate::tiptap_bridge::{consume_upload_event, upload_image_file, EditorHandle};
|
||||
// 共享上传状态类型:两端都编译(rsx 在 server SSR 时也要渲染这些结构)。
|
||||
use crate::tiptap_bridge::{UploadErrorEntry, UploadsInFlight};
|
||||
use crate::components::write_skeleton::WriteSkeleton;
|
||||
use crate::models::post::Post;
|
||||
use crate::router::Route;
|
||||
use crate::tiptap_bridge::{UploadErrorEntry, UploadsInFlight};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::closure::Closure;
|
||||
// 封面图上传:从 Dioxus 事件拿底层 web_sys::File,以及 paste 事件的原始 web 事件。
|
||||
// 这三个 trait 仅在 WASM 端可用(dioxus-web 提供),通过 dioxus 的 re-export 访问:
|
||||
// - HasFileData:evt.files()(FormEvent / DragEvent 取文件)— dioxus::html
|
||||
// - WebFileExt:file.get_web_file()(FileData 取底层 web_sys::File)— dioxus::web
|
||||
// - WebEventExt:evt.try_as_web_event()(ClipboardEvent 取原始 web 事件)— dioxus::web
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use dioxus::html::HasFileData;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use dioxus::web::{WebEventExt, WebFileExt};
|
||||
|
||||
/// 新建文章页面组件。
|
||||
///
|
||||
@ -62,6 +71,13 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
let mut cover_image = use_signal(|| "".to_string());
|
||||
let mut status = use_signal(|| "draft".to_string());
|
||||
let mut content = use_signal(|| "".to_string());
|
||||
// 封面图上传状态:uploading 进度态、错误消息、URL 输入框展开、拖拽高亮。
|
||||
let mut cover_uploading = use_signal(|| false);
|
||||
let mut cover_error = use_signal(|| None::<String>);
|
||||
let mut cover_url_mode = use_signal(|| false);
|
||||
let mut cover_drag_active = use_signal(|| false);
|
||||
// 封面 URL 输入框的临时值(确认前不直接写入 cover_image,避免半截 URL 触发预览加载)。
|
||||
let mut cover_url_input = use_signal(|| "".to_string());
|
||||
// 页面与编辑器加载、保存、错误、成功等状态。
|
||||
let mut loading = use_signal(|| true);
|
||||
let mut saving = use_signal(|| false);
|
||||
@ -181,13 +197,8 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
}
|
||||
editor_content_set.set(true);
|
||||
}
|
||||
let handle = EditorHandle::new(
|
||||
inst,
|
||||
on_update,
|
||||
on_image_upload,
|
||||
on_ready,
|
||||
on_upload_event,
|
||||
);
|
||||
let handle =
|
||||
EditorHandle::new(inst, on_update, on_image_upload, on_ready, on_upload_event);
|
||||
editor.set(Some(handle));
|
||||
}
|
||||
Ok(None) => {
|
||||
@ -216,21 +227,54 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
loading.set(false);
|
||||
});
|
||||
|
||||
// 封面图上传:spawn 一个 async 调用 upload_image_file。
|
||||
// 三条入口(file input / drop / paste)收敛成拿到 web_sys::File 后统一调用此闭包。
|
||||
// 仅在 WASM 端有意义(upload_image_file 与 spawn 都依赖 WASM 运行时),
|
||||
// server SSR 不渲染上传逻辑,故整体 cfg-gate 避免引用 wasm-only 符号。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let mut spawn_cover_upload = move |file: web_sys::File| {
|
||||
cover_uploading.set(true);
|
||||
cover_error.set(None);
|
||||
spawn(async move {
|
||||
match upload_image_file(file).await {
|
||||
Ok(url) => {
|
||||
cover_image.set(url);
|
||||
}
|
||||
Err(msg) => {
|
||||
cover_error.set(Some(msg));
|
||||
}
|
||||
}
|
||||
cover_uploading.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
// 提交表单:校验标题与内容,读取 Tiptap 编辑器 Markdown,调用 create_post 或 update_post。
|
||||
let on_submit = move |_| {
|
||||
// 上传未完成/失败拦截:有占位符时阻止保存
|
||||
let in_flight = uploads_in_flight.read();
|
||||
if in_flight.uploading > 0 || in_flight.error > 0 {
|
||||
let msg = if in_flight.uploading > 0 {
|
||||
format!("有 {} 张图片正在上传,请等待完成后再保存", in_flight.uploading)
|
||||
format!(
|
||||
"有 {} 张图片正在上传,请等待完成后再保存",
|
||||
in_flight.uploading
|
||||
)
|
||||
} else {
|
||||
format!("有 {} 张图片上传失败,请移除或重试后再保存", in_flight.error)
|
||||
format!(
|
||||
"有 {} 张图片上传失败,请移除或重试后再保存",
|
||||
in_flight.error
|
||||
)
|
||||
};
|
||||
error.set(Some(msg));
|
||||
return;
|
||||
}
|
||||
drop(in_flight);
|
||||
|
||||
// 封面图上传中拦截:防止保存半成品。
|
||||
if cover_uploading() {
|
||||
error.set(Some("封面图正在上传,请等待完成后再保存".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
if title().trim().is_empty() {
|
||||
error.set(Some("标题不能为空".to_string()));
|
||||
}
|
||||
@ -384,8 +428,13 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
// 顶部元信息区域 - 固定高度,不滚动
|
||||
div { class: "flex-shrink-0 space-y-5 pt-8",
|
||||
// 可滚动主体:元信息 + 编辑器 + 内联错误提示。
|
||||
// 底部操作栏留在此容器外(固定吸底),保证保存/状态按钮始终可见。
|
||||
// flex flex-col:让内部 flex-1 编辑器能撑满剩余高度;
|
||||
// overflow-y-auto:内容超出时滚动(min-h-0 允许 flex 子项收缩触发滚动)。
|
||||
div { class: "flex-1 min-h-0 overflow-y-auto flex flex-col",
|
||||
// 顶部元信息区域 - 固定高度,不滚动
|
||||
div { class: "flex-shrink-0 space-y-5 pt-8",
|
||||
// 标题区域 - 大字号无框输入
|
||||
div {
|
||||
input {
|
||||
@ -398,7 +447,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
|
||||
// 摘要
|
||||
textarea {
|
||||
class: "w-full text-base bg-transparent text-[var(--color-paper-secondary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none resize-none leading-relaxed",
|
||||
class: "w-full text-base bg-transparent text-[var(--color-paper-secondary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none resize-none leading-relaxed mb-0",
|
||||
placeholder: "摘要(留空则自动生成)",
|
||||
rows: "2",
|
||||
value: "{summary}",
|
||||
@ -429,22 +478,254 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
oninput: move |evt| tags.set(evt.value()),
|
||||
}
|
||||
}
|
||||
div { class: "flex-1 min-w-[140px]",
|
||||
label { class: "{META_LABEL_CLASS}",
|
||||
"封面图"
|
||||
}
|
||||
|
||||
// 封面图上传区:空态矮横条(不挤压编辑器),有图时展开成 21:9 超宽预览。
|
||||
// 21:9 与首页卡片封面统一比例,比 16:9 更扁,适合宽屏横幅式封面。
|
||||
// 容器统一绑定拖拽与粘贴事件;内部按 cover_image / cover_uploading 切换空态、上传中、预览。
|
||||
div {
|
||||
class: "relative w-full rounded-xl border border-dashed overflow-hidden transition-all duration-200 group/cover",
|
||||
// 空态矮横条;有图/上传中展开成 21:9。
|
||||
class: if cover_image().is_empty() && !cover_uploading() {
|
||||
"h-14"
|
||||
} else {
|
||||
"aspect-[21/9]"
|
||||
},
|
||||
class: if cover_drag_active() {
|
||||
"border-[var(--color-paper-accent)] bg-[var(--color-paper-accent-soft)]"
|
||||
} else if cover_image().is_empty() {
|
||||
"border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] hover:border-[var(--color-paper-accent)] hover:bg-[var(--color-paper-accent-soft)]"
|
||||
} else {
|
||||
"border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]"
|
||||
},
|
||||
|
||||
// 整个容器可接收拖拽与粘贴(ondragover 必须 prevent_default,否则浏览器直接打开文件)。
|
||||
ondragover: move |evt| {
|
||||
evt.prevent_default();
|
||||
if !cover_uploading() && cover_image().is_empty() {
|
||||
cover_drag_active.set(true);
|
||||
}
|
||||
},
|
||||
ondragenter: move |evt| {
|
||||
evt.prevent_default();
|
||||
},
|
||||
ondragleave: move |_| {
|
||||
cover_drag_active.set(false);
|
||||
},
|
||||
ondrop: move |evt| {
|
||||
evt.prevent_default();
|
||||
cover_drag_active.set(false);
|
||||
if !cover_uploading() && cover_image().is_empty() {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if let Some(file) = evt.files().into_iter().next() {
|
||||
if let Some(web_file) = file.get_web_file() {
|
||||
spawn_cover_upload(web_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onpaste: move |evt| {
|
||||
evt.prevent_default();
|
||||
if !cover_uploading() && cover_image().is_empty() {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use wasm_bindgen::JsCast;
|
||||
if let Some(raw) = evt.try_as_web_event() {
|
||||
if let Some(ce) = raw.dyn_ref::<web_sys::ClipboardEvent>() {
|
||||
if let Some(dt) = ce.clipboard_data() {
|
||||
if let Some(file_list) = dt.files() {
|
||||
if let Some(file) = file_list.item(0) {
|
||||
spawn_cover_upload(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// —— 上传中:骨架占位 + 文案 ——
|
||||
if cover_uploading() {
|
||||
div { class: "absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[var(--color-paper-tertiary)]/30 animate-pulse",
|
||||
span { class: "text-sm text-[var(--color-paper-secondary)]",
|
||||
"上传中..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// —— 有图:预览 + 移除/更换 ——
|
||||
if !cover_image().is_empty() && !cover_uploading() {
|
||||
// 预览图:/uploads/ 路径加 ?w=600 缩略,外链 URL 原样用。
|
||||
// 用条件表达式内联计算,避免 rsx 内 let 块(宏在 server 端解析受限)。
|
||||
img {
|
||||
class: "absolute inset-0 w-full h-full object-cover",
|
||||
src: {
|
||||
let cv = cover_image();
|
||||
if cv.starts_with("/uploads/") {
|
||||
let base = cv.split('?').next().unwrap_or(&cv);
|
||||
if cv.contains('?') { format!("{}&w=600", base) } else { format!("{}?w=600", base) }
|
||||
} else {
|
||||
cv
|
||||
}
|
||||
},
|
||||
alt: "封面预览",
|
||||
// 外链预览加载失败时提示,避免空白。
|
||||
onerror: move |_| {
|
||||
cover_error.set(Some("封面图加载失败,请检查 URL".to_string()));
|
||||
},
|
||||
}
|
||||
// 右上角移除按钮(hover 出现)。
|
||||
button {
|
||||
class: "absolute top-2 right-2 w-7 h-7 flex items-center justify-center rounded-full bg-black/50 text-white opacity-0 group-hover/cover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer",
|
||||
aria_label: "移除封面",
|
||||
onclick: move |_| {
|
||||
cover_image.set(String::new());
|
||||
cover_error.set(None);
|
||||
cover_url_mode.set(false);
|
||||
cover_url_input.set(String::new());
|
||||
},
|
||||
// 内联 SVG:关闭 X(与 header.rs 关闭按钮同风格,view_box 0 0 24 24)。
|
||||
svg {
|
||||
class: "w-4 h-4",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
view_box: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
stroke_width: "2",
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
path { d: "M6 6l12 12M6 18L18 6" }
|
||||
}
|
||||
}
|
||||
// 底部渐变遮罩 + "更换封面"提示(hover 出现)。
|
||||
div { class: "absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover/cover:opacity-100 transition-opacity flex items-end justify-center pb-2 pointer-events-none",
|
||||
span { class: "text-xs text-white/90",
|
||||
"点击下方区域可重新上传"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// —— 空态:横向矮横条,图标+提示+URL 链 ——
|
||||
if cover_image().is_empty() && !cover_uploading() {
|
||||
// label 包裹整个空态:点击天然触发隐藏的 file input,无需 JS。
|
||||
label { class: "absolute inset-0 flex flex-row items-center gap-3 cursor-pointer px-4 text-left",
|
||||
// 上传图标(Feather 风格线框,与项目现有图标体系一致)。
|
||||
svg {
|
||||
class: "w-5 h-5 shrink-0 text-[var(--color-paper-secondary)]",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
view_box: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
stroke_width: "1.8",
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
path { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }
|
||||
polyline { points: "17 8 12 3 7 8" }
|
||||
line {
|
||||
x1: "12",
|
||||
y1: "3",
|
||||
x2: "12",
|
||||
y2: "15",
|
||||
}
|
||||
}
|
||||
span { class: "text-sm text-[var(--color-paper-secondary)] shrink-0",
|
||||
"拖拽 · 点击 · 粘贴封面图"
|
||||
}
|
||||
// URL 文字链:阻止 label 的默认 file 触发,切换到 URL 输入模式。
|
||||
span {
|
||||
class: "text-[11px] font-medium tracking-wider text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-accent)] transition-colors ml-auto shrink-0",
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
evt.stop_propagation();
|
||||
cover_url_mode.set(true);
|
||||
cover_url_input.set(cover_image());
|
||||
},
|
||||
"或使用图片 URL"
|
||||
}
|
||||
// 隐藏的 file input,由 label 点击触发。
|
||||
input {
|
||||
r#type: "file",
|
||||
accept: "image/jpeg,image/png,image/gif,image/webp",
|
||||
class: "hidden",
|
||||
onchange: move |evt| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if let Some(file) = evt.files().into_iter().next() {
|
||||
if let Some(web_file) = file.get_web_file() {
|
||||
spawn_cover_upload(web_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 注意:未重置 input.value,重复选择同一文件不会再次触发 onchange。
|
||||
// 这是 file input 的通用行为,封面场景影响可忽略。
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// —— URL 输入模式(内联展开,空态时叠加在容器外,避免与拖拽区争抢点击)——
|
||||
if cover_url_mode() && cover_image().is_empty() {
|
||||
div { class: "flex items-center gap-2 mt-2",
|
||||
input {
|
||||
class: "{META_INPUT_CLASS}",
|
||||
placeholder: "URL(可选)",
|
||||
value: "{cover_image}",
|
||||
oninput: move |evt| cover_image.set(evt.value()),
|
||||
class: "flex-1 {META_INPUT_CLASS}",
|
||||
placeholder: "粘贴图片 URL",
|
||||
value: "{cover_url_input}",
|
||||
oninput: move |evt| cover_url_input.set(evt.value()),
|
||||
onkeydown: move |evt| {
|
||||
if evt.key() == Key::Enter {
|
||||
let v = cover_url_input().trim().to_string();
|
||||
if !v.is_empty() {
|
||||
cover_image.set(v);
|
||||
cover_error.set(None);
|
||||
cover_url_mode.set(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "shrink-0 px-3 py-1 text-xs text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer",
|
||||
onclick: move |_| {
|
||||
let v = cover_url_input().trim().to_string();
|
||||
if !v.is_empty() {
|
||||
cover_image.set(v);
|
||||
cover_error.set(None);
|
||||
cover_url_mode.set(false);
|
||||
}
|
||||
},
|
||||
"确认"
|
||||
}
|
||||
button {
|
||||
class: "shrink-0 px-3 py-1 text-xs text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer",
|
||||
onclick: move |_| {
|
||||
cover_url_mode.set(false);
|
||||
cover_url_input.set(String::new());
|
||||
},
|
||||
"取消"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 封面上传失败提示:复用页面红色条风格。
|
||||
if let Some(err) = cover_error() {
|
||||
div { class: "flex items-center justify-between gap-3 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl text-sm border border-red-100 dark:border-red-900/30 mt-2",
|
||||
span { "封面图: {err}" }
|
||||
button {
|
||||
class: "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none",
|
||||
aria_label: "关闭提示",
|
||||
onclick: move |_| cover_error.set(None),
|
||||
"×"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器区域 - 沾满剩余高度
|
||||
div { class: "flex-1 min-h-0 flex flex-col my-4",
|
||||
// 编辑器区域 - 沾满剩余高度,但保证最小编辑空间。
|
||||
// min-h-[400px]:窗口过矮时不被元信息挤压到不可用;flex-1:窗口充裕时填充。
|
||||
div { class: "flex-1 min-h-[400px] flex flex-col my-4",
|
||||
div {
|
||||
class: "flex-1 min-h-0 w-full border border-[var(--color-paper-border)] rounded-xl overflow-hidden bg-[var(--color-paper-entry)] shadow-[0_2px_8px_rgba(0,0,0,0.04)] dark:shadow-none",
|
||||
id: "tiptap-editor",
|
||||
@ -488,9 +769,10 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
} // 滚动主体容器闭合
|
||||
|
||||
// 底部操作栏 - 在编辑器下方,左对齐
|
||||
div { class: "flex-shrink-0 flex items-center gap-2 pt-2 pb-4",
|
||||
// 底部操作栏 - 固定吸底,不在滚动区域内
|
||||
div { class: "flex-shrink-0 flex items-center gap-2 pt-2 pb-4 border-t border-[var(--color-paper-border)]",
|
||||
button {
|
||||
class: "px-4 py-1.5 text-sm text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer",
|
||||
onclick: move |_| {
|
||||
|
||||
@ -244,69 +244,85 @@ pub mod wasm {
|
||||
|
||||
// —— make_upload_closure:Rust fetch 上传 ——
|
||||
|
||||
/// 创建图片上传 closure:FormData POST /api/upload,解析 {success, url, error}。
|
||||
/// 通用图片上传:FormData POST /api/upload,解析 {success, url, error}。
|
||||
///
|
||||
/// 行为与旧版 write.rs eval 注入的 JS fetch 等价:
|
||||
/// 由封面图上传(spawn 直接 await)与 Tiptap 编辑器上传(make_upload_closure 包 Promise)共用,
|
||||
/// 避免两处重复同一份 fetch + 解析逻辑。
|
||||
///
|
||||
/// 行为:
|
||||
/// - credentials: same-origin(携带 session cookie)
|
||||
/// - 字段名 'image'(与服务端 upload.rs 对齐)
|
||||
/// - 成功 → resolve(url);失败 → reject(Error(服务端中文 error))
|
||||
/// - 成功 → Ok(url);失败 → Err(服务端中文 error,或状态码兜底)
|
||||
///
|
||||
/// 构造阶段的错误(FormData/Request 等)以 Err 返回,而非 panic,
|
||||
/// 单张坏文件不应导致整个上传流程崩溃。
|
||||
pub async fn upload_image_file(file: web_sys::File) -> Result<String, String> {
|
||||
// 构造 FormData:字段名 'image' 与服务端 upload.rs 对齐
|
||||
let form = web_sys::FormData::new().map_err(|_| "无法构造上传表单".to_string())?;
|
||||
form.append_with_blob("image", &file)
|
||||
.map_err(|_| "无法附加文件".to_string())?;
|
||||
|
||||
// 构造 POST 请求,credentials same-origin 携带 session cookie
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_method("POST");
|
||||
// set_body 接收 &JsValue(非 Option);FormData: AsRef<JsValue>。
|
||||
init.set_body(form.as_ref());
|
||||
init.set_credentials(web_sys::RequestCredentials::SameOrigin);
|
||||
|
||||
let request = web_sys::Request::new_with_str_and_init("/api/upload", &init)
|
||||
.map_err(|_| "无法构造上传请求".to_string())?;
|
||||
|
||||
let window = web_sys::window().expect("no window");
|
||||
let promise = window.fetch_with_request(&request);
|
||||
|
||||
// fetch Promise → Future → 解析响应体
|
||||
let resp_val = wasm_bindgen_futures::JsFuture::from(promise)
|
||||
.await
|
||||
.map_err(|e| format!("上传请求失败: {:?}", e))?;
|
||||
let resp: web_sys::Response = resp_val
|
||||
.dyn_into()
|
||||
.map_err(|_| "上传响应类型异常".to_string())?;
|
||||
|
||||
// 读响应体文本(无论 2xx 与否,服务端都返回 JSON)
|
||||
let text_promise = resp
|
||||
.text()
|
||||
.map_err(|e| format!("读取响应失败: {:?}", e))?;
|
||||
let text_val = wasm_bindgen_futures::JsFuture::from(text_promise)
|
||||
.await
|
||||
.map_err(|e| format!("读取响应失败: {:?}", e))?;
|
||||
let text = text_val.as_string().unwrap_or_default();
|
||||
|
||||
// 解析 {success, url, error}
|
||||
let data: serde_json::Value =
|
||||
serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
if data["success"].as_bool() == Some(true) {
|
||||
let url = data["url"].as_str().unwrap_or("");
|
||||
if !url.is_empty() {
|
||||
Ok(url.to_string())
|
||||
} else {
|
||||
// success=true 但 url 为空:服务端契约异常,按失败处理
|
||||
Err("上传成功但未返回图片地址".to_string())
|
||||
}
|
||||
} else {
|
||||
// 失败:优先用服务端中文 error,兜底用状态码
|
||||
Err(data["error"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("上传失败: {}", resp.status())))
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 Tiptap 图片上传 closure:内部复用 [`upload_image_file`],包装成 JS Promise。
|
||||
///
|
||||
/// 返回的 closure 签名 `(File) -> Promise` 对应 JS `onImageUpload`。
|
||||
/// 构造阶段的错误(FormData/Request 等)以 rejected Promise 返回,
|
||||
/// 而非 panic —— 单张坏文件不应导致整个编辑器崩溃。
|
||||
pub fn make_upload_closure() -> Closure<dyn Fn(web_sys::File) -> js_sys::Promise> {
|
||||
Closure::new(move |file: web_sys::File| -> js_sys::Promise {
|
||||
wasm_bindgen_futures::future_to_promise(async move {
|
||||
// 构造 FormData:字段名 'image' 与服务端 upload.rs 对齐
|
||||
let form = web_sys::FormData::new()
|
||||
.map_err(|_| js_sys::Error::new("无法构造上传表单"))?;
|
||||
form.append_with_blob("image", &file)
|
||||
.map_err(|_| js_sys::Error::new("无法附加文件"))?;
|
||||
|
||||
// 构造 POST 请求,credentials same-origin 携带 session cookie
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_method("POST");
|
||||
// set_body 接收 &JsValue(非 Option);FormData: AsRef<JsValue>。
|
||||
init.set_body(form.as_ref());
|
||||
init.set_credentials(web_sys::RequestCredentials::SameOrigin);
|
||||
|
||||
let request = web_sys::Request::new_with_str_and_init("/api/upload", &init)
|
||||
.map_err(|_| js_sys::Error::new("无法构造上传请求"))?;
|
||||
|
||||
let window = web_sys::window().expect("no window");
|
||||
let promise = window.fetch_with_request(&request);
|
||||
|
||||
// 把 fetch Promise → Future → 解析响应体 → 再包回 Promise
|
||||
let resp_val = wasm_bindgen_futures::JsFuture::from(promise).await?;
|
||||
let resp: web_sys::Response = resp_val.dyn_into()?;
|
||||
|
||||
// 读响应体文本(无论 2xx 与否,服务端都返回 JSON)
|
||||
let text_promise = resp
|
||||
.text()
|
||||
.map_err(|e| js_sys::Error::new(&format!("读取响应失败: {:?}", e)))?;
|
||||
let text_val = wasm_bindgen_futures::JsFuture::from(text_promise).await?;
|
||||
let text = text_val.as_string().unwrap_or_default();
|
||||
|
||||
// 解析 {success, url, error}
|
||||
let data: serde_json::Value =
|
||||
serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
if data["success"].as_bool() == Some(true) {
|
||||
let url = data["url"].as_str().unwrap_or("");
|
||||
if !url.is_empty() {
|
||||
Ok(js_sys::JsString::from(url).into())
|
||||
} else {
|
||||
// success=true 但 url 为空:服务端契约异常,按失败处理
|
||||
Err(js_sys::Error::new("上传成功但未返回图片地址").into())
|
||||
}
|
||||
} else {
|
||||
// 失败:优先用服务端中文 error,兜底用状态码
|
||||
let msg = data["error"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("上传失败: {}", resp.status()));
|
||||
Err(js_sys::Error::new(&msg).into())
|
||||
}
|
||||
upload_image_file(file)
|
||||
.await
|
||||
.map(|url| js_sys::JsString::from(url).into())
|
||||
.map_err(|msg| js_sys::Error::new(&msg).into())
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -314,6 +330,6 @@ pub mod wasm {
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use wasm::{
|
||||
consume_upload_event, make_upload_closure, EditorHandle, EditorOptions, UploadEventJs,
|
||||
get_module,
|
||||
consume_upload_event, make_upload_closure, upload_image_file, EditorHandle, EditorOptions,
|
||||
UploadEventJs, get_module,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user