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


画像
画像

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


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

        /* UI Layout */
        #app { display: flex; width: 100vw; height: 100vh; position: relative; }

        /* Sidebar */
        #sidebar {
            position: absolute; top: 0; left: 0; bottom: 0; width: 280px;
            background: var(--panel); border-right: 1px solid var(--cyan);
            display: flex; flex-direction: column; z-index: 50; backdrop-filter: blur(10px);
            transition: transform 0.3s; transform: translateX(0);
        }
        #sidebar.closed { transform: translateX(-280px); }
        #sb-tab {
            position: absolute; top: 50%; right: -30px; width: 30px; height: 100px; margin-top: -50px;
            background: var(--panel); border: 1px solid var(--cyan); border-left: none;
            display: flex; align-items: center; justify-content: center; cursor: pointer;
            writing-mode: vertical-rl; color: var(--cyan); font-weight:bold;
            border-radius: 0 10px 10px 0;
        }
        #sb-header { padding: 15px; color: var(--cyan); border-bottom: 1px solid rgba(0,255,255,0.3); font-weight: bold; letter-spacing: 2px; }
        #tree-container { flex: 1; overflow-y: auto; padding: 10px; }
        .tree-node { display: flex; align-items: center; padding: 6px; cursor: pointer; color: #888; transition:0.2s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 14px; }
        .tree-node:hover { color: #fff; background: rgba(0,255,255,0.1); }
        .icon-tree { margin-right: 8px; display: inline-block; }

        /* Main View */
        #main { flex: 1; position: relative; overflow: hidden; }
        #canvas-wrap { width: 100%; height: 100%; position: absolute; }
        #hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
        #hud-canvas { width: 100%; height: 100%; display: block; }
        
        #mode-info {
            position: absolute; top: 20px; 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: 100;
            display: none;
        }
        #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 */
        #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;
        }
        #stock.active { cursor: grabbing; }
        #stock.closed { bottom: -180px; }
        
        #stock-tab {
            position: absolute; bottom: 180px; left: 50%; transform: translateX(-50%);
            background: var(--orange); color: #fff; padding: 2px 30px; font-weight: bold;
            cursor: pointer; clip-path: polygon(15% 0, 85% 0, 100% 100%, 0% 100%); z-index: 41; transition: bottom 0.3s;
            max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        }
        #stock.closed + #stock-tab { bottom: 0; }

        .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;
        }
        .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; 
        }
        .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-item:last-child { border-bottom: none; }

        .ctx-menu.closing .ctx-item { animation: itemFold 0.2s forwards ease-in; }
        .ctx-menu.closing .ctx-item:nth-last-child(1) { animation-delay: 0s; }
        .ctx-menu.closing .ctx-item:nth-last-child(2) { animation-delay: 0.05s; }
        .ctx-menu.closing .ctx-item:nth-last-child(3) { animation-delay: 0.1s; }
        .ctx-menu.closing .ctx-item:nth-last-child(4) { animation-delay: 0.15s; }
        .ctx-menu.closing .ctx-item:nth-last-child(5) { animation-delay: 0.2s; }
        @keyframes itemFold {
            0% { transform: translateY(0); opacity: 1; height: 40px; }
            100% { transform: translateY(-20px); opacity: 0; height: 0; padding: 0; border: none; }
        }

        /* Modal Styles */
        .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; }
        @keyframes animFlipOpen {
            0% { transform: translateZ(-500px) rotateX(180deg); opacity: 0; }
            100% { transform: translateZ(0) rotateX(0deg); opacity: 1; }
        }
        
        .modal-overlay.closing .modal-box { animation: animFlipClose 0.5s cubic-bezier(0.19, 1, 0.22, 1) forwards; }
        @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; letter-spacing: 1px; }
        .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: 9999; 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="sidebar">
            <div id="sb-header">SYSTEM // NAV</div>
            <div style="padding:10px;"><button id="btn-mount" class="p-btn" style="width:100%">MOUNT DRIVE</button></div>
            <div id="tree-container"></div>
            <div id="sb-tab">TREE</div>
        </div>

        <div id="main">
            <div id="canvas-wrap"></div>
            <div id="hud"><canvas id="hud-canvas"></canvas></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="stock">
                <div id="stock-hint" style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY STOCK ]</div>
            </div>
            <div id="stock-tab">STOCK</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>
    </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';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';

        const CONFIG = {
            colFolder: 0xffee00, colFile: 0x00ffff, colCursor: 0xffee00, colStock: 0xffaa00
        };
        const state = {
            rootHandle: null, currentHandle: null, currentPath: [],
            objects: [], 
            stockHandle: null, stockEntries: [], 
            dragging: null, hovered: null, selected: null,
            clipboard: null, currentEntries: [],
            viewMode: 'F1', 
            targetRotX: 0, currentRotX: 0,
            targetRotY: 0, currentRotY: 0,
            targetPosX: 0, currentPosX: 0,
            targetPosY: 0, currentPosY: 0,
            targetPosZ: 0, currentPosZ: 0, 
            isRotating: false, lastMouse: new THREE.Vector2(),
            isTransitioning: false, contextTarget: null,
            dragStart: new THREE.Vector2(), isLoading: false
        };

        // --- Sound ---
        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 ---
        const container = document.getElementById('canvas-wrap');
        const scene = new THREE.Scene();
        scene.fog = new THREE.FogExp2(0x020205, 0.015);

        const camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.1, 1000);
        camera.position.set(0, 0, 0.1);

        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
        renderer.setSize(container.clientWidth, container.clientHeight);
        renderer.setPixelRatio(window.devicePixelRatio);
        renderer.sortObjects = true;
        container.appendChild(renderer.domElement);

        scene.add(new THREE.AmbientLight(0xffffff, 1.0)); 
        const dl = new THREE.DirectionalLight(0xffffff, 2);
        dl.position.set(0, 50, 20);
        scene.add(dl);
        
        // Background
        const starGroup = 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)*500;
        starsGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
        const starMat = new THREE.PointsMaterial({color:0xffffff, size:0.5, transparent:true, opacity:0.8});
        starGroup.add(new THREE.Points(starsGeo, starMat));

        const orbitGroup = new THREE.Group();
        for(let i=0; i<8; i++) {
            const r = 40 + i*15;
            const geo = new THREE.TorusGeometry(r, 0.1, 16, 100);
            const mat = new THREE.MeshBasicMaterial({color:0x00ffff, side:THREE.DoubleSide, transparent:true, opacity:0.15});
            const ring = new THREE.Mesh(geo, mat);
            ring.rotation.set(Math.random()*Math.PI, Math.random()*Math.PI, 0);
            orbitGroup.add(ring);
        }
        starGroup.add(orbitGroup);
        scene.add(starGroup);

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

        const composer = new EffectComposer(renderer);
        composer.addPass(new RenderPass(scene, camera));
        const bloom = new UnrealBloomPass(new THREE.Vector2(container.clientWidth, container.clientHeight), 0.4, 0.1, 0.9);
        composer.addPass(bloom);

        const orbitControls = new OrbitControls(camera, renderer.domElement);
        orbitControls.enableDamping = true;
        orbitControls.enablePan = false; orbitControls.enableRotate = false; orbitControls.enableZoom = false; 
        orbitControls.target.set(0,0,-1);

        const filesGroup = new THREE.Group();
        scene.add(filesGroup);

        const reticleGroup = new THREE.Group();
        scene.add(reticleGroup);
        reticleGroup.visible = false;
        reticleGroup.renderOrder = 9999;
        const cursorLight = new THREE.PointLight(0xffff00, 1.5, 8);
        reticleGroup.add(cursorLight);

        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);
                mesh.renderOrder = 9999;
                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 ---
        const hudC = document.getElementById('hud-canvas');
        const hudX = hudC.getContext('2d');
        function resizeHUD() {
            const dpr = window.devicePixelRatio || 1;
            hudC.width = container.clientWidth * dpr; hudC.height = container.clientHeight * dpr;
            hudX.scale(dpr, dpr);
        }
        window.onresize = () => {
            camera.aspect=container.clientWidth/container.clientHeight; camera.updateProjectionMatrix();
            renderer.setSize(container.clientWidth, container.clientHeight);
            composer.setSize(container.clientWidth, container.clientHeight);
            resizeHUD();
        }; 
        resizeHUD();

        // --- Logic ---
        document.getElementById('btn-mount').onclick = async () => {
            sfx.init(); sfx.play('click');
            try {
                const h = await window.showDirectoryPicker();
                state.rootHandle = h;
                enterFolder(h, false); 
                updateTree(h);
            } catch(e) {}
        };

        window.addEventListener('keydown', e => {
            if(e.key === 'F1') { e.preventDefault(); setMode('F1'); }
            if(e.key === 'F2') { e.preventDefault(); setMode('F2'); }
            if(e.key === 'F3') { e.preventDefault(); e.stopPropagation(); setMode('F3'); }
            if(e.key === 'F4') { e.preventDefault(); setMode('F4'); } 
        });

        function setMode(mode) {
            state.viewMode = mode;
            let label = "VERTICAL";
            if(mode==='F2') label="TUNNEL";
            if(mode==='F3') label="SPIRAL";
            if(mode==='F4') label="GROUND LIST";
            document.getElementById('mode-info').innerText = `MODE: ${mode} (${label})`;
            sfx.play('click');
            
            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;
            gridHelper.visible = false;

            if (mode === 'F3') state.targetPosY = 20; 
            if (mode === 'F4') {
                gridHelper.visible = true;
                const count = state.currentEntries.length;
                const gridW = 8; const spZ = 8.0; 
                const rows = Math.ceil(count / gridW);
                const totalDepth = rows * spZ;
                const h = totalDepth / 2; const zPos = h / 2;
                state.targetRotX = -Math.PI / 4; state.currentRotX = -Math.PI / 4;
                state.targetPosY = -h; state.currentPosY = -h;
                state.targetPosZ = -zPos; state.currentPosZ = -zPos; 
            } else {
                filesGroup.position.set(0, 0, 0);
            }
            rebuildCylinder();
        }

        document.getElementById('btn-cancel-load').onclick = () => {
            state.isLoading = false; 
            document.getElementById('loader').classList.remove('active');
        };

        async function enterFolder(handle, animate=true) {
            state.currentHandle = handle;
            if(state.currentPath.length === 0 || state.currentPath[state.currentPath.length-1] !== handle) {
                state.currentPath.push(handle);
            }
            state.isLoading = true;
            document.getElementById('loader').classList.add('active');
            
            if(!animate) {
                state.objects.forEach(o => filesGroup.remove(o));
                state.objects = [];
            }
            reticleGroup.visible = false; state.selected = null; updatePopup(null);
            
            const entries = [];
            try {
                for await (const e of handle.values()) {
                    if (!state.isLoading) break;
                    entries.push(e);
                    if (entries.length % 20 === 0) await new Promise(r => setTimeout(r, 0));
                }
            } catch(e) {}

            document.getElementById('loader').classList.remove('active');
            state.isLoading = false;
            state.currentEntries = entries;
            
            if(state.viewMode === 'F3') {
                 state.targetRotY = 0; state.currentRotY = 0;
                 state.targetPosY = 20; state.currentPosY = 20;
            }
            if(state.viewMode === 'F4') setMode('F4'); 
            else rebuildCylinder();
        }

        async function goUp() {
            if(state.currentPath.length > 1) {
                state.currentPath.pop();
                const parent = state.currentPath[state.currentPath.length-1];
                animateTransition('out', async () => {
                    await enterFolder(parent, false); 
                });
            }
        }

        function animateTransition(dir, asyncLoadCallback) {
            if (state.isTransitioning) return;
            state.isTransitioning = true;
            sfx.play(dir==='in'?'open':'back');
            const startT = Date.now(); const duration = 800; 
            const oldObjects = [...state.objects];
            oldObjects.forEach(o => {
                o.userData.startPos = o.position.clone();
                const v = new THREE.Vector3(o.position.x, o.position.y, 0).normalize();
                if(v.lengthSq()===0) v.set(1,0,0);
                o.userData.scatterDir = v;
            });

            function loop1() {
                const p = Math.min((Date.now() - startT) / duration, 1);
                const ease = p * p; 
                oldObjects.forEach(o => {
                    const sp = o.userData.startPos; const sd = o.userData.scatterDir;
                    if (dir === 'in') {
                        o.position.z = sp.z + (300 * ease);
                        o.position.x = sp.x + (sd.x * 100 * ease);
                        o.position.y = sp.y + (sd.y * 100 * ease);
                    } else {
                        o.position.z = sp.z - (400 * ease);
                        o.position.x = sp.x * (1 - ease);
                        o.position.y = sp.y * (1 - ease);
                    }
                    if(o.material) o.material.opacity = 0.3 * (1 - p);
                    o.children.forEach(c => { if(c.material) c.material.opacity = (1 - p); });
                });
                if (p < 1) requestAnimationFrame(loop1);
                else {
                    oldObjects.forEach(o => filesGroup.remove(o));
                    state.objects = [];
                    if (asyncLoadCallback) asyncLoadCallback().then(() => { startPhase3(); });
                    else startPhase3();
                }
            }
            loop1();

            function startPhase3() {
                const newObjects = state.objects;
                newObjects.forEach(o => {
                    o.userData.targetPos = o.position.clone();
                    const v = new THREE.Vector3(o.position.x, o.position.y, 0).normalize();
                    if(v.lengthSq()===0) v.set(1,0,0);
                    if (dir === 'in') {
                        o.position.z = -400; o.position.x = 0; o.position.y = 0;
                    } else {
                        o.position.z = 300;
                        o.position.x = o.userData.targetPos.x + (v.x * 100);
                        o.position.y = o.userData.targetPos.y + (v.y * 100);
                    }
                    if(o.material) o.material.opacity = 0;
                    o.children.forEach(c => { if(c.material) c.material.opacity = 0; });
                });

                const startT2 = Date.now();
                function loop2() {
                    const p2 = Math.min((Date.now() - startT2) / duration, 1);
                    const ease2 = 1 - Math.pow(1 - p2, 3); 
                    newObjects.forEach(o => {
                        const tp = o.userData.targetPos;
                        if (dir === 'in') {
                            o.position.z = -400 + (tp.z - (-400)) * ease2;
                            o.position.x = tp.x * ease2; o.position.y = tp.y * ease2;
                        } else {
                            const v = new THREE.Vector3(tp.x, tp.y, 0).normalize();
                            if(v.lengthSq()===0) v.set(1,0,0);
                            o.position.z = 300 + (tp.z - 300) * ease2;
                            o.position.x = (tp.x + v.x * 100) + (tp.x - (tp.x + v.x * 100)) * ease2;
                            o.position.y = (tp.y + v.y * 100) + (tp.y - (tp.y + v.y * 100)) * ease2;
                        }
                        if(o.material) o.material.opacity = 0.3 * p2;
                        o.children.forEach(c => { if(c.material) c.material.opacity = p2; });
                    });
                    if(p2 < 1) requestAnimationFrame(loop2);
                    else {
                        state.isTransitioning = false;
                        newObjects.forEach(o => {
                            o.position.copy(o.userData.targetPos);
                            if(o.material) o.material.opacity = 0.3;
                            o.children.forEach(c => { if(c.material) c.material.opacity = 1; });
                        });
                    }
                }
                loop2();
            }
        }

        function rebuildCylinder() {
            state.objects.forEach(o => filesGroup.remove(o));
            state.objects = [];
            const entries = state.currentEntries || [];
            const count = entries.length;
            const radius = 22; 

            if(state.viewMode === 'F1') {
                const spacing = 5.0; 
                const circumference = 2 * Math.PI * radius;
                const maxCols = Math.floor(circumference / spacing);
                const cols = Math.max(1, Math.min(count, maxCols)); 
                const rowHeight = 7.0; 
                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(state.viewMode === 'F2') {
                const itemsPerRing = 12; const spacingX = 6.0; const totalRings = Math.ceil(count / itemsPerRing);
                entries.forEach((e, i) => {
                    const ringIndex = Math.floor(i / itemsPerRing); const angleIndex = i % itemsPerRing;
                    const angle = angleIndex * ((Math.PI * 2) / itemsPerRing);
                    const x = (ringIndex * spacingX) - (totalRings * spacingX) / 2;
                    const y = radius * Math.sin(angle); const z = radius * Math.cos(angle);
                    createMonolith(e, x, y, z, angle, i, 'F2');
                });
            } else if(state.viewMode === 'F3') {
                const stepY = 2.0; const angleStep = 0.3; 
                entries.forEach((e, i) => {
                    const angle = i * angleStep;
                    const x = radius * Math.sin(angle); const z = radius * Math.cos(angle);
                    const y = -(i * stepY);
                    createMonolith(e, x, y, z, angle, i, 'F3');
                });
            } else if(state.viewMode === 'F4') {
                const gridW = 8; const spX = 7.0; const spZ = 8.0; 
                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; const y = 0; 
                    createMonolith(e, x, y, z, 0, i, 'F4');
                });
            }
            if(!state.isTransitioning) {
                if(state.viewMode==='F2' || state.viewMode==='F4' || state.viewMode==='F1') {
                    filesGroup.scale.set(1,1,1); filesGroup.rotation.set(0,0,0); filesGroup.position.set(0,0,0);
                }
            }
        }

        function drawStrokeText(ctx, txt, x, y, size, align='left') {
            ctx.font = `bold ${size}px "Segoe UI"`; 
            ctx.textAlign = align; ctx.textBaseline = 'middle';
            ctx.lineWidth = 6; ctx.strokeStyle = '#000000'; ctx.strokeText(txt, x, y);
            ctx.fillStyle = '#ffffff'; ctx.fillText(txt, x, y);
        }

        function createNameLabel(name, mode) {
            const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 128;
            const ctx = canvas.getContext('2d');
            ctx.fillStyle = '#ffffff'; ctx.font = 'bold 50px "Segoe UI", sans-serif';
            ctx.shadowColor = 'black'; ctx.shadowBlur = 6; ctx.shadowOffsetX = 3; ctx.shadowOffsetY = 3;
            ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(name, 256, 10);
            const tex = new THREE.CanvasTexture(canvas);
            const mat = new THREE.SpriteMaterial({ map: tex, transparent: true });
            const sprite = new THREE.Sprite(mat); sprite.scale.set(4, 1, 1);
            return sprite;
        }

        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, isRoot:true, thumbTex:null };

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

            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);
            drawStrokeText(ctx, isF ? 'DIR' : ext, 128, 128, 100, 'center'); 
            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);

            const tCvs = document.createElement('canvas'); tCvs.width=256; tCvs.height=256;
            const thumbTex = new THREE.CanvasTexture(tCvs); 
            const thumbSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: thumbTex, opacity: 0 })); 
            thumbSprite.scale.set(2, 2, 1); thumbSprite.position.z = 0.2;
            mesh.add(thumbSprite);

            const nameSprite = createNameLabel(handle.name, mode);
            nameSprite.position.set(0, -2.2, 0); mesh.add(nameSprite);

            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(()=>{});
                });
            }
            filesGroup.add(mesh); state.objects.push(mesh);
        }

        // --- Interaction ---
        const raycaster = new THREE.Raycaster();
        const mouse = new THREE.Vector2();
        let popupMesh = null; let infoMesh = null; 

        function getRoot(o) { while(o){if(o.userData?.isRoot)return o; o=o.parent;} return null; }
        function updateMouse(e) {
            const r = renderer.domElement.getBoundingClientRect();
            mouse.x = ((e.clientX-r.left)/r.width)*2-1;
            mouse.y = -((e.clientY-r.top)/r.height)*2+1;
        }

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

            const geo = new THREE.PlaneGeometry(6, 8); 
            const mat = new THREE.MeshBasicMaterial({
                map: target.material.map || null, 
                color: target.material.map ? 0xffffff : (target.userData.isFolder?CONFIG.colFolder:CONFIG.colFile),
                transparent: true, opacity: 0.95, side: THREE.DoubleSide, depthTest: false 
            });
            popupMesh = new THREE.Mesh(geo, mat); popupMesh.renderOrder = 9999;
            popupMesh.position.copy(target.position);
            
            if(state.viewMode === 'F1' || state.viewMode === 'F3') {
               const dir = new THREE.Vector3(target.position.x, 0, target.position.z).normalize();
               popupMesh.position.add(dir.multiplyScalar(5)); popupMesh.lookAt(0, target.position.y, 0);
            } else if (state.viewMode === 'F2') {
               const dir = new THREE.Vector3(0, target.position.y, target.position.z).normalize();
               popupMesh.position.sub(dir.multiplyScalar(5)); popupMesh.lookAt(target.position.x, 0, 0);
            } else if (state.viewMode === 'F4') {
                popupMesh.position.y += 3; popupMesh.position.z += 6; popupMesh.rotation.x = Math.PI/4; 
            }
            
            const edge = new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({color:0xffffff, depthTest:false}));
            edge.renderOrder = 9999; popupMesh.add(edge);
            filesGroup.add(popupMesh);
            reticleGroup.scale.set(3,3,1);

            const iCvs = document.createElement('canvas'); iCvs.width=512; iCvs.height=512;
            const iCtx = iCvs.getContext('2d');
            const iTex = new THREE.CanvasTexture(iCvs);
            const iGeo = new THREE.PlaneGeometry(8, 8);
            const iMat = new THREE.MeshBasicMaterial({ map: iTex, transparent: true, opacity: 1, side: THREE.DoubleSide, depthTest: false });
            infoMesh = new THREE.Mesh(iGeo, iMat); infoMesh.renderOrder = 9999; 
            
            infoMesh.position.copy(popupMesh.position);
            const right = new THREE.Vector3(1,0,0).applyQuaternion(popupMesh.quaternion);
            infoMesh.position.add(right.multiplyScalar(7.5)); infoMesh.quaternion.copy(popupMesh.quaternion);
            filesGroup.add(infoMesh);

            const lines = [ `NAME: ${target.userData.handle.name}`, `KIND: ${target.userData.handle.kind.toUpperCase()}`, `DATE: ${new Date().toLocaleString()}`, `SIZE: ...` ];
            let lineIdx = 0;
            const drawLines = () => {
                if(!infoMesh) return;
                iCtx.clearRect(0,0,512,512); iCtx.font = "bold 40px 'Courier New'";
                iCtx.fillStyle = "#00ffff"; iCtx.shadowColor="#00ffff"; iCtx.shadowBlur=5;
                for(let i=0; i<=lineIdx && i<lines.length; i++) iCtx.fillText(lines[i], 10, 50 + i*60);
                iTex.needsUpdate = true;
                if(lineIdx < lines.length - 1) { lineIdx++; setTimeout(drawLines, 150); }
            };
            drawLines();
        }

        window.addEventListener('pointerdown', e => {
            sfx.init();
            if(e.button!==0 || !e.target.closest('canvas')) return;
            updateMouse(e);
            state.dragStart.set(e.clientX, e.clientY);
            
            raycaster.setFromCamera(mouse, camera);
            const hits = raycaster.intersectObjects(state.objects, true);
            closeAllMenus();
            
            if(hits.length>0) {
                const t = getRoot(hits[0].object);
                state.selected = t;
                state.dragging = { mesh:t, isMove: false }; 
                orbitControls.enabled = false;
            } else {
                state.selected = null; reticleGroup.visible = false; updatePopup(null);
                state.isRotating = true; state.lastMouse.set(e.clientX, e.clientY);
                document.body.style.cursor = 'grabbing';
            }
        });

        window.addEventListener('pointermove', e => {
            updateMouse(e);
            if(state.isRotating) {
                const deltaX = e.clientX - state.lastMouse.x;
                const deltaY = e.clientY - state.lastMouse.y;
                
                if(state.viewMode === 'F1') {
                    state.targetRotY -= deltaX * 0.005; state.targetPosY -= deltaY * 0.1;
                } else if (state.viewMode === 'F2') {
                    state.targetPosX += deltaX * 0.05; state.targetRotX -= deltaY * 0.005; 
                } else if (state.viewMode === 'F3') {
                    state.targetRotY -= deltaX * 0.005; state.targetPosY -= deltaY * 0.2;
                } else if (state.viewMode === 'F4') {
                    state.targetPosX += deltaX * 0.1; state.targetPosZ += deltaY * 0.1;
                }
                state.lastMouse.set(e.clientX, e.clientY);
            } else if(state.dragging) {
                if(state.dragStart.distanceTo(new THREE.Vector2(e.clientX, e.clientY)) > 5) state.dragging.isMove = true;
            } else {
                checkHover();
            }
        });

        window.addEventListener('pointerup', async (e) => {
            if(state.dragging) {
                if(!state.dragging.isMove) {
                    activateReticle(state.dragging.mesh); updatePopup(state.dragging.mesh); sfx.play('lock');
                } else {
                    const m = state.dragging.mesh;
                    if(e.clientY > window.innerHeight - 180) { // Dropped on Stock area
                       await moveToStock(m.userData.handle);
                    }
                }
                state.dragging = null; orbitControls.enabled = true;
            }
            state.isRotating = false; document.body.style.cursor = 'default';
        });

        window.addEventListener('wheel', e => {
            if(e.target.closest('canvas')) {
                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; 
            }
        });

        function activateReticle(mesh) { reticleGroup.visible = true; }

        function checkHover() {
            raycaster.setFromCamera(mouse, camera);
            const hits = raycaster.intersectObjects(state.objects, true);
            if(hits.length>0) {
                const t = getRoot(hits[0].object);
                if(state.hovered !== t) sfx.play('hover');
                state.hovered = t; document.body.style.cursor = 'pointer';
            } else {
                state.hovered = null; document.body.style.cursor = 'default';
            }
        }

        function animate() {
            requestAnimationFrame(animate);
            orbitControls.update();
            
            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') {
                filesGroup.rotation.y = state.currentRotY; filesGroup.position.y = state.currentPosY;
                filesGroup.rotation.x = 0; filesGroup.position.x = 0; filesGroup.position.z = 0;
            } else if (state.viewMode === 'F2') {
                filesGroup.rotation.x = state.currentRotX; filesGroup.position.x = state.currentPosX;
                filesGroup.rotation.y = 0; filesGroup.position.y = 0; filesGroup.position.z = 0;
            } else if (state.viewMode === 'F4') {
                filesGroup.rotation.set(state.currentRotX, 0, 0); 
                filesGroup.position.x = state.currentPosX; filesGroup.position.y = state.currentPosY; filesGroup.position.z = state.currentPosZ; 
            }

            starGroup.rotation.y += 0.0005; 

            if(reticleGroup.visible && (state.selected || state.hovered)) {
                const t = state.selected || state.hovered;
                const wp = new THREE.Vector3(); t.getWorldPosition(wp);
                const wq = new THREE.Quaternion(); t.getWorldQuaternion(wq);
                reticleGroup.position.copy(wp); reticleGroup.quaternion.copy(wq);
                reticleGroup.translateZ(0.2); 
                const layers = reticleGroup.userData.layers;
                layers.forEach(l => { l.rotation.z += l.userData.speed * l.userData.dir; });
                const s = (state.selected && popupMesh) ? 3 : 1;
                reticleGroup.scale.set(s,s,1);
            } else {
                reticleGroup.visible = false;
            }
            drawHUD(); composer.render();
        }
        animate();

        // --- HUD / Menu ---
        function drawHUD() {
            const dpr = window.devicePixelRatio||1;
            hudX.clearRect(0,0,hudC.width/dpr, hudC.height/dpr);
            const t = state.hovered || state.selected;
            if(!t || state.dragging) return;
            const p = new THREE.Vector3(); t.getWorldPosition(p);
            p.y += 2.0; p.project(camera);
            if(p.z > 1) return;
            const x = (p.x*0.5+0.5)*(hudC.width/dpr);
            const y = (p.y*-0.5+0.5)*(hudC.height/dpr);
            hudX.shadowBlur = 10; hudX.shadowColor = CONFIG.colCursor;
            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 24px 'Segoe UI'"; hudX.fillStyle='#fff'; hudX.fillText(t.userData.handle.name, x+45, y-45);
            hudX.shadowBlur = 0;
        }

        const ctxObj = document.getElementById('ctx-obj');
        const ctxBg = document.getElementById('ctx-bg');
        [ctxObj, ctxBg].forEach(m => m.addEventListener('pointerdown', e => e.stopPropagation()));

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

        window.addEventListener('contextmenu', e => {
            e.preventDefault(); 
            if(e.target.closest('.tree-node')) { showMenu(ctxObj, e.clientX, e.clientY); return; }
            if(!e.target.closest('canvas')) return;
            updateMouse(e); raycaster.setFromCamera(mouse, camera);
            const hits = raycaster.intersectObjects(state.objects, true);
            closeAllMenus();
            sfx.play('click');
            if(hits.length>0) {
                const t = getRoot(hits[0].object);
                state.selected = t; state.contextTarget = t.userData.handle;
                activateReticle(t); updatePopup(t);
                showMenu(ctxObj, e.clientX, e.clientY);
            } else {
                state.selected = null; state.contextTarget = null;
                updatePopup(null); showMenu(ctxBg, e.clientX, e.clientY);
            }
        });
        
        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';
        }

        function getActionTarget() { return state.contextTarget || (state.selected ? state.selected.userData.handle : null); }
        document.getElementById('cm-open').onclick = () => {
            const t = getActionTarget();
            if(t) { if(t.kind==='directory') animateTransition('in', async ()=> await enterFolder(t, false)); else openPreview(t); }
            closeAllMenus();
        };
        document.getElementById('cm-copy').onclick = () => { state.clipboard = getActionTarget(); closeAllMenus(); };
        document.getElementById('cm-delete').onclick = async () => {
            closeAllMenus(); // Close first
            const t = getActionTarget();
            if(t && confirm('Delete?')) { await state.currentHandle.removeEntry(t.name, {recursive:true}); enterFolder(state.currentHandle, false); }
        };
        document.getElementById('cm-rename').onclick = async () => {
            closeAllMenus(); // Close first
            const t = getActionTarget();
            if(t) { 
                const n = await customPrompt("RENAME", t.name);
                if(n && n!==t.name && t.move) { await t.move(n); enterFolder(state.currentHandle, false); }
            }
        };
        document.getElementById('cm-add-stock').onclick = async () => {
            const t = getActionTarget();
            if (t && t.kind === 'directory') {
                state.stockHandle = t; // Set as actual folder
                document.getElementById('stock-tab').innerText = t.name.toUpperCase();
                refreshStock();
                document.getElementById('stock').classList.remove('closed');
            } else {
                alert("Please select a folder.");
            }
            closeAllMenus();
        };
        
        document.getElementById('cm-new').onclick = async () => {
            closeAllMenus(); // Close first
            const n = await customPrompt("NEW FOLDER", "New Folder");
            if(n) { await state.currentHandle.getDirectoryHandle(n, {create:true}); enterFolder(state.currentHandle, false); }
        };
        document.getElementById('cm-paste').onclick = async () => {
            if(state.clipboard && state.clipboard.kind === 'file') {
                const f = await state.clipboard.getFile();
                const h = await state.currentHandle.getFileHandle('Copy_'+f.name, {create:true});
                const w = await h.createWritable(); await w.write(f); await w.close();
                enterFolder(state.currentHandle, false);
            } closeAllMenus();
        };
        document.getElementById('cm-up').onclick = () => { goUp(); closeAllMenus(); };
        document.getElementById('cm-refresh').onclick = () => { enterFolder(state.currentHandle, false); closeAllMenus(); };

        window.addEventListener('dblclick', e => {
            if(!e.target.closest('canvas')) return;
            updateMouse(e); raycaster.setFromCamera(mouse, camera);
            const hits = raycaster.intersectObjects(state.objects, true);
            if(hits.length>0) {
                const t = getRoot(hits[0].object);
                if(t.userData.isFolder) animateTransition('in', async ()=> await enterFolder(t.userData.handle, false));
                else openPreview(t.userData.handle);
            }
        });

        document.getElementById('sb-tab').onclick=()=>document.getElementById('sidebar').classList.toggle('closed');
        document.getElementById('stock-tab').onclick=()=>document.getElementById('stock').classList.toggle('closed');
        
        // --- STOCK LOGIC REWRITE ---
        
        async function refreshStock() {
            if(!state.stockHandle) return;
            const s = document.getElementById('stock'); 
            s.innerHTML='';
            state.stockEntries = [];
            try {
                for await (const entry of state.stockHandle.values()) {
                    state.stockEntries.push(entry);
                }
                if(state.stockEntries.length === 0) {
                    s.innerHTML = '<div style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY STOCK ]</div>';
                } else {
                    for (let i=0; i<state.stockEntries.length; i++) {
                        const h = state.stockEntries[i];
                        const d = document.createElement('div'); 
                        d.className = 'stock-item'; d.draggable = true;
                        
                        // Thumbnail Area
                        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 if(n.match(/\.(mp4|webm)$/)) {
                                h.getFile().then(f => {
                                    const vid = document.createElement('video'); vid.src = URL.createObjectURL(f);
                                    vid.style.maxWidth='100%'; vid.style.maxHeight='100%';
                                    thumb.appendChild(vid);
                                });
                            } else {
                                thumb.innerHTML = '<span style="font-size:30px">📄</span>';
                            }
                        }
                        d.appendChild(thumb);

                        // Label
                        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(); // Stop scrolling on drag start
                        };
                        s.appendChild(d);
                    }
                }
            } catch(e) {
                s.innerHTML = '<div style="color:red">ACCESS ERROR</div>';
            }
        }

        // Actual Move Logic
        async function moveToStock(handle) {
            if(!state.stockHandle) { alert("No Stock Folder Set! Right click a folder and 'Add to Stock'."); return; }
            if(handle.move) {
                try {
                    await handle.move(state.stockHandle);
                    enterFolder(state.currentHandle, false); // Refresh Main
                    refreshStock(); // Refresh Stock
                } catch(e) { sfx.play('error'); alert("Move failed: " + e.message); }
            } else {
                alert("Browser doesn't support 'move' on this handle.");
            }
        }

        async function moveFromStock(stockIndex) {
            const h = state.stockEntries[stockIndex];
            if(!h) return;
            if(h.move) {
                try {
                    await h.move(state.currentHandle);
                    enterFolder(state.currentHandle, false);
                    refreshStock();
                } catch(e) { sfx.play('error'); alert("Move failed: " + e.message); }
            }
        }

        container.ondragover=e=>e.preventDefault();
        container.ondrop = async e => {
            e.preventDefault(); 
            const idx = e.dataTransfer.getData('stockIndex');
            if(idx !== '') {
                await moveFromStock(parseInt(idx));
            }
        };

        // Manual Scroll for Stock
        const stockDiv = document.getElementById('stock');
        let isStockDragging = false;
        let startX, scrollLeft;

        stockDiv.addEventListener('pointerdown', (e) => {
            if(e.target.closest('.stock-item')) return; // Allow Item DnD
            isStockDragging = true;
            stockDiv.classList.add('active');
            startX = e.pageX - stockDiv.offsetLeft;
            scrollLeft = stockDiv.scrollLeft;
        });

        stockDiv.addEventListener('pointerleave', () => {
            isStockDragging = false; stockDiv.classList.remove('active');
        });

        stockDiv.addEventListener('pointerup', () => {
            isStockDragging = false; stockDiv.classList.remove('active');
        });

        stockDiv.addEventListener('pointermove', (e) => {
            if(!isStockDragging) return;
            e.preventDefault();
            const x = e.pageX - stockDiv.offsetLeft;
            const walk = (x - startX) * 1.5; // Scroll speed
            stockDiv.scrollLeft = scrollLeft - walk;
        });


        // --- GUI Dialog Logic ---
        const dModal = document.getElementById('dialog');
        const dInput = document.getElementById('d-input');
        const btnOk = document.getElementById('d-ok');
        const btnCancel = document.getElementById('d-cancel');
        let dResolve = null;

        function customPrompt(title, defaultValue) {
            return new Promise(resolve => {
                document.getElementById('d-title').innerText = title;
                dInput.value = defaultValue;
                dResolve = resolve;
                dModal.classList.remove('closing');
                dModal.classList.add('active', 'opening');
                dInput.focus();
                sfx.play('open');
            });
        }

        function closeDialog(result) {
            dModal.classList.remove('opening');
            dModal.classList.add('closing');
            sfx.play('back');
            setTimeout(() => {
                dModal.classList.remove('active', 'closing');
                if(dResolve) dResolve(result);
                dResolve = null;
            }, 500);
        }

        btnOk.onclick = () => closeDialog(dInput.value);
        btnCancel.onclick = () => closeDialog(null);
        dInput.addEventListener('keydown', e => {
            if(e.key === 'Enter') closeDialog(dInput.value);
            if(e.key === 'Escape') closeDialog(null);
        });

        // --- Preview Modal Logic ---
        const pModal=document.getElementById('preview'), pBody=document.getElementById('p-body');
        const btnSave = document.getElementById('p-save');
        let currentFileHandle = null;

        document.getElementById('p-close').onclick=()=>{
            pModal.classList.remove('opening'); pModal.classList.add('closing');
            sfx.play('back');
            setTimeout(() => { pModal.classList.remove('active', 'closing'); }, 500); 
        };
        btnSave.onclick = async () => {
            const ta = document.getElementById('editor-area');
            if(currentFileHandle && ta) {
                try {
                    const w = await currentFileHandle.createWritable();
                    await w.write(ta.value); await w.close();
                    alert("Saved!");
                } catch(e) { alert("Save failed."); }
            }
        };

        async function openPreview(handle) {
            currentFileHandle = handle;
            pModal.classList.remove('closing'); pModal.classList.add('active', 'opening'); 
            sfx.play('open');
            document.getElementById('p-title').innerText=handle.name;
            pBody.innerHTML='Loading...'; btnSave.style.display='none';
            try {
                const f = await handle.getFile();
                const u = URL.createObjectURL(f);
                if(f.name.match(/\.(png|jpg|webp|gif|bmp)$/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 if(f.name.match(/\.(fbx|obj|glb)$/i)) pBody.innerHTML='[3D Model View]';
                else {
                    const t = await f.text();
                    pBody.innerHTML = `<textarea id="editor-area" class="hex-view">${t}</textarea>`;
                    btnSave.style.display = 'block';
                }
            } catch(e) { pBody.innerText="Error"; }
        }
        const treeC=document.getElementById('tree-container');
        async function updateTree(r) { treeC.innerHTML=''; treeC.appendChild(mkNode(r,0)); for await(const e of r.values()) if(e.kind==='directory') treeC.appendChild(mkNode(e,1)); }
        function mkNode(h,d) {
            const e=document.createElement('div'); e.className='tree-node'; e.style.paddingLeft=(d*15)+'px';
            e.innerHTML=`<span class="icon-tree">${h.kind==='directory'?'📂':'📄'}</span>${h.name}`;
            e.onclick=()=>{ animateTransition('in', async ()=> await enterFolder(h, false)); };
            e.oncontextmenu=(ev)=>{ ev.preventDefault(); ev.stopPropagation(); state.contextTarget=h; showMenu(ctxObj, ev.clientX, ev.clientY); };
            return e;
        }
    </script>
</body>
</html>

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

コメント

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