SF映画っぽいエクスプローラ「Exp3D 1.5」


画像
画像

ダウンロードされる方はこちら。↓


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Exp3D 1.5</title>
    <style>
        :root {
            --cyan: #00ffff;
            --magenta: #ff00ff;
            --orange: #ffaa00;
            --yellow: #ffee00;
            --blue: #0088ff;
            --bg: #010103;
            --panel: rgba(5, 10, 15, 0.95);
        }
        /* Body background provides the base darkness */
        body { margin: 0; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', monospace; user-select: none; color: #fff; }

        /* App Layout (Split View) */
        #app { display: flex; width: 100vw; height: 100vh; position: relative; overflow: hidden; }

        /* Panes - MUST BE TRANSPARENT to see the canvas underneath */
        #pane-tree { 
            flex: 0 0 25%; position: relative; overflow: hidden; 
            border-right: 1px solid #333; 
            background: transparent; /* Changed from opaque to transparent */
            z-index: 10; pointer-events: none; /* Allow events to pass through to canvas logic if needed, but we handle via window events */
        }
        #pane-file { 
            flex: 1; position: relative; overflow: hidden; 
            background: transparent; 
            z-index: 10; pointer-events: none; 
        }
        
        /* Interactive elements inside panes must re-enable pointer-events */
        .pane-label, .sys-btn, .stock-item, .stock-tab-item, #stock, #stock-tabs-container {
            pointer-events: auto;
        }

        /* Splitter Handle */
        #splitter {
            width: 6px; cursor: col-resize; background: #111; border-left: 1px solid #444; border-right: 1px solid #444;
            display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100;
            transition: background 0.2s; pointer-events: auto;
        }
        #splitter:hover, #splitter.active { background: var(--orange); }
        .grip { width: 2px; height: 4px; background: #666; margin: 2px 0; }

        /* Canvas Container - Sitting behind the panes */
        #canvas-wrap {
            position: absolute; top: 0; left: 0; width: 100%; height: 100%; 
            z-index: 0; /* Behind everything */
            pointer-events: auto; /* Canvas needs to receive events? We use window listener actually. */
        }
        canvas { display: block; outline: none; }

        /* HUD & UI Overlays */
        #hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 50; }
        #hud-canvas { width: 100%; height: 100%; display: block; }

        #top-bar {
            position: absolute; top: 0; left: 0; width: 100%; height: 40px;
            background: linear-gradient(to bottom, rgba(0,20,40,0.9), transparent);
            display: flex; align-items: center; justify-content: space-between; padding: 0 20px;
            z-index: 60; pointer-events: none;
        }
        .sys-title { font-size: 16px; font-weight: bold; color: var(--cyan); letter-spacing: 2px; pointer-events: auto; }
        .sys-btn { 
            background: rgba(0,0,0,0.5); border: 1px solid var(--cyan); color: var(--cyan);
            padding: 4px 12px; cursor: pointer; font-family: inherit; font-weight: bold; font-size: 12px;
            pointer-events: auto; transition: 0.3s;
        }
        .sys-btn:hover { background: var(--cyan); color: #000; box-shadow: 0 0 10px var(--cyan); }

        .pane-label {
            position: absolute; bottom: 10px; left: 10px; font-size: 12px; 
            color: rgba(255,255,255,0.3); pointer-events: none; z-index: 10;
        }
        #mode-info {
            position: absolute; top: 50px; right: 20px; text-align: right; pointer-events: none;
            color: var(--yellow); text-shadow: 0 0 5px var(--yellow); font-weight: bold; font-size: 18px; z-index: 20;
        }

        /* Async Loader */
        #loader {
            position: absolute; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.85); display: flex; flex-direction: column;
            justify-content: center; align-items: center; z-index: 200;
            display: none; pointer-events: auto;
        }
        #loader.active { display: flex; }
        .loader-text { color: var(--cyan); font-size: 24px; margin-bottom: 20px; animation: blink 1s infinite; letter-spacing: 2px; }
        @keyframes blink { 50% { opacity: 0.5; } }

        /* STOCK PANEL */
        #stock {
            position: absolute; bottom: 0; left: 0; right: 0; height: 180px;
            background: linear-gradient(to top, rgba(20,10,0,0.95), transparent);
            border-top: 2px solid var(--orange); display: flex; align-items: center; 
            padding: 0 50px; gap: 40px; 
            z-index: 40; transition: bottom 0.3s; overflow-x: hidden;
            cursor: grab; pointer-events: auto;
        }
        #stock.active { cursor: grabbing; }
        #stock.closed { bottom: -180px; }
        
        #stock-tabs-container {
            position: absolute; bottom: 180px; left: 50%; transform: translateX(-50%);
            display: flex; gap: 5px; z-index: 41; transition: bottom 0.3s; pointer-events: auto;
        }
        #stock.closed + #stock-tabs-container { bottom: 0; }
        
        .stock-tab-item {
            background: rgba(50, 20, 0, 0.8); color: #aaa; 
            padding: 2px 20px; font-weight: bold; font-size: 12px;
            cursor: pointer; clip-path: polygon(10% 0, 90% 0, 100% 100%, 0% 100%);
            border-bottom: none; transition: 0.2s; min-width: 60px; text-align: center;
            border-top: 2px solid #553300; pointer-events: auto;
        }
        .stock-tab-item:hover { color: #fff; background: rgba(100, 50, 0, 0.8); }
        .stock-tab-item.active { background: var(--orange); color: #000; border-top: 2px solid #fff; }
        
        .stock-item {
            width: 100px; height: 130px; 
            background: rgba(0,0,0,0.3); border: 1px solid var(--orange);
            display: flex; flex-direction: column; align-items: center; 
            cursor: grab; color: var(--orange); flex-shrink: 0; position: relative;
            transition: 0.2s; user-select: none; pointer-events: auto;
        }
        .stock-item:hover { background: rgba(255,170,0,0.1); box-shadow: 0 0 10px rgba(255,170,0,0.2); }
        .stock-thumb { width: 90px; height: 90px; margin-top: 5px; object-fit: contain; background: #000; display:flex; justify-content:center; align-items:center; overflow:hidden; }
        .stock-label { font-size: 11px; margin-top: 5px; width: 90px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: bold; }

        /* Context Menu */
        .ctx-menu {
            position: fixed; display: none; flex-direction: column;
            background: rgba(10, 15, 20, 0.98); border: 1px solid var(--cyan);
            box-shadow: 0 0 20px rgba(0,255,255,0.3); min-width: 200px; z-index: 9999;
            transform-origin: top; padding: 5px 0; overflow: hidden; pointer-events: auto;
        }
        .ctx-menu.opening { animation: menuOpen 0.2s ease-out forwards; }
        @keyframes menuOpen { from { transform: scaleY(0); opacity: 0; } to { transform: scaleY(1); opacity: 1; } }
        .ctx-item { padding: 10px 15px; cursor: pointer; color: #ccc; display: flex; justify-content: space-between; border-bottom: 1px solid #333; transition: background 0.1s; }
        .ctx-item:hover { background: var(--cyan); color: #000; }
        .ctx-menu.closing .ctx-item { animation: itemFold 0.2s forwards ease-in; }
        @keyframes itemFold { 0% { transform: translateY(0); opacity: 1; height: 40px; } 100% { transform: translateY(-20px); opacity: 0; height: 0; padding: 0; border: none; } }

        /* Modals */
        .modal-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8);
            display: flex; justify-content: center; align-items: center; z-index: 300;
            opacity: 0; pointer-events: none; visibility: hidden; transition: opacity 0.3s;
            perspective: 1500px;
        }
        .modal-overlay.active { opacity: 1; pointer-events: auto; visibility: visible; }
        
        .modal-box { 
            border: 1px solid var(--cyan); background: #000; 
            display: flex; flex-direction: column; box-shadow: 0 0 50px rgba(0,255,255,0.2); 
            transform-style: preserve-3d; opacity: 0; transform-origin: center;
        }
        .modal-overlay.opening .modal-box { animation: animFlipOpen 0.6s cubic-bezier(0.19, 1, 0.22, 1) forwards; }
        .modal-overlay.closing .modal-box { animation: animFlipClose 0.5s cubic-bezier(0.19, 1, 0.22, 1) forwards; }
        @keyframes animFlipOpen { 0% { transform: translateZ(-500px) rotateX(180deg); opacity: 0; } 100% { transform: translateZ(0) rotateX(0deg); opacity: 1; } }
        @keyframes animFlipClose { 0% { transform: translateZ(0) rotateX(0deg); opacity: 1; } 100% { transform: translateZ(-500px) rotateX(-180deg); opacity: 0; } }

        .modal-head { padding: 10px; background: rgba(0,255,255,0.15); color: var(--cyan); display: flex; justify-content: space-between; align-items: center; font-weight: bold; }
        .modal-body { flex: 1; padding: 0; overflow: hidden; position: relative; background: #050505; display: flex; justify-content: center; align-items: center; }
        .p-btn { background: transparent; border: 1px solid var(--cyan); color: var(--cyan); padding: 5px 15px; margin-left: 10px; cursor: pointer; transition: 0.2s; }
        .p-btn:hover { background: var(--cyan); color: #000; }
        .p-btn-save { display: none; border-color: #0f0; color: #0f0; }

        #p-box { width: 90%; height: 90%; }
        .hex-view { width: 100%; height: 100%; padding: 15px; color: #0f0; background: #000; border: none; resize: none; font-family: 'Courier New', monospace; outline: none; }
        #d-box { width: 400px; height: 200px; }
        #d-body { flex-direction: column; padding: 20px; gap: 20px; }
        .d-input { width: 100%; background: #111; border: 1px solid #333; color: #fff; padding: 10px; font-family: 'Segoe UI', monospace; font-size: 16px; outline: none; text-align: center; }
        .d-input:focus { border-color: var(--cyan); }
        .d-actions { display: flex; gap: 10px; width: 100%; justify-content: center; }

        .scanlines {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;
            background: linear-gradient(to bottom, rgba(255,255,255,0) 50%, rgba(0,0,0,0.1) 50%);
            background-size: 100% 3px; z-index: 9000; opacity: 0.15;
        }
    </style>
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
            }
        }
    </script>
</head>
<body>
    <div id="app">
        <div id="canvas-wrap"></div>

        <div id="pane-tree">
            <div class="pane-label">SYSTEM // TREE</div>
        </div>

        <div id="splitter">
            <div class="grip"></div><div class="grip"></div><div class="grip"></div>
        </div>

        <div id="pane-file">
            <div class="pane-label">SYSTEM // OBJECTS</div>
            
            <div id="hud"><canvas id="hud-canvas"></canvas></div>

            <div id="stock" class="closed">
                <div id="stock-hint" style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY STOCK ]</div>
            </div>
            <div id="stock-tabs-container">
                <div id="stock-tab-placeholder" class="stock-tab-item" style="opacity:0.5; pointer-events:none;">STOCK</div>
            </div>
        </div>

        <div id="top-bar">
            <div class="sys-title">EXP3D // DUAL_PANE</div>
            <div>
                 <button id="btn-mount" class="sys-btn">MOUNT DRIVE</button>
            </div>
        </div>
        <div id="mode-info">MODE: F1 (VERTICAL)</div>

        <div id="loader">
            <div class="loader-text">READING DATA...</div>
            <button id="btn-cancel-load" class="p-btn" style="border-color:#ff5555; color:#ff5555;">CLOSE</button>
        </div>

        <div id="ctx-obj" class="ctx-menu">
            <div class="ctx-item" id="cm-open">Open</div>
            <div class="ctx-item" id="cm-copy">Copy</div>
            <div class="ctx-item" id="cm-rename">Rename</div>
            <div class="ctx-item" id="cm-add-stock" style="color:var(--orange)">Add to Stock</div>
            <div class="ctx-item" id="cm-delete" style="color:#ff5555">Delete</div>
        </div>
        <div id="ctx-bg" class="ctx-menu">
            <div class="ctx-item" id="cm-new">New Folder</div>
            <div class="ctx-item" id="cm-paste">Paste</div>
            <div class="ctx-item" id="cm-up">Up Directory</div>
            <div class="ctx-item" id="cm-refresh">Refresh</div>
        </div>
        <div id="ctx-tab" class="ctx-menu">
            <div class="ctx-item" id="cm-tab-hide" style="color:var(--orange)">Hide</div>
            <div class="ctx-item" id="cm-tab-cancel" style="color:#ff5555">Cancel</div>
        </div>
    </div>

    <div id="preview" class="modal-overlay">
        <div id="p-box" class="modal-box">
            <div class="modal-head">
                <span id="p-title">FILE</span>
                <div>
                    <button id="p-save" class="p-btn p-btn-save">SAVE</button>
                    <button id="p-close" class="p-btn">CLOSE</button>
                </div>
            </div>
            <div id="p-body" class="modal-body"></div>
        </div>
    </div>

    <div id="dialog" class="modal-overlay">
        <div id="d-box" class="modal-box">
            <div class="modal-head">
                <span id="d-title">INPUT</span>
            </div>
            <div id="d-body" class="modal-body">
                <input type="text" id="d-input" class="d-input" spellcheck="false" autocomplete="off">
                <div class="d-actions">
                    <button id="d-ok" class="p-btn" style="border-color:var(--cyan); color:var(--cyan); width:100px;">OK</button>
                    <button id="d-cancel" class="p-btn" style="border-color:#ff5555; color:#ff5555; width:100px;">CANCEL</button>
                </div>
            </div>
        </div>
    </div>

    <div class="scanlines"></div>

    <script type="module">
        import * as THREE from 'three';

        // --- Config & State ---
        const CONFIG = {
            colFolder: 0x00ffff, colFile: 0x0088ff, colActive: 0xffaa00,
            treeCamOffset: { x:0, y:5, z:40 }
        };

        const state = {
            // Layout
            splitPercent: 25, 
            draggingSplit: false,
            activePane: 'none', // 'tree' or 'file'

            // Data
            rootHandle: null, currentHandle: null,
            treeNodes: [], // Array of TreeNode objects
            fileObjects: [], // Meshes in right pane
            activeNode: null, // Currently selected Directory in Tree

            // Stock & Clipboard
            stockList: [], activeStockIdx: -1,
            clipboard: null,

            // View Modes (Right Pane)
            viewMode: 'F1',
            targetRotX: 0, currentRotX: 0,
            targetRotY: 0, currentRotY: 0,
            targetPosX: 0, currentPosX: 0,
            targetPosY: 0, currentPosY: 0,
            targetPosZ: 0, currentPosZ: 0,

            // Tree Nav
            treeScrollZ: 0, targetTreeScrollZ: 0,
            treeScrollX: 0, targetTreeScrollX: 0,

            // Interaction
            mouse: new THREE.Vector2(),
            lastMouse: new THREE.Vector2(),
            hovered: null, selected: null,
            dragging: null, dragStart: new THREE.Vector2(),
            
            contextTarget: null, tabContextTargetIdx: -1,
            isLoading: false
        };

        // --- Sound System ---
        const sfx = {
            ctx: null,
            init: () => { if (!sfx.ctx) sfx.ctx = new (window.AudioContext || window.webkitAudioContext)(); },
            play: (type) => {
                if (!sfx.ctx) return;
                const t = sfx.ctx.currentTime;
                const o = sfx.ctx.createOscillator(); const g = sfx.ctx.createGain();
                o.connect(g); g.connect(sfx.ctx.destination);
                if(type==='click'){o.frequency.setValueAtTime(600,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.1);o.start(t);o.stop(t+0.1);}
                else if(type==='hover'){o.frequency.setValueAtTime(300,t);g.gain.setValueAtTime(0.05,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.05);o.start(t);o.stop(t+0.05);}
                else if(type==='open'){o.type='triangle';o.frequency.setValueAtTime(200,t);o.frequency.linearRampToValueAtTime(600,t+0.2);g.gain.exponentialRampToValueAtTime(0.001,t+0.2);o.start(t);o.stop(t+0.2);}
                else if(type==='back'){o.type='triangle';o.frequency.setValueAtTime(600,t);o.frequency.linearRampToValueAtTime(200,t+0.2);g.gain.exponentialRampToValueAtTime(0.001,t+0.2);o.start(t);o.stop(t+0.2);}
                else if(type==='lock'){o.type='square';o.frequency.setValueAtTime(1000,t);g.gain.setValueAtTime(0.05,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.1);o.start(t);o.stop(t+0.1);}
                else if(type==='error'){o.type='sawtooth';o.frequency.setValueAtTime(100,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.3);o.start(t);o.stop(t+0.3);}
            }
        };

        // --- Three.js Setup (Dual Scene, Single Renderer) ---
        const container = document.getElementById('canvas-wrap');
        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
        renderer.setPixelRatio(window.devicePixelRatio);
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setScissorTest(true); 
        container.appendChild(renderer.domElement);

        // Scene 1: TREE (Left)
        const sceneTree = new THREE.Scene();
        sceneTree.fog = new THREE.FogExp2(0x020205, 0.008);
        const camTree = new THREE.PerspectiveCamera(60, 1, 0.1, 2000);
        const groupTree = new THREE.Group(); sceneTree.add(groupTree);
        
        // Scene 2: FILES (Right)
        const sceneFile = new THREE.Scene();
        sceneFile.fog = new THREE.FogExp2(0x020205, 0.015);
        const camFile = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
        camFile.position.set(0, 0, 0.1); // Camera stays static relative to group
        const groupFile = new THREE.Group(); sceneFile.add(groupFile);
        const reticleGroup = new THREE.Group(); sceneFile.add(reticleGroup);
        reticleGroup.visible = false;

        // Lights & Environment
        [sceneTree, sceneFile].forEach(s => {
            s.add(new THREE.AmbientLight(0xffffff, 1.0));
            const dl = new THREE.DirectionalLight(0xffffff, 2);
            dl.position.set(10, 50, 20); s.add(dl);
        });

        // Background Elements
        function createStars() {
            const g = new THREE.Group();
            const starsGeo = new THREE.BufferGeometry();
            const starPos = new Float32Array(5000 * 3);
            for(let i=0; i<15000; i++) starPos[i] = (Math.random()-0.5)*800;
            starsGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
            const starMat = new THREE.PointsMaterial({color:0xffffff, size:0.5, transparent:true, opacity:0.8});
            g.add(new THREE.Points(starsGeo, starMat));
            return g;
        }
        sceneFile.add(createStars());

        const gridHelperTree = new THREE.GridHelper(500, 100, 0x004488, 0x001122);
        gridHelperTree.position.y = -20; sceneTree.add(gridHelperTree);

        const gridHelperFile = new THREE.GridHelper(500, 100, 0x00ffff, 0x111111);
        gridHelperFile.position.y = -15; gridHelperFile.visible = false; sceneFile.add(gridHelperFile);

        // Reticle
        function createSegmentedRing(rIn, rOut, count, speed, dir) {
            const g = new THREE.Group();
            const angle = (Math.PI * 2) / count;
            const gap = 0.2; 
            for(let i=0; i<count; i++) {
                const s = i * angle + gap/2;
                const l = angle - gap;
                const geo = new THREE.RingGeometry(rIn, rOut, 32, 1, s, l);
                const color = new THREE.Color(0xffff00); color.multiplyScalar(1.5); 
                const mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 1.0, side: THREE.DoubleSide, depthTest: false });
                const mesh = new THREE.Mesh(geo, mat);
                g.add(mesh);
            }
            g.userData = { speed: speed, dir: dir };
            return g;
        }
        const r1 = createSegmentedRing(1.4, 1.45, 3, 0.08, 1); reticleGroup.add(r1);
        const r2 = createSegmentedRing(1.55, 1.6, 4, 0.05, -1); reticleGroup.add(r2);
        const r3 = createSegmentedRing(1.7, 1.75, 6, 0.02, 1); reticleGroup.add(r3);
        reticleGroup.userData = { layers: [r1, r2, r3] };

        // HUD Canvas
        const hudC = document.getElementById('hud-canvas');
        const hudX = hudC.getContext('2d');

        function resizeRenderer() {
            const width = window.innerWidth;
            const height = window.innerHeight;
            renderer.setSize(width, height);
            
            const dpr = window.devicePixelRatio || 1;
            hudC.width = width * dpr;
            hudC.height = height * dpr;
            hudX.scale(dpr, dpr);
        }
        window.addEventListener('resize', resizeRenderer);
        resizeRenderer();

        // --- Logic: Tree View ---
        class TreeNode {
            constructor(handle, parent = null) {
                this.handle = handle;
                this.parent = parent;
                this.children = [];
                this.mesh = null;
                this.depth = parent ? parent.depth + 1 : 0;
                this.expanded = false;
            }
        }

        function createTreeMesh(node) {
            const grp = new THREE.Group();
            const geo = new THREE.BoxGeometry(3, 3, 3);
            const mat = new THREE.MeshPhongMaterial({
                color: CONFIG.colActive, emissive: 0x332200, wireframe: false,
                transparent: true, opacity: 0.9, flatShading: true
            });
            const mesh = new THREE.Mesh(geo, mat);
            grp.add(mesh);

            const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({ color: 0xffaa00, transparent:true, opacity:0.8 }));
            mesh.add(edges);

            const cvs = document.createElement('canvas'); cvs.width=256; cvs.height=64;
            const ctx = cvs.getContext('2d');
            ctx.font = 'bold 36px "Segoe UI"'; ctx.fillStyle='#fff'; ctx.textAlign='center';
            ctx.shadowBlur=4; ctx.shadowColor='#00ffff';
            ctx.fillText(node.handle.name, 128, 45);
            const tex = new THREE.CanvasTexture(cvs);
            const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex }));
            sprite.scale.set(6, 1.5, 1); sprite.position.y = -2.5;
            grp.add(sprite);

            grp.userData = { isNode: true, node: node };
            return grp;
        }

        function updateTreeLayout() {
            // Cleanup old connection lines
            groupTree.children.filter(c => c.userData.isLine).forEach(c => groupTree.remove(c));
            
            // Safety: Ensure root exists
            if (!state.treeNodes[0] || !state.treeNodes[0].mesh) return;
            
            const depthSpacing = 50; 
            const spreadX = 20;

            const traverse = (node, levelIdx, levelCount) => {
                if (!node.mesh) {
                    node.mesh = createTreeMesh(node);
                    groupTree.add(node.mesh);
                    state.treeNodes.push(node);
                }

                const z = -(node.depth * depthSpacing);
                let x = 0;
                if (node.parent) {
                    const offset = (levelIdx - (levelCount-1)/2) * spreadX;
                    x = node.parent.mesh.position.x + offset;
                    
                    // Draw Line
                    const pts = [node.parent.mesh.position.clone(), new THREE.Vector3(x, 0, z)];
                    const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0x004466 }));
                    line.userData = { isLine: true };
                    groupTree.add(line);
                }
                
                node.mesh.position.set(x, 0, z);
                node.mesh.visible = true;

                if (node.expanded && node.children.length > 0) {
                    node.children.forEach((c, i) => traverse(c, i, node.children.length));
                } else {
                    const hide = (n) => { 
                        if(n.mesh) n.mesh.visible = false; 
                        n.children.forEach(hide); 
                    };
                    node.children.forEach(hide);
                }
            };
            
            traverse(state.treeNodes[0], 0, 1);
        }

        async function selectNode(node) {
            if(!node) return;
            state.activeNode = node;
            state.currentHandle = node.handle;
            
            // Expand logic
            if(!node.expanded) {
                const children = [];
                try {
                    for await (const e of node.handle.values()) {
                        if (e.kind === 'directory') children.push(new TreeNode(e, node));
                    }
                } catch(e){}
                node.children = children;
                node.expanded = true;
                updateTreeLayout();
            }

            // Load Files for Right Pane
            loadFilesForPane(node.handle);

            // Move Tree Camera to focus on node
            if(node.mesh) {
                state.targetTreeScrollZ = node.mesh.position.z + CONFIG.treeCamOffset.z;
                state.targetTreeScrollX = node.mesh.position.x;
            }
            sfx.play('click');
        }

        // --- Logic: File View (Monoliths) ---
        let popupMesh = null; let infoMesh = null;

        async function loadFilesForPane(handle) {
            state.isLoading = true;
            document.getElementById('loader').classList.add('active');
            
            // Clear old
            state.fileObjects.forEach(o => groupFile.remove(o));
            state.fileObjects = [];
            reticleGroup.visible = false; updatePopup(null); state.selected = null;

            const entries = [];
            try {
                for await (const e of handle.values()) {
                     entries.push(e);
                     // Allow UI breathing
                     if(entries.length % 50 === 0) await new Promise(r=>setTimeout(r,0));
                }
            } catch(e){}

            document.getElementById('loader').classList.remove('active');
            state.isLoading = false;
            
            // Reset transforms
            setMode(state.viewMode, entries);
        }

        function createMonolith(handle, x, y, z, angle, index, mode) {
            const isF = handle.kind === 'directory';
            const col = isF ? CONFIG.colFolder : CONFIG.colFile;
            const geo = new THREE.BoxGeometry(2.4, 3.2, 0.1);
            const mat = new THREE.MeshPhysicalMaterial({ color: col, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
            const mesh = new THREE.Mesh(geo, mat);
            mesh.position.set(x, y, z);
            
            if(mode === 'F1' || mode === 'F3') mesh.lookAt(x*2, y, z*2);
            else if (mode === 'F2') mesh.lookAt(x, 0, 0);
            else if (mode === 'F4') mesh.rotation.set(0, 0, 0);

            mesh.userData = { handle, isFolder:isF, isFileObj:true };

            const edges = new THREE.EdgesGeometry(geo);
            const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 }));
            mesh.add(line);

            // Extension Icon
            const cvs = document.createElement('canvas'); cvs.width=256; cvs.height=256;
            const ctx = cvs.getContext('2d');
            const ext = handle.name.split('.').pop().toUpperCase().slice(0,4);
            ctx.font = `bold 100px "Segoe UI"`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
            ctx.lineWidth = 6; ctx.strokeStyle = '#000000'; ctx.strokeText(isF?'DIR':ext, 128, 128);
            ctx.fillStyle = '#ffffff'; ctx.fillText(isF?'DIR':ext, 128, 128);
            const defTex = new THREE.CanvasTexture(cvs);
            const extSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: defTex }));
            extSprite.scale.set(2, 2, 1); extSprite.position.z = -1.5; 
            mesh.add(extSprite);

            // Name
            const nCvs = document.createElement('canvas'); nCvs.width=512; nCvs.height=128;
            const nCtx = nCvs.getContext('2d');
            nCtx.fillStyle = '#ffffff'; nCtx.font = 'bold 50px "Segoe UI"';
            nCtx.shadowColor = 'black'; nCtx.shadowBlur = 6; nCtx.textAlign = 'center'; nCtx.fillText(handle.name, 256, 50);
            const nTex = new THREE.CanvasTexture(nCvs);
            const nameSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: nTex, transparent: true }));
            nameSprite.scale.set(4, 1, 1); nameSprite.position.set(0, -2.2, 0);
            mesh.add(nameSprite);

            // Thumbnail (Lazy)
            if (!isF) {
                const n = handle.name.toLowerCase();
                const loadT = (t) => { mesh.material = new THREE.MeshBasicMaterial({map:t, color:0xffffff}); };
                if(n.match(/\.(jpg|png|webp|gif)$/)) handle.getFile().then(f => new THREE.TextureLoader().load(URL.createObjectURL(f), loadT));
                else if(n.match(/\.bmp$/)) handle.getFile().then(f => createImageBitmap(f)).then(bmp => loadT(new THREE.CanvasTexture(bmp)));
                else if(n.match(/\.(mp4|webm)$/i)) handle.getFile().then(f => {
                    const v = document.createElement('video'); v.src=URL.createObjectURL(f);
                    v.muted=true; v.loop=true; v.play().then(()=>{ loadT(new THREE.VideoTexture(v)); }).catch(()=>{});
                });
            }

            groupFile.add(mesh); state.fileObjects.push(mesh);
        }

        function setMode(mode, entries) {
            state.viewMode = mode;
            let label = "VERTICAL";
            if(mode==='F2') label="TUNNEL";
            if(mode==='F3') label="SPIRAL";
            if(mode==='F4') label="GRID";
            document.getElementById('mode-info').innerText = `MODE: ${mode} (${label})`;

            state.targetRotX = 0; state.currentRotX = 0;
            state.targetRotY = 0; state.currentRotY = 0;
            state.targetPosX = 0; state.currentPosX = 0;
            state.targetPosY = 0; state.currentPosY = 0;
            state.targetPosZ = 0; state.currentPosZ = 0;
            gridHelperFile.visible = false;

            if(!entries) return; // Wait for load

            // Layout
            const count = entries.length; const radius = 22; 
            state.fileObjects.forEach(o => groupFile.remove(o));
            state.fileObjects = [];

            if (mode === 'F1') {
                const spacing = 5.0; const rowHeight = 7.0; 
                const circumference = 2 * Math.PI * radius;
                const cols = Math.max(1, Math.min(count, Math.floor(circumference/spacing)));
                entries.forEach((e, i) => {
                    const col = i % cols; const row = Math.floor(i / cols);
                    const angle = col * ((Math.PI * 2) / Math.max(cols, 10)) + Math.PI; 
                    const x = radius * Math.sin(angle); const z = radius * Math.cos(angle);
                    let y = -(row * rowHeight) + (Math.floor(count/cols)*rowHeight)/2;
                    if (col % 2 !== 0) y -= rowHeight * 0.5;
                    createMonolith(e, x, y, z, angle, i, 'F1');
                });
            } else if (mode === 'F2') {
                const itemsPerRing = 12; const spacingX = 6.0; const totalRings = Math.ceil(count / itemsPerRing);
                entries.forEach((e, i) => {
                    const ring = Math.floor(i / itemsPerRing); const ang = (i % itemsPerRing) * ((Math.PI*2)/itemsPerRing);
                    const x = (ring * spacingX) - (totalRings * spacingX) / 2;
                    createMonolith(e, x, radius * Math.sin(ang), radius * Math.cos(ang), ang, i, 'F2');
                });
            } else if (mode === 'F3') {
                state.targetPosY = 20; 
                entries.forEach((e, i) => {
                    const ang = i * 0.3;
                    createMonolith(e, radius*Math.sin(ang), -(i*2.0), radius*Math.cos(ang), ang, i, 'F3');
                });
            } else if (mode === 'F4') {
                gridHelperFile.visible = true;
                const gridW = 8; const spX = 7.0; const spZ = 8.0; 
                const rows = Math.ceil(count / gridW);
                state.targetRotX = -Math.PI / 4; state.currentRotX = -Math.PI / 4;
                state.targetPosY = -(rows*spZ)/2; state.currentPosY = -(rows*spZ)/2;
                entries.forEach((e, i) => {
                    const col = i % gridW; const row = Math.floor(i / gridW);
                    const x = (col - gridW/2 + 0.5) * spX; const z = -row * spZ;
                    createMonolith(e, x, 0, z, 0, i, 'F4');
                });
            }
        }

        function updatePopup(target) {
            if(popupMesh) { groupFile.remove(popupMesh); popupMesh=null; }
            if(infoMesh) { groupFile.remove(infoMesh); infoMesh=null; }
            if(!target) return;

            const geo = new THREE.PlaneGeometry(6, 8); 
            const mat = new THREE.MeshBasicMaterial({ color: target.material.map ? 0xffffff : 0x0088ff, transparent: true, opacity: 0.95, side: THREE.DoubleSide, depthTest: false });
            if(target.material.map) mat.map = target.material.map;
            popupMesh = new THREE.Mesh(geo, mat); popupMesh.renderOrder = 9999;
            popupMesh.position.copy(target.position);

            // Popout logic
            if(state.viewMode === 'F4') { popupMesh.position.y += 3; popupMesh.position.z += 6; popupMesh.rotation.x = Math.PI/4; }
            else { 
                const v = new THREE.Vector3().copy(target.position).normalize();
                if(state.viewMode==='F2') v.set(0, v.y, v.z).normalize();
                else v.set(v.x, 0, v.z).normalize();
                popupMesh.position.add(v.multiplyScalar(5)); 
                popupMesh.lookAt(target.position);
            }
            
            groupFile.add(popupMesh);
            reticleGroup.scale.set(3,3,1);
        }

        // --- Interaction ---
        const splitter = document.getElementById('splitter');
        const paneTree = document.getElementById('pane-tree');

        splitter.addEventListener('pointerdown', e => { state.draggingSplit = true; splitter.classList.add('active'); e.preventDefault(); });
        window.addEventListener('pointerup', async e => {
            if (state.draggingSplit) { state.draggingSplit = false; splitter.classList.remove('active'); return; }
            if (state.dragging) {
                // Drag Drop Logic (Add to Stock)
                if(e.clientY > window.innerHeight - 180 && state.dragging.mesh) {
                     await moveToStock(state.dragging.mesh.userData.handle);
                } else if (!state.dragging.isMove && state.dragging.mesh) {
                    // Click (Selection)
                    activateReticle(state.dragging.mesh); updatePopup(state.dragging.mesh); sfx.play('lock');
                }
                state.dragging = null; 
            }
            document.body.style.cursor = 'default';
        });

        window.addEventListener('pointermove', e => {
            // Splitter
            if (state.draggingSplit) {
                const pct = (e.clientX / window.innerWidth) * 100;
                if (pct > 10 && pct < 90) {
                    state.splitPercent = pct;
                    paneTree.style.flex = `0 0 ${pct}%`;
                    resizeRenderer();
                }
                return;
            }

            // Detect Pane
            const treeW = window.innerWidth * (state.splitPercent / 100);
            const isTree = e.clientX < treeW;
            state.activePane = isTree ? 'tree' : 'file';
            
            // Normalize Mouse for specific camera
            if (isTree) {
                state.mouse.x = (e.clientX / treeW) * 2 - 1;
                state.mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
            } else {
                const fileW = window.innerWidth - treeW;
                state.mouse.x = ((e.clientX - treeW) / fileW) * 2 - 1;
                state.mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
            }

            // Raycasting & Hover
            const raycaster = new THREE.Raycaster();
            if (isTree) {
                raycaster.setFromCamera(state.mouse, camTree);
                const hits = raycaster.intersectObjects(groupTree.children, true);
                let hitNode = null;
                if(hits.length>0) {
                    let obj = hits[0].object;
                    while(obj && !obj.userData.isNode) obj = obj.parent;
                    hitNode = obj;
                }
                if (hitNode && state.hovered !== hitNode) { sfx.play('hover'); state.hovered = hitNode; document.body.style.cursor='pointer'; }
                else if (!hitNode) { state.hovered = null; document.body.style.cursor='default'; }
            } else {
                // File Pane Interaction
                if(e.buttons === 1 && !state.dragging) {
                    // Rotate/Pan File View
                    const dx = e.clientX - state.lastMouse.x; const dy = e.clientY - state.lastMouse.y;
                    if(state.viewMode==='F1') { state.targetRotY -= dx*0.005; state.targetPosY -= dy*0.1; }
                    else if(state.viewMode==='F2') { state.targetPosX += dx*0.05; state.targetRotX -= dy*0.005; }
                    else if(state.viewMode==='F3') { state.targetRotY -= dx*0.005; state.targetPosY -= dy*0.2; }
                    else if(state.viewMode==='F4') { state.targetPosX += dx*0.1; state.targetPosZ += dy*0.1; }
                } else if (state.dragging) {
                     if(state.dragStart.distanceTo(new THREE.Vector2(e.clientX, e.clientY)) > 5) state.dragging.isMove = true;
                } else {
                    raycaster.setFromCamera(state.mouse, camFile);
                    const hits = raycaster.intersectObjects(state.fileObjects, true);
                    let hitObj = null;
                    if(hits.length>0) {
                         let obj = hits[0].object;
                         while(obj && !obj.userData.isFileObj) obj = obj.parent;
                         hitObj = obj;
                    }
                    if(hitObj && state.hovered !== hitObj) { sfx.play('hover'); state.hovered = hitObj; document.body.style.cursor='pointer'; }
                    else if (!hitObj) { state.hovered = null; document.body.style.cursor='default'; }
                }
            }
            state.lastMouse.set(e.clientX, e.clientY);
        });

        window.addEventListener('pointerdown', e => {
            sfx.init();
            if(e.target.closest('.ctx-menu') || e.target.closest('.stock-item') || e.target.closest('.stock-tab-item')) return;
            
            closeAllMenus();
            
            if(state.activePane === 'file' && state.hovered) {
                state.dragStart.set(e.clientX, e.clientY);
                state.dragging = { mesh: state.hovered, isMove: false };
                state.selected = state.hovered;
            } else if (state.activePane === 'tree' && state.hovered) {
                // Tree selection immediate
                selectNode(state.hovered.userData.node);
            } else {
                state.selected = null; reticleGroup.visible = false; updatePopup(null);
            }
        });

        window.addEventListener('wheel', e => {
            if(state.activePane === 'tree') {
                state.targetTreeScrollZ += e.deltaY * 0.2;
            } else {
                if(state.viewMode==='F1' || state.viewMode==='F3') state.targetRotY += e.deltaY * 0.001;
                else if(state.viewMode==='F2') state.targetRotX += e.deltaY * 0.001;
                else if(state.viewMode==='F4') state.targetPosZ -= e.deltaY * 0.1; 
                if(state.viewMode==='F3' || state.viewMode==='F1') state.targetPosY += e.deltaY * 0.05; 
            }
        });

        window.addEventListener('dblclick', e => {
             if(state.selected && state.selected.userData.isFileObj) {
                 const h = state.selected.userData.handle;
                 if(h.kind==='directory') {
                      // Find equivalent node in active directory to expand tree
                      if(state.activeNode) {
                          const child = state.activeNode.children.find(c => c.handle.name === h.name);
                          if(child) selectNode(child);
                          else { /* Should probably reload tree children */ }
                      }
                 } else openPreview(h);
             }
        });

        // Key Commands
        window.addEventListener('keydown', e => {
            if(e.key.startsWith('F')) {
                const m = e.key;
                if(['F1','F2','F3','F4'].includes(m)) {
                    e.preventDefault(); 
                    setMode(m, state.activeNode ? [] : null); // If active, re-layout happens in loop or manual trigger
                    // Re-trigger layout with current objects
                    if(state.activeNode) loadFilesForPane(state.activeNode.handle);
                }
            }
        });

        // --- Render Loop ---
        function activateReticle(mesh) {
            reticleGroup.visible = true;
            reticleGroup.position.copy(mesh.position);
            reticleGroup.rotation.copy(mesh.rotation);
        }

        function drawHUD() {
            const dpr = window.devicePixelRatio||1;
            hudX.clearRect(0,0,hudC.width, hudC.height);
            // Only draw HUD for File items in Right Pane
            const t = state.hovered || state.selected;
            if(!t || !t.userData.isFileObj || state.dragging) return;
            
            // Project position
            const p = new THREE.Vector3(); t.getWorldPosition(p);
            p.y += 2.0; p.project(camFile);
            
            // Convert to screen coords (Right pane relative)
            const width = window.innerWidth;
            const treeW = width * (state.splitPercent / 100);
            const fileW = width - treeW;
            
            const x = (p.x * 0.5 + 0.5) * fileW + treeW;
            const y = (-(p.y * 0.5) + 0.5) * window.innerHeight;

            hudX.save();
            hudX.scale(dpr, dpr);
            hudX.shadowBlur = 10; hudX.shadowColor = CONFIG.colActive;
            hudX.strokeStyle = '#ffee00'; hudX.lineWidth = 2;
            hudX.beginPath(); hudX.moveTo(x,y); hudX.lineTo(x+40,y-40); hudX.lineTo(x+160,y-40); hudX.stroke();
            hudX.font="bold 14px 'Segoe UI'"; hudX.fillStyle='#fff'; hudX.fillText(t.userData.handle.name, x+45, y-45);
            hudX.restore();
        }

        function animate() {
            requestAnimationFrame(animate);

            // 1. Update Tree Logic
            state.treeScrollZ += (state.targetTreeScrollZ - state.treeScrollZ) * 0.1;
            state.treeScrollX += (state.targetTreeScrollX - state.treeScrollX) * 0.1;
            camTree.position.z = state.treeScrollZ;
            camTree.position.x = state.treeScrollX;
            camTree.position.y = CONFIG.treeCamOffset.y;
            camTree.lookAt(state.treeScrollX, 0, state.treeScrollZ - 100);

            // 2. Update File Logic
            state.currentRotY += (state.targetRotY - state.currentRotY) * 0.1;
            state.currentPosY += (state.targetPosY - state.currentPosY) * 0.1;
            state.currentRotX += (state.targetRotX - state.currentRotX) * 0.1;
            state.currentPosX += (state.targetPosX - state.currentPosX) * 0.1;
            state.currentPosZ += (state.targetPosZ - state.currentPosZ) * 0.1;

            if(state.viewMode === 'F1' || state.viewMode === 'F3') {
                groupFile.rotation.y = state.currentRotY; groupFile.position.y = state.currentPosY;
                groupFile.rotation.x = 0; groupFile.position.x = 0; groupFile.position.z = 0;
            } else if (state.viewMode === 'F2') {
                groupFile.rotation.x = state.currentRotX; groupFile.position.x = state.currentPosX;
                groupFile.rotation.y = 0; groupFile.position.y = 0; groupFile.position.z = 0;
            } else if (state.viewMode === 'F4') {
                groupFile.rotation.set(state.currentRotX, 0, 0); 
                groupFile.position.set(state.currentPosX, state.currentPosY, state.currentPosZ);
            }

            // Reticle Anim
            if(reticleGroup.visible) {
                 const s = (state.selected && popupMesh) ? 3 : 1;
                 reticleGroup.scale.set(s,s,1);
                 reticleGroup.userData.layers.forEach(l => l.rotation.z += l.userData.speed * l.userData.dir);
                 // If dragging/rotating view, reticle must follow mesh world pos
                 if(state.selected) {
                     const wp = new THREE.Vector3(); state.selected.getWorldPosition(wp);
                     const wq = new THREE.Quaternion(); state.selected.getWorldQuaternion(wq);
                     reticleGroup.position.copy(wp); reticleGroup.quaternion.copy(wq);
                     reticleGroup.translateZ(0.2);
                 }
            }

            // 3. Render (Scissor)
            const width = window.innerWidth; const height = window.innerHeight;
            const treeW = width * (state.splitPercent / 100);
            
            // Left Pane
            const leftW = Math.floor(treeW);
            renderer.setViewport(0, 0, leftW, height);
            renderer.setScissor(0, 0, leftW, height);
            renderer.render(sceneTree, camTree);

            // Right Pane
            const rightW = width - leftW;
            renderer.setViewport(leftW, 0, rightW, height);
            renderer.setScissor(leftW, 0, rightW, height);
            renderer.render(sceneFile, camFile);

            drawHUD();
        }
        animate();


        // --- Menus, Stock, Dialogs (From 1.4.6) ---
        const ctxObj = document.getElementById('ctx-obj');
        const ctxBg = document.getElementById('ctx-bg');
        const ctxTab = document.getElementById('ctx-tab');

        function closeAllMenus() {
            document.querySelectorAll('.ctx-menu').forEach(m => {
                if(m.style.display !== 'none' && !m.classList.contains('closing')) {
                    m.classList.remove('opening'); m.classList.add('closing');
                    setTimeout(() => { m.style.display = 'none'; m.classList.remove('closing'); }, 400); 
                }
            });
        }
        function showMenu(el, x, y) {
            el.style.display='flex'; el.classList.remove('closing'); el.classList.add('opening');
            if(x+200>window.innerWidth) x-=200; if(y+250>window.innerHeight) y-=250;
            el.style.left=x+'px'; el.style.top=y+'px';
        }

        window.addEventListener('contextmenu', e => {
            e.preventDefault(); 
            if(e.target.closest('.stock-tab-item')) {
                const idx = e.target.closest('.stock-tab-item').dataset.idx;
                if (idx !== undefined) { state.tabContextTargetIdx = parseInt(idx); showMenu(ctxTab, e.clientX, e.clientY); }
                return;
            }
            if(state.activePane === 'tree' && state.hovered) {
                state.contextTarget = state.hovered.userData.node.handle;
                showMenu(ctxObj, e.clientX, e.clientY);
                return;
            }
            if(state.activePane === 'file' && state.hovered) {
                state.contextTarget = state.hovered.userData.handle;
                state.selected = state.hovered;
                activateReticle(state.hovered); updatePopup(state.hovered);
                showMenu(ctxObj, e.clientX, e.clientY);
            } else {
                state.contextTarget = null;
                showMenu(ctxBg, e.clientX, e.clientY);
            }
        });

        // Stock Logic
        function renderStockTabs() {
            const c = document.getElementById('stock-tabs-container'); c.innerHTML = '';
            if(state.stockList.length === 0) { c.innerHTML = '<div class="stock-tab-item" style="opacity:0.5; pointer-events:none;">STOCK</div>'; return; }
            state.stockList.forEach((item, i) => {
                const div = document.createElement('div'); div.className = 'stock-tab-item';
                if(i === state.activeStockIdx) div.classList.add('active');
                div.innerText = item.handle.name.toUpperCase(); div.dataset.idx = i;
                div.onclick = () => {
                    state.activeStockIdx = i; document.getElementById('stock').classList.remove('closed');
                    renderStockTabs(); refreshStock(); sfx.play('click');
                };
                c.appendChild(div);
            });
        }
        async function refreshStock() {
            const s = document.getElementById('stock'); s.innerHTML='';
            if(state.activeStockIdx < 0 || !state.stockList[state.activeStockIdx]) { s.innerHTML = '<div style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY STOCK ]</div>'; return; }
            const currentStock = state.stockList[state.activeStockIdx];
            currentStock.entries = []; 
            try {
                for await (const entry of currentStock.handle.values()) currentStock.entries.push(entry);
                if(currentStock.entries.length === 0) s.innerHTML = '<div style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY FOLDER ]</div>';
                else {
                    for (let i=0; i<currentStock.entries.length; i++) {
                        const h = currentStock.entries[i];
                        const d = document.createElement('div'); d.className = 'stock-item'; d.draggable = true;
                        const thumb = document.createElement('div'); thumb.className = 'stock-thumb';
                        if(h.kind === 'directory') thumb.innerHTML = '<span style="font-size:40px">📂</span>';
                        else {
                            const n = h.name.toLowerCase();
                            if(n.match(/\.(jpg|png|webp|gif)$/)) h.getFile().then(f => { const img=document.createElement('img'); img.src=URL.createObjectURL(f); img.style.maxWidth='100%'; img.style.maxHeight='100%'; thumb.appendChild(img); });
                            else thumb.innerHTML = '<span style="font-size:30px">📄</span>';
                        }
                        d.appendChild(thumb);
                        const lbl = document.createElement('div'); lbl.className = 'stock-label'; lbl.innerText = h.name; d.appendChild(lbl);
                        d.ondragstart = e => { e.dataTransfer.setData('stockIndex', i); e.stopPropagation(); };
                        s.appendChild(d);
                    }
                }
            } catch(e) { s.innerHTML = '<div style="color:red">ACCESS ERROR</div>'; }
        }
        async function moveToStock(handle) {
             if(state.activeStockIdx < 0 || !state.stockList[state.activeStockIdx]) { alert("No Active Stock Tab!"); return; }
             const dest = state.stockList[state.activeStockIdx].handle;
             if(handle.move) {
                 try { await handle.move(dest); refreshStock(); loadFilesForPane(state.currentHandle); } 
                 catch(e){ sfx.play('error'); alert(e.message); }
             }
        }
        async function moveFromStock(idx) {
             if(state.activeStockIdx<0) return;
             const h = state.stockList[state.activeStockIdx].entries[idx];
             if(h && h.move) {
                 try { await h.move(state.currentHandle); refreshStock(); loadFilesForPane(state.currentHandle); }
                 catch(e){ sfx.play('error'); alert(e.message); }
             }
        }
        document.getElementById('stock').ondrop = async e => {
             // Handled by pointerup on splitter logic or internal drag? 
             // Browser native drag needs this:
        };
        const stockDiv = document.getElementById('stock');
        stockDiv.ondragover = e => e.preventDefault();
        container.ondragover = e => e.preventDefault();
        container.ondrop = async e => { e.preventDefault(); const idx = e.dataTransfer.getData('stockIndex'); if(idx) moveFromStock(parseInt(idx)); };

        // Context Actions
        document.getElementById('cm-open').onclick = () => {
            const t = state.contextTarget;
            if(t) { if(t.kind==='directory') selectNode(state.treeNodes.find(n=>n.userData.node.handle.name===t.name).userData.node); else openPreview(t); }
            closeAllMenus();
        };
        document.getElementById('cm-add-stock').onclick = () => {
             const t = state.contextTarget;
             if(t && t.kind==='directory') {
                 if(state.stockList.find(s=>s.handle.name===t.name)) state.activeStockIdx = state.stockList.findIndex(s=>s.handle.name===t.name);
                 else { state.stockList.push({handle:t, entries:[]}); state.activeStockIdx=state.stockList.length-1; }
                 renderStockTabs(); refreshStock(); document.getElementById('stock').classList.remove('closed');
             }
             closeAllMenus();
        };
        document.getElementById('cm-tab-hide').onclick=()=>{ document.getElementById('stock').classList.add('closed'); closeAllMenus(); };
        document.getElementById('cm-tab-cancel').onclick=()=>{
            state.stockList.splice(state.tabContextTargetIdx, 1);
            if(state.stockList.length===0) { state.activeStockIdx=-1; document.getElementById('stock').classList.add('closed'); }
            else state.activeStockIdx = Math.max(0, state.stockList.length-1);
            renderStockTabs(); refreshStock(); closeAllMenus();
        };
        document.getElementById('cm-new').onclick = async () => {
            closeAllMenus(); const n = await customPrompt("NEW FOLDER", "New Folder");
            if(n) { await state.currentHandle.getDirectoryHandle(n, {create:true}); loadFilesForPane(state.currentHandle); }
        };
        document.getElementById('cm-rename').onclick = async () => {
             closeAllMenus(); const t = state.contextTarget;
             if(t) { const n = await customPrompt("RENAME", t.name); if(n && n!==t.name && t.move) { await t.move(n); loadFilesForPane(state.currentHandle); } }
        };
        document.getElementById('cm-delete').onclick = async () => {
             closeAllMenus(); const t = state.contextTarget;
             if(t && confirm('Delete?')) { await state.currentHandle.removeEntry(t.name, {recursive:true}); loadFilesForPane(state.currentHandle); }
        };

        // Dialogs
        const dModal=document.getElementById('dialog'), dInput=document.getElementById('d-input');
        let dResolve=null;
        function customPrompt(title, val) {
            return new Promise(r => {
                document.getElementById('d-title').innerText=title; dInput.value=val; dResolve=r;
                dModal.classList.remove('closing'); dModal.classList.add('active','opening'); dInput.focus(); sfx.play('open');
            });
        }
        function closeDialog(res){ dModal.classList.remove('opening'); dModal.classList.add('closing'); sfx.play('back'); setTimeout(()=>{dModal.classList.remove('active','closing'); if(dResolve) dResolve(res); dResolve=null;},500); }
        document.getElementById('d-ok').onclick=()=>closeDialog(dInput.value);
        document.getElementById('d-cancel').onclick=()=>closeDialog(null);
        dInput.onkeydown=e=>{if(e.key==='Enter')closeDialog(dInput.value);if(e.key==='Escape')closeDialog(null);};

        // Preview
        const pModal=document.getElementById('preview'), pBody=document.getElementById('p-body');
        let curPH=null;
        async function openPreview(h) {
            curPH=h; pModal.classList.remove('closing'); pModal.classList.add('active','opening'); sfx.play('open');
            document.getElementById('p-title').innerText=h.name; pBody.innerHTML='Loading...';
            try {
                const f = await h.getFile(); const u = URL.createObjectURL(f);
                if(f.name.match(/\.(png|jpg|webp|gif)$/i)) pBody.innerHTML=`<img src="${u}" style="max-width:100%;max-height:100%">`;
                else if(f.name.match(/\.(mp4|webm)$/i)) pBody.innerHTML=`<video src="${u}" controls autoplay style="max-width:100%"></video>`;
                else { const t = await f.text(); pBody.innerHTML=`<textarea id="editor-area" class="hex-view">${t}</textarea>`; document.getElementById('p-save').style.display='block'; }
            } catch(e){ pBody.innerText="Error"; }
        }
        document.getElementById('p-close').onclick=()=>{ pModal.classList.remove('opening'); pModal.classList.add('closing'); sfx.play('back'); setTimeout(()=>{pModal.classList.remove('active','closing');},500); };
        document.getElementById('p-save').onclick=async()=>{
            const ta=document.getElementById('editor-area');
            if(curPH && ta) { try { const w=await curPH.createWritable(); await w.write(ta.value); await w.close(); alert("Saved"); } catch(e){alert("Error");} }
        };

        // Init
        document.getElementById('btn-mount').onclick = async () => {
            sfx.init(); sfx.play('click');
            try {
                const h = await window.showDirectoryPicker();
                state.rootHandle = h;
                
                // Clear State
                state.treeNodes = []; 
                state.activeNode = null;
                
                // Reset Tree Group
                groupTree.children.slice().forEach(c => { 
                    if(c.userData.isNode || c.userData.isLine) groupTree.remove(c); 
                });
                
                // Create & Register Root
                const root = new TreeNode(h);
                root.mesh = createTreeMesh(root);
                groupTree.add(root.mesh);
                state.treeNodes.push(root);

                // Select
                await selectNode(root);
                
                // Initial Layout
                updateTreeLayout();
            } catch(e) { console.error(e); }
        };
    </script>
</body>
</html>

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
SF映画っぽいエクスプローラ「Exp3D 1.5」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1