,而不是只加一个 class ──────────── * 现状是 WordPress 编辑器把图片包在 `` 里,点击就跳转。 * 要让点击在页内打开,有两条路: * ① 给 加一个 class,JS 拦截 click 并 preventDefault * ② 由服务端改写成 (保留 href 做无 JS 降级) * 这里选 ②,原因有三: * · **无 JS 时仍能点开原图**(href 还在),不会功能全废; * · 原图地址由服务端确定,不依赖前端从 srcset 里猜最大的那张; * · 右键「在新标签页打开图片」仍然可用(用户习惯不该被剥夺)。 * * ── 为什么原图地址必须写在 href 里,不能用 data-full ──────── * 🔴 主题的 CDN 加速是**整页输出缓冲**改写(inc/theme-cdn.php), * 但它只改 `src=` / `href=` 两个属性名和 CSS 里的 `url()`。 * 写成 `data-full="..."` 的话 CDN 完全看不见,开了 CDN 之后 * 缩略图走 CDN、灯箱里的原图却回源站 —— 用户会看到"点开放大就变慢"。 * 所以原图地址**只能**放 href。这条是硬约束,改之前先看 theme-cdn.php。 * * ── 为什么不在前端拼接缩略图 → 原图地址 ────────────────────── * 常见的做法是把 `xxx-300x200.jpg` 里的 `-300x200` 抠掉得到原图。 * 这个做法有三个坑: * · 文件名本身就可能含 `-数字x数字`(比如 `photo-1920x1080.jpg` 是原名) * · CDN 或图片优化插件可能给的是 `xxx.jpg?w=300` 或 `xxx-300x200.webp` * · 多站点/子目录、域名替换之后字符串处理更容易错 * 服务端拿得到**附件 ID**,直接问 WordPress 要 full size 的原图地址, * 这是唯一可靠的来源。 * * @author Yongquan Yang * @license MIT License */ if (!defined('ABSPATH')) { exit; } /* ══════════════════════════════════════════════════════════════════ * 开关与配置 * ══════════════════════════════════════════════════════════════════ */ /** * 灯箱功能是否开启 * * std 与运行时默认值必须一致:Options Framework 的 `std` 只在 * 后台预填和「恢复默认」时起作用,**运行时完全不读它**。 * 所以默认值必须由这里提供,否则会出现「后台看着是勾上的、 * 前台却没生效」。 */ function yang_lightbox_active() { if (!function_exists('kratos_option')) { return false; } return kratos_option('a_lightbox_enable', '1') === '1'; } /** * 遮罩配色:'light'(白)或 'dark'(黑) * * 用户在需求里先说「黑背景也行、不黑也行,看哪种效果好」, * 看过两套演示后明确选了**白色**,并要求遮罩半透明、 * 不把底下正文完全盖死、只突出照片。所以默认值从 dark 改成 light。 */ function yang_lightbox_theme() { $v = function_exists('kratos_option') ? kratos_option('a_lightbox_theme', 'light') : 'light'; return $v === 'dark' ? 'dark' : 'light'; } /** * 遮罩不透明度(百分比,50~100) * * 用户要「约 80%」:底下内容隐约可见,照片仍然是视觉焦点。 * 为什么做成可调而不是写死 80: * · 每个人屏幕亮度、正文配色不一样,80 在别人站上可能偏透或偏实; * · 以后想临时更实一点(看清文字)或更透一点(确认自己在哪页), * 改一个数字就行,不用改代码。 * * ⚠️ 下限卡 50、上限 100: * 低于 50 时遮罩几乎挡不住正文,文字会彻底读不清(不是审美问题, * 是可用性问题);100 就是完全盖死,等于退回原来的实底效果。 * * 🔴 注意这里返回的是「遮罩背景色的 alpha」,不是 CSS 的 opacity。 * 用 opacity 会让图片/箭头/文字一起变半透明,图片跟着发灰 —— * 那不是用户要的效果(用户要的是底透、图清楚)。 */ function yang_lightbox_opacity() { $v = function_exists('kratos_option') ? kratos_option('a_lightbox_opacity', '80') : '80'; $v = (int) $v; if ($v < 50) { $v = 50; } if ($v > 100) { $v = 100; } return $v; } /* ══════════════════════════════════════════════════════════════════ * 正文改写:给图片链接打上灯箱标记 * ══════════════════════════════════════════════════════════════════ */ /** * 判断一个 href 是不是「指向图片」的地址 * * 只看扩展名会漏掉两类真实存在的地址: * · 带查询串的(`img.php?f=a.jpg`、`a.jpg?ver=2`) * · WordPress 的 `?attachment_id=123` 形式 * 所以先剥掉查询串与锚点再判扩展名。 */ function yang_lightbox_is_image_url($url) { $u = (string) $url; if ($u === '') { return false; } // 只认 http(s) 与站内相对路径;data: / javascript: / mailto: 一律不是 if (!preg_match('#^(?:https?:)?//#i', $u) && substr($u, 0, 1) !== '/') { return false; } // 剥查询串与锚点 $path = preg_replace('/[?#].*$/', '', $u); return (bool) preg_match( '#\.(?:jpe?g|png|gif|webp|avif|bmp|svg)$#i', $path ); } /** * 剥掉别的灯箱插件留下的触发标记 * * ── 为什么必须有这条 ──────────────────────────────────────── * 🔴 实测线上(抽 100 篇文章、112 张图):**每一张**图片外层 `` 都带着 * data-rel="lightbox-gallery-0" * data-imagelightbox="0" * data-rl_title="" data-rl_caption="" * 这是用户为「点开在当前页放大」装的那个灯箱插件写进数据库的 * **触发标记** —— 那个插件的 JS 靠这些属性选中元素并接管 click。 * * 而主题自身**从不**产生这些属性(全仓库 grep 为 0 处)。 * * ── 两条路,选了后者 ──────────────────────────────────────── * ① 让给插件:凡是带这些属性的就跳过不动。 * ✗ 行不通 —— 线上 112/112 张图全带,等于主题灯箱一个字都不改, * 装上去看上去「完全没效果」,比不装还让人困惑。 * ② 接管:开关开着就以主题为准,把这些触发属性剥掉, * 插件 JS 找不到元素就不会绑事件,不会出现两套灯箱同时弹。 * ✓ 采用。语义清晰:**开关开 = 主题管,开关关 = 谁都不管** * (用户关掉开关后属性原样保留,插件照常工作)。 * * ── 为什么剥属性就够,不用删插件的 JS/CSS ────────────────── * 这些插件都是「DOM ready 时按选择器找元素再绑事件」。 * 服务端已经把属性去掉了,JS 跑的时候就找不到目标, * 自然不会绑、也不会弹层。它多加载的那点 CSS/JS 不影响功能。 * * @param DOMElement $a 正文里的链接节点 * @return int 剥掉的属性个数(0 表示这个链接本来就没被插件接管) */ function yang_lightbox_strip_plugin_marks($a) { /* 常见灯箱插件的触发属性。新增别的插件时把它的标记加进来即可。 ⚠️ 只剥「触发属性」,不动 class —— 很多插件/主题会给图片链接 加无关的 class(如 wp-editor-md-post-content-link), 乱删 class 会破坏原有样式。 */ $marks = array( 'data-rel', // Responsive Lightbox & Gallery 等 'data-imagelightbox', // Image Lightbox 'data-lightbox', 'data-lightbox-gallery', 'data-fancybox', // FancyBox 'data-fancybox-group', 'data-lb', 'data-featherlight', // Featherlight 'data-elementor-lightbox', 'data-elementor-open-lightbox', 'data-rl_title', // Responsive Lightbox 的说明文字 'data-rl_caption', 'data-gallery', // 部分画廊插件 ); $n = 0; foreach ($marks as $m) { if ($a->hasAttribute($m)) { $a->removeAttribute($m); $n++; } } return $n; } /** * 从 标签里取「原图地址」的兜底来源 * * 优先级:`data-full` / `data-large-file` / `data-orig-file`(部分插件会写) * → 否则取 src(此时 src 本身就是原图,够用)。 * * ⚠️ 故意**不用** srcset 里最大的那张:srcset 给的是「不同 DPR 的显示用图」, * 它的最大项仍可能是被裁过/压缩过的,跟「原图」不是一回事。 * 真正可靠的原图要从附件 ID 拿(见 yang_lightbox_content())。 */ function yang_lightbox_img_src($img_tag) { foreach (array('data-full', 'data-large-file', 'data-orig-file', 'src') as $attr) { if (preg_match('#\s' . preg_quote($attr, '#') . '\s*=\s*(["\'])(.*?)\1#is', $img_tag, $m)) { $v = trim($m[2]); if ($v !== '') { return $v; } } } return ''; } /** * 从 class 里取 WordPress 附件 ID * * 编辑器插入图片时会把 `wp-image-123` 写进 class,这是**服务端唯一** * 能拿到原图的可靠线索。Gutenberg 的
里 * 图片本身也带这个 class。 */ function yang_lightbox_attachment_id($img_tag) { if (preg_match('#\bwp-image-(\d+)\b#', $img_tag, $m)) { return (int) $m[1]; } return 0; } /** * 由附件 ID 取原图地址 * * 用 `wp_get_attachment_image_src($id, 'full')` 而不是拼字符串: * 它会正确处理多站点、子目录、CDN 插件的 filters、以及 * 「原图被替换过」这类情况。 * * 取不到就返回空,让调用方退回 href 自己。 */ function yang_lightbox_full_url($id) { if ($id <= 0 || !function_exists('wp_get_attachment_image_src')) { return ''; } $src = wp_get_attachment_image_src($id, 'full'); if (!is_array($src) || empty($src[0])) { return ''; } $url = (string) $src[0]; // 有些环境会返回相对路径,补成绝对地址(灯箱里要做 URL 比较) if (substr($url, 0, 1) === '/') { $url = home_url($url); } return $url; } /** * 给正文里的图片链接加灯箱标记 * * ── 改什么、不改什么 ──────────────────────────────────────── * 改:`` → `` * 不改: * · 链接到**非图片**的 (外链、下载、锚点)—— 一改就把正常链接废了 * · 已经带 `data-yang-lb` 的(避免二次处理,比如 the_content 被调用两次) * · 图片外面没有 的裸 —— 单独处理见 yang_lightbox_bare_imgs() * · 后台/feed/REST —— 那里不需要灯箱,改了反而干扰编辑 * * ── 为什么用 DOM 而不是正则 ────────────────────────────────── * 正文 HTML 里 套 的结构、属性顺序、引号风格都可能变。 * 正则改属性在「属性值里含 > 」这类边界上会崩。PHP 的 DOMDocument * 能正确处理,代价是要处理编码问题(见下面的 mb_convert 说明)。 */ function yang_lightbox_content($content) { if (!yang_lightbox_active()) { return $content; } if (!is_string($content) || $content === '' || stripos($content, '。 * * ⚠️ 不依赖 mbstring:主题在 3.2.36 已经踩过「mbstring 是可选扩展, * 依赖它 = 整站白屏」这个坑。DOMDocument 的编码用 * `` 处理,不需要 mb_* 函数。 */ $wrapped = '
' . $content . '
'; $prev = libxml_use_internal_errors(true); $doc = new DOMDocument('1.0', 'UTF-8'); $ok = $doc->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); libxml_clear_errors(); libxml_use_internal_errors($prev); if (!$ok) { // 解析失败就原样返回 —— 宁可不加灯箱,也不能把正文弄坏 return $content; } $root = $doc->getElementById('yang-lb-root'); if ($root === null) { // getElementById 在 HTML 模式下对 id 的识别不总可靠,退回遍历 $divs = $doc->getElementsByTagName('div'); foreach ($divs as $d) { if ($d->getAttribute('id') === 'yang-lb-root') { $root = $d; break; } } } if ($root === null) { return $content; } $links = $root->getElementsByTagName('a'); // ⚠️ getElementsByTagName 返回的是**活**的 NodeList,边遍历边改属性没问题, // 但如果要删/加节点必须倒序。这里只改属性,顺序无所谓。 $n = 0; $stripped = 0; foreach ($links as $a) { if ($a->getAttribute('data-yang-lb') === '1') { continue; } if ($a->getAttribute('data-yang-lb-skip') === '1') { continue; } $href = trim($a->getAttribute('href')); if (!yang_lightbox_is_image_url($href)) { continue; } // 必须真的包着图片,否则只是个指向图片文件的普通链接 $imgs = $a->getElementsByTagName('img'); if ($imgs->length === 0) { continue; } /* 🔴 到这里才**确定接管**这个链接,可以剥插件标记了(原因见函数注释)。 ⚠️ 必须放在上面两个 continue 之后:日志里就有 `
` 这种 「图片包在普通链接里」的结构 —— 那种链接不归灯箱管, 剥它的插件标记会连累别的功能。 */ $stripped = yang_lightbox_strip_plugin_marks($a); $img = $imgs->item(0); $imgHtml = $doc->saveHTML($img); // 原图优先从附件 ID 取(最可靠),其次 href 自己 $full = yang_lightbox_full_url(yang_lightbox_attachment_id($imgHtml)); if ($full === '') { $full = $href; } // 用 full 覆盖 href —— 这样「右键在新标签页打开」拿到的也是原图 $a->setAttribute('href', $full); $a->setAttribute('data-yang-lb', '1'); // 保留/补上 class,CSS 用它做「可点击」的视觉提示 $cls = trim($a->getAttribute('class')); if (strpos(' ' . $cls . ' ', ' yang-lb-item ') === false) { $a->setAttribute('class', trim($cls . ' yang-lb-item')); } // 无障碍:让键盘用户也知道这是个按钮 if (!$a->hasAttribute('role')) { $a->setAttribute('role', 'button'); } // 提示文字,给读屏软件用(视觉上由 CSS 隐藏) if (!$a->hasAttribute('aria-label')) { $a->setAttribute('aria-label', '放大查看图片'); } /* * 🔴 删掉 target="_blank" * 现状:inc/theme-article.php 的 imgnofollow() 会给正文里所有 * 站外/非本站的 加 target="_blank",图片链接也被加上了, * 于是点击就跳新标签页。JS 里虽然会 preventDefault, * 但**中键点击 / Ctrl+点击 / 无 JS** 时仍会开新窗口。 * 灯箱接管后,这个链接不再承担跳转职责,target 必须去掉。 */ $a->removeAttribute('target'); // 同理,rel="nofollow" 对「打开自己站内的图」毫无意义 $rel = trim($a->getAttribute('rel')); if ($rel !== '') { $keep = array(); foreach (preg_split('/\s+/', $rel) as $r) { if ($r !== '' && $r !== 'nofollow' && $r !== 'noopener' && $r !== 'noreferrer') { $keep[] = $r; } } if (empty($keep)) { $a->removeAttribute('rel'); } else { $a->setAttribute('rel', implode(' ', $keep)); } } $n++; } if ($n === 0) { // 一张都没改到,直接返回原文 —— 别让 DOMDocument 的序列化 // 动到用户原有的 HTML(它可能重排属性、改自闭合写法) return $content; } // 取回 div 内部 HTML(不要外层那个包壳) $out = ''; foreach ($root->childNodes as $child) { $out .= $doc->saveHTML($child); } return $out; } /** * 裸图片(外面没有 的 )也让它可点 * * ── 为什么这条要做 ────────────────────────────────────────── * 用户说「文章中的**所有**图片」。实际排版里常见两种: * · 编辑器插入的「链接到媒体文件」→ 有 ,上面已处理 * · 直接粘的 / 某些插件输出 / Markdown 渲染 → 没有 * 后者如果不管,就会出现「有的图能点、有的点不动」,很难解释。 * * ── 怎么做 ────────────────────────────────────────────────── * 在 外面包一个 。 * ⚠️ 只包**真正是内容图**的:跳过表情、图标、以及任何带 * `data-yang-lb-skip` 的。判据用「有 wp-image-N 类 或 尺寸够大」, * 避免把 16px 的小图标也包成可点。 */ function yang_lightbox_bare_imgs($content) { /* 这个函数目前**不启用**。 理由:正文里的裸图在现代 WordPress 里基本不出现(媒体库插入 默认带链接,块编辑器也有
),而「包一层 」会改变 原有布局( 是 inline,包的如果是块级图会破坏排版)。 真需要时再开,且必须先解决 inline 包裹的问题。 保留函数体是为了让「想开的人」有现成起点,不是死代码 —— 但**不要在没验证布局的前提下直接挂上去**。 */ return $content; } /* ══════════════════════════════════════════════════════════════════ * 资源加载 * ══════════════════════════════════════════════════════════════════ */ /** * 生成「遮罩不透明度」的行内 CSS * * ── 为什么只改背景色的 alpha,不改别的 ───────────────────── * 用户要的是「底透一点、图清楚」。所以只让**遮罩背景**半透明, * 图片、箭头、文字、底部说明条全部保持不变(各自的底色本来就近不透明, * 详见 lightbox.css 里的说明)。 * * ── 为什么底部说明条要跟着算 ──────────────────────────────── * 说明条压在遮罩上,它自己的底是 0.9 的白/黑。 * 遮罩越透,透上来的正文越多,说明条的对比度就越靠它自己的底撑着。 * 这里让说明条的 alpha 也按同一比例抬一点(最低 0.9), * 保证「不管遮罩多透,文字始终可读」。 * * @return string */ function yang_lightbox_inline_css() { $p = yang_lightbox_opacity(); $a = $p / 100; /* alpha 用定点小数,避免 PHP 把 0.8 输出成 0.80000000000000004 这种难看且在某些老浏览器上舍入异常的形式 */ $a_str = rtrim(rtrim(sprintf('%.2f', $a), '0'), '.'); if ($a_str === '') { $a_str = '0'; } /* 底部说明条:始终比遮罩更实,最低 0.9 */ $foot = $a + 0.08; if ($foot < 0.9) { $foot = 0.9; } if ($foot > 1) { $foot = 1; } $foot_str = rtrim(rtrim(sprintf('%.2f', $foot), '0'), '.'); $dark = yang_lightbox_theme() === 'dark'; /* 白色遮罩用纯白,深色遮罩用近黑(纯黑会让透上来的正文失去层次) */ $rgb = $dark ? '12, 14, 18' : '255, 255, 255'; $foot_rgb = $rgb; /* 🔴 选择器必须带 [data-theme='...'],不能只写 `.yang-lb`。 原因:lightbox.css 里深色配色那条规则是 `.yang-lb[data-theme='dark']`, 优先级 (0,2,0);而裸 `.yang-lb` 只有 (0,1,0)。 只写裸类名的话,深色配色下这条注入会被样式表**压掉**, 表现成「换了深色配色,透明度设置不生效」—— 而且完全看不出原因, 因为白底那套是对的(基础规则也是 .yang-lb,优先级相同、行内样式胜)。 这里用同优先级的选择器,靠「行内样式排在样式表之后」取胜。 */ $sel = $dark ? ".yang-lb[data-theme='dark']" : ".yang-lb[data-theme='light']"; return $sel . '{--lb-bg:rgba(' . $rgb . ',' . $a_str . ');' . '--lb-foot-bg:rgba(' . $foot_rgb . ',' . $foot_str . ');}'; } /** * 只在「真的会有图片可点」的页面加载资源 * * 判据:单篇/单页,且正文里出现过 post_content)) { return; } if (stripos((string) $post->post_content, 'post_content, 'href') === false) { return; } wp_enqueue_style( 'yang-lightbox', ASSET_PATH . '/assets/css/lightbox.css', array('kratos'), THEME_VERSION ); wp_enqueue_script( 'yang-lightbox', ASSET_PATH . '/assets/js/lightbox.js', array(), THEME_VERSION, true ); wp_localize_script('yang-lightbox', 'YANG_LB', array( 'theme' => yang_lightbox_theme(), 'prev' => '上一张', 'next' => '下一张', 'close' => '关闭', 'counter' => '%1$s / %2$s', )); /* 透明度用行内样式注入,不改 lightbox.css 里的默认值。 ── 为什么走行内样式而不是把数值传给 JS ────────────────── 遮罩颜色必须在**首帧**就是对的。走 JS 设置的话,覆盖层 打开的那一瞬会先按 CSS 默认色渲染,再被改掉 —— 快速点击时 能看到颜色闪一下(深色闪白色最明显)。 ── 为什么挂在样式表之后 ────────────────────────────────── wp_add_inline_style 会输出在 lightbox.css 之后, 同优先级下后者胜,才能覆盖掉默认 alpha。 */ wp_add_inline_style('yang-lightbox', yang_lightbox_inline_css()); } add_action('wp_enqueue_scripts', 'yang_lightbox_assets', 22); /* 正文过滤:优先级 11(在 do_shortcode 的 11 之后、wpautop 之后) ⚠️ 必须晚于 imgnofollow(默认 10)—— 那个会给 加 target="_blank", 我们要在它之后把 target 去掉,否则会被它重新加上。 */ add_filter('the_content', 'yang_lightbox_content', 20);