@pinegrow/piny-vite
Version:
A Vite plugin that implements Piny integration in dev mode.
639 lines (546 loc) • 20.7 kB
JavaScript
class PinyPhone {
constructor() {
const _this = this;
this.version = 3;
this.runs_in_piny = false;
this.snapTimer = null;
this.observer = null;
this.observer_enabled = false;
this.debug = false;
this.log(`PinyPhone version ${this.version} is loaded. Not yet active.`)
this.id_count = 1;
this.id_to_el = {}
this.react_key = null;
this.options = {
fiber_use_loc: true
}
window.addEventListener("message", function(event) {
const m = event.data;
if(m?.from === 'pinegrow') {
switch(m.message) {
case 'hello':
_this.runs_in_piny = true;
if(m.options) {
_this.options = {..._this.options, ...m.options};
}
_this.sendMessage('hello', {});
break;
case 'scroll':
if(window.getComputedStyle(document.scrollingElement).scrollBehavior === 'smooth') {
document.scrollingElement.style.scrollBehavior = 'auto';
}
document.scrollingElement.scrollTop = m.scrollTop;
document.scrollingElement.scrollLeft = m.scrollLeft;
break;
case 'request_snap':
_this.snap(false);
break;
case 'request_debug':
_this.getDebugSnapshot(false);
break;
case 'enable_observer':
_this.enableObserver();
break;
case 'disable_observer':
_this.disableObserver();
break;
case 'request_sync_scroll':
_this.sendMessage('sync_scroll', {
scrollTop: document.scrollingElement.scrollTop,
scrollLeft: document.scrollingElement.scrollLeft
})
break;
}
}
}, false);
let current_route_url = null;
setInterval(function() {
if(_this.runs_in_piny) {
const href = window.location.pathname + window.location.search + window.location.hash;
if (current_route_url !== href) {
_this.sendMessage('route_change', href)
current_route_url = href;
}
}
}, 1000)
const KEY = '_piny_scroll_y';
window.addEventListener('beforeunload', function() {
if(_this.observer_enabled) {
sessionStorage.setItem(KEY, String(document.scrollingElement.scrollTop))
} else {
sessionStorage.removeItem(KEY)
}
});
window.addEventListener('DOMContentLoaded', () => {
const y = sessionStorage.getItem(KEY);
if (y) {
console.log('[Piny Phone] Scrolling to ' + y);
setTimeout(function() {
if(window.getComputedStyle(document.scrollingElement).scrollBehavior === 'smooth') {
document.scrollingElement.style.scrollBehavior = 'auto';
}
document.scrollingElement.scrollTop = +y;
}, 500)
}
});
}
isEnabled() {
return this.observer_enabled;
}
enableObserver() {
const _this = this;
const config = {attributes: true, childList: true, subtree: true};
const callback = (mutationList, observer) => {
if (_this.snapTimer) {
clearTimeout(_this.snapTimer)
}
_this.snapTimer = setTimeout(function () {
_this.snap(true)
}, 500)
};
if(!this.observer) {
this.observer = new MutationObserver(callback);
}
this.observer.observe(document.documentElement, config);
this.observer_enabled = true;
this.log('Observer is enabled.')
}
disableObserver() {
if(this.observer) {
this.observer.disconnect()
}
this.observer_enabled = false;
this.log('Observer is disabled.')
}
sendMessage(msg, data) {
window.parent.postMessage({
from: 'pinegrowPhone',
message: msg,
data: data
}, '*')
}
log(msg, a) {
this.debug && console.log('pinegrowPhone: ' + msg, a)
}
getReactKey(el) {
if(this.react_key) return this.react_key;
for (let attr in el) {
if (attr.startsWith('__reactFiber')) {
this.react_key = attr;
return attr;
}
}
}
snap(observer) {
const _this = this;
if(this.snapTimer) {
clearTimeout(this.snapTimer)
this.snapTimer = null;
}
const st = Date.now();
this.id_to_el = {}
const scroll_top = document.scrollingElement.scrollTop;
const scroll_left = document.scrollingElement.scrollLeft;
const root = {
route: window.location.pathname + window.location.search + window.location.hash,
children: [],
rect: {
x: 0,
y: 0,
ox: 0,
oy: 0
},
sl: scroll_left,
st: scroll_top,
css: {},
source_observer: observer
}
function getReactContainer(el) {
if(el.nodeType === 9 || el.nodeType === 1) {
for (let attr in el) {
if (attr.startsWith('__reactContainer')) {
return {
el: el,
f: el[attr],
attr: attr
};
}
}
}
}
function walk(el, current) {
let c = null;
if(el.nodeName === 'HEAD') return;
if(c = getReactContainer(el)) {
let root_fiber = c.f;
if(root_fiber?.stateNode?.current) {
root_fiber = root_fiber.stateNode.current;
}
_this.getReactTree(root_fiber, current, scroll_left, scroll_top);
} else {
if(el.nodeType === 1) {
const d = _this.doElement(el, current, scroll_left, scroll_top, [], null, true)
if(d === 'skip') return;
if(d) {
current.children.push(d);
d.parent = current;
current = d;
}
}
if(el.children) {
for(let i = 0; i < el.children.length; i++) {
walk(el.children[i], current);
}
}
}
}
const data = root;
walk(document, data);
data.scrollHeight = document.scrollingElement.scrollHeight;
data.scrollWidth = document.scrollingElement.scrollWidth;
//remove parent props
function rp(n) {
if(n.parent) {
delete n.parent;
}
n.children.forEach(rp);
}
rp(data)
this.sendMessage('snap', data)
this.log(`snap took ${Date.now() - st}ms`, data);
if(this.debug && false) {
let s = '';
function dolevel(d, prefix) {
s += `\n${prefix} ${d.name || d.tag} ${d.source ? `${d.source.file.split('/').pop()}, ${d.source.line}, ${d.source.character}` : '-'}`
if(d.debug) {
s += `${d.css?.position || ''} (${d.debug.rect.x}, ${d.debug.rect.y}, ${d.debug.rect.width}, ${d.debug.rect.height})`
}
d.children.forEach(function (ch) {
dolevel(ch, prefix + '--');
})
}
dolevel(data, '')
this.log(s)
}
}
getElementById(el_id) {
return this.id_to_el[el_id] || null;
}
getElementId(el) {
return el.__pinegrow_id || null;
}
getReactTree(app_root_f, root, scroll_left, scroll_top) {
const _this = this;
function firstChildWithNode(f) {
let i = 0;
while(f) {
if(f.stateNode?.getBoundingClientRect) {
return f;
}
f = f.child;
i++;
if(i === 100) break;
}
return null;
}
function walk(f, current) {
let add_child_to = current;
const fiber_with_state = firstChildWithNode(f)
let d = null;
if(!f.elementType && !f.ref && !f.type && !f.stateNode && !f._debugInfo) {
//skip this one
} else if(fiber_with_state) {
d = _this.doElement(fiber_with_state.stateNode, current, scroll_left, scroll_top, [], f, f === fiber_with_state);
if(d && d !== 'skip') {
add_child_to.children.push(d)
add_child_to = d;
}
}
if(f.child && f.type !== 'svg' && d !== 'skip') {
walk(f.child, add_child_to)
}
if(f.sibling) {
walk(f.sibling, current);
}
}
walk(app_root_f, root)
}
getComponentNameFromFiber(fiber) {
if(!fiber) return null;
if(fiber._debugInfo) {
return fiber._debugInfo?.name;
}
if(typeof fiber.type === 'function') {
return fiber.type.name;
}
if(typeof fiber.type === 'object') {
if(fiber.type?.render) {
return fiber.type.displayName || fiber.type.render.name || 'Anonymous';
}
}
return null;
}
getDebugInfo(fiber, ignore_fibers) {
if(!fiber) return null;
//if(ignore_fibers && ignore_fibers.indexOf(fiber) >= 0) return null;
if(fiber._debugInfo) {
ignore_fibers.push(fiber)
return fiber._debugInfo;
}
const name = this.getComponentNameFromFiber(fiber)
if(name) {
return [{
name: name
}]
}
return;
while(fiber._debugOwner) {
fiber = fiber._debugOwner;
const di = this.getDebugInfo(fiber, ignore_fibers);
if(di) return di;
}
}
doElement(element, current, scroll_left, scroll_top, parent_fibers, fiber, use_elid) {
const rect = element.getBoundingClientRect();
let data = null;
let subdata = null;
const tag = element.nodeName.toLowerCase();
if(tag === 'script') return;
const elid = element.__pinegrow_id || ('el' + (++this.id_count));
element.__pinegrow_id = elid;
this.id_to_el[elid] = element;
const computedStyle = window.getComputedStyle(element);
const isSticky = use_elid && computedStyle.position === 'sticky';
const isFixedOrSticky = (use_elid && computedStyle.position === 'fixed') || isSticky;
if(use_elid) {
let isHidden = computedStyle.display === 'none' || computedStyle.visibility === 'hidden';
if ((rect.width === 0 || rect.height === 0) && (computedStyle.overflow === 'scroll' || computedStyle.overflow === 'hidden')) {
isHidden = true;
}
if (isHidden) return 'skip';
}
const css = {};
if(use_elid && isFixedOrSticky) {
css.position = computedStyle.position;
}
if(computedStyle.zIndex !== 'auto') {
css['z-index'] = computedStyle.zIndex;
}
const props = {};
let source = null;
let owner_name = null;
let dis = null;
if(fiber) {
let _debugSource = null;
if (typeof fiber.memoizedProps === 'object') {
for (let prop in fiber.memoizedProps) {
if (prop !== 'className' && prop !== 'children') {
let val = fiber.memoizedProps[prop];
if (typeof val !== 'string' && typeof val !== 'number') {
val = '...';
}
props[prop] = val;
}
}
}
if (fiber.key) {
props.key = fiber.key.toString();
}
_debugSource = fiber._debugSource;
if(!_debugSource && fiber._debugOwner?._debugSource) {
_debugSource = fiber._debugOwner._debugSource;
}
if(_debugSource) {
source = {
line: _debugSource.lineNumber - 1,
character: _debugSource.columnNumber - 1,
file: _debugSource.fileName
};
if(!this.options.fiber_use_loc) {
delete source.line;
delete source.character;
}
}
owner_name = fiber._debugOwner ? this.getComponentNameFromFiber(fiber._debugOwner) : null;
dis = this.getDebugInfo(fiber, parent_fibers);
}
if(!source) {
const file = element.getAttribute('data-pg-source-file');
if(file) {
const pos = element.getAttribute('data-pg-source-loc');
if(pos) {
const a = pos.split(':');
source = {
line: parseInt(a[0]) - 1,
character: parseInt(a[1]) - 1,
file: file
};
}
if(!dis) {
let p = current;
let start_comp = true;
while(p) {
if (p.source?.file === file) {
start_comp = false;
break;
}
p = p.parent;
}
if(start_comp) {
const file_name = file.split(/[\/\\]/).pop();
const a = file_name.split('.');
a.pop();
const name = a.join('.');
if (name) {
dis = [{name: name}]
}
}
}
}
}
let debug = null;
if(this.debug) {
debug = {
rect: rect
};
}
// Compute final x,y. If 'fixed' or 'sticky', don't subtract parent's offsets.
const offsetX = isFixedOrSticky
? rect.x + (isSticky ? 0 : scroll_left)
: rect.x + scroll_left - current.rect.ox;
const offsetY = isFixedOrSticky
? rect.y + (isSticky ? 0 : scroll_top)
: rect.y + scroll_top - current.rect.oy;
// We gather all debug info "frames" from the fiber, if any
if(dis?.length) {
let first = true;
dis.forEach((di) => {
const el_data = {
name: di.name,
pgid: (first && use_elid) ? elid : null,
rect: {
x: first ? offsetX : 0,
y: first ? offsetY : 0,
w: rect.width,
h: rect.height,
ox: rect.x + scroll_left,
oy: rect.y + scroll_top
},
class: element.getAttribute('class'),
source: source,
children: [],
owner: owner_name,
st: first ? element.scrollTop : 0,
sl: first ? element.scrollLeft : 0,
props,
css,
debug
};
first = false;
if(!data) {
data = el_data;
} else {
subdata = el_data;
current.children.push(subdata);
}
// push future "frames" into the new object
current = el_data;
});
} else {
// Fallback for normal DOM element
data = {
tag: tag,
pgid: use_elid ? elid : null,
rect: {
x: offsetX,
y: offsetY,
w: rect.width,
h: rect.height,
ox: rect.x + scroll_left,
oy: rect.y + scroll_top
},
class: element.getAttribute('class'),
source: source,
children: [],
owner: owner_name,
sl: element.scrollLeft,
st: element.scrollTop,
props,
css,
debug
};
}
return data;
}
getDebugSnapshot() {
const _this = this;
function walk(el, current) {
if(el.nodeType !== 9 && el.nodeType !== 1) return;
const d = {
nodeType: el.nodeType,
}
if(el.nodeType === 1) {
const rect = el.getBoundingClientRect()
d.tag = el.nodeName;
d.rect = {
x: rect.x,
y: rect.y,
w: rect.width,
h: rect.height
}
const computedStyle = window.getComputedStyle(el);
d.sticky = computedStyle.position === 'sticky';
d.fixed = computedStyle.position === 'fixed';
}
for (let attr in el) {
if (attr.startsWith('__reactContainer')) {
let root_fiber = el[attr];
if(root_fiber?.stateNode?.current) {
root_fiber = root_fiber.stateNode.current;
}
const tree_data = {
children: [],
rect: {
x: 0,
y: 0,
ox: 0,
oy: 0
},
sl: 0,
st: 0,
}
_this.getReactTree(root_fiber, tree_data, 0, 0)
d.reactContainer = attr;
d.reactFiberTree = tree_data;
}
if (attr.startsWith('__reactFiber')) {
const f = el[attr];
d.reactFiber = {
attr: attr,
stateNode: f.stateNode ? f.stateNode?.nodeName : null,
};
}
}
current.children.push(d);
if(el.children) {
d.children = [];
for(let i = 0; i < el.children.length; i++) {
walk(el.children[i], d);
}
}
}
const data = {
version: this.version,
children: []
}
try {
walk(document, data);
console.log('[piny phone] debug snapshot', data);
} catch(err) {
console.error(`[piny phone]`, err)
}
this.sendMessage('debug', data)
return data;
}
}
window.pinyPhone = new PinyPhone();