症状(老师报的):选中图片删除后顶部选中状态不重置 —— 操作栏不收回, 之后任何一次重画又会显示「已选择 N 张图片」,再选一张时计数虚高(实测 1 张显示成 2 张)。 根因:删除成功那支只做 $(item).remove(),没把卡片从 DragSelect 的 SelectedSet / SelectableSet 里摘掉。DS 的 getSelection() 就是 `return SelectedSet.elements`, 全库没有一处 isConnected 判断 → 已删卡片成为「幽灵选中项」;而顶部标题与操作栏 完全由 bindOperates() 按 getSelection() 渲染(挂在 elementselect/elementunselect 上), 所以之后任何一次重画都会带着幽灵重画。 修法(实测两个候选,取更干净的 A): ds.removeSelectables(selected); // 从可选集合摘掉 ds.removeSelection(selected); // 从选中集合摘掉(发 Selected:removed → 自动重画) bindOperates(); // 兜底,顶部统一回「未选中」 验收(真 jQuery + 真 ds.min.js + 逐字抽出的本文件代码,jsdom): 删完 → 标题「我的图片」、可见操作项只剩 [refresh]、getSelection()=0、getSelectables()=2; 重画后仍正确;再选一张显示「已选择 1 张图片」。 同步:Dockerfile 两处 blade md5(自证段 + .code-revision 生成段)重钉为 d9ffacb2bbd8905703c279965a07ddbf;patches/ios-longpress.patch 重生成(并把此前记错的 「上游原文件 md5」更正为 22c896eb7322ec2ff37d5eddd7ca0dec)。
4552 lines
269 KiB
Diff
4552 lines
269 KiB
Diff
# ---------------------------------------------------------------------------
|
||
# fork 补丁存档:iOS 长按菜单 + 菜单交互修复 + 菜单边界钳制 + 手机端二级菜单
|
||
#
|
||
# ┌ 这个文件只是「给人看的 diff 存档」,**不参与构建**。
|
||
# └ 构建用的就是仓库里 src/ 下这三个文件本身(改它们 = 改产品)。
|
||
# 随时可用 `bash tools/diff-vs-upstream.sh` 重新生成/核对这份 diff。
|
||
#
|
||
# 上游基线:lsky-org/lsky-pro @ 38d52c4609eb85236b45ac75acac2ced55174953
|
||
# (2025-11-24 的 master;上游随后再无提交,且 README 已声明"开源版本已停止维护")
|
||
#
|
||
# 补丁内容:
|
||
# 1) iOS/iPadOS 长按(250ms)弹出图床自己的菜单,并吞掉抬手补发的那一发 click
|
||
# 2) 菜单守卫(document 捕获阶段):点菜单之外只关菜单,绝不点穿到页面
|
||
# - 判据是"屏幕上是否真有菜单"(状态 + 淡出窗口 + DOM 实测),不只看内存状态,
|
||
# 否则 scroll/resize/Esc 把状态提前置 false 时会漏拦(→ 偶发点穿开预览)
|
||
# - 菜单刚打开的 300ms 内忽略 scroll/resize(菜单插入自身引发的滚动不该把它自己关掉)
|
||
# 3) 触摸设备(hover:none)点父项展开/收起二级菜单,注入与 hover 等价的 CSS
|
||
# 4) 诊断:context.debugDump() 导出最近 240 条菜单事件(含每次关闭的原因)
|
||
# 5) 菜单/子菜单的视口边界钳制:上游只在 above:'auto' 时做过纵向翻转,横向从未处理,
|
||
# 二级菜单(left:100%)也只在 hover 路径判一次左右翻转、纵向没人管 —— 结果是靠屏幕
|
||
# 边缘右键/长按,菜单和二级菜单都被切在屏幕外。
|
||
# clampMenu():openMenu 定位完统一按视口夹 top/left(主菜单坐标是页面坐标,故用
|
||
# pageXOffset/clientWidth 换算),比视口还高时给 max-height + 内部滚动。
|
||
# fitSubmenu():hover 与 touch-open 两条路径都调用;每次从默认态重算,左右翻不够用
|
||
# 内联 left 兜底、纵向用内联 top 回拉、超高转内部滚动。
|
||
# 只动定位,不碰显示/隐藏逻辑(长按与点击防护依赖 CSS :hover 的真实可见性)。
|
||
# 6) 手机窄屏的二级菜单:上游/早期补丁都是"侧开"(left:100%),窄屏上要么被夹得乱跑、
|
||
# 要么直接盖住主菜单;更糟的是按住不放时手指抬起正好压在第一项上,会被判成选中
|
||
# (实测点「复制链接」直接被当成「Url」并立刻复制)。
|
||
# 做法:触摸端把面板**就地替换**成二级菜单(隐藏其它项,二级 ul 改 position:static
|
||
# + 顶部插一行「返回」),不覆盖、不需要额外宽度;
|
||
# 另加 SUBMENU_GUARD=350ms 防误触窗口:二级菜单刚出现这段时间内的触摸点击一律吞掉,
|
||
# 并要求 new touchstart 才能选中 —— "按住不放"那种手势永远选不中任何一项。
|
||
# 桌面端不受影响:仍是 hover 侧开 + fitSubmenu 钳制。
|
||
# 7) 桌面端右键菜单错位/超出屏幕:上一轮给桌面端加了 html{zoom:1.1}(common.less),
|
||
# zoom 会把菜单(<body> 下的绝对定位元素)的 left/top 长度**再乘一次**,而
|
||
# MouseEvent.pageX/pageY 与 getBoundingClientRect 都是视口物理 px —— 于是菜单
|
||
# 越靠右下偏得越多(≈ 点击坐标×0.1),贴边时被拉出屏幕(实测 1440×900 中心点偏
|
||
# 57.7px、右下角菜单 bottom=983 越界)。
|
||
# 做法:新增 zoomFactor()(运行时读 getComputedStyle(html).zoom,取不到则 1),
|
||
# openMenu / clampMenu / fitSubmenu 一律换算到「元素本地单位」再写 left/top/
|
||
# max-height;夹取用菜单真实盒子保证四边都落在视口内,装不下往左上翻。
|
||
# zoom===1 时算式逐字退化成改动前那套 → <768px 手机端零变化;
|
||
# 「滚动即关闭菜单」那段(document 捕获阶段 scroll 监听)一行未动。
|
||
#
|
||
# 说明(2026-10-01 补):上面第三个文件 images.blade.php 已不只承载「引入脚本那一行的资源版本串」——
|
||
# 本分支在同一个文件里做了(1)标签功能适配(顶部筛选 / 详情卡标签行 / 卡片角标 / 批量打标,
|
||
# 桌面与手机两套入口)/ 唯一的「标签管理」窗口(给选中图片加·减标签 + 标签本身的新建·重命名·删除;
|
||
# 勾选=加上、取消=移除两态;入口:工具栏 / 手机 ⋯ 菜单 / 图片右键菜单 / 标签下拉底部)/
|
||
# 「显示图片标签」开关(存 localStorage)与(2)原「图片权限」界面临口全部下线
|
||
# (原「设置权限」按钮与筛选下拉换成标签)。diff 因此大得多。
|
||
# 三个文件各自的 md5(与 Dockerfile「自证 2」里的期望值一致):
|
||
# c0e513ec8e93fd81b34b3c6de5cf5eb8 src/resources/js/context-js.js
|
||
# c0e513ec8e93fd81b34b3c6de5cf5eb8 src/public/js/context-js/context-js.js
|
||
# d9ffacb2bbd8905703c279965a07ddbf src/resources/views/user/images.blade.php
|
||
#
|
||
# 上游原文件 md5(改动前,供比对):
|
||
# c8e57f6232848ca8277341ddf1a3a7a6 resources/js/context-js.js
|
||
# bab81ff5e43b50a760935c7b3ae6475c public/js/context-js/context-js.js
|
||
# 22c896eb7322ec2ff37d5eddd7ca0dec resources/views/user/images.blade.php
|
||
|
||
# 2026-10-01 第二轮(老师试完测试版提的意见):两个标签窗口合并成一个(旧 tag-manage-modal 删除)、
|
||
# 三态勾选改成两态、加「显示图片标签」开关、修好卡片拖动框选(图片穿透 + 拖动起点放宽 +
|
||
# 由卡片自己开预览)。
|
||
#
|
||
# 2026-10-04(正式版 3.5.2):images.blade.php 再改一处 —— 删除选中图片后,把卡片**同时**从
|
||
# DragSelect 的 SelectedSet / SelectableSet 里摘掉(ds.removeSelectables + ds.removeSelection +
|
||
# bindOperates 兜底)。原来只 $(item).remove() 留在库里的是「幽灵选中项」,导致删除后顶部
|
||
# 选中状态不重置(操作栏不收回、任何一次重画又显示「已选择 N 张图片」、再选一张计数虚高)。
|
||
# 同时修正本文件此前记错的一行:上游原文件 resources/views/user/images.blade.php 的真实 md5
|
||
# 是 22c896eb7322ec2ff37d5eddd7ca0dec(旧值 28c8e000… 实为我们改后那份的值,写错了)。
|
||
---- src/resources/js/context-js.js
|
||
--- /root/.hermes/cache/scratch/tmp.BQ92ALlEpf/up/resources/js/context-js.js 2026-10-02 14:31:26.703747972 +0800
|
||
+++ src/resources/js/context-js.js 2026-10-01 15:40:13.406864745 +0800
|
||
@@ -2,9 +2,49 @@
|
||
* 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) {
|
||
@@ -15,14 +55,859 @@
|
||
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;
|
||
+ let submenuInplaceAt = 0; // 二级菜单就地替换的时刻(诊断/短窗用)
|
||
+ let touchGestureId = 0; // 每一次「手指按下」算一个新手势
|
||
+ let armedGestureId = 0; // 二级菜单是在哪一次手势里被打开的
|
||
+ let submenuArmingTimer = null; // 「刚弹出、先别高亮」的收尾定时器 // 因"点了菜单外"而关菜单后,要吞掉的那一发 click
|
||
+ let armedMenuGestureId = 0; // 主菜单是在哪一次手势里被打开的
|
||
+ let menuArmingTimer = null; // 主菜单「刚弹出、先别高亮」的收尾定时器
|
||
+ let fingerOnScreen = false; // 屏幕上还有没有手指按着(主菜单 arming 窗口据此跟手势续期,而不是死定时器)
|
||
+ 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; }'
|
||
+ // 手机:二级菜单就地在面板里展开(不侧开、不覆盖、不占额外宽度)
|
||
+ + '.dropdown-context.submenu-inplace > li { display: none !important; }'
|
||
+ + '.dropdown-context.submenu-inplace > li.submenu-active,'
|
||
+ + ' .dropdown-context.submenu-inplace > li.submenu-back { display: block !important; }'
|
||
+ + '.dropdown-context.submenu-inplace > li.submenu-active > a { display: none !important; }'
|
||
+ + '.dropdown-context.submenu-inplace > li.submenu-active > .dropdown-menu {'
|
||
+ + ' position: static !important; float: none !important; display: block !important;'
|
||
+ + ' min-width: 0 !important; margin: 0 !important; padding: 0 !important;'
|
||
+ + ' border: 0 !important; border-radius: 0 !important; box-shadow: none !important;'
|
||
+ + ' background: transparent !important; }'
|
||
+ // 主菜单版(与下面二级菜单那份同一套 arming 机制,见 armMenuHoverGuard):
|
||
+ // 长按弹出的主菜单同样会顶在手指底下,真机触摸的粘滞 hover 会把手指底下那一项当成 hover 目标
|
||
+ // → 弹出瞬间那项就跳高亮(老师截图里的「复制图片」)。这里连 li 一起关指针事件:
|
||
+ // 只关 a 的话,主题里的 `.dropdown-submenu:hover > a` 仍会因为 li 被 hover 而给「复制链接」上色。
|
||
+ // 另外 hover 那套是「底色 + 文字变白」两件一起上(见 context-js.less 的 li>a:hover),
|
||
+ // 所以文字色也一并还原 —— 万一 :hover/:focus 还是粘住了,那一项也保持原样,不会变成白底白字。
|
||
+ //
|
||
+ // ⚠ color / background-color 这两条强制覆盖只准落在「真正的菜单项」(`> li > a`) 上,不能落在 li 层:
|
||
+ // 标题是 `> li.nav-header`,自带灰色(.nav-header{color:var(--lsky-text-3)}),
|
||
+ // 被 `color:inherit` 压成面板继承来的近黑(--lsky-text)→ 守卫窗口内「图片操作」黑一下、
|
||
+ // 窗口一过又弹回灰色 = 老师看到的闪烁;
|
||
+ // 同理分隔线 `> li.divider` 有自己的底色,也会被 `background-color:transparent` 打掉又弹回来。
|
||
+ // 防误触真正的关键是 li 上的 pointer-events:none(配合 a 上那份),把强制换色的声明收窄到 a
|
||
+ // 不会削弱任何一层守卫:li 层原本也不需要靠换色来防误触。
|
||
+ + '.dropdown-context.menu-arming > li { pointer-events: none !important; }'
|
||
+ + '.dropdown-context.menu-arming > li > a {'
|
||
+ + ' pointer-events: none !important; background-color: transparent !important; color: inherit !important; }'
|
||
+ // 刚弹出的一瞬间:项不接受指针事件(浏览器就不会把它当成 hover 目标 → 不会高亮),
|
||
+ // 同时把 hover 底色压平做双保险;不改变任何尺寸/位置,面板本身照旧接收点击(由守卫吞掉)。
|
||
+ + '.dropdown-context.submenu-inplace.submenu-arming > li.submenu-active > .dropdown-menu > li > a,'
|
||
+ + ' .dropdown-context.submenu-inplace.submenu-arming > li.submenu-back > a {'
|
||
+ + ' pointer-events: none !important; background-color: transparent !important; }'
|
||
+ + '.dropdown-context.submenu-inplace > li.submenu-active > .dropdown-menu:before,'
|
||
+ + ' .dropdown-context.submenu-inplace > li.submenu-active > .dropdown-menu:after { display: none !important; }'
|
||
+ );
|
||
+ }
|
||
+
|
||
+ // ===== 诊断(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;
|
||
+
|
||
+ exitSubmenuInplace(true); // 就地替换状态跟着菜单一起清掉(不重夹,元素正在淡出)
|
||
+
|
||
+ $('.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 绑的就是它们)在二级菜单里,点击照旧放行。
|
||
+ // ---------- fork 补丁:菜单/子菜单的视口边界钳制 ----------
|
||
+ // 上游只做了「纵向翻转」(above:'auto')和子菜单「右溢出就左翻」,横向从来没有钳制过:
|
||
+ // 靠近右边缘的图片,主菜单会被切掉一半;「复制链接」这类二级菜单固定往右展开,直接飞出屏幕。
|
||
+ // 纵向也没人管,所以子菜单"有时候跑有时候不跑"——取决于菜单开在页面什么高度。
|
||
+ // 这里统一收口:菜单与子菜单在展开后按视口夹回来,装不下就给内部滚动,保证每一项都点得到。
|
||
+ // 只改定位,不碰显示/隐藏逻辑 —— 长按与点击防护依赖 CSS :hover 的真实可见性,动不得。
|
||
+ const EDGE_MARGIN = 8;
|
||
+ const SUBMENU_GUARD = 350; // 二级菜单「就地替换」后这段毫秒内,触摸点击一律吞掉(防按住误选)
|
||
+ const SUBMENU_ARM_MS = 420; // 二级菜单刚弹出的这段毫秒内,先别接受指针指向(防那一下高亮跳色)
|
||
+ const MENU_ARM_MS = 420; // 主菜单(长按弹出)同理:弹出后这段时间内的项不接受指针指向
|
||
+ const MENU_ARM_TAIL_MS = 260; // 抬手之后再压这么多毫秒 —— 老师原话「手抬起来的那一瞬间留冗余」,
|
||
+ // 与二级菜单那份抬手冗余同一个数(见 armMenuHoverGuard / touchend)
|
||
+
|
||
+ function viewportSize() {
|
||
+ const de = document.documentElement;
|
||
+
|
||
+ return {
|
||
+ w: de.clientWidth || window.innerWidth,
|
||
+ h: de.clientHeight || window.innerHeight,
|
||
+ sx: window.pageXOffset || de.scrollLeft || 0,
|
||
+ sy: window.pageYOffset || de.scrollTop || 0
|
||
+ };
|
||
+ }
|
||
+
|
||
+ // 主菜单是 <body> 下的绝对定位元素,它的 left/top/max-height 是**元素本地长度**;
|
||
+ // 桌面端 html{zoom:1.1}(common.less)会让这棵树里的长度在渲染时再乘一次 zoom,
|
||
+ // 而 MouseEvent 的 pageX/clientX、getBoundingClientRect、documentElement.clientWidth
|
||
+ // 用的都是**视口物理 px** —— 两套单位差一个 zoom。写 style 前除以它即可对齐。
|
||
+ // 运行时读实际生效的 zoom(不写死 1.1):取不到 / "normal" → fallback 1,手机端(<768px
|
||
+ // 不命中该 @media)读到的就是 1,全部换算退化为原样,观感零变化。
|
||
+ function zoomFactor() {
|
||
+ try {
|
||
+ const z = parseFloat(window.getComputedStyle(document.documentElement).zoom);
|
||
+ return (isFinite(z) && z > 0) ? z : 1;
|
||
+ } catch (e) {
|
||
+ return 1;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // 菜单的真实盒子(视口物理 px)。display:none 时量不出尺寸 → 临时显形量一次,
|
||
+ // 量完原样还原(只动 display/visibility,不碰定位)。
|
||
+ function measureBox($el) {
|
||
+ const node = $el.get(0);
|
||
+ let rect = node.getBoundingClientRect();
|
||
+ if (! rect.width || ! rect.height) {
|
||
+ const prevDisplay = node.style.display, prevVisibility = node.style.visibility;
|
||
+ node.style.display = 'block';
|
||
+ node.style.visibility = 'hidden';
|
||
+ rect = node.getBoundingClientRect();
|
||
+ node.style.display = prevDisplay;
|
||
+ node.style.visibility = prevVisibility;
|
||
+ }
|
||
+ return rect;
|
||
+ }
|
||
+
|
||
+ // 主菜单是 <body> 下的绝对定位元素,top/left 用的是**页面坐标**。
|
||
+ // 夹取全部在**元素本地单位**里做:凡是物理 px 的量(viewportSize / outerWidth /
|
||
+ // outerHeight / getBoundingClientRect)都先除以实际 zoom,写回 style 的也就是本地值。
|
||
+ // zoom === 1 时每一步都退化成改动前的算式(逐字节同样的结果)→ <768px 手机端零变化。
|
||
+ function clampMenu($el) {
|
||
+ if (! $el || ! $el.length || ! $el.get(0) || ! $el.get(0).parentNode) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ const margin = EDGE_MARGIN;
|
||
+ const vp = viewportSize();
|
||
+ const z = zoomFactor();
|
||
+
|
||
+ // 先看 DOM 里的真实盒子(物理 px,含 display:none 的临时显形测量),再换算成本地长度
|
||
+ const box = measureBox($el);
|
||
+ let w = box.width / z, h = box.height / z;
|
||
+ if (! w || ! h) {
|
||
+ return; // 真量不到就别乱动定位
|
||
+ }
|
||
+
|
||
+ // 视口 / 滚动量换算到元素本地单位
|
||
+ const vw = vp.w / z, vh = vp.h / z, sx = vp.sx / z, sy = vp.sy / z;
|
||
+ const m = margin / z;
|
||
+
|
||
+ // 比视口还高(手机上常见)→ 内部滚动,而不是被裁掉
|
||
+ const maxHeight = vh - m * 2;
|
||
+ if (h > maxHeight) {
|
||
+ $el.css({maxHeight: Math.round(maxHeight) + 'px', overflowY: 'auto'});
|
||
+ h = maxHeight;
|
||
+ }
|
||
+
|
||
+ let left = parseFloat($el.css('left'));
|
||
+ let top = parseFloat($el.css('top'));
|
||
+ left = isNaN(left) ? (sx + m) : left;
|
||
+ top = isNaN(top) ? (sy + m) : top;
|
||
+
|
||
+ if (left + w > sx + vw - m) {
|
||
+ left = sx + vw - m - w;
|
||
+ }
|
||
+ if (left < sx + m) {
|
||
+ left = sx + m;
|
||
+ }
|
||
+ if (top + h > sy + vh - m) {
|
||
+ top = sy + vh - m - h;
|
||
+ }
|
||
+ if (top < sy + m) {
|
||
+ top = sy + m;
|
||
+ }
|
||
+
|
||
+ $el.css({left: Math.round(left) + 'px', top: Math.round(top) + 'px'});
|
||
+ }
|
||
+
|
||
+ // 子菜单相对父 li 定位(left:100% / .drop-left 时 -100%)。调用时它必须是可见的
|
||
+ // (hover 路径靠 CSS :hover,触摸路径靠 .touch-open),否则量不出尺寸。
|
||
+ function fitSubmenu($li) {
|
||
+ const $sub = $li.find('.dropdown-context-sub:first');
|
||
+ if (! $sub.length || ! $sub.get(0)) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 每次都从「默认右开、无内联约束」重算,避免上一次留下的类/内联样式
|
||
+ $sub.removeClass('drop-left').css({left: '', top: '', maxHeight: '', overflowY: ''});
|
||
+
|
||
+ const node = $sub.get(0);
|
||
+ if (! node.getBoundingClientRect().width) {
|
||
+ return; // 此刻不可见,别乱改
|
||
+ }
|
||
+
|
||
+ const margin = EDGE_MARGIN;
|
||
+ const vp = viewportSize();
|
||
+
|
||
+ // ① 横向:右开溢出就翻到左边;翻过去更糟(窄屏)就退回右开
|
||
+ let rect = node.getBoundingClientRect();
|
||
+ if (rect.right > vp.w - margin) {
|
||
+ $sub.addClass('drop-left');
|
||
+ rect = node.getBoundingClientRect();
|
||
+ if (rect.left < margin) {
|
||
+ $sub.removeClass('drop-left');
|
||
+ rect = node.getBoundingClientRect();
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // ② 横向兜底:两边都不够时用内联 left 把子菜单夹进视口(坐标相对父 li)
|
||
+ // rect/liRect/vp 都是视口物理 px;写回的 left 是元素本地长度 → 除以 zoom。
|
||
+ const z = zoomFactor();
|
||
+ const liRect = $li.get(0).getBoundingClientRect();
|
||
+ let shiftX = 0;
|
||
+ if (rect.right > vp.w - margin) {
|
||
+ shiftX = (vp.w - margin) - rect.right;
|
||
+ }
|
||
+ if (rect.left + shiftX < margin) {
|
||
+ shiftX = margin - rect.left;
|
||
+ }
|
||
+ if (shiftX) {
|
||
+ $sub.css({left: Math.round(((rect.left - liRect.left) + shiftX) / z) + 'px'});
|
||
+ rect = node.getBoundingClientRect();
|
||
+ }
|
||
+
|
||
+ // ③ 纵向:溢出上下边就用内联 top 拉回来
|
||
+ const baseTop = parseFloat($sub.css('top'));
|
||
+ let shiftY = 0;
|
||
+ if (rect.bottom > vp.h - margin) {
|
||
+ shiftY = (vp.h - margin) - rect.bottom;
|
||
+ }
|
||
+ if (rect.top + shiftY < margin) {
|
||
+ shiftY = margin - rect.top;
|
||
+ }
|
||
+ if (shiftY) {
|
||
+ $sub.css({top: Math.round((isNaN(baseTop) ? 0 : baseTop) + shiftY / z) + 'px'});
|
||
+ }
|
||
+
|
||
+ // ④ 比视口还高 → 内部滚动
|
||
+ rect = node.getBoundingClientRect();
|
||
+ const maxHeight = vp.h - margin * 2;
|
||
+ if (rect.height > maxHeight) {
|
||
+ $sub.css({maxHeight: Math.round(maxHeight / z) + 'px', overflowY: 'auto'});
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // 手机窄屏没有空间放「侧开」的二级菜单:一开就盖住主菜单,手指还没抬起就会压到第一项上。
|
||
+ // 做法:把面板就地换成二级菜单(顶部插一行「返回」),既不需要额外宽度也不会重叠;
|
||
+ // 再配合 SUBMENU_GUARD 时间窗,按住不放那一发绝不可能选中任何一项。
|
||
+ function rootMenuFor($li) {
|
||
+ return $li.closest('.dropdown-context:not(.dropdown-context-sub)');
|
||
+ }
|
||
+
|
||
+ // 刚弹出的一瞬间先别让任何一项成为指针目标(否则浏览器会把手指底下那项判成 hover → 跳高亮)。
|
||
+ // 窗口从「本次按下」起算,并在抬手时续到抬手之后 —— 按住不动超过窗口也照样不跳色。
|
||
+ function armSubmenuHoverGuard(ms) {
|
||
+ $('.dropdown-context').addClass('submenu-arming');
|
||
+ clearTimeout(submenuArmingTimer);
|
||
+ submenuArmingTimer = setTimeout(function () {
|
||
+ $('.dropdown-context').removeClass('submenu-arming');
|
||
+ }, ms);
|
||
+ }
|
||
+
|
||
+ function clearSubmenuHoverGuard() {
|
||
+ clearTimeout(submenuArmingTimer);
|
||
+ $('.dropdown-context').removeClass('submenu-arming');
|
||
+ }
|
||
+
|
||
+ // 主菜单版(同一套机制,老师要求「复用到长按弹出的主菜单」):
|
||
+ // 主菜单弹在手指底下时,那一项会被浏览器判成 hover 目标 → 弹出瞬间跳高亮,功能和以前一样、就是看着脏。
|
||
+ // 做法与二级菜单那份一致:挂 .menu-arming(里面的项不接受指针事件 + hover 底色压平),
|
||
+ // 抬手后由 touchend 再续 MENU_ARM_TAIL_MS —— 窗口跟着手势走,不是弹出时定死的一个定时器。
|
||
+ // 只在「这一发菜单确实是紧随一次触摸打开」时才挂(判据见 openMenu):桌面右键路径因此一行都不动。
|
||
+ function armMenuHoverGuard(ms) {
|
||
+ $('.dropdown-context:not(.dropdown-context-sub)').addClass('menu-arming');
|
||
+ clearTimeout(menuArmingTimer);
|
||
+ menuArmingTimer = setTimeout(function () {
|
||
+ // 手指还按着(而且就是按出这个菜单的那次手势)→ 续期:按住多久都不跳色;
|
||
+ // 抬手那条路径会再压 MENU_ARM_TAIL_MS 收尾。
|
||
+ if (fingerOnScreen && armedMenuGestureId && armedMenuGestureId === touchGestureId) {
|
||
+ armMenuHoverGuard(MENU_ARM_MS);
|
||
+ return;
|
||
+ }
|
||
+ $('.dropdown-context').removeClass('menu-arming');
|
||
+ }, ms);
|
||
+ }
|
||
+
|
||
+ function clearMenuHoverGuard() {
|
||
+ clearTimeout(menuArmingTimer);
|
||
+ $('.dropdown-context').removeClass('menu-arming');
|
||
+ }
|
||
+
|
||
+ // arming 生效时面板里的项不接受指针事件(这正是"不跳高亮"的关键),于是手指按在菜单项上时
|
||
+ // e.target 拿到的是面板本身 —— 「按一下就展开二级菜单」这条触摸路径会被吃掉。
|
||
+ // 这里按触摸坐标把手指底下那一行找回来,只给这一个分支用,保证窗口内的第一次点按与平时一致。
|
||
+ function rowUnderTouch(e) {
|
||
+ if (! e || ! e.touches || ! e.touches.length) {
|
||
+ return null;
|
||
+ }
|
||
+
|
||
+ const t = e.touches[0], x = t.clientX, y = t.clientY;
|
||
+ const rows = document.querySelectorAll('.dropdown-context:not(.dropdown-context-sub) > li');
|
||
+ for (let i = 0; i < rows.length; i++) {
|
||
+ const r = rows[i].getBoundingClientRect();
|
||
+ if (r.width && r.height && x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
|
||
+ return rows[i];
|
||
+ }
|
||
+ }
|
||
+
|
||
+ return null;
|
||
+ }
|
||
+
|
||
+ // 打开某个父项的二级菜单:手机惯例是「按下就开」,这样抬手那发合成 click 也有东西可吞。
|
||
+ function openSubmenuFor($li) {
|
||
+ $li.siblings('.dropdown-submenu').removeClass('touch-open');
|
||
+ $li.addClass('touch-open');
|
||
+ enterSubmenuInplace($li);
|
||
+ }
|
||
+
|
||
+ function enterSubmenuInplace($li) {
|
||
+ const $menu = rootMenuFor($li);
|
||
+ if (! $menu.length) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ exitSubmenuInplace(true); // 先清掉上一次的
|
||
+ $menu.addClass('submenu-inplace');
|
||
+ $li.addClass('submenu-active');
|
||
+ $li.find('.dropdown-context-sub:first').removeClass('drop-left')
|
||
+ .css({left: '', top: '', maxHeight: '', overflowY: ''});
|
||
+
|
||
+ if (! $menu.children('.submenu-back').length) {
|
||
+ $menu.prepend($('<li class="submenu-back"><a href="javascript:void(0)" tabindex="-1">' +
|
||
+ '<i class="fas fa-chevron-left mr-1"></i>返回</a></li>'));
|
||
+ }
|
||
+
|
||
+ // 刚弹出的一瞬间,手指还压在同一位置上:浏览器会把「手指底下那个元素」当成 hover 目标,
|
||
+ // 于是二级菜单里会有一项立刻跳成高亮 —— 功能上不误触,但观感很脏。
|
||
+ // 弹出后 SUBMENU_ARM_MS 内给面板挂 .submenu-arming:里面的项不接受指针事件、hover 底色也压平,
|
||
+ // 手指不动就不会有高亮;过了这段时间(或者手指动/再点)恢复如常。
|
||
+ armSubmenuHoverGuard(SUBMENU_ARM_MS);
|
||
+
|
||
+ submenuInplaceAt = Date.now();
|
||
+ clampMenu($menu); // 面板高度变了,重新夹一次视口
|
||
+ }
|
||
+
|
||
+ function exitSubmenuInplace(keepGuard) {
|
||
+ const $menu = $('.dropdown-context.submenu-inplace');
|
||
+ if (! $menu.length) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ clearSubmenuHoverGuard();
|
||
+ $menu.removeClass('submenu-inplace').children('.submenu-back').remove();
|
||
+ $menu.children('.submenu-active').removeClass('submenu-active touch-open')
|
||
+ .find('.dropdown-context-sub:first').removeClass('drop-left')
|
||
+ .css({left: '', top: '', maxHeight: '', overflowY: ''});
|
||
+
|
||
+ if (! keepGuard) {
|
||
+ clampMenu($menu);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ 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');
|
||
+ exitSubmenuInplace(false);
|
||
+ return true;
|
||
+ }
|
||
+
|
||
+ openSubmenuFor($li);
|
||
+
|
||
+ 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) + ' → 菜单内部,放行(交给原有逻辑)');
|
||
+ const sameGesture = armedGestureId && armedGestureId === touchGestureId;
|
||
+ if (isTouchInput() && (sameGesture || (Date.now() - submenuInplaceAt) < SUBMENU_GUARD)) {
|
||
+ logMenuEvent('click target=' + describeNode(e.target) + ' → 吞掉(防误触:'
|
||
+ + (sameGesture ? '同一根手指那一发' : (Date.now() - submenuInplaceAt) + 'ms 内') + ')');
|
||
+ e.__contextjsClickSwallowed = true;
|
||
+ e.preventDefault();
|
||
+ e.stopPropagation();
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ if (handleSubmenuTap(e)) { // 点的是带二级菜单的父项:展开/收起,已消费
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 防误触(必须排在父项处理与「返回」之前):真机触摸带粘滞 :hover,二级菜单会先于
|
||
+ // 抬手出现,抬手那发合成 click 就落在菜单项上(老师实测点「复制链接」被判成「Url」,
|
||
+ // 按住久一点(实测 700ms)也照样中招 —— 所以判据是「同一根手指那一发」而不是时长)。
|
||
+ // 判据:这次 click 属于「打开二级菜单的那次手指按下」→ 一律吞掉,什么都不做。
|
||
+
|
||
+ // 二级菜单顶部的「返回」:切回主菜单,这一发到此为止
|
||
+ if ($(e.target).closest('.submenu-back').length) {
|
||
+ e.__contextjsClickSwallowed = true;
|
||
+ e.preventDefault();
|
||
+ e.stopPropagation();
|
||
+ exitSubmenuInplace(false);
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ 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 用时间窗吞掉。
|
||
+ // 手指一按到「带二级菜单的父项」上:立刻就地切换 + 武装防误触窗口。
|
||
+ // 这一步必须在 touchstart 做 —— 真机的粘滞 :hover 会让二级菜单先出现,
|
||
+ // 等抬手才处理就晚了(那时 click 已经落在菜单项上了)。
|
||
+ // 抬手:面板若还在「刚弹出」窗口内,把窗口续到抬手之后 —— 按住再久,那一刻也不跳高亮。
|
||
+ ['touchend', 'touchcancel'].forEach(function (evt) {
|
||
+ document.addEventListener(evt, function (e) {
|
||
+ // 屏幕上还有手指按着吗(多指时抬手一根不算)——主菜单 arming 窗口据此决定要不要续期
|
||
+ fingerOnScreen = !! (e && e.touches && e.touches.length);
|
||
+
|
||
+ // 判据是「这一发手指就是打开二级菜单的那一发」——不能看 .submenu-arming 还在不在:
|
||
+ // 按住超过 SUBMENU_ARM_MS 时,那个类早就被定时器摘掉了(踩过)。
|
||
+ if (armedGestureId && armedGestureId === touchGestureId
|
||
+ && $('.dropdown-context.submenu-inplace').length) {
|
||
+ armSubmenuHoverGuard(260); // 抬手后再压 260ms,抬手那一刻也不会跳色
|
||
+ }
|
||
+
|
||
+ // 主菜单同理(老师原话:「只要给我手抬起来的那一瞬间留冗余就够」)——
|
||
+ // 判据同样是「这一发手指就是打开主菜单的那一发」,按住再久也不会在抬手那一刻跳色。
|
||
+ if (armedMenuGestureId && armedMenuGestureId === touchGestureId
|
||
+ && $('.dropdown-context:not(.dropdown-context-sub)').length) {
|
||
+ armMenuHoverGuard(MENU_ARM_TAIL_MS);
|
||
+ }
|
||
+ }, true);
|
||
+ });
|
||
+
|
||
+ document.addEventListener('touchstart', function (e) {
|
||
+ touchGestureId++; // 新手势:上一发的防误触自动失效
|
||
+
|
||
+ // 只看「手指底下这个 a 的直接父级」—— 二级菜单的项嵌在父 li 里面,
|
||
+ // 用 closest('li.dropdown-submenu') 会把二级菜单里的项也误判成父行。
|
||
+ let $a = $(e.target).closest('a');
|
||
+ // 主菜单 arming 窗口里,项不接受指针事件 → 手指按在「复制链接」上时 e.target 只是面板,
|
||
+ // 这一发就按不到那个 a。按坐标把行找回来,只补「按一下就展开二级菜单」这一条路
|
||
+ // (叶子项靠抬手补发的那发 click,本来就不受影响;就地替换态不兜底,那时的行高/内容都变了)。
|
||
+ if (! $a.length && $('.dropdown-context.menu-arming').length) {
|
||
+ const row = rowUnderTouch(e);
|
||
+ if (row && row.classList.contains('dropdown-submenu')
|
||
+ && ! row.classList.contains('submenu-active')) {
|
||
+ $a = $(row).children('a');
|
||
+ }
|
||
+ }
|
||
+ const $row = $a.parent();
|
||
+ if ($row.hasClass('dropdown-submenu') && $row.closest('.dropdown-context').length) {
|
||
+ if ($row.hasClass('submenu-active')) {
|
||
+ exitSubmenuInplace(false); // 再按一次同一行 → 收起
|
||
+ } else {
|
||
+ openSubmenuFor($row);
|
||
+ }
|
||
+ submenuInplaceAt = Date.now();
|
||
+ armedGestureId = touchGestureId; // 武装到本次手势:这根手指抬起来的那发 click 必吞
|
||
+ } else {
|
||
+ // 手指挪去点别的东西了(包括点菜单项、滑菜单):立刻恢复正常 hover/点击。
|
||
+ // 两套 arming 都清 —— 它们各自只对「按出它来的那一次手势」负责,新手势一律失效。
|
||
+ clearSubmenuHoverGuard();
|
||
+ clearMenuHoverGuard();
|
||
+ }
|
||
+ }, true);
|
||
+
|
||
+ document.addEventListener('touchstart', function (e) {
|
||
+ lastTouchAt = Date.now();
|
||
+ fingerOnScreen = true; // 手指按下:主菜单 arming 窗口可以跟手势续期
|
||
+
|
||
+ 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 () {
|
||
- $('.dropdown-context').fadeOut(options.fadeSpeed, function () {
|
||
- $('.dropdown-context').css({display: ''}).find('.drop-left').removeClass('drop-left');
|
||
- });
|
||
+ // 菜单内部的点击照旧关闭菜单(菜单外的点击在守卫里处理,事件已经被吞掉、到不了这里)
|
||
+ if (menuVisible) {
|
||
+ closeMenus();
|
||
+ }
|
||
});
|
||
if (options.preventDoubleContext) {
|
||
$(document).on('contextmenu', '.dropdown-context', function (e) {
|
||
@@ -30,13 +915,8 @@
|
||
});
|
||
}
|
||
$(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');
|
||
- }
|
||
+ // 此刻 CSS :hover 已经把它展开(有尺寸),再按视口夹一次
|
||
+ fitSubmenu($(this));
|
||
});
|
||
|
||
}
|
||
@@ -130,12 +1010,15 @@
|
||
let data = opts.data || {};
|
||
let id = new Date().getTime();
|
||
|
||
- $(document).on('contextmenu', selector, function (e) {
|
||
- e.preventDefault();
|
||
- e.stopPropagation();
|
||
+ // fork 补丁:把"打开菜单"从 contextmenu 回调里抽出来,
|
||
+ // 让桌面/安卓的 contextmenu 与 iOS 的长按共用同一段逻辑。
|
||
+ // item 为匹配到的元素,pageX/pageY 为菜单定位坐标,evt 用于回调里的 this(长按时为 null)。
|
||
+ function openMenu(item, pageX, pageY, evt) {
|
||
+ if (! item) {
|
||
+ return;
|
||
+ }
|
||
|
||
- let item = e.target.closest(selector);
|
||
- typeof opts.beforeOpen === 'function' && opts.beforeOpen.call(e, item);
|
||
+ typeof opts.beforeOpen === 'function' && opts.beforeOpen.call(evt || item, item);
|
||
|
||
let $menu = buildMenu(item, data, id);
|
||
// clear dropdowns
|
||
@@ -144,42 +1027,122 @@
|
||
$('body').append($menu);
|
||
|
||
$('.dropdown-context:not(.dropdown-context-sub)').hide();
|
||
+ exitSubmenuInplace(true); // 兜底:别把上一次的就地替换状态带进新菜单
|
||
|
||
let $dd = $("#dropdown-" + id);
|
||
|
||
+ // fork 补丁:菜单是 <body> 下的绝对定位元素,left/top 是**元素本地长度**,会被
|
||
+ // html{zoom} 再乘一次;而 pageX/pageY 是视口物理 px。写 style 前除以实际生效的
|
||
+ // zoom(zoomFactor,取不到则 1)→ 菜单左上角与鼠标点对齐。(<768px 无 zoom,退化为原值。)
|
||
+ const z = zoomFactor();
|
||
+
|
||
if (typeof options.above == 'boolean' && options.above) {
|
||
$dd.addClass('dropdown-context-up').css({
|
||
- top: e.pageY - 20 - $('#dropdown-' + id).height(),
|
||
- left: e.pageX - 13
|
||
+ top: (pageY - 20 - $('#dropdown-' + id).height()) / z,
|
||
+ left: (pageX - 13) / z
|
||
}).fadeIn(options.fadeSpeed);
|
||
} else if (typeof options.above == 'string' && options.above === 'auto') {
|
||
$dd.removeClass('dropdown-context-up');
|
||
let autoH = $dd.height() + 12;
|
||
- if ((e.pageY + autoH) > $('html').height()) {
|
||
+ if ((pageY + autoH) > $('html').height()) {
|
||
$dd.addClass('dropdown-context-up').css({
|
||
- top: e.pageY - 20 - autoH,
|
||
- left: e.pageX - 13
|
||
+ top: (pageY - 20 - autoH) / z,
|
||
+ left: (pageX - 13) / z
|
||
}).fadeIn(options.fadeSpeed);
|
||
} else {
|
||
$dd.css({
|
||
- top: e.pageY + 10,
|
||
- left: e.pageX - 13
|
||
+ top: (pageY + 10) / z,
|
||
+ left: (pageX - 13) / z
|
||
}).fadeIn(options.fadeSpeed);
|
||
}
|
||
}
|
||
|
||
- typeof opts.afterOpen === 'function' && opts.afterOpen.call(e, item, $dd.get(0));
|
||
+ // fork 补丁:不管上面走了哪个分支,最后统一按视口夹一次
|
||
+ // (上游只在 above:'auto' 时做了纵向翻转,横向完全没有处理)
|
||
+ clampMenu($dd);
|
||
+
|
||
+ menuVisible = true;
|
||
+ menuOpenedAt = Date.now();
|
||
+ menuClosingUntil = 0;
|
||
+
|
||
+ // 刚弹出的主菜单会顶在手指底下:那一项马上会被当成 hover 目标 → 弹出瞬间就跳高亮。
|
||
+ // 复用二级菜单那套 arming:挂 .menu-arming(项不接受指针事件 + hover 底色压平),抬手后再续 260ms。
|
||
+ // **只在「这一发菜单确实紧随一次触摸」时才挂**:鼠标右键路径(lastTouchAt 是 0 或很久以前)
|
||
+ // 与上游完全一致 —— 桌面本来没有粘滞 hover,不该为此改动付出任何代价。
|
||
+ if ((Date.now() - lastTouchAt) < MENU_OPEN_GRACE) {
|
||
+ armedMenuGestureId = touchGestureId;
|
||
+ armMenuHoverGuard(MENU_ARM_MS);
|
||
+ }
|
||
+
|
||
+ 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');
|
||
}
|
||
|
||
- return {
|
||
+ let api = {
|
||
init: initialize,
|
||
settings: updateOptions,
|
||
attach: addContext,
|
||
- destroy: destroyContext
|
||
+ 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;
|
||
})();
|
||
---- src/public/js/context-js/context-js.js
|
||
--- /root/.hermes/cache/scratch/tmp.BQ92ALlEpf/up/public/js/context-js/context-js.js 2026-10-02 14:31:26.695747977 +0800
|
||
+++ src/public/js/context-js/context-js.js 2026-10-02 12:22:32.584186106 +0800
|
||
@@ -1 +1,1148 @@
|
||
-window.context=window.context||function(){let e={fadeSpeed:100,filter:function(e){},above:"auto",preventDoubleContext:!0,compress:!1};function t(o,n,d,i){let a=i?" dropdown-context-sub":"",s=e.compress?" compressed-context":"",f=$('<ul class="dropdown-menu dropdown-context'+a+s+'" id="dropdown-'+d+'"></ul>'),l=0,r="";for(;l<n.length;l++){if(void 0!==n[l].divider)f.append('<li class="divider"></li>');else if(void 0!==n[l].header)f.append('<li class="nav-header">'+n[l].header+"</li>");else{let e;void 0===n[l].href&&(n[l].href="javascript:void(0)"),void 0!==n[l].target&&(r=' target="'+n[l].target+'"'),e=void 0!==n[l].subMenu?$('<li class="dropdown-submenu"><a tabindex="-1" href="'+n[l].href+'">'+n[l].text+"</a></li>"):$('<li><a tabindex="-1" href="'+n[l].href+'"'+r+">"+n[l].text+"</a></li>"),"function"==typeof n[l].visible&&(n[l].visible(o)||e.hide());let i=e.find("a");if(void 0!==n[l].classes)for(const e in n[l].classes)i.addClass(n[l].classes[e]);if(void 0!==n[l].attributes)for(const e in n[l].attributes)i.attr(e,n[l].attributes[e]);if(void 0!==n[l].action){let e="event-"+(new Date).getTime()*Math.floor(1e5*Math.random()),t=n[l].action;i.attr("id",e),$("#"+e).addClass("context-event"),$(document).on("click","#"+e,(function(){t.call(this,o)}))}if(f.append(e),void 0!==n[l].subMenu){let e=t(o,n[l].subMenu,d,!0);f.find("li:last").append(e)}}"function"==typeof e.filter&&e.filter(f.find("li:last"))}return f}return{init:function(t){e=$.extend({},e,t),$(document).on("click","html",(function(){$(".dropdown-context").fadeOut(e.fadeSpeed,(function(){$(".dropdown-context").css({display:""}).find(".drop-left").removeClass("drop-left")}))})),e.preventDoubleContext&&$(document).on("contextmenu",".dropdown-context",(function(e){e.preventDefault()})),$(document).on("mouseenter",".dropdown-submenu",(function(){let e=$(this).find(".dropdown-context-sub:first");e.width()+e.offset().left>window.innerWidth&&e.addClass("drop-left")}))},settings:function(t){e=$.extend({},e,t)},attach:function(o,n){let d=(n=n||{}).data||{},i=(new Date).getTime();$(document).on("contextmenu",o,(function(a){a.preventDefault(),a.stopPropagation();let s=a.target.closest(o);"function"==typeof n.beforeOpen&&n.beforeOpen.call(a,s);let f=t(s,d,i);$("body .dropdown-menu.dropdown-context").remove(),$("body").append(f),$(".dropdown-context:not(.dropdown-context-sub)").hide();let l=$("#dropdown-"+i);if("boolean"==typeof e.above&&e.above)l.addClass("dropdown-context-up").css({top:a.pageY-20-$("#dropdown-"+i).height(),left:a.pageX-13}).fadeIn(e.fadeSpeed);else if("string"==typeof e.above&&"auto"===e.above){l.removeClass("dropdown-context-up");let t=l.height()+12;a.pageY+t>$("html").height()?l.addClass("dropdown-context-up").css({top:a.pageY-20-t,left:a.pageX-13}).fadeIn(e.fadeSpeed):l.css({top:a.pageY+10,left:a.pageX-13}).fadeIn(e.fadeSpeed)}"function"==typeof n.afterOpen&&n.afterOpen.call(a,s,l.get(0))}))},destroy:function(e){$(document).off("contextmenu",e).off("click",".context-event")}}}();
|
||
+/*
|
||
+ * 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;
|
||
+ let submenuInplaceAt = 0; // 二级菜单就地替换的时刻(诊断/短窗用)
|
||
+ let touchGestureId = 0; // 每一次「手指按下」算一个新手势
|
||
+ let armedGestureId = 0; // 二级菜单是在哪一次手势里被打开的
|
||
+ let submenuArmingTimer = null; // 「刚弹出、先别高亮」的收尾定时器 // 因"点了菜单外"而关菜单后,要吞掉的那一发 click
|
||
+ let armedMenuGestureId = 0; // 主菜单是在哪一次手势里被打开的
|
||
+ let menuArmingTimer = null; // 主菜单「刚弹出、先别高亮」的收尾定时器
|
||
+ let fingerOnScreen = false; // 屏幕上还有没有手指按着(主菜单 arming 窗口据此跟手势续期,而不是死定时器)
|
||
+ 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; }'
|
||
+ // 手机:二级菜单就地在面板里展开(不侧开、不覆盖、不占额外宽度)
|
||
+ + '.dropdown-context.submenu-inplace > li { display: none !important; }'
|
||
+ + '.dropdown-context.submenu-inplace > li.submenu-active,'
|
||
+ + ' .dropdown-context.submenu-inplace > li.submenu-back { display: block !important; }'
|
||
+ + '.dropdown-context.submenu-inplace > li.submenu-active > a { display: none !important; }'
|
||
+ + '.dropdown-context.submenu-inplace > li.submenu-active > .dropdown-menu {'
|
||
+ + ' position: static !important; float: none !important; display: block !important;'
|
||
+ + ' min-width: 0 !important; margin: 0 !important; padding: 0 !important;'
|
||
+ + ' border: 0 !important; border-radius: 0 !important; box-shadow: none !important;'
|
||
+ + ' background: transparent !important; }'
|
||
+ // 主菜单版(与下面二级菜单那份同一套 arming 机制,见 armMenuHoverGuard):
|
||
+ // 长按弹出的主菜单同样会顶在手指底下,真机触摸的粘滞 hover 会把手指底下那一项当成 hover 目标
|
||
+ // → 弹出瞬间那项就跳高亮(老师截图里的「复制图片」)。这里连 li 一起关指针事件:
|
||
+ // 只关 a 的话,主题里的 `.dropdown-submenu:hover > a` 仍会因为 li 被 hover 而给「复制链接」上色。
|
||
+ // 另外 hover 那套是「底色 + 文字变白」两件一起上(见 context-js.less 的 li>a:hover),
|
||
+ // 所以文字色也一并还原 —— 万一 :hover/:focus 还是粘住了,那一项也保持原样,不会变成白底白字。
|
||
+ //
|
||
+ // ⚠ color / background-color 这两条强制覆盖只准落在「真正的菜单项」(`> li > a`) 上,不能落在 li 层:
|
||
+ // 标题是 `> li.nav-header`,自带灰色(.nav-header{color:var(--lsky-text-3)}),
|
||
+ // 被 `color:inherit` 压成面板继承来的近黑(--lsky-text)→ 守卫窗口内「图片操作」黑一下、
|
||
+ // 窗口一过又弹回灰色 = 老师看到的闪烁;
|
||
+ // 同理分隔线 `> li.divider` 有自己的底色,也会被 `background-color:transparent` 打掉又弹回来。
|
||
+ // 防误触真正的关键是 li 上的 pointer-events:none(配合 a 上那份),把强制换色的声明收窄到 a
|
||
+ // 不会削弱任何一层守卫:li 层原本也不需要靠换色来防误触。
|
||
+ + '.dropdown-context.menu-arming > li { pointer-events: none !important; }'
|
||
+ + '.dropdown-context.menu-arming > li > a {'
|
||
+ + ' pointer-events: none !important; background-color: transparent !important; color: inherit !important; }'
|
||
+ // 刚弹出的一瞬间:项不接受指针事件(浏览器就不会把它当成 hover 目标 → 不会高亮),
|
||
+ // 同时把 hover 底色压平做双保险;不改变任何尺寸/位置,面板本身照旧接收点击(由守卫吞掉)。
|
||
+ + '.dropdown-context.submenu-inplace.submenu-arming > li.submenu-active > .dropdown-menu > li > a,'
|
||
+ + ' .dropdown-context.submenu-inplace.submenu-arming > li.submenu-back > a {'
|
||
+ + ' pointer-events: none !important; background-color: transparent !important; }'
|
||
+ + '.dropdown-context.submenu-inplace > li.submenu-active > .dropdown-menu:before,'
|
||
+ + ' .dropdown-context.submenu-inplace > li.submenu-active > .dropdown-menu:after { display: none !important; }'
|
||
+ );
|
||
+ }
|
||
+
|
||
+ // ===== 诊断(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;
|
||
+
|
||
+ exitSubmenuInplace(true); // 就地替换状态跟着菜单一起清掉(不重夹,元素正在淡出)
|
||
+
|
||
+ $('.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 绑的就是它们)在二级菜单里,点击照旧放行。
|
||
+ // ---------- fork 补丁:菜单/子菜单的视口边界钳制 ----------
|
||
+ // 上游只做了「纵向翻转」(above:'auto')和子菜单「右溢出就左翻」,横向从来没有钳制过:
|
||
+ // 靠近右边缘的图片,主菜单会被切掉一半;「复制链接」这类二级菜单固定往右展开,直接飞出屏幕。
|
||
+ // 纵向也没人管,所以子菜单"有时候跑有时候不跑"——取决于菜单开在页面什么高度。
|
||
+ // 这里统一收口:菜单与子菜单在展开后按视口夹回来,装不下就给内部滚动,保证每一项都点得到。
|
||
+ // 只改定位,不碰显示/隐藏逻辑 —— 长按与点击防护依赖 CSS :hover 的真实可见性,动不得。
|
||
+ const EDGE_MARGIN = 8;
|
||
+ const SUBMENU_GUARD = 350; // 二级菜单「就地替换」后这段毫秒内,触摸点击一律吞掉(防按住误选)
|
||
+ const SUBMENU_ARM_MS = 420; // 二级菜单刚弹出的这段毫秒内,先别接受指针指向(防那一下高亮跳色)
|
||
+ const MENU_ARM_MS = 420; // 主菜单(长按弹出)同理:弹出后这段时间内的项不接受指针指向
|
||
+ const MENU_ARM_TAIL_MS = 260; // 抬手之后再压这么多毫秒 —— 老师原话「手抬起来的那一瞬间留冗余」,
|
||
+ // 与二级菜单那份抬手冗余同一个数(见 armMenuHoverGuard / touchend)
|
||
+
|
||
+ function viewportSize() {
|
||
+ const de = document.documentElement;
|
||
+
|
||
+ return {
|
||
+ w: de.clientWidth || window.innerWidth,
|
||
+ h: de.clientHeight || window.innerHeight,
|
||
+ sx: window.pageXOffset || de.scrollLeft || 0,
|
||
+ sy: window.pageYOffset || de.scrollTop || 0
|
||
+ };
|
||
+ }
|
||
+
|
||
+ // 主菜单是 <body> 下的绝对定位元素,它的 left/top/max-height 是**元素本地长度**;
|
||
+ // 桌面端 html{zoom:1.1}(common.less)会让这棵树里的长度在渲染时再乘一次 zoom,
|
||
+ // 而 MouseEvent 的 pageX/clientX、getBoundingClientRect、documentElement.clientWidth
|
||
+ // 用的都是**视口物理 px** —— 两套单位差一个 zoom。写 style 前除以它即可对齐。
|
||
+ // 运行时读实际生效的 zoom(不写死 1.1):取不到 / "normal" → fallback 1,手机端(<768px
|
||
+ // 不命中该 @media)读到的就是 1,全部换算退化为原样,观感零变化。
|
||
+ function zoomFactor() {
|
||
+ try {
|
||
+ const z = parseFloat(window.getComputedStyle(document.documentElement).zoom);
|
||
+ return (isFinite(z) && z > 0) ? z : 1;
|
||
+ } catch (e) {
|
||
+ return 1;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // 菜单的真实盒子(视口物理 px)。display:none 时量不出尺寸 → 临时显形量一次,
|
||
+ // 量完原样还原(只动 display/visibility,不碰定位)。
|
||
+ function measureBox($el) {
|
||
+ const node = $el.get(0);
|
||
+ let rect = node.getBoundingClientRect();
|
||
+ if (! rect.width || ! rect.height) {
|
||
+ const prevDisplay = node.style.display, prevVisibility = node.style.visibility;
|
||
+ node.style.display = 'block';
|
||
+ node.style.visibility = 'hidden';
|
||
+ rect = node.getBoundingClientRect();
|
||
+ node.style.display = prevDisplay;
|
||
+ node.style.visibility = prevVisibility;
|
||
+ }
|
||
+ return rect;
|
||
+ }
|
||
+
|
||
+ // 主菜单是 <body> 下的绝对定位元素,top/left 用的是**页面坐标**。
|
||
+ // 夹取全部在**元素本地单位**里做:凡是物理 px 的量(viewportSize / outerWidth /
|
||
+ // outerHeight / getBoundingClientRect)都先除以实际 zoom,写回 style 的也就是本地值。
|
||
+ // zoom === 1 时每一步都退化成改动前的算式(逐字节同样的结果)→ <768px 手机端零变化。
|
||
+ function clampMenu($el) {
|
||
+ if (! $el || ! $el.length || ! $el.get(0) || ! $el.get(0).parentNode) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ const margin = EDGE_MARGIN;
|
||
+ const vp = viewportSize();
|
||
+ const z = zoomFactor();
|
||
+
|
||
+ // 先看 DOM 里的真实盒子(物理 px,含 display:none 的临时显形测量),再换算成本地长度
|
||
+ const box = measureBox($el);
|
||
+ let w = box.width / z, h = box.height / z;
|
||
+ if (! w || ! h) {
|
||
+ return; // 真量不到就别乱动定位
|
||
+ }
|
||
+
|
||
+ // 视口 / 滚动量换算到元素本地单位
|
||
+ const vw = vp.w / z, vh = vp.h / z, sx = vp.sx / z, sy = vp.sy / z;
|
||
+ const m = margin / z;
|
||
+
|
||
+ // 比视口还高(手机上常见)→ 内部滚动,而不是被裁掉
|
||
+ const maxHeight = vh - m * 2;
|
||
+ if (h > maxHeight) {
|
||
+ $el.css({maxHeight: Math.round(maxHeight) + 'px', overflowY: 'auto'});
|
||
+ h = maxHeight;
|
||
+ }
|
||
+
|
||
+ let left = parseFloat($el.css('left'));
|
||
+ let top = parseFloat($el.css('top'));
|
||
+ left = isNaN(left) ? (sx + m) : left;
|
||
+ top = isNaN(top) ? (sy + m) : top;
|
||
+
|
||
+ if (left + w > sx + vw - m) {
|
||
+ left = sx + vw - m - w;
|
||
+ }
|
||
+ if (left < sx + m) {
|
||
+ left = sx + m;
|
||
+ }
|
||
+ if (top + h > sy + vh - m) {
|
||
+ top = sy + vh - m - h;
|
||
+ }
|
||
+ if (top < sy + m) {
|
||
+ top = sy + m;
|
||
+ }
|
||
+
|
||
+ $el.css({left: Math.round(left) + 'px', top: Math.round(top) + 'px'});
|
||
+ }
|
||
+
|
||
+ // 子菜单相对父 li 定位(left:100% / .drop-left 时 -100%)。调用时它必须是可见的
|
||
+ // (hover 路径靠 CSS :hover,触摸路径靠 .touch-open),否则量不出尺寸。
|
||
+ function fitSubmenu($li) {
|
||
+ const $sub = $li.find('.dropdown-context-sub:first');
|
||
+ if (! $sub.length || ! $sub.get(0)) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 每次都从「默认右开、无内联约束」重算,避免上一次留下的类/内联样式
|
||
+ $sub.removeClass('drop-left').css({left: '', top: '', maxHeight: '', overflowY: ''});
|
||
+
|
||
+ const node = $sub.get(0);
|
||
+ if (! node.getBoundingClientRect().width) {
|
||
+ return; // 此刻不可见,别乱改
|
||
+ }
|
||
+
|
||
+ const margin = EDGE_MARGIN;
|
||
+ const vp = viewportSize();
|
||
+
|
||
+ // ① 横向:右开溢出就翻到左边;翻过去更糟(窄屏)就退回右开
|
||
+ let rect = node.getBoundingClientRect();
|
||
+ if (rect.right > vp.w - margin) {
|
||
+ $sub.addClass('drop-left');
|
||
+ rect = node.getBoundingClientRect();
|
||
+ if (rect.left < margin) {
|
||
+ $sub.removeClass('drop-left');
|
||
+ rect = node.getBoundingClientRect();
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // ② 横向兜底:两边都不够时用内联 left 把子菜单夹进视口(坐标相对父 li)
|
||
+ // rect/liRect/vp 都是视口物理 px;写回的 left 是元素本地长度 → 除以 zoom。
|
||
+ const z = zoomFactor();
|
||
+ const liRect = $li.get(0).getBoundingClientRect();
|
||
+ let shiftX = 0;
|
||
+ if (rect.right > vp.w - margin) {
|
||
+ shiftX = (vp.w - margin) - rect.right;
|
||
+ }
|
||
+ if (rect.left + shiftX < margin) {
|
||
+ shiftX = margin - rect.left;
|
||
+ }
|
||
+ if (shiftX) {
|
||
+ $sub.css({left: Math.round(((rect.left - liRect.left) + shiftX) / z) + 'px'});
|
||
+ rect = node.getBoundingClientRect();
|
||
+ }
|
||
+
|
||
+ // ③ 纵向:溢出上下边就用内联 top 拉回来
|
||
+ const baseTop = parseFloat($sub.css('top'));
|
||
+ let shiftY = 0;
|
||
+ if (rect.bottom > vp.h - margin) {
|
||
+ shiftY = (vp.h - margin) - rect.bottom;
|
||
+ }
|
||
+ if (rect.top + shiftY < margin) {
|
||
+ shiftY = margin - rect.top;
|
||
+ }
|
||
+ if (shiftY) {
|
||
+ $sub.css({top: Math.round((isNaN(baseTop) ? 0 : baseTop) + shiftY / z) + 'px'});
|
||
+ }
|
||
+
|
||
+ // ④ 比视口还高 → 内部滚动
|
||
+ rect = node.getBoundingClientRect();
|
||
+ const maxHeight = vp.h - margin * 2;
|
||
+ if (rect.height > maxHeight) {
|
||
+ $sub.css({maxHeight: Math.round(maxHeight / z) + 'px', overflowY: 'auto'});
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // 手机窄屏没有空间放「侧开」的二级菜单:一开就盖住主菜单,手指还没抬起就会压到第一项上。
|
||
+ // 做法:把面板就地换成二级菜单(顶部插一行「返回」),既不需要额外宽度也不会重叠;
|
||
+ // 再配合 SUBMENU_GUARD 时间窗,按住不放那一发绝不可能选中任何一项。
|
||
+ function rootMenuFor($li) {
|
||
+ return $li.closest('.dropdown-context:not(.dropdown-context-sub)');
|
||
+ }
|
||
+
|
||
+ // 刚弹出的一瞬间先别让任何一项成为指针目标(否则浏览器会把手指底下那项判成 hover → 跳高亮)。
|
||
+ // 窗口从「本次按下」起算,并在抬手时续到抬手之后 —— 按住不动超过窗口也照样不跳色。
|
||
+ function armSubmenuHoverGuard(ms) {
|
||
+ $('.dropdown-context').addClass('submenu-arming');
|
||
+ clearTimeout(submenuArmingTimer);
|
||
+ submenuArmingTimer = setTimeout(function () {
|
||
+ $('.dropdown-context').removeClass('submenu-arming');
|
||
+ }, ms);
|
||
+ }
|
||
+
|
||
+ function clearSubmenuHoverGuard() {
|
||
+ clearTimeout(submenuArmingTimer);
|
||
+ $('.dropdown-context').removeClass('submenu-arming');
|
||
+ }
|
||
+
|
||
+ // 主菜单版(同一套机制,老师要求「复用到长按弹出的主菜单」):
|
||
+ // 主菜单弹在手指底下时,那一项会被浏览器判成 hover 目标 → 弹出瞬间跳高亮,功能和以前一样、就是看着脏。
|
||
+ // 做法与二级菜单那份一致:挂 .menu-arming(里面的项不接受指针事件 + hover 底色压平),
|
||
+ // 抬手后由 touchend 再续 MENU_ARM_TAIL_MS —— 窗口跟着手势走,不是弹出时定死的一个定时器。
|
||
+ // 只在「这一发菜单确实是紧随一次触摸打开」时才挂(判据见 openMenu):桌面右键路径因此一行都不动。
|
||
+ function armMenuHoverGuard(ms) {
|
||
+ $('.dropdown-context:not(.dropdown-context-sub)').addClass('menu-arming');
|
||
+ clearTimeout(menuArmingTimer);
|
||
+ menuArmingTimer = setTimeout(function () {
|
||
+ // 手指还按着(而且就是按出这个菜单的那次手势)→ 续期:按住多久都不跳色;
|
||
+ // 抬手那条路径会再压 MENU_ARM_TAIL_MS 收尾。
|
||
+ if (fingerOnScreen && armedMenuGestureId && armedMenuGestureId === touchGestureId) {
|
||
+ armMenuHoverGuard(MENU_ARM_MS);
|
||
+ return;
|
||
+ }
|
||
+ $('.dropdown-context').removeClass('menu-arming');
|
||
+ }, ms);
|
||
+ }
|
||
+
|
||
+ function clearMenuHoverGuard() {
|
||
+ clearTimeout(menuArmingTimer);
|
||
+ $('.dropdown-context').removeClass('menu-arming');
|
||
+ }
|
||
+
|
||
+ // arming 生效时面板里的项不接受指针事件(这正是"不跳高亮"的关键),于是手指按在菜单项上时
|
||
+ // e.target 拿到的是面板本身 —— 「按一下就展开二级菜单」这条触摸路径会被吃掉。
|
||
+ // 这里按触摸坐标把手指底下那一行找回来,只给这一个分支用,保证窗口内的第一次点按与平时一致。
|
||
+ function rowUnderTouch(e) {
|
||
+ if (! e || ! e.touches || ! e.touches.length) {
|
||
+ return null;
|
||
+ }
|
||
+
|
||
+ const t = e.touches[0], x = t.clientX, y = t.clientY;
|
||
+ const rows = document.querySelectorAll('.dropdown-context:not(.dropdown-context-sub) > li');
|
||
+ for (let i = 0; i < rows.length; i++) {
|
||
+ const r = rows[i].getBoundingClientRect();
|
||
+ if (r.width && r.height && x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
|
||
+ return rows[i];
|
||
+ }
|
||
+ }
|
||
+
|
||
+ return null;
|
||
+ }
|
||
+
|
||
+ // 打开某个父项的二级菜单:手机惯例是「按下就开」,这样抬手那发合成 click 也有东西可吞。
|
||
+ function openSubmenuFor($li) {
|
||
+ $li.siblings('.dropdown-submenu').removeClass('touch-open');
|
||
+ $li.addClass('touch-open');
|
||
+ enterSubmenuInplace($li);
|
||
+ }
|
||
+
|
||
+ function enterSubmenuInplace($li) {
|
||
+ const $menu = rootMenuFor($li);
|
||
+ if (! $menu.length) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ exitSubmenuInplace(true); // 先清掉上一次的
|
||
+ $menu.addClass('submenu-inplace');
|
||
+ $li.addClass('submenu-active');
|
||
+ $li.find('.dropdown-context-sub:first').removeClass('drop-left')
|
||
+ .css({left: '', top: '', maxHeight: '', overflowY: ''});
|
||
+
|
||
+ if (! $menu.children('.submenu-back').length) {
|
||
+ $menu.prepend($('<li class="submenu-back"><a href="javascript:void(0)" tabindex="-1">' +
|
||
+ '<i class="fas fa-chevron-left mr-1"></i>返回</a></li>'));
|
||
+ }
|
||
+
|
||
+ // 刚弹出的一瞬间,手指还压在同一位置上:浏览器会把「手指底下那个元素」当成 hover 目标,
|
||
+ // 于是二级菜单里会有一项立刻跳成高亮 —— 功能上不误触,但观感很脏。
|
||
+ // 弹出后 SUBMENU_ARM_MS 内给面板挂 .submenu-arming:里面的项不接受指针事件、hover 底色也压平,
|
||
+ // 手指不动就不会有高亮;过了这段时间(或者手指动/再点)恢复如常。
|
||
+ armSubmenuHoverGuard(SUBMENU_ARM_MS);
|
||
+
|
||
+ submenuInplaceAt = Date.now();
|
||
+ clampMenu($menu); // 面板高度变了,重新夹一次视口
|
||
+ }
|
||
+
|
||
+ function exitSubmenuInplace(keepGuard) {
|
||
+ const $menu = $('.dropdown-context.submenu-inplace');
|
||
+ if (! $menu.length) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ clearSubmenuHoverGuard();
|
||
+ $menu.removeClass('submenu-inplace').children('.submenu-back').remove();
|
||
+ $menu.children('.submenu-active').removeClass('submenu-active touch-open')
|
||
+ .find('.dropdown-context-sub:first').removeClass('drop-left')
|
||
+ .css({left: '', top: '', maxHeight: '', overflowY: ''});
|
||
+
|
||
+ if (! keepGuard) {
|
||
+ clampMenu($menu);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ 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');
|
||
+ exitSubmenuInplace(false);
|
||
+ return true;
|
||
+ }
|
||
+
|
||
+ openSubmenuFor($li);
|
||
+
|
||
+ 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) + ' → 菜单内部,放行(交给原有逻辑)');
|
||
+ const sameGesture = armedGestureId && armedGestureId === touchGestureId;
|
||
+ if (isTouchInput() && (sameGesture || (Date.now() - submenuInplaceAt) < SUBMENU_GUARD)) {
|
||
+ logMenuEvent('click target=' + describeNode(e.target) + ' → 吞掉(防误触:'
|
||
+ + (sameGesture ? '同一根手指那一发' : (Date.now() - submenuInplaceAt) + 'ms 内') + ')');
|
||
+ e.__contextjsClickSwallowed = true;
|
||
+ e.preventDefault();
|
||
+ e.stopPropagation();
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ if (handleSubmenuTap(e)) { // 点的是带二级菜单的父项:展开/收起,已消费
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 防误触(必须排在父项处理与「返回」之前):真机触摸带粘滞 :hover,二级菜单会先于
|
||
+ // 抬手出现,抬手那发合成 click 就落在菜单项上(老师实测点「复制链接」被判成「Url」,
|
||
+ // 按住久一点(实测 700ms)也照样中招 —— 所以判据是「同一根手指那一发」而不是时长)。
|
||
+ // 判据:这次 click 属于「打开二级菜单的那次手指按下」→ 一律吞掉,什么都不做。
|
||
+
|
||
+ // 二级菜单顶部的「返回」:切回主菜单,这一发到此为止
|
||
+ if ($(e.target).closest('.submenu-back').length) {
|
||
+ e.__contextjsClickSwallowed = true;
|
||
+ e.preventDefault();
|
||
+ e.stopPropagation();
|
||
+ exitSubmenuInplace(false);
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ 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 用时间窗吞掉。
|
||
+ // 手指一按到「带二级菜单的父项」上:立刻就地切换 + 武装防误触窗口。
|
||
+ // 这一步必须在 touchstart 做 —— 真机的粘滞 :hover 会让二级菜单先出现,
|
||
+ // 等抬手才处理就晚了(那时 click 已经落在菜单项上了)。
|
||
+ // 抬手:面板若还在「刚弹出」窗口内,把窗口续到抬手之后 —— 按住再久,那一刻也不跳高亮。
|
||
+ ['touchend', 'touchcancel'].forEach(function (evt) {
|
||
+ document.addEventListener(evt, function (e) {
|
||
+ // 屏幕上还有手指按着吗(多指时抬手一根不算)——主菜单 arming 窗口据此决定要不要续期
|
||
+ fingerOnScreen = !! (e && e.touches && e.touches.length);
|
||
+
|
||
+ // 判据是「这一发手指就是打开二级菜单的那一发」——不能看 .submenu-arming 还在不在:
|
||
+ // 按住超过 SUBMENU_ARM_MS 时,那个类早就被定时器摘掉了(踩过)。
|
||
+ if (armedGestureId && armedGestureId === touchGestureId
|
||
+ && $('.dropdown-context.submenu-inplace').length) {
|
||
+ armSubmenuHoverGuard(260); // 抬手后再压 260ms,抬手那一刻也不会跳色
|
||
+ }
|
||
+
|
||
+ // 主菜单同理(老师原话:「只要给我手抬起来的那一瞬间留冗余就够」)——
|
||
+ // 判据同样是「这一发手指就是打开主菜单的那一发」,按住再久也不会在抬手那一刻跳色。
|
||
+ if (armedMenuGestureId && armedMenuGestureId === touchGestureId
|
||
+ && $('.dropdown-context:not(.dropdown-context-sub)').length) {
|
||
+ armMenuHoverGuard(MENU_ARM_TAIL_MS);
|
||
+ }
|
||
+ }, true);
|
||
+ });
|
||
+
|
||
+ document.addEventListener('touchstart', function (e) {
|
||
+ touchGestureId++; // 新手势:上一发的防误触自动失效
|
||
+
|
||
+ // 只看「手指底下这个 a 的直接父级」—— 二级菜单的项嵌在父 li 里面,
|
||
+ // 用 closest('li.dropdown-submenu') 会把二级菜单里的项也误判成父行。
|
||
+ let $a = $(e.target).closest('a');
|
||
+ // 主菜单 arming 窗口里,项不接受指针事件 → 手指按在「复制链接」上时 e.target 只是面板,
|
||
+ // 这一发就按不到那个 a。按坐标把行找回来,只补「按一下就展开二级菜单」这一条路
|
||
+ // (叶子项靠抬手补发的那发 click,本来就不受影响;就地替换态不兜底,那时的行高/内容都变了)。
|
||
+ if (! $a.length && $('.dropdown-context.menu-arming').length) {
|
||
+ const row = rowUnderTouch(e);
|
||
+ if (row && row.classList.contains('dropdown-submenu')
|
||
+ && ! row.classList.contains('submenu-active')) {
|
||
+ $a = $(row).children('a');
|
||
+ }
|
||
+ }
|
||
+ const $row = $a.parent();
|
||
+ if ($row.hasClass('dropdown-submenu') && $row.closest('.dropdown-context').length) {
|
||
+ if ($row.hasClass('submenu-active')) {
|
||
+ exitSubmenuInplace(false); // 再按一次同一行 → 收起
|
||
+ } else {
|
||
+ openSubmenuFor($row);
|
||
+ }
|
||
+ submenuInplaceAt = Date.now();
|
||
+ armedGestureId = touchGestureId; // 武装到本次手势:这根手指抬起来的那发 click 必吞
|
||
+ } else {
|
||
+ // 手指挪去点别的东西了(包括点菜单项、滑菜单):立刻恢复正常 hover/点击。
|
||
+ // 两套 arming 都清 —— 它们各自只对「按出它来的那一次手势」负责,新手势一律失效。
|
||
+ clearSubmenuHoverGuard();
|
||
+ clearMenuHoverGuard();
|
||
+ }
|
||
+ }, true);
|
||
+
|
||
+ document.addEventListener('touchstart', function (e) {
|
||
+ lastTouchAt = Date.now();
|
||
+ fingerOnScreen = true; // 手指按下:主菜单 arming 窗口可以跟手势续期
|
||
+
|
||
+ 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 () {
|
||
+ // 此刻 CSS :hover 已经把它展开(有尺寸),再按视口夹一次
|
||
+ fitSubmenu($(this));
|
||
+ });
|
||
+
|
||
+ }
|
||
+
|
||
+ 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();
|
||
+ exitSubmenuInplace(true); // 兜底:别把上一次的就地替换状态带进新菜单
|
||
+
|
||
+ let $dd = $("#dropdown-" + id);
|
||
+
|
||
+ // fork 补丁:菜单是 <body> 下的绝对定位元素,left/top 是**元素本地长度**,会被
|
||
+ // html{zoom} 再乘一次;而 pageX/pageY 是视口物理 px。写 style 前除以实际生效的
|
||
+ // zoom(zoomFactor,取不到则 1)→ 菜单左上角与鼠标点对齐。(<768px 无 zoom,退化为原值。)
|
||
+ const z = zoomFactor();
|
||
+
|
||
+ if (typeof options.above == 'boolean' && options.above) {
|
||
+ $dd.addClass('dropdown-context-up').css({
|
||
+ top: (pageY - 20 - $('#dropdown-' + id).height()) / z,
|
||
+ left: (pageX - 13) / z
|
||
+ }).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) / z,
|
||
+ left: (pageX - 13) / z
|
||
+ }).fadeIn(options.fadeSpeed);
|
||
+ } else {
|
||
+ $dd.css({
|
||
+ top: (pageY + 10) / z,
|
||
+ left: (pageX - 13) / z
|
||
+ }).fadeIn(options.fadeSpeed);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // fork 补丁:不管上面走了哪个分支,最后统一按视口夹一次
|
||
+ // (上游只在 above:'auto' 时做了纵向翻转,横向完全没有处理)
|
||
+ clampMenu($dd);
|
||
+
|
||
+ menuVisible = true;
|
||
+ menuOpenedAt = Date.now();
|
||
+ menuClosingUntil = 0;
|
||
+
|
||
+ // 刚弹出的主菜单会顶在手指底下:那一项马上会被当成 hover 目标 → 弹出瞬间就跳高亮。
|
||
+ // 复用二级菜单那套 arming:挂 .menu-arming(项不接受指针事件 + hover 底色压平),抬手后再续 260ms。
|
||
+ // **只在「这一发菜单确实紧随一次触摸」时才挂**:鼠标右键路径(lastTouchAt 是 0 或很久以前)
|
||
+ // 与上游完全一致 —— 桌面本来没有粘滞 hover,不该为此改动付出任何代价。
|
||
+ if ((Date.now() - lastTouchAt) < MENU_OPEN_GRACE) {
|
||
+ armedMenuGestureId = touchGestureId;
|
||
+ armMenuHoverGuard(MENU_ARM_MS);
|
||
+ }
|
||
+
|
||
+ 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;
|
||
+})();
|
||
---- src/resources/views/user/images.blade.php
|
||
--- lsky-org/lsky-pro@38d52c4609eb85236b45ac75acac2ced55174953:resources/views/user/images.blade.php
|
||
+++ src/resources/views/user/images.blade.php
|
||
@@ -4,45 +4,109 @@
|
||
<link rel="stylesheet" href="{{ asset('css/justified-gallery/justifiedGallery.min.css') }}">
|
||
<link rel="stylesheet" href="{{ asset('css/viewer-js/viewer.min.css') }}">
|
||
<link rel="stylesheet" href="{{ asset('css/context-js/context-js.css') }}">
|
||
+ {{-- fork:相册弹窗(#album-switch-modal)自己的样式。本仓库这个补丁只改这一个文件
|
||
+ (common.less 不动,所以写在页面里):
|
||
+ 1) 哨兵文案:与 common.less 里「只在这个弹窗里藏掉 .infinite-scroll」那条同一思路
|
||
+ —— utils.infiniteScroll 往列表末尾插的那行(「加载中.../加载更多/我也是有底线的~」)
|
||
+ 在相册弹窗里藏掉。列表容器仍 overflow-y-auto,「滚到底继续加载下一页」靠容器自己的
|
||
+ scroll 监听,能力没丢(代价同「移动到相册」弹窗:那三种文案在这个弹窗里不再显示,
|
||
+ 接口 status=false 时仍有 toastr.error)。
|
||
+ 2) 桌面宽度:x-modal 的卡片宽度是给「详细信息」那类宽内容定的(md:max-w-2xl / lg:max-w-4xl),
|
||
+ 相册列表在窄卡片里更好看 —— 按弹窗 id 把卡片收窄到约 520px(原来 420px,用户反馈
|
||
+ 「稍微加宽一些」)。手机上 x-modal 是底部抽屉
|
||
+ (<640px 贴底),这条 media query 不生效,抽屉行为不受影响。
|
||
+ 3) 相册行的编辑/删除是**行内常显的 44×44 按钮**(不再靠 hover 才出现),所以这里
|
||
+ 不再需要 @media (hover: none) 那条「给触摸设备常显操作按钮」的补丁 —— 已删掉,
|
||
+ 别再写回来:按钮本来就常显,触摸端与桌面端行为一套。--}}
|
||
+ <style>
|
||
+ /* 让"切图过渡"的起点稳定在屏幕中心。
|
||
+ * 库每切一张都新建 <img>:刚插进画布时用的是默认 CSS(满宽、贴顶,来自库自带的
|
||
+ * .viewer-container img{width:100%;height:auto} + margin:15px auto)。之后图片加载完成,
|
||
+ * 库才写内联样式:先写成"屏幕中心的一个点",再写最终尺寸,靠 CSS 过渡补间。
|
||
+ * 而过渡的起点 = 浏览器「上一次真正渲染过的样式」:若只渲染到"满宽贴顶"那一帧,
|
||
+ * 动画就从屏幕上方飞进来。哪一帧被渲染由缓存/网速/主线程决定 —— 这就是"忽上忽下"。
|
||
+ * 对策:用 :not([style]) 只命中"还没有内联样式"的那一瞬(库一写内联样式立刻失效,
|
||
+ * 因此绝不影响最终显示、也不会和库打架),把这一瞬做成"屏幕中心的小点" ——
|
||
+ * 无论渲染到哪一帧,起点都是中心。过渡照旧用库自带的,所以动画保留。 */
|
||
+ /* ★ 关掉浏览器「滚动锚定」(scroll anchoring):换排序/刷新后列表清空重排,
|
||
+ * 浏览器会把哨兵当锚点、上方内容变高就自动往下补 scrollTop,于是哨兵又进视口、
|
||
+ * 又触发下一页 —— 连锁滚到底。安卓 Edge 上尤其明显(安卓 Chrome / iOS 不这样)。 */
|
||
+ html, body, #images-scroll, #images-scroll .infinite-scroll { overflow-anchor: none; }
|
||
+ /* ★ 让滚动条的位置**永远被预留**(桌面端)。
|
||
+ * 看图器打开时库会给 body 加 .viewer-open{overflow:hidden},滚动条消失 ⇒
|
||
+ * 布局宽度凭空多出 9px(Win11 细滚动条)⇒ 内容右移/重排,关闭时再跳一次。
|
||
+ * 预留 gutter 后,滚动条即使消失,那 9px 也还在 ⇒ 布局宽度不变 ⇒ 不重排。
|
||
+ * 与库量到多少无关:viewerjs 的 scrollbarWidth 只在建实例那一刻量一次
|
||
+ * (render.js initBody),那时文档还没溢出,量到的是 0,所以它补的 paddingRight
|
||
+ * 恒为 0px(真机实测确认过),指望它补是不行的。 */
|
||
+ html { scrollbar-gutter: stable; }
|
||
+ .viewer-canvas > img:not([style]) {
|
||
+ width: 1px;
|
||
+ height: 1px;
|
||
+ margin-left: 50vw;
|
||
+ margin-top: 50vh;
|
||
+ opacity: 0;
|
||
+ }
|
||
+
|
||
+
|
||
+ #album-switch-modal .infinite-scroll {
|
||
+ display: none;
|
||
+ }
|
||
+
|
||
+ @media (min-width: 768px) {
|
||
+ #album-switch-modal [class*="md:max-w-2xl"] {
|
||
+ max-width: 520px;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /* 「显示图片标签」开关关掉时,卡片角标整体不显示(角标是每次翻页重新渲染的,
|
||
+ 所以用容器上的一个类来控制,而不是去改每一张卡片的 DOM)。 */
|
||
+ .image-tags-off .image-tags {
|
||
+ display: none;
|
||
+ }
|
||
+ </style>
|
||
@endpush
|
||
|
||
<x-app-layout>
|
||
- <div class="relative flex justify-between items-center px-2 py-2 z-[3] top-0 left-0 right-0 bg-white border-solid border-b">
|
||
+ {{-- 整页滚动后工具栏要吸在固定顶栏下面(top-14 = 56px),否则一滚就没了 --}}
|
||
+ <div class="sticky top-14 flex justify-between items-center px-2 py-2 z-[3] left-0 right-0 bg-surface border-solid border-b">
|
||
<div class="space-x-2 flex justify-between items-center">
|
||
- <a class="text-sm py-2 px-3 hover:bg-gray-100 rounded text-gray-800" href="javascript:getAlbums()"><i class="fas fa-bars text-blue-500"></i> 相册</a>
|
||
+ <a class="text-sm py-2 px-3 hover:bg-surface-3 rounded text-ink" href="javascript:getAlbums()"><i class="fas fa-bars text-brand"></i> 相册</a>
|
||
<div class="flex-row hidden lg:flex">
|
||
- <a data-operate="movements" class="hidden text-sm py-2 px-3 hover:bg-gray-100 rounded text-gray-800" href="javascript:void(0)">移动到相册</a>
|
||
- <a data-operate="remove" class="hidden text-sm py-2 px-3 hover:bg-gray-100 rounded text-gray-800" href="javascript:void(0)">移出当前相册</a>
|
||
- <a data-operate="permission" class="hidden text-sm py-2 px-3 hover:bg-gray-100 rounded text-gray-800" href="javascript:void(0)">设置权限</a>
|
||
- <a data-operate="detail" class="hidden text-sm py-2 px-3 hover:bg-gray-100 rounded text-gray-800" href="javascript:void(0)">详细信息</a>
|
||
- <a data-operate="rename" class="hidden text-sm py-2 px-3 hover:bg-gray-100 rounded text-gray-800" href="javascript:void(0)">重命名</a>
|
||
- <a data-operate="delete" class="hidden text-sm py-2 px-3 hover:bg-gray-100 rounded text-gray-800" href="javascript:void(0)">删除</a>
|
||
+ <a data-operate="movements" class="hidden text-sm py-2 px-3 hover:bg-surface-3 rounded text-ink" href="javascript:void(0)">移动到相册</a>
|
||
+ <a data-operate="remove" class="hidden text-sm py-2 px-3 hover:bg-surface-3 rounded text-ink" href="javascript:void(0)">移出当前相册</a>
|
||
+ <a data-operate="tag" class="hidden text-sm py-2 px-3 hover:bg-surface-3 rounded text-ink" href="javascript:void(0)">标签管理</a>
|
||
+ <a data-operate="detail" class="hidden text-sm py-2 px-3 hover:bg-surface-3 rounded text-ink" href="javascript:void(0)">详细信息</a>
|
||
+ <a data-operate="rename" class="hidden text-sm py-2 px-3 hover:bg-surface-3 rounded text-ink" href="javascript:void(0)">重命名</a>
|
||
+ <a data-operate="delete" class="hidden text-sm py-2 px-3 hover:bg-surface-3 rounded text-ink" href="javascript:void(0)">删除</a>
|
||
+ <a data-operate="deselect" class="hidden text-sm py-2 px-3 hover:bg-surface-3 rounded text-ink" href="javascript:void(0)">取消选择</a>
|
||
</div>
|
||
<div class="block lg:hidden">
|
||
<x-dropdown direction="right">
|
||
<x-slot name="trigger">
|
||
- <a class="text-sm py-2 px-3 hover:bg-gray-100 rounded text-gray-800" href="javascript:void(0)"><i class="fas fa-ellipsis-h text-blue-500"></i></a>
|
||
+ <a class="text-sm py-2 px-3 hover:bg-surface-3 rounded text-ink" href="javascript:void(0)"><i class="fas fa-ellipsis-h text-brand"></i></a>
|
||
</x-slot>
|
||
|
||
<x-slot name="content">
|
||
<x-dropdown-link data-operate="refresh" href="javascript:void(0)" @click="open = false">刷新</x-dropdown-link>
|
||
<x-dropdown-link data-operate="movements" class="hidden" href="javascript:void(0)" @click="open = false">移动到相册</x-dropdown-link>
|
||
<x-dropdown-link data-operate="remove" class="hidden" href="javascript:void(0)" @click="open = false">移出当前相册</x-dropdown-link>
|
||
- <x-dropdown-link data-operate="permission" class="hidden" href="javascript:void(0)" @click="open = false">设置权限</x-dropdown-link>
|
||
+ <x-dropdown-link data-operate="tag" class="hidden" href="javascript:void(0)" @click="open = false">标签管理</x-dropdown-link>
|
||
<x-dropdown-link data-operate="detail" class="hidden" href="javascript:void(0)" @click="open = false">详细信息</x-dropdown-link>
|
||
<x-dropdown-link data-operate="rename" class="hidden" href="javascript:void(0)" @click="open = false">重命名</x-dropdown-link>
|
||
<x-dropdown-link data-operate="delete" class="hidden" href="javascript:void(0)" @click="open = false">删除</x-dropdown-link>
|
||
+ <x-dropdown-link data-operate="deselect" class="hidden" href="javascript:void(0)" @click="open = false">取消选择</x-dropdown-link>
|
||
</x-slot>
|
||
</x-dropdown>
|
||
</div>
|
||
</div>
|
||
<div class="flex space-x-2 items-center">
|
||
- <input type="text" id="search" class="px-2.5 py-1.5 border-0 outline-none rounded bg-gray-100 text-sm transition-all duration-300 hidden md:block md:w-36 md:hover:w-52 md:focus:w-52" placeholder="输入关键字搜索...">
|
||
+ <input type="text" id="search" class="px-2.5 py-1.5 border-0 outline-none rounded bg-surface-3 text-sm transition-all duration-300 hidden md:block md:w-36 md:hover:w-52 md:focus:w-52" placeholder="输入关键字搜索...">
|
||
<x-dropdown direction="left">
|
||
<x-slot name="trigger">
|
||
- <a id="order" class="text-sm py-2 px-3 hover:bg-gray-100 rounded text-gray-800" href="javascript:void(0)">
|
||
+ <a id="order" class="text-sm py-2 px-3 hover:bg-surface-3 rounded text-ink" href="javascript:void(0)">
|
||
<span>最新</span>
|
||
- <i class="fas fa-sort-alpha-up text-blue-500"></i>
|
||
+ <i class="fas fa-sort-alpha-up text-brand"></i>
|
||
</a>
|
||
</x-slot>
|
||
|
||
@@ -57,151 +121,382 @@
|
||
</x-dropdown-link>
|
||
</x-slot>
|
||
</x-dropdown>
|
||
+ {{-- fork:标签筛选(多选,后端 AND 语义)。这里原来是「权限」下拉,
|
||
+ 已按老师要求整体下线、换成标签 —— 只替换这一个下拉,不新增第三个,
|
||
+ 免得 <768px 时工具栏换行、把 sticky top-14 的吸顶高度撑高。
|
||
+ 标签按用户隔离,列表来自 GET user/tags,由 JS(loadTags → renderTagFilter)
|
||
+ 渲染进 #tag-filter-list;勾选任意一项立刻 resetImages({page:1, tags:[...]})。
|
||
+ 多选项沿用本页约定 min-h-[44px],手机上点得中。--}}
|
||
<x-dropdown direction="left">
|
||
<x-slot name="trigger">
|
||
- <a id="permission" class="text-sm py-2 px-3 hover:bg-gray-100 rounded text-gray-800" href="javascript:void(0)">
|
||
- <span>全部</span>
|
||
- <i class="fas fa-eye text-blue-500"></i>
|
||
+ {{-- 图标与文字改成 flex 居中对齐:原来纯 inline 时图标盒 14px、文字盒 16px 靠基线
|
||
+ 对齐,实测图标中心比文字中心高 0.3px、底部高 1.6px(看着往上飘)。
|
||
+ py 由 2 收到 1.5 是补偿 flex 带来的高度增量,保证按钮总高与原来一致
|
||
+ (否则会顶动 sticky 吸顶工具栏那一行)。--}}
|
||
+ <a id="tag-filter" class="inline-flex items-center gap-1.5 text-sm py-1.5 px-3 hover:bg-surface-3 rounded text-ink" href="javascript:void(0)">
|
||
+ <span>标签</span>
|
||
+ {{-- 实测:图标墨迹中心比文字高 0.5px(文字 90.0 / 图标 89.5,截图按像素量的),
|
||
+ 用 relative+top 精确压下去 0.5px。--}}
|
||
+ <i class="fas fa-tags text-brand relative top-[0.5px]"></i>
|
||
</a>
|
||
</x-slot>
|
||
|
||
<x-slot name="content">
|
||
- <x-dropdown-link href="javascript:void(0)" @click="open = false; setPermission('all')">全部
|
||
- </x-dropdown-link>
|
||
- <x-dropdown-link href="javascript:void(0)" @click="open = false; setPermission('public')">公开
|
||
- </x-dropdown-link>
|
||
- <x-dropdown-link href="javascript:void(0)" @click="open = false; setPermission('private')">私有
|
||
- </x-dropdown-link>
|
||
+ <div id="tag-filter-menu">
|
||
+ <div id="tag-filter-list" class="max-h-[50vh] overflow-y-auto"></div>
|
||
+ <a id="tag-filter-clear" class="ls-menu-item hidden text-brand" href="javascript:void(0)" @click="open = false">清除筛选</a>
|
||
+ {{-- 卡片角标开关:控制图库里图片上的标签显不显示,状态记在本地(默认显示) --}}
|
||
+ <a id="tag-badge-toggle" class="ls-menu-item flex items-center justify-between gap-3 border-t border-line text-ink-2" href="javascript:void(0)" @click="open = false">
|
||
+ {{-- 之前按 canvas 字体度量把文字压低了 1.5px,实测过头(像素法:文字比行中心低 1.49px,
|
||
+ 反倒显得开关偏上)→ 撤回,恢复成自然行高。现在文字/开关都在行中心 ±0.2px 内。--}}
|
||
+ <span>显示图片标签</span>
|
||
+ {{-- 自己画的开关:两态形状完全一样,只变颜色与滑块位置(不用 FontAwesome 的
|
||
+ fa-toggle-on/off —— 那两个字形一粗一细,切起来画风不统一)。 --}}
|
||
+ <span id="tag-badge-switch" aria-hidden="true"
|
||
+ class="relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-brand transition-colors duration-150">
|
||
+ <span class="tag-badge-knob absolute left-[2px] h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-150"></span>
|
||
+ </span>
|
||
+ </a>
|
||
+ </div>
|
||
</x-slot>
|
||
</x-dropdown>
|
||
</div>
|
||
</div>
|
||
- <div class="relative inset-0 h-full overflow-hidden">
|
||
+ {{-- 布局容器已从「绝对定位、确定高度」改成正常流(为让手机浏览器能收起地址栏),
|
||
+ 所以这里不能再靠祖先的高度:h-full 的百分比会解析成 auto,里面的
|
||
+ #images-scroll(absolute inset-0)就跟着算成 0 高 —— 图片墙整片消失。
|
||
+ 改成 flex-1(容器是 flex flex-col + min-h-screen),自己吃掉除工具栏外的剩余高度。--}}
|
||
+ {{-- 图片墙改成随内容长高、由整页滚动承载(手机地址栏才能收起)。
|
||
+ -mb-14 抵消布局容器的 pb-14(56px):不留底栏、图片直接铺到页面底。
|
||
+ 注意:这里不能再有 overflow-hidden —— 弹窗/遮罩已是 fixed,不需要它裁切。--}}
|
||
+ <div class="relative -mb-14">
|
||
<!-- content -->
|
||
- <div id="images-scroll" class="absolute inset-0 overflow-y-scroll dragselect select-none">
|
||
+ {{-- 原来是 absolute inset-0 + overflow-y-scroll 的自滚容器;改成普通块随内容长高,
|
||
+ 无限加载由 utils.infiniteScroll(..., {root:'window'}) 跟随整页滚动 --}}
|
||
+ <div id="images-scroll" class="relative dragselect select-none">
|
||
<div id="images-grid" class="dragselect"></div>
|
||
</div>
|
||
- <!-- right drawer -->
|
||
- <div id="drawer-mask" class="absolute hidden inset-0 bg-gray-500 bg-opacity-50 z-[2]" onclick="drawer.close()"></div>
|
||
- <div id="drawer" class="absolute bg-white w-64 md:w-72 top-0 -right-[1000px] bottom-0 z-[2] flex flex-col transition-all duration-300">
|
||
- <div class="flex justify-between items-center text-md px-3 py-1 border-b">
|
||
- <span class="text-gray-600 truncate" id="drawer-title"></span>
|
||
- <a href="javascript:drawer.close()" class="p-2"><i class="fas fa-times text-blue-500"></i></a>
|
||
- </div>
|
||
- <div id="drawer-content" class="overflow-y-auto"></div>
|
||
- </div>
|
||
</div>
|
||
|
||
+ {{-- fork:图片「详细信息」「移动到相册」「相册列表」全部走居中卡片弹窗(复用 components/modal.blade.php,
|
||
+ 手机上它就是「底部抽屉式」——弹窗容器在 <640px 时贴底、不依赖 hover)。
|
||
+ 旧的右侧抽屉 #drawer / #drawer-mask(markup、JS 状态、无限加载容器)已整块删除:
|
||
+ 顶部工具栏的「相册」入口现在打开 #album-switch-modal。 --}}
|
||
+ <x-modal id="image-detail-modal">
|
||
+ <div id="image-detail-content"></div>
|
||
+ </x-modal>
|
||
+
|
||
+ <x-modal id="image-movements-modal">
|
||
+ <div id="image-movements-content"></div>
|
||
+ </x-modal>
|
||
+
|
||
+ {{-- fork:相册弹窗 —— 相册列表从右侧抽屉搬进来,用的是同一个 x-modal 机制 --}}
|
||
+ <x-modal id="album-switch-modal">
|
||
+ <div id="album-switch-content"></div>
|
||
+ </x-modal>
|
||
+
|
||
+ {{-- fork:「标签管理」弹窗 —— 全站唯一的标签窗口,一次管两件事:
|
||
+ ① 给「选中的这几张图」打标:勾选 = 加上该标签,取消 = 从这些图移除;
|
||
+ ② 标签本身的新建 / 重命名 / 删除。
|
||
+ 入口三处:桌面工具栏 / 手机 ⋯ 菜单的「标签管理」、图片右键菜单、顶部「标签」下拉底部。
|
||
+ (原来「修改标签」和「管理标签」是两个窗口,已按老师要求合并成一个。)--}}
|
||
+ <x-modal id="image-tags-modal">
|
||
+ <div id="image-tags-content"></div>
|
||
+ </x-modal>
|
||
+
|
||
<script type="text/html" id="images-item-tpl">
|
||
<a href="javascript:void(0)" data-id="__id__" data-json='__json__' class="images-item relative cursor-default rounded outline outline-2 outline-offset-2 outline-transparent">
|
||
<div class="image-selector absolute z-[2] top-0 right-0 overflow-hidden cursor-pointer sm:hidden group-hover:block">
|
||
<div class="p-1 text-xl sm:text-2xl">
|
||
- <i class="fas fa-check-circle block rounded-full bg-white text-white border border-gray-500"></i>
|
||
+ <i class="fas fa-check-circle block rounded-full bg-white text-white border border-line-2"></i>
|
||
</div>
|
||
</div>
|
||
- <div class="image-mask absolute left-0 right-0 bottom-0 h-20 z-[1] bg-gradient-to-t from-black" onclick="$(this).siblings('img').trigger('click')">
|
||
+ {{-- 缩略图上的名称/时间遮罩:老师要求先隐藏(不要删代码,以后可能改回来)。
|
||
+ 要恢复:把下面这个 hidden 去掉即可,其余一个字没动。
|
||
+ 注意:遮罩隐藏后点击落在 img 上,看图器照旧正常打开(原来靠这里的 onclick 转发)。--}}
|
||
+ <div class="image-mask hidden absolute left-0 right-0 bottom-0 h-20 z-[1] bg-gradient-to-t from-black" onclick="$(this).siblings('img').trigger('click')">
|
||
<div class="absolute left-2 bottom-2 text-white z-[2] w-[90%]">
|
||
<p class="text-sm truncate filename" title="__name__">__name__</p>
|
||
- <p class="text-xs date" title="__human_date__">__date__</p>
|
||
+ <p class="text-[13.5px] date" title="__human_date__">__date__</p>
|
||
</div>
|
||
</div>
|
||
+ {{-- fork:卡片角标 = 这张图的标签(少量、长名截断)。右上角被 .image-selector 占着,
|
||
+ 所以贴左下;pointer-events-none 是必须的 —— .images-item 本身就是链接,
|
||
+ 角标一旦接住点击,DragSelect 的选中与「点开预览」都会被抢掉。
|
||
+ (这里别写尖括号标签名:这段模板会被静态测试当纯文本取出来渲染。)--}}
|
||
+ <div class="image-tags pointer-events-none absolute left-0 right-0 bottom-0 z-[1] flex flex-wrap items-end gap-1 p-2">__tags__</div>
|
||
<img alt="__name__" data-original="__url__" src="__thumb_url__" width="__width__" height="__height__">
|
||
</a>
|
||
</script>
|
||
|
||
+ {{-- fork:「相册」弹窗的外壳(内容在 getAlbums() 里渲染进 #album-switch-content)。
|
||
+ 标题 16px/600;搜索框按相册名**本地**即时过滤已加载的行(不打接口);
|
||
+ #album-switch-scroll 是无限加载容器(每页 40 条、滚到底自动加载下一页),
|
||
+ 里面放的是 #albums-container-tpl(创建表单 + 相册行 + 加载中/空状态)。
|
||
+ 列表是弹窗里**唯一的主体**:底部那个「完成」按钮已删掉(右上角 ✕ 关弹窗就够),
|
||
+ 所以 max-h-[50vh] + overflow-y-auto 这条限高/滚动规则仍然只归列表自己,别再往底部加东西。
|
||
+ 桌面约 520px 宽见文件顶部 @push('styles') 里那条规则。 --}}
|
||
+ <script type="text/html" id="album-switch-tpl">
|
||
+ <div class="mx-auto flex w-full flex-col">
|
||
+ <p class="text-[16px] font-semibold leading-6 text-ink">__title__</p>
|
||
+ <input type="text" id="album-switch-search" class="ls-input mt-3" placeholder="搜索相册">
|
||
+ <div id="album-switch-scroll" class="mt-3 flex max-h-[50vh] w-full flex-col overflow-y-auto pr-1"></div>
|
||
+ </div>
|
||
+ </script>
|
||
+
|
||
<script type="text/html" id="albums-container-tpl">
|
||
<div id="albums-container" class="flex flex-col justify-center items-center w-full p-3 space-y-2">
|
||
+ {{-- fork:加载中(转圈)——第一页回来之前显示,无限加载的 complete 里收起来 --}}
|
||
+ <div id="album-switch-loading" class="flex w-full items-center justify-center py-4">
|
||
+ <x-loading-spin />
|
||
+ <div class="text-[13px] text-ink-3">加载中...</div>
|
||
+ </div>
|
||
+ {{-- fork:空状态——一个相册都没有时「还没有相册」+ 创建按钮;
|
||
+ 有相册但被搜索过滤光了换成「没有匹配的相册」 --}}
|
||
+ <div id="album-switch-empty" class="hidden flex w-full flex-col items-center justify-center gap-3 py-4">
|
||
+ <p id="album-switch-empty-text" class="text-[14px] text-ink-3">还没有相册</p>
|
||
+ <button type="button" class="ls-btn h-11 px-4 sm:h-9" onclick="$('#album-add').toggleClass('hidden')">创建相册</button>
|
||
+ </div>
|
||
+ {{-- fork:创建相册入口(原抽屉标题上的 + 号,机制一字未改:切换 #album-add 的显示) --}}
|
||
+ <button type="button" id="album-switch-create" class="flex min-h-[44px] w-full items-center gap-2.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-left text-[14px] text-brand hover:bg-surface-3" onclick="$('#album-add').toggleClass('hidden')">
|
||
+ <i class="fas fa-plus w-4 shrink-0 text-center" aria-hidden="true"></i>
|
||
+ <div class="min-w-0 flex-1 truncate">创建相册</div>
|
||
+ </button>
|
||
<div id="album-add" class="flex flex-col w-full hidden border rounded p-2">
|
||
<p class="error-message text-white p-2 mb-2 text-sm bg-red-500 rounded hidden"></p>
|
||
<form class="w-full space-y-2" action="/user/albums">
|
||
- <input type="text" class="w-full rounded px-2.5 py-1.5 text-sm border-0 bg-gray-200" name="name" placeholder="请输入名称">
|
||
- <textarea class="w-full resize-y rounded-md text-sm border-0 bg-gray-200" name="intro" placeholder="请输入简介"></textarea>
|
||
- <button class="w-full py-1 px-2 bg-indigo-500 text-white text-sm text-center tracking-wider font-semibold rounded-md">创建相册</button>
|
||
+ <input type="text" class="w-full rounded px-2.5 py-1.5 text-sm border-0 bg-surface-3" name="name" placeholder="请输入名称">
|
||
+ <textarea class="w-full resize-y rounded-md text-sm border-0 bg-surface-3" name="intro" placeholder="请输入简介"></textarea>
|
||
+ <button class="w-full py-1 px-2 bg-brand text-white text-sm text-center tracking-wider font-semibold rounded-md">创建相册</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</script>
|
||
|
||
+ {{-- fork:相册行(列表从抽屉搬进弹窗、再重做成「行内分区」):
|
||
+ 外层 .albums-row(承载 border/bg + 当前相册高亮 + data-id/data-json)里分两块 ——
|
||
+ 左边 <a class="albums-item"> 是切换区(min-h-[44px],名称 + 「当前」徽标 + 张数),
|
||
+ 右边 .albums-actions 是**常显的两个 44×44 按钮**(编辑/删除)。
|
||
+ 为什么按钮要移出 <a>:它们原来长在行链接里,图标可点区只有十几像素,点不中就落到
|
||
+ <a> 上直接跳进相册 —— 移出来 + 给足 44px 才点得中(顺带不用 group-hover 切换,
|
||
+ 张数也不再被按钮顶掉,行内容不再抽动)。
|
||
+ 名称是 <a> 里的 <div class="name">(**原来这里是 <span>**:utils.infiniteScroll 对列表
|
||
+ 容器挂的是「点里面的 span 就加载更多」的委托,相册名是 span 时点一下名字就顺手多拉
|
||
+ 一页相册 —— 现在行内除哨兵外一个 span 都没有;编辑面板按 class 找的 .name 照旧)。
|
||
+ 张数保持 <div class="albums-count">。
|
||
+ 基础态必须留着 border-line bg-surface,toggleClass 才有东西可换。
|
||
+
|
||
+ ⚠ 列表末尾那条「我也是有底线的~」哨兵是 utils.infiniteScroll 自己插进去的
|
||
+ `.infinite-scroll > span`,那才是**有意的**触发器,必须保持 span、不要动。 --}}
|
||
<script type="text/html" id="albums-item-tpl">
|
||
- <a href="javascript:void(0)" data-id="__id__" data-json='__json__' title="__intro__" class="albums-item flex justify-between items-center group px-2 h-7 rounded w-full bg-gray-100 text-gray-800 hover:bg-blue-300 hover:text-white">
|
||
- <span class="text-sm truncate w-[80%] name">__name__</span>
|
||
- <div class="flex items-center justify-center space-x-1 hidden group-hover:block">
|
||
- <span class="update"><i class="fas fa-edit text-xs"></i></span>
|
||
- <span class="delete"><i class="fas fa-trash-alt text-xs text-red-400"></i></span>
|
||
+ <div class="albums-row flex items-stretch min-h-[44px] w-full rounded-lg border border-line bg-surface" data-id="__id__" data-json='__json__'>
|
||
+ <a href="javascript:void(0)" data-id="__id__" data-json='__json__' title="__intro__" class="albums-item group flex min-w-0 flex-1 items-center gap-2.5 rounded-l-lg px-3 py-1">
|
||
+ <div class="min-w-0 flex-1 truncate text-[14px] name">__name__</div>
|
||
+ __current_badge__
|
||
+ <div class="albums-count shrink-0 text-[13px] text-ink-3">__image_num__ 张</div>
|
||
+ </a>
|
||
+ <div class="albums-actions flex shrink-0 items-center border-l border-line">
|
||
+ <button type="button" class="update flex h-11 w-11 items-center justify-center text-ink-2 hover:bg-surface-3 hover:text-brand" aria-label="重命名相册"><i class="fas fa-edit text-[15px]"></i></button>
|
||
+ <button type="button" class="delete flex h-11 w-11 items-center justify-center text-danger hover:bg-surface-3" aria-label="删除相册"><i class="fas fa-trash-alt text-[15px]"></i></button>
|
||
</div>
|
||
- <span class="group-hover:hidden text-xs">__image_num__</span>
|
||
- </a>
|
||
+ </div>
|
||
</script>
|
||
|
||
<script type="text/html" id="album-update-tpl">
|
||
<div id="album-edit" data-id="__id__" class="flex flex-col w-full border rounded p-2">
|
||
<p class="error-message text-white p-2 mb-2 text-sm bg-red-500 rounded hidden"></p>
|
||
<form class="w-full space-y-2" action="/user/albums/__id__">
|
||
- <input type="text" class="w-full rounded px-2.5 py-1.5 text-sm border-0 bg-gray-200" placeholder="请输入名称" name="name" value="__name__">
|
||
- <textarea class="w-full resize-y rounded-md text-sm border-0 bg-gray-200" name="intro" placeholder="请输入简介">__intro__</textarea>
|
||
- <button class="w-full py-1 px-2 bg-indigo-500 text-white text-sm text-center tracking-wider font-semibold rounded-md">确认修改</button>
|
||
+ <input type="text" class="w-full rounded px-2.5 py-1.5 text-sm border-0 bg-surface-3" placeholder="请输入名称" name="name" value="__name__">
|
||
+ <textarea class="w-full resize-y rounded-md text-sm border-0 bg-surface-3" name="intro" placeholder="请输入简介">__intro__</textarea>
|
||
+ <button class="w-full py-1 px-2 bg-brand text-white text-sm text-center tracking-wider font-semibold rounded-md">确认修改</button>
|
||
</form>
|
||
</div>
|
||
</script>
|
||
|
||
+ {{-- 图片「详细信息」:居中卡片弹窗(不再渲染进右侧抽屉)。
|
||
+ 字段顺序按老师要求:**上传时间第一、图片名称紧跟其后**,其余字段一个没删、只换了位置
|
||
+ (相册名称 / 使用策略 / 图片原始名称 / 图片大小 / 图片类型 / 尺寸 / MD5 / SHA-128 / 权限 / 上传 IP)。
|
||
+ 排版(老师二次验收提「排版、字号、字体颜色都优化一下」后统一成这一套):
|
||
+ 顶部=小缩略图 + 16px semibold 标题;下面一整块圆角卡片(rounded-lg + border-line + bg-surface-2),
|
||
+ 字段之间用 divide-line 细线分隔、每行等距 py-3(所以分区间距一致);
|
||
+ 标签 13px text-ink-3 在左(sm:w-28 定宽对齐)、值 14px text-ink 在右(窄屏 flex-col 自动上下堆叠),行高 leading-6。
|
||
+ 颜色全部走设计令牌(surface / ink / line),亮暗两套自动正确。
|
||
+ 这里不放任何快捷操作(复制链接/下载/删除都不进这个弹窗)。 --}}
|
||
<script type="text/html" id="image-detail-tpl">
|
||
- <div class="my-4 px-4 space-y-3">
|
||
- <div>
|
||
- <span class="text-sm font-semibold">相册名称</span>
|
||
- <p class="my-2 break-words text-gray-700">__album_name__</p>
|
||
- </div>
|
||
- <div>
|
||
- <div class="text-sm font-semibold">使用策略</div>
|
||
- <p class="my-2 break-words text-gray-600">__strategy_name__</p>
|
||
- </div>
|
||
- <div>
|
||
- <div class="text-sm font-semibold">图片名称</div>
|
||
- <p class="my-2 break-words text-gray-600">__filename__</p>
|
||
- </div>
|
||
- <div>
|
||
- <div class="text-sm font-semibold">图片原始名称</div>
|
||
- <p class="my-2 break-words text-gray-600">__origin_name__</p>
|
||
- </div>
|
||
- <div>
|
||
- <div class="text-sm font-semibold">图片大小</div>
|
||
- <p class="my-2 break-words text-gray-600">__size__</p>
|
||
- </div>
|
||
- <div>
|
||
- <div class="text-sm font-semibold">图片类型</div>
|
||
- <p class="my-2 break-words text-gray-600">__mimetype__</p>
|
||
- </div>
|
||
- <div>
|
||
- <div class="text-sm font-semibold">尺寸</div>
|
||
- <p class="my-2 break-words text-gray-600">__width__ * __height__</p>
|
||
- </div>
|
||
- <div>
|
||
- <div class="text-sm font-semibold">MD5</div>
|
||
- <p class="my-2 break-words text-gray-600">__md5__</p>
|
||
- </div>
|
||
- <div>
|
||
- <div class="text-sm font-semibold">SHA-128</div>
|
||
- <p class="my-2 break-words text-gray-600">__sha1__</p>
|
||
- </div>
|
||
- <div>
|
||
- <div class="text-sm font-semibold">权限</div>
|
||
- <p class="my-2 break-words text-gray-600">__permission__</p>
|
||
- </div>
|
||
- <div>
|
||
- <div class="text-sm font-semibold">上传 IP</div>
|
||
- <p class="my-2 break-words text-gray-600">__uploaded_ip__</p>
|
||
- </div>
|
||
- <div>
|
||
- <div class="text-sm font-semibold">上传时间</div>
|
||
- <p class="my-2 break-words text-gray-600">__created_at__</p>
|
||
+ <div class="mx-auto w-full max-w-2xl">
|
||
+ <div class="mb-4 flex items-center gap-3">
|
||
+ <img src="__thumb_url__" alt="__filename__" class="h-16 w-16 shrink-0 rounded-lg border border-line bg-surface-2 object-cover">
|
||
+ <p class="min-w-0 truncate text-[16px] font-semibold leading-6 text-ink">图片详细信息</p>
|
||
+ </div>
|
||
+ <dl class="divide-y divide-line rounded-lg border border-line bg-surface-2 px-4">
|
||
+ <div class="flex flex-col gap-0.5 py-3 sm:flex-row sm:gap-4">
|
||
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">上传时间</dt>
|
||
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__created_at__</dd>
|
||
+ </div>
|
||
+ <div class="flex flex-col gap-0.5 py-3 sm:flex-row sm:gap-4">
|
||
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">图片名称</dt>
|
||
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__filename__</dd>
|
||
+ </div>
|
||
+ <div class="flex flex-col gap-0.5 py-3 sm:flex-row sm:gap-4">
|
||
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">相册名称</dt>
|
||
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__album_name__</dd>
|
||
+ </div>
|
||
+ <div class="flex flex-col gap-0.5 py-3 sm:flex-row sm:gap-4">
|
||
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">使用策略</dt>
|
||
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__strategy_name__</dd>
|
||
+ </div>
|
||
+ <div class="flex flex-col gap-0.5 py-3 sm:flex-row sm:gap-4">
|
||
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">图片原始名称</dt>
|
||
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__origin_name__</dd>
|
||
+ </div>
|
||
+ <div class="flex flex-col gap-0.5 py-3 sm:flex-row sm:gap-4">
|
||
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">图片大小</dt>
|
||
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__size__</dd>
|
||
+ </div>
|
||
+ <div class="flex flex-col gap-0.5 py-3 sm:flex-row sm:gap-4">
|
||
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">图片类型</dt>
|
||
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__mimetype__</dd>
|
||
+ </div>
|
||
+ <div class="flex flex-col gap-0.5 py-3 sm:flex-row sm:gap-4">
|
||
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">尺寸</dt>
|
||
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__width__ * __height__</dd>
|
||
+ </div>
|
||
+ <div class="flex flex-col gap-0.5 py-3 sm:flex-row sm:gap-4">
|
||
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">MD5</dt>
|
||
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__md5__</dd>
|
||
+ </div>
|
||
+ <div class="flex flex-col gap-0.5 py-3 sm:flex-row sm:gap-4">
|
||
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">SHA-128</dt>
|
||
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__sha1__</dd>
|
||
+ </div>
|
||
+ <div class="flex flex-col gap-0.5 py-3 sm:flex-row sm:gap-4">
|
||
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">标签</dt>
|
||
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">
|
||
+ {{-- fork:这一行原来显示「权限」,已下线换成标签 ——
|
||
+ 可以现场增删,也可以直接输入新名字回车新建(JS 渲染 + 绑定)。 --}}
|
||
+ <div id="detail-tags" class="flex flex-wrap items-center gap-1.5"></div>
|
||
+ <div class="mt-2 flex items-center gap-2">
|
||
+ <input type="text" id="detail-tag-input" list="detail-tag-options" class="ls-input h-11 min-w-0 flex-1 text-[14px] sm:h-9" placeholder="请输入标签名称,回车即可添加" maxlength="64">
|
||
+ <button type="button" id="detail-tag-add" class="ls-btn h-11 shrink-0 px-4 sm:h-9">添加</button>
|
||
+ </div>
|
||
+ <datalist id="detail-tag-options"></datalist>
|
||
+ </dd>
|
||
+ </div>
|
||
+ <div class="flex flex-col gap-0.5 py-3 sm:flex-row sm:gap-4">
|
||
+ <dt class="shrink-0 text-[13px] leading-6 text-ink-3 sm:w-28">上传 IP</dt>
|
||
+ <dd class="min-w-0 break-words text-[14px] leading-6 text-ink">__uploaded_ip__</dd>
|
||
+ </div>
|
||
+ </dl>
|
||
+ </div>
|
||
+ </script>
|
||
+
|
||
+ {{-- 「移动到相册」弹窗:相册列表(单选)+ 底部「移动」「取消」。
|
||
+ 列表数据与顶部工具栏的「相册列表」同一个接口;点一行只选中,底部「移动」才提交。
|
||
+ 每行 min-h-[44px](手机点击区 ≥44px),当前所在相册带「当前」标记(__current_badge__)。
|
||
+ 排版(老师二次验收「排版/字号/字体颜色」统一):标题 16px semibold、副标题 13px text-ink-3、
|
||
+ 列表行 14px / 数量 13px text-ink-3,间距统一用 mt-4 + gap-1.5;颜色只走设计令牌。
|
||
+ 底部**不要横线**:老师看到的那条就是 footer 的 border-t,已去掉,只留上间距。
|
||
+ 按钮沿用全局 ls-btn / ls-btn-primary(与弹窗、页面其它按钮同一套)。
|
||
+ ⚠ 列表底部那条「我也是有底线的~」是 utils.infiniteScroll 自动插进 #movements-albums 的哨兵,
|
||
+ 在这个弹窗里用 CSS 隐藏(见 common.less:#image-movements-modal .infinite-scroll { display: none }),
|
||
+ 别的列表(图片墙那个哨兵)照旧显示、不受影响;列表容器仍 overflow-y-auto,
|
||
+ 「滚到底继续加载下一页相册」的能力没丢(每页 40 条,列表实际总是可滚动的)。 --}}
|
||
+ <script type="text/html" id="movements-container-tpl">
|
||
+ <div class="mx-auto flex w-full max-w-xl flex-col">
|
||
+ <p class="text-[16px] font-semibold leading-6 text-ink">移动到相册</p>
|
||
+ <p class="mt-1 text-[13px] leading-5 text-ink-3">已选择 __count__ 张图片</p>
|
||
+ <div id="movements-albums" class="mt-4 flex max-h-[50vh] w-full flex-col gap-1.5 overflow-y-auto pr-1"></div>
|
||
+ <div class="mt-4 flex justify-end gap-2">
|
||
+ <button type="button" id="movements-cancel" class="ls-btn h-11 px-4 sm:h-9">取消</button>
|
||
+ <button type="button" id="movements-confirm" class="ls-btn ls-btn-primary h-11 px-4 sm:h-9" disabled>移动</button>
|
||
</div>
|
||
</div>
|
||
</script>
|
||
|
||
+ {{-- 相册列表行:整行是一个点击区(min-h-[44px]),**行内一个 span 都不用** —— 名称是 <div>、
|
||
+ 「当前」徽标(JS 拼的 .ls-badge)也是 <div>,免得被无限加载「点 span 加载更多」的委托命中
|
||
+ (点一下相册名就多拉一页相册)。列表末尾那条哨兵是 utils.infiniteScroll 插的
|
||
+ `.infinite-scroll > span`,那个必须保持 span。选中态由 JS 切换 border-brand/bg-brand-soft/text-brand
|
||
+ (所以基础态必须留着 border-line bg-surface-2 text-ink,toggleClass 才有东西可换)。 --}}
|
||
+ <script type="text/html" id="movements-album-item-tpl">
|
||
+ <a href="javascript:void(0)" data-id="__id__" data-selected="false" class="movements-album flex min-h-[44px] w-full items-center gap-2.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-ink transition-colors duration-150 hover:bg-surface-3">
|
||
+ <i class="selected-mark fas fa-check-circle w-4 shrink-0 text-brand opacity-0" aria-hidden="true"></i>
|
||
+ <div class="min-w-0 flex-1 truncate text-[14px]">__name__</div>
|
||
+ __current_badge__
|
||
+ <div class="shrink-0 text-[13px] text-ink-3">__image_num__ 张</div>
|
||
+ </a>
|
||
+ </script>
|
||
+
|
||
+ {{-- 「标签管理」窗口的外壳:一行一个标签(勾选框 + 名称 + 张数 + 重命名/删除两个常显按钮),
|
||
+ 底部新建输入框与「取消 / 确定」。#image-tags-list 在手机上可滚(max-h-[50vh]),
|
||
+ 输入框与按钮在 <640px 用 h-11(点击区 ≥44px),≥640px 收成 h-9 与页面其它按钮同一档。
|
||
+ 副标题由 JS 按「有没有选中图片」填 __hint__。--}}
|
||
+ <script type="text/html" id="image-tags-tpl">
|
||
+ <div class="mx-auto flex w-full max-w-xl flex-col">
|
||
+ <p class="text-[16px] font-semibold leading-6 text-ink">标签管理</p>
|
||
+ <p class="mt-1 text-[13px] leading-5 text-ink-3">__hint__</p>
|
||
+ {{-- 新建标签放在首行(老师要求):进窗口第一眼就能建新标签 --}}
|
||
+ <div class="mt-3 flex items-center gap-2">
|
||
+ <input type="text" id="image-tags-new" maxlength="64" class="ls-input h-11 min-w-0 flex-1 text-[14px] sm:h-9" placeholder="请输入新标签名称">
|
||
+ <button type="button" id="image-tags-create" class="ls-btn h-11 shrink-0 px-4 sm:h-9">新建标签</button>
|
||
+ </div>
|
||
+ <div id="image-tags-list" class="mt-3 flex max-h-[50vh] w-full flex-col gap-1.5 overflow-y-auto pr-1"></div>
|
||
+ <div class="mt-4 flex justify-end gap-2">
|
||
+ <button type="button" id="image-tags-cancel" class="ls-btn h-11 px-4 sm:h-9">取消</button>
|
||
+ <button type="button" id="image-tags-confirm" class="ls-btn ls-btn-primary h-11 px-4 sm:h-9" disabled>确定</button>
|
||
+ </div>
|
||
+ </div>
|
||
+ </script>
|
||
+
|
||
+ {{-- 标签重命名的行内表单(在那一行下方就地展开,再点一次收起)。
|
||
+ 名称不写进模板 —— 由 JS 用 .val() 填,避免标签名里的引号/尖括号破坏属性。 --}}
|
||
+ <script type="text/html" id="image-tags-edit-tpl">
|
||
+ <div id="tag-edit" class="flex w-full flex-col rounded-lg border border-line p-2">
|
||
+ <p class="error-message text-white p-2 mb-2 text-sm bg-red-500 rounded hidden"></p>
|
||
+ <form class="flex w-full items-center gap-2" action="{{ route('user.tag.update', ['id' => '__id__']) }}" method="POST">
|
||
+ <input type="text" name="name" maxlength="64" class="ls-input h-11 min-w-0 flex-1 text-[14px] sm:h-9" placeholder="请输入标签名称">
|
||
+ <button type="submit" class="ls-btn h-11 shrink-0 px-4 sm:h-9">确认修改</button>
|
||
+ </form>
|
||
+ </div>
|
||
+ </script>
|
||
+
|
||
+ {{-- 「标签管理」窗口里的一行:左边是勾选区(整块 ≥44px 点击区),右边两个常显的 44×44
|
||
+ 「重命名 / 删除」按钮(改标签本身,不影响图片)。
|
||
+ 勾选语义(按老师定的两态):勾上 = 给选中的这些图片加上该标签;取消勾选 = 从这些图片移除。
|
||
+ 如果该标签只在「部分选中的图片」上有,就显示成半勾表示现状,点一下变全勾(加上)。 --}}
|
||
+ <script type="text/html" id="image-tags-item-tpl">
|
||
+ <div class="image-tag-row flex min-h-[44px] w-full items-stretch overflow-hidden rounded-lg border border-line bg-surface transition-colors duration-150" data-id="__id__" data-json='__json__'>
|
||
+ <a href="javascript:void(0)" class="image-tag-toggle flex min-h-[44px] min-w-0 flex-1 items-center gap-2.5 px-3 py-2 hover:bg-surface-2">
|
||
+ <i class="tag-state-icon fas fa-square w-4 shrink-0 text-ink-3" aria-hidden="true"></i>
|
||
+ <div class="min-w-0 flex-1 truncate text-[14px] name">__name__</div>
|
||
+ <div class="shrink-0 text-[13px] text-ink-3"><span class="images-count">__images_count__</span> 张</div>
|
||
+ </a>
|
||
+ <div class="tag-row-actions flex shrink-0 items-center border-l border-line">
|
||
+ <button type="button" class="update flex h-11 w-11 items-center justify-center text-ink-2 hover:bg-surface-3 hover:text-brand" aria-label="重命名标签"><i class="fas fa-edit text-[15px]"></i></button>
|
||
+ <button type="button" class="delete flex h-11 w-11 items-center justify-center text-danger hover:bg-surface-3" aria-label="删除标签"><i class="fas fa-trash-alt text-[15px]"></i></button>
|
||
+ </div>
|
||
+ </div>
|
||
+ </script>
|
||
+
|
||
+ @push('styles')
|
||
+ <style>
|
||
+ /* 按在图片上拖动时,浏览器会启动 <img> 的原生拖拽、把 mousemove 变成 drag 事件 ——
|
||
+ 框选的选择框不跟随、松手也不会结束互动(实测「锁定不释放」)。Chromium/Safari 用
|
||
+ -webkit-user-drag 关掉原生拖拽(Firefox 不看这条,由脚本里的 dragstart 兜底)。 */
|
||
+ /* 注意卡片本身也要:<a href> 默认就是可拖拽元素,只禁 img 没用(实测 dragstart 照样发) */
|
||
+ #images-grid .images-item,
|
||
+ #images-grid .images-item img {
|
||
+ -webkit-user-drag: none;
|
||
+ }
|
||
+ </style>
|
||
+ @endpush
|
||
+
|
||
@push('scripts')
|
||
<script src="{{ asset('js/justified-gallery/jquery.justifiedGallery.min.js') }}"></script>
|
||
<script src="{{ asset('js/viewer-js/viewer.min.js') }}"></script>
|
||
<script src="{{ asset('js/dragselect/ds.min.js') }}"></script>
|
||
- <script src="{{ asset('js/context-js/context-js.js') }}"></script>
|
||
+ {{-- fork 补丁:加版本串,避免 iOS/Safari 的启发式缓存把旧版 context-js.js 一直喂给老用户 --}}
|
||
+ <script src="{{ asset('js/context-js/context-js.js') }}?v={{ \App\Utils::assetVersion('js/context-js/context-js.js') }}"></script>
|
||
<script src="{{ asset('js/clipboard/index.browser.js') }}"></script>
|
||
<script src="{{ asset('js/clipboard/clipboard.min.js') }}"></script>
|
||
<script>
|
||
@@ -217,44 +512,335 @@
|
||
|
||
const HEADER_TITLE = '#header-title';
|
||
const IMAGES_SCROLL = '#images-scroll';
|
||
+ // ★ 关掉浏览器「刷新时恢复滚动位置」:刷新图库时我们要的是回到顶部从头看,
|
||
+ // 而恢复机制会把页面拽回原位置,配合无限加载就是又一轮连锁。
|
||
+ try { if ('scrollRestoration' in history) history.scrollRestoration = 'manual'; } catch (e) {}
|
||
const IMAGES_GRID = '#images-grid';
|
||
const IMAGES_ITEM = '.images-item';
|
||
- const ALBUM_ITEM = '.albums-item';
|
||
+ // 相册行容器(承载 data-id / data-json + 当前相册高亮)。
|
||
+ // 行不再是「整行一个 <a>」,而是 .albums-row 里「左边切换链接 + 右边常显按钮」,
|
||
+ // 所以按钮往上找 id 要 closest 到这一层,不能再用 .albums-item(那是 <a> 自己)。
|
||
+ const ALBUM_ROW = '.albums-row';
|
||
|
||
const $headerTitle = $(HEADER_TITLE);
|
||
const $photos = $(IMAGES_GRID);
|
||
- const $drawer = $("#drawer");
|
||
- const $drawerMask = $('#drawer-mask');
|
||
- const viewer = new Viewer(document.getElementById('images-grid'), {url: 'data-original'});
|
||
- const drawer = {
|
||
- open(title, content, callback) {
|
||
- $drawerMask.fadeIn();
|
||
- $drawer.css('right', 0);
|
||
- $drawer.find('#drawer-title').html(title);
|
||
- $drawer.find('#drawer-content').html(content);
|
||
- callback && callback();
|
||
- },
|
||
- close(callback) {
|
||
- $drawerMask.fadeOut();
|
||
- $drawer.css('right', '-1000px');
|
||
- albumsInfinite && albumsInfinite.destroy();
|
||
- callback && callback();
|
||
- },
|
||
- toggle(title, content, callback) {
|
||
- if ($drawerMask.is(':hidden')) {
|
||
- this.open(title, content, callback);
|
||
- } else {
|
||
- this.close(callback);
|
||
- }
|
||
+ // 居中卡片弹窗(复用 components/modal.blade.php 的 Alpine store,用法同 admin 页)
|
||
+ const modal = Alpine.store('modal');
|
||
+ // 「移动到相册」弹窗里的相册列表容器 / 底部按钮
|
||
+ const MOVEMENTS_MODAL = 'image-movements-modal';
|
||
+ const DETAIL_MODAL = 'image-detail-modal';
|
||
+ const ALBUM_MODAL = 'album-switch-modal';
|
||
+ // 标签窗口(全站唯一):桌面工具栏 / 手机 ⋯ 菜单 / 图片右键菜单 / 标签下拉共用
|
||
+ const TAGS_MODAL = 'image-tags-modal';
|
||
+ // 「显示图片标签」开关的本地记忆键(与页面其它偏好一致,存 localStorage)
|
||
+ const TAG_BADGE_KEY = 'lsky.show_image_tags';
|
||
+ // 删除标签的地址(模板里给个占位符,运行时替换真实 id)
|
||
+ const TAG_DELETE_URL = "{{ route('user.tag.delete', ['id' => '__ID__']) }}";
|
||
+
|
||
+ /* ---------------- 标签(fork 新增) ----------------
|
||
+ * 标签按用户隔离:列表来自 GET user/tags,打标/移除走 PUT user/images/tags。
|
||
+ * allTags 是本页唯一的标签缓存(顶部筛选、详情卡候选、打标弹窗都用它)。
|
||
+ * -------------------------------------------------- */
|
||
+ let allTags = [];
|
||
+ let selectedTagIds = []; // 顶部筛选选中的标签 id(多选,后端是 AND 语义)
|
||
+
|
||
+ // 标签名来自用户输入,塞进 innerHTML 之前一律先转义
|
||
+ const escapeHtml = (value) => String(value === null || value === undefined ? '' : value)
|
||
+ .replace(/&/g, '&')
|
||
+ .replace(/</g, '<')
|
||
+ .replace(/>/g, '>')
|
||
+ .replace(/"/g, '"')
|
||
+ .replace(/'/g, ''');
|
||
+
|
||
+ // 统一的接口错误文案:优先用后端返回的 message(含 422 校验失败),其次 Error.message
|
||
+ const apiErrMsg = (error, fallback) => (error && error.response && error.response.data
|
||
+ && error.response.data.message) || (error && error.message) || fallback;
|
||
+
|
||
+ // 图片墙卡片角标:只显示前几个,长名字截断(角标容器是 pointer-events-none,不抢点击)
|
||
+ const cardTagsHtml = (tags) => {
|
||
+ if (! tags || ! tags.length) {
|
||
+ return '';
|
||
}
|
||
- }
|
||
+ return tags.slice(0, 3).map(tag =>
|
||
+ '<span class="max-w-[7rem] truncate rounded-full bg-black/60 px-2 py-0.5 text-[11px] leading-4 text-white">'
|
||
+ + escapeHtml(tag.name) + '</span>'
|
||
+ ).join('');
|
||
+ };
|
||
+ const viewer = new Viewer(document.getElementById('images-grid'), {
|
||
+ url: 'data-original',
|
||
+ slideOnTouch: false, // 关掉库自带的触摸切图:单指动作永远是 move ⇒ 库自己的平移就是跟手拖动
|
||
+
|
||
+ // 到头不再绕回(老师明确不要循环:第一张向右滑会绕到最后一张,且那一下会让
|
||
+ // 控件重建画布/缩略图条 ⇒ iOS 整个界面左偏、安卓缩略图全空,见后续修复记录)
|
||
+ loop: false,
|
||
+ // focus: false —— Claude 定位 + 真机对照验证:
|
||
+ // view() 在摆放缩略图条之前会先对目标缩略图的 <li> 调 .focus()(源码 s.focus&&h.focus()),
|
||
+ // 而 .viewer-navbar 虽然 overflow:hidden 但仍是滚动容器 ⇒ 浏览器为了"让焦点可见"把导航条
|
||
+ // 滚走(scrollLeft 推到最大);随后 renderList() 再叠一层 translateX ⇒ 位置被偏移两次,
|
||
+ // 整条列表跑到屏幕外,只剩空黑底。表现就是"从靠前的图跳到很靠后的图(或关掉再打开靠后的图)后,
|
||
+ // 缩略图条里后面那批不显示,前面的还在"——因为前面那批本来就在初始可视区,不触发滚动。
|
||
+ // 加 focus:false 后:navScrollLeft 由 2439 → 0,可见缩略图 0 → 43(真 Chromium 实测)。
|
||
+ // 代价:关掉"打开后聚焦容器 + 焦点陷阱",对鼠标/触屏无影响,仅纯键盘 Tab 导航有差别
|
||
+ //(键盘切图与 Esc 关闭实测不受影响)。
|
||
+ focus: false,
|
||
+ });
|
||
+
|
||
+ /* 修 Viewer.js 的另一个坑:update() 重建缩略图条却不重算它的位置。
|
||
+ * Claude 定位 + jsdom 复现(我核对了源码与线上压缩产物):
|
||
+ * update() 会把 .viewer-list 的 width 设成 auto 并重建所有 <li>,
|
||
+ * 但当前图没变时不会调 renderList() ⇒ 位置(width + translateX((容器宽-30)/2 - 31*index))
|
||
+ * 还是旧值。图越靠后这个负偏移越大,超过一屏宽后整个列表被推出导航条可视区,
|
||
+ * 只剩一条空的黑底 —— 就是老师看到的"缩略图条不显示";手指一滑(goTo → view → renderList)
|
||
+ * 位置被重算,缩略图立刻回来。
|
||
+ * 谁会在看图时调 update():images.blade.php 里无限滚动每次请求结束的 complete 回调
|
||
+ * (看图时后台正好加载完一批就会触发),以及侧栏折叠。所以是"偶尔"。
|
||
+ * 修法:包一层 update(),让它跑完补一次 renderList(),并把丢失的 active 状态找回来。
|
||
+ * 注意不要依赖 viewer.items / isShown —— 线上压缩产物里查不到这两个名字(可能被改名),
|
||
+ * 改成查 DOM,稳。
|
||
+ * 注意这是 fork 侧的自证:Dockerfile 里钉的 images.blade.php md5 不变的话构建会失败。 */
|
||
+ (function () {
|
||
+ if (typeof viewer.update !== 'function' || typeof viewer.renderList !== 'function') {
|
||
+ return; // 产物变了就安静退出,不折腾
|
||
+ }
|
||
+ const rawUpdate = viewer.update.bind(viewer);
|
||
+ viewer.update = function () {
|
||
+ const ret = rawUpdate.apply(null, arguments);
|
||
+ try {
|
||
+ const list = document.querySelector('.viewer-navbar .viewer-list');
|
||
+ if (!list || !viewer.ready) return ret;
|
||
+ // 重建出来的 <li> 会丢掉 active 状态,按当前 index 补回去
|
||
+ if (!list.querySelector('li.viewer-active') && list.children[viewer.index]) {
|
||
+ list.children[viewer.index].classList.add('viewer-active');
|
||
+ }
|
||
+ viewer.renderList(); // 重算 width 与 translateX
|
||
+ } catch (e) { /* 兜底:绝不因为补一刀而让看图界面挂掉 */ }
|
||
+ return ret;
|
||
+ };
|
||
+ })();
|
||
+
|
||
+ /* 底部缩略图条上的快捷切图(老师要的功能)。
|
||
+ * · 电脑:光标在缩略图条上滚轮 ⇒ 滚一格切一张。
|
||
+ * 控件自己在外层容器上绑了"滚轮缩放"(捕获阶段),所以这里也在 document 捕获
|
||
+ * 阶段抢在它之前,并且**只对落在缩略图条上的滚轮生效**:图上滚轮依旧是缩放。
|
||
+ * · 手机:在缩略图条上左右拖动 ⇒ 连续切图(每滑过 DRAG_STEP 像素切一张,
|
||
+ * 拖回来也会切回去)。缩略图条被库设成 touch-action:none、本来就划不动,
|
||
+ * 所以不存在"抢走缩略图条自己的滚动"这个问题(它靠自动跟随当前图)。
|
||
+ * 一律从外面调它的对外 API viewer.view(index),不改库;到头即止(loop 已关)。 */
|
||
+ (function () {
|
||
+ const BAR = '.viewer-navbar';
|
||
+ const WHEEL_GAP = 260; // 滚轮节流:一格切一张,别被惯性滚轮连着切
|
||
+ const DRAG_STEP = 40; // 手机上每滑过这么多像素切一张
|
||
+ let lastWheel = 0;
|
||
+ let navDown = false;
|
||
+ let dragStartX = 0;
|
||
+ let dragBaseIndex = 0;
|
||
+ const inBar = (el) => el instanceof Element && !!el.closest(BAR);
|
||
+ const count = () => document.querySelectorAll(BAR + ' .viewer-list > li').length;
|
||
+ const goTo = (i) => {
|
||
+ const n = count();
|
||
+ if (!n || !Number.isFinite(i)) return;
|
||
+ const target = Math.max(0, Math.min(n - 1, i));
|
||
+ if (target !== viewer.index) viewer.view(target);
|
||
+ };
|
||
+ document.addEventListener('wheel', (e) => {
|
||
+ if (!inBar(e.target)) return;
|
||
+ e.stopPropagation(); // 足够:控件"把滚轮当缩放"是在它自己的监听器里做的,
|
||
+ // 拦住传播它就收不到。这里**不能**用 preventDefault ——
|
||
+ // document 上的非 passive wheel 同样会让浏览器失去
|
||
+ // "不等 JS 就滚动/绘制"的快路径(与 touchmove 那个坑同源)。
|
||
+ const now = Date.now();
|
||
+ if (now - lastWheel < WHEEL_GAP) return;
|
||
+ lastWheel = now;
|
||
+ goTo(viewer.index + (e.deltaY > 0 ? 1 : -1));
|
||
+ }, {capture: true, passive: true});
|
||
+ document.addEventListener('touchstart', (e) => {
|
||
+ navDown = inBar(e.target);
|
||
+ const t = e.changedTouches && e.changedTouches[0];
|
||
+ if (navDown && t) {
|
||
+ dragStartX = t.clientX;
|
||
+ dragBaseIndex = viewer.index;
|
||
+ }
|
||
+ }, true);
|
||
+ /* 注意:这个监听器**必须**是 passive,绝不能 preventDefault。
|
||
+ * 在 document 上注册「非 passive 的 touchmove」会让 Chrome 失去"不等 JS 就滚动/绘制"
|
||
+ * 的快路径 —— 表现是远离手指的元素(比如屏幕底部的缩略图条)不重绘:一直空白,
|
||
+ * 手指一碰它才突然画出来(安卓上尤其明显)。老师说"像被什么卡住了"就是这个。
|
||
+ * 而缩略图条本来就是 touch-action:none、浏览器不会滚它,所以根本不用 preventDefault。 */
|
||
+ document.addEventListener('touchmove', (e) => {
|
||
+ if (!navDown) return;
|
||
+ const t = e.changedTouches && e.changedTouches[0];
|
||
+ if (!t) return;
|
||
+ // 往左拖 = 下一张,往右拖 = 上一张;按"已经滑过的距离"连续跟随,拖回来也切回去
|
||
+ goTo(dragBaseIndex + Math.trunc((dragStartX - t.clientX) / DRAG_STEP));
|
||
+ }, {capture: true, passive: true});
|
||
+ document.addEventListener('touchend', () => { navDown = false; }, true);
|
||
+ document.addEventListener('touchcancel', () => { navDown = false; }, true);
|
||
+ })();
|
||
+
|
||
+ /* 触摸端切图判定(接管 slideOnTouch 后的配套逻辑):
|
||
+ * 库里 slideOnTouch:false ⇒ 单指动作永远是 'move',库自己的平移就是跟手拖动,
|
||
+ * 不再出现 'switch' 动作 ⇒ 1px 判据与"闩锁"都不存在。切图还是回弹由松手时决定。
|
||
+ * 为什么不拦事件:库用 HAS_POINTER_EVENT ? 'pointermove' : 'touchmove' 选通道,
|
||
+ * 拦 pointermove 在真机上可能整段失效(踩过)。这里只读 viewer.action 判定是否放权,
|
||
+ * 一个字都不写库的内部状态 —— 捏合/双击/过渡因此完全不受影响。 */
|
||
+ (function () {
|
||
+ const FAR_RATIO = 0.18; // 够远:视口宽的这个比例
|
||
+ const FAR_MIN = 56; // 或至少这么多 px
|
||
+ const FAST_SPEED = 0.5; // 够快:px/ms
|
||
+ let g = null;
|
||
+ let guardClick = 0; // 刚切过图的时刻:用来吞掉随后那发背景 click
|
||
+ const inCanvas = (el) => el instanceof Element && !!el.closest('.viewer-canvas');
|
||
+ const isFit = () => {
|
||
+ const d = viewer.imageData;
|
||
+ const v = viewer.viewerData;
|
||
+ if (!d || !v) return false;
|
||
+ return d.x >= 0 && d.y >= 0 && d.width <= v.width && d.height <= v.height;
|
||
+ };
|
||
+ document.addEventListener('pointerdown', (e) => {
|
||
+ if (!e.isPrimary) { if (g) g.multi = true; return; } // 第二指 ⇒ 捏合,放权给库
|
||
+ g = (e.pointerType !== 'mouse' && inCanvas(e.target))
|
||
+ ? { x: e.clientX, y: e.clientY, t: e.timeStamp, fit: isFit(), multi: false }
|
||
+ : null;
|
||
+ }, true);
|
||
+ document.addEventListener('pointerup', (e) => {
|
||
+ if (!g || !e.isPrimary) return;
|
||
+ const s = g;
|
||
+ g = null;
|
||
+ if (!s.fit || s.multi) return; // 放大态 / 捏合:一律交给库
|
||
+ const dx = e.clientX - s.x;
|
||
+ const dy = e.clientY - s.y;
|
||
+ const dt = Math.max(1, e.timeStamp - s.t);
|
||
+ const far = Math.abs(dx) > Math.max(FAR_MIN, window.innerWidth * FAR_RATIO);
|
||
+ const fast = Math.abs(dx) / dt > FAST_SPEED && Math.abs(dx) > 30;
|
||
+ if ((far || fast) && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
||
+ if (dx < 0) viewer.next(false); else viewer.prev(false);
|
||
+ guardClick = Date.now(); // 标记:刚刚切过图
|
||
+ } else if (dx !== 0 || dy !== 0) {
|
||
+ viewer.reset(); // 带动画的回弹
|
||
+ }
|
||
+ }, false);
|
||
+ /* 手指在图片外空白处结束时,浏览器会补一发 click 落到背景上,库因此关闭查看器 ——
|
||
+ * 于是"切图成功"和"退出查看"同时发生。这里只吞紧跟切图之后那 400ms 内的背景 click,
|
||
+ * 且点在图片上的不算,避免误伤正常点击关闭。 */
|
||
+ document.addEventListener('click', (e) => {
|
||
+ if (Date.now() - guardClick > 400) return;
|
||
+ if (e.target instanceof Element && e.target.closest('.viewer-canvas img')) return;
|
||
+ guardClick = 0;
|
||
+ e.stopPropagation();
|
||
+ e.preventDefault();
|
||
+ }, true);
|
||
+ })();
|
||
+
|
||
+ /* 双击放大:库里的「模拟双击」(handlers.js:424-451)只看两次抬手的间隔(硬编码 500ms),
|
||
+ * 完全不检查中间有没有拖动 ⇒ 连续拖动两下也会被当成双击放大。这里在外面收口(不改库):
|
||
+ * 1. 抬手时用「起点→终点」的位移判断这一下是轻点还是拖动。阈值 30px 的依据是实测:
|
||
+ * 真滑动位移 130~300px(中位 214px),轻点抖动一般 <15px,30px 正好把两者分开。
|
||
+ * 2. 是拖动 ⇒ 立刻清掉库的 imageClicked,让拖动永远不能充当"第一次轻点";
|
||
+ * 若这一下之前已经有"第一次轻点"了(imageClicked 曾被置位),说明是"轻点+拖动",
|
||
+ * 那发 50ms 后要派发的合成 dblclick 也一并拦掉。
|
||
+ * 3. 两次都不动的轻点 ⇒ 真双击,放行;并按老师要求把有效期从 500ms 收到 300ms
|
||
+ * (300ms 后清掉库的 imageClicked,等效于缩短双击判定窗口)。
|
||
+ * 注意必须用 touchstart/touchend 的坐标差,**不能**靠 touchmove:安卓上控件在指针处理里
|
||
+ * preventDefault 之后 touchmove 可能根本收不到。 */
|
||
+ (function () {
|
||
+ const TAP_SLOP = 30; // 超过它就当"拖动"
|
||
+ const TAP_WINDOW = 300; // 等效双击窗口(老师定)
|
||
+ let startX = 0;
|
||
+ let startY = 0;
|
||
+ let tapTimer = 0;
|
||
+ let swallowDblUntil = 0;
|
||
+ const isViewer = (el) => el instanceof Element && !!el.closest('.viewer-container');
|
||
+ document.addEventListener('touchstart', (e) => {
|
||
+ const t = e.changedTouches && e.changedTouches[0];
|
||
+ if (t) { startX = t.clientX; startY = t.clientY; }
|
||
+ }, true);
|
||
+ document.addEventListener('touchend', (e) => {
|
||
+ const t = e.changedTouches && e.changedTouches[0];
|
||
+ if (!t || !isViewer(e.target)) return;
|
||
+ const moved = Math.abs(t.clientX - startX) > TAP_SLOP
|
||
+ || Math.abs(t.clientY - startY) > TAP_SLOP;
|
||
+ clearTimeout(tapTimer);
|
||
+ if (moved) {
|
||
+ if (viewer.imageClicked) swallowDblUntil = Date.now() + 150; // 轻点+拖动 ≠ 双击
|
||
+ viewer.imageClicked = false;
|
||
+ } else if (viewer.imageClicked) {
|
||
+ tapTimer = setTimeout(() => { viewer.imageClicked = false; }, TAP_WINDOW);
|
||
+ }
|
||
+ }, true);
|
||
+ document.addEventListener('dblclick', (e) => {
|
||
+ if (Date.now() < swallowDblUntil && isViewer(e.target)) e.stopPropagation();
|
||
+ }, true);
|
||
+ })();
|
||
+
|
||
+ /* 修 Viewer.js 的「闩锁」bug(真机打点 + 源码逐行核对 + jsdom 复现确认):
|
||
+ * others.js:216-223 change() 里
|
||
+ * case ACTION_SWITCH: {
|
||
+ * this.action = 'switched'; // ← 第一次 pointermove 就先上闩
|
||
+ * if (|offsetX| > 1 && |offsetX| > |offsetY|) { … this.view(index) }
|
||
+ * 上闩之后 action 成了字符串 'switched',而外层 switch 没有这个分支 ⇒ 之后
|
||
+ * 每一次 pointermove 全部落空:不切图、不平移、不放大 =「纹丝不动」。
|
||
+ * 判据又只看开头那次采样,真手指刚落下天然带纵向抖动(dy ≥ dx)⇒ 大多数手势
|
||
+ * 一开始就被闩死,偶尔开头偏横向才成功 —— 这就是「大多数失败、少数成功、无规律」。
|
||
+ * 从外面把闩松开:手势期间发现 action 变成那个闩值,就恢复成刚才那个动作值,
|
||
+ * 后续采样继续参与判定(切图判据看的是"最近一次位移",所以横滑一定能切到)。
|
||
+ * 不改 viewer.min.js(压缩产物,改了没法维护)。 */
|
||
+ (function () {
|
||
+ let inViewer = false;
|
||
+ let lastAction = null;
|
||
+ let switchAction = null;
|
||
+ const isViewer = (el) => el instanceof Element && !!el.closest('.viewer-container');
|
||
+ document.addEventListener('pointerdown', (e) => {
|
||
+ inViewer = isViewer(e.target);
|
||
+ lastAction = null;
|
||
+ }, true);
|
||
+ document.addEventListener('pointermove', (e) => {
|
||
+ if (!inViewer) return;
|
||
+ const a = viewer.action;
|
||
+ if (a === 'switched') {
|
||
+ // 学到切图动作的真值后,把闩松开
|
||
+ if (!switchAction && lastAction) switchAction = lastAction;
|
||
+ if (switchAction && viewer.action !== switchAction) viewer.action = switchAction;
|
||
+ } else if (a) {
|
||
+ lastAction = a; // 手势开始时 Viewer 刚算出来的动作值
|
||
+ }
|
||
+ }, true);
|
||
+ document.addEventListener('pointerup', () => { inViewer = false; }, true);
|
||
+ document.addEventListener('pointercancel', () => { inViewer = false; }, true);
|
||
+ })();
|
||
+
|
||
+
|
||
+ /* 桌面 110% 缩放下看图控件(Viewer.js)的整体偏移:**不在这里用 JS 打补丁**。
|
||
+ * 真因:<html>{zoom:1.1} 让 Viewer 内部「以为的 1px」只有屏幕上的 1/1.1 ——
|
||
+ * 它按视觉尺寸(window.innerWidth = 1280)算居中与适配,却把结果当【布局值】
|
||
+ * 写进内联样式(布局空间只有 1164),渲染时浏览器又乘 1.1 ⇒
|
||
+ * ① 图片整体偏右下(1280 宽下约 +64px);
|
||
+ * ② 按视觉算出的适配尺寸偏大,图片会盖住下方导航条/缩略图,且放大后拖不动。
|
||
+ * 正解在外壳上做**反向缩放**(common.less 的 `html .viewer-container { zoom: ... }`):
|
||
+ * 让 Viewer 的子坐标系与屏幕 1:1,它自己的居中/适配/平移数学就全对了 ——
|
||
+ * 点开、键盘 ←→ 切图、滚轮放大、拖拽平移都不必再各修一遍。
|
||
+ * (教训:第三方控件遇到根节点 zoom,就把它那层缩回去,别逐条路径打补丁。) */
|
||
+
|
||
|
||
$photos.justifiedGallery(gridConfigs);
|
||
|
||
let albumsInfinite = null;
|
||
+ // 相册弹窗:渲染内容(外壳 + 列表)→ 跑初始化回调 → 打开弹窗。
|
||
+ // 职责与旧的 drawer.open(title, content, callback) 一一对应,只是换成 Alpine 的 modal store。
|
||
+ const openAlbums = (content, callback) => {
|
||
+ $('#album-switch-content').html(content);
|
||
+ $('#album-switch-scroll').html($('#albums-container-tpl').html());
|
||
+ callback && callback();
|
||
+ modal.open(ALBUM_MODAL);
|
||
+ };
|
||
+ // 相册弹窗的「收起来」= 旧的 drawer.close():先收掉相册列表的无限加载,再关弹窗
|
||
+ const closeAlbums = () => {
|
||
+ albumsInfinite && albumsInfinite.destroy();
|
||
+ modal.close(ALBUM_MODAL);
|
||
+ };
|
||
const imagesInfinite = utils.infiniteScroll(IMAGES_SCROLL, {
|
||
url: '{{ route('user.images') }}',
|
||
classes: ['dragselect'],
|
||
+ root: 'window', // 图片墙已是整页滚动(见上面的容器注释)
|
||
success: function (response) {
|
||
if (!response.status) {
|
||
return toastr.error(response.message);
|
||
@@ -276,7 +862,11 @@
|
||
.replace(/__thumb_url__/g, images[i].thumb_url)
|
||
.replace(/__width__/g, images[i].width)
|
||
.replace(/__height__/g, images[i].height)
|
||
- .replace(/__json__/g, JSON.stringify(images[i]).replace(/\$/g, '$$$$'))
|
||
+ // 卡片角标 = 这张图的标签(列表接口已带 tags)
|
||
+ .replace(/__tags__/g, cardTagsHtml(images[i].tags).replace(/\$/g, '$$$$'))
|
||
+ // 标签名会进这里的 JSON,而 data-json 是单引号属性 —— 名字里带 '
|
||
+ // 就能把属性提前闭合(自伤型 XSS 面),所以先按 HTML 转义再注入
|
||
+ .replace(/__json__/g, escapeHtml(JSON.stringify(images[i])).replace(/\$/g, '$$$$'))
|
||
}
|
||
|
||
$photos.append(html);
|
||
@@ -300,22 +890,73 @@
|
||
}
|
||
});
|
||
|
||
+ // ★ B 保险:告诉无限加载「用户是不是真的在往下滚 + 是不是刚重置过」。
|
||
+ // 浏览器为了维持滚动位置偷偷补的那一滚,不满足这两个条件,于是不会连锁。
|
||
+ {
|
||
+ // 只做记录、不做拦截。
|
||
+ // ★ 教训:曾经这里做过一道「必须由用户手指驱动(touchmove/wheel)才放行」的闸,
|
||
+ // 结果把真实滑到底一起堵死了 —— 用户滑到底那一刻可能刚处在窗口边缘,当场被拦
|
||
+ // 后又不再有 scroll 事件 ⇒ 永远不加载;程序化滚动(测试、浏览器补滚)更是永远
|
||
+ // 挤不进这条路径。守卫测试 infinite-scroll 三套当时全红,失败项正是「到底了不
|
||
+ // 触发」。说明那道闸方向就是错的,别再往这里加法。
|
||
+ // 拦截只保留 app.js 里那一道「重置窗口」。
|
||
+ window.__lskyScrollGate = window.__lskyScrollGate || { lastY: window.scrollY };
|
||
+ window.addEventListener('scroll', function () {
|
||
+ window.__lskyScrollGate.lastY = window.scrollY;
|
||
+ }, { passive: true });
|
||
+ } // ★ 重置窗口的截止时间戳(见 resetImages)
|
||
const resetImages = (params) => {
|
||
+ // ★ 顺序很重要:清空内容之前先回顶部。
|
||
+ // 清空后页面高度塌缩,浏览器的滚动位置会被夹到某个浅位置,再叠加重排补滚,
|
||
+ // 就是「无脑滚到底」的起点。换排序/刷新本来也该从头看。
|
||
+ try { window.scrollTo(0, 0); } catch (e) {}
|
||
$photos.addClass('reset').html('').justifiedGallery('destroy');
|
||
ds.clearSelection();
|
||
params = $.extend({page: 1}, params)
|
||
+ // ★ 重置窗口:从清空到第一页真正落地这段时间里,忽略滚动触发的加载。
|
||
+ // 这段时间页面高度剧烈变化(塌缩 → 逐页长回来),最容易被补齐/重排带着连发。
|
||
+ resetGuardUntil = Date.now() + 800;
|
||
+ try { window.__lskyResetGuardUntil = resetGuardUntil; } catch (e) {}
|
||
imagesInfinite.refresh(params);
|
||
}
|
||
|
||
const getAlbums = (options, callback) => {
|
||
- let title = '__title__ <i class="cursor-pointer fas fa-plus text-blue-500" onclick="$(\'#album-add\').toggleClass(\'hidden\')"></i>'.replace(/__title__/g, (options || {}).title || '我的相册');
|
||
- let content = $('#albums-container-tpl').html();
|
||
- drawer.toggle(title, content, function () {
|
||
+ // 相册列表从右侧抽屉搬进居中卡片弹窗(与「移动到相册」同一套 x-modal):
|
||
+ // 外壳(标题 + 本地搜索框 + 列表容器 —— 底部的「完成」按钮已删)进 #album-switch-content,
|
||
+ // 列表内容(#albums-container:创建/重命名表单 + 相册行)进无限加载容器 #album-switch-scroll。
|
||
+ // 创建入口也从抽屉标题的 + 号搬进了列表里(见 #albums-container-tpl)。
|
||
+ let content = $('#album-switch-tpl').html().replace(/__title__/g, (options || {}).title || '相册');
|
||
+ openAlbums(content, function () {
|
||
let $albums = $('#albums-container');
|
||
const CREATE_ID = '#album-add';
|
||
const UPDATE_ID = '#album-edit';
|
||
- albumsInfinite = utils.infiniteScroll('#drawer-content', {
|
||
+
|
||
+ // 空状态:一行都没有 → 「还没有相册」;有相册但被搜索过滤光了 → 「没有匹配的相册」
|
||
+ const updateEmpty = () => {
|
||
+ let total = $albums.find('> ' + ALBUM_ROW).length;
|
||
+ let visible = $albums.find('> ' + ALBUM_ROW + ':not(.hidden)').length;
|
||
+ $('#album-switch-empty').toggleClass('hidden', visible > 0);
|
||
+ $('#album-switch-empty-text').text(total === 0 ? '还没有相册' : '没有匹配的相册');
|
||
+ };
|
||
+
|
||
+ // 搜索框:只在已加载的相册里按名称即时过滤(本地过滤,不打接口)
|
||
+ const applyFilter = () => {
|
||
+ let keyword = ($('#album-switch-search').val() || '').trim().toLowerCase();
|
||
+ $albums.find('> ' + ALBUM_ROW).each(function () {
|
||
+ $(this).toggleClass('hidden', keyword !== '' && $(this).find('.name').text().toLowerCase().indexOf(keyword) === -1);
|
||
+ });
|
||
+ updateEmpty();
|
||
+ };
|
||
+
|
||
+ // 上一轮打开留下的无限加载实例先收掉(旧实现是在 drawer.close() 里做的)
|
||
+ albumsInfinite && albumsInfinite.destroy();
|
||
+ albumsInfinite = utils.infiniteScroll('#album-switch-scroll', {
|
||
url: '{{ route('user.albums') }}',
|
||
+ complete: function () {
|
||
+ // 首屏「加载中...」收起(接口报错也收起,不留一个转不完的圈)
|
||
+ $('#album-switch-loading').addClass('hidden');
|
||
+ updateEmpty();
|
||
+ },
|
||
success: function (response) {
|
||
if (!response.status) {
|
||
return toastr.error(response.message);
|
||
@@ -328,29 +969,40 @@
|
||
|
||
let html = '';
|
||
for (const i in albums) {
|
||
- let item = $('#albums-item-tpl').html()
|
||
+ html += $('#albums-item-tpl').html()
|
||
.replace(/__id__/g, albums[i].id)
|
||
.replace(/__name__/g, albums[i].name)
|
||
.replace(/__intro__/g, albums[i].intro)
|
||
.replace(/__image_num__/g, albums[i].image_num)
|
||
+ // 当前所在相册标出来(与「移动到相册」弹窗同一个徽标)
|
||
+ .replace(/__current_badge__/g, albums[i].id === selectedAlbum.id
|
||
+ ? '<div class="ls-badge shrink-0 bg-brand-soft text-brand">当前</div>'
|
||
+ : '')
|
||
.replace(/__json__/g, JSON.stringify(albums[i]))
|
||
- if (albums[i].id === selectedAlbum.id) {
|
||
- // 选中的相册高亮
|
||
- item = item
|
||
- .replace(/bg-gray-100/g, 'bg-blue-400')
|
||
- .replace(/text-gray-800/g, 'text-white')
|
||
- }
|
||
-
|
||
- html += item;
|
||
}
|
||
|
||
$albums.append(html);
|
||
|
||
+ // 当前相册高亮:与「移动到相册」弹窗同一套令牌切换。
|
||
+ // 打在行容器(.albums-row)上 —— 高亮边框要包住右边的两个按钮;
|
||
+ // 基础态就是模板里的 border-line bg-surface,on 时一起摘掉换成品牌色。
|
||
+ $albums.find('> ' + ALBUM_ROW).each(function () {
|
||
+ let on = $(this).data('id') === selectedAlbum.id;
|
||
+ $(this)
|
||
+ .toggleClass('border-brand bg-brand-soft text-brand', on)
|
||
+ .toggleClass('border-line bg-surface', ! on);
|
||
+ });
|
||
+
|
||
+ // 新追加的页也要跟上当前的搜索词
|
||
+ applyFilter();
|
||
+
|
||
callback && callback.call(this, $albums.get(0));
|
||
}
|
||
});
|
||
|
||
- $albums.off('click', '>a').on('click', '>a', function () {
|
||
+ // 切换只挂在行内的 <a class="albums-item"> 上:编辑/删除按钮现在是 <a> 的兄弟节点,
|
||
+ // 点它们冒泡到不了这里(这正是「点不中就跳进相册」的修法)。
|
||
+ $albums.off('click', '.albums-item').on('click', '.albums-item', function () {
|
||
// 如果当前已经为选中状态则清除
|
||
if (selectedAlbum.id === $(this).data('id')) {
|
||
selectedAlbum = {};
|
||
@@ -358,12 +1010,13 @@
|
||
selectedAlbum = $(this).data('json');
|
||
}
|
||
resetImages({page: 1, album_id: selectedAlbum.id || null});
|
||
- drawer.close();
|
||
+ // 选中即切换:与旧抽屉一样,切换后把相册弹窗收起来
|
||
+ closeAlbums();
|
||
ds.clearSelection();
|
||
});
|
||
|
||
const resetAlbums = () => {
|
||
- $albums.find('>a').remove();
|
||
+ $albums.find('> ' + ALBUM_ROW).remove();
|
||
$albums.find(CREATE_ID).addClass('hidden');
|
||
$albums.find(UPDATE_ID).remove();
|
||
albumsInfinite.refresh({page: 1});
|
||
@@ -372,13 +1025,14 @@
|
||
$albums.off('click', '.update').on('click', '.update', function (e) {
|
||
e.stopPropagation();
|
||
let selectedId = $albums.find(UPDATE_ID).data('id');
|
||
- let $item = $(this).closest('a.albums-item');
|
||
+ // 按钮在 <a> 外面,id / 名称 / 简介都要从外层行容器再往下取
|
||
+ let $item = $(this).closest(ALBUM_ROW);
|
||
$albums.find(UPDATE_ID).remove();
|
||
if (selectedId !== $item.data('id')) {
|
||
$item.after($('#album-update-tpl').html()
|
||
.replace(/__id__/g, $item.data('id'))
|
||
- .replace(/__name__/g, $item.find('>span').html())
|
||
- .replace(/__intro__/g, $item.attr('title'))
|
||
+ .replace(/__name__/g, $item.find('.name').html())
|
||
+ .replace(/__intro__/g, $item.find('a.albums-item').attr('title'))
|
||
);
|
||
}
|
||
});
|
||
@@ -386,6 +1040,12 @@
|
||
$albums.off('click', '.delete').on('click', '.delete', function (e) {
|
||
e.stopPropagation();
|
||
Swal.fire({
|
||
+ // 与新弹窗(x-modal)一套:不动背景页面。
|
||
+ // heightAuto 关掉 html/body 上的 swal2-height-auto;scrollbarPadding 关掉
|
||
+ // sweetalert2 往 body 写 padding-right 的那一步(它按「滚动条宽度」补内边距,
|
||
+ // 手机上明明没有占位滚动条,补 8px 却真的会把内容挤窄 → 工具栏换行、整页下移)。
|
||
+ heightAuto: false,
|
||
+ scrollbarPadding: false,
|
||
title: '确认删除该相册?',
|
||
text: "删除后相册中的图片将会被移出。",
|
||
icon: 'warning',
|
||
@@ -395,12 +1055,14 @@
|
||
confirmButtonText: '确认',
|
||
}).then((result) => {
|
||
if (result.isConfirmed) {
|
||
- let id = $(this).closest(ALBUM_ITEM).data('id');
|
||
+ let id = $(this).closest(ALBUM_ROW).data('id');
|
||
axios.delete(`/user/albums/${id}`).then(response => {
|
||
if (response.data.status) {
|
||
selectedAlbum = {};
|
||
resetImages();
|
||
- setTimeout(_ => drawer.close(), 300)
|
||
+ // 旧实现是 300ms 后把抽屉整个收掉;弹窗里改成原地刷新列表
|
||
+ // —— 删掉的那一行立刻消失、图片数跟着重算,用户还能接着挑别的相册
|
||
+ resetAlbums();
|
||
} else {
|
||
toastr.error(response.data.message);
|
||
}
|
||
@@ -432,15 +1094,18 @@
|
||
let $errorMessage = $albums.find(UPDATE_ID + ' .error-message').html('').hide();
|
||
if (response.data.status) {
|
||
let $editContainer = $(this).closest(UPDATE_ID);
|
||
- $albums.find(`>a[data-id=${$editContainer.data('id')}]`)
|
||
- .attr('title', $form.find('textarea').val())
|
||
- .find('.name').text($form.find('input').val());
|
||
+ let $row = $albums.find(`> ${ALBUM_ROW}[data-id=${$editContainer.data('id')}]`);
|
||
+ $row.find('a.albums-item').attr('title', $form.find('textarea').val());
|
||
+ $row.find('.name').text($form.find('input').val());
|
||
$editContainer.remove();
|
||
} else {
|
||
$errorMessage.html('<i class="fas fa-exclamation-circle"></i> ' + response.data.message).show();
|
||
}
|
||
});
|
||
});
|
||
+
|
||
+ // 搜索框:输入即过滤(本地,不打接口)
|
||
+ $('#album-switch-search').off('input').on('input', _ => applyFilter());
|
||
});
|
||
}
|
||
|
||
@@ -449,9 +1114,366 @@
|
||
$('#order span').text({newest: '最新', earliest: '最早', utmost: '最大', least: '最小'}[sort]);
|
||
};
|
||
|
||
- const setPermission = function (permission) {
|
||
- resetImages({page: 1, permission: permission})
|
||
- $('#permission span').text({public: '公开', private: '私有', all: '全部'}[permission]);
|
||
+ // 顶部「标签」下拉的内容由这里渲染:点一下即筛选(多选 AND),不等下拉收起。
|
||
+ // 多选项沿用本页 44px 点击区约定(min-h-[44px]),手机上才点得中。
|
||
+ const renderTagFilter = () => {
|
||
+ let $list = $('#tag-filter-list');
|
||
+ if (! $list.length) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ let html = '';
|
||
+ if (! allTags.length) {
|
||
+ html = '<div class="px-3.5 py-3 text-[13px] text-ink-3">暂无标签</div>';
|
||
+ } else {
|
||
+ for (const tag of allTags) {
|
||
+ let on = selectedTagIds.indexOf(tag.id) !== -1;
|
||
+ html += '<a href="javascript:void(0)" data-tag-id="' + tag.id + '"'
|
||
+ + ' class="tag-filter-item flex min-h-[44px] items-center gap-2 px-3.5 py-2 text-[13.5px] cursor-pointer '
|
||
+ + (on ? 'text-brand font-medium' : 'text-ink-2')
|
||
+ + ' hover:bg-surface-2 hover:text-ink">'
|
||
+ + '<i class="fas ' + (on ? 'fa-check-square text-brand' : 'fa-square text-ink-3') + ' w-3.5 shrink-0"></i>'
|
||
+ + '<span class="min-w-0 flex-1 truncate">' + escapeHtml(tag.name) + '</span>'
|
||
+ + '<span class="shrink-0 text-[12px] text-ink-3">' + tag.images_count + ' 张</span>'
|
||
+ + '</a>';
|
||
+ }
|
||
+ }
|
||
+ $list.html(html);
|
||
+ $('#tag-filter-clear').toggleClass('hidden', selectedTagIds.length === 0);
|
||
+ $('#tag-filter span').text(selectedTagIds.length ? `标签(${selectedTagIds.length})` : '标签');
|
||
+ };
|
||
+
|
||
+ // 拉一次标签列表(页面加载时、以及增删标签之后)
|
||
+ const loadTags = () => axios.get('{{ route('user.tags') }}').then(response => {
|
||
+ if (! response.data.status) {
|
||
+ return;
|
||
+ }
|
||
+ allTags = response.data.data.tags || [];
|
||
+ renderTagFilter();
|
||
+ renderDetailTagOptions();
|
||
+ });
|
||
+
|
||
+ // 按当前选中的标签重拉图片墙(多标签 AND;没有选中就是不筛)
|
||
+ const setTags = function () {
|
||
+ // ⚠ 这里**必须每次都显式带 tags**(清空时给空数组):utils.infiniteScroll 是把参数
|
||
+ // $.extend 进它内部那份 data 的,只传 {page:1} 的话上一次的 tags 会残留下来 ——
|
||
+ // 表现就是「点了清除筛选,结果还是被标签筛着」。空数组会被 axios 序列化成零个参数,
|
||
+ // 所以清空时查询串里不会出现任何 tags(后端也就不用处理「空值」这种形状)。
|
||
+ resetImages({page: 1, tags: selectedTagIds.slice()});
|
||
+ };
|
||
+
|
||
+ // 勾/取消一个标签
|
||
+ const toggleTagFilter = function (id) {
|
||
+ let index = selectedTagIds.indexOf(id);
|
||
+ if (index === -1) {
|
||
+ selectedTagIds.push(id);
|
||
+ } else {
|
||
+ selectedTagIds.splice(index, 1);
|
||
+ }
|
||
+ renderTagFilter();
|
||
+ setTags();
|
||
+ };
|
||
+
|
||
+ $('#tag-filter-menu').off('click', '.tag-filter-item').on('click', '.tag-filter-item', function () {
|
||
+ toggleTagFilter($(this).data('tag-id'));
|
||
+ });
|
||
+
|
||
+ $('#tag-filter-clear').off('click').on('click', function () {
|
||
+ selectedTagIds = [];
|
||
+ renderTagFilter();
|
||
+ setTags();
|
||
+ });
|
||
+
|
||
+ /* ---------------- 标签管理(全站唯一的标签窗口) ----------------
|
||
+ * 一个窗口管两件事:
|
||
+ * ① 给「选中的这几张图」打标:勾上 = 给它们加上该标签,取消勾选 = 从它们移除;
|
||
+ * ② 标签本身:新建 / 重命名 / 删除(行右侧两个常显的 44×44 按钮)。
|
||
+ * 入口:桌面工具栏 / 手机 ⋯ 菜单(有选中图片时出现)、图片右键菜单、顶部标签下拉底部。
|
||
+ * 没选中图片也能打开(此时只有第 ② 件事可做)。
|
||
+ * 任何一处改动都会刷新三个消费方:筛选下拉、详情卡候选、图片墙(角标 + 筛选结果)。
|
||
+ * ------------------------------------------------------------------ */
|
||
+
|
||
+ /* 注:这里原有一个 refreshAfterTagChange()(改名/删除后 loadTags + setTags)。
|
||
+ * setTags() 会通过 resetImages() 清空图片墙并 ds.clearSelection() —— 把用户正在打标的
|
||
+ * 这批选中图片连同弹窗勾选态一起丢掉(老师报的「编辑/删除会整页刷新」就是这个)。
|
||
+ * 已删除:改名/删除改成 patchCardsTag() 就地同步;只有「被删的标签正用作筛选项」
|
||
+ * 那种结果集真的变了的情况,才单独调一次 setTags()。 */
|
||
+
|
||
+ // 打开标签管理窗口。selIds 是当前选中的图片 id(可能为空)
|
||
+ const openTagManager = (selIds) => {
|
||
+ selIds = (selIds || []).map(String); // 与 data-id / JSON 里的 id 统一成字符串比较
|
||
+
|
||
+ // 选中的这些图片各自带了哪些标签:直接读卡片上的 data-json(列表接口已带 tags)
|
||
+ // tagId -> 命中张数,用来区分「全有 / 只有部分有 / 都没有」
|
||
+ const hitCount = {};
|
||
+ $photos.find(IMAGES_ITEM).each(function () {
|
||
+ const json = $(this).data('json') || {};
|
||
+ if (selIds.indexOf(String(json.id)) === -1) {
|
||
+ return;
|
||
+ }
|
||
+ for (const tag of (json.tags || [])) {
|
||
+ hitCount[String(tag.id)] = (hitCount[String(tag.id)] || 0) + 1;
|
||
+ }
|
||
+ });
|
||
+
|
||
+ const hasSelection = selIds.length > 0;
|
||
+ const allHave = (id) => hasSelection && hitCount[String(id)] === selIds.length;
|
||
+ const someHave = (id) => (hitCount[String(id)] || 0) > 0;
|
||
+
|
||
+ let addIds = []; // 要加上去的标签
|
||
+ let removeIds = []; // 要从这些图片上移除的标签
|
||
+
|
||
+ $('#image-tags-content').html(
|
||
+ $('#image-tags-tpl').html().replace(/__hint__/g, hasSelection
|
||
+ ? `已选择 ${selIds.length} 张图片`
|
||
+ : '未选择图片')
|
||
+ );
|
||
+
|
||
+ const $list = $('#image-tags-list');
|
||
+ const $confirm = $('#image-tags-confirm');
|
||
+ // 没选中图片时,「确定」没有可提交的内容 —— 直接不显示它
|
||
+ $confirm.prop('disabled', true).toggle(hasSelection);
|
||
+ // 有实际改动(要加或要移除至少一项)才让「确定」可点
|
||
+ const refreshConfirm = () => $confirm.prop('disabled', addIds.length === 0 && removeIds.length === 0);
|
||
+
|
||
+ // 勾选态 = 「确定后这些图片都会有这个标签」
|
||
+ const isChecked = (id) => addIds.indexOf(String(id)) !== -1
|
||
+ || (removeIds.indexOf(String(id)) === -1 && allHave(id));
|
||
+
|
||
+ // 每一行按状态上色/换图标;标签名一律转义后再进 innerHTML
|
||
+ const renderRows = () => {
|
||
+ if (! allTags.length) {
|
||
+ // 空态跟菜单项一样厚(原来 py-4 比别的行高一倍,肉眼看整块偏下)
|
||
+ $list.html('<div class="w-full py-2 text-center text-[13px] leading-5 text-ink-3">暂无标签,可在下方新建</div>');
|
||
+ return;
|
||
+ }
|
||
+ let html = '';
|
||
+ for (const tag of allTags) {
|
||
+ let $row = $($('#image-tags-item-tpl').html()
|
||
+ .replace(/__id__/g, tag.id)
|
||
+ .replace(/__name__/g, escapeHtml(tag.name).replace(/\$/g, '$$$$'))
|
||
+ .replace(/__images_count__/g, tag.images_count)
|
||
+ .replace(/__json__/g, escapeHtml(JSON.stringify(tag)).replace(/\$/g, '$$$$')));
|
||
+
|
||
+ let id = String(tag.id);
|
||
+ let checked = isChecked(id);
|
||
+ let pendingAdd = addIds.indexOf(id) !== -1;
|
||
+ let pendingRemove = removeIds.indexOf(id) !== -1;
|
||
+ let partial = ! checked && ! pendingRemove && someHave(id) && ! allHave(id);
|
||
+
|
||
+ // 行内只留左边那个勾表达状态(老师要求:不要「已有 / 移除」这类字样,太误导)
|
||
+ let icon = 'fa-square text-ink-3';
|
||
+ if (checked) {
|
||
+ icon = 'fa-check-square text-brand';
|
||
+ } else if (pendingRemove) {
|
||
+ icon = 'fa-square text-danger';
|
||
+ } else if (partial) {
|
||
+ icon = 'fa-minus-square text-brand';
|
||
+ }
|
||
+ $row.find('.tag-state-icon').attr('class', 'tag-state-icon fas w-4 shrink-0 ' + icon);
|
||
+ if (pendingAdd) {
|
||
+ $row.addClass('border-brand bg-brand-soft');
|
||
+ } else if (pendingRemove) {
|
||
+ $row.addClass('border-danger');
|
||
+ }
|
||
+
|
||
+ html += $row.get(0).outerHTML;
|
||
+ }
|
||
+ $list.html(html);
|
||
+ };
|
||
+
|
||
+ // 勾选 / 取消勾选:两态语义(勾上=加上,取消=移除);「部分有」点一下变全勾
|
||
+ $list.off('click', '.image-tag-toggle').on('click', '.image-tag-toggle', function (e) {
|
||
+ e.preventDefault();
|
||
+ let id = String($(this).closest('.image-tag-row').data('id'));
|
||
+ let checked = isChecked(id);
|
||
+ addIds = addIds.filter(item => item !== id);
|
||
+ removeIds = removeIds.filter(item => item !== id);
|
||
+ if (! checked) {
|
||
+ addIds.push(id); // 变勾上 = 给这些图片加上
|
||
+ } else if (someHave(id)) {
|
||
+ removeIds.push(id); // 变不勾 = 从这些图片移除(本来就没有的不必提交)
|
||
+ }
|
||
+ renderRows();
|
||
+ refreshConfirm();
|
||
+ });
|
||
+
|
||
+ // 新建标签:有选中图片时,建好就直接算「给这些图片加上」
|
||
+ $('#image-tags-create').off('click').on('click', function () {
|
||
+ let $input = $('#image-tags-new');
|
||
+ let name = ($input.val() || '').trim();
|
||
+ if (! name) {
|
||
+ return $input.trigger('focus');
|
||
+ }
|
||
+ const attach = (id) => {
|
||
+ $input.val('');
|
||
+ if (hasSelection) {
|
||
+ id = String(id);
|
||
+ if (addIds.indexOf(id) === -1) {
|
||
+ addIds.push(id);
|
||
+ }
|
||
+ removeIds = removeIds.filter(item => item !== id);
|
||
+ renderRows();
|
||
+ refreshConfirm();
|
||
+ }
|
||
+ };
|
||
+ let exists = allTags.find(tag => tag.name === name);
|
||
+ if (exists) {
|
||
+ return attach(exists.id); // 同名幂等:不重复建,直接勾上它
|
||
+ }
|
||
+ axios.post('{{ route('user.tag.create') }}', {name: name}).then(response => {
|
||
+ if (! response.data.status) {
|
||
+ return toastr.warning(response.data.message);
|
||
+ }
|
||
+ allTags.unshift({id: response.data.data.id, name: response.data.data.name, images_count: 0});
|
||
+ renderTagFilter();
|
||
+ renderDetailTagOptions();
|
||
+ renderRows();
|
||
+ toastr.success(response.data.message);
|
||
+ attach(response.data.data.id);
|
||
+ }).catch(error => toastr.warning(apiErrMsg(error, '创建标签失败')));
|
||
+ });
|
||
+
|
||
+ // 重命名:点行右侧「编辑」在那一行下方就地展开表单(再点一次收起)
|
||
+ $list.off('click', '.update').on('click', '.update', function (e) {
|
||
+ e.preventDefault();
|
||
+ e.stopPropagation();
|
||
+ let $row = $(this).closest('.image-tag-row');
|
||
+ let opened = $('#tag-edit').data('id');
|
||
+ $('#tag-edit').remove();
|
||
+ if (String(opened) !== String($row.data('id'))) {
|
||
+ let $edit = $($('#image-tags-edit-tpl').html().replace(/__id__/g, $row.data('id')));
|
||
+ $edit.data('id', $row.data('id'));
|
||
+ $edit.find('input[name=name]').val($row.find('.name').text());
|
||
+ $row.after($edit);
|
||
+ $edit.find('input[name=name]').trigger('focus').trigger('select');
|
||
+ }
|
||
+ });
|
||
+
|
||
+ $list.off('submit', '#tag-edit form').on('submit', '#tag-edit form', function (e) {
|
||
+ e.preventDefault();
|
||
+ let $form = $(this);
|
||
+ let $error = $('#tag-edit .error-message').html('').hide();
|
||
+ axios.put($form.attr('action'), $form.serialize()).then(response => {
|
||
+ if (! response.data.status) {
|
||
+ return $error.html('<i class="fas fa-exclamation-circle"></i> ' + response.data.message).show();
|
||
+ }
|
||
+ let $row = $form.closest('#tag-edit').prev('.image-tag-row');
|
||
+ let tagId = $row.data('id');
|
||
+ let newName = ($form.find('input[name=name]').val() || '').trim();
|
||
+ $('#tag-edit').remove();
|
||
+ toastr.success(response.data.message);
|
||
+ // 局部更新:重拉标签缓存(筛选下拉 / 详情卡候选 / 行内名字),
|
||
+ // 再就地改写卡片角标与 data-json —— 不重拉图片墙,选中不丢。
|
||
+ loadTags().then(() => {
|
||
+ patchCardsTag(tagId, newName);
|
||
+ renderRows();
|
||
+ });
|
||
+ }).catch(error => $error.html('<i class="fas fa-exclamation-circle"></i> '
|
||
+ + apiErrMsg(error, '修改失败')).show());
|
||
+ });
|
||
+
|
||
+ // 删除标签:二次确认(明确说明会从所有图片上移除,且不可恢复)
|
||
+ $list.off('click', '.delete').on('click', '.delete', function (e) {
|
||
+ e.preventDefault();
|
||
+ e.stopPropagation();
|
||
+ let tag = $(this).closest('.image-tag-row').data('json') || {};
|
||
+ Swal.fire({
|
||
+ // 与新弹窗(x-modal)一套:不动背景页面
|
||
+ heightAuto: false,
|
||
+ scrollbarPadding: false,
|
||
+ title: '确认删除该标签?',
|
||
+ html: '删除后将从所有图片上移除标签「' + escapeHtml(tag.name) + '」,且不可恢复。',
|
||
+ icon: 'warning',
|
||
+ showCancelButton: true,
|
||
+ confirmButtonColor: '#3085d6',
|
||
+ cancelButtonColor: '#d33',
|
||
+ confirmButtonText: '确认',
|
||
+ cancelButtonText: '取消',
|
||
+ }).then(result => {
|
||
+ if (! result.isConfirmed) {
|
||
+ return;
|
||
+ }
|
||
+ axios.delete(TAG_DELETE_URL.replace('__ID__', tag.id)).then(response => {
|
||
+ if (! response.data.status) {
|
||
+ return toastr.warning(response.data.message);
|
||
+ }
|
||
+ // 这个标签要从所有本地状态里摘干净:筛选选中、待添加、待移除、行内编辑表单
|
||
+ // 它是不是正被用作筛选项 —— 决定要不要重拉图片墙(见下)
|
||
+ let wasFilter = selectedTagIds.some(id => String(id) === String(tag.id));
|
||
+ selectedTagIds = selectedTagIds.filter(id => String(id) !== String(tag.id));
|
||
+ addIds = addIds.filter(id => id !== String(tag.id));
|
||
+ removeIds = removeIds.filter(id => id !== String(tag.id));
|
||
+ $('#tag-edit').remove();
|
||
+ refreshConfirm();
|
||
+ toastr.success(response.data.message);
|
||
+ loadTags().then(() => {
|
||
+ patchCardsTag(tag.id, null);
|
||
+ renderRows();
|
||
+ // 只有它正被当筛选项时结果集才真的变了,那时才有必要重拉图片墙
|
||
+ if (wasFilter) {
|
||
+ setTags();
|
||
+ }
|
||
+ });
|
||
+ }).catch(error => toastr.warning(apiErrMsg(error, '删除失败')));
|
||
+ });
|
||
+ });
|
||
+
|
||
+ $('#image-tags-cancel').off('click').on('click', _ => modal.close(TAGS_MODAL));
|
||
+
|
||
+ $confirm.off('click').on('click', function () {
|
||
+ if (! addIds.length && ! removeIds.length) {
|
||
+ return false;
|
||
+ }
|
||
+
|
||
+ // 内部一律用字符串比较 id;提交给后端时转回数字(与原来的请求形状一致)
|
||
+ let payload = {ids: selIds.map(Number)};
|
||
+ if (addIds.length) {
|
||
+ payload.tags = addIds.map(Number);
|
||
+ }
|
||
+ if (removeIds.length) {
|
||
+ payload.remove_tags = removeIds.map(Number);
|
||
+ }
|
||
+
|
||
+ axios.put('{{ route('user.images.tags') }}', payload).then(response => {
|
||
+ if (! response.data.status) {
|
||
+ return toastr.warning(response.data.message);
|
||
+ }
|
||
+
|
||
+ modal.close(TAGS_MODAL);
|
||
+
|
||
+ // 就地同步这些图片的卡片角标(含刚新建的标签)
|
||
+ let nameOf = {};
|
||
+ for (const tag of allTags) {
|
||
+ nameOf[tag.id] = tag.name;
|
||
+ }
|
||
+ for (const id of selIds) {
|
||
+ let $item = $photos.find(`${IMAGES_ITEM}[data-id="${id}"]`);
|
||
+ let json = $item.data('json') || {};
|
||
+ let tags = (json.tags || []).filter(tag => removeIds.indexOf(String(tag.id)) === -1);
|
||
+ for (const addId of addIds) {
|
||
+ if (! tags.some(tag => String(tag.id) === addId)) {
|
||
+ tags.push({id: Number(addId), name: nameOf[addId] || ''});
|
||
+ }
|
||
+ }
|
||
+ syncCardTags(json.id, tags);
|
||
+ }
|
||
+
|
||
+ toastr.success(response.data.message);
|
||
+ // 正在按标签筛选时结果集可能变了;标签张数也变了 —— 都重拉一次
|
||
+ if (selectedTagIds.length) {
|
||
+ setTags();
|
||
+ }
|
||
+ loadTags();
|
||
+ }).catch(error => toastr.warning(apiErrMsg(error, '设置失败')));
|
||
+ });
|
||
+
|
||
+ renderRows();
|
||
+ refreshConfirm();
|
||
+ modal.open(TAGS_MODAL);
|
||
+
|
||
+ // 打开时静默对齐一次服务端(别的标签页刚改过也能看到最新的)
|
||
+ loadTags().then(renderRows);
|
||
};
|
||
|
||
$('#search').keydown(function (e) {
|
||
@@ -468,11 +1490,158 @@
|
||
});
|
||
</script>
|
||
<script>
|
||
+ /* ---- 页面缩放必须告诉 DragSelect(fork 修复,这是"框选判定整体偏右下"的真因)----
|
||
+ * 页面 <html> 带着 common.less 的 zoom:1.1。DragSelect **自带**缩放支持(zoom 选项,
|
||
+ * 内部 _zoom 就是为它准备的),但不传就会按"无缩放"算:
|
||
+ * 实测它的「内部判定框」= 用户看到的那只框 × 1.1 + 偏移(-28, +55),
|
||
+ * 尺寸大 10%(411×312 → 452×343),位置偏差随滚动量增大 ——
|
||
+ * 于是"框住上一行、下面一整行却被选中"、"误差随滚动变大"(老师报的正是这个)。
|
||
+ * 之前 fork 里叠的那层手工补偿(把内联坐标除以 zoom 再写回去)是**第二个补偿**,
|
||
+ * 与库自身的算法互相打架,已整体删除 —— 交给库自己算。
|
||
+ * 倍数用「视觉尺寸 ÷ 布局尺寸」的比值求,不读 getComputedStyle(html).zoom:
|
||
+ * 浏览器自身的页面缩放(Ctrl+±)也会折进那个值里,读数会偏大。 */
|
||
+ const dsPageZoom = () => {
|
||
+ const el = document.querySelector(IMAGES_SCROLL);
|
||
+ if (! el) {
|
||
+ return 1;
|
||
+ }
|
||
+ const visual = el.getBoundingClientRect().width;
|
||
+ const layout = el.offsetWidth;
|
||
+ return (visual > 0 && layout > 0) ? visual / layout : 1;
|
||
+ };
|
||
+
|
||
const ds = new DragSelect({
|
||
area: $(IMAGES_SCROLL).get(0),
|
||
keyboardDrag: false,
|
||
+ zoom: dsPageZoom(),
|
||
});
|
||
|
||
+ /* ---------------- 框选失效的真因与修法(fork 修复) ----------------
|
||
+ * 现象:图片墙上按住左键拖动,怎么拖都框不出选择框(按在图片上、按在缝隙上都不行)。
|
||
+ * 真因:DragSelect 会给区域套一层 .ds-selector-area 包装盒,并把这个盒子的矩形**缓存**下来
|
||
+ * (SelectorArea.rect,只在 reset 时失效)。构造 DragSelect 时图片还没渲染,量到的
|
||
+ * 高度只有几十像素;图片墙长高后缓存没刷新,于是 mousedown 的位置被判成「在区域之外」
|
||
+ * (Interaction._canInteract 里 SelectorArea.isClicked 为 false),拖动直接不启动。
|
||
+ * 实测:缓存 59px vs 实际 1697px。
|
||
+ * 修法:图片墙每次排完版(justifiedGallery 触发 jg.complete / jg.resize)就调一次
|
||
+ * ds.Interaction.init(),让它重新测量 —— 实测恢复后按在图片上、按在缝隙上都能框选。
|
||
+ * 注意:不能用 justifiedGallery('norewind') 之后立刻调用,那时排版还没跑完(norewind 本身
|
||
+ * 是空操作),量到的还是旧高度,必须挂在 jg.complete / jg.resize 事件上。
|
||
+ * ---------------------------------------------------------------- */
|
||
+ $photos.on('jg.complete jg.resize', () => {
|
||
+ try {
|
||
+ ds.Interaction.init();
|
||
+ // 顺手让库的缓存矩形失效(它就是当初"拖不动"的元凶:构造时图片没渲染,量到几十像素)
|
||
+ ds.Area && (ds.Area._rect = undefined);
|
||
+ ds.SelectorArea && (ds.SelectorArea._rect = undefined);
|
||
+ } catch (e) {
|
||
+ console.warn('ds re-measure skipped:', e);
|
||
+ }
|
||
+ });
|
||
+
|
||
+ /* ---- 自己记选择框:让"库的判定矩形"与"屏幕上那只框"都绑到原始指针坐标上 ----
|
||
+ * 真因(实测,别再走回头路):
|
||
+ * 页面 = <html>{zoom:1.1} + 整页滚动。DragSelect 内部把**布局单位**的滚动量混进了
|
||
+ * **视觉**指针坐标里,算出的判定框 = 真实框 ×1.1 + 随滚动增大的偏移
|
||
+ * (滚动 670 时偏 (28,−55) 量级;误差 ≈ scroll×(1−1/zoom),所以"不滚动就不偏")。
|
||
+ * 它的 zoom 选项只影响尺寸、不修指针坐标 —— 喂给它 zoom:1.1 也没用(实测照旧 ×1.1)。
|
||
+ * 于是"框住上一行、下面一整行被选中",而且框越大偏得越离谱。
|
||
+ * 之前的错法:覆盖 Selector.rect 时读 .ds-selector 的实时矩形 —— 那正是库刚写进去的
|
||
+ * 错误值,等于把错误读了两遍(验收因此假绿)。
|
||
+ * 正解:clientX/clientY 与 getBoundingClientRect() 同属**视觉**坐标,这里按原始指针
|
||
+ * 路径自己记框,然后 ① 覆盖 Selector.rect 给库判定用 ② 每帧把屏幕上的框也摆到同一处。
|
||
+ * ------------------------------------------------------------------------ */
|
||
+ const dsBox = {on: false, x0: 0, y0: 0, x1: 0, y1: 0};
|
||
+ let dsRaf = 0;
|
||
+ const dsBoxRect = () => {
|
||
+ const l = Math.min(dsBox.x0, dsBox.x1), t = Math.min(dsBox.y0, dsBox.y1);
|
||
+ const r = Math.max(dsBox.x0, dsBox.x1), b = Math.max(dsBox.y0, dsBox.y1);
|
||
+ return {left: l, top: t, right: r, bottom: b, width: r - l, height: b - t};
|
||
+ };
|
||
+ const dsPaint = () => {
|
||
+ if (! dsBox.on) {
|
||
+ dsRaf = 0;
|
||
+ return;
|
||
+ }
|
||
+ const z = dsPageZoom() || 1;
|
||
+ const area = document.querySelector(IMAGES_SCROLL);
|
||
+ const wrap = document.querySelector('.ds-selector-area');
|
||
+ const box = document.querySelector('.ds-selector');
|
||
+ const ar = area ? area.getBoundingClientRect() : null;
|
||
+ if (wrap && ar) {
|
||
+ // 外壳:库也把它摆错(内联 281.59 渲染成 310)→ 按区域矩形重铺
|
||
+ wrap.style.left = (ar.left / z) + 'px';
|
||
+ wrap.style.top = (ar.top / z) + 'px';
|
||
+ wrap.style.width = (ar.width / z) + 'px';
|
||
+ wrap.style.height = (ar.height / z) + 'px';
|
||
+ }
|
||
+ if (box && ar && getComputedStyle(box).display !== 'none') {
|
||
+ const r = dsBoxRect();
|
||
+ // 框是外壳的子元素 → 坐标必须相对区域原点,否则会再叠一层外壳的偏移(实测差 310px)
|
||
+ box.style.left = ((r.left - ar.left) / z) + 'px';
|
||
+ box.style.top = ((r.top - ar.top) / z) + 'px';
|
||
+ box.style.width = (r.width / z) + 'px';
|
||
+ box.style.height = (r.height / z) + 'px';
|
||
+ }
|
||
+ dsRaf = requestAnimationFrame(dsPaint);
|
||
+ };
|
||
+ // 必须用**捕获阶段**:DragSelect 在 mousedown 的冒泡阶段就做判定,
|
||
+ // 注册在后的话它那一刻读到的是"还没开始的空框" → 单击卡片不会勾选(实测 0 张)。
|
||
+ document.addEventListener('mousedown', e => {
|
||
+ if (e.button !== 0 || ! e.target || ! e.target.closest) {
|
||
+ return;
|
||
+ }
|
||
+ if (! e.target.closest(IMAGES_SCROLL + ', ' + IMAGES_ITEM)) {
|
||
+ return;
|
||
+ }
|
||
+ dsBox.on = true;
|
||
+ dsBox.x0 = dsBox.x1 = e.clientX;
|
||
+ dsBox.y0 = dsBox.y1 = e.clientY;
|
||
+ if (! dsRaf) {
|
||
+ dsRaf = requestAnimationFrame(dsPaint);
|
||
+ }
|
||
+ }, true);
|
||
+ document.addEventListener('mousemove', e => {
|
||
+ if (! dsBox.on) {
|
||
+ return;
|
||
+ }
|
||
+ dsBox.x1 = e.clientX;
|
||
+ dsBox.y1 = e.clientY;
|
||
+ }, true);
|
||
+ document.addEventListener('mouseup', () => {
|
||
+ dsBox.on = false;
|
||
+ }, true);
|
||
+ // 库判定用的矩形 = 原始指针坐标下的框(与卡片矩形同属视觉坐标,比较才成立)
|
||
+ try {
|
||
+ Object.defineProperty(ds.Selector, 'rect', {
|
||
+ configurable: true,
|
||
+ get() {
|
||
+ return dsBox.on ? dsBoxRect()
|
||
+ : {left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0};
|
||
+ },
|
||
+ });
|
||
+ } catch (e) {
|
||
+ console.warn('ds selector rect override skipped:', e);
|
||
+ }
|
||
+
|
||
+ // 让图片不再被浏览器当成可拖拽元素:否则按在图片上拖动会起原生拖拽,
|
||
+ // mousemove 被 drag 事件吃掉 —— 选择框不跟随、松手也不结束互动(实测「锁定不释放」)。
|
||
+ // -webkit-user-drag 管 Chromium/Safari(见样式块),dragstart 拦一发兜住 Firefox。
|
||
+ $photos.on('dragstart', e => {
|
||
+ if ($(e.target).closest(IMAGES_ITEM).length) {
|
||
+ e.preventDefault();
|
||
+ }
|
||
+ });
|
||
+
|
||
+ // 单击(没拖动)不改变勾选 —— 见上面 ②
|
||
+ // 兜底:万一库没收到 mouseup(拖到窗口外等),松手也要解锁,别让界面卡在拖动状态
|
||
+ document.addEventListener('mouseup', () => {
|
||
+ if (ds.Interaction && ds.Interaction.isInteracting) {
|
||
+ ds.Interaction.reset({});
|
||
+ }
|
||
+ });
|
||
+
|
||
+
|
||
const bindOperates = () => {
|
||
let selected = ds.getSelection();
|
||
if (selected.length) {
|
||
@@ -486,10 +1655,10 @@
|
||
operates = ['refresh'];
|
||
}
|
||
if (selected.length === 1) {
|
||
- operates = ['refresh', 'movements', 'permission', 'detail', 'rename', 'delete'];
|
||
+ operates = ['refresh', 'movements', 'tag', 'detail', 'rename', 'delete', 'deselect'];
|
||
}
|
||
if (selected.length > 1) {
|
||
- operates = ['refresh', 'movements', 'permission', 'delete'];
|
||
+ operates = ['refresh', 'movements', 'tag', 'delete', 'deselect'];
|
||
}
|
||
if (selected.length && selectedAlbum.id !== undefined) {
|
||
operates.push('remove');
|
||
@@ -502,17 +1671,68 @@
|
||
ds.stop();
|
||
}
|
||
|
||
- if (! $(event.target).hasClass('dragselect')) {
|
||
+ // 能起拖动的目标:网格/空白(带 dragselect 类)、卡片 <a> 本身、卡片里的 <img>。
|
||
+ // 别的(右上角小圆勾等控件)一律 break(),保持它们原来的点选行为 ——
|
||
+ // 不这么做的话,点小圆勾会被 DragSelect 当成一次拉框,顺带把旁边那张也选上。
|
||
+ // 注:实测不能退回上游「不是 .dragselect 就 break()」的判据 —— 卡片带 10px 内边距,
|
||
+ // 卡片之间的缝隙其实落在卡片自己的盒子里,按下去的目标是卡片而不是空白网格,
|
||
+ // 一 break 就连「从缝隙起手」也框不出来了(实测 0 张)。
|
||
+ let $target = $(event.target);
|
||
+ let onCardSurface = $target.is(IMAGES_ITEM)
|
||
+ || ($target.is('img') && $target.closest(IMAGES_ITEM).length > 0);
|
||
+ if (! $target.hasClass('dragselect') && ! onCardSurface) {
|
||
ds.break();
|
||
}
|
||
});
|
||
ds.subscribe('elementselect', _ => bindOperates());
|
||
ds.subscribe('elementunselect', _ => bindOperates());
|
||
|
||
+ // 侧栏折叠/展开会改变图片墙的可用宽度:justifiedGallery 是按容器宽度算布局的,
|
||
+ // 不重排就会挤成一团或留一大块空白。等 300ms 的宽度动画结束再重排,期间不动它,
|
||
+ // 免得按"动画中间"的宽度算。viewer(看图器)与框选目标也要跟着更新。
|
||
+ window.addEventListener('lsky:sidebar-toggled', () => {
|
||
+ setTimeout(() => {
|
||
+ if (! $photos.find(IMAGES_ITEM).length) {
|
||
+ return; // 没数据时 justifiedGallery 是销毁状态,重排会报错
|
||
+ }
|
||
+
|
||
+ try {
|
||
+ $photos.justifiedGallery('norewind');
|
||
+ viewer.update();
|
||
+ ds.setSelectables($photos.find(IMAGES_ITEM));
|
||
+ } catch (e) {
|
||
+ // 重排失败也不影响页面:下次翻页/切相册会重新走一遍布局
|
||
+ console.warn('sidebar re-layout skipped:', e);
|
||
+ }
|
||
+ }, 320);
|
||
+ });
|
||
+
|
||
$photos.on('click', '.image-selector', function () {
|
||
ds.toggleSelection($(this).closest('a'));
|
||
bindOperates();
|
||
})
|
||
+
|
||
+ /* ---------------- 卡片角标开关(显示图片标签) ---------------- */
|
||
+
|
||
+ // 默认显示。关掉后给网格加一个类(.image-tags-off),角标整体不渲染显示 ——
|
||
+ // 角标是每次翻页重新生成的,所以用容器上的类控制,不去改每张卡片的 DOM。
|
||
+ const applyTagBadgeVisibility = (show) => {
|
||
+ $photos.toggleClass('image-tags-off', ! show);
|
||
+ $('#tag-badge-switch')
|
||
+ .toggleClass('bg-brand', show)
|
||
+ .toggleClass('bg-line', ! show)
|
||
+ .attr('aria-checked', show ? 'true' : 'false');
|
||
+ $('#tag-badge-switch .tag-badge-knob')
|
||
+ .toggleClass('translate-x-[12px]', show); // 开:滑块滑到右边(28 - 12 - 2*2 = 12)
|
||
+ };
|
||
+ let showImageTags = localStorage.getItem(TAG_BADGE_KEY) !== '0';
|
||
+ applyTagBadgeVisibility(showImageTags);
|
||
+
|
||
+ $('#tag-badge-toggle').off('click').on('click', function () {
|
||
+ showImageTags = ! showImageTags;
|
||
+ localStorage.setItem(TAG_BADGE_KEY, showImageTags ? '1' : '0');
|
||
+ applyTagBadgeVisibility(showImageTags);
|
||
+ });
|
||
</script>
|
||
<script>
|
||
context.init({
|
||
@@ -533,27 +1753,246 @@
|
||
toastr.warning('复制失败')
|
||
});
|
||
|
||
+ /* ---------------- 标签(fork 新增) ----------------
|
||
+ * 详情卡里的标签:本地态 detailImageTags 保存当前这张图的标签,
|
||
+ * 增/删成功后立刻回写弹窗与图片墙上的卡片角标,并顺手刷新标签使用数量。
|
||
+ * -------------------------------------------------- */
|
||
+ let detailImageId = null;
|
||
+ let detailImageTags = [];
|
||
+
|
||
+ // 把当前图片的标签画成一排 chip(每个 chip 带一个「移除」小按钮)
|
||
+ const renderDetailTags = () => {
|
||
+ let $box = $('#detail-tags');
|
||
+ if (! $box.length) {
|
||
+ return;
|
||
+ }
|
||
+ if (! detailImageTags.length) {
|
||
+ $box.html('<span class="text-[13px] text-ink-3">暂无标签</span>');
|
||
+ return;
|
||
+ }
|
||
+ let html = '';
|
||
+ for (const tag of detailImageTags) {
|
||
+ html += '<span class="ls-badge gap-1 bg-brand-soft text-brand">' + escapeHtml(tag.name)
|
||
+ + '<button type="button" class="detail-tag-remove -mr-0.5 leading-none text-ink-3 hover:text-danger"'
|
||
+ + ' data-tag-id="' + tag.id + '" aria-label="移除标签">×</button></span>';
|
||
+ }
|
||
+ $box.html(html);
|
||
+ };
|
||
+
|
||
+ // 详情卡输入框的候选 = 已有标签(datalist),输入框里没见过的名字回车即现场新建
|
||
+ const renderDetailTagOptions = () => {
|
||
+ let $options = $('#detail-tag-options');
|
||
+ if (! $options.length) {
|
||
+ return;
|
||
+ }
|
||
+ $options.html(allTags.map(tag => `<option value="${escapeHtml(tag.name)}"></option>`).join(''));
|
||
+ };
|
||
+
|
||
+ /* 标签「改名 / 删除」后就地同步所有卡片与详情卡 —— **绝不重拉图片墙**。
|
||
+ * 重拉(setTags → resetImages)会清空图片墙并 ds.clearSelection(),把用户正在打标的
|
||
+ * 这批选中图片丢掉,弹窗里的勾选态也随之失真(老师报过的「编辑/删除会整页刷新」)。
|
||
+ * newName 传 null 表示这个标签已被删除。 */
|
||
+ const patchCardsTag = (tagId, newName) => {
|
||
+ tagId = String(tagId);
|
||
+ $photos.find(IMAGES_ITEM).each(function () {
|
||
+ let $item = $(this);
|
||
+ let json = $item.data('json');
|
||
+ if (! json || ! (json.tags || []).some(t => String(t.id) === tagId)) {
|
||
+ return;
|
||
+ }
|
||
+ let next = (json.tags || [])
|
||
+ .filter(t => newName !== null || String(t.id) !== tagId)
|
||
+ .map(t => String(t.id) === tagId ? {id: t.id, name: newName} : {id: t.id, name: t.name});
|
||
+ syncCardTags(json.id, next);
|
||
+ });
|
||
+ // 详情卡正开着这张图时,它的 chip 也要跟着换
|
||
+ if (detailImageTags.some(t => String(t.id) === tagId)) {
|
||
+ detailImageTags = newName === null
|
||
+ ? detailImageTags.filter(t => String(t.id) !== tagId)
|
||
+ : detailImageTags.map(t => String(t.id) === tagId ? {id: t.id, name: newName} : t);
|
||
+ renderDetailTags();
|
||
+ }
|
||
+ };
|
||
+
|
||
+ // 同步图片墙上某张图的卡片角标(连同它的 data-json,右键菜单/详情读的是这份)
|
||
+ const syncCardTags = (imageId, tags) => {
|
||
+ let $item = $photos.find(`${IMAGES_ITEM}[data-id="${imageId}"]`);
|
||
+ if (! $item.length) {
|
||
+ return;
|
||
+ }
|
||
+ let json = $item.data('json');
|
||
+ if (json) {
|
||
+ json.tags = tags.map(tag => ({id: tag.id, name: tag.name}));
|
||
+ // 属性值同样要转义(浏览器解码后仍是合法 JSON,jQuery 读到的内容不变)
|
||
+ $item.data('json', json).attr('data-json', escapeHtml(JSON.stringify(json)));
|
||
+ }
|
||
+ $item.find('.image-tags').html(cardTagsHtml(tags));
|
||
+ };
|
||
+
|
||
+ // 单图打标/移除(详情卡用):成功后就地更新本地态 + 卡片角标
|
||
+ const submitImageTags = (imageId, addIds, removeIds) => {
|
||
+ let payload = {ids: [imageId]};
|
||
+ if (addIds.length) {
|
||
+ payload.tags = addIds;
|
||
+ }
|
||
+ if (removeIds.length) {
|
||
+ payload.remove_tags = removeIds;
|
||
+ }
|
||
+ return axios.put('{{ route('user.images.tags') }}', payload).then(response => {
|
||
+ if (! response.data.status) {
|
||
+ throw new Error(response.data.message);
|
||
+ }
|
||
+
|
||
+ let nameOf = {};
|
||
+ for (const tag of allTags) {
|
||
+ nameOf[tag.id] = tag.name;
|
||
+ }
|
||
+ for (const id of addIds) {
|
||
+ if (! detailImageTags.some(tag => tag.id === id)) {
|
||
+ detailImageTags.push({id: id, name: nameOf[id] || ''});
|
||
+ }
|
||
+ }
|
||
+ detailImageTags = detailImageTags.filter(tag => removeIds.indexOf(tag.id) === -1);
|
||
+
|
||
+ renderDetailTags();
|
||
+ syncCardTags(imageId, detailImageTags);
|
||
+ loadTags(); // 使用数量变了,顺手刷新(顶部筛选与候选同步更新)
|
||
+ toastr.success(response.data.message);
|
||
+ }).catch(error => toastr.warning(apiErrMsg(error, '设置失败')));
|
||
+ };
|
||
+
|
||
+ // 详情卡里「添加」:已有同名标签就直接挂上,没有就先 POST 新建
|
||
+ const addDetailTagFromInput = () => {
|
||
+ let $input = $('#detail-tag-input');
|
||
+ let name = ($input.val() || '').trim();
|
||
+ if (! name || detailImageId === null) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ let exists = allTags.find(tag => tag.name === name);
|
||
+ let pending = exists ? Promise.resolve(exists.id) : axios.post('{{ route('user.tag.create') }}', {name: name})
|
||
+ .then(response => {
|
||
+ if (! response.data.status) {
|
||
+ throw new Error(response.data.message);
|
||
+ }
|
||
+ allTags.unshift({id: response.data.data.id, name: response.data.data.name, images_count: 0});
|
||
+ renderTagFilter();
|
||
+ renderDetailTagOptions();
|
||
+ return response.data.data.id;
|
||
+ });
|
||
+
|
||
+ pending.then(tagId => {
|
||
+ $input.val('');
|
||
+ if (detailImageTags.some(tag => tag.id === tagId)) {
|
||
+ return;
|
||
+ }
|
||
+ return submitImageTags(detailImageId, [tagId], []);
|
||
+ }).catch(error => toastr.warning(apiErrMsg(error, '添加失败')));
|
||
+ };
|
||
+
|
||
+ // 详情弹窗里的标签按钮/回车(委托绑在常驻容器上,弹窗内容每次重渲染都不用重绑)
|
||
+ $('#image-detail-content')
|
||
+ .off('click', '.detail-tag-remove').on('click', '.detail-tag-remove', function () {
|
||
+ submitImageTags(detailImageId, [], [$(this).data('tag-id')]);
|
||
+ })
|
||
+ .off('click', '#detail-tag-add').on('click', '#detail-tag-add', _ => addDetailTagFromInput())
|
||
+ .off('keydown', '#detail-tag-input').on('keydown', '#detail-tag-input', function (e) {
|
||
+ if (e.key === 'Enter' || e.keyCode === 13) {
|
||
+ e.preventDefault();
|
||
+ addDetailTagFromInput();
|
||
+ }
|
||
+ });
|
||
+
|
||
const methods = {
|
||
movements() {
|
||
- getAlbums({title: '选择相册'}, e => {
|
||
- let selected = ds.getSelection().map(item => $(item).data('id'));
|
||
- $headerTitle.text(`移动 ${selected.length} 张图片至...`)
|
||
- $(e).off('click', '>a').on('click', '>a', function () {
|
||
- axios.put('{{ route('user.images.movement') }}', {
|
||
- selected: selected,
|
||
- id: $(this).data('id'),
|
||
- album_id: selectedAlbum.id || 0,
|
||
- }).then(response => {
|
||
- if (response.data.status) {
|
||
- drawer.close();
|
||
- resetImages();
|
||
- toastr.success(response.data.message);
|
||
- } else {
|
||
- toastr.warning(response.data.message);
|
||
- }
|
||
- })
|
||
+ // 「移动到相册」走居中卡片弹窗(相册列表也在 #album-switch-modal 里,
|
||
+ // 页面里已经没有右侧抽屉了)。
|
||
+ // 相册列表仍走同一个 user.albums 接口 + 同一个 utils.infiniteScroll;
|
||
+ // 弹窗内是单选列表(当前所在相册带「当前」标记),底部「移动」「取消」。
|
||
+ let selected = ds.getSelection().map(item => $(item).data('id'));
|
||
+ if (! selected.length) {
|
||
+ return false;
|
||
+ }
|
||
+
|
||
+ $('#image-movements-content').html(
|
||
+ $('#movements-container-tpl').html().replace(/__count__/g, selected.length)
|
||
+ );
|
||
+
|
||
+ const $movementsAlbums = $('#movements-albums');
|
||
+ const $movementsConfirm = $('#movements-confirm');
|
||
+ let targetId = null; // 本次要移动到的相册(单选)
|
||
+
|
||
+ // 单选:点一行只选中并高亮,不发请求;底部「移动」才提交
|
||
+ const selectRow = ($row) => {
|
||
+ targetId = $row.length ? $row.data('id') : null;
|
||
+ $movementsAlbums.find('.movements-album').each(function () {
|
||
+ let on = targetId !== null && $(this).data('id') === targetId;
|
||
+ $(this)
|
||
+ .toggleClass('border-brand bg-brand-soft text-brand', on)
|
||
+ .toggleClass('border-line bg-surface-2 text-ink', ! on)
|
||
+ .attr('data-selected', on ? 'true' : 'false')
|
||
+ .find('.selected-mark').toggleClass('opacity-0', ! on);
|
||
});
|
||
+ $movementsConfirm.prop('disabled', targetId === null);
|
||
+ };
|
||
+
|
||
+ utils.infiniteScroll('#movements-albums', {
|
||
+ url: '{{ route('user.albums') }}',
|
||
+ success: function (response) {
|
||
+ if (! response.status) {
|
||
+ return toastr.error(response.message);
|
||
+ }
|
||
+
|
||
+ let albums = response.data.albums.data;
|
||
+ if (albums.length <= 0 || response.data.albums.current_page === response.data.albums.last_page) {
|
||
+ this.finished = true;
|
||
+ }
|
||
+
|
||
+ let html = '';
|
||
+ for (const i in albums) {
|
||
+ html += $('#movements-album-item-tpl').html()
|
||
+ .replace(/__id__/g, albums[i].id)
|
||
+ .replace(/__name__/g, albums[i].name)
|
||
+ .replace(/__image_num__/g, albums[i].image_num)
|
||
+ // 当前所在相册标出来
|
||
+ .replace(/__current_badge__/g, albums[i].id === selectedAlbum.id
|
||
+ ? '<div class="ls-badge shrink-0 bg-brand-soft text-brand">当前</div>'
|
||
+ : '')
|
||
+ }
|
||
+
|
||
+ $movementsAlbums.append(html);
|
||
+ }
|
||
+ });
|
||
+
|
||
+ $movementsAlbums.off('click', '.movements-album').on('click', '.movements-album', function () {
|
||
+ selectRow($(this));
|
||
});
|
||
+
|
||
+ $('#movements-cancel').off('click').on('click', _ => modal.close(MOVEMENTS_MODAL));
|
||
+
|
||
+ // 移动请求沿用原实现(同一个 route、同一份 payload),
|
||
+ // 唯一区别:原来「点哪行就移动哪行」,现在是「选中后点移动」
|
||
+ $movementsConfirm.off('click').on('click', function () {
|
||
+ if (targetId === null) {
|
||
+ return false;
|
||
+ }
|
||
+
|
||
+ axios.put('{{ route('user.images.movement') }}', {
|
||
+ selected: selected,
|
||
+ id: targetId,
|
||
+ album_id: selectedAlbum.id || 0,
|
||
+ }).then(response => {
|
||
+ if (response.data.status) {
|
||
+ modal.close(MOVEMENTS_MODAL);
|
||
+ resetImages();
|
||
+ toastr.success(response.data.message);
|
||
+ } else {
|
||
+ toastr.warning(response.data.message);
|
||
+ }
|
||
+ })
|
||
+ });
|
||
+
|
||
+ modal.open(MOVEMENTS_MODAL);
|
||
},
|
||
remove() {
|
||
let selected = ds.getSelection().map(item => $(item).data('id'));
|
||
@@ -564,7 +2003,9 @@
|
||
id: null,
|
||
}).then(response => {
|
||
if (response.data.status) {
|
||
- drawer.close();
|
||
+ // 旧实现顺手把右侧抽屉收掉;语义照旧 —— 相册面板这时是脏的(图片数变了),
|
||
+ // 收起来(弹窗开着时点不到工具栏上的操作,这里是防御性关闭)
|
||
+ closeAlbums();
|
||
resetImages();
|
||
toastr.success(response.data.message);
|
||
} else {
|
||
@@ -572,47 +2013,19 @@
|
||
}
|
||
});
|
||
},
|
||
- permission() {
|
||
- Swal.fire({
|
||
- title: '选择一个权限',
|
||
- text: '选择公开将会出现在画廊中(若平台开启了画廊)',
|
||
- input: 'select',
|
||
- inputOptions: {
|
||
- public: '公开',
|
||
- private: '私有',
|
||
- },
|
||
- confirmButtonText: '确认设置',
|
||
- inputPlaceholder: '请选择一个权限',
|
||
- showCancelButton: true,
|
||
- inputValidator: (value) => {
|
||
- return new Promise((resolve) => {
|
||
- if (value === '') {
|
||
- resolve('请选择正确的权限')
|
||
- } else {
|
||
- resolve();
|
||
- }
|
||
- })
|
||
- }
|
||
- }).then(result => {
|
||
- if (result.isConfirmed) {
|
||
- let selected = ds.getSelection().map(item => $(item).data('id'));
|
||
- axios.put('{{ route('user.images.permission') }}', {
|
||
- ids: selected,
|
||
- permission: result.value,
|
||
- }).then(response => {
|
||
- if (response.data.status) {
|
||
- ds.clearSelection();
|
||
- toastr.success(response.data.message);
|
||
- } else {
|
||
- toastr.warning(response.data.message);
|
||
- }
|
||
- });
|
||
- }
|
||
- });
|
||
+ tag() {
|
||
+ // 工具栏 / 手机 ⋯ 菜单 / 图片右键菜单的「标签管理」:带上当前选中的图片打开这个窗口。
|
||
+ // 窗口本身也能做标签的新建 / 重命名 / 删除(与下拉里那个入口是同一个窗口、同一份代码)。
|
||
+ let selected = ds.getSelection().map(item => $(item).data('id'));
|
||
+ openTagManager(selected);
|
||
},
|
||
rename(e) {
|
||
let item = $(e).data('json');
|
||
Swal.fire({
|
||
+ // 与新弹窗(x-modal)一套:不动背景页面(heightAuto 去 swal2-height-auto,
|
||
+ // scrollbarPadding 去 body 的 padding-right 注入 —— 否则整页会有细微位移)
|
||
+ heightAuto: false,
|
||
+ scrollbarPadding: false,
|
||
title: '请输入图片名称',
|
||
input: 'text',
|
||
inputValue: item.filename,
|
||
@@ -649,6 +2062,10 @@
|
||
},
|
||
delete() {
|
||
Swal.fire({
|
||
+ // 与新弹窗(x-modal)一套:不动背景页面(heightAuto 去 swal2-height-auto,
|
||
+ // scrollbarPadding 去 body 的 padding-right 注入 —— 否则整页会有细微位移)
|
||
+ heightAuto: false,
|
||
+ scrollbarPadding: false,
|
||
title: '确认要删除选中的图片?',
|
||
text: "删除后不可恢复,记录和文件同时删除",
|
||
icon: 'warning',
|
||
@@ -662,13 +2079,23 @@
|
||
data: selected,
|
||
}).then(response => {
|
||
if (response.data.status) {
|
||
+ // ★ 删除后必须把卡片**也从 DragSelect 里摘干净**(2026-10-04 修):
|
||
+ // 只做 $(item).remove() 的话,库的 SelectedSet / SelectableSet 里会留下
|
||
+ // 「已不在 DOM 的幽灵卡片」—— DS 的 getSelection() 实现就是
|
||
+ // `return SelectedSet.elements`,它从不检查元素是否还在文档里(全库没有一处
|
||
+ // isConnected 判断)。于是之后任何一次 elementselect / elementunselect 触发的
|
||
+ // bindOperates() 都会把幽灵算进去:顶部又显示「已选择 N 张图片」、操作栏不收回、
|
||
+ // 再选一张时计数虚高(实测:屏幕上 1 张却显示「已选择 2 张图片」)。
|
||
+ let selected = ds.getSelection();
|
||
let size = 0;
|
||
- ds.getSelection().map(item => {
|
||
+ selected.forEach(item => {
|
||
size += $(item).data('json').size;
|
||
$(item).remove();
|
||
});
|
||
+ ds.removeSelectables(selected); // 从「可选集合」摘掉(第二个参数默认 false,不动选中集合)
|
||
+ ds.removeSelection(selected); // 从「选中集合」摘掉(发 Selected:removed → elementunselect → 自动重画顶部)
|
||
+ bindOperates(); // 兜底:标题与操作栏统一回到「未选中」形态
|
||
utils.setCapacityProgress(-size);
|
||
- $headerTitle.text('我的图片');
|
||
$photos.justifiedGallery(gridConfigs).removeClass('reset');
|
||
toastr.success(response.data.message);
|
||
} else {
|
||
@@ -683,7 +2110,11 @@
|
||
axios.get(`/user/images/${item.id}`).then(response => {
|
||
if (response.data.status) {
|
||
let image = response.data.data.image;
|
||
+ // 这张图的标签(详情卡里可以现场增删)
|
||
+ detailImageId = image.id;
|
||
+ detailImageTags = (image.tags || []).map(tag => ({id: tag.id, name: tag.name}));
|
||
let content = $('#image-detail-tpl').html()
|
||
+ .replace(/__thumb_url__/g, image.thumb_url)
|
||
.replace(/__album_name__/g, image.album ? image.album.name : '-')
|
||
.replace(/__strategy_name__/g, image.strategy ? image.strategy.name : '-')
|
||
.replace(/__filename__/g, image.filename.replace(/\$/g, '$$$$'))
|
||
@@ -694,10 +2125,13 @@
|
||
.replace(/__height__/g, image.height)
|
||
.replace(/__md5__/g, image.md5)
|
||
.replace(/__sha1__/g, image.sha1)
|
||
- .replace(/__permission__/g, image.permission === 1 ? '公开' : '私有')
|
||
.replace(/__uploaded_ip__/g, image.uploaded_ip)
|
||
.replace(/__created_at__/g, image.created_at)
|
||
- drawer.open(item.filename, content);
|
||
+ // 「详细信息」改成居中卡片弹窗(原来画进右侧抽屉)
|
||
+ $('#image-detail-content').html(content);
|
||
+ renderDetailTags();
|
||
+ renderDetailTagOptions();
|
||
+ modal.open(DETAIL_MODAL);
|
||
} else {
|
||
toastr.error(response.data.message);
|
||
}
|
||
@@ -783,9 +2217,9 @@
|
||
text: '删除',
|
||
action: _ => methods.delete(),
|
||
},
|
||
- permission: {
|
||
- text: '设置权限',
|
||
- action: _ => methods.permission(),
|
||
+ tag: {
|
||
+ text: '标签管理',
|
||
+ action: _ => methods.tag(),
|
||
},
|
||
};
|
||
// right click 'images scroll' container
|
||
@@ -803,7 +2237,7 @@
|
||
actions.open,
|
||
actions.movements,
|
||
actions.remove,
|
||
- actions.permission,
|
||
+ actions.tag,
|
||
actions.detail,
|
||
{divider: true},
|
||
actions.rename,
|
||
@@ -844,8 +2278,8 @@
|
||
case 'rename': // 重命名
|
||
methods.rename(selected[0]);
|
||
break;
|
||
- case 'permission': // 设置权限
|
||
- methods.permission();
|
||
+ case 'tag': // 标签管理(可增可减,支持现场新建;标签本身也能在这里改名/删除)
|
||
+ methods.tag();
|
||
break;
|
||
case 'detail':
|
||
methods.detail(selected[0]);
|
||
@@ -853,8 +2287,15 @@
|
||
case 'delete': // 删除
|
||
methods.delete();
|
||
break;
|
||
+ case 'deselect': // 取消选择(清空当前选中的图片)
|
||
+ ds.clearSelection();
|
||
+ bindOperates();
|
||
+ break;
|
||
}
|
||
});
|
||
+
|
||
+ // 标签列表最后拉:此时 renderDetailTagOptions / renderTagFilter 都已定义(避免 TDZ)
|
||
+ loadTags();
|
||
</script>
|
||
@endpush
|
||
</x-app-layout>
|