/**
* Manages the translation of art from PSD to the stage.
* <br>-Handles scaling, screen density, resize events and layout projection.
* <br>There are two units used to measure distance information:
* <br>- `pixels` are the physical pixels on screen. This unit will be double for a 2x retina screen.
* <br>- `points` are screen density independent units, that match CSS units.
* <br>Artboards represent the PSD (art) document dimensions.
* <br>- When a layout is translated from Photoshop to the screen, positions are calculated based on artboard projections, set via `configureArtboard()` method.
* @module scaler
*/
import { pixiApp, utils, mutils, nav, htmlEle} from './../storymode.js';
import fscreen from 'fscreen';
let proj; // Artboard projections
/**
* The stage width, in points.
* @type {number}
* @readonly
*/
let stageW = 0;
/**
* The stage height, in points.
* @type {number}
* @readonly
*/
let stageH = 0;
/**
* Stores previous proj, stageW, stageH
* @type {Object}
* @readonly
* @private
*/
let prev; //
/**
* Default projection scale factor. Convenience alias of `scaler.proj.default.scale`.
* <br>- These properties are used to convert from PSD pts to layout pts.
* @type {number}
* @readonly
* @example
let tenPt = scaler.scale * 10.0*0.5; // Convert 10px in a retina PSD to screen points
*/
let scale; // Alias of scaler.proj.default.scale
/**
* UI projection scale factor. Convenience alias of `scaler.proj.ui.scale`.
* @type {number}
* @readonly
*/
let scaleUI; // Alias of scaler.proj.ui.scale
//let ats; // Screen to art factor - for dimension or relative position. Refers to **default** proj.
//let sta; // Art to screen factor - for dimension or relative position. Refers to **default** proj.
//let atsUI; // Screen to art factor - for dimension or relative position. Alias of uiScaleFactor. Refers to **ui** proj.
//let staUI; // Art to screen factor - for dimension or relative position. Refers to **ui** proj.
// Defaults
export let artboardDims = {width: Math.round(756.0*0.5), height:Math.round(1334.0*0.5)}; // PSD dimensions
let artboardScaleFactor = 2.0; // How many px in a pt in art
let artboardProjectionParams = {
default: {
alignment: {x:0, y:0}, // -1 (left/top) 0:(centered) 1:(right/bottom)
scaleToFit: 'contain', // `w` / `h` / `contain` / 'cover' (case insensitive).
minDensity: 1.0 // Limits up scaling. Eg. 1.0 will scale no larger than SD on retina.
},
ui: {
matchProjScale: 'default', // Match the scale of other projection before applying own limits
pinToProj: 'default', // Other projection will be used to position
minScale: 1, // Lock scale to no smaller than pts match with art.
maxScale: 1.2 // Avoid oversized UI elements
}
};
/**
* Informs projection of scale required for a given stage dimension.
* @callback module:scaler#ScaleFunction
* @function
* @param {number} stageWidth
* @param {number} stageHeight
*/
/**
* Represents the core paramters of a projection.
* @name module:scaler#ProjectionConfig
* @function
* @param {Vector} alignment - Value pair for horizontal and vertical alignment.. <br>-1: (left/top) <br>0: (centered) <br>1: (right/bottom).<br>Eg. {x:0,y:0} for centered.
* @param {module:scaler#ScaleFunction} [scaleFunction=null] - Will call a function at runtime to calculate the scale for the projection for a given width and height.
* @param {'w'|'h'|'contain'|'cover'} [scaleToFit='contain'] - How the artboard will be scaled relative to the stage. <br>- 'w': Match stage width.<br>- 'h': Match stage height.<br>- 'contain': Scale to fit inside stage.<br>- 'cover': Scale to fill entire stage.
* @param {string} [matchProjScale=null] - A projection slug that will be used as the starting point for the projection settings.
* @param {number} [minDensity=1.0] - Limits up scaling. Eg. 1.0 will scale no larger than SD on retina.
* @param {string} [pinToProj=null] - Projection slug to be used for positioning
* @param {number} [minScale=null] - Minimum scale. `1.0` will match PSD (accounting for retina).
* @param {number} [maxScale=null] - Maximum scale.
*/
/**
* Configures how layouts will be translated to the screen.
* <br>- To be called before {@link storymode#createApp}
* @param {Object} artboardDims - Defines the width and height of all PSD docs, in pts. Note: All PSD docs need to have the same dimensions.
* @param {number} artboardDims.width
* @param {number} artboardDims.height
* @param {number} artboardScaleFactor - The density of the artboard. Eg. If the PSD is x2 retina, then this will be set to 2.0.
* @param {Object.<string, module:scaler#ProjectionConfig>} artboardProjectionParams - Used to create the artboard projections. Both `default` and `ui` should be present.
* @example
const artboardDims = {width: Math.round(1080.0*0.5), height:Math.round(1920.0*0.5)};
const artboardScaleFactor = 2.0; // How many px in a pt in art
const artboardProjectionParams = {
default: {
alignment: {x:0, y:0}, // -1 (left/top) 0:(centered) 1:(right/bottom)
scaleToFit: 'contain', // `w` / `h` / `contain` / 'cover' (case insensitive).
minDensity: 1.0 // Limits up scaling. Eg. 1.0 will scale no larger than SD on retina.
},
ui: {
matchProjScale: 'default', // Match the scale of other projection before applying own limits
pinToProj: 'default', // Other projection will be used to position
minScale: 1, // Lock scale to no smaller than pts match with art.
maxScale: 1.2 // Avoid oversized UI elements
}
};
scaler.configureArtboard(artboardDims, artboardScaleFactor, artboardProjectionParams);
*/
export function configureArtboard(_artboardDims, _artboardScaleFactor, _artboardProjectionParams){
if (_artboardDims){
artboardDims = _artboardDims;
}
if (_artboardScaleFactor){
artboardScaleFactor = _artboardScaleFactor;
}
if (_artboardProjectionParams){
artboardProjectionParams = _artboardProjectionParams;
}
}
/**
* Called once on init.
* @private
*/
function init(){ // Called once on init for now
// Points to pixel conversion factors
initResizeListener();
onResizeThrottled(true);
initFullScreenListener();
}
// Artboard projection class
// -------------------------
/**
* Represents a projection from the PSD Artboard to the screen.
* @hideconstructor
*/
class ArtboardProjection {
constructor(alignment = null, scaleFn = null, scaleToFit = null, matchProjScale = null, pinToProj = null, stretchPosMode = false, minDensity = null, minScale = null, maxScale = null){
/**
* Will distribute position throughout stage based relatively to art board position.
* @readonly
* @private
* @type {boolean}
*/
this.stretchPosMode = stretchPosMode;
// Determine scale
/**
* The scale applied to display objects.
* @readonly
* @private
* @type {number}
*/
this.scale;
if (scaleFn !== null){
this.scale = scaleFn(stageW, stageH);
} else if (scaleToFit !== null){
scaleToFit = scaleToFit.toLowerCase();
// `w` / `h` / `contain` / 'cover' (case insensitive).
if (scaleToFit == 'contain'){
// Make entire artboard visible to up to stage bounds, will use letterboxing
this.scale = mutils.containScale(artboardDims.width, artboardDims.height, stageW, stageH);
} else if (scaleToFit == 'cover'){
// Cover stage bounds entirely, clipping tops or bottoms as necessary
this.scale = mutils.coverScale(artboardDims.width, artboardDims.height, stageW, stageH);
} else if (scaleToFit == 'w'){
this.scale = stageW/artboardDims.width;
} else if (scaleToFit == 'h'){
this.scale = stageH/artboardDims.height;
}
} else if (matchProjScale){
// Get scale from another projection
if (matchProjScale && !proj[matchProjScale]){
throw new Error('Scale match projection not found `'+matchProjScale+'`. Check declaration order.')
}
this.scale = proj[matchProjScale].scale;
}
// Apply scale limits
if (minDensity !== null){
// Limits up scaling. Eg. 1.0 will scale no larger than SD on retina.
this.scale = this.scaleForPxDensity(Math.max(this.pxDensity, minDensity))
}
if (minScale !== null){
this.scale = Math.max(this.scale, minScale)
}
if (maxScale !== null){
this.scale = Math.min(this.scale, maxScale)
}
/**
* The scale used to position in top level only.
* @readonly
* @private
* @type {number}
*/
this.positionScale = this.scale;
/// Translate alignment to top left coords
/**
* Top left coordinates of the artboard on the stage.
* @readonly
* @private
* @type {number}
*/
this.topLeft = {x:0.0, y:0.0};
if (alignment !== null){
if (alignment.x == -1){
this.topLeft.x = 0.0;
} else if (alignment.x == 0){
this.topLeft.x = Math.round(stageW*0.5-this.scale*artboardDims.width*0.5);
} else if (alignment.x == 1){
this.topLeft.x = Math.round(stageW-this.scale*artboardDims.width);
}
if (alignment.y == -1){
this.topLeft.y = 0.0;
} else if (alignment.y == 0){
this.topLeft.y = Math.round(stageH*0.5-this.scale*artboardDims.height*0.5);
} else if (alignment.y == 1){
this.topLeft.y = Math.round(stageH-this.scale*artboardDims.height);
}
} else if (pinToProj){
if (pinToProj && !proj[pinToProj]){
throw new Error('Pin projection not found `'+pinToProj+'`. Check declaration order.')
}
this.topLeft = {x:proj[pinToProj].topLeft.x, y:proj[pinToProj].topLeft.y};
this.positionScale = proj[pinToProj].positionScale;
}
this.sta = this.scale; // Screen to art factor - for dimension or relative position. Alias of scale.
this.ats = 1.0/this.scale; // Art to screen factor - for dimension or relative position
}
/**
* Pixel scaling based on this.scale. Eg. If this.scale is 0.5 on @2 retina then will return 1.0
* @readonly
* @type {number}
*/
get pxScale() {
return window.devicePixelRatio*(this.scale/artboardScaleFactor);
}
/**
* Screen density based on this.scale. Eg. 2 for @2 retina.
* @readonly
* @type {number}
*/
get pxDensity(){
return (1.0/(window.devicePixelRatio*(this.scale/artboardScaleFactor)))*window.devicePixelRatio;
}
/**
* Scale for given pixel density.
* @returns {number} scale
*/
scaleForPxDensity(_pxDensity){
return ((1.0/(_pxDensity/window.devicePixelRatio))/window.devicePixelRatio)*artboardScaleFactor;
}
/**
* Translate art board relative pts to screen position (in pts).
* @param {number} x - Artboard x position (in PSD pts).
*/
transArtX(x){
if (this.stretchPosMode){
return this.topLeft.x + (x/artboardDims.width)*stageW;
}
return this.topLeft.x + this.positionScale*x;
}
/**
* Translate art board relative pts to screen position (in pts).
* @param {number} y - Artboard y position (in PSD pts).
*/
transArtY(y){
if (this.stretchPosMode){
return this.topLeft.y + (y/artboardDims.width)*stageH;
}
return this.topLeft.y + this.positionScale*y;
}
/**
* Translate screen position (in pts) to art board relative pts.
* <br>- Opposite of `transArtY()`.
* @param {number} screenX - Screen x position (in pts).
*/
transScreenX(screenX){
if (this.stretchPosMode){
return ((screenX - this.topLeft.x)/stageW)*artboardDims.width;
}
return (screenX - this.topLeft.x)/this.positionScale
}
/**
* Translate screen position (in pts) to art board relative pts.
* <br>- Opposite of `transArtX()`.
* @param {number} screenY - Screen y position (in pts).
*/
transScreenY(screenY){
if (this.stretchPosMode){
return ((screenY - this.topLeft.y)/stageH)*artboardDims.height;
}
return (screenY - this.topLeft.y)/this.positionScale
}
}
// Resize listener
// ---------------
let emitter;
const resizeThrottleDelay = 0.2;
/**
* Sets up stage resizing functionality.
* @private
*/
function initResizeListener(){
// https://nodejs.org/api/events.html
// https://github.com/primus/eventemitter3
emitter = new PIXI.utils.EventEmitter();
pixiApp.renderer.on('resize', onResizeImmediate); // Listen for stage events
}
/**
* Receives immediate notification of the stage being resized.
* @private
*/
function onResizeImmediate(){
utils.killWaitsFor(onResizeThrottled)
if (!pixiApp || !pixiApp.renderer){
return;
}
let _stageW = pixiApp.renderer.view.width/window.devicePixelRatio;
let _stageH = pixiApp.renderer.view.height/window.devicePixelRatio;
/**
* Called when stage is resized, though the call is not debounced.
* @event module:scaler#resize_immediate
* @property {number} stageWidth - Stage width (in pts).
* @property {number} stageHeight - Stage height (in pts).
*/
emitter.emit('resize_immediate', _stageW, _stageH);
utils.wait(resizeThrottleDelay, onResizeThrottled)
}
/**
* Receives throttled (ie. debounce) notification of the stage being resized.
* <br>- Triggers stage resize logic.
* @private
*/
function onResizeThrottled(force = false){
if (!pixiApp || !pixiApp.renderer){
return;
}
let _stageW = pixiApp.renderer.view.width/window.devicePixelRatio;
let _stageH = pixiApp.renderer.view.height/window.devicePixelRatio;
if (!force && _stageW == stageW && _stageH == stageH){
return;
}
// Save previous
if (proj){
if (prev && prev.proj){
for (let projID in prev.proj){
prev.proj[projID] = null;
delete prev.proj[projID];
}
}
prev = {};
prev.proj = proj;
prev.stageW = stageW;
prev.stageW = stageH;
prev.scale = scale;
prev.scaleUI = scaleUI;
}
stageW = _stageW;
stageH = _stageH;
proj = {};
for (let projectionSlug in artboardProjectionParams){
let params = artboardProjectionParams[projectionSlug];
proj[projectionSlug] = new ArtboardProjection(params.alignment, params.scaleFn, params.scaleToFit, params.matchProjScale, params.pinToProj, params.stretchPosMode, params.minDensity, params.minScale, params.maxScale);
}
scale = proj.default.scale;
scaleUI = proj.ui.scale;
/**
* Called when stage is resized. The call frequency of method is debounced (throttled).
* <br>- This call is made *after* `scaler.stageW` and `scaler.stageH` properties have been updated.
* @event module:scaler#resize
* @property {number} stageWidth - Stage width (in pts).
* @property {number} stageHeight - Stage height (in pts).
* @example
scaler.on('resize', this.onStageResize, this);
function onStageResize(stageW, stageH){
// ...
}
scaler.off('resize', this.onStageResize, this); // Cancel
*/
emitter.emit('resize', stageW, stageH);
}
// https://nodejs.org/api/events.html
// https://github.com/primus/eventemitter3
/**
* Listen for event.
* <br>- See {@link https://nodejs.org/api/events.html#eventsonemitter-eventname-options}.
* @param {string} eventName - The event identifier.
* @param {Function} listener - The function to call.
* @param {Object} context - The scope in which to call the function (ie. defines `this`).
* @example
scaler.on('resize', this.onStageResize, this); // 3rd arg is function scope
*/
function on(eventName, listener, context){
return emitter.on(eventName, listener, context);
}
/**
* Cancel an event listener.
* @param {string} eventName - The event identifier.
* @param {Function} listener - The function to call.
* @param {Object} context - The scope in which to call the function.
*/
function off(eventName, listener, context){
return emitter.off(eventName, listener, context);
}
/**
* Removes all event listeners.
* @private
*/
function removeAllListeners(){
if (!emitter){
return true;
}
return emitter.removeAllListeners();
}
// Fullscreen
// ----------
const FULLBROWSER_ENABLED = true;
/**
* Sets up fullscreen functionality.
* @private
*/
function initFullScreenListener(){
if (!supportsFullScreen()){
return;
}
fscreen.addEventListener('fullscreenchange', onFullscreenChange);
}
/**
* Called internally when app toggles fullscreen state.
* @private
*/
function onFullscreenChange(){
const _isFullscreen = isFullScreen()
if (_isFullscreen){
utils.addClass(htmlEle, 'fullscreen'); // class is added so pixi div can take over browser
} else {
utils.removeClass(htmlEle, 'fullscreen')
}
/**
* Called when fullscreen mode changes.
* @event module:scaler#fullscreenchange
* @property {boolean} isFullScreen - Whether the app is now being displayed in full-screen (or full-browser).
* @example
scaler.on('fullscreenchange', this.syncState, this); // 3rd arg is function scope
dispose(){
scaler.off('fullscreenchange', this.syncState, this);
}
*/
emitter.emit('fullscreenchange', _isFullscreen);
}
/**
* Indicates whether the current browser supports full screen functionality.
* <br>- This includes fullbrowser support (if enabled).
* @returns {boolean} fullscreenSupported
*/
function supportsFullScreen(){
return _supportsActualFullScreen() || _supportsFullBrowser();
}
/**
* Indicates whether the current browser supports full-browser functionality.
* @private
* @returns {boolean} fullBrowserSupported
*/
function _supportsFullBrowser(){
if (!FULLBROWSER_ENABLED){
return false;
}
return pixiApp.resizeTo !== window;
}
/**
* Indicates whether the current browser supports full-screen.
* @private
*/
function _supportsActualFullScreen(){
return fscreen.fullscreenEnabled;
}
/**
* Indicates whether the app is currently being presented full-screen (or full-browser).
* @returns {boolean} isFullScreen
*/
function isFullScreen(){
if (!supportsFullScreen()){
return false;
}
if (_supportsActualFullScreen()){
return Boolean(fscreen.fullscreenElement);
}
// Full browser:
return utils.hasClass(htmlEle, 'fullscreen')
}
/**
* Toggles full-screen / full-browser presentation mode.
* <br>- The CSS class `fullscreen` will be added to the containing element when fullscreen is applied.
* @param {string} {string|DOMElement} [resizeTarget=null] - The element to resize. Ignored for full-browser. Optionally the HTML DOMElement id can be supplied as a string or use `cv` to target the PIXI canvas. Will default to the html tag of the page.
*/
function toggleFullScreen(ele = null, forceState = null){
if (!supportsFullScreen()){
return;
} else if (!_supportsActualFullScreen()){
// Toggle full browser without actually going full screen
let _isFullscreen;
if (utils.hasClass(htmlEle, 'fullscreen')){
_isFullscreen = true;
} else {
_isFullscreen = false;
}
let _state = (forceState === true || forceState === false) ? forceState : !_isFullscreen;
if (_state == _isFullscreen){
return
}
if (_state){
utils.addClass(htmlEle, 'fullscreen')
} else {
utils.removeClass(htmlEle, 'fullscreen')
}
emitter.emit('fullscreenchange', _state); // Simulate this event
pixiApp.resize();
return;
}
if (typeof ele == 'string'){
if (ele == 'cv'){ // Canvas shorthand
ele = pixiApp.view;
} else {
ele = utils.e(ele.split('#').join('')); // Id is assumed if string
}
}
if (!ele){
ele = document.body.parentNode; // html * as default
}
let isFS = isFullScreen();
let state = (forceState === true || forceState === false) ? forceState : !isFS;
if (state == isFS || !ele){ // No change or ele not found
return;
}
if (state){ // Full screen
// this.fsPrevResizeTo = pixiApp.resizeTo
// pixiApp.resizeTo = window
fscreen.requestFullscreen(ele);
} else {
//pixiApp.resizeTo = this.fsPrevResizeTo
fscreen.exitFullscreen();
}
}
/**
* Called by `storymode.destroy()`.
* @param {boolean} reset - If true then will be able to be used again after calling `scaler.init()`
* @private
*/
function destroy(reset){
fscreen.removeEventListener('fullscreenchange', onFullscreenChange);
removeAllListeners();
prev = null;
if (!reset){
emitter = null;
}
}
// export {pixiApp}
export {toggleFullScreen, isFullScreen, supportsFullScreen}
export {scale, scaleUI, prev, artboardScaleFactor}
export {init, proj, stageW, stageH, on, off, resizeThrottleDelay, destroy}