v0.10.19: G2 页脚 Footer 一比一复刻(原站 elementor_library post 58)
- 导入脚本 scripts/import-footer-template.ts:从 legacy dump 提取 post 58 的 _elementor_data 写入 contents(type=template, slug=footer),并按线上 post-58.css/DOM 补齐快照后改动 (f95e22e Google Preferred Source、405d251 width:auto、2481635 桌面外距) - Layout/getLayoutData 下发 footerDoc,Footer 改用 ElementorRenderer 直出原模板, 短代码按 widget id 注入:menu-2 移动端折叠菜单、gtranslate 语言下拉(原生 select.gt_selector) - Renderer 增强:section html_tag/边框/content_position、column 响应式宽度与 populated padding/margin(tablet/mobile)、widget 响应式盒模型、icon widget(fixed 返回顶部)、 icon-list 图标/间距/文字排版与 CSS 变量悬停、image 宽度、divider Elementor DOM、 text-editor 对齐与 current-date-time 动态年份、shortcode 预渲染上下文 - slim 白名单补齐响应式/图标/动态标签字段;app.css 新增 FA Solid/Regular 字体、 页脚 scoped 规则、elementor-hidden-* 响应式可见性 - 实测 1436:页脚总高 590.4 与原站全等(列宽/内层列/logo/社交/下拉逐项一致); 390:56/15/347/337/97/46.4/61 逐项全等;语言切换写 ui_lang cookie;返回顶部回滚 0 - bun test 11 pass;版本 0.10.19
This commit is contained in:
@@ -59,6 +59,18 @@ function stripStructuralTags(html: string): string {
|
||||
return html.replace(/<\/?(?:main|body|html|head)[^>]*>/gi, "");
|
||||
}
|
||||
|
||||
/** # 函数 CSS 长度折半(Elementor icon-list 间距:space/2 落在 padding/margin)
|
||||
* 参数 v CSS 长度(如 "5px" / "1rem")
|
||||
* 返回值 折半后的长度;无法解析返回 undefined
|
||||
*/
|
||||
function halfLength(v: string | undefined): string | undefined {
|
||||
if (!v) return undefined;
|
||||
const m = /^(-?[\d.]+)(px|rem|em|%)$/.exec(v.trim());
|
||||
if (!m) return undefined;
|
||||
const n = Number(m[1]) / 2;
|
||||
return `${n}${m[2]}`;
|
||||
}
|
||||
|
||||
/** # 函数 折叠 CSS 盒模型四值简写为最短等价形式(1/2/4 值)
|
||||
* React 19 水合按 CSSOM 归一化后的字符串比对样式,"4rem 0rem 4rem 0rem"
|
||||
* 会被浏览器归一为 "4rem 0rem",不折叠会导致水合不匹配。
|
||||
@@ -190,6 +202,29 @@ function resolveGlobalsStr(v: string): string {
|
||||
return v;
|
||||
}
|
||||
|
||||
/** # 函数 响应式可见性类名(Elementor hide_* 设置 → elementor-hidden-*,配 app.css 媒体查询)
|
||||
* 参数 settings 元素设置
|
||||
* 返回值 空格分隔的类名(无隐藏设置则空串)
|
||||
*/
|
||||
function visibilityClass(settings: Record<string, unknown>): string {
|
||||
const cls: string[] = [];
|
||||
if (s(settings.hide_desktop) === "hidden-desktop") cls.push("elementor-hidden-desktop");
|
||||
if (s(settings.hide_tablet) === "hidden-tablet") cls.push("elementor-hidden-tablet");
|
||||
if (s(settings.hide_mobile) === "hidden-mobile") cls.push("elementor-hidden-mobile");
|
||||
return cls.join(" ");
|
||||
}
|
||||
|
||||
/** # 函数 section 的自定义 HTML 标签(Elementor html_tag 设置;默认 section)
|
||||
* 参数 settings 元素设置
|
||||
* 返回值 合法标签名
|
||||
*/
|
||||
function sectionTag(settings: Record<string, unknown>): "section" | "div" | "footer" | "header" | "aside" | "article" {
|
||||
const tag = s(settings.html_tag);
|
||||
return (["section", "div", "footer", "header", "aside", "article"] as const).includes(tag as never)
|
||||
? (tag as "section" | "div" | "footer" | "header" | "aside" | "article")
|
||||
: "section";
|
||||
}
|
||||
|
||||
/** # 函数 section 的背景样式(纯色 / 图片) */
|
||||
function sectionBgStyle(settings: Record<string, unknown>): CSSProperties {
|
||||
const style: CSSProperties = {};
|
||||
@@ -235,19 +270,26 @@ function normalizeDoc(doc: ElDocument): ElElement[] {
|
||||
/** # 成员变量 posts widget 文章数据上下文(loader 预取,经 widget id 分发) **/
|
||||
const PostsDataContext = createContext<Record<string, PostCardItem[]>>({});
|
||||
|
||||
/** # 组件 Elementor 渲染器入口 */
|
||||
export function ElementorRenderer({ doc, postsData }: { doc: ElDocument; postsData?: Record<string, PostCardItem[]> }) {
|
||||
/** # 成员变量 WordPress 短代码渲染上下文(widget id → 预渲染节点;页脚 menu-2/gtranslate) **/
|
||||
const ShortcodesContext = createContext<Record<string, ReactNode>>({});
|
||||
|
||||
/** # 组件 Elementor 渲染器入口
|
||||
* 参数 shortcodes 短代码 widget 的预渲染内容(键为 widget id;缺省渲染为空)
|
||||
*/
|
||||
export function ElementorRenderer({ doc, postsData, shortcodes }: { doc: ElDocument; postsData?: Record<string, PostCardItem[]>; shortcodes?: Record<string, ReactNode> }) {
|
||||
const elements = normalizeDoc(doc);
|
||||
if (!elements.length) return null;
|
||||
const css = collectCustomCss(elements) + collectResponsiveCss(elements);
|
||||
return (
|
||||
<PostsDataContext.Provider value={postsData || {}}>
|
||||
<div className="content-html">
|
||||
{css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null}
|
||||
{elements.map((el, i) => (
|
||||
<ElementNode key={el.id || i} el={el} />
|
||||
))}
|
||||
</div>
|
||||
<ShortcodesContext.Provider value={shortcodes || {}}>
|
||||
<div className="content-html">
|
||||
{css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null}
|
||||
{elements.map((el, i) => (
|
||||
<ElementNode key={el.id || i} el={el} />
|
||||
))}
|
||||
</div>
|
||||
</ShortcodesContext.Provider>
|
||||
</PostsDataContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -279,14 +321,64 @@ function collectResponsiveCss(elements: ElElement[]): string {
|
||||
let css = "";
|
||||
function emit(el: ElElement) {
|
||||
const sel = `[data-el="${el.id}"]`;
|
||||
const t = paddingCss((el.settings ?? {} as Record<string, unknown>).padding_tablet);
|
||||
const m = paddingCss((el.settings ?? {} as Record<string, unknown>).padding_mobile);
|
||||
if (t) css += `@media (max-width:1024px){${sel}{padding:${t} !important;}}\n`;
|
||||
if (m) css += `@media (max-width:767px){${sel}{padding:${m} !important;}}\n`;
|
||||
const st = (el.settings ?? {}) as Record<string, unknown>;
|
||||
// 列的内边距/外边距落在内层 populated(ColumnNode),section 落根元素
|
||||
const boxSel = s(el.elType) === "column" ? `${sel} > div` : sel;
|
||||
const t = paddingCss(st.padding_tablet);
|
||||
const m = paddingCss(st.padding_mobile);
|
||||
if (t) css += `@media (max-width:1024px){${boxSel}{padding:${t} !important;}}\n`;
|
||||
if (m) css += `@media (max-width:767px){${boxSel}{padding:${m} !important;}}\n`;
|
||||
// 响应式外边距(页脚 a428bb4 tablet 70px / mobile 10px;Elementor 根元素 margin)
|
||||
const mt = paddingCss(st.margin_tablet);
|
||||
const mm = paddingCss(st.margin_mobile);
|
||||
if (mt) css += `@media (max-width:1024px){${boxSel}{margin:${mt} !important;}}\n`;
|
||||
if (mm) css += `@media (max-width:767px){${boxSel}{margin:${mm} !important;}}\n`;
|
||||
// widget 高级盒模型响应式(_margin/_padding 的 tablet/mobile 变体挂内层容器)
|
||||
if (s(el.elType) === "widget") {
|
||||
const wsel = `${sel} > div`;
|
||||
const wpt = paddingCss(st._padding_tablet);
|
||||
const wpm = paddingCss(st._padding_mobile);
|
||||
const wmt = paddingCss(st._margin_tablet);
|
||||
const wmm = paddingCss(st._margin_mobile);
|
||||
if (wpt) css += `@media (max-width:1024px){${wsel}{padding:${wpt} !important;}}\n`;
|
||||
if (wpm) css += `@media (max-width:767px){${wsel}{padding:${wpm} !important;}}\n`;
|
||||
if (wmt) css += `@media (max-width:1024px){${wsel}{margin:${wmt} !important;}}\n`;
|
||||
if (wmm) css += `@media (max-width:767px){${wsel}{margin:${wmm} !important;}}\n`;
|
||||
// widget 宽度模式响应式(页脚 gtranslate 移动端 width inherit)
|
||||
const ewm = s(st._element_width_mobile);
|
||||
if (ewm === "inherit") css += `@media (max-width:767px){${sel}{width:100% !important;max-width:100% !important;align-self:center;}}\n`;
|
||||
else if (ewm === "auto") css += `@media (max-width:767px){${sel}{width:auto !important;max-width:auto !important;align-self:auto;}}\n`;
|
||||
// image widget 图片移动端宽度(页脚 logo 47%)
|
||||
if (s(el.widgetType) === "image") {
|
||||
const iw = sizeCss(st.width_mobile);
|
||||
if (iw) css += `@media (max-width:767px){${sel} img{width:${iw} !important;}}\n`;
|
||||
}
|
||||
}
|
||||
// 列响应式宽度(_inline_size_tablet / _inline_size_mobile;页脚内层四列 43%/100%)
|
||||
const tw = st._inline_size_tablet;
|
||||
const mw = st._inline_size_mobile;
|
||||
if (typeof tw === "number" && tw > 0) {
|
||||
css += `@media (max-width:1024px){${sel}{flex:0 0 ${tw}% !important;max-width:${tw}% !important;}}\n`;
|
||||
}
|
||||
if (typeof mw === "number" && mw > 0) {
|
||||
css += `@media (max-width:767px){${sel}{flex:0 0 ${mw}% !important;max-width:${mw}% !important;}}\n`;
|
||||
}
|
||||
// icon-list 响应式项间距(space_between_tablet/mobile:页脚社交 10 → 7px)
|
||||
if (s(el.widgetType) === "icon-list") {
|
||||
const inlineItems = `${sel} .elementor-icon-list-items.elementor-inline-items`;
|
||||
const stHalf = halfLength(sizeCss(st.space_between_tablet));
|
||||
const smHalf = halfLength(sizeCss(st.space_between_mobile)) || stHalf;
|
||||
if (stHalf) {
|
||||
css += `@media (max-width:1024px){${inlineItems}{margin-inline:-${stHalf} !important;}${inlineItems} > .elementor-icon-list-item{margin-inline:${stHalf} !important;}}\n`;
|
||||
}
|
||||
if (smHalf) {
|
||||
css += `@media (max-width:767px){${inlineItems}{margin-inline:-${smHalf} !important;}${inlineItems} > .elementor-icon-list-item{margin-inline:${smHalf} !important;}}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
function walk(els: ElElement[]) {
|
||||
for (const el of els) {
|
||||
if (el.id && (s(el.elType) === "section" || s(el.elType) === "container" || s(el.elType) === "column")) emit(el);
|
||||
if (el.id) emit(el);
|
||||
if (el.elements?.length) walk(el.elements);
|
||||
}
|
||||
}
|
||||
@@ -296,18 +388,19 @@ function collectResponsiveCss(elements: ElElement[]): string {
|
||||
|
||||
/** # 组件 单个元素节点分发(section/column/container/widget)
|
||||
* 参数 gapPad 所属 section 的默认列内边距(Elementor gap 档位,仅 column 消费)
|
||||
* 参数 contentPos 所属 section 的 content_position(middle → 列的 widget-wrap 垂直居中)
|
||||
*/
|
||||
function ElementNode({ el, gapPad }: { el: ElElement; gapPad?: string }) {
|
||||
function ElementNode({ el, gapPad, contentPos }: { el: ElElement; gapPad?: string; contentPos?: string }) {
|
||||
const type = s(el.elType);
|
||||
if (type === "widget") return <WidgetNode el={el} />;
|
||||
if (type === "section" || type === "container") return <SectionNode el={el} />;
|
||||
if (type === "column") return <ColumnNode el={el} gapPad={gapPad} />;
|
||||
if (type === "column") return <ColumnNode el={el} gapPad={gapPad} contentPos={contentPos} />;
|
||||
// 未知结构:有子元素则递归,否则忽略
|
||||
if (el.elements?.length) {
|
||||
return (
|
||||
<>
|
||||
{el.elements.map((c, i) => (
|
||||
<ElementNode key={c.id || i} el={c} gapPad={gapPad} />
|
||||
<ElementNode key={c.id || i} el={c} gapPad={gapPad} contentPos={contentPos} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -369,17 +462,33 @@ function SectionNode({ el }: { el: ElElement }) {
|
||||
const ch = sizeCss(settings.custom_height);
|
||||
if (heightMode === "min-height" && ch) style.minHeight = ch;
|
||||
else if (heightMode === "full" && ch) style.height = ch;
|
||||
// section 边框(Elementor 仅在 border_border 有值时生成;页脚 a428bb4 顶部点线)
|
||||
const secBorder = s(settings.border_border);
|
||||
if (secBorder) {
|
||||
style.borderStyle = secBorder as CSSProperties["borderStyle"];
|
||||
const bw = paddingCss(settings.border_width);
|
||||
if (bw) style.borderWidth = bw;
|
||||
const bc = s(settings.border_color);
|
||||
if (bc) style.borderColor = bc;
|
||||
}
|
||||
// 列内容垂直对齐(content_position=middle → align-items:center;页脚 d834160 实测)
|
||||
const contentPos = s(settings.content_position);
|
||||
if (contentPos === "middle") inner.alignItems = "center";
|
||||
else if (contentPos === "top") inner.alignItems = "flex-start";
|
||||
else if (contentPos === "bottom") inner.alignItems = "flex-end";
|
||||
if (overlay) style.position = style.position || "relative";
|
||||
const Tag = sectionTag(settings);
|
||||
const vis = visibilityClass(settings);
|
||||
return (
|
||||
<section style={style} data-el={el.id}>
|
||||
<Tag className={vis || undefined} style={style} data-el={el.id}>
|
||||
{overlay}
|
||||
{/* 行加 relative:与绝对定位遮罩同属定位层,DOM 靠后绘制在上,内容不被蒙灰 */}
|
||||
<div className="mx-auto flex flex-wrap" style={{ ...inner, position: "relative" }}>
|
||||
{(el.elements || []).map((c, i) => (
|
||||
<ElementNode key={c.id || i} el={c} gapPad={gapPad} />
|
||||
<ElementNode key={c.id || i} el={c} gapPad={gapPad} contentPos={contentPos} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -405,7 +514,7 @@ function boxNonZero(v: unknown): boolean {
|
||||
* (frontend.min.css .elementor-column-gap-default > … > .elementor-element-populated{padding:10px},
|
||||
* 实测 H6 列 59bc2e1 四向 10px、H7 列 66de90a 设置覆盖为 8/16/16/0,两端一致)。
|
||||
*/
|
||||
function ColumnNode({ el, gapPad }: { el: ElElement; gapPad?: string }) {
|
||||
function ColumnNode({ el, gapPad, contentPos }: { el: ElElement; gapPad?: string; contentPos?: string }) {
|
||||
const settings = resolveGlobals(el.settings || {});
|
||||
const outer: CSSProperties = { minWidth: 0, flex: "1 1 0" };
|
||||
const inline = settings._inline_size;
|
||||
@@ -413,7 +522,17 @@ function ColumnNode({ el, gapPad }: { el: ElElement; gapPad?: string }) {
|
||||
outer.flex = `0 0 ${inline}%`;
|
||||
outer.maxWidth = `${inline}%`;
|
||||
}
|
||||
// widget-wrap 对齐:Elementor 默认 align-items:normal(stretch,与线上 computed 一致);
|
||||
// section content_position=middle 时 align-content/align-items 均为 center
|
||||
// (页脚 d834160 生成规则;社交行因此与 gtranslate 下拉同高 29px)
|
||||
const inner: CSSProperties = {};
|
||||
if (contentPos === "middle") {
|
||||
inner.alignItems = "center";
|
||||
inner.alignContent = "center";
|
||||
} else if (contentPos === "bottom") {
|
||||
inner.alignItems = "flex-end";
|
||||
inner.alignContent = "flex-end";
|
||||
}
|
||||
const mg = paddingCss(settings.margin);
|
||||
if (mg) {
|
||||
// margin 写在 populated(与 Elementor 相同):宽保持 auto——块级填充会自动扣除自身外边距
|
||||
@@ -440,7 +559,7 @@ function ColumnNode({ el, gapPad }: { el: ElElement; gapPad?: string }) {
|
||||
// margin 溢出);H11/H14/H15 等"扣除 margin"语义的列在 app.css 用 !important 覆写为 auto。
|
||||
// 空列(无子元素)裸渲染:原站空列无 populated 盒(H15 占位格 margin/边框均为 0),
|
||||
// 仅保留外层弹性宽度与拉伸高度
|
||||
<div className="el-col min-w-0" style={outer} data-el={el.id}>
|
||||
<div className={"el-col min-w-0 " + visibilityClass(settings)} style={outer} data-el={el.id}>
|
||||
{(el.elements || []).length ? (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", alignContent: "flex-start", width: "100%", ...inner }}>
|
||||
{(el.elements || []).map((c, i) => (
|
||||
@@ -493,13 +612,19 @@ function WidgetNode({ el }: { el: ElElement }) {
|
||||
// widget 默认满宽(flex wrap 中独占一行),_element_width=auto 时按内容宽(横排)
|
||||
const widthAuto = s(settings._element_width) === "auto";
|
||||
const cls = s(settings._css_classes) || undefined;
|
||||
const baseCls = cls ? (widthAuto ? cls + " el-w-auto" : cls) : widthAuto ? "el-w-auto" : undefined;
|
||||
const hasContainerStyle = Object.keys(style).length > 0;
|
||||
const vis = visibilityClass(settings);
|
||||
const baseCls = [cls, widthAuto ? "el-w-auto" : "", vis].filter(Boolean).join(" ") || undefined;
|
||||
// 有桌面盒样式,或有响应式 _margin_*/_padding_*(移动端需挂在内层容器,页脚 c015d97
|
||||
// -1rem 上外距靠内层负外距把整块高度压到 15px)→ 双层结构
|
||||
const hasResponsiveBox =
|
||||
boxNonZero(settings._margin_mobile) || boxNonZero(settings._padding_mobile) ||
|
||||
boxNonZero(settings._margin_tablet) || boxNonZero(settings._padding_tablet);
|
||||
const hasContainerStyle = Object.keys(style).length > 0 || hasResponsiveBox;
|
||||
if (hasContainerStyle) {
|
||||
return (
|
||||
// 外层也要带 el-w-auto:列内基础规则按 :not(.el-w-auto) 放行 width:auto 的根,
|
||||
// 否则 width-auto widget(如图标)被 width:100% 强推成每行一个(H7 回归根因)
|
||||
<div className={widthAuto ? "el-w-auto" : undefined} data-el={el.id}>
|
||||
<div className={[widthAuto ? "el-w-auto" : "", vis].filter(Boolean).join(" ") || undefined} data-el={el.id}>
|
||||
<div className={baseCls} style={{ width: widthAuto ? "auto" : "100%", ...style }}>{inner}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -518,6 +643,7 @@ class WidgetGuard extends Component<{ widgetType: string; children: ReactNode },
|
||||
function WidgetNodeInner({ el }: { el: ElElement }) {
|
||||
const w = s(el.widgetType);
|
||||
const settings = resolveGlobals(el.settings || {});
|
||||
const shortcodes = useContext(ShortcodesContext);
|
||||
const unknownFallback = () => {
|
||||
const title = s(settings.heading_title) || s(settings.title);
|
||||
const text = s(settings.editor) || s(settings.html) || s(settings.text);
|
||||
@@ -562,10 +688,21 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
|
||||
return <ReviewsWidget settings={settings} />;
|
||||
case "icon-list":
|
||||
return <IconListWidget settings={settings} />;
|
||||
case "icon":
|
||||
return <IconWidget settings={settings} />;
|
||||
case "spacer":
|
||||
return <div style={{ height: sizeCss(settings.space, "px") || "20px" }} />;
|
||||
case "divider":
|
||||
return <hr style={{ border: 0, borderTop: `1px solid ${s(settings.color) || "#e3e9f0"}`, margin: "18px 0" }} />;
|
||||
// Elementor divider DOM(页脚 c015d97:padding-block 15px + 1px #4D4E53 分隔线,
|
||||
// 高 31px,与 widget 的 20px 下外距合计 51px 间距,实测 2026-11-02)
|
||||
return (
|
||||
<div className="elementor-divider" style={{ paddingBlock: sizeCss(settings.gap) || "15px" }}>
|
||||
<span
|
||||
className="elementor-divider-separator"
|
||||
style={{ display: "block", width: "100%", borderTop: `1px solid ${s(settings.color) || "#e3e9f0"}` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "toggle":
|
||||
case "accordion":
|
||||
return <AccordionWidget settings={settings} />;
|
||||
@@ -595,7 +732,9 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
|
||||
</div>
|
||||
);
|
||||
case "shortcode":
|
||||
return null; // WordPress shortcode 无法在 Node 端执行,忽略
|
||||
// WordPress 短代码无法在 Node 端执行:由调用方按 widget id 传入预渲染节点
|
||||
// (页脚 post 58:0683e73 → menu-2 移动端折叠菜单;2481635 → gtranslate 语言切换)
|
||||
return shortcodes[s(el.id)] ?? null;
|
||||
case "code-highlight":
|
||||
return s(settings.source_code) ? (
|
||||
<pre className="mb-[18px]" style={{ background: "#0b1c2c", color: "#d7e5f5", padding: 16, borderRadius: 8, overflowX: "auto" }}>
|
||||
@@ -637,13 +776,32 @@ function HeadingWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||
}
|
||||
/** # 组件 text-editor widget:富文本 HTML */
|
||||
function TextEditorWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||
const html = s(settings.editor);
|
||||
let html = s(settings.editor);
|
||||
// 动态标签 current-date-time(页脚 1fe90e6 版权年份):editor 为空 + __dynamic__.editor
|
||||
// 含 current-date-time → 按 before + Y 生成(原站实测 "Copyright ©2026")
|
||||
const dynamic = s((settings.__dynamic__ as Record<string, unknown> | undefined)?.editor);
|
||||
if (!html && dynamic.includes("current-date-time")) {
|
||||
// settings 属性是 URL 编码的 JSON:settings="%7B%22date_format%22...%22before%22%3A%22Copyright%20%C2%A9%22"
|
||||
let before = "Copyright ©";
|
||||
const m = /settings="([^"]*)"/.exec(dynamic);
|
||||
if (m) {
|
||||
try {
|
||||
const dj = JSON.parse(decodeURIComponent(m[1]!)) as { before?: string };
|
||||
if (dj.before) before = dj.before;
|
||||
} catch {
|
||||
/* 解析失败用默认前缀 */
|
||||
}
|
||||
}
|
||||
html = `${before}${new Date().getFullYear()}`;
|
||||
}
|
||||
if (!html) return null;
|
||||
// 文本颜色可能来自全局色(如蓝底白字副标题),style 方式应用
|
||||
const color = s(settings.text_color);
|
||||
const style: CSSProperties = {};
|
||||
if (color) style.color = color;
|
||||
Object.assign(style, typographyCss(settings));
|
||||
const align = s(settings.align);
|
||||
if (align) style.textAlign = align as CSSProperties["textAlign"];
|
||||
// _padding/_margin/_background_* 属 widget 包裹层(WidgetNode),不在此重复
|
||||
return <div className="el-texteditor" style={Object.keys(style).length ? style : undefined} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
@@ -659,7 +817,10 @@ function ImageWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||
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" />;
|
||||
// 图片宽度设置(Elementor 生成 `.elementor-element-X img{width:57%}`;页脚 logo 57%、
|
||||
// Google 徽章 300px):作用于 img 本身,a 为 inline-block 时按固有宽度收缩(与原站一致)
|
||||
const imgWidth = sizeCss(settings.width);
|
||||
const img = <img className={animCls || undefined} src={url} alt={s(img0.alt)} loading="lazy" style={imgWidth ? { width: imgWidth } : undefined} />;
|
||||
const cls = ("el-image " + (animCls || "")).trim();
|
||||
return (
|
||||
<div className={cls} style={{ textAlign: (align as CSSProperties["textAlign"]) || "center" }}>
|
||||
@@ -1291,30 +1452,60 @@ function IconListWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||
const list = Array.isArray(settings.icon_list)
|
||||
? (settings.icon_list as { text?: string; _id?: string; link?: { url?: string; is_external?: string }; selected_icon?: { value?: string } }[])
|
||||
: [];
|
||||
const items = list.filter((it) => s(it.text));
|
||||
// 允许只有图标没有文字的项(页脚社交图标行 text 为空、selected_icon=fab fa-*)
|
||||
const items = list.filter((it) => s(it.text) || s(it.selected_icon?.value));
|
||||
if (!items.length) return null;
|
||||
const inline = s(settings.view) === "inline";
|
||||
const color = s(settings.text_color) || undefined;
|
||||
const iconColor = s(settings.icon_color) || undefined;
|
||||
const iconSize = sizeCss(settings.icon_size);
|
||||
const iconAlign = s(settings.icon_align);
|
||||
// 项间距(space_between):Elementor 竖排 = padding-block-end(space/2) + margin-block-start(space/2),
|
||||
// 横排 = li margin-inline(space/2) + ul 负 margin-inline(页脚 5px / 社交 10px,实测 li 高 23.5)
|
||||
const half = halfLength(sizeCss(settings.space_between));
|
||||
const typo = typographyCss(settings, "icon_typography");
|
||||
// 颜色用 CSS 变量承载:文字/图标的内联色若写死十六进制,页面级 :hover 规则无法覆盖
|
||||
// (内联样式优先级最高)。改为 span/i 引用 var(--el-il-text/icon),悬停规则改 li 上的变量值。
|
||||
const vars: Record<string, string> = {};
|
||||
if (color) vars["--el-il-text"] = color;
|
||||
if (iconColor) vars["--el-il-icon"] = iconColor;
|
||||
const ulStyle: CSSProperties = { ...typo };
|
||||
Object.assign(ulStyle, vars);
|
||||
if (iconAlign === "left") ulStyle.justifyContent = "flex-start";
|
||||
else if (iconAlign === "center") ulStyle.justifyContent = "center";
|
||||
else if (iconAlign === "right") ulStyle.justifyContent = "flex-end";
|
||||
if (inline && half) ulStyle.marginInline = `-${half}`;
|
||||
return (
|
||||
<ul className={"elementor-icon-list-items" + (inline ? " elementor-inline-items" : "")}>
|
||||
<ul className={"elementor-icon-list-items" + (inline ? " elementor-inline-items" : "")} style={Object.keys(ulStyle).length ? ulStyle : undefined}>
|
||||
{items.map((it, i) => {
|
||||
const link = it.link?.url;
|
||||
const external = it.link?.is_external === "on";
|
||||
const icon = s(it.selected_icon?.value);
|
||||
const liStyle: CSSProperties | undefined = half
|
||||
? inline
|
||||
? { marginInline: half }
|
||||
: { paddingBottom: i < items.length - 1 ? half : undefined, marginTop: i > 0 ? half : undefined }
|
||||
: undefined;
|
||||
const iconStyle: CSSProperties = { color: iconColor ? "var(--el-il-icon)" : undefined, fontSize: iconSize };
|
||||
const text = (
|
||||
<span
|
||||
className="elementor-icon-list-text"
|
||||
style={color ? { color } : undefined}
|
||||
style={color ? { color: "var(--el-il-text)" } : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: s(it.text) }}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<li key={it._id || i} className={"elementor-icon-list-item" + (inline ? " elementor-inline-item" : "")}>
|
||||
<li key={it._id || i} className={"elementor-icon-list-item" + (inline ? " elementor-inline-item" : "")} style={liStyle}>
|
||||
{link ? (
|
||||
<a href={link} target={external ? "_blank" : undefined} rel={external ? "noopener" : undefined}>
|
||||
<a href={link} target={external ? "_blank" : undefined} rel={external ? "noopener noreferrer" : undefined}>
|
||||
{icon ? <span className="elementor-icon-list-icon"><i className={icon} aria-hidden="true" style={iconStyle} /></span> : null}
|
||||
{text}
|
||||
</a>
|
||||
) : (
|
||||
text
|
||||
<>
|
||||
{icon ? <span className="elementor-icon-list-icon"><i className={icon} aria-hidden="true" style={iconStyle} /></span> : null}
|
||||
{text}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
@@ -1323,6 +1514,70 @@ function IconListWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 icon widget(Elementor .elementor-widget-icon)
|
||||
* 页脚 post 58 的 5ffe573:view=stacked + _position=fixed(左下角返回顶部按钮),
|
||||
* 48×48 圆(图标 24px + padding 0.5em)、底色 secondary #FFA11B、悬停主色 #ED6C00(post-58.css 取证);
|
||||
* 088727e(询价弹窗入口)在桌面/平板/移动全部隐藏,由可见性类名隐藏。
|
||||
*/
|
||||
function IconWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||
const icon = s((settings.selected_icon as { value?: string } | undefined)?.value);
|
||||
if (!icon) return null;
|
||||
const view = s(settings.view);
|
||||
const size = sizeCss(settings.size) || "24px";
|
||||
const primary = s(settings.primary_color);
|
||||
const secondary = s(settings.secondary_color);
|
||||
const link = (settings.link as { url?: string } | undefined)?.url;
|
||||
const wrapper: CSSProperties = {};
|
||||
if (s(settings._position) === "fixed") {
|
||||
wrapper.position = "fixed";
|
||||
const x = sizeCss(settings._offset_x);
|
||||
const xe = sizeCss(settings._offset_x_end);
|
||||
const y = sizeCss(settings._offset_y);
|
||||
const ye = sizeCss(settings._offset_y_end);
|
||||
if (s(settings._offset_orientation_h) === "end") {
|
||||
if (xe) wrapper.right = xe;
|
||||
} else if (x) wrapper.left = x;
|
||||
else if (xe) wrapper.right = xe;
|
||||
if (s(settings._offset_orientation_v) === "end") {
|
||||
if (ye) wrapper.bottom = ye;
|
||||
} else if (y) wrapper.top = y;
|
||||
else if (ye) wrapper.bottom = ye;
|
||||
wrapper.zIndex = 99;
|
||||
wrapper.width = "auto";
|
||||
}
|
||||
const iconStyle: CSSProperties = { fontSize: size, lineHeight: 0 };
|
||||
if (view === "stacked") {
|
||||
iconStyle.display = "inline-flex";
|
||||
iconStyle.alignItems = "center";
|
||||
iconStyle.justifyContent = "center";
|
||||
iconStyle.padding = "0.5em";
|
||||
iconStyle.borderRadius = "50%";
|
||||
iconStyle.backgroundColor = secondary || primary || "#FFA11B";
|
||||
iconStyle.color = "#ffffff";
|
||||
} else {
|
||||
if (primary) iconStyle.color = primary;
|
||||
if (view === "framed") {
|
||||
iconStyle.display = "inline-flex";
|
||||
iconStyle.padding = "0.5em";
|
||||
iconStyle.borderRadius = "50%";
|
||||
iconStyle.border = `3px solid ${primary || "currentColor"}`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="elementor-icon-wrapper" style={{ textAlign: "center", ...wrapper }}>
|
||||
{link ? (
|
||||
<a className="elementor-icon" href={link} style={iconStyle}>
|
||||
<i className={icon} aria-hidden="true" />
|
||||
</a>
|
||||
) : (
|
||||
<div className="elementor-icon" style={iconStyle}>
|
||||
<i className={icon} aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 toggle / accordion widget:手风琴 */
|
||||
function AccordionWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||
const tabs = Array.isArray(settings.tabs) ? (settings.tabs as { tab_title?: string; tab_content?: string }[]) : [];
|
||||
|
||||
Reference in New Issue
Block a user