@datagrok/bio 2.26.6 → 2.26.8
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/CLAUDE.md +0 -14
- package/css/monomer-collections.css +12 -2
- package/dist/422.js +1 -1
- package/dist/422.js.map +1 -1
- package/dist/package-test.js +3 -3
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +2 -2
- package/dist/package.js.map +1 -1
- package/package.json +1 -1
- package/src/package-api.ts +6 -2
- package/src/package.g.ts +13 -2
- package/src/package.ts +22 -3
- package/src/utils/cell-renderer.ts +2 -2
- package/src/utils/monomer-cell-renderer.ts +15 -9
- package/src/utils/monomer-lib/library-file-manager/ui.ts +3 -2
- package/src/utils/monomer-lib/monomer-collection-handler.ts +359 -0
- package/src/utils/monomer-lib/monomer-collections-view.ts +167 -9
- package/src/widgets/monomer-info-widget.ts +119 -0
- package/test-console-output-1.log +617 -625
- package/test-record-1.mp4 +0 -0
|
@@ -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
|
-
//
|
|
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.
|
|
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:
|
|
292
|
+
// Actions: show edit/delete with disabled state for non-owners
|
|
232
293
|
const isOwner = collection.updatedBy === this.currentUser;
|
|
233
|
-
if (isOwner)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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,119 @@
|
|
|
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
|
+
import {MONOMER_MOTIF_SPLITTER, MONOMER_CANONICALIZER_TEMP} from '@datagrok-libraries/bio/src/utils/macromolecule/consts';
|
|
9
|
+
import {IMonomerCanonicalizer} from '@datagrok-libraries/bio/src/utils/macromolecule/types';
|
|
10
|
+
|
|
11
|
+
import {getCorrectedSmiles, capSmiles} from '../utils/monomer-lib/monomer-manager/monomer-manager';
|
|
12
|
+
|
|
13
|
+
/** Caps the monomer (replaces R-groups with cap atoms) and returns capped SMILES. */
|
|
14
|
+
function getCappedSmiles(monomer: Monomer): string | null {
|
|
15
|
+
try {
|
|
16
|
+
const corrected = getCorrectedSmiles(monomer[REQ.RGROUPS], monomer.smiles, monomer.molfile);
|
|
17
|
+
return capSmiles(corrected, monomer[REQ.RGROUPS]);
|
|
18
|
+
} catch { /* capping may fail for some monomers */ }
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Renders a single-monomer details pane content. */
|
|
23
|
+
function renderMonomerDetails(monomer: Monomer): HTMLElement {
|
|
24
|
+
const map: {[key: string]: any} = {
|
|
25
|
+
'Symbol': monomer[REQ.SYMBOL],
|
|
26
|
+
'Name': monomer[REQ.NAME],
|
|
27
|
+
'Polymer Type': monomer[REQ.POLYMER_TYPE],
|
|
28
|
+
'Monomer Type': monomer[REQ.MONOMER_TYPE],
|
|
29
|
+
};
|
|
30
|
+
if (monomer[REQ.AUTHOR])
|
|
31
|
+
map['Author'] = monomer[REQ.AUTHOR];
|
|
32
|
+
if (monomer.naturalAnalog)
|
|
33
|
+
map['Natural Analog'] = monomer.naturalAnalog;
|
|
34
|
+
if (monomer.lib?.source) {
|
|
35
|
+
let source = monomer.lib.source;
|
|
36
|
+
if (source.endsWith('.json'))
|
|
37
|
+
source = source.substring(0, source.length - 5);
|
|
38
|
+
map['Library'] = source;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Structure
|
|
42
|
+
if (monomer.molfile)
|
|
43
|
+
map['Structure'] = grok.chem.drawMolecule(monomer.molfile, 150, 150);
|
|
44
|
+
else if (monomer.smiles)
|
|
45
|
+
map['Structure'] = grok.chem.drawMolecule(monomer.smiles, 150, 150);
|
|
46
|
+
|
|
47
|
+
// R-Groups
|
|
48
|
+
const rgroups = monomer[REQ.RGROUPS];
|
|
49
|
+
if (rgroups && rgroups.length > 0) {
|
|
50
|
+
for (const rg of rgroups)
|
|
51
|
+
map[rg[RGP.LABEL] ?? '?'] = rg[RGP.ALTERNATE_ID] ?? '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return ui.tableFromMap(map);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Renders the molecule info panel pane content for a single monomer. */
|
|
58
|
+
function renderMoleculePane(monomer: Monomer): HTMLElement {
|
|
59
|
+
const cappedMol = getCappedSmiles(monomer);
|
|
60
|
+
if (!cappedMol)
|
|
61
|
+
return ui.divText('No molecular structure available');
|
|
62
|
+
|
|
63
|
+
const molSv = DG.SemanticValue.fromValueType(cappedMol, DG.SEMTYPE.MOLECULE);
|
|
64
|
+
return ui.panels.infoPanel(molSv).root;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Tries to get the canonicalizer from the SemanticValue's cell column. */
|
|
68
|
+
function getCanonicalizer(sv: DG.SemanticValue): IMonomerCanonicalizer | null {
|
|
69
|
+
try {
|
|
70
|
+
const col = sv.cell?.column;
|
|
71
|
+
if (col)
|
|
72
|
+
return col.temp[MONOMER_CANONICALIZER_TEMP] ?? null;
|
|
73
|
+
} catch { /* no cell context */ }
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Creates a widget for the monomer info panel shown in the context panel. */
|
|
78
|
+
export function getMonomerInfoWidget(sv: DG.SemanticValue, monomerLib: IMonomerLib): DG.Widget {
|
|
79
|
+
const rawValue = sv.value as string;
|
|
80
|
+
if (!rawValue)
|
|
81
|
+
return new DG.Widget(ui.divText('No monomer value.'));
|
|
82
|
+
|
|
83
|
+
// Canonicalize if a canonicalizer is available from the column context
|
|
84
|
+
const canonicalizer = getCanonicalizer(sv);
|
|
85
|
+
const canonicalized = canonicalizer ? canonicalizer.canonicalize(rawValue) : rawValue;
|
|
86
|
+
|
|
87
|
+
// Split by motif splitter — a cell value may contain multiple monomers
|
|
88
|
+
const symbols = canonicalized.split(MONOMER_MOTIF_SPLITTER).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
89
|
+
|
|
90
|
+
// Resolve monomers from library
|
|
91
|
+
const monomers: {symbol: string; monomer: Monomer | null}[] =
|
|
92
|
+
symbols.map((s) => ({symbol: s, monomer: monomerLib.getMonomer(null, s)}));
|
|
93
|
+
|
|
94
|
+
const found = monomers.filter((m) => m.monomer !== null);
|
|
95
|
+
if (found.length === 0)
|
|
96
|
+
return new DG.Widget(ui.divText(`Monomer '${rawValue}' not found in the library.`));
|
|
97
|
+
|
|
98
|
+
const acc = ui.accordion('Monomer');
|
|
99
|
+
|
|
100
|
+
if (found.length === 1) {
|
|
101
|
+
// Single monomer — flat panes
|
|
102
|
+
const m = found[0].monomer!;
|
|
103
|
+
acc.addPane('Details', () => renderMonomerDetails(m), true);
|
|
104
|
+
acc.addPane('Molecule', () => renderMoleculePane(m), true);
|
|
105
|
+
} else {
|
|
106
|
+
// Multiple monomers — one pane per monomer
|
|
107
|
+
for (const {symbol, monomer} of found) {
|
|
108
|
+
acc.addPane(symbol, () => {
|
|
109
|
+
return ui.divV([
|
|
110
|
+
renderMonomerDetails(monomer!),
|
|
111
|
+
ui.element('hr'),
|
|
112
|
+
renderMoleculePane(monomer!),
|
|
113
|
+
]);
|
|
114
|
+
}, true);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return new DG.Widget(acc.root);
|
|
119
|
+
}
|