@matdata/yasgui 5.14.0 → 5.16.0

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.
Files changed (60) hide show
  1. package/build/ts/src/Tab.d.ts +2 -0
  2. package/build/ts/src/Tab.js +67 -5
  3. package/build/ts/src/Tab.js.map +1 -1
  4. package/build/ts/src/TabContextMenu.d.ts +2 -1
  5. package/build/ts/src/TabContextMenu.js +11 -3
  6. package/build/ts/src/TabContextMenu.js.map +1 -1
  7. package/build/ts/src/TabElements.js +13 -3
  8. package/build/ts/src/TabElements.js.map +1 -1
  9. package/build/ts/src/TabSettingsModal.d.ts +9 -0
  10. package/build/ts/src/TabSettingsModal.js +114 -4
  11. package/build/ts/src/TabSettingsModal.js.map +1 -1
  12. package/build/ts/src/endpointSelect.js +1 -1
  13. package/build/ts/src/queryManagement/QueryBrowser.d.ts +1 -0
  14. package/build/ts/src/queryManagement/QueryBrowser.js +62 -0
  15. package/build/ts/src/queryManagement/QueryBrowser.js.map +1 -1
  16. package/build/ts/src/queryManagement/SaveManagedQueryModal.d.ts +7 -0
  17. package/build/ts/src/queryManagement/SaveManagedQueryModal.js +82 -4
  18. package/build/ts/src/queryManagement/SaveManagedQueryModal.js.map +1 -1
  19. package/build/ts/src/queryManagement/WorkspaceSettingsForm.d.ts +1 -0
  20. package/build/ts/src/queryManagement/WorkspaceSettingsForm.js +185 -8
  21. package/build/ts/src/queryManagement/WorkspaceSettingsForm.js.map +1 -1
  22. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.d.ts +1 -0
  23. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.js +18 -0
  24. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.js.map +1 -1
  25. package/build/ts/src/queryManagement/backends/InMemoryWorkspaceBackend.d.ts +1 -0
  26. package/build/ts/src/queryManagement/backends/InMemoryWorkspaceBackend.js +17 -0
  27. package/build/ts/src/queryManagement/backends/InMemoryWorkspaceBackend.js.map +1 -1
  28. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.d.ts +1 -0
  29. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.js +61 -0
  30. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.js.map +1 -1
  31. package/build/ts/src/queryManagement/backends/WorkspaceBackend.d.ts +1 -0
  32. package/build/ts/src/urlUtils.d.ts +1 -0
  33. package/build/ts/src/urlUtils.js +21 -0
  34. package/build/ts/src/urlUtils.js.map +1 -0
  35. package/build/ts/src/version.d.ts +1 -1
  36. package/build/ts/src/version.js +1 -1
  37. package/build/yasgui.min.css +1 -1
  38. package/build/yasgui.min.css.map +3 -3
  39. package/build/yasgui.min.js +245 -213
  40. package/build/yasgui.min.js.map +4 -4
  41. package/package.json +1 -1
  42. package/src/Tab.ts +83 -5
  43. package/src/TabContextMenu.ts +15 -5
  44. package/src/TabElements.scss +17 -0
  45. package/src/TabElements.ts +17 -3
  46. package/src/TabSettingsModal.scss +73 -0
  47. package/src/TabSettingsModal.ts +152 -6
  48. package/src/endpointSelect.scss +0 -22
  49. package/src/endpointSelect.ts +1 -1
  50. package/src/queryManagement/QueryBrowser.ts +72 -0
  51. package/src/queryManagement/SaveManagedQueryModal.ts +102 -4
  52. package/src/queryManagement/WorkspaceSettingsForm.ts +215 -8
  53. package/src/queryManagement/backends/GitWorkspaceBackend.ts +19 -0
  54. package/src/queryManagement/backends/InMemoryWorkspaceBackend.ts +17 -0
  55. package/src/queryManagement/backends/SparqlWorkspaceBackend.ts +69 -0
  56. package/src/queryManagement/backends/WorkspaceBackend.ts +6 -0
  57. package/src/tab.scss +1 -0
  58. package/src/themes.scss +0 -10
  59. package/src/urlUtils.ts +40 -0
  60. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@matdata/yasgui",
3
3
  "description": "Yet Another SPARQL GUI",
4
- "version": "5.14.0",
4
+ "version": "5.16.0",
5
5
  "main": "build/yasgui.min.js",
6
6
  "types": "build/ts/src/index.d.ts",
7
7
  "license": "MIT",
package/src/Tab.ts CHANGED
@@ -17,6 +17,7 @@ import { saveManagedQuery } from "./queryManagement/saveManagedQuery";
17
17
  import { getWorkspaceBackend } from "./queryManagement/backends/getWorkspaceBackend";
18
18
  import { asWorkspaceBackendError } from "./queryManagement/backends/errors";
19
19
  import { normalizeQueryFilename } from "./queryManagement/normalizeQueryFilename";
20
+ import { resolveEndpointUrl } from "./urlUtils";
20
21
 
21
22
  export interface PersistedJsonYasr extends YasrPersistentConfig {
22
23
  responseSummary: Parser.ResponseSummary;
@@ -174,6 +175,42 @@ export class Tab extends EventEmitter {
174
175
  return this.persistentJson.yasqe.value;
175
176
  }
176
177
 
178
+ public async downloadAsRqFile() {
179
+ const query = this.getQueryTextForSave();
180
+ const tabName = this.name() || "query";
181
+ const filename = tabName.endsWith(".rq") || tabName.endsWith(".sparql") ? tabName : `${tabName}.rq`;
182
+ const blob = new Blob([query], { type: "application/sparql-query" });
183
+
184
+ // Use File System Access API if available so the user can choose the save location
185
+ if ("showSaveFilePicker" in window) {
186
+ try {
187
+ const showSaveFilePicker = (
188
+ window as Window & { showSaveFilePicker: (...args: unknown[]) => Promise<FileSystemFileHandle> }
189
+ ).showSaveFilePicker;
190
+ const fileHandle = await showSaveFilePicker({
191
+ suggestedName: filename,
192
+ types: [{ description: "SPARQL Query", accept: { "application/sparql-query": [".rq", ".sparql"] } }],
193
+ });
194
+ const writable = await fileHandle.createWritable();
195
+ await writable.write(blob);
196
+ await writable.close();
197
+ return;
198
+ } catch (e: any) {
199
+ // User cancelled the picker – abort silently
200
+ if (e?.name === "AbortError") return;
201
+ // Other errors fall through to the legacy download below
202
+ }
203
+ }
204
+
205
+ // Fallback for browsers without File System Access API
206
+ const url = URL.createObjectURL(blob);
207
+ const a = document.createElement("a");
208
+ a.href = url;
209
+ a.download = filename;
210
+ a.click();
211
+ URL.revokeObjectURL(url);
212
+ }
213
+
177
214
  public async saveManagedQueryOrSaveAsManagedQuery(): Promise<void> {
178
215
  const meta = this.getManagedQueryMetadata();
179
216
  if (!meta) {
@@ -304,7 +341,7 @@ export class Tab extends EventEmitter {
304
341
  name: result.name,
305
342
  filename: result.filename,
306
343
  queryText: this.getQueryTextForSave(),
307
- associatedEndpoint: workspace.type === "sparql" ? this.getEndpoint() : undefined,
344
+ associatedEndpoint: workspace.type === "sparql" ? resolveEndpointUrl(this.getEndpoint()) : undefined,
308
345
  message: result.message,
309
346
  expectedVersionTag,
310
347
  });
@@ -534,7 +571,7 @@ export class Tab extends EventEmitter {
534
571
  if (!this.controlBarEl) return;
535
572
 
536
573
  this.orientationToggleButton = document.createElement("button");
537
- this.orientationToggleButton.className = "tabContextButton orientationToggle";
574
+ this.orientationToggleButton.className = "tabContextButton orientationToggle desktopOnly";
538
575
  this.orientationToggleButton.setAttribute("aria-label", "Toggle layout orientation");
539
576
  this.orientationToggleButton.title = "Toggle layout orientation";
540
577
 
@@ -614,6 +651,10 @@ export class Tab extends EventEmitter {
614
651
  return this.yasr;
615
652
  }
616
653
 
654
+ public getCurrentOrientation() {
655
+ return this.currentOrientation;
656
+ }
657
+
617
658
  private initTabSettingsMenu() {
618
659
  if (!this.controlBarEl) throw new Error("Need to initialize wrapper elements before drawing tab settings");
619
660
  this.settingsModal = new TabSettingsModal(this, this.controlBarEl);
@@ -1301,6 +1342,11 @@ export class Tab extends EventEmitter {
1301
1342
  void this.saveManagedQueryOrSaveAsManagedQuery();
1302
1343
  });
1303
1344
 
1345
+ // Hook up download as .rq file
1346
+ this.yasqe.on("downloadRqFile", () => {
1347
+ void this.downloadAsRqFile();
1348
+ });
1349
+
1304
1350
  // Show/hide save button based on workspace configuration
1305
1351
  this.updateSaveButtonVisibility();
1306
1352
 
@@ -1445,14 +1491,38 @@ export class Tab extends EventEmitter {
1445
1491
 
1446
1492
  // Check if token is a URI (not a variable)
1447
1493
  // URIs typically have token.type of 'string-2' or might be in angle brackets
1448
- const tokenString = token.string.trim();
1494
+ let tokenString = token.string.trim();
1449
1495
 
1450
1496
  // Skip if it's a variable (starts with ? or $)
1451
1497
  if (tokenString.startsWith("?") || tokenString.startsWith("$")) return;
1452
1498
 
1499
+ // Handle prefixed names that may be split into multiple tokens by the tokenizer
1500
+ // The tokenizer splits "prefix:localname" into two tokens:
1501
+ // - PNAME_LN_PREFIX (e.g., "bnd:") with type "string-2"
1502
+ // - PNAME_LN_LOCAL (e.g., "_subnetwork_bane_KVGB") with type "string"
1503
+ // We need to combine them to get the full prefixed name
1504
+ if (token.type === "string-2" && tokenString.endsWith(":")) {
1505
+ // This is a prefix token, get the next token for the local name
1506
+ const nextToken = this.yasqe.getTokenAt({ line: pos.line, ch: token.end + 1 });
1507
+ if (nextToken && nextToken.type === "string" && nextToken.start === token.end) {
1508
+ tokenString = `${tokenString}${nextToken.string.trim()}`;
1509
+ }
1510
+ } else if (token.type === "string" && token.start > 0) {
1511
+ // This might be a local name token, check if previous token is a prefix
1512
+ const prevToken = this.yasqe.getTokenAt({ line: pos.line, ch: token.start - 1 });
1513
+ if (
1514
+ prevToken &&
1515
+ prevToken.type === "string-2" &&
1516
+ prevToken.string.trim().endsWith(":") &&
1517
+ prevToken.end === token.start
1518
+ ) {
1519
+ tokenString = `${prevToken.string.trim()}${tokenString}`;
1520
+ }
1521
+ }
1522
+
1453
1523
  // Check if it's a URI - either in angle brackets or a prefixed name
1454
1524
  const isFullUri = tokenString.startsWith("<") && tokenString.endsWith(">");
1455
- const isPrefixedName = /^[\w-]+:[\w-]+/.test(tokenString);
1525
+ const isPrefixedName = /^[\w-]+:/.test(tokenString);
1456
1526
 
1457
1527
  if (!isFullUri && !isPrefixedName) return;
1458
1528
 
@@ -1467,7 +1537,8 @@ export class Tab extends EventEmitter {
1467
1537
  } else if (isPrefixedName) {
1468
1538
  // Expand prefixed name to full URI
1469
1539
  const prefixes = this.yasqe.getPrefixesFromQuery();
1470
- const [prefix, localName] = tokenString.split(":");
1540
+ const [prefix, ...localParts] = tokenString.split(":");
1541
+ const localName = localParts.join(":");
1471
1542
  const prefixUri = prefixes[prefix];
1472
1543
  if (prefixUri) {
1473
1544
  uri = prefixUri + localName;
@@ -1601,6 +1672,13 @@ WHERE {
1601
1672
  // Add default renderers to the end, to give our custom ones priority.
1602
1673
  ...(Yasr.defaults.errorRenderers || []),
1603
1674
  ],
1675
+ executeQuery: async (query: string, options?: { acceptHeader?: string }) => {
1676
+ if (!this.yasqe) throw new Error("No YASQE instance available");
1677
+ return Yasqe.Sparql.executeQuery(this.yasqe, undefined, {
1678
+ customQuery: query,
1679
+ customAccept: options?.acceptHeader,
1680
+ });
1681
+ },
1604
1682
  };
1605
1683
  // Allow getDownloadFilName to be overwritten by the global config
1606
1684
  if (yasrConf.getDownloadFileName === undefined) {
@@ -14,8 +14,9 @@ export default class TabContextMenu {
14
14
  private contextEl!: HTMLElement;
15
15
  private newTabEl!: HTMLElement;
16
16
  private renameTabEl!: HTMLElement;
17
- private copyTabEl!: HTMLElement;
17
+ private duplicateTabEl!: HTMLElement;
18
18
  private saveManagedQueryEl!: HTMLElement;
19
+ private saveAsRqFileEl!: HTMLElement;
19
20
  private closeTabEl!: HTMLElement;
20
21
  private closeOtherTabsEl!: HTMLElement;
21
22
  private reOpenOldTab!: HTMLElement;
@@ -48,10 +49,12 @@ export default class TabContextMenu {
48
49
 
49
50
  this.renameTabEl = this.getMenuItemEl("Rename Tab");
50
51
 
51
- this.copyTabEl = this.getMenuItemEl("Copy Tab");
52
+ this.duplicateTabEl = this.getMenuItemEl("Duplicate Tab");
52
53
 
53
54
  this.saveManagedQueryEl = this.getMenuItemEl("Save as managed query");
54
55
 
56
+ this.saveAsRqFileEl = this.getMenuItemEl("Save as .rq file");
57
+
55
58
  this.closeTabEl = this.getMenuItemEl("Close Tab");
56
59
 
57
60
  this.closeOtherTabsEl = this.getMenuItemEl("Close other tabs");
@@ -61,8 +64,9 @@ export default class TabContextMenu {
61
64
  // Add items to list
62
65
  dropDownList.appendChild(this.newTabEl);
63
66
  dropDownList.appendChild(this.renameTabEl);
64
- dropDownList.appendChild(this.copyTabEl);
67
+ dropDownList.appendChild(this.duplicateTabEl);
65
68
  dropDownList.appendChild(this.saveManagedQueryEl);
69
+ dropDownList.appendChild(this.saveAsRqFileEl);
66
70
  // Add divider
67
71
  dropDownList.appendChild(document.createElement("hr"));
68
72
  dropDownList.appendChild(this.closeTabEl);
@@ -101,8 +105,8 @@ export default class TabContextMenu {
101
105
  // Set rename functionality
102
106
  this.renameTabEl.onclick = () => currentTabEl.startRename();
103
107
 
104
- // Copy tab functionality`
105
- this.copyTabEl.onclick = () => {
108
+ // Duplicate tab functionality
109
+ this.duplicateTabEl.onclick = () => {
106
110
  if (!tab) return;
107
111
  const config = cloneDeep(tab.getPersistedJson());
108
112
  config.id = getRandomId();
@@ -115,6 +119,12 @@ export default class TabContextMenu {
115
119
  this.closeConfigMenu();
116
120
  };
117
121
 
122
+ this.saveAsRqFileEl.onclick = () => {
123
+ if (!tab) return;
124
+ tab.downloadAsRqFile();
125
+ this.closeConfigMenu();
126
+ };
127
+
118
128
  // Close tab functionality
119
129
  this.closeTabEl.onclick = () => tab?.close();
120
130
 
@@ -94,6 +94,10 @@ $minTabHeight: 35px;
94
94
  position: relative;
95
95
  $activeColor: #337ab7;
96
96
  $hoverColor: color.adjust($activeColor, $lightness: 30%);
97
+ user-select: none;
98
+ -webkit-user-select: none;
99
+ -moz-user-select: none;
100
+ -ms-user-select: none;
97
101
 
98
102
  // Distinguish managed-query tabs from legacy tabs via background.
99
103
  // Legacy tabs keep their current styling.
@@ -190,6 +194,19 @@ $minTabHeight: 35px;
190
194
  padding: 0px 24px 0px 30px;
191
195
  white-space: nowrap;
192
196
  overflow: hidden;
197
+ user-select: none;
198
+ -webkit-user-select: none;
199
+ -moz-user-select: none;
200
+ -ms-user-select: none;
201
+ -webkit-user-drag: none;
202
+
203
+ span {
204
+ user-select: none;
205
+ -webkit-user-select: none;
206
+ -moz-user-select: none;
207
+ -ms-user-select: none;
208
+ pointer-events: none;
209
+ }
193
210
 
194
211
  &:hover {
195
212
  border-bottom-color: $hoverColor;
@@ -107,7 +107,7 @@ export class TabListEl {
107
107
  tabLinkEl.href = "#" + this.tabId;
108
108
  tabLinkEl.id = "tab-" + this.tabId; // use the id for the tabpanel which is tabId to set the actual tab id
109
109
  tabLinkEl.setAttribute("aria-controls", this.tabId); // respective tabPanel id
110
- tabLinkEl.draggable = false; // Prevent default link dragging that interferes with text selection
110
+ // Note: draggable attribute removed - CSS user-select:none prevents text selection during drag
111
111
  tabLinkEl.addEventListener("blur", () => {
112
112
  if (!this.tabEl) return;
113
113
  if (this.tabEl.classList.contains("active")) {
@@ -133,6 +133,17 @@ export class TabListEl {
133
133
  this.yasgui.selectTabId(this.tabId);
134
134
  });
135
135
 
136
+ // Prevent anchor default drag behavior that interferes with SortableJS in some browsers
137
+ tabLinkEl.addEventListener("dragstart", (e) => {
138
+ e.preventDefault();
139
+ });
140
+ tabLinkEl.addEventListener("mousedown", (e) => {
141
+ // Only prevent default on middle/right click to avoid navigation
142
+ if (e.button !== 0) {
143
+ e.preventDefault();
144
+ }
145
+ });
146
+
136
147
  //tab name
137
148
  this.nameEl = document.createElement("span");
138
149
  this.nameEl.textContent = name;
@@ -315,7 +326,7 @@ export class TabList {
315
326
  queryBrowserButton.className = "queryBrowserToggle";
316
327
  queryBrowserButton.setAttribute("aria-label", "Open query browser");
317
328
  queryBrowserButton.title = "Open query browser";
318
- queryBrowserButton.innerHTML = '<i class="fas fa-bars"></i>';
329
+ queryBrowserButton.innerHTML = '<i class="fa-regular fa-file-lines"></i>';
319
330
  queryBrowserButton.addEventListener("click", () => {
320
331
  this.yasgui.queryBrowser.toggle(queryBrowserButton);
321
332
  });
@@ -326,12 +337,15 @@ export class TabList {
326
337
  sortablejs.create(this._tabsListEl, {
327
338
  group: "tabList",
328
339
  animation: 100,
340
+ draggable: ".tab",
341
+ forceFallback: true,
342
+ fallbackTolerance: 3,
329
343
  onUpdate: (_ev: any) => {
330
344
  const tabs = this.deriveTabOrderFromEls();
331
345
  this.yasgui.emit("tabOrderChanged", this.yasgui, tabs);
332
346
  this.yasgui.persistentConfig.setTabOrder(tabs);
333
347
  },
334
- filter: ".queryBrowserToggle, .addTab, input, .renaming",
348
+ filter: ".queryBrowserToggle, .addTab, input, .renaming, .closeTab",
335
349
  preventOnFilter: false,
336
350
  onMove: (ev: any, _origEv: any) => {
337
351
  return hasClass(ev.related, "tab");
@@ -1251,3 +1251,76 @@
1251
1251
  transform: translateY(1px);
1252
1252
  }
1253
1253
  }
1254
+
1255
+ // Hamburger menu styles
1256
+ .hamburgerContainer {
1257
+ position: relative;
1258
+ display: none; // Hidden by default (desktop)
1259
+
1260
+ @media (max-width: 768px) {
1261
+ display: flex; // Show on mobile
1262
+ }
1263
+ }
1264
+
1265
+ .hamburgerMenuButton {
1266
+ &.active {
1267
+ color: var(--yasgui-accent-color, #337ab7);
1268
+ }
1269
+ }
1270
+
1271
+ .hamburgerDropdown {
1272
+ position: absolute;
1273
+ top: 100%;
1274
+ left: 0;
1275
+ background: var(--yasgui-bg-primary, white);
1276
+ border: 1px solid var(--yasgui-border-color, #e0e0e0);
1277
+ border-radius: 4px;
1278
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1279
+ min-width: 200px;
1280
+ z-index: 1000;
1281
+ margin-top: 5px;
1282
+ overflow: hidden;
1283
+ }
1284
+
1285
+ .hamburgerMenuItem {
1286
+ display: flex;
1287
+ align-items: center;
1288
+ gap: 12px;
1289
+ width: 100%;
1290
+ padding: 12px 16px;
1291
+ background: none;
1292
+ border: none;
1293
+ border-bottom: 1px solid var(--yasgui-border-color, #f0f0f0);
1294
+ text-align: left;
1295
+ cursor: pointer;
1296
+ color: var(--yasgui-text-primary, #333);
1297
+ transition: background-color 0.2s;
1298
+
1299
+ &:last-child {
1300
+ border-bottom: none;
1301
+ }
1302
+
1303
+ &:hover {
1304
+ background: var(--yasgui-bg-secondary, #f7f7f7);
1305
+ }
1306
+
1307
+ i {
1308
+ font-size: 16px;
1309
+ width: 20px;
1310
+ text-align: center;
1311
+ color: var(--yasgui-button-text, #505050);
1312
+ }
1313
+
1314
+ span {
1315
+ flex: 1;
1316
+ font-size: 14px;
1317
+ font-weight: 500;
1318
+ }
1319
+ }
1320
+
1321
+ // Responsive visibility classes
1322
+ .desktopOnly {
1323
+ @media (max-width: 768px) {
1324
+ display: none !important;
1325
+ }
1326
+ }
@@ -39,6 +39,10 @@ export default class TabSettingsModal {
39
39
  private prefixButton!: HTMLButtonElement;
40
40
  private prefixTextarea!: HTMLTextAreaElement;
41
41
  private autoCaptureCheckbox!: HTMLInputElement;
42
+ private hamburgerMenuButton!: HTMLButtonElement;
43
+ private hamburgerDropdown!: HTMLElement;
44
+ private isHamburgerOpen = false;
45
+ private mouseDownOnOverlay = false;
42
46
  private handleKeyDown = (e: KeyboardEvent) => {
43
47
  if (e.key !== "Escape") return;
44
48
  if (!this.modalOverlay.classList.contains("open")) return;
@@ -54,7 +58,7 @@ export default class TabSettingsModal {
54
58
  private init(controlBarEl: HTMLElement) {
55
59
  // Settings button
56
60
  this.settingsButton = document.createElement("button");
57
- addClass(this.settingsButton, "tabContextButton");
61
+ addClass(this.settingsButton, "tabContextButton", "desktopOnly");
58
62
  this.settingsButton.setAttribute("aria-label", "Settings");
59
63
  this.settingsButton.title = "Settings";
60
64
  this.settingsButton.innerHTML = '<i class="fas fa-cog"></i>';
@@ -64,7 +68,7 @@ export default class TabSettingsModal {
64
68
  // Theme toggle button (if enabled)
65
69
  if (this.tab.yasgui.config.showThemeToggle) {
66
70
  this.themeToggleButton = document.createElement("button");
67
- addClass(this.themeToggleButton, "tabContextButton", "themeToggle");
71
+ addClass(this.themeToggleButton, "tabContextButton", "themeToggle", "desktopOnly");
68
72
  this.themeToggleButton.setAttribute("aria-label", "Toggle between light and dark theme");
69
73
  this.themeToggleButton.title = "Toggle theme";
70
74
  this.themeToggleButton.innerHTML = this.getThemeToggleIcon();
@@ -80,19 +84,143 @@ export default class TabSettingsModal {
80
84
  this.prefixButton.setAttribute("aria-label", "Insert Prefixes");
81
85
  this.prefixButton.title = "Insert saved prefixes into query";
82
86
  this.prefixButton.innerHTML = '<i class="fas fa-arrow-up-right-from-square"></i>';
83
- addClass(this.prefixButton, "tabContextButton", "prefixButton");
87
+ addClass(this.prefixButton, "tabContextButton", "prefixButton", "desktopOnly");
84
88
  controlBarEl.appendChild(this.prefixButton);
85
89
  this.prefixButton.onclick = () => this.insertPrefixesIntoQuery();
86
90
 
91
+ // Hamburger menu button and dropdown (mobile only)
92
+ const hamburgerContainer = document.createElement("div");
93
+ addClass(hamburgerContainer, "hamburgerContainer", "mobileOnly");
94
+
95
+ this.hamburgerMenuButton = document.createElement("button");
96
+ addClass(this.hamburgerMenuButton, "tabContextButton", "hamburgerMenuButton");
97
+ this.hamburgerMenuButton.setAttribute("aria-label", "Menu");
98
+ this.hamburgerMenuButton.title = "Open menu";
99
+ this.hamburgerMenuButton.innerHTML = '<i class="fas fa-bars"></i>';
100
+ this.hamburgerMenuButton.onclick = (e) => {
101
+ e.stopPropagation();
102
+ this.toggleHamburgerMenu();
103
+ };
104
+ hamburgerContainer.appendChild(this.hamburgerMenuButton);
105
+
106
+ // Create hamburger dropdown menu
107
+ this.createHamburgerDropdown(hamburgerContainer);
108
+
109
+ controlBarEl.appendChild(hamburgerContainer);
110
+
111
+ // Close hamburger menu when clicking outside
112
+ document.addEventListener("click", (e) => {
113
+ if (
114
+ this.isHamburgerOpen &&
115
+ !this.hamburgerDropdown.contains(e.target as Node) &&
116
+ e.target !== this.hamburgerMenuButton
117
+ ) {
118
+ this.closeHamburgerMenu();
119
+ }
120
+ });
121
+
87
122
  this.createModal();
88
123
  }
89
124
 
125
+ private createHamburgerDropdown(hamburgerContainer: HTMLElement) {
126
+ this.hamburgerDropdown = document.createElement("div");
127
+ addClass(this.hamburgerDropdown, "hamburgerDropdown");
128
+ this.hamburgerDropdown.style.display = "none";
129
+
130
+ // Settings menu item
131
+ const settingsItem = document.createElement("button");
132
+ addClass(settingsItem, "hamburgerMenuItem");
133
+ settingsItem.innerHTML = '<i class="fas fa-cog"></i><span>Settings</span>';
134
+ settingsItem.onclick = () => {
135
+ this.open();
136
+ this.closeHamburgerMenu();
137
+ };
138
+ this.hamburgerDropdown.appendChild(settingsItem);
139
+
140
+ // Prefix menu item
141
+ const prefixItem = document.createElement("button");
142
+ addClass(prefixItem, "hamburgerMenuItem");
143
+ prefixItem.innerHTML = '<i class="fas fa-arrow-up-right-from-square"></i><span>Insert Prefixes</span>';
144
+ prefixItem.onclick = () => {
145
+ this.insertPrefixesIntoQuery();
146
+ this.closeHamburgerMenu();
147
+ };
148
+ this.hamburgerDropdown.appendChild(prefixItem);
149
+
150
+ // Theme toggle menu item (if enabled)
151
+ if (this.tab.yasgui.config.showThemeToggle) {
152
+ const themeItem = document.createElement("button");
153
+ addClass(themeItem, "hamburgerMenuItem", "themeMenuItem");
154
+ themeItem.innerHTML = this.getThemeToggleIcon() + "<span>Toggle Theme</span>";
155
+ themeItem.onclick = () => {
156
+ this.tab.yasgui.toggleTheme();
157
+ themeItem.innerHTML = this.getThemeToggleIcon() + "<span>Toggle Theme</span>";
158
+ // Keep menu open for theme toggle so user can see the change
159
+ };
160
+ this.hamburgerDropdown.appendChild(themeItem);
161
+ }
162
+
163
+ // Layout orientation menu item
164
+ const layoutItem = document.createElement("button");
165
+ addClass(layoutItem, "hamburgerMenuItem", "layoutMenuItem");
166
+ this.updateLayoutMenuItemContent(layoutItem);
167
+ layoutItem.onclick = () => {
168
+ this.tab.toggleOrientation();
169
+ this.updateLayoutMenuItemContent(layoutItem);
170
+ // Keep menu open for layout toggle so user can see the change
171
+ };
172
+ this.hamburgerDropdown.appendChild(layoutItem);
173
+
174
+ hamburgerContainer.appendChild(this.hamburgerDropdown);
175
+ }
176
+
177
+ private updateLayoutMenuItemContent(layoutItem: HTMLButtonElement) {
178
+ const orientation = this.tab.getCurrentOrientation();
179
+ const icon =
180
+ orientation === "vertical" ? '<i class="fas fa-grip-lines-vertical"></i>' : '<i class="fas fa-grip-lines"></i>';
181
+ const label = orientation === "vertical" ? "Horizontal Layout" : "Vertical Layout";
182
+ layoutItem.innerHTML = icon + "<span>" + label + "</span>";
183
+ }
184
+
185
+ private toggleHamburgerMenu() {
186
+ if (this.isHamburgerOpen) {
187
+ this.closeHamburgerMenu();
188
+ } else {
189
+ this.openHamburgerMenu();
190
+ }
191
+ }
192
+
193
+ private openHamburgerMenu() {
194
+ this.isHamburgerOpen = true;
195
+ this.hamburgerDropdown.style.display = "block";
196
+ addClass(this.hamburgerMenuButton, "active");
197
+ }
198
+
199
+ private closeHamburgerMenu() {
200
+ this.isHamburgerOpen = false;
201
+ this.hamburgerDropdown.style.display = "none";
202
+ removeClass(this.hamburgerMenuButton, "active");
203
+ }
204
+
90
205
  private createModal() {
91
206
  // Modal overlay
92
207
  this.modalOverlay = document.createElement("div");
93
208
  addClass(this.modalOverlay, "tabSettingsModalOverlay");
94
- // Removed: this.modalOverlay.onclick = () => this.close();
95
- // Users must explicitly click 'Save' or 'Cancel' to close the modal
209
+
210
+ // Track mousedown on overlay to distinguish from text selection that moves outside
211
+ this.modalOverlay.addEventListener("mousedown", (e) => {
212
+ if (e.target === this.modalOverlay) {
213
+ this.mouseDownOnOverlay = true;
214
+ }
215
+ });
216
+
217
+ // Only close if mousedown also happened on overlay (not during text selection)
218
+ this.modalOverlay.addEventListener("mouseup", (e) => {
219
+ if (e.target === this.modalOverlay && this.mouseDownOnOverlay) {
220
+ this.close();
221
+ }
222
+ this.mouseDownOnOverlay = false;
223
+ });
96
224
 
97
225
  // Modal content
98
226
  this.modalContent = document.createElement("div");
@@ -912,10 +1040,27 @@ export default class TabSettingsModal {
912
1040
  const config = this.tab.yasgui.persistentConfig.getEndpointConfig(endpoint);
913
1041
  const existingAuth = config?.authentication;
914
1042
 
1043
+ // Track mousedown for proper modal close behavior
1044
+ let mouseDownOnOverlay = false;
1045
+
915
1046
  // Create modal overlay
916
1047
  const authModalOverlay = document.createElement("div");
917
1048
  addClass(authModalOverlay, "authModalOverlay");
918
- authModalOverlay.onclick = () => authModalOverlay.remove();
1049
+
1050
+ // Track mousedown on overlay to distinguish from text selection that moves outside
1051
+ authModalOverlay.addEventListener("mousedown", (e) => {
1052
+ if (e.target === authModalOverlay) {
1053
+ mouseDownOnOverlay = true;
1054
+ }
1055
+ });
1056
+
1057
+ // Only close if mousedown also happened on overlay (not during text selection)
1058
+ authModalOverlay.addEventListener("mouseup", (e) => {
1059
+ if (e.target === authModalOverlay && mouseDownOnOverlay) {
1060
+ authModalOverlay.remove();
1061
+ }
1062
+ mouseDownOnOverlay = false;
1063
+ });
919
1064
 
920
1065
  // Create modal content
921
1066
  const authModal = document.createElement("div");
@@ -1516,6 +1661,7 @@ export default class TabSettingsModal {
1516
1661
  }
1517
1662
 
1518
1663
  public close() {
1664
+ this.mouseDownOnOverlay = false;
1519
1665
  removeClass(this.modalOverlay, "open");
1520
1666
  document.removeEventListener("keydown", this.handleKeyDown);
1521
1667
  }
@@ -99,28 +99,6 @@
99
99
  }
100
100
  }
101
101
 
102
- .clearEndpointBtn {
103
- border: 1px solid #d1d1d1;
104
- background-color: #d1d1d1;
105
- color: #505050;
106
- border-radius: 3px;
107
- cursor: pointer;
108
-
109
- padding: 4px 8px;
110
- margin: 4px;
111
-
112
- display: flex;
113
- align-items: center;
114
- justify-content: center;
115
-
116
- opacity: 1;
117
- transition: opacity ease-in 200ms;
118
-
119
- &:hover {
120
- opacity: 0.8;
121
- }
122
- }
123
-
124
102
  .endpointButtonsContainer {
125
103
  display: flex;
126
104
  align-items: center;
@@ -92,7 +92,7 @@ export class EndpointSelect extends EventEmitter {
92
92
  // Create clear button
93
93
  const clearBtn = document.createElement("button");
94
94
  clearBtn.title = "Clear endpoint";
95
- addClass(clearBtn, "clearEndpointBtn");
95
+ addClass(clearBtn, "tabContextButton");
96
96
  clearBtn.innerText = "✖";
97
97
  clearBtn.addEventListener("click", () => {
98
98
  this.inputField.value = "";