diff --git a/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js new file mode 100644 index 0000000..4bb8c7f --- /dev/null +++ b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js @@ -0,0 +1,211 @@ +define( + ['easeljs', 'CatLab/Easelbone/Utilities/DirtyFlag'], + function (createjs, DirtyFlag) { + + 'use strict'; + + // Pin state lives on the shared stage object so that separate easelbone + // bundles (game + library) rendering onto the SAME stage cooperate: both + // read/write stage._pinnedElements and stage._pinContainer, and whichever + // RootView owns the tick loop drives the sync for all of them. + + function ensureState(stage) { + if (!stage._pinnedElements) { + stage._pinnedElements = []; + } + return stage._pinnedElements; + } + + function ensureContainer(stage) { + if (stage._pinContainer && stage._pinContainer.stage === stage) { + return stage._pinContainer; + } + // Fallback for contexts with no designated pin layer (e.g. a + // standalone library portal with no blackout): a last-child + // container on the stage. + var container = new createjs.Container(); + stage.addChild(container); + stage._pinContainer = container; + return container; + } + + var Pinner = { + + _props: {}, + + setContainer: function (stage, container) { + stage._pinContainer = container; + }, + + // Retry pinning on each tick until obj is attached to a stage. + // Gives up after ~5s (300 ticks) so a never-attached object can't + // leak a permanent retry loop. + _deferPin: function (obj, attempt) { + if (obj.stage) { + Pinner.pin(obj); + return; + } + if (attempt >= 300) { + return; + } + createjs.Ticker.on('tick', function () { + Pinner._deferPin(obj, attempt + 1); + }, null, true); + }, + + pin: function (obj) { + var stage = obj.stage; + if (!stage) { + // Not on a stage yet. We can't rely on obj's 'added' + // event here: obj is typically addChild'd to its + // placeholder BEFORE pinToTop is called, so 'added' has + // already fired and will NOT fire again when an ancestor + // (the placeholder) is later attached to the stage. Retry + // on the ticker until obj is actually on a stage. + Pinner._deferPin(obj, 0); + return; + } + + // Idempotent: a second pin() on an object that's already + // pinned must not re-derive the anchor from obj.parent, since + // that's now the pin container, not the original anchor -- + // which would orphan the real record (wrong key for + // _removeByAnchor) and push a bogus, never-torn-down one. + if (Pinner._isPinned(stage, obj)) { + return; + } + + var anchor = obj.parent; + if (!anchor) { + return; + } + + ensureState(stage); + + // Re-pin replaces: drop any existing pin on the same anchor so a + // re-rendered QR (removeAllChildren + regenerate) never stacks. + Pinner._removeByAnchor(stage, anchor); + + var wrapper = new createjs.Container(); + var record = { obj: obj, anchor: anchor, wrapper: wrapper }; + + ensureContainer(stage).addChild(wrapper); + wrapper.addChild(obj); // reparents obj out of anchor; obj's own transform/regX/regY untouched + + stage._pinnedElements.push(record); + + // Immediate sync: correctly placed even without a running tick. + Pinner._syncRecord(stage, record); + + // Reparenting doesn't itself mark the RootView dirty; under + // dirtyRendering:true this ensures the pin paints next frame + // instead of waiting for an unrelated event or the heartbeat. + DirtyFlag.invalidate(); + }, + + unpin: function (obj) { + var stage = obj.stage; + var records = stage && stage._pinnedElements; + if (!records) { + return; + } + for (var i = 0; i < records.length; i++) { + if (records[i].obj === obj) { + Pinner._restore(records[i]); + records.splice(i, 1); + DirtyFlag.invalidate(); + return; + } + } + }, + + _isPinned: function (stage, obj) { + var records = stage._pinnedElements; + if (!records) { + return false; + } + for (var i = 0; i < records.length; i++) { + if (records[i].obj === obj) { + return true; + } + } + return false; + }, + + sync: function (stage) { + var records = stage._pinnedElements; + if (!records || records.length === 0) { + return false; + } + for (var i = records.length - 1; i >= 0; i--) { + var record = records[i]; + // Auto-teardown: anchor removed from the stage. + if (record.anchor.stage !== stage) { + Pinner._restore(record); + records.splice(i, 1); + continue; + } + Pinner._syncRecord(stage, record); + } + return records.length > 0; + }, + + _removeByAnchor: function (stage, anchor) { + var records = stage._pinnedElements; + for (var i = records.length - 1; i >= 0; i--) { + if (records[i].anchor === anchor) { + Pinner._restore(records[i]); + records.splice(i, 1); + } + } + }, + + _restore: function (record) { + if (record.obj.parent) { + record.obj.parent.removeChild(record.obj); + } + if (record.wrapper.parent) { + record.wrapper.parent.removeChild(record.wrapper); + } + if (record.anchor && record.anchor.stage) { + record.anchor.addChild(record.obj); + } + }, + + _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; + } + + // Carry the anchor's bounds so bounds-sizing children (e.g. Fill) size correctly. + var bounds = record.anchor.getBounds(); + if (bounds) { + wrapper.setBounds(0, 0, bounds.width, bounds.height); + } + + // Place the wrapper in the anchor's coordinate space: + // wrapperLocal = pinContainerConcatenated^-1 * anchorConcatenated + var local = container.getConcatenatedMatrix().invert() + .appendMatrix(record.anchor.getConcatenatedMatrix()); + + var props = local.decompose(Pinner._props); + wrapper.x = props.x; + wrapper.y = props.y; + wrapper.scaleX = props.scaleX; + wrapper.scaleY = props.scaleY; + wrapper.rotation = props.rotation; + wrapper.skewX = props.skewX; + wrapper.skewY = props.skewY; + } + }; + + return Pinner; + } +); diff --git a/app/scripts/CatLab/Easelbone/FrontController.js b/app/scripts/CatLab/Easelbone/FrontController.js index 47feba5..cf9f555 100644 --- a/app/scripts/CatLab/Easelbone/FrontController.js +++ b/app/scripts/CatLab/Easelbone/FrontController.js @@ -31,7 +31,9 @@ define( 'CatLab/Easelbone/Utilities/GlobalProperties', 'CatLab/Easelbone/Utilities/MovieClipHelper', - 'CatLab/FakeWebremote/Models/KeyboardUser' + 'CatLab/FakeWebremote/Models/KeyboardUser', + + 'CatLab/Easelbone/EaselJS/Pinner' ], function ( Loader, @@ -65,7 +67,9 @@ define( GlobalProperties, MovieClipHelper, - KeyboardUser + KeyboardUser, + + Pinner ) { return { @@ -110,6 +114,14 @@ define( } }, + pinToTop: function (displayObject) { + return Pinner.pin(displayObject); + }, + + unpinFromTop: function (displayObject) { + return Pinner.unpin(displayObject); + }, + FakeWebremote: { KeyboardUser: KeyboardUser }, diff --git a/app/scripts/CatLab/Easelbone/Views/Root.js b/app/scripts/CatLab/Easelbone/Views/Root.js index 11a9118..78a52db 100755 --- a/app/scripts/CatLab/Easelbone/Views/Root.js +++ b/app/scripts/CatLab/Easelbone/Views/Root.js @@ -4,9 +4,10 @@ define( 'easeljs', 'CatLab/Easelbone/Views/Layer', - 'CatLab/Easelbone/Utilities/DirtyFlag' + 'CatLab/Easelbone/Utilities/DirtyFlag', + 'CatLab/Easelbone/EaselJS/Pinner' ], - function (Backbone, createjs, Layer, DirtyFlag) { + function (Backbone, createjs, Layer, DirtyFlag, Pinner) { var i; var layer; @@ -150,6 +151,16 @@ define( return this.layerMap[name]; }, + /** + * Designate the container that pinToTop reparents objects into. + * Lets the host control z-order (e.g. above the smiley layer but + * below a blackout overlay). + * @param container + */ + setPinContainer: function (container) { + Pinner.setContainer(this.stage, container); + }, + /** * Set a view on the main layer. * @param view @@ -239,6 +250,7 @@ define( // Direct paints (render/resize) also reset the heartbeat. this._lastPaintTime = createjs.Ticker.getTime(); } + Pinner.sync(this.stage); this.stage.update(); }, diff --git a/dist/scripts/easelbone.js b/dist/scripts/easelbone.js index 4628c45..0325f6e 100644 --- a/dist/scripts/easelbone.js +++ b/dist/scripts/easelbone.js @@ -8,7 +8,8 @@ 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/Views/Root",["backbone","easeljs","CatLab/Easelbone/Views/Layer","CatLab/Easelbone/Utilities/DirtyFlag"],(function(i,t,e,n){var a;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]},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,a=0;a=this.heartbeatInterval&&(this.dirty=!0),this.dirty&&(this._lastPaintTime=i.time,this.update())):this.dirty&&this.update(),this.trigger("tick:after")},render:function(){for(a=0;a=300||n.Ticker.on("tick",(function(){r._deferPin(e,i+1)}),null,!0)},pin:function(i){var t=i.stage;if(t){if(!r._isPinned(t,i)){var a=i.parent;if(a){!function(n){n._pinnedElements||(n._pinnedElements=[]),n._pinnedElements}(t),r._removeByAnchor(t,a);var o=new n.Container,s={obj:i,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})(t).addChild(o),o.addChild(i),t._pinnedElements.push(s),r._syncRecord(t,s),e.invalidate()}}}else r._deferPin(i,0)},unpin:function(n){var i=n.stage,t=i&&i._pinnedElements;if(t)for(var a=0;a=0;i--){var t=e[i];t.anchor.stage===n?r._syncRecord(n,t):(r._restore(t),e.splice(i,1))}return e.length>0},_removeByAnchor:function(n,e){for(var i=n._pinnedElements,t=i.length-1;t>=0;t--)i[t].anchor===e&&(r._restore(i[t]),i.splice(t,1))},_restore:function(n){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 i=e.wrapper;i.visible=!n._pinsHidden;var t=i.parent;if(t){var a=e.anchor.getBounds();a&&i.setBounds(0,0,a.width,a.height);var o=t.getConcatenatedMatrix().invert().appendMatrix(e.anchor.getConcatenatedMatrix()).decompose(r._props);i.x=o.x,i.y=o.y,i.scaleX=o.scaleX,i.scaleY=o.scaleY,i.rotation=o.rotation,i.skewX=o.skewX,i.skewY=o.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;sthis.distance.y?"horizontal":"vertical",this.indicatorSize={x:0,y:0}};return i.prototype.getPosition=function(t){return isNaN(t)&&(t=0),{x:this.start.x+(this.distance.x-this.indicatorSize.x)*t,y:this.start.y+(this.distance.y-this.indicatorSize.y)*t}},i.prototype.getValue=function(i,n){return t="horizontal"===this.orientation?(i-this.start.x)/this.distance.x:(n-this.start.y)/this.distance.y,Math.max(0,Math.min(1,t))},i.prototype.position=function(t,i){var n=this.getPosition(i);t.x=n.x,t.y=n.y},i.prototype.setIndicatorSize=function(t,i){this.indicatorSize={x:t,y:i}},i})); @@ -34,5 +35,5 @@ define("CatLab/FakeWebremote/Models/User",[],(function(){return function(t){var define("CatLab/FakeWebremote/Models/Control",[],(function(){return function(t,n){var i,e,r=0,s={},o=t,c=[];this.id=t,this.pushed=function(){return 0===r},this.scale=function(){return r},this.update=function(t){r>0&&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 baseline fixture + + + + + + + + diff --git a/tools/pin-test.js b/tools/pin-test.js new file mode 100644 index 0000000..4d6655f --- /dev/null +++ b/tools/pin-test.js @@ -0,0 +1,124 @@ +/** + * Behavioral test for Pinner (pinToTop). + * + * Renders tools/fixtures/pin-baseline.html headlessly and checks: + * - the pinned GREEN box paints ON TOP of the BLUE occluder at the anchor + * - moving the anchor moves the pinned box with it + * + * Serves the repository root itself; no external server needed. + * Usage: node tools/pin-test.js [port] + */ +var spawn = require('child_process').spawn; +var chromium = require('playwright').chromium; + +var FIXTURE = 'tools/fixtures/pin-baseline.html'; +var PORT = parseInt(process.argv[2], 10) || 8125; + +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; } + +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 }); + + // Anchor at (100,100), box 50x50 -> center (125,125) should be GREEN + // (pinned box on top of the blue occluder). + var center = await pixel(page, 125, 125); + if (!isGreen(center)) { failures.push('expected GREEN at (125,125), got ' + center); } + + // Wrapper must carry the anchor's bounds (50x50) so a bounds-sizing + // child (e.g. a Fill/QR) sizes itself to the anchor's box instead of + // 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. + 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'); } + await page.evaluate('window.__setPinsHidden(false)'); + var shownSpot = await pixel(page, 125, 125); + if (!isGreen(shownSpot)) { failures.push('pins shown: expected GREEN to return at (125,125), got ' + shownSpot); } + + var wrapperBounds = await page.evaluate('window.__wrapperBounds()'); + if (!wrapperBounds || wrapperBounds[0] !== 50 || wrapperBounds[1] !== 50) { + failures.push('expected wrapper bounds [50,50], got ' + JSON.stringify(wrapperBounds)); + } + + // Move anchor +100,+0 -> old center now blue, new center green. + await page.evaluate('window.__moveAnchor(100, 0)'); + await page.waitForTimeout(200); + var oldSpot = await pixel(page, 125, 125); + var newSpot = await pixel(page, 225, 125); + if (isGreen(oldSpot)) { failures.push('expected GREEN to have LEFT (125,125), still green'); } + if (!isGreen(newSpot)) { failures.push('expected GREEN at moved (225,125), got ' + newSpot); } + + // Anchor is now at (200,100), center (225,125). + + // Re-pin replace: a NEW object instance pinned to the SAME anchor + // must replace the old pinned record (no stacking/leak) and render + // on top at the anchor. + var newId = await page.evaluate('window.__rePinNewBox()'); + var countAfterRepin = await page.evaluate('window.__pinnedCount()'); + var recordIdAfterRepin = await page.evaluate('window.__pinnedRecordId()'); + var repinSpot = await pixel(page, 225, 125); + if (countAfterRepin !== 1) { failures.push('re-pin replace: expected 1 tracked record, got ' + countAfterRepin); } + if (recordIdAfterRepin !== newId) { failures.push('re-pin replace: expected tracked record to be the NEW box (id ' + newId + '), got ' + recordIdAfterRepin); } + if (!isGreen(repinSpot)) { failures.push('re-pin replace: expected GREEN at (225,125), got ' + repinSpot); } + + // Idempotent re-pin: calling pinToTop twice on the SAME + // already-pinned object must not create a second record. + await page.evaluate('window.__pinAgain()'); + await page.evaluate('window.__pinAgain()'); + var countAfterIdempotent = await page.evaluate('window.__pinnedCount()'); + var idempotentSpot = await pixel(page, 225, 125); + if (countAfterIdempotent !== 1) { failures.push('idempotent re-pin: expected 1 tracked record, got ' + countAfterIdempotent); } + if (!isGreen(idempotentSpot)) { failures.push('idempotent re-pin: expected GREEN to still render at (225,125), got ' + idempotentSpot); } + + // Auto-teardown: removing the anchor from the stage must drop the + // pin on the next render (no more paint, no tracked record). + await page.evaluate('window.__removeAnchor()'); + await page.waitForTimeout(200); + var countAfterRemoveAnchor = await page.evaluate('window.__pinnedCount()'); + var teardownSpot = await pixel(page, 225, 125); + if (countAfterRemoveAnchor !== 0) { failures.push('auto-teardown: expected 0 tracked records, got ' + countAfterRemoveAnchor); } + if (isGreen(teardownSpot)) { failures.push('auto-teardown: expected GREEN to be gone at (225,125), still green'); } + + // Deferral: pinning an object whose anchor isn't on the stage yet must + // retry and complete once the anchor is attached. Regression guard for + // the bug where pin() relied on obj's 'added' event, which had already + // fired before pinToTop was called and never fired again. Runs after + // auto-teardown so its extra record doesn't skew the count above. + await page.evaluate('window.__deferSetup()'); + var pinnedBeforeAttach = await page.evaluate('window.__deferBoxPinned()'); + if (pinnedBeforeAttach) { failures.push('deferred pin: box should NOT be pinned before its anchor is on the stage'); } + await page.evaluate('window.__deferAttach()'); + await page.waitForTimeout(500); + var pinnedAfterAttach = await page.evaluate('window.__deferBoxPinned()'); + if (!pinnedAfterAttach) { failures.push('deferred pin: box should be pinned after its anchor is attached to the stage'); } + + if (errors.length) { failures.push('page errors: ' + errors.join('; ')); } + await page.close(); + } finally { + await browser.close(); + server.kill(); + } + if (failures.length) { console.error('FAIL\n' + failures.join('\n')); process.exit(1); } + console.log('PASS'); +} +main();