fix(build): 前端资源重建不再破坏补丁自证(webpack 会压缩 mix.copy 过去的文件)

- webpack.mix.js:去掉 context-js 的 mix.copy —— --production 下 webpack 会对 copy 过去的文件做
  压缩改名,public/js 里的补丁版会丢掉 isIOSWebKit / LONG_PRESS_DELAY = 250 / installMenuGuard /
  MENU_OPEN_GRACE 这些标记,既让 Dockerfile 的补丁自证 grep 失败,又与 resources/js 不再字节一致
  (CI 的「两份 context-js.js 内容一致」测试正是抓出这个问题的)
- 新增 scripts/copy-patched-assets.js,package.json 的 production 收尾调用它做字节级拷贝,
  保证 public/js 与 resources/js 两份永远一模一样
- user/images.blade.php 的 md5 同步更新(界面改造后重算)

验证:npm run prod 通过;两份 context-js.js md5 均为 e114c840…(含全部补丁标记);
70/70 jsdom 行为测试通过;verify_repo.py 通过
This commit is contained in:
mole404 committed 2026-09-29 00:34:55 +08:00
1 parent 84029b9d27
commit bdb10c1683
7 files changed
+769 -6

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
fix(build): 前端资源重建不再破坏补丁自证(webpack 会压缩 mix.copy 过去的文件)
- webpack.mix.js:去掉 context-js 的 mix.copy —— --production 下 webpack 会对 copy 过去的文件做
压缩改名,public/js 里的补丁版会丢掉 isIOSWebKit / LONG_PRESS_DELAY = 250 / installMenuGuard /
MENU_OPEN_GRACE 这些标记,既让 Dockerfile 的补丁自证 grep 失败,又与 resources/js 不再字节一致
(CI 的「两份 context-js.js 内容一致」测试正是抓出这个问题的)
- 新增 scripts/copy-patched-assets.js,package.json 的 production 收尾调用它做字节级拷贝,
保证 public/js 与 resources/js 两份永远一模一样
- user/images.blade.php 的 md5 同步更新(界面改造后重算)
验证:npm run prod 通过;两份 context-js.js md5 均为 e114c840…(含全部补丁标记);
70/70 jsdom 行为测试通过;verify_repo.py 通过
+2 -2
View File
@@ -101,7 +101,7 @@ RUN php -r "file_exists('.env') || copy('.env.example', '.env');" \
RUN printf '%s\n' \
'e114c840101d021aa416239196925624 ./public/js/context-js/context-js.js' \
'e114c840101d021aa416239196925624 ./resources/js/context-js.js' \
'ec25ecc2a4f09395db5a50de1883d521 ./resources/views/user/images.blade.php' \
'fb65dc33a394fc28e14af5ab92272406 ./resources/views/user/images.blade.php' \
| md5sum -c - \
&& grep -q "context-js.js') . '?v=ios-longpress4'" ./resources/views/user/images.blade.php \
&& grep -q 'isIOSWebKit' ./public/js/context-js/context-js.js \
@@ -118,7 +118,7 @@ RUN printf '%s\n' \
"fork_sha=${FORK_SHA}" \
"lsky_commit=${LSKY_COMMIT}" \
"context_js_md5=e114c840101d021aa416239196925624" \
"images_blade_md5=ec25ecc2a4f09395db5a50de1883d521" \
"images_blade_md5=fb65dc33a394fc28e14af5ab92272406" \
> .code-revision \
&& cat .code-revision
+1 -1
View File
@@ -7,7 +7,7 @@
"watch-poll": "mix watch -- --watch-options-poll=1000",
"hot": "mix watch --hot",
"prod": "npm run production",
"production": "mix --production"
"production": "mix --production && node scripts/copy-patched-assets.js"
},
"devDependencies": {
"@fortawesome/fontawesome-free": "^5.15.4",
+726 -1
View File
@@ -1 +1,726 @@
window.context=window.context||function(){let e={fadeSpeed:100,filter:function(e){},above:"auto",preventDoubleContext:!0,compress:!1};let t=[],n=!1,o=0,i=0,a=!1;function r(e,r){!function(){if(n)return;n=!0;let e=t.map((function(e){return e+", "+e+" *"})).join(", ")+", .dropdown-context, .dropdown-context *";$('<style id="context-js-ios-touch">').appendTo("head").text(e+" { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }")}(),a||(a=!0,document.addEventListener("click",(function(e){if(!(Date.now()>o)){o=0;for(let n=0;n<t.length;n++)if(e.target.closest&&e.target.closest(t[n]))return e.__contextjsClickSwallowed=!0,e.preventDefault(),void e.stopPropagation()}}),!0));let d=null,c=0,s=0,l=!1;function u(){null!==d&&(clearTimeout(d),d=null)}$(document).on("touchstart",e,(function(e){let t=e.originalEvent,n=t.touches[0];if(u(),l=!1,!n||t.touches.length>1)return;if(t.__contextjsLongPressTaken)return;t.__contextjsLongPressTaken=!0,c=n.clientX,s=n.clientY;let o=this;d=setTimeout((function(){d=null,l=!0,i=Date.now(),r(o,n.clientX,n.clientY)}),250)})),$(document).on("touchmove",e,(function(e){let t=e.originalEvent.touches[0];null!==d&&t&&(Math.abs(t.clientX-c)>10||Math.abs(t.clientY-s)>10)&&u()})),$(document).on("touchend touchcancel",e,(function(e){u(),l&&(o=Date.now()+700,e.preventDefault())}))}let d=!1,c=0,s=0,l=0,u=[],f=!1,p=0,w=0,h=!1,g=!1;function m(){return window.matchMedia?!!window.matchMedia("(hover: none)").matches:"ontouchstart"in window}function v(e){if(!e||!e.tagName)return String(e);let t=e.id?"#"+e.id:"",n="string"==typeof e.className&&e.className.trim()?"."+e.className.trim().split(/\s+/).join("."):"";return e.tagName.toLowerCase()+t+n}function x(e){l=Date.now();let t="["+(new Date).toISOString().slice(11,23)+"] "+e;u.push(t),u.length>240&&(u=u.slice(-240)),f&&window.console&&window.console.log&&window.console.log("[context-js] "+t)}function b(){if(d)return!0;if(Date.now()<=s)return!0;let e=document.querySelectorAll(".dropdown-context");for(let t=0;t<e.length;t++){let n=e[t];if((!n.style||"none"!==n.style.display)&&("function"==typeof n.getBoundingClientRect&&n.getBoundingClientRect().height>0))return!0}return!1}function D(){return Date.now()-c<300}function C(t){d&&x("close reason="+(t||"unknown")+" openFor="+(Date.now()-c)+"ms"),d=!1,s=Date.now()+e.fadeSpeed+50,$(".dropdown-context").fadeOut(e.fadeSpeed,(function(){$(".dropdown-context").css({display:""}),$(".dropdown-context .drop-left").removeClass("drop-left"),$(".dropdown-context .touch-open").removeClass("touch-open")}))}function k(e){return!!(e&&e.closest&&e.closest(".dropdown-context"))}function y(){g||(g=!0,h||(h=!0,$('<style id="context-js-touch-submenu">').appendTo("head").text(".dropdown-context .dropdown-submenu.touch-open > .dropdown-menu { display: block; }")),document.addEventListener("click",(function(e){if(!(e.__contextjsClickSwallowed||Date.now()<=o)){if(Date.now()<=w)return w=0,e.__contextjsClickSwallowed=!0,e.preventDefault(),void e.stopPropagation();if(b()){if(k(e.target))return x("click target="+v(e.target)+" → 菜单内部,放行(交给原有逻辑)"),void function(e){if(!m())return!1;let t=$(e.target).closest("a");if(!t.length)return!1;let n=t.parent();if(!n.length||!n.hasClass("dropdown-submenu"))return!1;e.preventDefault(),e.stopPropagation();let o=n.hasClass("touch-open");if(n.siblings(".dropdown-submenu").removeClass("touch-open"),o)return n.removeClass("touch-open"),!0;n.addClass("touch-open");let i=n.find(".dropdown-context-sub:first");i.length&&(i.width()+i.offset().left>window.innerWidth?i.addClass("drop-left"):i.removeClass("drop-left"))}(e);if(Date.now()-p<800&&Date.now()-c<800)return x("click target="+v(e.target)+" → 吞掉(长按抬手补发的那一发,菜单保持打开)"),e.__contextjsClickSwallowed=!0,e.preventDefault(),void e.stopPropagation();x("click target="+v(e.target)+" → 关菜单 + 吞掉(state="+d+" closing="+Math.max(0,s-Date.now())+"ms)"),C("click-outside"),e.preventDefault(),e.stopPropagation()}else Date.now()-l<3e3&&x("click target="+v(e.target)+" → 放行(屏幕上没有菜单)")}}),!0),document.addEventListener("touchstart",(function(e){p=Date.now(),b()&&!k(e.target)&&(x("touch target="+v(e.target)+" → 关菜单 + 吞掉随后的 click"),C("touch-outside"),w=Date.now()+700)}),!0),document.addEventListener("touchmove",(function(){w=0}),!0),document.addEventListener("scroll",(function(e){d&&!k(e.target)&&(D()?x("scroll target="+v(e.target)+" → 忽略(菜单刚打开 "+(Date.now()-c)+"ms)"):C("scroll"))}),!0),window.addEventListener("resize",(function(){d&&!D()&&C("resize")}),!0),window.addEventListener("orientationchange",(function(){d&&C("orientationchange")}),!0),document.addEventListener("keydown",(function(e){let t=e.key||"";!d||"Escape"!==t&&"Esc"!==t&&27!==e.keyCode||C("esc")}),!0))}function S(t,n,o,i){let a=i?" dropdown-context-sub":"",r=e.compress?" compressed-context":"",d=$('<ul class="dropdown-menu dropdown-contexLine truncated
/*
* Context.js
* Copyright Jacob Kelley, Modified by WispX
* MIT License
*
* ===== fork 补丁(mole404/lsky-pro-docker)=====
* 背景一:iOS 上的 WebKit 永远不会派发 contextmenu 事件(WebKit bug 213953),
* 长按图片只会弹出系统 callout,于是"我的图片"里的自定义菜单在 iPhone/iPad 上完全不可用。
* 处理:在**不改动原有 contextmenu 路径**的前提下,新增一条仅 iOS 生效的长按分支:
* 1. 自己用 touchstart + 定时器识别长按,复用同一段开菜单逻辑(beforeOpen/afterOpen 照旧生效)
* 2. 注入 -webkit-touch-callout: none 压掉 iOS 的原生菜单/放大镜(只作用于本库绑定的元素范围)
* 3. 吞掉 iOS 抬手补发的那一发 click(否则会顺手触发图片预览等点击行为)
*
* 背景二(菜单收放,2026-09-28 追加):
* 原库只在 document 的冒泡阶段 fadeOut,既不阻止事件传播;二级菜单又只靠 CSS :hover 显示。于是
* (a) 点"菜单之外"会点穿到页面(点别的图片顺手开了预览、点链接会跳转);
* (b) 手机上没有光标,点"复制链接"这种带二级菜单的项时,合成鼠标事件让二级菜单闪一下,
* 紧接着那发 click 撞上关闭逻辑,整个菜单直接消失。
* 处理:(a) 在 document 的**捕获阶段**装一层菜单守卫 —— 只关菜单、并吞掉这一发事件;
* (b) 触摸设备((hover: none))改成点击展开/收起二级菜单,有鼠标的设备继续用 hover;
* 另外支持滚动/缩放/Esc 关闭菜单(都是老师要的"收得优雅")。
*
* 隔离性:长按分支只在 iPad/iPhone/iPod(含 iPadOS 伪装成 Mac)上返回真,Android/Windows 继续走
* contextmenu 原路径,长按相关代码一行都不执行。菜单守卫是通用行为,但其中"点击展开二级菜单"
* 只在没有鼠标/触摸板的设备上生效,桌面端交互与上游一致。
*/
window.context = window.context || (function () {
const LONG_PRESS_DELAY = 250; // 长按判定阈值(毫秒)。iOS 原生 callout 约 500ms,这里减半更快响应;
// 手指移动超过 LONG_PRESS_MOVE 即取消,用来抵消阈值变短带来的误触风险
const LONG_PRESS_MOVE = 10; // 手指移动超过这个像素数就取消长按,让位给滚动/框选
const DEDUPE_WINDOW = 700; // 长按已开菜单后,忽略紧随其后的 contextmenu(防将来 iOS 支持该事件后弹两次)
const NEXT_CLICK_WINDOW = 700; // 长按之后要吞掉的第一发 click
const MENU_CLOSE_CLICK_WINDOW = 700; // 因"点了菜单外"而关菜单时,要吞掉随后那一发 click 的时间窗
const MENU_OPEN_GRACE = 800; // 菜单刚打开的这一小段时间里,抬手补发的 click 不许把菜单关掉
// (iOS/Android 长按抬手都可能补发一发 click,目标正是刚被长按的那张图;
// 菜单是手指还按着时就弹出来的,抬手到补发 click 的延迟通常 <500ms,
// 所以留 800ms 余量。真手指再点一次必定先有 touchstart,那条路径另有处理)
const MENU_OPEN_IGNORE_INPUT = 300; // 菜单刚打开的这一小段时间里,忽略 scroll/resize/orientation。
// 为什么需要它:菜单是绝对定位插进 body 的,**插入这个动作本身**就可能
// 让页面高度/滚动条变化,触发浏览器自己的滚动(滚动锚定、图片懒加载重排、
// justified-gallery 重新布局)。这些“非用户操作”的滚动会把菜单刚开就关掉,
// 而关掉之后元素还要淡出,屏幕上看着菜单还在 —— 那一瞬间的点击就会点穿。
const MENU_FADE_GUARD = 50; // 关菜单后,元素还要淡出 fadeSpeed 毫秒;这段时间它在屏幕上,
// 点击必须照样被吞(否则就是“看得见菜单却点穿了”)。这里再加一点余量。
const DEBUG_BUFFER_SIZE = 240; // 诊断环缓冲长度(context.debugDump() 用)
let options = {
fadeSpeed: 100,
filter: function ($obj) {
// Modify $obj, Do not return
},
above: 'auto',
preventDoubleContext: true,
compress: false
};
// ===== fork 补丁:iOS 长按支持(开始)=====
// 是否 iOS/iPadOS 上的 WebKit。注意只判 iOS 不判浏览器:iOS 上 Chrome/Edge/Firefox 内核同样是 WebKit。
// iPadOS 13+ 的"请求桌面网站"会把 UA 伪装成 Mac(platform === 'MacIntel'),
// 而真 Mac —— 包括苹果 M 系列 —— 的 maxTouchPoints 是 0,据此区分,不会误判。
function isIOSWebKit() {
if (! ('ontouchstart' in window)) {
return false;
}
return /iPad|iPhone|iPod/.test(navigator.userAgent)
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
}
let touchSelectors = []; // 已经接上长按分支的选择器
let stylesInjected = false;
let swipeClickUntil = 0; // 需要吞掉 click 的时间窗
let lastOpenAt = 0; // 上一次由长按打开菜单的时间(用于与 contextmenu 去重)
let guardInstalled = false;
// 压掉 iOS 的原生长按菜单与放大镜。只作用于本库绑定的元素范围 + 菜单本身,
// 不碰页面其它地方的链接/文字,保证其它长按能力不受影响。
function injectTouchStyles() {
if (stylesInjected) {
return;
}
stylesInjected = true;
let rules = touchSelectors.map(function (selector) {
return selector + ', ' + selector + ' *';
}).join(', ') + ', .dropdown-context, .dropdown-context *';
$('<style id="context-js-ios-touch">').appendTo('head').text(
rules + ' { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }'
);
}
// 捕获阶段吞掉长按之后 iOS 补发的那一发 click。
// 用 preventDefault + stopPropagation:既不触发元素自身的点击行为(例如打开图片预览),
// 也确认长按手势已经结束。同时在事件上打标,菜单守卫据此知道"这一发已经处理过了"。
function installClickGuard() {
if (guardInstalled) {
return;
}
guardInstalled = true;
document.addEventListener('click', function (e) {
if (Date.now() > swipeClickUntil) {
return;
}
swipeClickUntil = 0;
for (let i = 0; i < touchSelectors.length; i++) {
if (e.target.closest && e.target.closest(touchSelectors[i])) {
e.__contextjsClickSwallowed = true;
e.preventDefault();
e.stopPropagation();
return;
}
}
}, true);
}
// 给某个选择器接上长按识别。仅 iOS 调用。
function installLongPress(selector, openMenu) {
injectTouchStyles();
installClickGuard();
let timer = null,
startX = 0,
startY = 0,
triggered = false;
function cancel() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
}
$(document).on('touchstart', selector, function (e) {
let nativeEvent = e.originalEvent,
touch = nativeEvent.touches[0];
cancel();
triggered = false;
// 多指(缩放等)不参与长按
if (! touch || nativeEvent.touches.length > 1) {
return;
}
// 与桌面端 stopPropagation 的语义保持一致:一次长按只交给最内层(最具体)的那个
// attach 处理。jQuery 的委托分发是从目标向上逐层调用,所以这里是"先到先得",
// 外层容器(例如 #images-scroll 的"刷新"菜单)不会再抢走图片自己的菜单。
if (nativeEvent.__contextjsLongPressTaken) {
return;
}
nativeEvent.__contextjsLongPressTaken = true;
startX = touch.clientX;
startY = touch.clientY;
let item = this;
timer = setTimeout(function () {
timer = null;
triggered = true;
lastOpenAt = Date.now();
openMenu(item, touch.clientX, touch.clientY);
}, LONG_PRESS_DELAY);
});
$(document).on('touchmove', selector, function (e) {
let touch = e.originalEvent.touches[0];
if (timer === null || ! touch) {
return;
}
if (Math.abs(touch.clientX - startX) > LONG_PRESS_MOVE
|| Math.abs(touch.clientY - startY) > LONG_PRESS_MOVE) {
cancel();
}
});
$(document).on('touchend touchcancel', selector, function (e) {
cancel();
if (triggered) {
swipeClickUntil = Date.now() + NEXT_CLICK_WINDOW;
e.preventDefault();
}
});
}
// ===== fork 补丁:iOS 长按支持(结束)=====
// ===== fork 补丁:菜单收放 + 触摸端二级菜单(开始)=====
let menuVisible = false; // 菜单是否打开(状态)。**“拦不拦这一发点击”不能只看它**:
// scroll/resize/Esc/淡出等多条路径都会把它提前置 false,而菜单可能还在屏幕上。
// 一律走 menuOnScreen(),那里以“真实可见性”为准。
let menuOpenedAt = 0; // 菜单打开时刻,用于 MENU_OPEN_GRACE / MENU_OPEN_IGNORE_INPUT
let menuClosingUntil = 0; // 关菜单后到这一刻为止,元素仍在淡出、还在屏幕上
let lastMenuEventAt = 0; // 最近一次菜单相关事件的时刻(诊断用:只记“与菜单有关”的点击)
let debugBuffer = []; // 诊断环缓冲
let debugToConsole = false; // context.debug = true 时实时打控制台
let lastTouchAt = 0; // 最近一次触摸开始时刻:用来区分"触摸补发的 click"和"真鼠标点击"
let suppressClickUntil = 0; // 因"点了菜单外"而关菜单后,要吞掉的那一发 click
let menuStylesInjected = false;
let menuGuardInstalled = false;
// 有鼠标/触摸板的设备((hover: none) 为假)继续用 hover 展开二级菜单;手机/平板改成点击展开。
// jsdom 之类没有 matchMedia 的环境,退回"有没有触摸能力"判定。
function isTouchInput() {
if (window.matchMedia) {
return !! window.matchMedia('(hover: none)').matches;
}
return ('ontouchstart' in window);
}
function injectMenuStyles() {
if (menuStylesInjected) {
return;
}
menuStylesInjected = true;
// 与主题里这条等价,只是改成用类触发(手机上没有 hover):
// .dropdown-context .dropdown-submenu:hover>.dropdown-menu{display:block}
// 桌面端永远不会加上这个类,所以那条路径零影响。
$('<style id="context-js-touch-submenu">').appendTo('head').text(
'.dropdown-context .dropdown-submenu.touch-open > .dropdown-menu { display: block; }'
);
}
// ===== 诊断(fork 新增)=====
// 常驻记录“与菜单有关”的事件:开、关(含原因)、以及菜单牵涉到的点击判定。
// 万一现场还出问题,老师说一句 context.debugDump() 就能把证据拿出来,不用再猜。
function describeNode(node) {
if (! node || ! node.tagName) {
return String(node);
}
let id = node.id ? '#' + node.id : '',
cls = (typeof node.className === 'string' && node.className.trim())
? '.' + node.className.trim().split(/\s+/).join('.')
: '';
return node.tagName.toLowerCase() + id + cls;
}
function logMenuEvent(text) {
lastMenuEventAt = Date.now();
let line = '[' + new Date().toISOString().slice(11, 23) + '] ' + text;
debugBuffer.push(line);
if (debugBuffer.length > DEBUG_BUFFER_SIZE) {
debugBuffer = debugBuffer.slice(-DEBUG_BUFFER_SIZE);
}
if (debugToConsole && window.console && window.console.log) {
window.console.log('[context-js] ' + line);
}
}
// 菜单到底还在不在屏幕上?判据顺序:
// 1) 状态就是打开 → 在;
// 2) 状态刚被置 false 但还没淡出完 → 也在(否则这段时间就是“看得见却点穿”的窗口);
// 3) DOM 实测兜底:元素还画着(高度 > 0)就按“在”算。
// 第 3 条是关键:真浏览器里它才是唯一可信的判据 —— 任何一条把状态提前置 false 的路径,
// 都不该让“屏幕上明明看得见的菜单”失去拦截能力。
function menuOnScreen() {
if (menuVisible) {
return true;
}
if (Date.now() <= menuClosingUntil) {
return true;
}
let nodes = document.querySelectorAll('.dropdown-context');
for (let i = 0; i < nodes.length; i++) {
let el = nodes[i];
if (el.style && el.style.display === 'none') {
continue;
}
if (typeof el.getBoundingClientRect === 'function' && el.getBoundingClientRect().height > 0) {
return true;
}
}
return false;
}
// 菜单刚打开?用来忽略“菜单自己引发的”scroll/resize(见 MENU_OPEN_IGNORE_INPUT)
function justOpenedMenu() {
return (Date.now() - menuOpenedAt) < MENU_OPEN_IGNORE_INPUT;
}
function closeMenus(reason) {
if (menuVisible) {
logMenuEvent('close reason=' + (reason || 'unknown')
+ ' openFor=' + (Date.now() - menuOpenedAt) + 'ms');
}
menuVisible = false;
// 淡出期间元素仍在屏幕上:这段时间的点击必须照样被吞
menuClosingUntil = Date.now() + options.fadeSpeed + MENU_FADE_GUARD;
$('.dropdown-context').fadeOut(options.fadeSpeed, function () {
$('.dropdown-context').css({ display: '' });
$('.dropdown-context .drop-left').removeClass('drop-left');
$('.dropdown-context .touch-open').removeClass('touch-open');
});
}
function isInsideMenu(node) {
return !! (node && node.closest && node.closest('.dropdown-context'));
}
// 触摸设备:点"带二级菜单的父项" → 展开/收起它的二级菜单,且**不关闭**整个菜单。
// 返回 true 表示这次点击已被消费(守卫不要再往下走)。
// 注意父项本身没有 action(只是个容器),所以拦下它不会影响复制等功能:
// 真正干活的叶子项(.copy,ClipboardJS 绑的就是它们)在二级菜单里,点击照旧放行。
function handleSubmenuTap(e) {
if (! isTouchInput()) {
return false;
}
let $a = $(e.target).closest('a');
if (! $a.length) {
return false;
}
let $li = $a.parent();
if (! $li.length || ! $li.hasClass('dropdown-submenu')) {
return false;
}
e.preventDefault();
e.stopPropagation();
let wasOpen = $li.hasClass('touch-open');
// 同一层级只留一个展开的
$li.siblings('.dropdown-submenu').removeClass('touch-open');
if (wasOpen) {
$li.removeClass('touch-open');
return true;
}
$li.addClass('touch-open');
// 复用 hover 路径的溢出检测:先展开(有了尺寸)再判断要不要往左翻
let $sub = $li.find('.dropdown-context-sub:first');
if ($sub.length) {
let subWidth = $sub.width(),
subLeft = $sub.offset().left;
if ((subWidth + subLeft) > window.innerWidth) {
$sub.addClass('drop-left');
} else {
$sub.removeClass('drop-left');
}
}
return true;
}
// 菜单守卫。全部装在 document 的**捕获阶段**:jQuery 的委托与 viewer.js 都挂在冒泡阶段,
// 捕获阶段拦下 = 这一发事件永远不会到达页面自己的处理器。
// 它在 context.init() 时就装好(不是等第一次开菜单),因为守卫要记录"最近一次触摸开始时刻"——
// 第一次长按的 touchstart 必须被记到,否则那次抬手补发的 click 会被误判成真点击、把菜单关掉。
function installMenuGuard() {
if (menuGuardInstalled) {
return;
}
menuGuardInstalled = true;
injectMenuStyles();
document.addEventListener('click', function (e) {
if (e.__contextjsClickSwallowed) {
return; // 同一发事件已被长按分支吞过
}
if (Date.now() <= swipeClickUntil) {
return; // 长按刚开过菜单,抬手那发 click 归长按分支处理
}
// 因"点了菜单外"而关菜单时,菜单在**手指按下**的瞬间就已经关掉了(见下面的
// touchstart),所以这一发必须排在 menuVisible 判断之前,否则漏吞、点击会点穿到页面。
if (Date.now() <= suppressClickUntil) {
suppressClickUntil = 0;
e.__contextjsClickSwallowed = true;
e.preventDefault();
e.stopPropagation();
return;
}
if (! menuOnScreen()) {
// 连“刚关不久、还在淡出”都算不上 → 与菜单无关的普通点击,照旧放行
if (Date.now() - lastMenuEventAt < 3000) {
logMenuEvent('click target=' + describeNode(e.target) + ' → 放行(屏幕上没有菜单)');
}
return;
}
if (isInsideMenu(e.target)) {
logMenuEvent('click target=' + describeNode(e.target) + ' → 菜单内部,放行(交给原有逻辑)');
handleSubmenuTap(e); // 菜单内部:只处理二级菜单的点击展开
return; // 其它内部点击照旧(执行动作 + 原有逻辑关菜单)
}
// 菜单刚打开 + 这一发 click 紧随一次触摸 → 认定是长按抬手补发的那一发 click
// (目标正是刚被长按的图片)。吞掉它,但**不**关菜单,否则菜单会"刚开就自己关掉"。
// 桌面鼠标点击不满足"紧随触摸"这个条件(lastTouchAt 是 0 或很久以前),照常关菜单。
if ((Date.now() - lastTouchAt) < MENU_OPEN_GRACE
&& (Date.now() - menuOpenedAt) < MENU_OPEN_GRACE) {
logMenuEvent('click target=' + describeNode(e.target)
+ ' → 吞掉(长按抬手补发的那一发,菜单保持打开)');
e.__contextjsClickSwallowed = true;
e.preventDefault();
e.stopPropagation();
return;
}
logMenuEvent('click target=' + describeNode(e.target)
+ ' → 关菜单 + 吞掉(state=' + menuVisible + ' closing='
+ Math.max(0, menuClosingUntil - Date.now()) + 'ms)');
// 点击菜单之外的任何位置:只关闭菜单,绝不把这发事件放给页面
// (否则会顺手打开图片预览、跳转链接)
closeMenus('click-outside');
e.preventDefault();
e.stopPropagation();
}, true);
// 触摸:手指一按下就把菜单收起来,比等抬手更跟手。
// **不拦截 touchstart 本身**(没有 preventDefault),所以页面滚动/缩放完全不受影响;
// 随后补发的那发 click 用时间窗吞掉。
document.addEventListener('touchstart', function (e) {
lastTouchAt = Date.now();
if (! menuOnScreen() || isInsideMenu(e.target)) {
return;
}
logMenuEvent('touch target=' + describeNode(e.target) + ' → 关菜单 + 吞掉随后的 click');
closeMenus('touch-outside');
suppressClickUntil = Date.now() + MENU_CLOSE_CLICK_WINDOW;
}, true);
// 手指动了说明是滚动/拖拽手势,别把随后那一发 click 也吞掉
document.addEventListener('touchmove', function () {
suppressClickUntil = 0;
}, true);
// 滚动 / 旋转缩放:菜单用的是页面坐标,视口一动它就会粘在错的地方,直接收起来。
// 监听装在 document 且用捕获,是为了连 #images-scroll 这类内部滚动容器也能收到。
document.addEventListener('scroll', function (e) {
if (! menuVisible || isInsideMenu(e.target)) {
return;
}
if (justOpenedMenu()) {
// 菜单刚插进 body,浏览器自己可能来一发滚动(滚动锚定 / 懒加载重排 / 画廊重排)——
// 这一发不是用户滚的,别拿它把菜单关掉:关掉之后元素还要淡出,
// 屏幕上看着菜单还在,那一瞬间的点击就会点穿。
logMenuEvent('scroll target=' + describeNode(e.target)
+ ' → 忽略(菜单刚打开 ' + (Date.now() - menuOpenedAt) + 'ms)');
return;
}
closeMenus('scroll');
}, true);
window.addEventListener('resize', function () {
if (menuVisible && ! justOpenedMenu()) {
closeMenus('resize');
}
}, true);
window.addEventListener('orientationchange', function () {
if (menuVisible) {
closeMenus('orientationchange');
}
}, true);
// 桌面端顺手:Esc 关闭(不 preventDefault,免得抢走其它 UI 的 Esc)
document.addEventListener('keydown', function (e) {
let key = e.key || '';
if (menuVisible && (key === 'Escape' || key === 'Esc' || e.keyCode === 27)) {
closeMenus('esc');
}
}, true);
}
// ===== fork 补丁:菜单收放 + 触摸端二级菜单(结束)=====
function initialize(opts) {
options = $.extend({}, options, opts);
installMenuGuard();
$(document).on('click', 'html', function () {
// 菜单内部的点击照旧关闭菜单(菜单外的点击在守卫里处理,事件已经被吞掉、到不了这里)
if (menuVisible) {
closeMenus();
}
});
if (options.preventDoubleContext) {
$(document).on('contextmenu', '.dropdown-context', function (e) {
e.preventDefault();
});
}
$(document).on('mouseenter', '.dropdown-submenu', function () {
let $sub = $(this).find('.dropdown-context-sub:first'),
subWidth = $sub.width(),
subLeft = $sub.offset().left,
collision = (subWidth + subLeft) > window.innerWidth;
if (collision) {
$sub.addClass('drop-left');
}
});
}
function updateOptions(opts) {
options = $.extend({}, options, opts);
}
function buildMenu(event, data, id, subMenu) {
let subClass = (subMenu) ? ' dropdown-context-sub' : '',
compressed = options.compress ? ' compressed-context' : '',
$menu = $('<ul class="dropdown-menu dropdown-context' + subClass + compressed + '" id="dropdown-' + id + '"></ul>');
let i = 0, linkTarget = '';
for (i; i < data.length; i++) {
if (typeof data[i].divider !== 'undefined') {
$menu.append('<li class="divider"></li>');
} else if (typeof data[i].header !== 'undefined') {
$menu.append('<li class="nav-header">' + data[i].header + '</li>');
} else {
if (typeof data[i].href == 'undefined') {
data[i].href = 'javascript:void(0)';
}
if (typeof data[i].target !== 'undefined') {
linkTarget = ' target="' + data[i].target + '"';
}
let $sub;
if (typeof data[i].subMenu !== 'undefined') {
$sub = $('<li class="dropdown-submenu"><a tabindex="-1" href="' + data[i].href + '">' + data[i].text + '</a></li>');
} else {
$sub = $('<li><a tabindex="-1" href="' + data[i].href + '"' + linkTarget + '>' + data[i].text + '</a></li>');
}
// show or hide?
if (typeof data[i].visible === 'function') {
if (! data[i].visible(event)) {
$sub.hide();
}
}
let $a = $sub.find('a');
// custom classes
if (typeof data[i].classes !== 'undefined') {
for (const classKey in data[i].classes) {
$a.addClass(data[i].classes[classKey]);
}
}
// custom attributes
if (typeof data[i].attributes !== 'undefined') {
for (const attributesKey in data[i].attributes) {
$a.attr(attributesKey, data[i].attributes[attributesKey]);
}
}
// click callback
if (typeof data[i].action !== 'undefined') {
let actionID = 'event-' + new Date().getTime() * Math.floor(Math.random() * 100000),
eventAction = data[i].action;
$a.attr('id', actionID);
$('#' + actionID).addClass('context-event');
$(document).on('click', '#' + actionID, function () {
eventAction.call(this, event);
});
}
$menu.append($sub);
if (typeof data[i].subMenu != 'undefined') {
let subMenuData = buildMenu(event, data[i].subMenu, id, true);
$menu.find('li:last').append(subMenuData);
}
}
if (typeof options.filter == 'function') {
options.filter($menu.find('li:last'));
}
}
return $menu;
}
/**
* 添加菜单
* @param selector 被右击元素
* @param opts 参数 {
* data: Array[Object] {
* text: String, // 文本
* classes: Array, // class
* attributes: Object, // 属性
* action: Function, // 点击后的回调 function (e) {}
* visible: Function, // 函数返回bool类型,表示显示或隐藏按钮 function (e) {}
* }
* beforeOpen: Function, // 打开前 function (item) {}
* afterOpen: Function, // 打开后 function (item, dropdown) {}
* }
*/
function addContext(selector, opts) {
opts = opts || {};
let data = opts.data || {};
let id = new Date().getTime();
// fork 补丁:把"打开菜单"从 contextmenu 回调里抽出来,
// 让桌面/安卓的 contextmenu 与 iOS 的长按共用同一段逻辑。
// item 为匹配到的元素,pageX/pageY 为菜单定位坐标,evt 用于回调里的 this(长按时为 null)。
function openMenu(item, pageX, pageY, evt) {
if (! item) {
return;
}
typeof opts.beforeOpen === 'function' && opts.beforeOpen.call(evt || item, item);
let $menu = buildMenu(item, data, id);
// clear dropdowns
$('body .dropdown-menu.dropdown-context').remove();
// create dropdown
$('body').append($menu);
$('.dropdown-context:not(.dropdown-context-sub)').hide();
let $dd = $("#dropdown-" + id);
if (typeof options.above == 'boolean' && options.above) {
$dd.addClass('dropdown-context-up').css({
top: pageY - 20 - $('#dropdown-' + id).height(),
left: pageX - 13
}).fadeIn(options.fadeSpeed);
} else if (typeof options.above == 'string' && options.above === 'auto') {
$dd.removeClass('dropdown-context-up');
let autoH = $dd.height() + 12;
if ((pageY + autoH) > $('html').height()) {
$dd.addClass('dropdown-context-up').css({
top: pageY - 20 - autoH,
left: pageX - 13
}).fadeIn(options.fadeSpeed);
} else {
$dd.css({
top: pageY + 10,
left: pageX - 13
}).fadeIn(options.fadeSpeed);
}
}
menuVisible = true;
menuOpenedAt = Date.now();
menuClosingUntil = 0;
logMenuEvent('open trigger=' + (evt ? 'contextmenu' : 'longpress')
+ ' item=' + describeNode(item)
+ ' at=' + Math.round(pageX) + ',' + Math.round(pageY));
typeof opts.afterOpen === 'function' && opts.afterOpen.call(evt || item, item, $dd.get(0));
}
$(document).on('contextmenu', selector, function (e) {
e.preventDefault();
e.stopPropagation();
// 长按刚开过菜单就忽略这一发(避免重复弹菜单)
if (Date.now() - lastOpenAt < DEDUPE_WINDOW) {
return;
}
openMenu(e.target.closest(selector), e.pageX, e.pageY, e);
});
// fork 补丁:iOS 上 contextmenu 永远不来,改由长按触发。仅 iOS 生效。
if (isIOSWebKit()) {
touchSelectors.push(selector);
installLongPress(selector, function (item, clientX, clientY) {
openMenu(item, clientX + window.pageXOffset, clientY + window.pageYOffset, null);
});
}
}
function destroyContext(selector) {
$(document).off('contextmenu', selector).off('click', '.context-event');
}
let api = {
init: initialize,
settings: updateOptions,
attach: addContext,
destroy: destroyContext,
// fork 补丁:暴露菜单开合状态,供测试与诊断用(上游没有这个)
isMenuOpen: function () {
return menuVisible;
},
// fork 补丁:菜单此刻是否“在屏幕上”(状态 + 淡出窗口 + DOM 实测),诊断用
isMenuOnScreen: function () {
return menuOnScreen();
},
// fork 补丁:诊断转储。出问题时在控制台执行 copy(context.debugDump()) 即可复制出来。
debugDump: function () {
let head = [
'# context-js 诊断转储 ' + new Date().toISOString(),
'url: ' + window.location.href,
'ua: ' + navigator.userAgent,
'fadeSpeed: ' + options.fadeSpeed + ' | 触摸输入(hover:none): ' + isTouchInput(),
'menuVisible: ' + menuVisible + ' | menuOnScreen: ' + menuOnScreen()
+ ' | 淡出剩余: ' + Math.max(0, menuClosingUntil - Date.now()) + 'ms',
'--- 事件(时间升序,最多 ' + DEBUG_BUFFER_SIZE + ' 条)---'
];
return head.concat(debugBuffer).join('\n');
}
};
Object.defineProperty(api, 'debug', {
get: function () {
return debugToConsole;
},
set: function (v) {
debugToConsole = !! v;
logMenuEvent('debug 控制台实时输出 = ' + debugToConsole);
}
});
return api;
})();
-1
View File
@@ -16,7 +16,6 @@
"/js/clipboard/clipboard.min.js": "/js/clipboard/clipboard.min.js",
"/js/clipboard/index.browser.js": "/js/clipboard/index.browser.js",
"/js/dragselect/ds.min.js": "/js/dragselect/ds.min.js",
"/js/context-js/context-js.js": "/js/context-js/context-js.js",
"/js/echarts/echarts.min.js": "/js/echarts/echarts.min.js",
"/js/masonry/masonry.pkgd.min.js": "/js/masonry/masonry.pkgd.min.js",
"/js/imagesloaded/imagesloaded.pkgd.min.js": "/js/imagesloaded/imagesloaded.pkgd.min.js",
+23
View File
@@ -0,0 +1,23 @@
/*
* 构建收尾:把「打过补丁、且在 Dockerfile 里按 md5 + 关键标记自证」的资源按原字节拷进 public/。
*
* 为什么不用 webpack 的 mix.copy:
* laravel-mix 在 --production 下会对 copy 过去的文件做压缩改名。我们的补丁文件
* (resources/js/context-js.js,iOS 长按菜单)在镜像构建时按 md5 与关键标记 grep 自证,
* 被压缩后标记消失、md5 变化 → 镜像构建直接失败。
* 这里用字节级拷贝,保证 public/js 与 resources/js 两份永远一致。
*/
const fs = require('fs');
const path = require('path');
const pairs = [
['resources/js/context-js.js', 'public/js/context-js/context-js.js'],
];
pairs.forEach(function (pair) {
const src = path.resolve(__dirname, '..', pair[0]);
const dst = path.resolve(__dirname, '..', pair[1]);
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(src, dst);
console.log('[copy-patched-assets] ' + pair[0] + ' -> ' + pair[1]);
});
+5 -1
View File
@@ -42,7 +42,11 @@ mix.copy('node_modules/dragselect/dist/ds.min.js', 'public/js/dragselect')
// context-menu.min.js
mix.less('resources/css/context-js.less', 'public/css/context-js')
mix.copy('resources/js/context-js.js', 'public/js/context-js')
// 注意:这一份【不能】用 mix.copy —— --production 下 webpack 会对 copy 过去的文件做压缩改名,
// 结果 public/js 里的补丁版被 terser 处理(isIOSWebKit / LONG_PRESS_DELAY = 250 / installMenuGuard
// 这些标记全丢),既让 Dockerfile 的补丁自证 grep 失败,也与 resources/js 那份不再字节一致。
// 改由 npm run prod 收尾做字节级拷贝(scripts/copy-patched-assets.js),保证两份始终一致。
// mix.copy('resources/js/context-js.js', 'public/js/context-js')
// apache echarts
mix.copy('node_modules/echarts/dist/echarts.min.js', 'public/js/echarts')