Skip to content

Commit ba2c8e0

Browse files
authored
[OGUI-1863] Add dropdown with multiple save root as image extensions (#3270)
- Changed the "download root as PNG image" to a dropdown - The dropdown contains buttons to save the root as an image for multiple filetypes - Moved `SUPPORTED_ROOT_IMAGE_FILE_TYPES` to a seperate enum file - Added `simpleDebouncer` to the global utility file and removed the unused function `getFileExtensionFromName` - Added an "extra object data" attribute to `QCObject`, so extra data about an object can be stored (in-memory) - This is currently only used to know whether the toolbar of a plot on the `layoutShow` page should remain visible.
1 parent bcf23a2 commit ba2c8e0

14 files changed

Lines changed: 327 additions & 168 deletions

QualityControl/public/common/downloadRootImageButton.js

Lines changed: 0 additions & 39 deletions
This file was deleted.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @license
3+
* Copyright 2019-2020 CERN and copyright holders of ALICE O2.
4+
* See http://alice-o2.web.cern.ch/copyright for details of the copyright holders.
5+
* All rights not expressly granted are reserved.
6+
*
7+
* This software is distributed under the terms of the GNU General Public
8+
* License v3 (GPL Version 3), copied verbatim in the file "COPYING".
9+
*
10+
* In applying this license CERN does not waive the privileges and immunities
11+
* granted to it by virtue of its status as an Intergovernmental Organization
12+
* or submit itself to any jurisdiction.
13+
*/
14+
15+
import { h, DropdownComponent, imagE } from '/js/src/index.js';
16+
import { downloadRoot } from './utils.js';
17+
import { isObjectOfTypeChecker } from '../../library/qcObject/utils.js';
18+
import { RootImageDownloadExtensions } from './enums/rootImageMimes.enum.js';
19+
20+
/**
21+
* Download root image button.
22+
* @param {string} filename - The name of the downloaded file excluding its file extension.
23+
* @param {RootObject} root - The JSROOT RootObject to render.
24+
* @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options.
25+
* @param {(visible: boolean) => void} [onVisibilityChange=()=>{}] - Callback for any change in
26+
* visibility of the dropdown.
27+
* @param {string|undefined} [uniqueIdentifier=undefined] - An unique identifier for the dropdown,
28+
* or the `filename` if `undefined`.
29+
* @returns {vnode|undefined} - Download root image button element.
30+
*/
31+
export function downloadRootImageDropdown(
32+
filename,
33+
root,
34+
drawingOptions = [],
35+
onVisibilityChange = () => {},
36+
uniqueIdentifier = undefined,
37+
) {
38+
if (isObjectOfTypeChecker(root)) {
39+
return undefined;
40+
}
41+
42+
const dropdownComponent = DropdownComponent(
43+
h('button.btn.save-root-as-image-button', {
44+
title: 'Save root as image',
45+
}, imagE()),
46+
h('#download-root-image-dropdown', [
47+
RootImageDownloadExtensions()
48+
.map((fileExtension) => h('button.btn.d-block.w-100', {
49+
key: `${uniqueIdentifier ?? filename}.${fileExtension}`,
50+
id: `${uniqueIdentifier ?? filename}.${fileExtension}`,
51+
title: `Save root as image (${fileExtension})`,
52+
onclick: async (event) => {
53+
try {
54+
event.target.disabled = true;
55+
await downloadRoot(filename, fileExtension, root, drawingOptions);
56+
} finally {
57+
event.target.disabled = false;
58+
dropdownComponent.state.hidePopover();
59+
}
60+
},
61+
}, fileExtension)),
62+
]),
63+
{ onVisibilityChange },
64+
);
65+
66+
return dropdownComponent;
67+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* @license
3+
* Copyright 2019-2020 CERN and copyright holders of ALICE O2.
4+
* See http://alice-o2.web.cern.ch/copyright for details of the copyright holders.
5+
* All rights not expressly granted are reserved.
6+
*
7+
* This software is distributed under the terms of the GNU General Public
8+
* License v3 (GPL Version 3), copied verbatim in the file "COPYING".
9+
*
10+
* In applying this license CERN does not waive the privileges and immunities
11+
* granted to it by virtue of its status as an Intergovernmental Organization
12+
* or submit itself to any jurisdiction.
13+
*/
14+
15+
/**
16+
* Enumeration for allowed `ROOT.makeImage` file extensions to MIME types
17+
* @enum {string}
18+
* @readonly
19+
*/
20+
export const RootImageDownloadSupportedTypes = Object.freeze({
21+
SVG: 'image/svg+xml',
22+
PNG: 'file/png',
23+
JPG: 'file/jpeg',
24+
JPEG: 'file/jpeg',
25+
WEBP: 'file/webp',
26+
});
27+
28+
/**
29+
* Get the list of unique supported ROOT image download extensions
30+
* @returns {string[]} - Array of supported ROOT image download extensions
31+
*/
32+
export const RootImageDownloadExtensions = () => {
33+
const extensions = new Set();
34+
Object.keys(RootImageDownloadSupportedTypes)
35+
.forEach((ext) => extensions.add(ext.toLowerCase()));
36+
return Array.from(extensions);
37+
};

QualityControl/public/common/utils.js

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,10 @@
1414

1515
import { isUserRoleSufficient } from '../../../../library/userRole.enum.js';
1616
import { generateDrawingOptionString } from '../../library/qcObject/utils.js';
17+
import { RootImageDownloadSupportedTypes } from './enums/rootImageMimes.enum.js';
1718

1819
/* global JSROOT BOOKKEEPING */
1920

20-
/**
21-
* Map of allowed `ROOT.makeImage` file extensions to MIME types
22-
* @type {Map<string, string>}
23-
*/
24-
const SUPPORTED_ROOT_IMAGE_FILE_TYPES = new Map([
25-
['svg', 'image/svg+xml'],
26-
['png', 'file/png'],
27-
['jpg', 'file/jpeg'],
28-
['jpeg', 'file/jpeg'],
29-
['webp', 'file/webp'],
30-
]);
31-
3221
/**
3322
* Generates a new ObjectId
3423
* @returns {string} 16 random chars, base 16
@@ -47,6 +36,32 @@ export function clone(obj) {
4736
return JSON.parse(JSON.stringify(obj));
4837
}
4938

39+
// Map storing timers per key
40+
const simpleDebouncerTimers = new Map();
41+
42+
/**
43+
* Produces a debounced function that uses a key to manage timers.
44+
* Each key has its own debounce timer, so calls with different keys
45+
* are debounced independently.
46+
* @template PrimitiveKey extends unknown
47+
* @param {PrimitiveKey} key - The key for this call.
48+
* @param {(key: PrimitiveKey) => void} fn - Function to debounce.
49+
* @param {number} time - Debounce delay in milliseconds.
50+
* @returns {undefined}
51+
*/
52+
export function simpleDebouncer(key, fn, time) {
53+
if (simpleDebouncerTimers.has(key)) {
54+
clearTimeout(simpleDebouncerTimers.get(key));
55+
}
56+
57+
const timerId = setTimeout(() => {
58+
fn(key);
59+
simpleDebouncerTimers.delete(key);
60+
}, time);
61+
62+
simpleDebouncerTimers.set(key, timerId);
63+
}
64+
5065
/**
5166
* Produces a lambda function waiting `time` ms before calling fn.
5267
* No matter how many calls are done to lambda, the last call is the waiting starting point.
@@ -178,14 +193,6 @@ export const camelToTitleCase = (text) => {
178193
return titleCase;
179194
};
180195

181-
/**
182-
* Get the file extension from a filename
183-
* @param {string} filename - The file name including the file extension
184-
* @returns {string} - the file extension
185-
*/
186-
export const getFileExtensionFromName = (filename) =>
187-
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase().trim();
188-
189196
/**
190197
* Helper to trigger a download for a file
191198
* @param {string} url - The URL to the file source
@@ -216,14 +223,14 @@ export const downloadFile = (file, filename) => {
216223

217224
/**
218225
* Generates a rasterized image of a JSROOT RootObject and triggers download.
219-
* @param {string} filename - The name of the downloaded file including its extension.
226+
* @param {string} filename - The name of the downloaded file excluding the file extension.
227+
* @param {string} filetype - The file extension of the downloaded file.
220228
* @param {RootObject} root - The JSROOT RootObject to render.
221229
* @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options.
222230
* @returns {undefined}
223231
*/
224-
export const downloadRoot = async (filename, root, drawingOptions = []) => {
225-
const filetype = getFileExtensionFromName(filename);
226-
const mime = SUPPORTED_ROOT_IMAGE_FILE_TYPES.get(filetype);
232+
export const downloadRoot = async (filename, filetype, root, drawingOptions = []) => {
233+
const mime = RootImageDownloadSupportedTypes[filetype.toLocaleUpperCase()];
227234
if (!mime) {
228235
throw new Error(`The file extension (${filetype}) is not supported`);
229236
}
@@ -235,7 +242,7 @@ export const downloadRoot = async (filename, root, drawingOptions = []) => {
235242
as_buffer: true,
236243
});
237244
const blob = new Blob([image], { type: mime });
238-
downloadFile(blob, filename);
245+
downloadFile(blob, `${filename}.${filetype}`);
239246
};
240247

241248
/**

QualityControl/public/layout/view/panels/objectInfoResizePanel.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { downloadButton } from '../../../common/downloadButton.js';
1616
import { isOnLeftSideOfViewport } from '../../../common/utils.js';
1717
import { defaultRowAttributes, qcObjectInfoPanel } from './../../../common/object/objectInfoCard.js';
1818
import { h, iconResizeBoth, info } from '/js/src/index.js';
19-
import { downloadRootImageButton } from '../../../common/downloadRootImageButton.js';
19+
import { downloadRootImageDropdown } from '../../../common/downloadRootImageDropdown.js';
2020

2121
/**
2222
* Builds 2 actionable buttons which are to be placed on top of a JSROOT plot
@@ -40,8 +40,9 @@ export const objectInfoResizePanel = (model, tabObject) => {
4040
const toUseDrawingOptions = Array.from(new Set(ignoreDefaults
4141
? drawingOptions
4242
: [...drawingOptions, ...displayHints, ...drawOptions]));
43+
const visibility = object.getExtraObjectData(tabObject.id)?.saveImageDropdownOpen ? 'visible' : 'hidden';
4344
return h('.text-right.resize-element.item-action-row.flex-row.g1', {
44-
style: 'visibility: hidden; padding: .25rem .25rem 0rem .25rem;',
45+
style: `visibility: ${visibility}; padding: .25rem .25rem 0rem .25rem;`,
4546
}, [
4647

4748
h('.dropdown', { class: isSelectedOpen ? 'dropdown-open' : '',
@@ -69,10 +70,14 @@ export const objectInfoResizePanel = (model, tabObject) => {
6970
),
7071
]),
7172
objectRemoteData.isSuccess() && [
72-
downloadRootImageButton(
73-
`${objectRemoteData.payload.name}.png`,
73+
downloadRootImageDropdown(
74+
objectRemoteData.payload.name,
7475
objectRemoteData.payload.qcObject.root,
7576
toUseDrawingOptions,
77+
(isDropdownOpen) => {
78+
object.appendExtraObjectData(tabObject.id, { saveImageDropdownOpen: isDropdownOpen });
79+
},
80+
tabObject.id,
7681
),
7782
downloadButton({
7883
href: model.objectViewModel.getDownloadQcdbObjectUrl(objectRemoteData.payload.id),

QualityControl/public/object/QCObject.js

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import { RemoteData, iconCaretTop, BrowserStorage } from '/js/src/index.js';
1616
import ObjectTree from './ObjectTree.class.js';
17-
import { prettyFormatDate, setBrowserTabTitle } from './../common/utils.js';
17+
import { simpleDebouncer, prettyFormatDate, setBrowserTabTitle } from './../common/utils.js';
1818
import { isObjectOfTypeChecker } from './../library/qcObject/utils.js';
1919
import { BaseViewModel } from '../common/abstracts/BaseViewModel.js';
2020
import { StorageKeysEnum } from '../common/enums/storageKeys.enum.js';
@@ -39,6 +39,7 @@ export default class QCObject extends BaseViewModel {
3939
this.selected = null; // Object - { name; createTime; lastModified; }
4040
this.selectedOpen = false;
4141
this.objects = {}; // ObjectName -> RemoteData.payload -> plot
42+
this._extraObjectData = {};
4243

4344
this.searchInput = ''; // String - content of input search
4445
this.searchResult = []; // Array<object> - result list of search
@@ -303,6 +304,7 @@ export default class QCObject extends BaseViewModel {
303304
async loadObjects(objectsName) {
304305
this.objectsRemote = RemoteData.loading();
305306
this.objects = {}; // Remove any in-memory loaded objects
307+
this._extraObjectData = {}; // Remove any in-memory extra object data
306308
this.model.services.object.objectsLoadedMap = {}; // TODO not here
307309
this.notify();
308310
if (!objectsName || !objectsName.length) {
@@ -642,4 +644,49 @@ export default class QCObject extends BaseViewModel {
642644
}
643645
this.loadList();
644646
}
647+
648+
/**
649+
* Returns the extra data associated with a given object name.
650+
* @param {string} objectName The name of the object whose extra data should be retrieved.
651+
* @returns {object | undefined} The extra data associated with the given object name, or undefined if none exists.
652+
*/
653+
getExtraObjectData(objectName) {
654+
return this._extraObjectData[objectName];
655+
}
656+
657+
/**
658+
* Appends extra data to an existing object entry.
659+
* Existing keys are preserved unless overwritten by the provided data. If no data exists, a new entry is created.
660+
* @param {string} objectName The name of the object to which extra data should be appended.
661+
* @param {object} data The extra data to merge into the existing object data.
662+
* @returns {undefined}
663+
*/
664+
appendExtraObjectData(objectName, data) {
665+
this._extraObjectData[objectName] = { ...this._extraObjectData[objectName] ?? {}, ...data };
666+
// debounce notify by 1ms
667+
simpleDebouncer('QCObject.appendExtraObjectData', () => this.notify(), 1);
668+
}
669+
670+
/**
671+
* Sets (overwrites) the extra data for a given object name.
672+
* Any previously stored data for the object is replaced entirely.
673+
* @param {string} objectName The name of the object whose extra data should be set.
674+
* @param {object | undefined} data The extra data to associate with the object.
675+
* @returns {undefined}
676+
*/
677+
setExtraObjectData(objectName, data) {
678+
this._extraObjectData[objectName] = data;
679+
// debounce notify by 1ms
680+
simpleDebouncer('QCObject.setExtraObjectData', () => this.notify(), 1);
681+
}
682+
683+
/**
684+
* Clears all stored extra object data.
685+
* After calling this method, no extra data will be associated with any object name.
686+
* @returns {undefined}
687+
*/
688+
clearAllExtraObjectData() {
689+
this._extraObjectData = {};
690+
this.notify();
691+
}
645692
}

QualityControl/public/object/objectTreePage.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ import virtualTable from './virtualTable.js';
2828
import { defaultRowAttributes, qcObjectInfoPanel } from '../common/object/objectInfoCard.js';
2929
import { downloadButton } from '../common/downloadButton.js';
3030
import { resizableDivider } from '../common/resizableDivider.js';
31+
import { downloadRootImageDropdown } from '../common/downloadRootImageDropdown.js';
3132
import { SortDirectionsEnum } from '../common/enums/columnSort.enum.js';
3233
import { sortableTableHead } from '../common/sortButton.js';
33-
import { downloadRootImageButton } from '../common/downloadRootImageButton.js';
3434

3535
/**
3636
* Shows a page to explore though a tree of objects with a preview on the right if clicked
@@ -120,7 +120,7 @@ const drawPlot = (model, object) => {
120120
: `?page=objectView&objectName=${name}`;
121121
return h('', { style: 'height:100%; display: flex; flex-direction: column' }, [
122122
h('.item-action-row.flex-row.g1.p1', [
123-
downloadRootImageButton(`${name}.png`, root, ['stat']),
123+
downloadRootImageDropdown(name, root, ['stat']),
124124
downloadButton({
125125
href: model.objectViewModel.getDownloadQcdbObjectUrl(id),
126126
title: 'Download root object',

QualityControl/public/pages/objectView/ObjectViewPage.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { dateSelector } from '../../common/object/dateSelector.js';
2020
import { defaultRowAttributes, qcObjectInfoPanel } from '../../common/object/objectInfoCard.js';
2121
import { downloadButton } from '../../common/downloadButton.js';
2222
import { visibilityToggleButton } from '../../common/visibilityButton.js';
23-
import { downloadRootImageButton } from '../../common/downloadRootImageButton.js';
23+
import { downloadRootImageDropdown } from '../../common/downloadRootImageDropdown.js';
2424

2525
/**
2626
* Shows a page to view an object on the whole page
@@ -66,7 +66,7 @@ const objectPlotAndInfo = (objectViewModel) =>
6666
),
6767
),
6868
h('.item-action-row.flex-row.g1.p2', [
69-
downloadRootImageButton(`${qcObject.name}.png`, qcObject.qcObject.root, drawingOptions),
69+
downloadRootImageDropdown(qcObject.name, qcObject.qcObject.root, drawingOptions),
7070
downloadButton({
7171
href: objectViewModel.getDownloadQcdbObjectUrl(qcObject.id),
7272
title: 'Download root object',

0 commit comments

Comments
 (0)