@vespura/vmenu-plugin 1.0.14 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@ The TypeScript and JavaScript plugin API for vMenu Enhanced, the version of vMen
4
4
 
5
5
  A plugin is a normal FiveM resource of your own. With this package, your resource can add menus to vMenu, under a Plugins entry on its main menu. You describe the menu in code and the package talks to vMenu over events for you. vMenu never needs to know about your resource ahead of time.
6
6
 
7
- Everything the C# plugin API can do is here too. That covers buttons, checkboxes, lists, sliders, dynamic lists, submenus, confirm rows, separators, keys, player actions, translations, permission and setting gates, notifications, text prompts and themes.
7
+ Everything the C# plugin API can do is here too. That covers buttons, checkboxes, lists, sliders, dynamic lists, submenus, confirm rows, separators, keys, player actions, translations, permission and setting gates, notifications, text prompts, themes and standalone menus.
8
8
 
9
9
  ```ts
10
10
  import { Gate, NotifyStyle, Text, VMenuPlugin } from '@vespura/vmenu-plugin/client';
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var version = true ? "1.0.14" : "0.0.0-local";
2
+ var version = true ? "1.0.15" : "0.0.0-local";
3
3
 
4
4
  // src/text.ts
5
5
  var Text = class _Text {
@@ -133,7 +133,9 @@ function int(value) {
133
133
  }
134
134
 
135
135
  // src/protocol.ts
136
- var PROTOCOL_VERSION = 2;
136
+ var PROTOCOL_VERSION = 3;
137
+ var BASE_PROTOCOL_VERSION = 2;
138
+ var MAX_STANDALONE_MENUS = 50;
137
139
  var VMENU_RESOURCE = "vMenu.Enhanced";
138
140
  var PluginEvents = {
139
141
  Probe: "vMenu.Enhanced:Plugins:Probe",
@@ -153,6 +155,7 @@ var PluginEvents = {
153
155
  registerResultFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:RegisterResult`,
154
156
  eventFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:Event`,
155
157
  promptResultFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:PromptResult`,
158
+ openResultFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:OpenResult`,
156
159
  themesFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:Themes`,
157
160
  themesRegisteredFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:ThemesRegistered`,
158
161
  serverReadyFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:Server:Ready`,
@@ -173,6 +176,8 @@ var NodeEvents = {
173
176
  Opened: "opened",
174
177
  Closed: "closed",
175
178
  IndexChanged: "indexChanged",
179
+ Replaced: "replaced",
180
+ ForceClosed: "forceClosed",
176
181
  Highlighted: "highlighted"
177
182
  };
178
183
  var SettingTypes = {
@@ -195,6 +200,8 @@ var CallbackTypes = {
195
200
  MenuOpened: "menuOpened",
196
201
  MenuClosed: "menuClosed",
197
202
  MenuIndexChanged: "menuIndexChanged",
203
+ MenuReplaced: "menuReplaced",
204
+ MenuForceClosed: "menuForceClosed",
198
205
  PlayerActionSelected: "playerActionSelected",
199
206
  PlayerActionConfirmed: "playerActionConfirmed",
200
207
  PlayerActionListSelected: "playerActionListSelected",
@@ -231,6 +238,7 @@ var UpdateOps = {
231
238
  SetMenuEvents: "setMenuEvents",
232
239
  SetFilter: "setFilter",
233
240
  ClearFilter: "clearFilter",
241
+ AddStandaloneMenu: "addStandaloneMenu",
234
242
  AddKeys: "addKeys",
235
243
  SetKeyText: "setKeyText",
236
244
  SetKeyEnabled: "setKeyEnabled",
@@ -913,6 +921,37 @@ var PluginKey = class {
913
921
  };
914
922
 
915
923
  // src/client/menu.ts
924
+ var MenuPriority = {
925
+ /** Only opens when no vMenu or plugin menu is open. */
926
+ None: "none",
927
+ /** Replaces vMenu's menus and other menus at low priority. */
928
+ Low: "low",
929
+ /** Replaces anything at low or medium priority. */
930
+ Medium: "medium",
931
+ /**
932
+ * Replaces anything that is not locked. Only use this in extreme situations, prevents vMenu or other plugins with a
933
+ * lower priority from opening any menus while yours is open. Use responsibly!
934
+ */
935
+ High: "high"
936
+ };
937
+ var OpenMenuResult = {
938
+ /** The menu is on screen. */
939
+ Opened: "opened",
940
+ /** A menu with a higher priority is open, or any menu is open for a request at `None`. */
941
+ BlockedByPriority: "blockedByPriority",
942
+ /** Another menu asked not to be closed. */
943
+ BlockedByLock: "blockedByLock",
944
+ /** vMenu does not know this menu, for example an empty root menu, which has no row yet. */
945
+ UnknownMenu: "unknownMenu",
946
+ /** The player is not allowed into this menu, for example because a gate on the row leading to it fails. */
947
+ NotAllowed: "notAllowed",
948
+ /** A later open or close in the same batch took its place. */
949
+ Superseded: "superseded",
950
+ /** The plugin is not connected to vMenu, or vMenu stopped before it answered. */
951
+ NotConnected: "notConnected",
952
+ /** The vMenu this plugin talks to predates open priorities. Nothing was opened, use `open()` there instead. */
953
+ NotSupported: "notSupported"
954
+ };
916
955
  var PluginMenu = class _PluginMenu {
917
956
  /** @internal */
918
957
  constructor(plugin, node, title, subtitle) {
@@ -925,6 +964,8 @@ var PluginMenu = class _PluginMenu {
925
964
  this.opened = new Signal(() => plugin.resource, "opened");
926
965
  this.closed = new Signal(() => plugin.resource, "closed");
927
966
  this.indexChanged = new Signal(() => plugin.resource, "indexChanged");
967
+ this.replaced = new Signal(() => plugin.resource, "replaced");
968
+ this.forceClosed = new Signal(() => plugin.resource, "forceClosed");
928
969
  }
929
970
  /** The menu's id, how vMenu refers to it. */
930
971
  get id() {
@@ -1152,10 +1193,53 @@ var PluginMenu = class _PluginMenu {
1152
1193
  this.node.items.length = 0;
1153
1194
  this.plugin.emitOp({ op: UpdateOps.ClearMenu, menuId: this.id });
1154
1195
  }
1155
- /** Opens this menu on screen, closing whatever vMenu menu was open. */
1196
+ /** Opens this menu on screen at low priority, closing whatever vMenu menu was open. Use `requestOpen` to learn whether it worked. */
1156
1197
  open() {
1157
1198
  this.plugin.emitOp({ op: UpdateOps.OpenMenu, menuId: this.id });
1158
1199
  }
1200
+ /**
1201
+ * Opens this menu only when no vMenu or plugin menu is open, so it never interrupts the player. Switching between your
1202
+ * own menus always works.
1203
+ */
1204
+ requestOpen() {
1205
+ return this.requestOpenWithPriority(MenuPriority.None);
1206
+ }
1207
+ /**
1208
+ * Opens this menu, closing an open menu of equal or lower priority. A menu with a higher priority, or one that asked
1209
+ * not to be closed, keeps the screen and the request is refused.
1210
+ *
1211
+ * @param priority `High`: only use this in extreme situations, prevents vMenu or other plugins with a lower priority
1212
+ * from opening any menus while yours is open. Use responsibly!
1213
+ * @param doNotClose Only with `High`. Only use this in extreme situations. It blocks the player from using anything
1214
+ * else, so always close the menu as soon as possible. The lock ends when you close the menu or open its parent, and
1215
+ * the player can still force it shut by holding back.
1216
+ * @throws When `doNotClose` is set below `High`.
1217
+ */
1218
+ requestOpenWithPriority(priority, doNotClose = false) {
1219
+ if (!Object.values(MenuPriority).includes(priority)) {
1220
+ throw new Error(`'${String(priority)}' is not a menu priority.`);
1221
+ }
1222
+ if (doNotClose && priority !== MenuPriority.High) {
1223
+ throw new Error("doNotClose only works with MenuPriority.High.");
1224
+ }
1225
+ return this.plugin.requestOpen(this, priority, doNotClose);
1226
+ }
1227
+ /** Called when another plugin's menu took the screen while the player was in this one. `onClosed` fires as well. */
1228
+ onReplaced(handler) {
1229
+ const unsubscribe = this.replaced.add(handler);
1230
+ this.subscribeMenuEvent(NodeEvents.Replaced);
1231
+ return unsubscribe;
1232
+ }
1233
+ /**
1234
+ * Called when this menu was opened with `doNotClose` and closed by anything other than your own code: the player
1235
+ * holding back to force it shut, or vMenu having to close it. Whatever the player was in the middle of was left
1236
+ * unfinished.
1237
+ */
1238
+ onForceClosed(handler) {
1239
+ const unsubscribe = this.forceClosed.add(handler);
1240
+ this.subscribeMenuEvent(NodeEvents.ForceClosed);
1241
+ return unsubscribe;
1242
+ }
1159
1243
  /** Closes this plugin's menu if one is open. */
1160
1244
  close() {
1161
1245
  this.plugin.emitOp({ op: UpdateOps.CloseMenu, menuId: this.id });
@@ -1199,6 +1283,12 @@ var PluginMenu = class _PluginMenu {
1199
1283
  this.indexChanged.fire(callback.oldIndex ?? 0, callback.newIndex);
1200
1284
  }
1201
1285
  break;
1286
+ case CallbackTypes.MenuReplaced:
1287
+ this.replaced.fire();
1288
+ break;
1289
+ case CallbackTypes.MenuForceClosed:
1290
+ this.forceClosed.fire();
1291
+ break;
1202
1292
  }
1203
1293
  }
1204
1294
  /** @internal */
@@ -1206,8 +1296,12 @@ var PluginMenu = class _PluginMenu {
1206
1296
  if (!node.menu) {
1207
1297
  return node;
1208
1298
  }
1209
- const { items: _items, ...menu } = node.menu;
1210
- return { ...node, menu: { ...menu, items: [] } };
1299
+ return { ...node, menu: _PluginMenu.withoutItems(node.menu) };
1300
+ }
1301
+ /** @internal */
1302
+ static withoutItems(node) {
1303
+ const { items: _items, ...menu } = node;
1304
+ return { ...menu, items: [] };
1211
1305
  }
1212
1306
  newNode(type, text, options) {
1213
1307
  return { id: options?.id ?? this.plugin.nextItemId(), type, text: Text.toRef(text) };
@@ -1537,6 +1631,8 @@ var VMenuPlugin = class _VMenuPlugin {
1537
1631
  this.menusById = /* @__PURE__ */ new Map();
1538
1632
  this.keysById = /* @__PURE__ */ new Map();
1539
1633
  this.pendingPrompts = /* @__PURE__ */ new Map();
1634
+ this.pendingOpens = /* @__PURE__ */ new Map();
1635
+ this.standalone = [];
1540
1636
  this.pendingItems = /* @__PURE__ */ new Set();
1541
1637
  this.pendingMenus = /* @__PURE__ */ new Set();
1542
1638
  this.dirtyFilters = [];
@@ -1544,6 +1640,8 @@ var VMenuPlugin = class _VMenuPlugin {
1544
1640
  this.nextItem = 0;
1545
1641
  this.nextMenu = 0;
1546
1642
  this.nextPrompt = 0;
1643
+ this.nextOpen = 0;
1644
+ this.vMenuProtocol = 0;
1547
1645
  this.handlersRegistered = false;
1548
1646
  this.connected = false;
1549
1647
  this.resource = currentResource();
@@ -1557,6 +1655,10 @@ var VMenuPlugin = class _VMenuPlugin {
1557
1655
  this.disconnected = new Signal(() => this.resource, "disconnected");
1558
1656
  this.registerMenu(this.rootMenu);
1559
1657
  }
1658
+ /** Menus added with `addStandaloneMenu`. */
1659
+ get standaloneMenus() {
1660
+ return this.standalone;
1661
+ }
1560
1662
  /** Creates the plugin. One per resource: a second call returns the first instance. */
1561
1663
  static create(displayName) {
1562
1664
  if (instance) {
@@ -1591,6 +1693,38 @@ var VMenuPlugin = class _VMenuPlugin {
1591
1693
  emitLocal(PluginEvents.Probe);
1592
1694
  return this.firstResult;
1593
1695
  }
1696
+ /**
1697
+ * Adds a top level menu of your own, outside vMenu's Plugins menu. It has no row anywhere and no toggle key: only your
1698
+ * code opens it, through `menu.requestOpen()`. Needs a vMenu that knows standalone menus, an older one refuses the
1699
+ * registration. Adding one after connecting appears live.
1700
+ *
1701
+ * @param id Letters, digits and underscores, unique within your plugin. "root" and "m" followed by digits are taken
1702
+ * by the menus the API names itself.
1703
+ * @throws When the id is malformed, reserved or already used, when the plugin already has 50 standalone menus, or when
1704
+ * it is connected to a vMenu that predates standalone menus.
1705
+ */
1706
+ addStandaloneMenu(id, title, subtitle) {
1707
+ if (!/^[A-Za-z0-9_]+$/.test(id)) {
1708
+ throw new Error("A standalone menu id may only contain letters, digits and underscores.");
1709
+ }
1710
+ if (id === "root" || /^m\d+$/.test(id)) {
1711
+ throw new Error(`'${id}' is reserved for the menus the API names itself.`);
1712
+ }
1713
+ if (this.menusById.has(id)) {
1714
+ throw new Error(`A menu with id '${id}' already exists.`);
1715
+ }
1716
+ if (this.standalone.length >= MAX_STANDALONE_MENUS) {
1717
+ throw new Error(`A plugin may have at most ${MAX_STANDALONE_MENUS} standalone menus.`);
1718
+ }
1719
+ if (this.connected && this.vMenuProtocol < PROTOCOL_VERSION) {
1720
+ throw new Error("The vMenu this plugin is connected to predates standalone menus. Update vMenu.");
1721
+ }
1722
+ const menu = new PluginMenu(this, { id, title: Text.toRef(title), subtitle: Text.toRef(subtitle), items: [] }, title, subtitle);
1723
+ this.registerMenu(menu);
1724
+ this.standalone.push(menu);
1725
+ this.emitOp({ op: UpdateOps.AddStandaloneMenu, menu: menu.node });
1726
+ return menu;
1727
+ }
1594
1728
  /** Shows a message through vMenu's notification area, credited to your resource. */
1595
1729
  notify(style, text, durationMs) {
1596
1730
  if (!this.connected) {
@@ -1687,6 +1821,9 @@ var VMenuPlugin = class _VMenuPlugin {
1687
1821
  if (this.carriedByPendingAdd(op)) {
1688
1822
  return;
1689
1823
  }
1824
+ if (op.op === UpdateOps.AddStandaloneMenu && op.menu) {
1825
+ this.pendingMenus.add(op.menu.id);
1826
+ }
1690
1827
  if (op.op === UpdateOps.AddItems) {
1691
1828
  for (const node of op.items ?? []) {
1692
1829
  this.pendingItems.add(node.id);
@@ -1698,6 +1835,19 @@ var VMenuPlugin = class _VMenuPlugin {
1698
1835
  batch.push(op);
1699
1836
  }
1700
1837
  /** @internal */
1838
+ requestOpen(menu, priority, doNotClose) {
1839
+ if (!this.connected) {
1840
+ return Promise.resolve(OpenMenuResult.NotConnected);
1841
+ }
1842
+ if (this.vMenuProtocol < PROTOCOL_VERSION) {
1843
+ return Promise.resolve(OpenMenuResult.NotSupported);
1844
+ }
1845
+ const requestId = ++this.nextOpen;
1846
+ const pending = new Promise((resolve) => this.pendingOpens.set(requestId, resolve));
1847
+ this.emitOp({ op: UpdateOps.OpenMenu, menuId: menu.id, priority, doNotClose: doNotClose || void 0, requestId });
1848
+ return pending;
1849
+ }
1850
+ /** @internal */
1701
1851
  filterChanged(menu) {
1702
1852
  if (!this.connected) {
1703
1853
  return;
@@ -1767,9 +1917,15 @@ var VMenuPlugin = class _VMenuPlugin {
1767
1917
  return false;
1768
1918
  }
1769
1919
  send(ops) {
1770
- const payload = ops.map(
1771
- (op) => op.op === UpdateOps.AddItems && op.items ? { ...op, items: op.items.map(PluginMenu.asAdded) } : op
1772
- );
1920
+ const payload = ops.map((op) => {
1921
+ if (op.op === UpdateOps.AddItems && op.items) {
1922
+ return { ...op, items: op.items.map(PluginMenu.asAdded) };
1923
+ }
1924
+ if (op.op === UpdateOps.AddStandaloneMenu && op.menu) {
1925
+ return { ...op, menu: PluginMenu.withoutItems(op.menu) };
1926
+ }
1927
+ return op;
1928
+ });
1773
1929
  emitLocal(PluginEvents.Update, JSON.stringify({ ops: payload }));
1774
1930
  }
1775
1931
  ensureHandlers() {
@@ -1777,23 +1933,30 @@ var VMenuPlugin = class _VMenuPlugin {
1777
1933
  return;
1778
1934
  }
1779
1935
  this.handlersRegistered = true;
1780
- onLocal(PluginEvents.Ready, () => this.sendRegistration());
1781
- onLocal(PluginEvents.readyFor(this.resource), () => this.sendRegistration());
1936
+ onLocal(PluginEvents.Ready, (protocolVersion) => this.onReady(protocolVersion));
1937
+ onLocal(PluginEvents.readyFor(this.resource), (protocolVersion) => this.onReady(protocolVersion));
1782
1938
  onLocal(PluginEvents.registerResultFor(this.resource), (json) => this.onRegisterResult(json));
1783
1939
  onLocal(PluginEvents.eventFor(this.resource), (json) => this.onCallback(json));
1784
1940
  onLocal(PluginEvents.promptResultFor(this.resource), (json) => this.onPromptResult(json));
1941
+ onLocal(PluginEvents.openResultFor(this.resource), (json) => this.onOpenResult(json));
1785
1942
  onLocal(PluginEvents.themesFor(this.resource), (json) => this.themes.handle(json));
1786
1943
  onLocal("onResourceStop", (stopped) => this.onResourceStop(stopped));
1787
1944
  }
1945
+ onReady(protocolVersion) {
1946
+ this.vMenuProtocol = typeof protocolVersion === "number" ? protocolVersion : 0;
1947
+ this.sendRegistration();
1948
+ }
1788
1949
  sendRegistration() {
1789
1950
  emitLocal(PluginEvents.Register, JSON.stringify(this.buildRequest()));
1790
1951
  }
1791
1952
  buildRequest() {
1792
1953
  const request = {
1793
- protocolVersion: PROTOCOL_VERSION,
1954
+ // Only a plugin that needs a newer vMenu says so, so everything else still registers with an older one.
1955
+ protocolVersion: this.standalone.length > 0 ? PROTOCOL_VERSION : BASE_PROTOCOL_VERSION,
1794
1956
  displayName: Text.toRef(this.displayName),
1795
1957
  descriptionKey: this.descriptionKey,
1796
1958
  menu: this.rootMenu.node,
1959
+ standaloneMenus: this.standalone.length > 0 ? this.standalone.map((menu) => menu.node) : void 0,
1797
1960
  playerActions: this.playerActions.nodes.length > 0 ? this.playerActions.nodes : void 0
1798
1961
  };
1799
1962
  if (Object.keys(this.translations.tables).length > 0) {
@@ -1819,6 +1982,7 @@ var VMenuPlugin = class _VMenuPlugin {
1819
1982
  this.connected = result.accepted;
1820
1983
  if (!this.connected) {
1821
1984
  this.cancelPendingPrompts();
1985
+ this.cancelPendingOpens();
1822
1986
  } else {
1823
1987
  this.batch(() => {
1824
1988
  for (const menu of this.menusById.values()) {
@@ -1841,7 +2005,9 @@ var VMenuPlugin = class _VMenuPlugin {
1841
2005
  switch (callback.type) {
1842
2006
  case CallbackTypes.MenuOpened:
1843
2007
  case CallbackTypes.MenuClosed:
1844
- case CallbackTypes.MenuIndexChanged: {
2008
+ case CallbackTypes.MenuIndexChanged:
2009
+ case CallbackTypes.MenuReplaced:
2010
+ case CallbackTypes.MenuForceClosed: {
1845
2011
  const menu = callback.menuId === void 0 ? void 0 : this.menusById.get(callback.menuId);
1846
2012
  menu?.handleMenu(callback);
1847
2013
  break;
@@ -1875,6 +2041,25 @@ var VMenuPlugin = class _VMenuPlugin {
1875
2041
  pending(result);
1876
2042
  }
1877
2043
  }
2044
+ onOpenResult(json) {
2045
+ const result = parseJson(json);
2046
+ if (!result) {
2047
+ return;
2048
+ }
2049
+ const pending = this.pendingOpens.get(result.requestId);
2050
+ if (!pending) {
2051
+ return;
2052
+ }
2053
+ this.pendingOpens.delete(result.requestId);
2054
+ const known = Object.values(OpenMenuResult);
2055
+ pending(known.includes(result.reason) ? result.reason : OpenMenuResult.BlockedByPriority);
2056
+ }
2057
+ cancelPendingOpens() {
2058
+ for (const pending of this.pendingOpens.values()) {
2059
+ pending(OpenMenuResult.NotConnected);
2060
+ }
2061
+ this.pendingOpens.clear();
2062
+ }
1878
2063
  cancelPendingPrompts() {
1879
2064
  for (const pending of this.pendingPrompts.values()) {
1880
2065
  pending({ requestId: 0, cancelled: true, busy: false });
@@ -1887,12 +2072,15 @@ var VMenuPlugin = class _VMenuPlugin {
1887
2072
  }
1888
2073
  this.connected = false;
1889
2074
  this.cancelPendingPrompts();
2075
+ this.cancelPendingOpens();
1890
2076
  this.disconnected.fire();
1891
2077
  }
1892
2078
  };
1893
2079
  export {
1894
2080
  Gate,
2081
+ MenuPriority,
1895
2082
  NotifyStyle,
2083
+ OpenMenuResult,
1896
2084
  PluginBoolSetting,
1897
2085
  PluginButton,
1898
2086
  PluginCheckbox,
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var version = true ? "1.0.14" : "0.0.0-local";
2
+ var version = true ? "1.0.15" : "0.0.0-local";
3
3
 
4
4
  // src/format.ts
5
5
  function formatFloat(value) {
@@ -27,6 +27,7 @@ var PluginEvents = {
27
27
  registerResultFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:RegisterResult`,
28
28
  eventFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:Event`,
29
29
  promptResultFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:PromptResult`,
30
+ openResultFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:OpenResult`,
30
31
  themesFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:Themes`,
31
32
  themesRegisteredFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:ThemesRegistered`,
32
33
  serverReadyFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:Server:Ready`,
@@ -1,7 +1,7 @@
1
1
  import type { UpdateOp } from '../protocol.js';
2
2
  import type { PluginItem } from './item.js';
3
3
  import type { PluginKey } from './key.js';
4
- import type { PluginMenu } from './menu.js';
4
+ import type { MenuPriority, OpenMenuResult, PluginMenu } from './menu.js';
5
5
  export interface PluginHost {
6
6
  readonly resource: string;
7
7
  readonly isConnected: boolean;
@@ -12,6 +12,7 @@ export interface PluginHost {
12
12
  registerKey(key: PluginKey): void;
13
13
  unregisterItem(item: PluginItem): void;
14
14
  emitOp(op: UpdateOp): void;
15
+ requestOpen(menu: PluginMenu, priority: MenuPriority, doNotClose: boolean): Promise<OpenMenuResult>;
15
16
  emitAdd(menu: PluginMenu, item: PluginItem, add: UpdateOp): void;
16
17
  filterChanged(menu: PluginMenu): void;
17
18
  mergeTranslations(code: string, entries: Record<string, string>): void;
@@ -7,7 +7,7 @@ export { PluginSetting, PluginBoolSetting, PluginIntSetting, PluginFloatSetting,
7
7
  export { PluginItem, type ItemOptions } from './item.js';
8
8
  export { PluginButton, PluginConfirmButton, PluginCheckbox, PluginList, PluginConfirmList, PluginSlider, PluginDynamicList, PluginSeparator, PluginSubmenu, } from './items.js';
9
9
  export { PluginKey, type PluginKeyPress } from './key.js';
10
- export { PluginMenu, type CheckboxOptions, type ConfirmOptions, type KeyOptions, type ListOptions, type SliderOptions, type SubmenuOptions, } from './menu.js';
10
+ export { MenuPriority, OpenMenuResult, PluginMenu, type CheckboxOptions, type ConfirmOptions, type KeyOptions, type ListOptions, type SliderOptions, type SubmenuOptions, } from './menu.js';
11
11
  export { PluginPlayerActions, PluginPlayerButton, PluginPlayerConfirmButton, PluginPlayerList, type PlayerTarget, } from './playerActions.js';
12
12
  export { PluginThemes, type PluginTheme } from './themes.js';
13
13
  export { PluginTranslations } from './translations.js';
@@ -45,8 +45,45 @@ export interface KeyOptions {
45
45
  /** A game control index to suppress while the menu is open, for a default key the game already uses. */
46
46
  shadowedControl?: number;
47
47
  }
48
+ /** How hard `requestOpenWithPriority` may push. vMenu's own menus always count as `None`. The plain strings work too. */
49
+ export declare const MenuPriority: {
50
+ /** Only opens when no vMenu or plugin menu is open. */
51
+ readonly None: 'none';
52
+ /** Replaces vMenu's menus and other menus at low priority. */
53
+ readonly Low: 'low';
54
+ /** Replaces anything at low or medium priority. */
55
+ readonly Medium: 'medium';
56
+ /**
57
+ * Replaces anything that is not locked. Only use this in extreme situations, prevents vMenu or other plugins with a
58
+ * lower priority from opening any menus while yours is open. Use responsibly!
59
+ */
60
+ readonly High: 'high';
61
+ };
62
+ /** How hard `requestOpenWithPriority` may push. */
63
+ export type MenuPriority = (typeof MenuPriority)[keyof typeof MenuPriority];
64
+ /** What happened to a request to open a menu. The plain strings work too. */
65
+ export declare const OpenMenuResult: {
66
+ /** The menu is on screen. */
67
+ readonly Opened: 'opened';
68
+ /** A menu with a higher priority is open, or any menu is open for a request at `None`. */
69
+ readonly BlockedByPriority: 'blockedByPriority';
70
+ /** Another menu asked not to be closed. */
71
+ readonly BlockedByLock: 'blockedByLock';
72
+ /** vMenu does not know this menu, for example an empty root menu, which has no row yet. */
73
+ readonly UnknownMenu: 'unknownMenu';
74
+ /** The player is not allowed into this menu, for example because a gate on the row leading to it fails. */
75
+ readonly NotAllowed: 'notAllowed';
76
+ /** A later open or close in the same batch took its place. */
77
+ readonly Superseded: 'superseded';
78
+ /** The plugin is not connected to vMenu, or vMenu stopped before it answered. */
79
+ readonly NotConnected: 'notConnected';
80
+ /** The vMenu this plugin talks to predates open priorities. Nothing was opened, use `open()` there instead. */
81
+ readonly NotSupported: 'notSupported';
82
+ };
83
+ /** What happened to a request to open a menu. */
84
+ export type OpenMenuResult = (typeof OpenMenuResult)[keyof typeof OpenMenuResult];
48
85
  /**
49
- * One of your plugin's menus: the root under your row in vMenu's Plugins menu, or a submenu. Rows
86
+ * One of your plugin's menus: the root under your row in vMenu's Plugins menu, a standalone menu, or a submenu. Rows
50
87
  * added before connecting ride along with the registration, rows added later appear live.
51
88
  */
52
89
  export declare class PluginMenu {
@@ -58,6 +95,8 @@ export declare class PluginMenu {
58
95
  private readonly opened;
59
96
  private readonly closed;
60
97
  private readonly indexChanged;
98
+ private readonly replaced;
99
+ private readonly forceClosed;
61
100
  private insertIndex?;
62
101
  private filterFn?;
63
102
  /** The menu's id, how vMenu refers to it. */
@@ -126,8 +165,33 @@ export declare class PluginMenu {
126
165
  remove(item: PluginItem): void;
127
166
  /** Removes every row. */
128
167
  clear(): void;
129
- /** Opens this menu on screen, closing whatever vMenu menu was open. */
168
+ /** Opens this menu on screen at low priority, closing whatever vMenu menu was open. Use `requestOpen` to learn whether it worked. */
130
169
  open(): void;
170
+ /**
171
+ * Opens this menu only when no vMenu or plugin menu is open, so it never interrupts the player. Switching between your
172
+ * own menus always works.
173
+ */
174
+ requestOpen(): Promise<OpenMenuResult>;
175
+ /**
176
+ * Opens this menu, closing an open menu of equal or lower priority. A menu with a higher priority, or one that asked
177
+ * not to be closed, keeps the screen and the request is refused.
178
+ *
179
+ * @param priority `High`: only use this in extreme situations, prevents vMenu or other plugins with a lower priority
180
+ * from opening any menus while yours is open. Use responsibly!
181
+ * @param doNotClose Only with `High`. Only use this in extreme situations. It blocks the player from using anything
182
+ * else, so always close the menu as soon as possible. The lock ends when you close the menu or open its parent, and
183
+ * the player can still force it shut by holding back.
184
+ * @throws When `doNotClose` is set below `High`.
185
+ */
186
+ requestOpenWithPriority(priority: MenuPriority, doNotClose?: boolean): Promise<OpenMenuResult>;
187
+ /** Called when another plugin's menu took the screen while the player was in this one. `onClosed` fires as well. */
188
+ onReplaced(handler: () => void): Unsubscribe;
189
+ /**
190
+ * Called when this menu was opened with `doNotClose` and closed by anything other than your own code: the player
191
+ * holding back to force it shut, or vMenu having to close it. Whatever the player was in the middle of was left
192
+ * unfinished.
193
+ */
194
+ onForceClosed(handler: () => void): Unsubscribe;
131
195
  /** Closes this plugin's menu if one is open. */
132
196
  close(): void;
133
197
  /**
@@ -42,6 +42,8 @@ export declare class VMenuPlugin {
42
42
  private readonly menusById;
43
43
  private readonly keysById;
44
44
  private readonly pendingPrompts;
45
+ private readonly pendingOpens;
46
+ private readonly standalone;
45
47
  private readonly pendingItems;
46
48
  private readonly pendingMenus;
47
49
  private readonly dirtyFilters;
@@ -54,6 +56,8 @@ export declare class VMenuPlugin {
54
56
  private nextItem;
55
57
  private nextMenu;
56
58
  private nextPrompt;
59
+ private nextOpen;
60
+ private vMenuProtocol;
57
61
  private handlersRegistered;
58
62
  private connected;
59
63
  /** The resource this plugin runs in, its identity towards vMenu. */
@@ -70,6 +74,8 @@ export declare class VMenuPlugin {
70
74
  readonly themes: PluginThemes;
71
75
  /** The menu behind your row in vMenu's Plugins menu. */
72
76
  readonly rootMenu: PluginMenu;
77
+ /** Menus added with `addStandaloneMenu`. */
78
+ get standaloneMenus(): readonly PluginMenu[];
73
79
  /** Actions added to every player's entry of vMenu's Online Players menu. */
74
80
  readonly playerActions: PluginPlayerActions;
75
81
  private constructor();
@@ -87,6 +93,17 @@ export declare class VMenuPlugin {
87
93
  * `accepted` false.
88
94
  */
89
95
  connect(): Promise<RegisterResult>;
96
+ /**
97
+ * Adds a top level menu of your own, outside vMenu's Plugins menu. It has no row anywhere and no toggle key: only your
98
+ * code opens it, through `menu.requestOpen()`. Needs a vMenu that knows standalone menus, an older one refuses the
99
+ * registration. Adding one after connecting appears live.
100
+ *
101
+ * @param id Letters, digits and underscores, unique within your plugin. "root" and "m" followed by digits are taken
102
+ * by the menus the API names itself.
103
+ * @throws When the id is malformed, reserved or already used, when the plugin already has 50 standalone menus, or when
104
+ * it is connected to a vMenu that predates standalone menus.
105
+ */
106
+ addStandaloneMenu(id: string, title: TextLike, subtitle?: TextLike): PluginMenu;
90
107
  /** Shows a message through vMenu's notification area, credited to your resource. */
91
108
  notify(style: NotifyStyle, text: TextLike, durationMs?: number): void;
92
109
  /** Asks the player for text through vMenu's input box. Resolves to null if they cancelled or the box was unavailable. */
@@ -108,12 +125,15 @@ export declare class VMenuPlugin {
108
125
  private carriedByPendingAdd;
109
126
  private send;
110
127
  private ensureHandlers;
128
+ private onReady;
111
129
  private sendRegistration;
112
130
  private buildRequest;
113
131
  private onRegisterResult;
114
132
  private onCallback;
115
133
  private itemOrUndefined;
116
134
  private onPromptResult;
135
+ private onOpenResult;
136
+ private cancelPendingOpens;
117
137
  private cancelPendingPrompts;
118
138
  private onResourceStop;
119
139
  }
@@ -1,4 +1,7 @@
1
- export declare const PROTOCOL_VERSION = 2;
1
+ export declare const PROTOCOL_VERSION = 3;
2
+ /** What a plugin without standalone menus announces, so it still registers with a vMenu that predates them. */
3
+ export declare const BASE_PROTOCOL_VERSION = 2;
4
+ export declare const MAX_STANDALONE_MENUS = 50;
2
5
  export declare const SERVER_PROTOCOL_VERSION = 1;
3
6
  export declare const VMENU_RESOURCE = "vMenu.Enhanced";
4
7
  export declare const PluginEvents: {
@@ -19,6 +22,7 @@ export declare const PluginEvents: {
19
22
  readonly registerResultFor: (resource: string) => string;
20
23
  readonly eventFor: (resource: string) => string;
21
24
  readonly promptResultFor: (resource: string) => string;
25
+ readonly openResultFor: (resource: string) => string;
22
26
  readonly themesFor: (resource: string) => string;
23
27
  readonly themesRegisteredFor: (resource: string) => string;
24
28
  readonly serverReadyFor: (resource: string) => string;
@@ -40,6 +44,8 @@ export declare const NodeEvents: {
40
44
  readonly Opened: 'opened';
41
45
  readonly Closed: 'closed';
42
46
  readonly IndexChanged: 'indexChanged';
47
+ readonly Replaced: 'replaced';
48
+ readonly ForceClosed: 'forceClosed';
43
49
  readonly Highlighted: 'highlighted';
44
50
  };
45
51
  export declare const SettingTypes: {
@@ -62,11 +68,27 @@ export declare const CallbackTypes: {
62
68
  readonly MenuOpened: 'menuOpened';
63
69
  readonly MenuClosed: 'menuClosed';
64
70
  readonly MenuIndexChanged: 'menuIndexChanged';
71
+ readonly MenuReplaced: 'menuReplaced';
72
+ readonly MenuForceClosed: 'menuForceClosed';
65
73
  readonly PlayerActionSelected: 'playerActionSelected';
66
74
  readonly PlayerActionConfirmed: 'playerActionConfirmed';
67
75
  readonly PlayerActionListSelected: 'playerActionListSelected';
68
76
  readonly KeyPressed: 'keyPressed';
69
77
  };
78
+ export declare const MenuPriorities: {
79
+ readonly None: 'none';
80
+ readonly Low: 'low';
81
+ readonly Medium: 'medium';
82
+ readonly High: 'high';
83
+ };
84
+ export declare const OpenMenuReasons: {
85
+ readonly Opened: 'opened';
86
+ readonly BlockedByPriority: 'blockedByPriority';
87
+ readonly BlockedByLock: 'blockedByLock';
88
+ readonly UnknownMenu: 'unknownMenu';
89
+ readonly NotAllowed: 'notAllowed';
90
+ readonly Superseded: 'superseded';
91
+ };
70
92
  export declare const UpdateOps: {
71
93
  readonly SetText: 'setText';
72
94
  readonly SetDescription: 'setDescription';
@@ -98,6 +120,7 @@ export declare const UpdateOps: {
98
120
  readonly SetMenuEvents: 'setMenuEvents';
99
121
  readonly SetFilter: 'setFilter';
100
122
  readonly ClearFilter: 'clearFilter';
123
+ readonly AddStandaloneMenu: 'addStandaloneMenu';
101
124
  readonly AddKeys: 'addKeys';
102
125
  readonly SetKeyText: 'setKeyText';
103
126
  readonly SetKeyEnabled: 'setKeyEnabled';
@@ -180,12 +203,16 @@ export interface UpdateOp {
180
203
  options?: TextRef[];
181
204
  gate?: GateNode;
182
205
  items?: ItemNode[];
206
+ menu?: MenuNode;
183
207
  itemIds?: string[];
184
208
  beforeItemId?: string;
185
209
  events?: string[];
186
210
  keys?: KeyNode[];
187
211
  language?: string;
188
212
  entries?: Record<string, string>;
213
+ priority?: string;
214
+ doNotClose?: boolean;
215
+ requestId?: number;
189
216
  }
190
217
  export interface PluginCallback {
191
218
  type: string;
@@ -213,6 +240,7 @@ export interface RegisterRequest {
213
240
  translations?: Record<string, Record<string, string>>;
214
241
  settings?: SettingNode[];
215
242
  menu?: MenuNode;
243
+ standaloneMenus?: MenuNode[];
216
244
  playerActions?: ItemNode[];
217
245
  }
218
246
  /** vMenu's answer to a registration. Errors mean it was refused, warnings mean it was accepted with parts skipped. */
@@ -238,6 +266,11 @@ export interface PromptRequest {
238
266
  }[];
239
267
  }[];
240
268
  }
269
+ export interface OpenMenuResultPayload {
270
+ requestId: number;
271
+ opened: boolean;
272
+ reason: string;
273
+ }
241
274
  export interface PromptResult {
242
275
  requestId: number;
243
276
  cancelled: boolean;
package/dist/vmenu.js CHANGED
@@ -1,4 +1,4 @@
1
- // vMenu Enhanced plugin API for JavaScript, built for vMenu Enhanced v1.0.14.
1
+ // vMenu Enhanced plugin API for JavaScript, built for vMenu Enhanced v1.0.15.
2
2
  // Copy this file into your resource and load it before your own scripts, see https://docs.vespura.com/vmenu/enhanced/plugins/javascript/
3
3
  "use strict";
4
4
  var vMenu = (() => {
@@ -24,7 +24,9 @@ var vMenu = (() => {
24
24
  var iife_exports = {};
25
25
  __export(iife_exports, {
26
26
  Gate: () => Gate,
27
+ MenuPriority: () => MenuPriority,
27
28
  NotifyStyle: () => NotifyStyle,
29
+ OpenMenuResult: () => OpenMenuResult,
28
30
  PluginBoolSetting: () => PluginBoolSetting,
29
31
  PluginButton: () => PluginButton,
30
32
  PluginCheckbox: () => PluginCheckbox,
@@ -57,7 +59,7 @@ var vMenu = (() => {
57
59
  });
58
60
 
59
61
  // src/version.ts
60
- var version = true ? "1.0.14" : "0.0.0-local";
62
+ var version = true ? "1.0.15" : "0.0.0-local";
61
63
 
62
64
  // src/text.ts
63
65
  var Text = class _Text {
@@ -192,7 +194,9 @@ var vMenu = (() => {
192
194
  }
193
195
 
194
196
  // src/protocol.ts
195
- var PROTOCOL_VERSION = 2;
197
+ var PROTOCOL_VERSION = 3;
198
+ var BASE_PROTOCOL_VERSION = 2;
199
+ var MAX_STANDALONE_MENUS = 50;
196
200
  var SERVER_PROTOCOL_VERSION = 1;
197
201
  var VMENU_RESOURCE = "vMenu.Enhanced";
198
202
  var PluginEvents = {
@@ -213,6 +217,7 @@ var vMenu = (() => {
213
217
  registerResultFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:RegisterResult`,
214
218
  eventFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:Event`,
215
219
  promptResultFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:PromptResult`,
220
+ openResultFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:OpenResult`,
216
221
  themesFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:Themes`,
217
222
  themesRegisteredFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:ThemesRegistered`,
218
223
  serverReadyFor: (resource) => `vMenu.Enhanced:Plugins:${resource}:Server:Ready`,
@@ -234,6 +239,8 @@ var vMenu = (() => {
234
239
  Opened: "opened",
235
240
  Closed: "closed",
236
241
  IndexChanged: "indexChanged",
242
+ Replaced: "replaced",
243
+ ForceClosed: "forceClosed",
237
244
  Highlighted: "highlighted"
238
245
  };
239
246
  var SettingTypes = {
@@ -256,6 +263,8 @@ var vMenu = (() => {
256
263
  MenuOpened: "menuOpened",
257
264
  MenuClosed: "menuClosed",
258
265
  MenuIndexChanged: "menuIndexChanged",
266
+ MenuReplaced: "menuReplaced",
267
+ MenuForceClosed: "menuForceClosed",
259
268
  PlayerActionSelected: "playerActionSelected",
260
269
  PlayerActionConfirmed: "playerActionConfirmed",
261
270
  PlayerActionListSelected: "playerActionListSelected",
@@ -292,6 +301,7 @@ var vMenu = (() => {
292
301
  SetMenuEvents: "setMenuEvents",
293
302
  SetFilter: "setFilter",
294
303
  ClearFilter: "clearFilter",
304
+ AddStandaloneMenu: "addStandaloneMenu",
295
305
  AddKeys: "addKeys",
296
306
  SetKeyText: "setKeyText",
297
307
  SetKeyEnabled: "setKeyEnabled",
@@ -974,6 +984,37 @@ var vMenu = (() => {
974
984
  };
975
985
 
976
986
  // src/client/menu.ts
987
+ var MenuPriority = {
988
+ /** Only opens when no vMenu or plugin menu is open. */
989
+ None: "none",
990
+ /** Replaces vMenu's menus and other menus at low priority. */
991
+ Low: "low",
992
+ /** Replaces anything at low or medium priority. */
993
+ Medium: "medium",
994
+ /**
995
+ * Replaces anything that is not locked. Only use this in extreme situations, prevents vMenu or other plugins with a
996
+ * lower priority from opening any menus while yours is open. Use responsibly!
997
+ */
998
+ High: "high"
999
+ };
1000
+ var OpenMenuResult = {
1001
+ /** The menu is on screen. */
1002
+ Opened: "opened",
1003
+ /** A menu with a higher priority is open, or any menu is open for a request at `None`. */
1004
+ BlockedByPriority: "blockedByPriority",
1005
+ /** Another menu asked not to be closed. */
1006
+ BlockedByLock: "blockedByLock",
1007
+ /** vMenu does not know this menu, for example an empty root menu, which has no row yet. */
1008
+ UnknownMenu: "unknownMenu",
1009
+ /** The player is not allowed into this menu, for example because a gate on the row leading to it fails. */
1010
+ NotAllowed: "notAllowed",
1011
+ /** A later open or close in the same batch took its place. */
1012
+ Superseded: "superseded",
1013
+ /** The plugin is not connected to vMenu, or vMenu stopped before it answered. */
1014
+ NotConnected: "notConnected",
1015
+ /** The vMenu this plugin talks to predates open priorities. Nothing was opened, use `open()` there instead. */
1016
+ NotSupported: "notSupported"
1017
+ };
977
1018
  var PluginMenu = class _PluginMenu {
978
1019
  /** @internal */
979
1020
  constructor(plugin, node, title, subtitle) {
@@ -986,6 +1027,8 @@ var vMenu = (() => {
986
1027
  this.opened = new Signal(() => plugin.resource, "opened");
987
1028
  this.closed = new Signal(() => plugin.resource, "closed");
988
1029
  this.indexChanged = new Signal(() => plugin.resource, "indexChanged");
1030
+ this.replaced = new Signal(() => plugin.resource, "replaced");
1031
+ this.forceClosed = new Signal(() => plugin.resource, "forceClosed");
989
1032
  }
990
1033
  /** The menu's id, how vMenu refers to it. */
991
1034
  get id() {
@@ -1213,10 +1256,53 @@ var vMenu = (() => {
1213
1256
  this.node.items.length = 0;
1214
1257
  this.plugin.emitOp({ op: UpdateOps.ClearMenu, menuId: this.id });
1215
1258
  }
1216
- /** Opens this menu on screen, closing whatever vMenu menu was open. */
1259
+ /** Opens this menu on screen at low priority, closing whatever vMenu menu was open. Use `requestOpen` to learn whether it worked. */
1217
1260
  open() {
1218
1261
  this.plugin.emitOp({ op: UpdateOps.OpenMenu, menuId: this.id });
1219
1262
  }
1263
+ /**
1264
+ * Opens this menu only when no vMenu or plugin menu is open, so it never interrupts the player. Switching between your
1265
+ * own menus always works.
1266
+ */
1267
+ requestOpen() {
1268
+ return this.requestOpenWithPriority(MenuPriority.None);
1269
+ }
1270
+ /**
1271
+ * Opens this menu, closing an open menu of equal or lower priority. A menu with a higher priority, or one that asked
1272
+ * not to be closed, keeps the screen and the request is refused.
1273
+ *
1274
+ * @param priority `High`: only use this in extreme situations, prevents vMenu or other plugins with a lower priority
1275
+ * from opening any menus while yours is open. Use responsibly!
1276
+ * @param doNotClose Only with `High`. Only use this in extreme situations. It blocks the player from using anything
1277
+ * else, so always close the menu as soon as possible. The lock ends when you close the menu or open its parent, and
1278
+ * the player can still force it shut by holding back.
1279
+ * @throws When `doNotClose` is set below `High`.
1280
+ */
1281
+ requestOpenWithPriority(priority, doNotClose = false) {
1282
+ if (!Object.values(MenuPriority).includes(priority)) {
1283
+ throw new Error(`'${String(priority)}' is not a menu priority.`);
1284
+ }
1285
+ if (doNotClose && priority !== MenuPriority.High) {
1286
+ throw new Error("doNotClose only works with MenuPriority.High.");
1287
+ }
1288
+ return this.plugin.requestOpen(this, priority, doNotClose);
1289
+ }
1290
+ /** Called when another plugin's menu took the screen while the player was in this one. `onClosed` fires as well. */
1291
+ onReplaced(handler) {
1292
+ const unsubscribe = this.replaced.add(handler);
1293
+ this.subscribeMenuEvent(NodeEvents.Replaced);
1294
+ return unsubscribe;
1295
+ }
1296
+ /**
1297
+ * Called when this menu was opened with `doNotClose` and closed by anything other than your own code: the player
1298
+ * holding back to force it shut, or vMenu having to close it. Whatever the player was in the middle of was left
1299
+ * unfinished.
1300
+ */
1301
+ onForceClosed(handler) {
1302
+ const unsubscribe = this.forceClosed.add(handler);
1303
+ this.subscribeMenuEvent(NodeEvents.ForceClosed);
1304
+ return unsubscribe;
1305
+ }
1220
1306
  /** Closes this plugin's menu if one is open. */
1221
1307
  close() {
1222
1308
  this.plugin.emitOp({ op: UpdateOps.CloseMenu, menuId: this.id });
@@ -1260,6 +1346,12 @@ var vMenu = (() => {
1260
1346
  this.indexChanged.fire(callback.oldIndex ?? 0, callback.newIndex);
1261
1347
  }
1262
1348
  break;
1349
+ case CallbackTypes.MenuReplaced:
1350
+ this.replaced.fire();
1351
+ break;
1352
+ case CallbackTypes.MenuForceClosed:
1353
+ this.forceClosed.fire();
1354
+ break;
1263
1355
  }
1264
1356
  }
1265
1357
  /** @internal */
@@ -1267,8 +1359,12 @@ var vMenu = (() => {
1267
1359
  if (!node.menu) {
1268
1360
  return node;
1269
1361
  }
1270
- const { items: _items, ...menu } = node.menu;
1271
- return { ...node, menu: { ...menu, items: [] } };
1362
+ return { ...node, menu: _PluginMenu.withoutItems(node.menu) };
1363
+ }
1364
+ /** @internal */
1365
+ static withoutItems(node) {
1366
+ const { items: _items, ...menu } = node;
1367
+ return { ...menu, items: [] };
1272
1368
  }
1273
1369
  newNode(type, text, options) {
1274
1370
  return { id: options?.id ?? this.plugin.nextItemId(), type, text: Text.toRef(text) };
@@ -1598,6 +1694,8 @@ var vMenu = (() => {
1598
1694
  this.menusById = /* @__PURE__ */ new Map();
1599
1695
  this.keysById = /* @__PURE__ */ new Map();
1600
1696
  this.pendingPrompts = /* @__PURE__ */ new Map();
1697
+ this.pendingOpens = /* @__PURE__ */ new Map();
1698
+ this.standalone = [];
1601
1699
  this.pendingItems = /* @__PURE__ */ new Set();
1602
1700
  this.pendingMenus = /* @__PURE__ */ new Set();
1603
1701
  this.dirtyFilters = [];
@@ -1605,6 +1703,8 @@ var vMenu = (() => {
1605
1703
  this.nextItem = 0;
1606
1704
  this.nextMenu = 0;
1607
1705
  this.nextPrompt = 0;
1706
+ this.nextOpen = 0;
1707
+ this.vMenuProtocol = 0;
1608
1708
  this.handlersRegistered = false;
1609
1709
  this.connected = false;
1610
1710
  this.resource = currentResource();
@@ -1618,6 +1718,10 @@ var vMenu = (() => {
1618
1718
  this.disconnected = new Signal(() => this.resource, "disconnected");
1619
1719
  this.registerMenu(this.rootMenu);
1620
1720
  }
1721
+ /** Menus added with `addStandaloneMenu`. */
1722
+ get standaloneMenus() {
1723
+ return this.standalone;
1724
+ }
1621
1725
  /** Creates the plugin. One per resource: a second call returns the first instance. */
1622
1726
  static create(displayName) {
1623
1727
  if (instance) {
@@ -1652,6 +1756,38 @@ var vMenu = (() => {
1652
1756
  emitLocal(PluginEvents.Probe);
1653
1757
  return this.firstResult;
1654
1758
  }
1759
+ /**
1760
+ * Adds a top level menu of your own, outside vMenu's Plugins menu. It has no row anywhere and no toggle key: only your
1761
+ * code opens it, through `menu.requestOpen()`. Needs a vMenu that knows standalone menus, an older one refuses the
1762
+ * registration. Adding one after connecting appears live.
1763
+ *
1764
+ * @param id Letters, digits and underscores, unique within your plugin. "root" and "m" followed by digits are taken
1765
+ * by the menus the API names itself.
1766
+ * @throws When the id is malformed, reserved or already used, when the plugin already has 50 standalone menus, or when
1767
+ * it is connected to a vMenu that predates standalone menus.
1768
+ */
1769
+ addStandaloneMenu(id, title, subtitle) {
1770
+ if (!/^[A-Za-z0-9_]+$/.test(id)) {
1771
+ throw new Error("A standalone menu id may only contain letters, digits and underscores.");
1772
+ }
1773
+ if (id === "root" || /^m\d+$/.test(id)) {
1774
+ throw new Error(`'${id}' is reserved for the menus the API names itself.`);
1775
+ }
1776
+ if (this.menusById.has(id)) {
1777
+ throw new Error(`A menu with id '${id}' already exists.`);
1778
+ }
1779
+ if (this.standalone.length >= MAX_STANDALONE_MENUS) {
1780
+ throw new Error(`A plugin may have at most ${MAX_STANDALONE_MENUS} standalone menus.`);
1781
+ }
1782
+ if (this.connected && this.vMenuProtocol < PROTOCOL_VERSION) {
1783
+ throw new Error("The vMenu this plugin is connected to predates standalone menus. Update vMenu.");
1784
+ }
1785
+ const menu = new PluginMenu(this, { id, title: Text.toRef(title), subtitle: Text.toRef(subtitle), items: [] }, title, subtitle);
1786
+ this.registerMenu(menu);
1787
+ this.standalone.push(menu);
1788
+ this.emitOp({ op: UpdateOps.AddStandaloneMenu, menu: menu.node });
1789
+ return menu;
1790
+ }
1655
1791
  /** Shows a message through vMenu's notification area, credited to your resource. */
1656
1792
  notify(style, text, durationMs) {
1657
1793
  if (!this.connected) {
@@ -1748,6 +1884,9 @@ var vMenu = (() => {
1748
1884
  if (this.carriedByPendingAdd(op)) {
1749
1885
  return;
1750
1886
  }
1887
+ if (op.op === UpdateOps.AddStandaloneMenu && op.menu) {
1888
+ this.pendingMenus.add(op.menu.id);
1889
+ }
1751
1890
  if (op.op === UpdateOps.AddItems) {
1752
1891
  for (const node of op.items ?? []) {
1753
1892
  this.pendingItems.add(node.id);
@@ -1759,6 +1898,19 @@ var vMenu = (() => {
1759
1898
  batch.push(op);
1760
1899
  }
1761
1900
  /** @internal */
1901
+ requestOpen(menu, priority, doNotClose) {
1902
+ if (!this.connected) {
1903
+ return Promise.resolve(OpenMenuResult.NotConnected);
1904
+ }
1905
+ if (this.vMenuProtocol < PROTOCOL_VERSION) {
1906
+ return Promise.resolve(OpenMenuResult.NotSupported);
1907
+ }
1908
+ const requestId = ++this.nextOpen;
1909
+ const pending = new Promise((resolve) => this.pendingOpens.set(requestId, resolve));
1910
+ this.emitOp({ op: UpdateOps.OpenMenu, menuId: menu.id, priority, doNotClose: doNotClose || void 0, requestId });
1911
+ return pending;
1912
+ }
1913
+ /** @internal */
1762
1914
  filterChanged(menu) {
1763
1915
  if (!this.connected) {
1764
1916
  return;
@@ -1828,9 +1980,15 @@ var vMenu = (() => {
1828
1980
  return false;
1829
1981
  }
1830
1982
  send(ops) {
1831
- const payload = ops.map(
1832
- (op) => op.op === UpdateOps.AddItems && op.items ? { ...op, items: op.items.map(PluginMenu.asAdded) } : op
1833
- );
1983
+ const payload = ops.map((op) => {
1984
+ if (op.op === UpdateOps.AddItems && op.items) {
1985
+ return { ...op, items: op.items.map(PluginMenu.asAdded) };
1986
+ }
1987
+ if (op.op === UpdateOps.AddStandaloneMenu && op.menu) {
1988
+ return { ...op, menu: PluginMenu.withoutItems(op.menu) };
1989
+ }
1990
+ return op;
1991
+ });
1834
1992
  emitLocal(PluginEvents.Update, JSON.stringify({ ops: payload }));
1835
1993
  }
1836
1994
  ensureHandlers() {
@@ -1838,23 +1996,30 @@ var vMenu = (() => {
1838
1996
  return;
1839
1997
  }
1840
1998
  this.handlersRegistered = true;
1841
- onLocal(PluginEvents.Ready, () => this.sendRegistration());
1842
- onLocal(PluginEvents.readyFor(this.resource), () => this.sendRegistration());
1999
+ onLocal(PluginEvents.Ready, (protocolVersion) => this.onReady(protocolVersion));
2000
+ onLocal(PluginEvents.readyFor(this.resource), (protocolVersion) => this.onReady(protocolVersion));
1843
2001
  onLocal(PluginEvents.registerResultFor(this.resource), (json) => this.onRegisterResult(json));
1844
2002
  onLocal(PluginEvents.eventFor(this.resource), (json) => this.onCallback(json));
1845
2003
  onLocal(PluginEvents.promptResultFor(this.resource), (json) => this.onPromptResult(json));
2004
+ onLocal(PluginEvents.openResultFor(this.resource), (json) => this.onOpenResult(json));
1846
2005
  onLocal(PluginEvents.themesFor(this.resource), (json) => this.themes.handle(json));
1847
2006
  onLocal("onResourceStop", (stopped) => this.onResourceStop(stopped));
1848
2007
  }
2008
+ onReady(protocolVersion) {
2009
+ this.vMenuProtocol = typeof protocolVersion === "number" ? protocolVersion : 0;
2010
+ this.sendRegistration();
2011
+ }
1849
2012
  sendRegistration() {
1850
2013
  emitLocal(PluginEvents.Register, JSON.stringify(this.buildRequest()));
1851
2014
  }
1852
2015
  buildRequest() {
1853
2016
  const request = {
1854
- protocolVersion: PROTOCOL_VERSION,
2017
+ // Only a plugin that needs a newer vMenu says so, so everything else still registers with an older one.
2018
+ protocolVersion: this.standalone.length > 0 ? PROTOCOL_VERSION : BASE_PROTOCOL_VERSION,
1855
2019
  displayName: Text.toRef(this.displayName),
1856
2020
  descriptionKey: this.descriptionKey,
1857
2021
  menu: this.rootMenu.node,
2022
+ standaloneMenus: this.standalone.length > 0 ? this.standalone.map((menu) => menu.node) : void 0,
1858
2023
  playerActions: this.playerActions.nodes.length > 0 ? this.playerActions.nodes : void 0
1859
2024
  };
1860
2025
  if (Object.keys(this.translations.tables).length > 0) {
@@ -1880,6 +2045,7 @@ var vMenu = (() => {
1880
2045
  this.connected = result.accepted;
1881
2046
  if (!this.connected) {
1882
2047
  this.cancelPendingPrompts();
2048
+ this.cancelPendingOpens();
1883
2049
  } else {
1884
2050
  this.batch(() => {
1885
2051
  for (const menu of this.menusById.values()) {
@@ -1902,7 +2068,9 @@ var vMenu = (() => {
1902
2068
  switch (callback.type) {
1903
2069
  case CallbackTypes.MenuOpened:
1904
2070
  case CallbackTypes.MenuClosed:
1905
- case CallbackTypes.MenuIndexChanged: {
2071
+ case CallbackTypes.MenuIndexChanged:
2072
+ case CallbackTypes.MenuReplaced:
2073
+ case CallbackTypes.MenuForceClosed: {
1906
2074
  const menu = callback.menuId === void 0 ? void 0 : this.menusById.get(callback.menuId);
1907
2075
  menu?.handleMenu(callback);
1908
2076
  break;
@@ -1936,6 +2104,25 @@ var vMenu = (() => {
1936
2104
  pending(result);
1937
2105
  }
1938
2106
  }
2107
+ onOpenResult(json) {
2108
+ const result = parseJson(json);
2109
+ if (!result) {
2110
+ return;
2111
+ }
2112
+ const pending = this.pendingOpens.get(result.requestId);
2113
+ if (!pending) {
2114
+ return;
2115
+ }
2116
+ this.pendingOpens.delete(result.requestId);
2117
+ const known = Object.values(OpenMenuResult);
2118
+ pending(known.includes(result.reason) ? result.reason : OpenMenuResult.BlockedByPriority);
2119
+ }
2120
+ cancelPendingOpens() {
2121
+ for (const pending of this.pendingOpens.values()) {
2122
+ pending(OpenMenuResult.NotConnected);
2123
+ }
2124
+ this.pendingOpens.clear();
2125
+ }
1939
2126
  cancelPendingPrompts() {
1940
2127
  for (const pending of this.pendingPrompts.values()) {
1941
2128
  pending({ requestId: 0, cancelled: true, busy: false });
@@ -1948,6 +2135,7 @@ var vMenu = (() => {
1948
2135
  }
1949
2136
  this.connected = false;
1950
2137
  this.cancelPendingPrompts();
2138
+ this.cancelPendingOpens();
1951
2139
  this.disconnected.fire();
1952
2140
  }
1953
2141
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vespura/vmenu-plugin",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "description": "Build vMenu Enhanced plugins in TypeScript or JavaScript.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "author": "Tom Grobbe",