@datagrok/bio 2.26.6 → 2.26.7

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.
@@ -9,6 +9,8 @@ import {HelmTypes} from '@datagrok-libraries/bio/src/helm/consts';
9
9
  import {MonomerSelectionWidget} from '@datagrok-libraries/bio/src/utils/monomer-selection-dialog';
10
10
 
11
11
  import {MonomerLibManager} from './lib-manager';
12
+ import {MonomerCollectionHandler, MonomerCollectionInfo} from './monomer-collection-handler';
13
+ import {SEM_TYPES} from '../constants';
12
14
  import {_package} from '../../package';
13
15
 
14
16
  //@ts-ignore
@@ -29,6 +31,12 @@ export class MonomerCollectionsView {
29
31
  private currentUser: string = '';
30
32
  /** Cached collection data for search filtering */
31
33
  private collectionsCache: Map<string, MonomerCollection> = new Map();
34
+ /** Currently selected collection names */
35
+ private selectedCollections: Set<string> = new Set();
36
+ /** Map of collection name -> card element for quick access */
37
+ private cardElements: Map<string, HTMLDivElement> = new Map();
38
+ /** The handler instance for context menu and actions */
39
+ private handler: MonomerCollectionHandler = new MonomerCollectionHandler();
32
40
 
33
41
  static readonly VIEW_NAME = 'Monomer Collections';
34
42
 
@@ -40,13 +48,15 @@ export class MonomerCollectionsView {
40
48
 
41
49
  this.view = DG.View.create();
42
50
  this.view.name = MonomerCollectionsView.VIEW_NAME;
51
+ // Store reference for the handler to find
52
+ (this.view as any)._mcView = this;
43
53
 
44
54
  // Ribbon
45
55
  const addBtn = ui.icons.add(() => this.showAddCollectionDialog(), 'Create new monomer collection');
46
56
  const refreshBtn = ui.iconFA('sync', () => this.refresh(), 'Refresh collections');
47
57
  this.view.setRibbonPanels([[addBtn, refreshBtn]]);
48
58
 
49
- // Context menu
59
+ // Background context menu (not on cards)
50
60
  this.view.root.addEventListener('contextmenu', (e: MouseEvent) => {
51
61
  const target = e.target as HTMLElement;
52
62
  if (target.closest('.monomer-collection-card') || target.closest('.monomer-collection-add-card'))
@@ -58,6 +68,20 @@ export class MonomerCollectionsView {
58
68
  menu.show();
59
69
  });
60
70
 
71
+ // Click on empty space deselects all
72
+ this.view.root.addEventListener('click', (e: MouseEvent) => {
73
+ const target = e.target as HTMLElement;
74
+ if (!target.closest('.monomer-collection-card') && !target.closest('.monomer-collection-add-card'))
75
+ this.clearSelection();
76
+ });
77
+
78
+ // Escape key clears selection
79
+ this.view.root.tabIndex = 0;
80
+ this.view.root.addEventListener('keydown', (e: KeyboardEvent) => {
81
+ if (e.key === 'Escape')
82
+ this.clearSelection();
83
+ });
84
+
61
85
  await this.buildContent();
62
86
  return this.view;
63
87
  }
@@ -125,13 +149,17 @@ export class MonomerCollectionsView {
125
149
 
126
150
  this.displayedCount = 0;
127
151
  this.cardsContainer!.innerHTML = '';
152
+ this.cardElements.clear();
128
153
  this.showNextPage();
129
154
  }
130
155
 
131
156
  private showNextPage(): void {
132
157
  const end = Math.min(this.displayedCount + PAGE_SIZE, this.filteredNames.length);
133
- for (let i = this.displayedCount; i < end; i++)
134
- this.cardsContainer!.appendChild(this.createCollectionCard(this.filteredNames[i]));
158
+ for (let i = this.displayedCount; i < end; i++) {
159
+ const card = this.createCollectionCard(this.filteredNames[i]);
160
+ this.cardsContainer!.appendChild(card);
161
+ this.cardElements.set(this.filteredNames[i], card);
162
+ }
135
163
 
136
164
  this.displayedCount = end;
137
165
 
@@ -175,6 +203,34 @@ export class MonomerCollectionsView {
175
203
  const actionsEl = ui.div([], {classes: 'monomer-collection-card-actions'});
176
204
 
177
205
  const card = ui.div([headerEl, bodyEl, actionsEl], {classes: 'monomer-collection-card'}) as HTMLDivElement;
206
+ card.dataset.collectionName = collectionName;
207
+
208
+ // Apply selected state if already selected
209
+ if (this.selectedCollections.has(collectionName))
210
+ card.classList.add('monomer-collection-card-selected');
211
+
212
+ // Click handler: select card and set as current object
213
+ card.addEventListener('click', (e: MouseEvent) => {
214
+ // Don't handle clicks on buttons or monomer tags
215
+ const target = e.target as HTMLElement;
216
+ if (target.closest('.monomer-collection-card-actions') || target.closest('.monomer-collection-tag'))
217
+ return;
218
+
219
+ e.stopPropagation();
220
+ this.handleCardClick(collectionName, e.ctrlKey || e.metaKey);
221
+ });
222
+
223
+ // Right-click context menu
224
+ card.addEventListener('contextmenu', (e: MouseEvent) => {
225
+ e.preventDefault();
226
+ e.stopPropagation();
227
+
228
+ // If right-clicking a non-selected card, select it first
229
+ if (!this.selectedCollections.has(collectionName))
230
+ this.handleCardClick(collectionName, false);
231
+
232
+ this.showCardContextMenu(e);
233
+ });
178
234
 
179
235
  // Load content in background
180
236
  const loadContent = async (): Promise<HTMLElement> => {
@@ -222,20 +278,31 @@ export class MonomerCollectionsView {
222
278
  }
223
279
  });
224
280
  tag.addEventListener('mouseleave', () => ui.tooltip.hide());
281
+ // Clicking a monomer tag sets its SemanticValue as the current object
282
+ tag.addEventListener('click', (e: MouseEvent) => {
283
+ e.stopPropagation();
284
+ grok.shell.o = DG.SemanticValue.fromValueType(symbol, SEM_TYPES.MONOMER);
285
+ });
225
286
  monomerTagsContainer.appendChild(tag);
226
287
  }
227
288
 
228
289
  const countLabel = ui.divText(`${symbols.length} monomer(s)`, {style: {fontSize: '11px', color: 'var(--grey-4)', marginBottom: '6px'}});
229
290
  const contentDiv = ui.divV([countLabel, monomerTagsContainer]);
230
291
 
231
- // Actions: only show edit/delete if the current user matches updatedBy
292
+ // Actions: show edit/delete with disabled state for non-owners
232
293
  const isOwner = collection.updatedBy === this.currentUser;
233
- if (isOwner) {
234
- const editBtn = ui.button('Edit', () => this.showEditCollectionDialog(collectionName, collection));
235
- const deleteBtn = ui.button('Delete', () => this.confirmDeleteCollection(collectionName));
236
- actionsEl.appendChild(editBtn);
237
- actionsEl.appendChild(deleteBtn);
294
+ const editBtn = ui.button('Edit', () => { if (isOwner) this.showEditCollectionDialog(collectionName, collection); });
295
+ const deleteBtn = ui.button('Delete', () => { if (isOwner) this.confirmDeleteCollection(collectionName); });
296
+ if (!isOwner) {
297
+ for (const btn of [editBtn, deleteBtn]) {
298
+ btn.style.opacity = '0.4';
299
+ btn.style.cursor = 'default';
300
+ }
301
+ ui.tooltip.bind(editBtn, 'Only the author can edit');
302
+ ui.tooltip.bind(deleteBtn, 'Only the author can delete');
238
303
  }
304
+ actionsEl.appendChild(editBtn);
305
+ actionsEl.appendChild(deleteBtn);
239
306
 
240
307
  return contentDiv;
241
308
  };
@@ -244,6 +311,91 @@ export class MonomerCollectionsView {
244
311
  return card;
245
312
  }
246
313
 
314
+ /** Handle card click with optional Ctrl/Cmd for multi-select. */
315
+ private handleCardClick(collectionName: string, isMulti: boolean): void {
316
+ if (isMulti) {
317
+ // Toggle selection
318
+ if (this.selectedCollections.has(collectionName))
319
+ this.selectedCollections.delete(collectionName);
320
+ else
321
+ this.selectedCollections.add(collectionName);
322
+ } else {
323
+ // Single select: clear others
324
+ this.selectedCollections.clear();
325
+ this.selectedCollections.add(collectionName);
326
+ }
327
+
328
+ this.updateCardSelectionStyles();
329
+ this.setCurrentObject();
330
+ }
331
+
332
+ /** Clear all selections. */
333
+ private clearSelection(): void {
334
+ this.selectedCollections.clear();
335
+ this.updateCardSelectionStyles();
336
+ }
337
+
338
+ /** Update card CSS classes to reflect current selection. */
339
+ private updateCardSelectionStyles(): void {
340
+ for (const [name, card] of this.cardElements) {
341
+ if (this.selectedCollections.has(name))
342
+ card.classList.add('monomer-collection-card-selected');
343
+ else
344
+ card.classList.remove('monomer-collection-card-selected');
345
+ }
346
+ }
347
+
348
+ /** Set the current object in grok.shell.o based on selection. */
349
+ private async setCurrentObject(): Promise<void> {
350
+ const selected = [...this.selectedCollections];
351
+ if (selected.length === 0) return;
352
+
353
+ // Build MonomerCollectionInfo items for the selected collections
354
+ const infos: MonomerCollectionInfo[] = [];
355
+ for (const name of selected) {
356
+ let data = this.collectionsCache.get(name);
357
+ if (!data) {
358
+ try { data = await this.libHelper!.readMonomerCollection(name); } catch { continue; }
359
+ }
360
+ infos.push({
361
+ name,
362
+ displayName: name.replace(/\.json$/i, ''),
363
+ data,
364
+ });
365
+ }
366
+
367
+ if (infos.length === 1)
368
+ grok.shell.o = infos[0];
369
+ else if (infos.length > 1)
370
+ grok.shell.o = infos;
371
+ }
372
+
373
+ /** Show context menu for selected cards. */
374
+ private showCardContextMenu(e: MouseEvent): void {
375
+ const menu = DG.Menu.popup();
376
+ const selected = [...this.selectedCollections];
377
+
378
+ if (selected.length <= 1 && selected.length > 0) {
379
+ const name = selected[0];
380
+ const data = this.collectionsCache.get(name);
381
+ if (data) {
382
+ const info: MonomerCollectionInfo = {name, displayName: name.replace(/\.json$/i, ''), data};
383
+ MonomerCollectionHandler.buildContextMenu(menu, info, this.handler);
384
+ }
385
+ } else if (selected.length > 1) {
386
+ const items: MonomerCollectionInfo[] = selected
387
+ .map((name) => {
388
+ const data = this.collectionsCache.get(name);
389
+ return data ? {name, displayName: name.replace(/\.json$/i, ''), data} : null;
390
+ })
391
+ .filter((x): x is MonomerCollectionInfo => x !== null);
392
+ if (items.length > 0)
393
+ MonomerCollectionHandler.buildMultiContextMenu(menu, items, this.handler);
394
+ }
395
+
396
+ menu.show();
397
+ }
398
+
247
399
  private getMonomerTooltipSafe(symbol: string): HTMLElement | null {
248
400
  if (!this.monomerLib) return null;
249
401
  const helmTypes: HelmType[] = [HelmTypes.AA, HelmTypes.NUCLEOTIDE, HelmTypes.CHEM, HelmTypes.BLOB];
@@ -321,6 +473,11 @@ export class MonomerCollectionsView {
321
473
  monomerWidget.focus();
322
474
  }
323
475
 
476
+ /** Public method so the handler can trigger edit from the context panel. */
477
+ editCollectionPublic(collectionName: string, collection: MonomerCollection): void {
478
+ this.showEditCollectionDialog(collectionName, collection);
479
+ }
480
+
324
481
  private showEditCollectionDialog(collectionName: string, collection: MonomerCollection): void {
325
482
  const displayName = collectionName.replace(/\.json$/i, '');
326
483
  const nameInput = ui.input.string('Name', {value: displayName, nullable: false});
@@ -395,6 +552,7 @@ export class MonomerCollectionsView {
395
552
  }
396
553
 
397
554
  async refresh(): Promise<void> {
555
+ this.selectedCollections.clear();
398
556
  await this.loadCollections();
399
557
  }
400
558
  }
@@ -0,0 +1,79 @@
1
+ /* eslint-disable max-len */
2
+ import * as grok from 'datagrok-api/grok';
3
+ import * as ui from 'datagrok-api/ui';
4
+ import * as DG from 'datagrok-api/dg';
5
+
6
+ import {IMonomerLib, Monomer} from '@datagrok-libraries/bio/src/types/monomer-library';
7
+ import {HELM_REQUIRED_FIELD as REQ, HELM_RGROUP_FIELDS as RGP} from '@datagrok-libraries/bio/src/utils/const';
8
+
9
+ import {getCorrectedSmiles, capSmiles} from '../utils/monomer-lib/monomer-manager/monomer-manager';
10
+
11
+ /** Finds a monomer in the library across all polymer types. */
12
+ function findMonomer(monomerLib: IMonomerLib, symbol: string): Monomer | null {
13
+ return monomerLib.getMonomer(null, symbol);
14
+ }
15
+
16
+ /** Caps the monomer (replaces R-groups with cap atoms) and returns capped SMILES. */
17
+ function getCappedSmiles(monomer: Monomer): string | null {
18
+ try {
19
+ const corrected = getCorrectedSmiles(monomer[REQ.RGROUPS], monomer.smiles, monomer.molfile);
20
+ return capSmiles(corrected, monomer[REQ.RGROUPS]);
21
+ } catch { /* capping may fail for some monomers */ }
22
+ return null;
23
+ }
24
+
25
+ /** Creates a widget for the monomer info panel shown in the context panel. */
26
+ export function getMonomerInfoWidget(symbol: string, monomerLib: IMonomerLib): DG.Widget {
27
+ const monomer = findMonomer(monomerLib, symbol);
28
+ if (!monomer)
29
+ return new DG.Widget(ui.divText(`Monomer '${symbol}' not found in the library.`));
30
+
31
+ const acc = ui.accordion('Monomer');
32
+
33
+ // Details pane — includes general info, structure, and R-groups in one table
34
+ acc.addPane('Details', () => {
35
+ const map: {[key: string]: any} = {
36
+ 'Symbol': monomer[REQ.SYMBOL],
37
+ 'Name': monomer[REQ.NAME],
38
+ 'Polymer Type': monomer[REQ.POLYMER_TYPE],
39
+ 'Monomer Type': monomer[REQ.MONOMER_TYPE],
40
+ };
41
+ if (monomer[REQ.AUTHOR])
42
+ map['Author'] = monomer[REQ.AUTHOR];
43
+ if (monomer.naturalAnalog)
44
+ map['Natural Analog'] = monomer.naturalAnalog;
45
+ if (monomer.lib?.source) {
46
+ let source = monomer.lib.source;
47
+ if (source.endsWith('.json'))
48
+ source = source.substring(0, source.length - 5);
49
+ map['Library'] = source;
50
+ }
51
+
52
+ // Structure
53
+ if (monomer.molfile)
54
+ map['Structure'] = grok.chem.drawMolecule(monomer.molfile, 150, 150);
55
+ else if (monomer.smiles)
56
+ map['Structure'] = grok.chem.drawMolecule(monomer.smiles, 150, 150);
57
+
58
+ // R-Groups
59
+ const rgroups = monomer[REQ.RGROUPS];
60
+ if (rgroups && rgroups.length > 0) {
61
+ for (const rg of rgroups)
62
+ map[rg[RGP.LABEL] ?? '?'] = rg[RGP.ALTERNATE_ID] ?? '';
63
+ }
64
+
65
+ return ui.tableFromMap(map);
66
+ }, true);
67
+
68
+ // Molecule panel pane — cap the monomer first, then embed the generic Molecule context panel
69
+ acc.addPane('Molecule', () => {
70
+ const cappedMol = getCappedSmiles(monomer);
71
+ if (!cappedMol)
72
+ return ui.divText('No molecular structure available');
73
+
74
+ const molSv = DG.SemanticValue.fromValueType(cappedMol, DG.SEMTYPE.MOLECULE);
75
+ return ui.panels.infoPanel(molSv).root;
76
+ }, true);
77
+
78
+ return new DG.Widget(acc.root);
79
+ }