diff --git a/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js index 9fa0250..a2da831 100644 --- a/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js +++ b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js @@ -101,8 +101,14 @@ define( stage._pinnedElements.push(record); - // Immediate sync: correctly placed even without a running tick. - Pinner._syncRecord(stage, record); + // Immediate placement: if the stage is currently suppressed + // (e.g. a modal is open) the new pin starts parked; otherwise + // sync it into place even without a running tick. + if (stage._pinsHidden) { + Pinner._park(record); + } else { + Pinner._syncRecord(stage, record); + } // Reparenting doesn't itself mark the RootView dirty; under // dirtyRendering:true this ensures the pin paints next frame @@ -126,6 +132,107 @@ define( } }, + // ---- Modal suppression ------------------------------------- + // suppress(obj) registers a display object (typically a modal + // view's container). While ANY registered object is attached to a + // stage, that stage's pins are parked at their anchors. State is + // evaluated on the shared Ticker (not the RootView sync) so it + // works even when an older easelbone copy owns the render loop; + // the outcome is materialized into stage._pinsHidden, which IS + // the cross-bundle protocol. + + _suppressors: [], + _suppressedStages: [], + _suppressTickerHandle: null, + + suppress: function (obj) { + for (var i = 0; i < Pinner._suppressors.length; i++) { + if (Pinner._suppressors[i].obj === obj) { + return; + } + } + Pinner._suppressors.push({ obj: obj, wasAttached: !!obj.stage, attempts: 0 }); + Pinner._evaluateSuppression(); + if (!Pinner._suppressTickerHandle && Pinner._suppressors.length) { + Pinner._suppressTickerHandle = createjs.Ticker.on('tick', function () { + Pinner._evaluateSuppression(); + }); + } + }, + + release: function (obj) { + for (var i = 0; i < Pinner._suppressors.length; i++) { + if (Pinner._suppressors[i].obj === obj) { + Pinner._suppressors.splice(i, 1); + break; + } + } + Pinner._evaluateSuppression(); + }, + + _evaluateSuppression: function () { + var i; + var stages = []; + var suppressors = Pinner._suppressors; + + for (i = suppressors.length - 1; i >= 0; i--) { + var entry = suppressors[i]; + var stage = entry.obj.stage; + if (stage) { + entry.wasAttached = true; + if (stages.indexOf(stage) === -1) { + stages.push(stage); + } + } else if (entry.wasAttached) { + // Modal left the stage: suppression over, one-shot. + suppressors.splice(i, 1); + } else if (++entry.attempts >= 300) { + // Never attached within ~5s: give up (same grace as + // _deferPin) so a never-shown modal can't leak. + suppressors.splice(i, 1); + } + } + + for (i = 0; i < stages.length; i++) { + if (Pinner._suppressedStages.indexOf(stages[i]) === -1) { + Pinner._setSuppressed(stages[i], true); + Pinner._suppressedStages.push(stages[i]); + } + } + for (i = Pinner._suppressedStages.length - 1; i >= 0; i--) { + if (stages.indexOf(Pinner._suppressedStages[i]) === -1) { + Pinner._setSuppressed(Pinner._suppressedStages[i], false); + Pinner._suppressedStages.splice(i, 1); + } + } + + if (!suppressors.length && !Pinner._suppressedStages.length && Pinner._suppressTickerHandle) { + createjs.Ticker.off('tick', Pinner._suppressTickerHandle); + Pinner._suppressTickerHandle = null; + } + }, + + // Flip a stage's suppression state: write the shared _pinsHidden + // flag and park/unpark its records right away (don't wait for a + // sync that an older bundle's Pinner might be driving). + _setSuppressed: function (stage, suppressed) { + stage._pinsHidden = suppressed; + var records = stage._pinnedElements; + if (records) { + for (var i = 0; i < records.length; i++) { + if (records[i].anchor.stage !== stage) { + continue; // sync() will tear this one down + } + if (suppressed) { + Pinner._park(records[i]); + } else { + Pinner._unpark(records[i]); + } + } + } + DirtyFlag.invalidate(); + }, + _isPinned: function (stage, obj) { var records = stage._pinnedElements; if (!records) { @@ -139,11 +246,49 @@ define( return false; }, + // Park: move a pinned object out of its wrapper and back into its + // anchor -- it renders in its regular spot and z-order (e.g. under + // an open modal). The record is kept so the pin can resume. + _park: function (record) { + if (record._parked) { + return; + } + record._parked = true; + record.obj._pinnedToTop = false; + if (record.obj.parent === record.wrapper) { + record.wrapper.removeChild(record.obj); + } + record.anchor.addChild(record.obj); + DirtyFlag.invalidate(); + }, + + // Unpark: lift the object back into its pin wrapper; the pin + // resumes exactly as before. wrapper.visible is forced true in + // case an older Pinner copy hid it while we were parked. + _unpark: function (record) { + if (!record._parked) { + return; + } + record._parked = false; + if (record.obj.parent) { + record.obj.parent.removeChild(record.obj); + } + record.wrapper.addChild(record.obj); + record.wrapper.visible = true; + record.obj._pinnedToTop = true; + DirtyFlag.invalidate(); + }, + sync: function (stage) { var records = stage._pinnedElements; if (!records || records.length === 0) { return false; } + // _pinsHidden is the shared-stage suppression protocol: set by + // the suppressor registry, or manually by older callers. While + // set, pins are PARKED at their anchors (regular spot), not + // positioned in the pin layer. + var suppressed = !!stage._pinsHidden; for (var i = records.length - 1; i >= 0; i--) { var record = records[i]; // Auto-teardown: anchor removed from the stage. @@ -152,7 +297,12 @@ define( records.splice(i, 1); continue; } - Pinner._syncRecord(stage, record); + if (suppressed) { + Pinner._park(record); + } else { + Pinner._unpark(record); + Pinner._syncRecord(stage, record); + } } return records.length > 0; }, @@ -169,13 +319,14 @@ define( _restore: function (record) { record.obj._pinnedToTop = false; - if (record.obj.parent) { + record._parked = false; + if (record.obj.parent && record.obj.parent !== record.anchor) { record.obj.parent.removeChild(record.obj); } if (record.wrapper.parent) { record.wrapper.parent.removeChild(record.wrapper); } - if (record.anchor && record.anchor.stage) { + if (record.obj.parent !== record.anchor && record.anchor && record.anchor.stage) { record.anchor.addChild(record.obj); } }, @@ -183,10 +334,6 @@ define( _syncRecord: function (stage, record) { var wrapper = record.wrapper; - // Callers can hide all pinned overlays (e.g. while a modal is - // open over the pinned QR) by setting stage._pinsHidden. - wrapper.visible = !stage._pinsHidden; - var container = wrapper.parent; if (!container) { return; diff --git a/app/scripts/CatLab/Easelbone/FrontController.js b/app/scripts/CatLab/Easelbone/FrontController.js index cf9f555..d42fcad 100644 --- a/app/scripts/CatLab/Easelbone/FrontController.js +++ b/app/scripts/CatLab/Easelbone/FrontController.js @@ -122,6 +122,20 @@ define( return Pinner.unpin(displayObject); }, + // Register a modal-like display object: while it is attached to a + // stage, that stage's pinned objects are parked back at their + // anchors (regular spot, under the modal). Auto-releases when the + // object leaves the stage. + suppressPins: function (displayObject) { + return Pinner.suppress(displayObject); + }, + + // Explicit counterpart for modals that linger on the stage after + // closing (e.g. covered by the next screen instead of removed). + releasePins: function (displayObject) { + return Pinner.release(displayObject); + }, + FakeWebremote: { KeyboardUser: KeyboardUser }, diff --git a/dist/scripts/easelbone.js b/dist/scripts/easelbone.js index 5b4ffed..ccda1a6 100644 --- a/dist/scripts/easelbone.js +++ b/dist/scripts/easelbone.js @@ -8,7 +8,7 @@ define("CatLab/Easelbone/Utilities/MovieClipHelper",["easeljs","CatLab/Easelbone define("CatLab/Easelbone/Views/Base",["backbone","easeljs","CatLab/Easelbone/Utilities/GlobalProperties","CatLab/Easelbone/Utilities/MovieClipHelper","CatLab/Easelbone/Utilities/Deferred"],(function(e,t,i,r,n){"use strict";return e.View.extend({el:"div",easelScreen:null,setRootView:function(e){this.set("root",e)},getWidth:function(){return this.getStage().canvas.width},getHeight:function(){return this.getStage().canvas.height},getStage:function(){return void 0!==this.el.stage?this.el.stage:void 0!==this.el.getStage?this.el.getStage():void 0},setScreen:function(e){return this.easelScreen=e,this},getScreen:function(){return this.easelScreen},hasLabeledFrame:function(e,t){if(void 0===t&&(t=this.easelScreen),!t)throw new Error("hasLabeledFrame requires a screen to be set or a container to be provided.");return r.hasLabeledFrame(e,t)},jumpToFrame:function(e,t){if(void 0===t&&(t=this.easelScreen)&&!t.parent){var i=new n;return this._pendingFrameJumps||(this._pendingFrameJumps=[]),this._pendingFrameJumps.push({label:e,deferred:i}),i.promise()}if(!t)throw new Error("hasLabeledFrame requires a screen to be set or a container to be provided.");return r.jumpToFrame(e,t)},_replayPendingFrameJumps:function(){if(this._pendingFrameJumps){var e=this._pendingFrameJumps;this._pendingFrameJumps=null,e.forEach(function(e){this.jumpToFrame(e.label).done((function(){e.deferred.resolve()}))}.bind(this))}},pause:function(e){if(void 0===e&&(e=this.easelScreen),!e)throw new Error("pause requires a screen to be set or a container to be provided.");return r.pause(this.easelScreen)},resume:function(e){if(void 0===e&&(e=this.easelScreen),!e)throw new Error("resume requires a screen to be set or a container to be provided.");return r.resume(this.easelScreen)},scale:function(e){var t=this.getScale();e.scaleX=t.x,e.scaleY=t.y},getScale:function(e,t,r){null==e&&(e=i.getWidth()),null==t&&(t=i.getHeight()),void 0===r&&(r=!1);var n=this.getWidth()/e,a=this.getHeight()/t,s=r?Math.max(n,a):Math.min(n,a);return{x:s,y:s}},addCenter:function(e,r,n,a,s){null==r&&(r=i.getWidth()),null==n&&(n=i.getHeight()),null==s&&(s=1);var h=this.getScale(r,n,a),o=!0;if(this.getWidth()===r&&this.getHeight()===n&&(o=!1),o&&(h.x=h.x*s,h.y=h.y*s,e.x=(this.getWidth()-r*h.x)/2,e.y=(this.getHeight()-n*h.y)/2,e.scaleX=h.x,e.scaleY=h.y),this.el.addChild(e),o){var l=new t.Graphics;e.x>=1&&(l.beginFill(this.getBackground()).drawRect(0,0,Math.ceil(e.x),this.getHeight()),l.beginFill(this.getBackground()).drawRect(this.getWidth()-Math.ceil(e.x),0,Math.ceil(e.x),this.getHeight())),e.y>=1&&(l.beginFill(this.getBackground()).drawRect(0,0,this.getWidth(),Math.ceil(e.y)),l.beginFill(this.getBackground()).drawRect(0,this.getHeight()-Math.ceil(e.y),this.getWidth(),Math.ceil(e.y)));var d=new t.Shape(l);this.el.addChild(d)}},clear:function(){this.el.removeAllChildren();var e=new t.Graphics;e.beginFill(this.getBackground()).drawRect(0,0,this.getWidth(),this.getHeight());var i=new t.Shape(e);this.el.addChild(i)},getBackground:function(){return"#000000"},render:function(){if(this.easelScreen)return this.addCenter(this.easelScreen),this._replayPendingFrameJumps(),this;var e=new t.Container;e.setBounds(0,0,this.getWidth(),this.getHeight());var i=new t.BigText("easelbone view: no screen set and render function was not overridden...","Arial","#000000");e.addChild(i),this.el.addChild(e)},findPlaceholders:function(e,t){return void 0===t&&(t=[],this.easelScreen&&t.push(this.easelScreen)),r.findPlaceholders(e,t)},findTextPlaceholders:function(e,t){return this.findPlaceholdersPreferPostfix(e,t,"text")},findPlaceholdersPreferPostfix:function(e,t,i){var r=[];return Array.isArray(e)?e.forEach((function(e){r.push(e+".text"),r.push(e)})):(r.push(e+".text"),r.push(e)),this.findPlaceholders(r,t)},findFromNames:function(e,t){return void 0===t&&(t=[],this.easelScreen&&t.push(this.easelScreen)),r.findFromNames(e,t)},findFromNameInContainer:function(e,t,i){return r.findFromNameInContainer(e,t,i)},forEachNamedChild:function(e,t){return r.forEachNamedChild(e,t)},tick:function(){return!0},afterRender:function(){},onRemove:function(){},scrollIntoView:function(e){for(var t=e;t.parent;){if(void 0!==t._parentScrollArea)return void t._parentScrollArea.focus(e,200);t=t.parent}}})})); define("CatLab/Easelbone/Views/LoadingView",["CatLab/Easelbone/Views/Base"],(function(t){"use strict";var e=new Image;e.crossOrigin="anonymous",e.src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAAWCAYAAAA1vze2AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA3XAAAN1wFCKJt4AAAAB3RJTUUH3wMRCycoY5y7DgAAAeBJREFUSMel1j9sTlEYBvD3fv2DIqmmmmjTBSXVkYSBhAixWNRmMYvBgMZQSUeLQWIw2hqLWIRBYpCIQYcyYDBJNAQpBk21P8u5cdzcfv1835Pc3JznOed9zn3f95zciCbAAbzEIk5VtI04jxmMRzvAKD76i1VszvSZTFvESDsmk1jxLyYz/UtFu7pWrEYTn9EafUcyGIiIgYo23o5Jdw23kt79NVrR1AQNbEEe+F0WtMSLiIiiKN5HxGqdluL1oB89JdGFabzGLWxKfB9ms5zfqdTsYqbNoT+LN5tqdq+cfLrSQUcrOxrGCLoqJg1sxxg2ZPyZSsNcCTyvdMlUdABMpc2W+NyIiP2VecMtBjuMEzVSX2U82B0RSxHRk5H5gTsbEbcjYig1wpuI2BoRx7I5cxFxqCiK5URtq3SawINKui6nxfvwU2u4npneqGjzkQr3LRFPMZil40eLJnfLxsAQHuErHpfx1sr5Xnxq0WS6Wf2anfhzKb/r4XeqVcvt14sL//EFJZ6Uh3g9gzF80D7u4yQm1rzU8DYi9kRnWE333WBRFN/rarIQnaMREb/SU5uu3VjWOS6haFaX41iqWbjQosE8eltpgAk8TKf9FXYl/kgawzMcTPxOXMPN/B8gxx+XbHMxap3gAwAAAABJRU5ErkJggg==";var i=0,s={x:{start:0,end:100},y:{start:0,end:100}},a={x:s.x.end/2,y:s.y.end/2},n=[],h=!1;return t.extend({initialize:function(t){this.gameVersion=null,void 0!==t.gameVersion&&(this.gameVersion=t.gameVersion),this.dRotation=0,this.speed=10,this.angle=Math.random()*Math.PI*2,this.targetAngle=null,this.flippedX=!1,this.flippedY=!1,this.rotationSpeedRad=.2,this.screenMargin=50},render:function(){s={x:{start:0,end:this.el.stage.canvas.width},y:{start:0,end:this.el.stage.canvas.height}},this.loadingText=new createjs.Text("Loading","15px monospace","#ffffff"),this.loadingText.textAlign="center",this.loadingText.lineHeight=20,this.el.addChild(this.loadingText),this.versionText=null,this.gameVersion&&(this.versionText=new createjs.Text("v"+this.gameVersion,"12px monospace","#ffffff"),this.el.addChild(this.versionText),this.versionText.cache(-100,-100,200,200),this.loadingText.cache(-100,-100,200,200)),this.el.setBounds(0,0,this.el.stage.canvas.width,this.el.stage.canvas.height),this.updatePositions(),a={x:Math.random()*(s.x.end-2*this.screenMargin)+this.screenMargin,y:Math.random()*(s.y.end-2*this.screenMargin)+this.screenMargin}},tick:function(){this.updatePositions();var t=this.angle;null!==this.targetAngle&&(t=this.targetAngle),(a.xs.x.end-this.screenMargin&&Math.cos(t)>0)&&(this.targetAngle=Math.PI-t),(a.ys.y.end-this.screenMargin&&Math.sin(t)>0)&&(this.targetAngle=2*Math.PI-t),null!==this.targetAngle&&(this.targetAngle-this.angle>this.rotationSpeedRad?this.angle+=this.rotationSpeedRad:this.targetAngle-this.angle<0-this.rotationSpeedRad?this.angle-=this.rotationSpeedRad:this.targetAngle=null);var e=Math.cos(this.angle)*this.speed,h=Math.sin(this.angle)*this.speed;a.x+=e,a.y+=h,++i%6==0&&this.addPaw();for(var r=0;r0&&g.push(n[r]);return n=g,!0},updatePositions:function(){this.loadingText.x=this.el.getBounds().width/2,this.loadingText.y=this.el.getBounds().height/2,this.versionText&&(this.versionText.x=this.el.getBounds().width-60,this.versionText.y=this.el.getBounds().height-20)},stop:function(){},start:function(){},addPaw:function(){var t=10;(h=!h)&&(t*=-1);var i=new createjs.Bitmap(e),s=this.angle;i.regX=10,i.regY=10,i.x=a.x+Math.cos(s+Math.PI/2)*t,i.y=a.y+Math.sin(s+Math.PI/2)*t,i.scaleX=20/e.width,i.scaleY=20/e.height,i.rotation=(s+Math.PI/2)*(180/Math.PI),this.el.addChild(i),n.push(i)},setProgress:function(t){t=Math.floor(100*t),this.loadingText.text="Loading\n"+t+"%",this.loadingText.cache(-100,-100,200,200)}})})); define("CatLab/Easelbone/Views/Layer",["easeljs","underscore","backbone","CatLab/Easelbone/Utilities/Deferred"],(function(e,i,t,n){var r=function(n){i.extend(this,t.Events),void 0===n&&(n={}),void 0!==n.container?this.container=n.container:this.container=new e.Container,this.view=null};return r.prototype.setView=function(i){var t=this.view;this.view=i;var n=new e.Container;this.view.setElement(n),this.container.addChild(n),this.view.on("all",function(e){this.trigger("view:"+e)}.bind(this)),this.view.trigger("stage:added"),t&&this.container.children.length>0&&this.container.setChildIndex(t.el,this.container.children.length-1),t&&this._destroyView(t).then((function(){t=null}))},r.prototype.clearView=function(){this.view&&this._destroyView(this.view).pipe(function(){this.view=null}.bind(this))},r.prototype._destroyView=function(e){return this._waitForViewDestroy(e).pipe(function(){null!==e&&(e.trigger("stage:removed"),this.container.removeChild(e.el),e=null)}.bind(this))},r.prototype._waitForViewDestroy=function(e){var i=new n;return e&&void 0!==e.hasLabeledFrame&&void 0!==e.easelScreen&&e.easelScreen&&e.hasLabeledFrame("view:destroy")?(e.jumpToFrame("view:destroy").then((function(){i.resolve()})),i.promise()):(i.resolve(),i.promise())},r.prototype.render=function(){this.view&&(this.view.el.removeAllChildren(),this.view.trigger("render:before"),this.view.render(),this.view.trigger("render"),this.view.trigger("render:after"))},r.prototype.tick=function(e){return!!this.view&&this.view.tick(e)},r})); -define("CatLab/Easelbone/EaselJS/Pinner",["easeljs","CatLab/Easelbone/Utilities/DirtyFlag"],(function(n,e){"use strict";var r={_props:{},setContainer:function(n,e){n._pinContainer=e},_deferPin:function(e,t){e.stage?r.pin(e):t>=300||n.Ticker.on("tick",(function(){r._deferPin(e,t+1)}),null,!0)},pin:function(t){var i=t.stage;if(i){if(!r._isPinned(i,t)){var a=t.parent;if(a){!function(n){n._pinnedElements||(n._pinnedElements=[]),n._pinnedElements}(i),r._removeByAnchor(i,a);var o=new n.Container,s={obj:t,anchor:a,wrapper:o};(function(e){if(e._pinContainer&&e._pinContainer.stage===e)return e._pinContainer;var r=new n.Container;return e.addChild(r),e._pinContainer=r,r})(i).addChild(o),o.addChild(t),t._pinnedToTop=!0,i._pinnedElements.push(s),r._syncRecord(i,s),e.invalidate()}}}else r._deferPin(t,0)},unpin:function(n){var t=n.stage,i=t&&t._pinnedElements;if(i)for(var a=0;a=0;t--){var i=e[t];i.anchor.stage===n?r._syncRecord(n,i):(r._restore(i),e.splice(t,1))}return e.length>0},_removeByAnchor:function(n,e){for(var t=n._pinnedElements,i=t.length-1;i>=0;i--)t[i].anchor===e&&(r._restore(t[i]),t.splice(i,1))},_restore:function(n){n.obj._pinnedToTop=!1,n.obj.parent&&n.obj.parent.removeChild(n.obj),n.wrapper.parent&&n.wrapper.parent.removeChild(n.wrapper),n.anchor&&n.anchor.stage&&n.anchor.addChild(n.obj)},_syncRecord:function(n,e){var t=e.wrapper;t.visible=!n._pinsHidden;var i=t.parent;if(i){var a,o;try{a=e.anchor.getConcatenatedMatrix(),o=i.getConcatenatedMatrix()}catch(n){return}if(a&&o){var s=null;try{s=e.anchor.getBounds()}catch(n){s=null}!s||e._bw===s.width&&e._bh===s.height||(e._bw=s.width,e._bh=s.height,t.setBounds(0,0,s.width,s.height));var c=o.invert().appendMatrix(a),d=e._m||(e._m={a:0,b:0,c:0,d:0,tx:0,ty:0});if(d.a!==c.a||d.b!==c.b||d.c!==c.c||d.d!==c.d||d.tx!==c.tx||d.ty!==c.ty){d.a=c.a,d.b=c.b,d.c=c.c,d.d=c.d,d.tx=c.tx,d.ty=c.ty;var p=c.decompose(r._props);t.x=p.x,t.y=p.y,t.scaleX=p.scaleX,t.scaleY=p.scaleY,t.rotation=p.rotation,t.skewX=p.skewX,t.skewY=p.skewY}}}}};return r})); +define("CatLab/Easelbone/EaselJS/Pinner",["easeljs","CatLab/Easelbone/Utilities/DirtyFlag"],(function(e,n){"use strict";var r={_props:{},setContainer:function(e,n){e._pinContainer=n},_deferPin:function(n,s){n.stage?r.pin(n):s>=300||e.Ticker.on("tick",(function(){r._deferPin(n,s+1)}),null,!0)},pin:function(s){var t=s.stage;if(t){if(!r._isPinned(t,s)){var a=s.parent;if(a){!function(e){e._pinnedElements||(e._pinnedElements=[]),e._pinnedElements}(t),r._removeByAnchor(t,a);var p=new e.Container,i={obj:s,anchor:a,wrapper:p};(function(n){if(n._pinContainer&&n._pinContainer.stage===n)return n._pinContainer;var r=new e.Container;return n.addChild(r),n._pinContainer=r,r})(t).addChild(p),p.addChild(s),s._pinnedToTop=!0,t._pinnedElements.push(i),t._pinsHidden?r._park(i):r._syncRecord(t,i),n.invalidate()}}}else r._deferPin(s,0)},unpin:function(e){var s=e.stage,t=s&&s._pinnedElements;if(t)for(var a=0;a=0;n--){var a=t[n],p=a.obj.stage;p?(a.wasAttached=!0,-1===s.indexOf(p)&&s.push(p)):(a.wasAttached||++a.attempts>=300)&&t.splice(n,1)}for(n=0;n=0;n--)-1===s.indexOf(r._suppressedStages[n])&&(r._setSuppressed(r._suppressedStages[n],!1),r._suppressedStages.splice(n,1));t.length||r._suppressedStages.length||!r._suppressTickerHandle||(e.Ticker.off("tick",r._suppressTickerHandle),r._suppressTickerHandle=null)},_setSuppressed:function(e,s){e._pinsHidden=s;var t=e._pinnedElements;if(t)for(var a=0;a=0;t--){var a=n[t];a.anchor.stage===e?s?r._park(a):(r._unpark(a),r._syncRecord(e,a)):(r._restore(a),n.splice(t,1))}return n.length>0},_removeByAnchor:function(e,n){for(var s=e._pinnedElements,t=s.length-1;t>=0;t--)s[t].anchor===n&&(r._restore(s[t]),s.splice(t,1))},_restore:function(e){e.obj._pinnedToTop=!1,e._parked=!1,e.obj.parent&&e.obj.parent!==e.anchor&&e.obj.parent.removeChild(e.obj),e.wrapper.parent&&e.wrapper.parent.removeChild(e.wrapper),e.obj.parent!==e.anchor&&e.anchor&&e.anchor.stage&&e.anchor.addChild(e.obj)},_syncRecord:function(e,n){var s=n.wrapper,t=s.parent;if(t){var a,p;try{a=n.anchor.getConcatenatedMatrix(),p=t.getConcatenatedMatrix()}catch(e){return}if(a&&p){var i=null;try{i=n.anchor.getBounds()}catch(e){i=null}!i||n._bw===i.width&&n._bh===i.height||(n._bw=i.width,n._bh=i.height,s.setBounds(0,0,i.width,i.height));var o=p.invert().appendMatrix(a),d=n._m||(n._m={a:0,b:0,c:0,d:0,tx:0,ty:0});if(d.a!==o.a||d.b!==o.b||d.c!==o.c||d.d!==o.d||d.tx!==o.tx||d.ty!==o.ty){d.a=o.a,d.b=o.b,d.c=o.c,d.d=o.d,d.tx=o.tx,d.ty=o.ty;var _=o.decompose(r._props);s.x=_.x,s.y=_.y,s.scaleX=_.scaleX,s.scaleY=_.scaleY,s.rotation=_.rotation,s.skewX=_.skewX,s.skewY=_.skewY}}}}};return r})); define("CatLab/Easelbone/Views/Root",["backbone","easeljs","CatLab/Easelbone/Views/Layer","CatLab/Easelbone/Utilities/DirtyFlag","CatLab/Easelbone/EaselJS/Pinner"],(function(i,t,e,n,a){var s;return i.View.extend({stage:null,container:null,hudcontainer:null,view:null,hud:null,maxCanvasSize:null,width:null,height:null,initialize:function(i){this.initializeRootView(i)},initializeRootView:function(i){if(void 0!==i.canvas)this.canvas=i.canvas,this.container=this.canvas.parentNode;else{if(void 0===i.container)throw new Error("Container must be defined for root view.");this.canvas=document.createElement("canvas"),this.container=i.container,this.container.appendChild(this.canvas)}if(void 0!==i.width&&void 0!==i.height&&(this.width=i.width,this.height=i.height),this.stage=this.createStage(i),this.layers=[],this.layerMap={},this.dirty=!1,this.mainLayer=this.nextLayer("main"),this.snapToPixel=void 0!==i.snapToPixel&&i.snapToPixel,this.dirtyRendering=void 0!==i.dirtyRendering&&!!i.dirtyRendering,this.heartbeatInterval=1e3,this._lastPaintTime=0,this.dirtyRendering){this.stage.tickOnUpdate=!1,n.installMovieClipHook(t);var e=this.invalidate.bind(this);this.stage.on("stagemousedown",e),this.stage.on("stagemouseup",e),this.stage.on("stagemousemove",e)}t.Ticker.addEventListener("tick",function(i){t.Ticker.paused||this.tick(i)}.bind(this)),this.resize()},createStage:function(i){var e;return(e=void 0!==i.webgl&&i.webgl?new t.StageGL(this.canvas):new t.Stage(this.canvas)).snapToPixelEnabled=this.snapToPixel,e},setMaxCanvasSize:function(i,t){this.maxCanvasSize=[i,t]},nextLayer:function(i){void 0===i&&(i="Layer"+this.layers.length);var t=new e;return t.container.snapToPixel=this.snapToPixel,this.stage.addChild(t.container),this.layers.push(t),this.layerMap[i]=t,t},getLayer:function(i){return this.layerMap[i]},setPinContainer:function(i){a.setContainer(this.stage,i)},setView:function(i){this.mainLayer.setView(i),this.render()},invalidate:function(){n.invalidate()},tick:function(i){for(this.trigger("tick:before",i),this.dirty=!1,s=0;s=this.heartbeatInterval&&(this.dirty=!0),this.dirty&&(this._lastPaintTime=i.time,this.update())):this.dirty&&this.update(),this.trigger("tick:after")},render:function(){for(s=0;s0&&0==t&&this.trigger("click"),r=t},this.getLabel=function(){return''+o+""},this.setStaticLabel=function(t,n){return o=t,e=n,this},this.setLabel=function(i,e){return n.emit("button:label",{id:t,label:i}),this},this.click=function(t){return this.on("click",t),this},this.on=function(t,n){return void 0===s[t]&&(s[t]=[]),s[t].push(n),this},this.trigger=function(t){var n=[];if(void 0!==s[t])for(i=0;i + + + Pinner suppression fixture + + + + + + + + diff --git a/tools/pin-suppress-test.js b/tools/pin-suppress-test.js new file mode 100644 index 0000000..61d883f --- /dev/null +++ b/tools/pin-suppress-test.js @@ -0,0 +1,149 @@ +/** + * Behavioral test for Pinner suppression (park-at-anchor). + * + * Renders tools/fixtures/pin-suppress.html headlessly. + * + * Semantics under test: while a stage is suppressed, pinned objects are + * PARKED back into their anchors (regular spot, regular z-order) -- not + * hidden. Unsuppressing lifts them back into their pin wrappers. + * + * Usage: node tools/pin-suppress-test.js [port] + */ +var spawn = require('child_process').spawn; +var chromium = require('playwright').chromium; + +var FIXTURE = 'tools/fixtures/pin-suppress.html'; +var PORT = parseInt(process.argv[2], 10) || 8127; + +function pixel(page, x, y) { + return page.evaluate(function (p) { + var ctx = document.getElementById('canvas').getContext('2d'); + var d = ctx.getImageData(p.x, p.y, 1, 1).data; + return [d[0], d[1], d[2]]; + }, { x: x, y: y }); +} + +function isGreen(rgb) { return rgb[1] > 150 && rgb[0] < 100 && rgb[2] < 100; } +function isBlue(rgb) { return rgb[2] > 150 && rgb[0] < 100 && rgb[1] < 100; } + +async function main() { + var server = spawn('node', ['node_modules/http-server/bin/http-server', '.', '-p', String(PORT), '-c-1'], { stdio: 'ignore' }); + await new Promise(function (r) { setTimeout(r, 1500); }); + var browser = await chromium.launch(); + var failures = []; + try { + var page = await browser.newPage(); + var errors = []; + page.on('pageerror', function (e) { errors.push(e.message); }); + page.on('console', function (m) { if (m.type() === 'error') errors.push(m.text()); }); + await page.goto('http://localhost:' + PORT + '/' + FIXTURE, { waitUntil: 'load', timeout: 30000 }); + await page.waitForFunction('window.__ready === true', { timeout: 10000 }); + + // Baseline: pinned + lifted. Green above the blue occluder at the + // anchor spot (125,125); record obj lives in the wrapper. + var spot = await pixel(page, 125, 125); + if (!isGreen(spot)) { failures.push('baseline: expected GREEN at (125,125), got ' + spot); } + if ((await page.evaluate('window.__pinnedParent()')) !== 'wrapper') { failures.push('baseline: obj should be in wrapper'); } + if ((await page.evaluate('window.__pinnedToTop()')) !== true) { failures.push('baseline: _pinnedToTop should be true'); } + + // Manual flag on -> PARKED: obj back in the anchor (regular spot, + // under the occluder -> BLUE), _pinnedToTop false, record kept. + await page.evaluate('window.__setPinsHidden(true)'); + spot = await pixel(page, 125, 125); + if (!isBlue(spot)) { failures.push('parked: expected BLUE at (125,125), got ' + spot); } + if ((await page.evaluate('window.__pinnedParent()')) !== 'anchor') { failures.push('parked: obj should be in anchor'); } + if ((await page.evaluate('window.__pinnedToTop()')) !== false) { failures.push('parked: _pinnedToTop should be false'); } + if ((await page.evaluate('window.__pinnedCount()')) !== 1) { failures.push('parked: record must be kept'); } + + // A pin created WHILE suppressed starts parked (replaces the old + // record on the same anchor -> still exactly 1 record). + await page.evaluate('window.__pinNewBox()'); + if ((await page.evaluate('window.__pinnedCount()')) !== 1) { failures.push('pin-while-parked: expected 1 record'); } + if ((await page.evaluate('window.__pinnedParent()')) !== 'anchor') { failures.push('pin-while-parked: obj should start in anchor'); } + spot = await pixel(page, 125, 125); + if (!isBlue(spot)) { failures.push('pin-while-parked: expected BLUE at (125,125), got ' + spot); } + + // Manual flag off -> UNPARKED: lifted again, green on top. + await page.evaluate('window.__setPinsHidden(false)'); + spot = await pixel(page, 125, 125); + if (!isGreen(spot)) { failures.push('unparked: expected GREEN at (125,125), got ' + spot); } + if ((await page.evaluate('window.__pinnedParent()')) !== 'wrapper') { failures.push('unparked: obj should be back in wrapper'); } + if ((await page.evaluate('window.__pinnedToTop()')) !== true) { failures.push('unparked: _pinnedToTop should be true'); } + + // ---- Suppressor registry ---- + + // Registering a detached modal does nothing yet. + await page.evaluate('window.__registerModal("a")'); + await page.waitForTimeout(200); + await page.evaluate('window.__render()'); + spot = await pixel(page, 125, 125); + if (!isGreen(spot)) { failures.push('detached suppressor: pins must stay lifted, got ' + spot); } + if ((await page.evaluate('window.__suppressorCount()')) !== 1) { failures.push('detached suppressor: expected 1 registered'); } + + // Attaching the modal parks the pins (next ticks). + await page.evaluate('window.__attachModal("a")'); + await page.waitForTimeout(300); + await page.evaluate('window.__render()'); + spot = await pixel(page, 125, 125); + if (!isBlue(spot)) { failures.push('modal attached: expected parked/BLUE, got ' + spot); } + if ((await page.evaluate('window.__pinnedParent()')) !== 'anchor') { failures.push('modal attached: obj should be in anchor'); } + if ((await page.evaluate('window.__pinsHiddenFlag()')) !== true) { failures.push('modal attached: _pinsHidden must be written for old-bundle compat'); } + + // Nesting: second modal attached, then first detached -> stays parked. + await page.evaluate('window.__registerModal("b")'); + await page.evaluate('window.__attachModal("b")'); + await page.waitForTimeout(200); + await page.evaluate('window.__render()'); + await page.evaluate('window.__detachModal("a")'); + await page.waitForTimeout(300); + await page.evaluate('window.__render()'); + spot = await pixel(page, 125, 125); + if (!isBlue(spot)) { failures.push('nesting: expected still parked after first modal closed, got ' + spot); } + if ((await page.evaluate('window.__suppressorCount()')) !== 1) { failures.push('nesting: detached suppressor must auto-deregister'); } + + // Last modal detached -> unparked automatically, registry empty. + await page.evaluate('window.__detachModal("b")'); + await page.waitForTimeout(300); + await page.evaluate('window.__render()'); + spot = await pixel(page, 125, 125); + if (!isGreen(spot)) { failures.push('last modal closed: expected GREEN again, got ' + spot); } + if ((await page.evaluate('window.__pinnedParent()')) !== 'wrapper') { failures.push('last modal closed: obj should be back in wrapper'); } + if ((await page.evaluate('window.__suppressorCount()')) !== 0) { failures.push('last modal closed: registry should be empty'); } + if ((await page.evaluate('window.__pinsHiddenFlag()')) !== false) { failures.push('last modal closed: _pinsHidden must be cleared'); } + + // Explicit release while still attached -> unparks immediately. + await page.evaluate('window.__registerModal("c")'); + await page.evaluate('window.__attachModal("c")'); + await page.waitForTimeout(300); + await page.evaluate('window.__render()'); + await page.evaluate('window.__releaseModal("c")'); + await page.waitForTimeout(200); + await page.evaluate('window.__render()'); + spot = await pixel(page, 125, 125); + if (!isGreen(spot)) { failures.push('explicit release: expected GREEN, got ' + spot); } + if ((await page.evaluate('window.__suppressorCount()')) !== 0) { failures.push('explicit release: registry should be empty'); } + await page.evaluate('window.__detachModal("c")'); + await page.waitForTimeout(200); + await page.evaluate('window.__render()'); + + // Grace period: a suppressor that never attaches expires after ~300 + // evaluations and never affects the pins. + await page.evaluate('window.__registerModal("never")'); + await page.evaluate('window.__tickSuppression(301)'); + if ((await page.evaluate('window.__suppressorCount()')) !== 0) { failures.push('grace: never-attached suppressor should expire'); } + spot = await pixel(page, 125, 125); + if (!isGreen(spot)) { failures.push('grace: pins must stay lifted, got ' + spot); } + + if (errors.length) { failures.push('page errors: ' + errors.join(' | ')); } + } finally { + await browser.close(); + server.kill(); + } + if (failures.length) { + console.error('FAIL pin-suppress-test:\n - ' + failures.join('\n - ')); + process.exit(1); + } + console.log('PASS pin-suppress-test'); +} + +main().catch(function (e) { console.error(e); process.exit(1); }); diff --git a/tools/pin-test.js b/tools/pin-test.js index b1ecde5..2ebe280 100644 --- a/tools/pin-test.js +++ b/tools/pin-test.js @@ -47,7 +47,9 @@ async function main() { // falling back to a 100x100 default. This requires a per-pin wrapper // container -- it fails against pre-fix code that reparents obj // directly into the (bounds-less) pin container. - // Suppression: hiding pins makes the pinned wrapper invisible; restoring shows it. + // Suppression: _pinsHidden parks the pinned box back into its anchor + // (under the occluder here), so the spot stops being green; clearing + // the flag lifts it back on top. await page.evaluate('window.__setPinsHidden(true)'); var hiddenSpot = await pixel(page, 125, 125); if (isGreen(hiddenSpot)) { failures.push('pins hidden: expected GREEN hidden at (125,125), still green'); }