v0.10.7 首页 H4 五卡组 1:1:eael-info-box 完整结构/WidgetNode 双层容器/列 margin 折叠/heading link/kit-spacing 20/image inline-block,sec.h 与原站一致(529.6)

This commit is contained in:
BigCode
2026-11-01 02:42:56 +08:00
parent 37b71ff230
commit 544cbdc5f6
5 changed files with 258 additions and 41 deletions
+145 -37
View File
@@ -72,6 +72,26 @@ function collapseBoxValues(v: string): string {
return v;
}
/** # 函数 Elementor 背景位置词转 CSS 百分比(bottom center → 50% 100%) */
function mapBgPosition(v: string): string {
const parts = v.trim().split(/\s+/).filter(Boolean);
const to = (w: string): string | null => {
switch (w) {
case "top": return "0%";
case "center": return "50%";
case "bottom": return "100%";
case "left": return "0%";
case "right": return "100%";
default: return null;
}
};
// 顺序统一为 [x, y]
const [xs, ys] = parts.length === 2 && ["top", "bottom"].includes(parts[0]!) ? [parts[1]!, parts[0]!] : [parts[0]!, parts[1]!];
const x = to(xs) ?? xs;
const y = to(ys) ?? ys;
return `${x} ${y}`;
}
/** # 函数 解析 Typography 组合样式(typography_typography=custom 时的 typography_* 字段)
* 覆盖 font-family/size/weight/line-height/letter-spacing/word-spacing/text-transform/
* font-style/text-decoration(凡 settings 中有定义的值写入内联样式),
@@ -318,51 +338,98 @@ function SectionNode({ el }: { el: ElElement }) {
);
}
/** # 组件 column:按 _inline_size 百分比设置列宽 */
/** # 组件 column:双层对应原站 .elementor-column(外层)+ .elementor-element-populated(内层)
* 外层参与 flex 布局(按 _inline_size 百分比否则均分),不承载盒样式;
* 内层承载 populated 的盒样式:_margin(margin,移动卡片间距且不影响 flex 宽度,与原站一致)、
* padding、背景、边框、圆角。
*/
function ColumnNode({ el }: { el: ElElement }) {
const settings = resolveGlobals(el.settings || {});
// 未声明列宽时按 Elementor 语义均分(flex-basis 0),避免固定 240px 底宽把多列挤成换行
const style: CSSProperties = { flex: "1 1 0", minWidth: 0 };
const outer: CSSProperties = { minWidth: 0, flex: "1 1 0" };
const inline = settings._inline_size;
if (typeof inline === "number" && inline > 0) {
style.flex = `0 0 ${inline}%`;
style.maxWidth = `${inline}%`;
outer.flex = `0 0 ${inline}%`;
outer.maxWidth = `${inline}%`;
}
const inner: CSSProperties = {};
const mg = paddingCss(settings.margin);
if (mg) {
// margin 写在 populated(与 Elementor 相同):width 取 100% 外层宽而不随 margin 收缩
// (原站实测 populated rect 272px 与 1.6/6.4 margin 并存)
inner.width = "100%";
inner.margin = mg;
}
const pad = paddingCss(settings.padding);
if (pad) style.padding = pad;
if (pad) inner.padding = pad;
const bg = sectionBgStyle(settings);
Object.assign(style, { backgroundColor: bg.backgroundColor, backgroundImage: bg.backgroundImage });
// 列圆角/边框(卡片样式)
Object.assign(inner, { backgroundColor: bg.backgroundColor, backgroundImage: bg.backgroundImage });
const radius = paddingCss(settings.border_radius);
if (radius) style.borderRadius = radius;
if (radius) inner.borderRadius = radius;
if (s(settings.border_border) === "solid") {
inner.border = `1px solid ${s(settings.border_color) || "#e3e9f0"}`;
const bw = paddingCss(settings.border_width);
style.border = `1px solid ${s(settings.border_color) || "#e3e9f0"}`;
if (bw && bw.split(" ").length === 4) {
const [t, r2, b, l] = bw.split(" ");
style.borderWidth = `${t} ${r2} ${b} ${l}`;
} else style.borderWidth = "1px";
inner.borderWidth = `${t} ${r2} ${b} ${l}`;
} else inner.borderWidth = "1px";
}
return (
<div className="min-w-0 px-2.5" style={style} data-el={el.id}>
{(el.elements || []).map((c, i) => (
<ElementNode key={c.id || i} el={c} />
))}
<div className="el-col min-w-0" style={outer} data-el={el.id}>
<div className="px-2.5" style={inner}>
{(el.elements || []).map((c, i) => (
<ElementNode key={c.id || i} el={c} />
))}
</div>
</div>
);
}
/** # 组件 widget 分发:按 widgetType 渲染,未知类型优雅降级
* 外层 div 与原站 widget 包裹层一致(.elementor-widget …),挂 data-el=<id>,
* 页面级规则(post-18.css 对应块按 [data-el] 范围书写)与 _css_classes 因此都能命中。
* 双层对应原站结构:外层 div = .elementor-widget(挂 data-el=<id>,页面级规则与
* _css_classes 命中于此);内层 div = .elementor-widget-container(承载 _padding /
* _margin / _border 系列 / _border_radius / _background_* 等高级盒模型)。
* 双层的目的:外层仅带 kit-spacing 的 20px 下外距,内层负外距(如 H4 图片 -2.5rem 0 -1rem)
* 能与外层外距按标准块级折叠(max 规则),与原站实测间距(折叠后 20px)一致。
*/
function WidgetNode({ el }: { el: ElElement }) {
const settings = (el.settings || {}) as Record<string, unknown>;
const style: CSSProperties = {};
const pad = paddingCss(settings._padding);
if (pad) style.padding = pad;
const mrg = paddingCss(settings._margin);
if (mrg) style.margin = mrg;
if (s(settings._border_border) === "solid") {
style.border = `1px solid ${s(settings.border_color) || s(settings._border_color) || "#e3e9f0"}`;
const bw = paddingCss(settings._border_width);
if (bw && bw.split(" ").length === 4) {
const [t, r2, b, l] = bw.split(" ");
style.borderWidth = `${t} ${r2} ${b} ${l}`;
} else style.borderWidth = "1px";
}
const rad = paddingCss(settings._border_radius);
if (rad) style.borderRadius = rad;
// widget 容器背景(_background_*):仅图片(classic),位置/平铺逐字采用
const wbg = settings._background_image as { url?: string } | undefined;
if (wbg?.url) {
style.backgroundImage = `url("${wbg.url}")`;
// Elementor 位置词转 CSS:bottom center → 50% 100%(避免浏览器归一差异)
style.backgroundPosition = mapBgPosition(s(settings._background_position));
if (s(settings._background_repeat)) style.backgroundRepeat = s(settings._background_repeat);
}
const inner = (
<WidgetGuard widgetType={s(el.widgetType)}>
<WidgetNodeInner el={el} />
</WidgetGuard>
);
const cls = s(el.settings?._css_classes) || undefined;
const cls = s(settings._css_classes) || undefined;
const hasContainerStyle = Object.keys(style).length > 0;
if (hasContainerStyle) {
return (
<div data-el={el.id}>
<div className={cls} style={style}>{inner}</div>
</div>
);
}
return <div className={cls} data-el={el.id}>{inner}</div>;
}
@@ -464,7 +531,7 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
}
}
/** # 组件 heading widget:标题级别/颜色/对齐 */
/** # 组件 heading widget:标题(可带 link,is_external → target=_blank;title 支持 HTML) */
function HeadingWidget({ settings }: { settings: Record<string, unknown> }) {
// 标签合法域同原站 Utils::validate_html_tag(h1..h6 / div / span / p);默认 h2
const size = s(settings.header_size) || "h2";
@@ -474,13 +541,24 @@ function HeadingWidget({ settings }: { settings: Record<string, unknown> }) {
const align = s(settings.align);
if (align) style.textAlign = align as CSSProperties["textAlign"];
Object.assign(style, typographyCss(settings));
// widget 自带内边距(Elementor 高级项 _padding)
const pad = paddingCss(settings._padding);
if (pad) style.padding = pad;
// 通用 margin 不设(实测原站 heading widget margin 0,间距全部来自原站 settings)
return <Tag style={style} dangerouslySetInnerHTML={{ __html: s(settings.title) }} />;
// 基础规则(frontend.min.css):.elementor-heading-title{line-height:1;margin:0;padding:0}
// settings 里未定义行高时用 1(H3 有显式 1.2 覆写,H4 无设置 → 实测原站 20px/20px)
if (!style.lineHeight) style.lineHeight = 1;
// _padding/_margin/_background_* 属 widget 包裹层(WidgetNode),不在标题标签内重复
const title = s(settings.title);
const link = (settings.link as { url?: string } | undefined)?.url;
const isExternal = (settings.link as { is_external?: string } | undefined)?.is_external === "on";
const linkProps = link ? { href: link, ...(isExternal ? { target: "_blank" as const } : {}) } : undefined;
return (
<Tag className="el-heading-title" style={style}>
{linkProps ? (
<a {...linkProps} dangerouslySetInnerHTML={{ __html: title }} />
) : (
<span dangerouslySetInnerHTML={{ __html: title }} />
)}
</Tag>
);
}
/** # 组件 text-editor widget:富文本 HTML */
function TextEditorWidget({ settings }: { settings: Record<string, unknown> }) {
const html = s(settings.editor);
@@ -490,19 +568,26 @@ function TextEditorWidget({ settings }: { settings: Record<string, unknown> }) {
const style: CSSProperties = {};
if (color) style.color = color;
Object.assign(style, typographyCss(settings));
const pad = paddingCss(settings._padding);
if (pad) style.padding = pad;
return <div className="mb-[18px]" style={Object.keys(style).length ? style : undefined} dangerouslySetInnerHTML={{ __html: html }} />;
// _padding/_margin/_background_* 属 widget 包裹层(WidgetNode),不在此重复
return <div className="el-texteditor" style={Object.keys(style).length ? style : undefined} dangerouslySetInnerHTML={{ __html: html }} />;
}
/** # 组件 image widget */
/** # 组件 image widget:图(可带 link;hover_animation=shrink 复刻原站缩小动画) */
function ImageWidget({ settings }: { settings: Record<string, unknown> }) {
const url = (settings.image as { url?: string } | undefined)?.url;
const img0 = settings.image as { url?: string; alt?: string } | undefined;
const url = img0?.url;
if (!url) return null;
const align = s(settings.align);
const anim = s(settings.hover_animation); // 原站映射 elementor-animation-* 类
const animCls = anim === "shrink" ? "el-anim-shrink" : "";
const link = (settings.link as { url?: string } | undefined)?.url;
const isExternal = (settings.link as { is_external?: string } | undefined)?.is_external === "on";
const linkProps = link ? { href: link, ...(isExternal ? { target: "_blank" as const } : {}) } : undefined;
const img = <img className={animCls || undefined} src={url} alt={s(img0.alt)} loading="lazy" />;
const cls = ("el-image " + (animCls || "")).trim();
return (
<div className="mb-[18px]" style={{ textAlign: (align as CSSProperties["textAlign"]) || "center" }}>
<img src={url} alt={s((settings.image as { alt?: string }).alt) || ""} loading="lazy" />
<div className={cls} style={{ textAlign: (align as CSSProperties["textAlign"]) || "center" }}>
{linkProps ? <a {...linkProps} tabIndex={-1}>{img}</a> : img}
</div>
);
}
@@ -765,16 +850,39 @@ function IconBoxWidget({ settings }: { settings: Record<string, unknown> }) {
/** # 组件 eael-info-box(EAEL Infobox 皮肤):标题(.title,custom_css 提供左侧图标背景) + 富文本描述(.infobox-content)
* 与原生 icon-box 的键不同:内容在 eael_infobox_title / eael_infobox_text。
*/
/** # 组件 eael-info-box(EAEL Infobox,忠实复刻 info-box.min.css + 原站渲染 DOM):
* eael-infobox(icon-on-left / 默认上下) > .infobox-icon(img,eael_infobox_img_margin)
* + .infobox-content > .infobox-title-section > .title(标题标签 eael_infobox_title_tag)
* [+ .sub_title][+ p 描述];图在左时为 flex 行(content padding-left 15px)。
* 标题/描述样式(颜色/字号/外距)由页面级规则与 eael_infobox_title_margin 提供。
*/
function EaelInfoBoxWidget({ settings }: { settings: Record<string, unknown> }) {
const title = s(settings.eael_infobox_title) || s(settings.title_text) || s(settings.title);
const desc = s(settings.eael_infobox_text) || s(settings.description_text) || s(settings.description);
if (!title && !desc) return null;
const img = settings.eael_infobox_image as { url?: string; alt?: string } | undefined;
if (!title && !desc && !img?.url) return null;
const tag = s(settings.eael_infobox_title_tag);
const Tag = (["h1", "h2", "h3", "h4", "h5", "h6", "div"].includes(tag) ? tag : "div") as "div";
const left = s(settings.eael_infobox_img_type) === "img-on-left" || s(settings.eael_infobox_img_type) === "icon-on-left";
const cls = "eael-infobox" + (left ? " icon-on-left" : "");
return (
<div className="eael-infobox mb-[18px]">
{title ? <Tag className="title" dangerouslySetInnerHTML={{ __html: title }} /> : null}
{desc ? <div className="infobox-content" dangerouslySetInnerHTML={{ __html: desc }} /> : null}
<div className={cls}>
{img?.url ? (
<div className="infobox-icon">
<img src={img.url} alt={s(img.alt)} loading="lazy" />
</div>
) : null}
<div className="infobox-content">
{title ? (
<div className="infobox-title-section">
<Tag className="title" dangerouslySetInnerHTML={{ __html: title }} />
{s(settings.eael_infobox_sub_title) && s(settings.eael_infobox_sub_title) !== "This is a sub title" ? (
<div className="sub_title" dangerouslySetInnerHTML={{ __html: s(settings.eael_infobox_sub_title) }} />
) : null}
</div>
) : null}
{desc ? <p dangerouslySetInnerHTML={{ __html: desc }} /> : null}
</div>
</div>
);
}
+19 -1
View File
@@ -92,6 +92,24 @@ const SETTING_KEYS = new Set([
"eael_infobox_title",
"eael_infobox_text",
"eael_infobox_title_tag",
"eael_infobox_image",
"eael_infobox_img_type",
"eael_infobox_img_margin",
"eael_infobox_title_margin",
"eael_infobox_title_margin_mobile",
"eael_infobox_title_typography_typography",
"eael_infobox_title_typography_font_size",
"hover_animation",
// widget 高级盒模型(如 eael-info-box 边框卡:_border_* + _padding/_margin)
"_margin",
// widget 容器背景(如 H4 标题下的橙线图)
"_background_image",
"_background_position",
"_background_repeat",
"_border_border",
"_border_color",
"_border_width",
"_border_radius",
"title_text_a",
"description_text_a",
"button_text",
@@ -111,7 +129,7 @@ const SETTING_KEYS = new Set([
function slimSettingValue(key: string, val: unknown): unknown {
if (!val || typeof val !== "object" || Array.isArray(val)) return val;
const v = val as Record<string, unknown>;
if (key === "image" || key === "background_image" || key === "background_a_image") {
if (key === "image" || key === "background_image" || key === "background_a_image" || key === "eael_infobox_image") {
// 渲染器只读 url / alt
return { url: v.url, alt: v.alt };
}