Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ define (
return;
}

// If this placeholder has been pinned (Pinner.pinToTop moved it
// into a pin layer), don't drag it back next to the source
// element -- that would rip it out of the pin layer every tick
// and defeat the pin.
if (innerPlaceholder._pinnedToTop) {
return;
}

// Fast path: verify the cached position directly instead of
// scanning the parent's children twice with getChildIndex.
var siblings = element.parent.children;
Expand Down
61 changes: 56 additions & 5 deletions app/scripts/CatLab/Easelbone/EaselJS/Pinner.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ define(
ensureContainer(stage).addChild(wrapper);
wrapper.addChild(obj); // reparents obj out of anchor; obj's own transform/regX/regY untouched

// Flag so other machinery that manages an object's place in the
// display list (e.g. Placeholder.updateZIndex, which re-inserts
// an inner placeholder next to its source element every tick)
// can leave a pinned object where the Pinner put it instead of
// dragging it back and defeating the pin.
obj._pinnedToTop = true;

stage._pinnedElements.push(record);

// Immediate sync: correctly placed even without a running tick.
Expand Down Expand Up @@ -161,6 +168,7 @@ define(
},

_restore: function (record) {
record.obj._pinnedToTop = false;
if (record.obj.parent) {
record.obj.parent.removeChild(record.obj);
}
Expand All @@ -184,16 +192,59 @@ define(
return;
}

// Carry the anchor's bounds so bounds-sizing children (e.g. Fill) size correctly.
var bounds = record.anchor.getBounds();
if (bounds) {
// Positioning needs only the concatenated matrices. Reading
// them walks the anchor/container up to the stage, which can
// throw (or return null) while the tree is still being built
// (e.g. a pin created from a deferred tick during screen init).
// If so, leave the pin where it is and place it on a later sync.
var anchorMatrix;
var containerMatrix;
try {
anchorMatrix = record.anchor.getConcatenatedMatrix();
containerMatrix = container.getConcatenatedMatrix();
} catch (e) {
return;
}

if (!anchorMatrix || !containerMatrix) {
return;
}

// The anchor's bounds are OPTIONAL -- only used so bounds-sizing
// children (e.g. a Fill) can match the anchor's box. getBounds()
// walks the anchor's *subtree* and can throw when a descendant
// has incomplete bounds; that must NOT block positioning, so
// guard it separately and just skip the bounds carry on failure.
// Only touch bounds when they actually change.
var bounds = null;
try {
bounds = record.anchor.getBounds();
} catch (e) {
bounds = null;
}
if (bounds && (record._bw !== bounds.width || record._bh !== bounds.height)) {
record._bw = bounds.width;
record._bh = bounds.height;
wrapper.setBounds(0, 0, bounds.width, bounds.height);
}

// Place the wrapper in the anchor's coordinate space:
// wrapperLocal = pinContainerConcatenated^-1 * anchorConcatenated
var local = container.getConcatenatedMatrix().invert()
.appendMatrix(record.anchor.getConcatenatedMatrix());
var local = containerMatrix.invert().appendMatrix(anchorMatrix);

// Short-circuit: if the resulting transform is bit-identical to
// last frame (a static QR on a still screen), skip the decompose
// and the property writes. The matrix reads above are the same
// inputs -> same outputs, so an exact compare is safe.
var m = record._m || (record._m = { a: 0, b: 0, c: 0, d: 0, tx: 0, ty: 0 });
if (
m.a === local.a && m.b === local.b && m.c === local.c &&
m.d === local.d && m.tx === local.tx && m.ty === local.ty
) {
return;
}
m.a = local.a; m.b = local.b; m.c = local.c;
m.d = local.d; m.tx = local.tx; m.ty = local.ty;

var props = local.decompose(Pinner._props);
wrapper.x = props.x;
Expand Down
4 changes: 2 additions & 2 deletions dist/scripts/easelbone.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"bump:minor": "grunt bump:minor",
"smoke": "node tools/smoke-test.js",
"test:baseline": "node tools/baseline-test.js",
"test:pin": "node tools/pin-test.js"
"test:pin": "node tools/pin-test.js",
"test:pin:placeholder": "node tools/pin-placeholder-test.js"
}
}
23 changes: 23 additions & 0 deletions tools/fixtures/pin-baseline.html
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,29 @@
return false;
};

// Crash guard + positioning: pin against an anchor whose getBounds()
// throws on EVERY access (as a MovieClip with an incomplete-bounds
// descendant does). _syncRecord must (a) not let the exception break
// the tick loop and (b) STILL position the pin from the anchor's
// matrix -- getBounds is only for optional child-sizing and must not
// block positioning. The anchor is at (300,300), so the pinned box
// must render there, not stuck at the pin container's origin.
window.__pinThrowingAnchor = function () {
var throwing = new createjs.Container();
throwing.x = 300; throwing.y = 300;
throwing.setBounds(0, 0, 30, 30);
throwing.getBounds = function () {
throw new TypeError("Cannot read properties of undefined (reading 'x')");
};
var box = new createjs.Shape();
box.graphics.beginFill('#00ff00').drawRect(0, 0, 30, 30);
throwing.addChild(box);
anchorLayer.container.addChild(throwing);
easelbone.pinToTop(box); // anchor = throwing; getBounds always throws
root.render();
root.render();
};

root.render();
window.__ready = true;
});
Expand Down
79 changes: 79 additions & 0 deletions tools/fixtures/pin-placeholder.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head lang="en"><title>Pinner + Placeholder z-index repro</title><style>body{margin:0}</style></head>
<body>
<canvas id="canvas" width="400" height="400"></canvas>
<script src="/app/scripts/vendor/requirejs/require.js"></script>
<script>
// Pinning a Placeholder must survive Placeholder.updateZIndex, which runs
// every tick of the source element and re-inserts the inner placeholder
// next to it. If it drags the pinned placeholder back into the screen, the
// pinned content falls BELOW the occluder (the emoji layer). We pin the
// inner placeholder, add a GREEN box to it, tick a few times, and check
// the box still paints ON TOP of the BLUE occluder.
require.config({ baseUrl: '/app/scripts/' });
require(['main'], function () {
require([
'easeljs',
'CatLab/Easelbone/Views/Root',
'CatLab/Easelbone/FrontController',
'CatLab/Easelbone/Utilities/MovieClipHelper'
], function (createjs, RootView, easelbone, helper) {
var root = new RootView({ canvas: document.getElementById('canvas') });

var bg = new createjs.Shape();
bg.graphics.beginFill('#000').drawRect(0, 0, 400, 400);
root.getLayer('main').container.addChild(bg);

// Screen movieclip with a named qrCode child at (100,100).
var mcLayer = root.nextLayer('mc');
var screen = new createjs.Container();
var qrCodeEl = new createjs.Container();
qrCodeEl.name = 'qrCode';
qrCodeEl.x = 100; qrCodeEl.y = 100;
qrCodeEl.setBounds(0, 0, 50, 50);
var pink = new createjs.Shape();
pink.graphics.beginFill('#ff00ff').drawRect(0, 0, 50, 50);
qrCodeEl.addChild(pink);
screen.qrCode = qrCodeEl; // Animate-style named property
screen.addChild(qrCodeEl);
mcLayer.container.addChild(screen);

// Full-canvas BLUE occluder (the emoji layer).
var occLayer = root.nextLayer('occluder');
var blue = new createjs.Shape();
blue.graphics.beginFill('#0000ff').drawRect(0, 0, 400, 400);
occLayer.container.addChild(blue);

var pinLayer = root.nextLayer('pin');
root.setPinContainer(pinLayer.container);

// Turn qrCode into a Placeholder and pin the placeholder itself.
var placeholder = helper.findPlaceholders('qrCode', screen)[0];
root.render(); // let updateBounds run so the placeholder is sized/placed
easelbone.pinToTop(placeholder);
var green = new createjs.Shape();
green.graphics.beginFill('#00ff00').drawRect(0, 0, 50, 50);
placeholder.addChild(green);
root.render();

window.__centerRGB = function () {
var d = document.getElementById('canvas').getContext('2d').getImageData(125, 125, 1, 1).data;
return [d[0], d[1], d[2]];
};
window.__tick = function () {
// Drive several ticks+renders so updateZIndex runs repeatedly.
for (var i = 0; i < 5; i++) {
createjs.Ticker.dispatchEvent('tick');
root.render();
}
};
window.__placeholderInPinLayer = function () {
return placeholder.parent && placeholder.parent.parent === pinLayer.container;
};
window.__ready = true;
});
});
</script>
</body>
</html>
44 changes: 44 additions & 0 deletions tools/pin-placeholder-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Verifies a pinned Placeholder stays in the pin layer despite
// Placeholder.updateZIndex re-inserting inner placeholders each tick.
// Serves the repo root itself. Usage: node tools/pin-placeholder-test.js [port]
var spawn = require('child_process').spawn;
var chromium = require('playwright').chromium;

var FIXTURE = 'tools/fixtures/pin-placeholder.html';
var PORT = parseInt(process.argv[2], 10) || 8171;

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 });

var initial = await page.evaluate('window.__centerRGB()');
if (!isGreen(initial)) { failures.push('expected pinned placeholder GREEN at (125,125), got ' + initial); }

// Run several ticks so Placeholder.updateZIndex fires repeatedly.
await page.evaluate('window.__tick()');
var afterTicks = await page.evaluate('window.__centerRGB()');
var inPinLayer = await page.evaluate('window.__placeholderInPinLayer()');
if (!isGreen(afterTicks)) { failures.push('after ticks: expected GREEN to stay on top at (125,125), got ' + afterTicks + ' (updateZIndex dragged the placeholder back below the occluder)'); }
if (!inPinLayer) { failures.push('after ticks: expected pinned placeholder to remain in the pin layer'); }

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();
7 changes: 7 additions & 0 deletions tools/pin-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ async function main() {
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'); }

// Crash guard: an anchor whose getBounds() throws on first access must
// not break the tick loop; the pin still places once it stops throwing.
await page.evaluate('window.__pinThrowingAnchor()');
await page.waitForTimeout(100);
var throwSpot = await pixel(page, 315, 315);
if (!isGreen(throwSpot)) { failures.push('throwing-anchor: expected GREEN at (315,315), got ' + throwSpot); }

if (errors.length) { failures.push('page errors: ' + errors.join('; ')); }
await page.close();
} finally {
Expand Down
Loading