From 02e51dac88c8514d1a05860ca14126fcaa0d1876 Mon Sep 17 00:00:00 2001 From: Thijs Van der Schaeghe Date: Tue, 14 Jul 2026 14:49:47 +0200 Subject: [PATCH 1/7] feat(pinner): element-level pin-to-top for display objects pinToTop reparents an object onto a designated pin container and re-syncs its transform from its original anchor each paint, so it renders on top while following the anchor's position/scale/animation. State lives on the stage object so separate easelbone bundles on one stage cooperate. Co-Authored-By: Claude Opus 4.8 --- .../CatLab/Easelbone/EaselJS/Pinner.js | 163 ++++++++++++++++++ .../CatLab/Easelbone/FrontController.js | 16 +- app/scripts/CatLab/Easelbone/Views/Root.js | 16 +- dist/scripts/easelbone.js | 5 +- package.json | 3 +- tools/fixtures/pin-baseline.html | 80 +++++++++ tools/pin-test.js | 62 +++++++ 7 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 app/scripts/CatLab/Easelbone/EaselJS/Pinner.js create mode 100644 tools/fixtures/pin-baseline.html create mode 100644 tools/pin-test.js diff --git a/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js new file mode 100644 index 0000000..61122d4 --- /dev/null +++ b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js @@ -0,0 +1,163 @@ +define( + ['easeljs'], + function (createjs) { + + '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.getStage() === 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; + }, + + pin: function (obj) { + var stage = obj.getStage(); + if (!stage) { + // Not on a stage yet; defer until it is added. + var deferred = function () { + obj.off('added', deferred); + Pinner.pin(obj); + }; + obj.on('added', deferred); + 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 record = { + obj: obj, + anchor: anchor, + localMatrix: obj.getMatrix(), + origRegX: obj.regX, + origRegY: obj.regY + }; + + ensureContainer(stage).addChild(obj); // reparents out of anchor + stage._pinnedElements.push(record); + + // Immediate sync: correctly placed even without a running tick. + Pinner._syncRecord(stage, record); + }, + + unpin: function (obj) { + var stage = obj.getStage(); + 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); + return; + } + } + }, + + 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.getStage || record.anchor.getStage() !== 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); + } + record.obj.regX = record.origRegX; + record.obj.regY = record.origRegY; + }, + + _syncRecord: function (stage, record) { + var obj = record.obj; + var container = obj.parent; + if (!container) { + return; + } + + // desired global matrix = anchorConcatenated * capturedLocal + var desired = record.anchor.getConcatenatedMatrix() + .appendMatrix(record.localMatrix); + + // Express in the pin container's local space (do not assume the + // pin container sits at identity): + // local = containerConcatenated^-1 * desired + var local = container.getConcatenatedMatrix().invert() + .appendMatrix(desired); + + var props = local.decompose(Pinner._props); + obj.x = props.x; + obj.y = props.y; + obj.scaleX = props.scaleX; + obj.scaleY = props.scaleY; + obj.rotation = props.rotation; + obj.skewX = props.skewX; + obj.skewY = props.skewY; + // The captured localMatrix already bakes in the original regX/regY + // as translation, so the pinned copy must carry none of its own. + obj.regX = 0; + obj.regY = 0; + } + }; + + 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..90bc0b3 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=0;t--){var o=r[t];o.anchor.getStage&&o.anchor.getStage()===e?n._syncRecord(e,o):(n._restore(o),r.splice(t,1))}return r.length>0},_removeByAnchor:function(e,r){for(var t=e._pinnedElements,o=t.length-1;o>=0;o--)t[o].anchor===r&&(n._restore(t[o]),t.splice(o,1))},_restore:function(e){e.obj.parent&&e.obj.parent.removeChild(e.obj),e.obj.regX=e.origRegX,e.obj.regY=e.origRegY},_syncRecord:function(e,r){var t=r.obj,o=t.parent;if(o){var a=r.anchor.getConcatenatedMatrix().appendMatrix(r.localMatrix),i=o.getConcatenatedMatrix().invert().appendMatrix(a).decompose(n._props);t.x=i.x,t.y=i.y,t.scaleX=i.scaleX,t.scaleY=i.scaleY,t.rotation=i.rotation,t.skewX=i.skewX,t.skewY=i.skewY,t.regX=0,t.regY=0}}};return n})); +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..8fe4c2e --- /dev/null +++ b/tools/pin-test.js @@ -0,0 +1,62 @@ +/** + * 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); } + + // 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); } + + 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(); From 977b350b64c31e9f2f58e78cdf4a0b1f90540b83 Mon Sep 17 00:00:00 2001 From: Thijs Van der Schaeghe Date: Tue, 14 Jul 2026 15:04:40 +0200 Subject: [PATCH 2/7] fix(pinner): idempotent re-pin, dirty invalidation, regression tests pin() previously re-derived the anchor from obj.parent, which is wrong for an already-pinned object (parent is by then the pin container), causing a leaked never-torn-down record plus redundant per-frame matrix work on every re-pin of the same instance. Guard pin() to no-op when the object is already tracked. Also mark RootView dirty on successful pin/unpin via DirtyFlag, since reparenting/removing a child doesn't itself trigger a repaint under dirtyRendering: true. Extends the pin-baseline fixture/test with re-pin-replace, idempotent-re-pin, and anchor-removed auto-teardown coverage; the idempotent case reproduces FAIL against the pre-fix pin() (2 tracked records instead of 1) and passes after the fix. --- .../CatLab/Easelbone/EaselJS/Pinner.js | 32 ++++++++++++- dist/scripts/easelbone.js | 2 +- tools/fixtures/pin-baseline.html | 46 +++++++++++++++++++ tools/pin-test.js | 31 +++++++++++++ 4 files changed, 108 insertions(+), 3 deletions(-) diff --git a/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js index 61122d4..308694d 100644 --- a/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js +++ b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js @@ -1,6 +1,6 @@ define( - ['easeljs'], - function (createjs) { + ['easeljs', 'CatLab/Easelbone/Utilities/DirtyFlag'], + function (createjs, DirtyFlag) { 'use strict'; @@ -49,6 +49,15 @@ define( 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; @@ -73,6 +82,11 @@ define( // 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) { @@ -85,11 +99,25 @@ define( 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) { diff --git a/dist/scripts/easelbone.js b/dist/scripts/easelbone.js index 90bc0b3..47a56ce 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"],(function(e){"use strict";var n={_props:{},setContainer:function(e,n){e._pinContainer=n},pin:function(r){var t=r.getStage();if(t){var o=r.parent;if(o){!function(e){e._pinnedElements||(e._pinnedElements=[]),e._pinnedElements}(t),n._removeByAnchor(t,o);var a={obj:r,anchor:o,localMatrix:r.getMatrix(),origRegX:r.regX,origRegY:r.regY};(function(n){if(n._pinContainer&&n._pinContainer.getStage()===n)return n._pinContainer;var r=new e.Container;return n.addChild(r),n._pinContainer=r,r})(t).addChild(r),t._pinnedElements.push(a),n._syncRecord(t,a)}}else{var i=function(){r.off("added",i),n.pin(r)};r.on("added",i)}},unpin:function(e){var r=e.getStage(),t=r&&r._pinnedElements;if(t)for(var o=0;o=0;t--){var o=r[t];o.anchor.getStage&&o.anchor.getStage()===e?n._syncRecord(e,o):(n._restore(o),r.splice(t,1))}return r.length>0},_removeByAnchor:function(e,r){for(var t=e._pinnedElements,o=t.length-1;o>=0;o--)t[o].anchor===r&&(n._restore(t[o]),t.splice(o,1))},_restore:function(e){e.obj.parent&&e.obj.parent.removeChild(e.obj),e.obj.regX=e.origRegX,e.obj.regY=e.origRegY},_syncRecord:function(e,r){var t=r.obj,o=t.parent;if(o){var a=r.anchor.getConcatenatedMatrix().appendMatrix(r.localMatrix),i=o.getConcatenatedMatrix().invert().appendMatrix(a).decompose(n._props);t.x=i.x,t.y=i.y,t.scaleX=i.scaleX,t.scaleY=i.scaleY,t.rotation=i.rotation,t.skewX=i.skewX,t.skewY=i.skewY,t.regX=0,t.regY=0}}};return n})); +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},pin:function(t){var i=t.getStage();if(i){if(!r._isPinned(i,t)){var a=t.parent;if(a){!function(e){e._pinnedElements||(e._pinnedElements=[]),e._pinnedElements}(i),r._removeByAnchor(i,a);var o={obj:t,anchor:a,localMatrix:t.getMatrix(),origRegX:t.regX,origRegY:t.regY};(function(n){if(n._pinContainer&&n._pinContainer.getStage()===n)return n._pinContainer;var r=new e.Container;return n.addChild(r),n._pinContainer=r,r})(i).addChild(t),i._pinnedElements.push(o),r._syncRecord(i,o),n.invalidate()}}}else{var s=function(){t.off("added",s),r.pin(t)};t.on("added",s)}},unpin:function(e){var t=e.getStage(),i=t&&t._pinnedElements;if(i)for(var a=0;a=0;t--){var i=n[t];i.anchor.getStage&&i.anchor.getStage()===e?r._syncRecord(e,i):(r._restore(i),n.splice(t,1))}return n.length>0},_removeByAnchor:function(e,n){for(var t=e._pinnedElements,i=t.length-1;i>=0;i--)t[i].anchor===n&&(r._restore(t[i]),t.splice(i,1))},_restore:function(e){e.obj.parent&&e.obj.parent.removeChild(e.obj),e.obj.regX=e.origRegX,e.obj.regY=e.origRegY},_syncRecord:function(e,n){var t=n.obj,i=t.parent;if(i){var a=n.anchor.getConcatenatedMatrix().appendMatrix(n.localMatrix),o=i.getConcatenatedMatrix().invert().appendMatrix(a).decompose(r._props);t.x=o.x,t.y=o.y,t.scaleX=o.scaleX,t.scaleY=o.scaleY,t.rotation=o.rotation,t.skewX=o.skewX,t.skewY=o.skewY,t.regX=0,t.regY=0}}};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;s Date: Tue, 14 Jul 2026 16:08:03 +0200 Subject: [PATCH 3/7] fix(pinner): wrap pinned objects so bounds-sizing Fills render at full size pinToTop reparented the pinned obj directly into the shared pin container and rewrote its transform. That broke Fill/Background objects (e.g. QR codes), which size themselves via parent.getBounds() -- the plain pin container has no bounds, so the Fill fell back to a 100x100 default and rendered tiny in the top-left of its placeholder. Fix: reparent obj into a per-pin wrapper Container instead. The wrapper carries the anchor's bounds and is transformed to the anchor's global position; obj itself (and its own transform/regX/ regY) is left untouched. This reproduces the anchor as obj's parent exactly (same bounds + same global transform), so Fills size correctly and non-Fill objects render identically to before. Record shape is now { obj, anchor, wrapper }. _restore detaches the wrapper and reattaches obj to its original anchor (if still on stage), which also makes explicit unpin() put the object back where it came from instead of leaving it stranded with regX/regY reset. Added a regression assertion in tools/pin-test.js (via a new window.__wrapperBounds() fixture hook) proving the wrapper carries the anchor's [50,50] bounds -- this fails against the pre-fix code (no wrapper existed) and passes after. --- .../CatLab/Easelbone/EaselJS/Pinner.js | 60 +++++++++---------- dist/scripts/easelbone.js | 2 +- tools/fixtures/pin-baseline.html | 13 ++++ tools/pin-test.js | 10 ++++ 4 files changed, 53 insertions(+), 32 deletions(-) diff --git a/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js index 308694d..6ccb9e7 100644 --- a/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js +++ b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js @@ -69,15 +69,12 @@ define( // re-rendered QR (removeAllChildren + regenerate) never stacks. Pinner._removeByAnchor(stage, anchor); - var record = { - obj: obj, - anchor: anchor, - localMatrix: obj.getMatrix(), - origRegX: obj.regX, - origRegY: obj.regY - }; - - ensureContainer(stage).addChild(obj); // reparents out of 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. @@ -150,39 +147,40 @@ define( if (record.obj.parent) { record.obj.parent.removeChild(record.obj); } - record.obj.regX = record.origRegX; - record.obj.regY = record.origRegY; + if (record.wrapper.parent) { + record.wrapper.parent.removeChild(record.wrapper); + } + if (record.anchor && record.anchor.getStage && record.anchor.getStage()) { + record.anchor.addChild(record.obj); + } }, _syncRecord: function (stage, record) { - var obj = record.obj; - var container = obj.parent; + var wrapper = record.wrapper; + var container = wrapper.parent; if (!container) { return; } - // desired global matrix = anchorConcatenated * capturedLocal - var desired = record.anchor.getConcatenatedMatrix() - .appendMatrix(record.localMatrix); + // 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); + } - // Express in the pin container's local space (do not assume the - // pin container sits at identity): - // local = containerConcatenated^-1 * desired + // Place the wrapper in the anchor's coordinate space: + // wrapperLocal = pinContainerConcatenated^-1 * anchorConcatenated var local = container.getConcatenatedMatrix().invert() - .appendMatrix(desired); + .appendMatrix(record.anchor.getConcatenatedMatrix()); var props = local.decompose(Pinner._props); - obj.x = props.x; - obj.y = props.y; - obj.scaleX = props.scaleX; - obj.scaleY = props.scaleY; - obj.rotation = props.rotation; - obj.skewX = props.skewX; - obj.skewY = props.skewY; - // The captured localMatrix already bakes in the original regX/regY - // as translation, so the pinned copy must carry none of its own. - obj.regX = 0; - obj.regY = 0; + 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; } }; diff --git a/dist/scripts/easelbone.js b/dist/scripts/easelbone.js index 47a56ce..f2c90aa 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(e,n){"use strict";var r={_props:{},setContainer:function(e,n){e._pinContainer=n},pin:function(t){var i=t.getStage();if(i){if(!r._isPinned(i,t)){var a=t.parent;if(a){!function(e){e._pinnedElements||(e._pinnedElements=[]),e._pinnedElements}(i),r._removeByAnchor(i,a);var o={obj:t,anchor:a,localMatrix:t.getMatrix(),origRegX:t.regX,origRegY:t.regY};(function(n){if(n._pinContainer&&n._pinContainer.getStage()===n)return n._pinContainer;var r=new e.Container;return n.addChild(r),n._pinContainer=r,r})(i).addChild(t),i._pinnedElements.push(o),r._syncRecord(i,o),n.invalidate()}}}else{var s=function(){t.off("added",s),r.pin(t)};t.on("added",s)}},unpin:function(e){var t=e.getStage(),i=t&&t._pinnedElements;if(i)for(var a=0;a=0;t--){var i=n[t];i.anchor.getStage&&i.anchor.getStage()===e?r._syncRecord(e,i):(r._restore(i),n.splice(t,1))}return n.length>0},_removeByAnchor:function(e,n){for(var t=e._pinnedElements,i=t.length-1;i>=0;i--)t[i].anchor===n&&(r._restore(t[i]),t.splice(i,1))},_restore:function(e){e.obj.parent&&e.obj.parent.removeChild(e.obj),e.obj.regX=e.origRegX,e.obj.regY=e.origRegY},_syncRecord:function(e,n){var t=n.obj,i=t.parent;if(i){var a=n.anchor.getConcatenatedMatrix().appendMatrix(n.localMatrix),o=i.getConcatenatedMatrix().invert().appendMatrix(a).decompose(r._props);t.x=o.x,t.y=o.y,t.scaleX=o.scaleX,t.scaleY=o.scaleY,t.rotation=o.rotation,t.skewX=o.skewX,t.skewY=o.skewY,t.regX=0,t.regY=0}}};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},pin:function(t){var a=t.getStage();if(a){if(!r._isPinned(a,t)){var i=t.parent;if(i){!function(e){e._pinnedElements||(e._pinnedElements=[]),e._pinnedElements}(a),r._removeByAnchor(a,i);var o=new e.Container,s={obj:t,anchor:i,wrapper:o};(function(n){if(n._pinContainer&&n._pinContainer.getStage()===n)return n._pinContainer;var r=new e.Container;return n.addChild(r),n._pinContainer=r,r})(a).addChild(o),o.addChild(t),a._pinnedElements.push(s),r._syncRecord(a,s),n.invalidate()}}}else{var d=function(){t.off("added",d),r.pin(t)};t.on("added",d)}},unpin:function(e){var t=e.getStage(),a=t&&t._pinnedElements;if(a)for(var i=0;i=0;t--){var a=n[t];a.anchor.getStage&&a.anchor.getStage()===e?r._syncRecord(e,a):(r._restore(a),n.splice(t,1))}return n.length>0},_removeByAnchor:function(e,n){for(var t=e._pinnedElements,a=t.length-1;a>=0;a--)t[a].anchor===n&&(r._restore(t[a]),t.splice(a,1))},_restore:function(e){e.obj.parent&&e.obj.parent.removeChild(e.obj),e.wrapper.parent&&e.wrapper.parent.removeChild(e.wrapper),e.anchor&&e.anchor.getStage&&e.anchor.getStage()&&e.anchor.addChild(e.obj)},_syncRecord:function(e,n){var t=n.wrapper,a=t.parent;if(a){var i=n.anchor.getBounds();i&&t.setBounds(0,0,i.width,i.height);var o=a.getConcatenatedMatrix().invert().appendMatrix(n.anchor.getConcatenatedMatrix()).decompose(r._props);t.x=o.x,t.y=o.y,t.scaleX=o.scaleX,t.scaleY=o.scaleY,t.rotation=o.rotation,t.skewX=o.skewX,t.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;s old center now blue, new center green. await page.evaluate('window.__moveAnchor(100, 0)'); await page.waitForTimeout(200); From 377f1c763247be54104aae7ba945a68ca493b041 Mon Sep 17 00:00:00 2001 From: Thijs Van der Schaeghe Date: Tue, 14 Jul 2026 16:36:21 +0200 Subject: [PATCH 4/7] fix(pinner): retry deferred pin on ticker until object is on a stage pin() relied on obj's 'added' event when the object wasn't on a stage yet, but the object is typically addChild'd to its (still-detached) placeholder BEFORE pinToTop is called -- so 'added' already fired and never fires again when the placeholder is later attached, silently dropping the pin. Retry on the ticker until getStage() is truthy. Adds a regression test. Co-Authored-By: Claude Opus 4.8 --- .../CatLab/Easelbone/EaselJS/Pinner.js | 29 +++++++++++++++---- dist/scripts/easelbone.js | 2 +- tools/fixtures/pin-baseline.html | 28 ++++++++++++++++++ tools/pin-test.js | 13 +++++++++ 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js index 6ccb9e7..e658a1f 100644 --- a/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js +++ b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js @@ -37,15 +37,32 @@ define( 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.getStage()) { + 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.getStage(); if (!stage) { - // Not on a stage yet; defer until it is added. - var deferred = function () { - obj.off('added', deferred); - Pinner.pin(obj); - }; - obj.on('added', deferred); + // 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; } diff --git a/dist/scripts/easelbone.js b/dist/scripts/easelbone.js index f2c90aa..7f090cc 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(e,n){"use strict";var r={_props:{},setContainer:function(e,n){e._pinContainer=n},pin:function(t){var a=t.getStage();if(a){if(!r._isPinned(a,t)){var i=t.parent;if(i){!function(e){e._pinnedElements||(e._pinnedElements=[]),e._pinnedElements}(a),r._removeByAnchor(a,i);var o=new e.Container,s={obj:t,anchor:i,wrapper:o};(function(n){if(n._pinContainer&&n._pinContainer.getStage()===n)return n._pinContainer;var r=new e.Container;return n.addChild(r),n._pinContainer=r,r})(a).addChild(o),o.addChild(t),a._pinnedElements.push(s),r._syncRecord(a,s),n.invalidate()}}}else{var d=function(){t.off("added",d),r.pin(t)};t.on("added",d)}},unpin:function(e){var t=e.getStage(),a=t&&t._pinnedElements;if(a)for(var i=0;i=0;t--){var a=n[t];a.anchor.getStage&&a.anchor.getStage()===e?r._syncRecord(e,a):(r._restore(a),n.splice(t,1))}return n.length>0},_removeByAnchor:function(e,n){for(var t=e._pinnedElements,a=t.length-1;a>=0;a--)t[a].anchor===n&&(r._restore(t[a]),t.splice(a,1))},_restore:function(e){e.obj.parent&&e.obj.parent.removeChild(e.obj),e.wrapper.parent&&e.wrapper.parent.removeChild(e.wrapper),e.anchor&&e.anchor.getStage&&e.anchor.getStage()&&e.anchor.addChild(e.obj)},_syncRecord:function(e,n){var t=n.wrapper,a=t.parent;if(a){var i=n.anchor.getBounds();i&&t.setBounds(0,0,i.width,i.height);var o=a.getConcatenatedMatrix().invert().appendMatrix(n.anchor.getConcatenatedMatrix()).decompose(r._props);t.x=o.x,t.y=o.y,t.scaleX=o.scaleX,t.scaleY=o.scaleY,t.rotation=o.rotation,t.skewX=o.skewX,t.skewY=o.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,t){n.getStage()?r.pin(n):t>=300||e.Ticker.on("tick",(function(){r._deferPin(n,t+1)}),null,!0)},pin:function(t){var i=t.getStage();if(i){if(!r._isPinned(i,t)){var a=t.parent;if(a){!function(e){e._pinnedElements||(e._pinnedElements=[]),e._pinnedElements}(i),r._removeByAnchor(i,a);var o=new e.Container,s={obj:t,anchor:a,wrapper:o};(function(n){if(n._pinContainer&&n._pinContainer.getStage()===n)return n._pinContainer;var r=new e.Container;return n.addChild(r),n._pinContainer=r,r})(i).addChild(o),o.addChild(t),i._pinnedElements.push(s),r._syncRecord(i,s),n.invalidate()}}}else r._deferPin(t,0)},unpin:function(e){var t=e.getStage(),i=t&&t._pinnedElements;if(i)for(var a=0;a=0;t--){var i=n[t];i.anchor.getStage&&i.anchor.getStage()===e?r._syncRecord(e,i):(r._restore(i),n.splice(t,1))}return n.length>0},_removeByAnchor:function(e,n){for(var t=e._pinnedElements,i=t.length-1;i>=0;i--)t[i].anchor===n&&(r._restore(t[i]),t.splice(i,1))},_restore:function(e){e.obj.parent&&e.obj.parent.removeChild(e.obj),e.wrapper.parent&&e.wrapper.parent.removeChild(e.wrapper),e.anchor&&e.anchor.getStage&&e.anchor.getStage()&&e.anchor.addChild(e.obj)},_syncRecord:function(e,n){var t=n.wrapper,i=t.parent;if(i){var a=n.anchor.getBounds();a&&t.setBounds(0,0,a.width,a.height);var o=i.getConcatenatedMatrix().invert().appendMatrix(n.anchor.getConcatenatedMatrix()).decompose(r._props);t.x=o.x,t.y=o.y,t.scaleX=o.scaleX,t.scaleY=o.scaleY,t.rotation=o.rotation,t.skewX=o.skewX,t.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;s must defer + }; + window.__deferAttach = function () { + anchorLayer.container.addChild(deferAnchor); // now on the stage + root.render(); + }; + window.__deferBoxPinned = function () { + var records = root.stage._pinnedElements || []; + for (var i = 0; i < records.length; i++) { + if (records[i].obj === deferBox) { return true; } + } + return false; + }; + root.render(); window.__ready = true; }); diff --git a/tools/pin-test.js b/tools/pin-test.js index 53ae2a4..c6d43c9 100644 --- a/tools/pin-test.js +++ b/tools/pin-test.js @@ -91,6 +91,19 @@ async function main() { 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 { From 08aa571356ead2a42f31abc7ce23c5dc5c302434 Mon Sep 17 00:00:00 2001 From: Thijs Van der Schaeghe Date: Tue, 14 Jul 2026 16:48:53 +0200 Subject: [PATCH 5/7] feat(pinner): honor stage._pinsHidden to hide pinned overlays Lets callers hide all pinned overlays (e.g. the portal QR) while a modal is open over them, restoring on close. Adds a regression test. Co-Authored-By: Claude Opus 4.8 --- app/scripts/CatLab/Easelbone/EaselJS/Pinner.js | 5 +++++ dist/scripts/easelbone.js | 2 +- tools/fixtures/pin-baseline.html | 7 +++++++ tools/pin-test.js | 8 ++++++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js index e658a1f..c9e817c 100644 --- a/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js +++ b/app/scripts/CatLab/Easelbone/EaselJS/Pinner.js @@ -174,6 +174,11 @@ 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/dist/scripts/easelbone.js b/dist/scripts/easelbone.js index 7f090cc..836c74d 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(e,n){"use strict";var r={_props:{},setContainer:function(e,n){e._pinContainer=n},_deferPin:function(n,t){n.getStage()?r.pin(n):t>=300||e.Ticker.on("tick",(function(){r._deferPin(n,t+1)}),null,!0)},pin:function(t){var i=t.getStage();if(i){if(!r._isPinned(i,t)){var a=t.parent;if(a){!function(e){e._pinnedElements||(e._pinnedElements=[]),e._pinnedElements}(i),r._removeByAnchor(i,a);var o=new e.Container,s={obj:t,anchor:a,wrapper:o};(function(n){if(n._pinContainer&&n._pinContainer.getStage()===n)return n._pinContainer;var r=new e.Container;return n.addChild(r),n._pinContainer=r,r})(i).addChild(o),o.addChild(t),i._pinnedElements.push(s),r._syncRecord(i,s),n.invalidate()}}}else r._deferPin(t,0)},unpin:function(e){var t=e.getStage(),i=t&&t._pinnedElements;if(i)for(var a=0;a=0;t--){var i=n[t];i.anchor.getStage&&i.anchor.getStage()===e?r._syncRecord(e,i):(r._restore(i),n.splice(t,1))}return n.length>0},_removeByAnchor:function(e,n){for(var t=e._pinnedElements,i=t.length-1;i>=0;i--)t[i].anchor===n&&(r._restore(t[i]),t.splice(i,1))},_restore:function(e){e.obj.parent&&e.obj.parent.removeChild(e.obj),e.wrapper.parent&&e.wrapper.parent.removeChild(e.wrapper),e.anchor&&e.anchor.getStage&&e.anchor.getStage()&&e.anchor.addChild(e.obj)},_syncRecord:function(e,n){var t=n.wrapper,i=t.parent;if(i){var a=n.anchor.getBounds();a&&t.setBounds(0,0,a.width,a.height);var o=i.getConcatenatedMatrix().invert().appendMatrix(n.anchor.getConcatenatedMatrix()).decompose(r._props);t.x=o.x,t.y=o.y,t.scaleX=o.scaleX,t.scaleY=o.scaleY,t.rotation=o.rotation,t.skewX=o.skewX,t.skewY=o.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,t){n.getStage()?r.pin(n):t>=300||e.Ticker.on("tick",(function(){r._deferPin(n,t+1)}),null,!0)},pin:function(t){var i=t.getStage();if(i){if(!r._isPinned(i,t)){var a=t.parent;if(a){!function(e){e._pinnedElements||(e._pinnedElements=[]),e._pinnedElements}(i),r._removeByAnchor(i,a);var o=new e.Container,s={obj:t,anchor:a,wrapper:o};(function(n){if(n._pinContainer&&n._pinContainer.getStage()===n)return n._pinContainer;var r=new e.Container;return n.addChild(r),n._pinContainer=r,r})(i).addChild(o),o.addChild(t),i._pinnedElements.push(s),r._syncRecord(i,s),n.invalidate()}}}else r._deferPin(t,0)},unpin:function(e){var t=e.getStage(),i=t&&t._pinnedElements;if(i)for(var a=0;a=0;t--){var i=n[t];i.anchor.getStage&&i.anchor.getStage()===e?r._syncRecord(e,i):(r._restore(i),n.splice(t,1))}return n.length>0},_removeByAnchor:function(e,n){for(var t=e._pinnedElements,i=t.length-1;i>=0;i--)t[i].anchor===n&&(r._restore(t[i]),t.splice(i,1))},_restore:function(e){e.obj.parent&&e.obj.parent.removeChild(e.obj),e.wrapper.parent&&e.wrapper.parent.removeChild(e.wrapper),e.anchor&&e.anchor.getStage&&e.anchor.getStage()&&e.anchor.addChild(e.obj)},_syncRecord:function(e,n){var t=n.wrapper;t.visible=!e._pinsHidden;var i=t.parent;if(i){var a=n.anchor.getBounds();a&&t.setBounds(0,0,a.width,a.height);var o=i.getConcatenatedMatrix().invert().appendMatrix(n.anchor.getConcatenatedMatrix()).decompose(r._props);t.x=o.x,t.y=o.y,t.scaleX=o.scaleX,t.scaleY=o.scaleY,t.rotation=o.rotation,t.skewX=o.skewX,t.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;s