Pane resizing and inspector control

shared/frame.js

// Shared presentation controls; framework rendering remains inside its iframe.
function resizePane(grid, handle, first, property, key, initial, percent, minimum, reserve) {
    if (!grid || !handle) return;
    const bounds = () => [minimum, Math.max(minimum, Math.min(grid.clientWidth - reserve,
        percent ? Infinity : window.innerWidth * .35))];
    const width = () => first.getBoundingClientRect().width;
    function apply(value) {
        const [min, max] = bounds();
        const pixels = Math.min(max, Math.max(min, value));
        const setting = percent ? `${pixels / grid.clientWidth * 100}%` : `${pixels}px`;
        grid.style.setProperty(property, setting);
        handle.setAttribute('aria-valuemin', Math.round(min));
        handle.setAttribute('aria-valuemax', Math.round(max));
        handle.setAttribute('aria-valuenow', Math.round(pixels));
        handle.setAttribute('aria-valuetext', `${Math.round(pixels)} pixels`);
        try { localStorage.setItem(key, setting); } catch { /* Storage is optional. */ }
    }
    try {
        const saved = localStorage.getItem(key);
        if (saved && /^\d+(\.\d+)?(px|%)$/.test(saved)) grid.style.setProperty(property, saved);
    } catch { /* Storage is optional. */ }
    handle.title = 'Drag to resize · Arrow keys to adjust · Double-click to reset';
    handle.setAttribute('aria-valuemin', minimum);
    handle.setAttribute('aria-valuemax', Math.round(bounds()[1]));
    handle.setAttribute('aria-valuenow', Math.round(width()));
    let drag;
    handle.addEventListener('pointerdown', event => {
        if (event.button !== 0) return;
        drag = {x: event.clientX, width: width()};
        handle.setPointerCapture(event.pointerId);
        document.body.classList.add('resizing');
        event.preventDefault();
    });
    handle.addEventListener('pointermove', event => {
        if (drag) apply(drag.width + event.clientX - drag.x);
    });
    function finish() { drag = null; document.body.classList.remove('resizing'); }
    handle.addEventListener('pointerup', finish);
    handle.addEventListener('pointercancel', finish);
    handle.addEventListener('lostpointercapture', finish);
    handle.addEventListener('keydown', event => {
        const [min, max] = bounds();
        const step = event.shiftKey ? 40 : 10;
        const values = {ArrowLeft: width() - step, ArrowRight: width() + step, Home: min, End: max};
        if (!(event.key in values)) return;
        event.preventDefault();
        apply(values[event.key]);
    });
    handle.addEventListener('dblclick', () => {
        grid.style.setProperty(property, initial);
        try { localStorage.removeItem(key); } catch { /* Storage is optional. */ }
        handle.setAttribute('aria-valuenow', Math.round(width()));
    });
}
resizePane(document.querySelector('.master-layout'), document.querySelector('.tree-splitter'),
    document.querySelector('.lesson-tree'), '--tree-width', 'rosetta-tree-width', '195px', false, 140, 480);
resizePane(document.querySelector('.rosetta-layout'), document.querySelector('.example-splitter'),
    document.querySelector('.example-panel'), '--live-width', 'rosetta-live-width', '35%', true, 160, 240);

const inspectorButton = document.querySelector('.inspector-tool');
const exampleFrame = document.querySelector('.example-panel iframe');
let inspectorElement;
let inspectedApplication;
let opening;
let observer;

function disposeInspector() {
    inspectorElement?.dispose();
    inspectorElement?.remove();
    inspectorElement = null;
    inspectedApplication = null;
}
function watchExample() {
    observer?.disconnect();
    disposeInspector();
    const root = exampleFrame?.contentDocument?.getElementById('root');
    if (!root) return;
    observer = new MutationObserver(() => {
        if (inspectedApplication?._disposed) disposeInspector();
    });
    observer.observe(root, {childList: true, subtree: true});
}
async function openInspector() {
    if (inspectorElement && !inspectedApplication?._disposed) {
        inspectorElement.opened = !inspectorElement.opened;
        return;
    }
    const doc = exampleFrame?.contentDocument;
    const launcher = doc?.querySelector('.gramlot-inspector-launcher');
    if (!launcher) return;
    // Obtain the running application through the library's lazy inspector component.
    launcher.click();
    const original = await new Promise((resolve, reject) => {
        const existing = doc.querySelector('gramlot-inspector');
        if (existing) { resolve(existing); return; }
        const changes = new MutationObserver(() => {
            const component = doc.querySelector('gramlot-inspector');
            if (component) { clearTimeout(timeout); changes.disconnect(); resolve(component); }
        });
        const timeout = setTimeout(() => {
            changes.disconnect(); reject(new Error('Inspector is not available yet. Please try again.'));
        }, 5000);
        changes.observe(doc.body, {childList: true, subtree: true});
    });
    const application = original.application;
    await application.inspector.open();
    if (application._disposed) return;
    // Create in the example's realm so typed Bags and widgets keep their identity.
    // Connect to the master BEFORE initialization: moving an initialized component
    // would invoke its disposal lifecycle. Palette bounds now use the master document.
    const external = new original.constructor();
    external.application = application;
    external.setAttribute('presentation', 'floating');
    application.inspector.dispose();
    document.body.append(external);
    inspectorElement = external;
    inspectedApplication = application;
    try {
        await external.initialize();
        if (application._disposed) { disposeInspector(); return; }
        external.opened = true;
    } catch (error) { disposeInspector(); throw error; }
}
inspectorButton?.addEventListener('click', () => {
    if (opening) return;
    opening = openInspector().catch(error => {
        inspectorButton.title = error.message;
    }).finally(() => { opening = null; });
});
exampleFrame?.addEventListener('load', watchExample);
if (exampleFrame?.contentDocument?.readyState === 'complete') watchExample();
window.addEventListener('pagehide', () => { observer?.disconnect(); disposeInspector(); });