Files
mole404 e5e925c579 fix(delete): 删完把卡片从 DragSelect 的两个集合里摘掉
症状(老师报的):选中图片删除后顶部选中状态不重置 —— 操作栏不收回,
之后任何一次重画又会显示「已选择 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)。
2026-10-04 14:39:00 +08:00

4552 lines
269 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ---------------------------------------------------------------------------
# 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, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;');
+
+ // 统一的接口错误文案:优先用后端返回的 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="移除标签">&times;</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>