/* globals
Utils
G_Update_Stages_Global
G_VSEM_STAGE
G_VSEM_PAUSED
G_GUI_Controller
G_GUI_PRELOADED_IMGS_SELECTMENU
G_GUI_ADVANCE_MODE_CB
G_GUI_IMG_SMOOTHING_CB
*/
/* exported LayoutManager, G_VSEM_PAUSED */
/**
* Dialog and layout setup with helper functions
* @namespace LayoutManager
*/
const LayoutManager = {
/** The array/list of all the stages. */
Stages: [],
/** The number of stages to create */
stages_count: 9,
/** The calculated size of each box/stage, changes when .Initialize() is called. */
box_size: 300, // default value
/** The minimum size of a stage */
minSize: 200,
/** The maximum size of a stage */
maxSize: 600,
// -----------------------------------------------------
// UI and layout related values
// -----------------------------------------------------
// the class of the element used for jquery dialog objects
_stage_dialog_class: "stage-dlg",
// the class of the entire dialog element
_dialog_parent_class: "stage-dialog",
// the id for the menubar / topbar
_menubar_id: "menubar",
// ids for menubar checkboxes
_cb_hide_options: "cb_hide_options",
_cb_advanced_mode: "cb_advanced_mode",
_cb_use_image_smoothing: "cb_use_image_smoothing",
// the class of a stage's parent element
_stageContainer_class: "stageContainer",
_stages_per_row: 3,
_stages_per_column: 2,
/** a reference to the main body container that holds the boxes/stages. */
_main_container: $('#main-container'),
/** The base z-index to use for cascading dialogs */
_cascade_dialog_base_z_index: 100,
// advanced mode stages start at this index for the stages array
__advanced_dialogs_start: 6,
// -----------------------------------------------------
// URLs
// -----------------------------------------------------
_URLs: {
home: 'https://joedf.github.io/ImgBeamer',
guide: 'https://joedf.github.io/ImgBeamer/misc/ImgBeamer_QS_guide.pdf',
},
// -----------------------------------------------------
/**
* Sets up the stage dialogs and menubar. This only needs to be called only once.
*/
Initialize: function(){
var me = this;
$(document).ready(function(){
me.__onDocReady();
});
// eslint-disable-next-line no-magic-numbers
this.box_size = this.__GetOptimalBoxWidth(true, 30);
this.Stages = this.__SetupStages();
},
__onDocReady: function(){
this.__SetupMenubar();
},
/**
* Sets the title on the dialog window of a given stage.
* @param {object} stage the stage.
* @param {string} title the title to set.
*/
SetDialogTitle: function (stage, title){
let e = stage.getContainer();
let dlgCnt = e.closest('.ui-dialog-content');
if (dlgCnt != null) {
let dlg = $(dlgCnt).dialog();
// set jquery-ui dialog title
dlg.dialog('option', 'title', title);
// support for minimized dialogExtend dialogs
if (typeof dlg.dialogExtend == 'function') {
const dlgExtCntr = $('#dialog-extend-fixed-container');
let dlg_id = dlg.dialog('widget').find('.ui-dialog-title').attr('id');
let dlgExt = dlgExtCntr.find('#'+dlg_id);
if (dlgExt.length) {
dlgExt.text(title);
}
}
}
},
/**
* Adds the given class to the dialog element associated to the given stage.
* @param {*} stage the stage
* @param {string} className the class name
*/
DialogAddClass: function(stage, className){
let dlg = this.GetDialogForStage(stage);
if (dlg.length) {
dlg.addClass(className);
}
},
/**
* Gets the dialog element for the given stage.
* @param {*} stage the stage
* @param {Boolean} inner Specify true to get the inner element that is used for jQuery.dialog().
* @returns The associated dialog element for the given stage.
*/
GetDialogForStage: function(stage, inner=false){
let stageContainer = stage.getContainer();
let dialog = $(stageContainer).closest("." + this._dialog_parent_class);
if (inner) {
dialog = dialog.find('.ui-dialog-content');
}
return dialog;
},
/**
* Restores dialogs.
* All dialogs by default, otherwise for a given range.
* @param {integer} start the dialog to start tiling with.
* @param {integer} end the last dialog to tile.
*/
RestoreDialogs: function(start=0, end=null){
let dialogs = $("."+this._stage_dialog_class);
let last = (end==null) ? dialogs.length : Utils.clampValue(end, 0, dialogs.length);
for (let i = start; i < last; i++) {
dialogs.eq(i).dialogExtend("restore");
}
},
/**
* Positions dialogs in a tiled layout.
* All dialogs by default, otherwise for a given range.
* @param {integer} tile_start the dialog to start tiling with.
* @param {integer} tile_end the last dialog to tile.
*/
TileDialogs: function(tile_start=0, tile_end=null){
let g_dlg_selector = '.' + this._stage_dialog_class;
let dialogs = $(g_dlg_selector).dialog();
tile_end = (tile_end==null) ? dialogs.length : tile_end;
let parentPos = this._main_container.position();
let start_x = parentPos.left;
let start_y = parentPos.top;
// position first one
$(g_dlg_selector).dialog('widget').eq(tile_start).css({top:start_y, left:start_x});
// position the rest
for (let i = tile_start + 1; i < tile_end; i++) {
const dialog = dialogs.eq(i);
const prev = dialogs.eq(i-1).dialog('widget');
if (i % this._stages_per_row == 0) {
const prev = dialogs.eq(i-this._stages_per_row).dialog('widget');
var eDialog = prev.get(0);
var newPos = {
x: parseInt(prev.css('left')),
y: eDialog.offsetHeight + parseInt(prev.css('top')),
};
dialog.dialog('widget').css({top:newPos.y, left:newPos.x});
} else {
dialog.dialog({position: {my:"left top", at:"right top", of:$(prev)}});
}
}
},
/**
* Positions dialogs in a cascaded layout from the top right to the bottom left.
* All dialogs by default, otherwise for a given range.
* @param {integer} cascade_start the dialog to start cascading with.
* @param {integer} cascade_end the last dialog to cascade.
* @param {Number} offset_x the distance between each cascaded dialog in x.
* @param {Number} offset_y the distance between each cascaded dialog in y.
*/
// eslint-disable-next-line no-magic-numbers
CascadeDialogs: function(cascade_start=0, cascade_end=null, offset_x=100, offset_y=80){
// cascade the last n dialogs
let g_dlg_selector = '.' + this._stage_dialog_class;
let dialogs = $(g_dlg_selector).dialog();
cascade_end = (cascade_end==null) ? dialogs.length : cascade_end;
// position first one
let first = $(g_dlg_selector).eq(cascade_start).dialog({
position: {
my: "right top",
at: "right top",
of: this._main_container
}
});
let firstPos = first.position();
// position the rest
for (let i = cascade_start + 1; i < cascade_end; i++) {
const dialog = dialogs.eq(i);
const prev = dialogs.eq(i-1).dialog('widget');
const nDiaglog = i - cascade_start;
dialog.dialog('widget').css({
top: offset_y*nDiaglog + firstPos.top,
right: offset_x*nDiaglog + firstPos.left,
left: 'auto',
'z-index': parseInt(prev.css('z-index'))+this._cascade_dialog_base_z_index + 1
});
}
},
/**
*
* Calculated the size to use for each drawing box/stage.
* Edit the values in the functions to change the box sizing.
* @param {Boolean} considerViewportHeight Whether or not the box height can be reduced to fit the client area.
* @param {Number} vpFootOffset The amount of space to further offset at the bottom of the client area.
* @param {Number} titlebarHeight The height of a dialog's titlebar (used for calculations).
* @returns The size to use.
*/
// eslint-disable-next-line no-magic-numbers
__GetOptimalBoxWidth: function(considerViewportHeight=false, vpFootOffset=0, titlebarHeight=30){
// Values used to calculate the size of each box/stage
let boxesPerPageWidth = this._stages_per_row;
// count-in the width of the borders of the boxes
let boxBorderW = 2 * (parseInt($('.box:first').css('border-width')) || 1);
let scrollBarW = 15; // scroll bar width
let boxSizeMax = this.maxSize - titlebarHeight; //max width for the boxes
// calculate the box width based on how many we want per page-width
let calculatedBoxSize = Math.min(
(document.body.clientWidth / boxesPerPageWidth) - boxBorderW - scrollBarW,
boxSizeMax);
// optionally limit size further to fit at least n rows of boxes
if (considerViewportHeight) {
let boxMaxH = Math.floor((window.innerHeight / this._stages_per_column) - titlebarHeight);
calculatedBoxSize = Math.min(calculatedBoxSize, boxMaxH);
// further, reduce if we want a larger foot margin space
calculatedBoxSize = calculatedBoxSize - (vpFootOffset / this._stages_per_column);
}
// make sure to have an integer value to prevent slight sizing differences between each box
calculatedBoxSize = Math.ceil(calculatedBoxSize);
// bound/clamp the number to the size limits
calculatedBoxSize = Utils.clampValue(calculatedBoxSize, this.minSize, this.maxSize);
return calculatedBoxSize;
},
/**
* Determines if a given stage is visible to the user (as defined by
* https://api.jquery.com/visible-selector/)
* @param {*} stage the (konva) stage to test visibility
* @returns true if visible, false otherwise
*/
IsStageVisible: function(stage) {
let container = stage.getContainer();
let visible = $(container).is(':visible');
return visible;
},
/**
* Prints whether each stage is visible or not to the user in the web console.
* Useful for debugging.
*/
__visible_stages: function() {
let stages = this.Stages;
for(let i = 0; i < stages.length; i++) {
let vis = this.IsStageVisible(stages[i]);
console.log("stage #"+i+" visibility = " + vis);
}
},
/**
* [Private] Creates a DOM element to be used for a stage dialog.
* @param {*} parentContainer the DOM element of the parent container in which to add a stage dialog.
* @param {*} startMinimized Whether or not the dialog should start minimized.
*/
__newStageDialog: function(parentContainer, startMinimized = false){
$('<div class="' + this._stage_dialog_class + '"/>')
.attr('dlg-start-minimized', startMinimized)
.appendTo(parentContainer)
.append('<div class="' + this._stageContainer_class + '"/>');
},
// eslint-disable-next-line no-unused-vars
__ShouldStageDialogStartMinimized: function(i){
// eslint-disable-next-line no-magic-numbers
// return (i >= this.__advanced_dialogs_start);
// Start none as minimized for now
// see https://github.com/joedf/ImgBeamer/issues/53#issuecomment-2776922914
return false;
},
__SetupDialogs: function(stages_count){
var me = this;
let g_dlg_selector = '.' + this._stage_dialog_class;
let parentContainer = this._main_container;
let parentTop = parentContainer.position().top;
var _em_ = 12.96; //px
var _border_w_ = (2/3);
// eslint-disable-next-line no-magic-numbers
var _titlebar_h_offset_ = 15 + 0.2*_em_ + 0.3*_em_ + 2*_border_w_;
let drag_snap = {
// eslint-disable-next-line no-magic-numbers
x: (me.box_size/10) + 0.2,
// eslint-disable-next-line no-magic-numbers
y: (me.box_size + _titlebar_h_offset_ + parentTop) / 10,
};
// create the containers for the dialogs
for (let i = 0; i < stages_count; i++) {
let startMinimized = this.__ShouldStageDialogStartMinimized(i);
this.__newStageDialog(parentContainer, startMinimized);
}
// transform them into jquery-ui extented dialogs with options
$(g_dlg_selector).dialog({
maxHeight: me.maxSize,
maxWidth: me.maxSize,
minHeight: me.minSize,
minWidth: me.minSize,
width: me.box_size,
height: me.box_size + _titlebar_h_offset_,
resizable: false,
classes: { "ui-dialog": me._dialog_parent_class },
drag: function( event, ui ) {
// https://stackoverflow.com/a/20712561/883015
var snapTolerance = drag_snap.x;
var grid = {
x: drag_snap.x,
y: drag_snap.y,
};
var topRemainder = ui.position.top % grid.y;
var leftRemainder = ui.position.left % grid.x;
if (topRemainder <= snapTolerance) {
let newTop = ui.position.top - topRemainder;
// dont allow position height than the parent container
ui.position.top = Math.max(newTop, parentTop);
}
if (leftRemainder <= snapTolerance) {
ui.position.left = ui.position.left - leftRemainder;
}
},
resize: function( event, ui ) {
// https://stackoverflow.com/a/20712561/883015
var snapTolerance = 80;
var grid = {
x: 20,
y: 20,
};
var widthRemainder = ui.size.width % grid.x;
var heightRemainder = ui.size.height % grid.y;
if (widthRemainder <= snapTolerance) {
ui.size.width = ui.size.width - widthRemainder;
}
if (heightRemainder <= snapTolerance) {
ui.size.height = ui.size.height - heightRemainder;
}
}
}).dialogExtend({
"closable" : false,
"maximizable" : false,
"minimizable" : true,
"collapsable" : true,
"dblclick" : "collapse",
"minimizeLocation" : "right",
"icons": {
"collapse": "ui-icon-arrowthickstop-1-n"
},
"load": function(){
var e = $(this);
if (e.attr('dlg-start-minimized') == 'true') {
e.dialogExtend('minimize');
}
},
"restore": me.__onDialogShow.bind(me),
"maximize": me.__onDialogShow.bind(me),
"collapse": me.__onDialogHide.bind(me),
"minimize": me.__onDialogHide.bind(me),
});
// Contain the dialog within the parent container
$("."+this._dialog_parent_class).draggable( "option", "containment", parentContainer );
},
__onDialogShow: function(evt) {
// called when a dialog has been restored or maximized,
// i.e. content is made visible to the user
if (typeof G_Update_Stages_Global == 'function') {
G_Update_Stages_Global();
}
// check if we should "un-pause" / resume the VSEM stage rendering
if (G_VSEM_STAGE != null && typeof G_GUI_Controller != 'undefined')
{
let isVSemStageDialog = this.__isDialogForStage(evt.target, G_VSEM_STAGE);
if (isVSemStageDialog) {
// if the dialog is the VSEM stage, then we can restore the rendering / drawing flag
// Yes, the user could potentially unpause while it was collapsed, but that is not critical
// since collapse/minimize, restore the value from the GUI checkbox
// eslint-disable-next-line no-global-assign
G_VSEM_PAUSED = G_GUI_Controller.pause_vSEM;
}
}
},
__onDialogHide: function(evt) {
// called when a dialog has been collapse or minimized,
// i.e. content is being made hidden to the user
if (G_VSEM_STAGE != null) {
let isVSemStageDialog = this.__isDialogForStage(evt.target, G_VSEM_STAGE);
if (isVSemStageDialog) {
// if the dialog is the VSEM stage, then we can pause the rendering / drawing
// we explicitly pause the VSEM rendering, whether it was already paused or not
// eslint-disable-next-line no-global-assign
G_VSEM_PAUSED = true;
}
}
},
/**
* Determines whether the given dialog is associated with the given stage.
* @param {*} dialogElement The dialog.
* @param {*} stage The stage.
* @returns True if they are associated, false otherwise.
*/
__isDialogForStage: function(dialogElement, stage) {
let $dialog = $(dialogElement);
let dlg_stageContainer = $dialog.find('.stageContainer > .box:first').get(0);
let stageContainer = stage.getContainer();
let isMatch = (stageContainer == dlg_stageContainer);
return isMatch;
},
__SetupStages: function(layoutDialogs=true){
// first, set up the dialogs
this.__SetupDialogs(this.stages_count);
// optionally, tile the dialogs
if (layoutDialogs) {
this.TileDialogs(0, this.__advanced_dialogs_start);
this.CascadeDialogs(this.__advanced_dialogs_start, this.stages_count);
}
// then create the stages and plug them in
let stages = [];
let g_stage_containers = $('.'+this._stageContainer_class);
for (let i = 0; i < this.stages_count; i++) {
let stage = Utils.newStageTemplate(g_stage_containers[i], this.box_size, this.box_size);
stages.push(stage);
}
return stages;
},
__SetupMenubar: function(){
let el = $('#'+this._menubar_id);
let gui = G_GUI_Controller;
var me = this;
el.menu('option', 'select', function(event){
// console.log(event);
let target = event.currentTarget;
let textraw = target.innerText;
let text = textraw.toLocaleLowerCase();
let keyword = text.split(' ')[0];
let data_filename = $(target).attr('data-image-filename');
let data_pixelsize = parseFloat($(target).attr('data-pixelsize-nm'));
// detect if a preloaded image menu item was clicked
if (typeof data_filename == 'string') { keyword = '__preloaded'; }
// prevent handling erroneous drag-clicking
if (keyword == '__preloaded' && textraw.indexOf('\n')>=0) {
return;
}
switch(keyword){
case 'import':
gui.importImage();
break;
case '__preloaded': //preloaded images
// set the selected preloaded image, by using the options GUI controller
// simulates as if the user click that option, so other things trigger correctly
G_GUI_PRELOADED_IMGS_SELECTMENU.setValue(data_filename);
// attempt to set pixel size accordingly if possible
if (!isNaN(data_pixelsize)) {
Utils.setPixelSizeNmInput(data_pixelsize);
console.log('Changed pixel size to '+data_pixelsize+' nm');
}
break;
case 'save':
if (text.indexOf('actual') >= 0) {
gui.exportResultTrueImage();
} else{
gui.exportResultImg();
}
break;
case 'advanced': //mode
// get the check state in the menubar
var _cbchk_am = $('#'+me._cb_advanced_mode).is(':checked');
G_GUI_ADVANCE_MODE_CB.setValue(_cbchk_am);
break;
case 'use': //image smoothing
// get the check state in the menubar
var _cbchk_is = $('#'+me._cb_use_image_smoothing).is(':checked');
G_GUI_IMG_SMOOTHING_CB.setValue(_cbchk_is);
break;
case 'tile': // displays/dialogs
// Ensure no dialog is collapsed before tiling, otherwise it won't work right...
me.RestoreDialogs(0, me.__advanced_dialogs_start);
me.TileDialogs(0, me.__advanced_dialogs_start);
break;
case 'homepage':
window.open(me._URLs.home,'_blank').focus();
break;
case 'quick-start':
window.open(me._URLs.guide,'_blank').focus();
break;
case 'hide': // Hide Options
var _cbchk_ho = $('#'+me._cb_hide_options).is(':checked');
$('#options-anchor').toggle(!_cbchk_ho);
break;
case 'about':
gui.aboutMessage();
break;
case 'quit':
// doesnt work for user-opened origin pages
// window.close();
// instead replace page (in history) with repo page for now
window.location.replace(me._URLs.home);
break;
}
});
// don't overwrite the focus handler from jquery-ui-menubar
let _onFocus = el.menu('option', 'focus').bind(el);
// we want to add to it
el.menu('option', 'focus', function(event, ui){
// call original handler first
// which positions submenus correctly
_onFocus(event, ui);
// ensure advanced mode checkbox is updated
var __checked_am = G_GUI_ADVANCE_MODE_CB.getValue();
$('#'+me._cb_advanced_mode).prop('checked', __checked_am);
// ensure image smoothing checkbox is updated
var __checked_is = G_GUI_IMG_SMOOTHING_CB.getValue();
$('#'+me._cb_use_image_smoothing).prop('checked', __checked_is);
});
},
};