Skip to content

Commit bd08959

Browse files
cyfung1031CodFrm
andauthored
✨ GM_registerMenuCommand 二级菜单 & 分隔线 (#831)
* GM_registerMenuCommand 二级菜单 & 分隔线 * 加入稳定单独菜单项目设计 * 修一下 * 加入高级示例 * 修复单元测试 --------- Co-authored-by: 王一之 <yz@ggnb.top>
1 parent 690c4a2 commit bd08959

11 files changed

Lines changed: 219 additions & 54 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// ==UserScript==
2+
// @name ScriptCat PR #831 菜单演示(二级菜单 & 分隔线)
3+
// @namespace demo.pr831.scriptcat
4+
// @version 1.0.0
5+
// @description 演示 GM_registerMenuCommand 新增的 nested(二级菜单)与 separator(分隔线)选项;兼容 TM/SC。
6+
// @author you
7+
// @match *://*/*
8+
// @grant GM_registerMenuCommand
9+
// @grant GM_unregisterMenuCommand
10+
// ==/UserScript==
11+
12+
(async function () {
13+
"use strict";
14+
15+
const test = 4;
16+
17+
if (test === 1) {
18+
// 1 测试分隔线
19+
20+
GM_registerMenuCommand("Item 1");
21+
GM_registerMenuCommand("");
22+
let itemId = GM_registerMenuCommand("Some Item");
23+
GM_registerMenuCommand("Item 2");
24+
GM_registerMenuCommand("");
25+
GM_registerMenuCommand("Some Item");
26+
GM_unregisterMenuCommand(itemId); // 可注䆁掉看看
27+
}
28+
29+
if (test === 2) {
30+
// 2 测试分隔线 + 单独菜单项目
31+
32+
GM_registerMenuCommand("Item 1");
33+
GM_registerMenuCommand("");
34+
let itemId = GM_registerMenuCommand("Some Item", { individual: true }); // 单独显示,不合并
35+
GM_registerMenuCommand("Item 2");
36+
GM_registerMenuCommand("");
37+
GM_registerMenuCommand("Some Item", { individual: true }); // 单独显示,不合并
38+
// GM_unregisterMenuCommand(itemId);
39+
}
40+
41+
if (test === 3) {
42+
// 3 测试nested: false + 分隔线
43+
44+
GM_registerMenuCommand("Item 1");
45+
GM_registerMenuCommand("");
46+
let itemId = GM_registerMenuCommand("Some Item", { nested: false }); // 单独显示,不合并
47+
GM_registerMenuCommand("Item 2");
48+
GM_registerMenuCommand("");
49+
GM_registerMenuCommand("", { nested: false });
50+
GM_registerMenuCommand("Some Item", { nested: false }); // 单独显示,不合并
51+
// GM_unregisterMenuCommand(itemId);
52+
}
53+
54+
if (test === 4) {
55+
// 4 测试nested: false + 分隔线 + 单独菜单项目
56+
57+
GM_registerMenuCommand("Item 1");
58+
GM_registerMenuCommand("");
59+
let itemId = GM_registerMenuCommand("Some Item", { individual: true, nested: false }); // 单独显示,不合并
60+
GM_registerMenuCommand("Item 2");
61+
GM_registerMenuCommand("");
62+
GM_registerMenuCommand("", { nested: false });
63+
GM_registerMenuCommand("Some Item", { individual: true, nested: false }); // 单独显示,不合并
64+
// GM_unregisterMenuCommand(itemId);
65+
}
66+
})();

src/app/service/content/gm_api.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,16 @@ describe("GM_menu", () => {
281281
action: "content/runtime/gmApi",
282282
data: {
283283
api: "GM_registerMenuCommand",
284-
params: [actualMenuKey, "test", {}],
284+
params: [
285+
actualMenuKey,
286+
"test",
287+
{
288+
autoClose: true,
289+
mIndividualKey: 0,
290+
mSeparator: false,
291+
nested: true,
292+
},
293+
],
285294
runFlag: expect.any(String),
286295
uuid: undefined,
287296
},
@@ -344,7 +353,18 @@ describe("GM_menu", () => {
344353
action: "content/runtime/gmApi",
345354
data: {
346355
api: "GM_registerMenuCommand",
347-
params: [actualMenuKey, "test", {}],
356+
params: [
357+
actualMenuKey,
358+
"test",
359+
{
360+
autoClose: true,
361+
id: undefined,
362+
individual: undefined,
363+
mIndividualKey: 0,
364+
mSeparator: false,
365+
nested: true,
366+
},
367+
],
348368
runFlag: expect.any(String),
349369
uuid: undefined,
350370
},

src/app/service/content/gm_api.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
GMUnRegisterMenuCommandParam,
66
NotificationMessageOption,
77
ScriptMenuItemOption,
8+
SWScriptMenuItemOption,
89
TScriptMenuItemID,
910
TScriptMenuItemKey,
1011
} from "../service_worker/types";
@@ -42,6 +43,7 @@ const execEnvInit = (execEnv: GMApi) => {
4243
execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境
4344
execEnv.menuKeyRegistered = new Set();
4445
execEnv.menuIdCounter = 0;
46+
execEnv.regMenuCounter = 0;
4547
}
4648
};
4749

@@ -580,6 +582,10 @@ export default class GMApi extends GM_Base {
580582
// 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。
581583
menuIdCounter: number | undefined;
582584

585+
// 菜单注冊累计器 - 用於穩定同一Tab不同frame之選項的單獨項目不合併狀態
586+
// 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。
587+
regMenuCounter: number | undefined;
588+
583589
// 内容脚本执行环境识别符,用于区分 mainframe / subframe 等环境并作为 menu key 的命名空间。
584590
// 由 execEnvInit() 以 randomMessageFlag() 生成,避免跨 frame 的 ID 碰撞。
585591
// (同一环境跨脚本也不一样)
@@ -588,24 +594,50 @@ export default class GMApi extends GM_Base {
588594
@GMContext.API({ alias: "GM.registerMenuCommand" })
589595
GM_registerMenuCommand(
590596
name: string,
591-
listener: (inputValue?: any) => void,
597+
listener?: (inputValue?: any) => void,
592598
options_or_accessKey?: ScriptMenuItemOption | string
593599
): TScriptMenuItemID {
594600
if (!this.EE) return -1;
595601
execEnvInit(this);
602+
this.regMenuCounter! += 1;
603+
// 兼容 GM_registerMenuCommand(name, options_or_accessKey)
604+
if (!options_or_accessKey && typeof listener === "object") {
605+
options_or_accessKey = listener;
606+
listener = undefined;
607+
}
596608
// 浅拷贝避免修改/共用参数
597-
const options = (
609+
const options: SWScriptMenuItemOption = (
598610
typeof options_or_accessKey === "string"
599611
? { accessKey: options_or_accessKey }
600612
: options_or_accessKey
601-
? { ...options_or_accessKey }
613+
? { ...options_or_accessKey, id: undefined, individual: undefined } // id不直接储存在options (id 影响 groupKey 操作)
602614
: {}
603615
) as ScriptMenuItemOption;
604-
let providedId: string | number | undefined = options.id;
605-
delete options.id; // id不直接储存在options (id 影响 groupKey 操作)
616+
const isSeparator = !listener && !name;
617+
let isIndividual = typeof options_or_accessKey === "object" ? options_or_accessKey.individual : undefined;
618+
if (isIndividual === undefined && isSeparator) {
619+
isIndividual = true;
620+
}
621+
options.mIndividualKey = isIndividual ? this.regMenuCounter : 0;
622+
if (options.autoClose === undefined) {
623+
options.autoClose = true;
624+
}
625+
if (options.nested === undefined) {
626+
options.nested = true;
627+
}
628+
if (isSeparator) {
629+
// GM_registerMenuCommand("") 时自动设为分隔线
630+
options.mSeparator = true;
631+
name = "";
632+
listener = undefined;
633+
} else {
634+
options.mSeparator = false;
635+
}
636+
let providedId: string | number | undefined =
637+
typeof options_or_accessKey === "object" ? options_or_accessKey.id : undefined;
606638
if (providedId === undefined) providedId = this.menuIdCounter! += 1; // 如无指定,使用累计器id
607-
const ret = providedId as TScriptMenuItemID;
608-
providedId = `t${providedId}`; // 见 TScriptMenuItemID 注释
639+
const ret = providedId! as TScriptMenuItemID;
640+
providedId = `t${providedId!}`; // 见 TScriptMenuItemID 注释
609641
providedId = `${this.contentEnvKey!}.${providedId}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释
610642
const menuKey = providedId; // menuKey为唯一键:{环境识别符}.t{注册ID}
611643
// 检查之前有否注册
@@ -616,7 +648,10 @@ export default class GMApi extends GM_Base {
616648
// 没注册过,先记录一下
617649
this.menuKeyRegistered!.add(menuKey);
618650
}
619-
this.EE.addListener("menuClick:" + menuKey, listener);
651+
if (listener) {
652+
// GM_registerMenuCommand("hi", undefined, {accessKey:"h"}) 时TM不会报错
653+
this.EE.addListener("menuClick:" + menuKey, listener);
654+
}
620655
// 发送至 service worker 处理(唯一键,显示名字,不包括id的其他设定)
621656
this.sendMessage("GM_registerMenuCommand", [menuKey, name, options] as GMRegisterMenuCommandParam);
622657
return ret;

src/app/service/queue.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Script, SCRIPT_RUN_STATUS, SCRIPT_STATUS, SCRIPT_TYPE } from "../repo/scripts";
22
import type {
33
InstallSource,
4-
ScriptMenuItemOption,
4+
SWScriptMenuItemOption,
55
TScriptMenuItemKey,
66
TScriptMenuItemName,
77
} from "./service_worker/types";
@@ -36,7 +36,7 @@ export type TScriptMenuRegister = {
3636
uuid: string;
3737
key: TScriptMenuItemKey;
3838
name: TScriptMenuItemName;
39-
options?: Omit<ScriptMenuItemOption, "id">;
39+
options: SWScriptMenuItemOption;
4040
tabId: number;
4141
frameId?: number;
4242
documentId?: string;

src/app/service/service_worker/popup.ts

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -80,23 +80,35 @@ export class PopupService {
8080
for (const { uuid, name, menus } of menu) {
8181
const subMenuEntries = [] as chrome.contextMenus.CreateProperties[];
8282
let withMenuItem = false;
83-
const groupKeys = new Map<string, string>();
83+
const groupKeys = new Map<string, { name: string; mSeparator?: boolean; nested?: boolean }>();
8484
for (const { name, options, groupKey } of menus) {
8585
if (options?.inputType) continue; // 如果是带输入框的菜单则不在页面内注册
8686
if (groupKeys.has(groupKey)) continue;
87-
groupKeys.set(groupKey, name);
87+
groupKeys.set(groupKey, { name, mSeparator: options?.mSeparator, nested: options?.nested });
8888
}
89-
for (const [groupKey, name] of groupKeys) {
90-
if (!name) continue; // 日后再调整 name 为空的情况
89+
for (const [groupKey, { name, mSeparator, nested }] of groupKeys) {
9190
// 创建菜单
9291
const menuUid = `scriptMenu_menu_${uuid}_${groupKey}`;
93-
const createProperties = {
94-
id: menuUid,
95-
title: name,
96-
contexts: ["all"],
97-
parentId: `scriptMenu_${uuid}`, // 上层是 `scriptMenu_${uuid}`
98-
} as chrome.contextMenus.CreateProperties;
99-
withMenuItem = true; // 日后或引入菜单分隔线的设计。 withMenuItem = true 表示实际菜单选项有。
92+
let createProperties;
93+
if (mSeparator) {
94+
createProperties = {
95+
id: menuUid,
96+
type: "separator",
97+
contexts: ["all"],
98+
} as chrome.contextMenus.CreateProperties;
99+
} else {
100+
createProperties = {
101+
id: menuUid,
102+
title: name,
103+
contexts: ["all"],
104+
} as chrome.contextMenus.CreateProperties;
105+
withMenuItem = true; // 表示实际菜单选项有。
106+
}
107+
if (nested) {
108+
createProperties.parentId = `scriptMenu_${uuid}`; // 上层是 `scriptMenu_${uuid}`
109+
} else {
110+
createProperties.parentId = `scriptMenu`;
111+
}
100112
subMenuEntries.push(createProperties);
101113
}
102114
if (withMenuItem) {
@@ -186,7 +198,7 @@ export class PopupService {
186198
for (const listEntry of list) {
187199
const message = listEntry as TScriptMenuRegister;
188200
// message.key是唯一的。 即使在同一tab里的mainframe subframe也是不一样
189-
const { uuid, key, name } = message;
201+
const { uuid, key, name, options } = message;
190202
const script = scripts.get(uuid);
191203
if (!script) continue;
192204
const menus = script.menus;
@@ -198,12 +210,18 @@ export class PopupService {
198210
// 例如 subframe 和 mainframe 创建了相同的 menu item,显示时只会出现一个。
199211
// 但点击后,两边都会执行。
200212
// 目的是整理显示,实际上内部还是存有多笔 entry(分别记录不同的 frameId 和 id)。
201-
const groupKey = uuidv5(
202-
message.options?.inputType
203-
? JSON.stringify({ ...message.options, autoClose: undefined, id: undefined, name: name })
204-
: `${name}\n${message.options?.accessKey || ""}`,
205-
groupKeyNS
206-
);
213+
const nameForKey = options.mSeparator ? "" : `${name}_${options.accessKey || ""}`;
214+
const popupGroup = options.inputType
215+
? JSON.stringify({
216+
...message.options,
217+
autoClose: undefined,
218+
id: undefined,
219+
name: nameForKey,
220+
nested: undefined,
221+
mSeparator: undefined,
222+
})
223+
: `${nameForKey}_${options.mIndividualKey}`; // 一般菜單項目不需要 JSON.stringify
224+
const groupKey = `${uuidv5(popupGroup, groupKeyNS)},${options.nested ? 3 : 2}`;
207225
const menu = menus.find((item) => item.key === key);
208226
if (!menu) {
209227
// 不存在新增

src/app/service/service_worker/types.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,29 @@ export type NotificationMessageOption = {
7676
export type Api = (request: Request, con: IGetSender) => Promise<any>;
7777

7878
/** 脚本菜单选项 */
79+
// GM_registerMenuCommand optionsOrAccessKey
7980
export type ScriptMenuItemOption = {
80-
id?: number; // 用于菜单修改及删除 (GM API)
81+
id?: number | string; // 用于菜单修改及删除 (GM API)
8182
accessKey?: string; // GM/TM 共通参数
8283
autoClose?: boolean; // SC独自设定。用于一般菜单项目。预设 true。false 时点击后不关闭菜单
84+
nested?: boolean; // SC独自设定。用于一般菜单项目。预设 true。false 的话右键菜单项目由三级菜单升至二级菜单
85+
individual?: boolean; // SC独自设定。预设 false。true 的话表示不进行显示重叠(单独项)
86+
/** 可选输入框类型 */
87+
inputType?: "text" | "number" | "boolean";
88+
title?: string; // title 只适用于输入框类型
89+
inputLabel?: string;
90+
inputDefaultValue?: string | number | boolean;
91+
inputPlaceholder?: string;
92+
};
93+
94+
/** 脚本菜单选项 */
95+
// Service_Worker 接收到的
96+
export type SWScriptMenuItemOption = {
97+
accessKey?: string; // GM/TM 共通参数
98+
autoClose?: boolean; // SC独自设定。用于一般菜单项目。预设 true。false 时点击后不关闭菜单
99+
nested?: boolean; // SC独自设定。用于一般菜单项目。预设 true。false 的话由三级菜单升至二级菜单
100+
mIndividualKey?: number; // 内部用。用於单独项提供稳定 GroupKey
101+
mSeparator?: boolean; // 内部用。true 为分隔线
83102
/** 可选输入框类型 */
84103
inputType?: "text" | "number" | "boolean";
85104
title?: string; // title 只适用于输入框类型
@@ -139,7 +158,7 @@ export type ScriptMenuItem = {
139158
groupKey: string;
140159
key: TScriptMenuItemKey;
141160
name: TScriptMenuItemName;
142-
options?: ScriptMenuItemOption;
161+
options?: SWScriptMenuItemOption;
143162
tabId: number; //-1表示后台脚本
144163
frameId?: number;
145164
documentId?: string;
@@ -164,7 +183,7 @@ export type GroupScriptMenuItem = {
164183
* 使用范例:
165184
* GM_registerMenuCommand信息传递("myKey", "开启设定", { autoClose: true });
166185
*/
167-
export type GMRegisterMenuCommandParam = [TScriptMenuItemKey, TScriptMenuItemName, Omit<ScriptMenuItemOption, "id">];
186+
export type GMRegisterMenuCommandParam = [TScriptMenuItemKey, TScriptMenuItemName, SWScriptMenuItemOption];
168187

169188
/**
170189
* GM_unregisterMenuCommand 信息传递的呼叫参数型别:

src/pages/components/ScriptMenuList/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,8 @@ const ScriptMenuList = React.memo(
350350

351351
const resultMap = new Map<string, ScriptMenuItem[]>();
352352
for (const menu of menus) {
353-
const groupKey = menu.groupKey;
353+
if (menu.options?.mSeparator) continue; // popup 不显示分隔线
354+
const groupKey = menu.groupKey.split(",")[0]; // popup 显示不区分二级菜单或三级菜单
354355
let m = resultMap.get(groupKey);
355356
if (!m) resultMap.set(groupKey, (m = []));
356357
m.push(menu);

0 commit comments

Comments
 (0)