円錐形のフォルダビューワ「Folder Viewer 3D」


画像
バージョン1.1の画面


画像
バージョン1.2の画面


画像
バージョン1.3の画面

[ 操作説明 ]

・上キーで下の階層に進む。
・下キーで上の階層に戻る。
・左右のキーで移動先を選ぶ。
・homeキーでルートフォルダに戻る。
・Enterキーで選択中のファイルを開く。
・escキーで閉じる。

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

・バージョン1.3のソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Cyber Stream Explorer 1.3</title>
    <style>
        :root {
            --main: #ffcc00;   /* Amber */
            --accent: #00ffff; /* Cyan */
            --bg: #010101;     /* Black */
        }
        body { margin: 0; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', sans-serif; user-select: none; color: #fff; }
        
        #ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
        #error-log { position: absolute; bottom: 10px; left: 10px; color: #ff0000; font-family: monospace; background: rgba(0,0,0,0.8); padding: 5px; display: none; pointer-events: auto; }
        
        #header { position: absolute; top: 20px; left: 20px; pointer-events: auto; }
        button.sys-btn { background: rgba(50, 40, 0, 0.6); border: 1px solid var(--main); color: var(--main); padding: 10px 24px; font-weight: bold; cursor: pointer; text-transform: uppercase; letter-spacing: 2px; }
        button.sys-btn:hover { background: rgba(255, 200, 0, 0.3); color: #fff; }
        #path-display { color: var(--accent); font-family: monospace; font-size: 18px; font-weight: bold; text-shadow: 0 0 5px var(--accent); margin-top: 10px; }

        .instructions { 
            position: absolute; top: 20px; right: 20px; text-align: right; 
            color: rgba(200, 220, 255, 0.9); font-size: 13px; line-height: 1.8;
            background: rgba(0, 0, 0, 0.7); padding: 20px; border-right: 4px solid var(--main); 
            pointer-events: auto; box-shadow: 0 0 20px rgba(0,0,0,0.5);
        }
        .key { 
            border: 1px solid #fff; padding: 2px 6px; border-radius: 4px; 
            background: #fff; color: #000; font-weight: bold; margin: 0 3px; font-family: monospace;
        }

        #loading { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.95); z-index: 9000; display: none; justify-content: center; align-items: center; color: var(--main); font-size: 24px; font-weight: bold; letter-spacing: 4px; }
        
        #stock { position: absolute; bottom: 0; left: 0; width: 100%; height: 160px; background: linear-gradient(to top, rgba(10,5,0,0.95), transparent); border-top: 2px solid var(--main); display: flex; align-items: center; padding: 0 40px; gap: 20px; z-index: 40; transition: bottom 0.3s; overflow-x: auto; pointer-events: auto; }
        #stock.closed { bottom: -160px; }
        #stock-tabs { position: absolute; bottom: 160px; left: 50%; transform: translateX(-50%); display: flex; gap: 4px; z-index: 41; transition: bottom 0.3s; pointer-events: auto; }
        #stock.closed + #stock-tabs { bottom: 0; }
        
        .tab-btn { background: rgba(20, 10, 0, 0.9); color: #888; padding: 6px 20px; cursor: pointer; border-top: 2px solid #553300; min-width: 100px; text-align: center; font-weight: bold; clip-path: polygon(10% 0, 90% 0, 100% 100%, 0% 100%); }
        .tab-btn.active { background: var(--main); color: #000; border-top-color: #fff; }
        .stock-item { width: 100px; height: 120px; background: rgba(0,0,0,0.5); border: 1px solid var(--main); display: flex; flex-direction: column; align-items: center; cursor: grab; color: var(--main); flex-shrink: 0; position: relative; }
        .stock-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }

        #viewer-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.85); z-index: 100; justify-content: center; align-items: center; pointer-events: auto; perspective: 1500px; }
        #viewer-win { width: 90%; height: 85%; border: 2px solid var(--main); background: #050505; display: flex; flex-direction: column; transform-style: preserve-3d; opacity: 0; box-shadow: 0 0 50px rgba(255, 200, 0, 0.2); }
        #viewer-overlay.active #viewer-win { animation: animFlipOpen 0.6s cubic-bezier(0.19, 1, 0.22, 1) forwards; }
        #viewer-overlay.closing #viewer-win { animation: animFlipClose 0.5s cubic-bezier(0.19, 1, 0.22, 1) forwards; }
        @keyframes animFlipOpen { 0% { transform: translateZ(-500px) rotateX(180deg) scale(0.5); opacity: 0; } 100% { transform: translateZ(0) rotateX(0deg) scale(1); opacity: 1; } }
        @keyframes animFlipClose { 0% { transform: translateZ(0) rotateX(0deg) scale(1); opacity: 1; } 100% { transform: translateZ(-500px) rotateX(-180deg) scale(0.5); opacity: 0; } }
        
        #viewer-head { padding: 10px 20px; background: rgba(255, 200, 0, 0.1); border-bottom: 1px solid var(--main); color: var(--main); font-weight: bold; display: flex; justify-content: space-between; align-items: center; }
        #viewer-body { flex: 1; overflow: hidden; position: relative; display:flex; justify-content:center; align-items:center; }
        #viewer-body img, #viewer-body video { max-width: 100%; max-height: 100%; object-fit: contain; }
        .win-btn { background: transparent; border: 1px solid var(--main); color: var(--main); padding: 5px 20px; cursor: pointer; margin-left: 10px; font-weight: bold; transition:0.2s;}
        .win-btn:hover { background: var(--main); color: #000; }

        #dialog-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 500; justify-content: center; align-items: center; pointer-events: auto; }
        #dialog-box { background: #080808; border: 1px solid var(--accent); padding: 30px; width: 450px; text-align: center; box-shadow: 0 0 30px rgba(0,255,255,0.2); }
        #dialog-title { color: var(--accent); font-weight: bold; margin-bottom: 20px; display: block; font-size: 18px; }
        #dialog-input { width: 100%; background: #111; border: 1px solid #333; color: #fff; padding: 12px; font-size: 18px; outline: none; margin-bottom: 25px; text-align: center; }
        #dialog-input:focus { border-color: var(--accent); }

        .ctx-menu { display: none; position: absolute; z-index: 200; background: rgba(5, 10, 15, 0.98); border: 1px solid var(--main); min-width: 200px; pointer-events: auto; }
        .ctx-item { padding: 12px 20px; color: #ddd; cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.1); }
        .ctx-item:hover { background: var(--main); color: #000; }
        #drag-ghost { position: absolute; pointer-events: none; z-index: 9999; padding: 10px 20px; background: rgba(0,0,0,0.8); border: 1px solid var(--main); color: var(--main); font-weight: bold; border-radius: 5px; display: none; }
    </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="ui-layer">
        <div id="error-log"></div>
        <div id="header">
            <button id="btn-init" class="sys-btn">INITIALIZE SYSTEM</button>
            <div id="path-display">SYSTEM STANDBY</div>
        </div>
        <div class="instructions">
            <b>CONTROLS:</b><br>
            <span class="key">←</span> <span class="key">→</span> : Rotate & Select<br>
            <span class="key">↑</span> / <span class="key">Ent</span> : Open<br>
            <span class="key">↓</span> : Back<br>
            <span class="key">Drag</span> : Move Camera<br>
            <span class="key">R-Click</span> : Menu
        </div>
        <div id="stock" class="closed"></div>
        <div id="stock-tabs"></div>
    </div>

    <div id="viewer-overlay">
        <div id="viewer-win">
            <div id="viewer-head">
                <span id="viewer-title">FILE</span>
                <div><button id="viewer-close" class="win-btn">CLOSE</button></div>
            </div>
            <div id="viewer-body"></div>
        </div>
    </div>

    <div id="ctx-menu" 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-paste">Paste</div>
        <div class="ctx-item" id="cm-rename">Rename</div>
        <div class="ctx-item" id="cm-new">New Folder</div>
        <div class="ctx-item" id="cm-stock">Add to Stock</div>
        <div class="ctx-item" id="cm-del" style="color:#ff5555;">Delete</div>
    </div>
    <div id="ctx-tab" class="ctx-menu">
        <div class="ctx-item" id="cm-tab-hide">Hide Stock</div>
        <div class="ctx-item" id="cm-tab-cancel" style="color:#ff5555;">Cancel Tab</div>
    </div>
    <div id="dialog-overlay">
        <div id="dialog-box">
            <span id="dialog-title">INPUT</span>
            <input type="text" id="dialog-input">
            <div>
                <button id="dialog-ok" class="sys-btn" style="border-color:var(--accent);color:var(--accent);width:120px;">OK</button>
                <button id="dialog-cancel" class="sys-btn" style="border-color:#ff5555;color:#ff5555;width:120px;">CANCEL</button>
            </div>
        </div>
    </div>
    <div id="drag-ghost">MOVING...</div>
    <div id="loading"><div class="blink">SCANNING...</div></div>

    <script type="module">
        import * as THREE from 'three';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
        import TWEEN from 'three/addons/libs/tween.module.js';

        const CONFIG = {
            layerDepth: 800,
            coneSlope: 0.7,
            baseRadius: 180,
            camHeight: 280,
            camDist: 650,       
            camLookAhead: 750,  
            nodeWidth: 180,
            spacingFactor: 2.5, 
            colMain: 0xffcc00,
            colFile: 0x00aaff,
            colLine: 0xffaa00,
            bloomStr: 2.5, bloomRad: 0.8, bloomThres: 0.7,
            flowSpeedNormal: 20,
            flowSpeedFast: 400
        };

        const state = {
            rootNode: null, activeNode: null, targetChildIdx: 0,
            visibleNodes: [], stockList: [], activeStockIdx: -1, clipboard: null,
            focusPointZ: 0, targetFocusZ: 0, currentRotation: 0, targetRotation: 0,
            mouse: new THREE.Vector2(), raycaster: new THREE.Raycaster(), 
            ctxTarget: null, tabCtxIdx: -1,
            dragging: null, isCamDragging: false, lastMouse: new THREE.Vector2(),
            isMoving: false, centerMesh: null,
            currentFlowSpeed: CONFIG.flowSpeedNormal
        };

        const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x020202, 0.0005);
        const camera = new THREE.PerspectiveCamera(55, window.innerWidth/window.innerHeight, 1, 60000);
        const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: "high-performance" });
        renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(renderer.domElement);

        const composer = new EffectComposer(renderer);
        composer.addPass(new RenderPass(scene, camera));
        const bloom = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), CONFIG.bloomStr, CONFIG.bloomRad, CONFIG.bloomThres);
        composer.addPass(bloom);

        const world = new THREE.Group(); scene.add(world);
        const grid = new THREE.GridHelper(80000, 400, 0x332200, 0x080500); grid.position.y = -2000; scene.add(grid);

        // Stream Lines (Background)
        const streamGroup = new THREE.Group(); scene.add(streamGroup);
        const streamGeo = new THREE.BufferGeometry();
        const streamPos = [];
        for(let i=0; i<1500; i++) {
            const x=(Math.random()-0.5)*15000, y=(Math.random()-0.5)*15000, z=(Math.random()-0.5)*30000;
            const len = 3000 + Math.random() * 4000;
            streamPos.push(x,y,z, x,y,z-len);
        }
        streamGeo.setAttribute('position', new THREE.Float32BufferAttribute(streamPos, 3));
        const streamMat = new THREE.LineBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.6 }); 
        const streamLines = new THREE.LineSegments(streamGeo, streamMat);
        streamGroup.add(streamLines);

        // --- OVERLAYS ---

        // 1. Info Mesh
        const infoMesh = new THREE.Mesh(new THREE.PlaneGeometry(600, 300), new THREE.MeshBasicMaterial({ transparent:true, opacity:0, depthTest:false, side: THREE.DoubleSide }));
        infoMesh.renderOrder = 9999; scene.add(infoMesh); infoMesh.visible = false;

        function updateInfoTexture(node) {
            if(!node) return;
            const w=1024; const h=512;
            const c = document.createElement('canvas'); c.width=w; c.height=h;
            const ctx = c.getContext('2d');

            ctx.shadowColor = "#00ffff"; ctx.shadowBlur = 10;
            ctx.font = "bold 50px 'Segoe UI', monospace";
            
            let y = 100; const x = 50;
            ctx.fillStyle = "#00ffff"; ctx.textAlign = 'left'; ctx.fillText("NAME: ", x, y);
            ctx.fillStyle = "#ffffff"; ctx.fillText(node.name, x + 180, y); 
            y += 80;
            ctx.fillStyle = "#00ffff"; ctx.fillText("KIND: ", x, y);
            ctx.fillStyle = "#cccccc"; ctx.fillText(node.kind.toUpperCase(), x + 180, y);
            y += 80;
            ctx.fillStyle = "#00ffff"; ctx.fillText("INFO: ", x, y);
            ctx.fillStyle = "#cccccc"; ctx.fillText(`${node.sizeStr || '-'} / ${node.dateStr || '-'}`, x + 180, y);

            infoMesh.material.map = new THREE.CanvasTexture(c);
            infoMesh.material.map.colorSpace = THREE.SRGBColorSpace;
            infoMesh.material.needsUpdate = true;
            infoMesh.material.opacity = 1;
        }

        // 2. Magnified Mesh
        const magMesh = new THREE.Mesh(new THREE.PlaneGeometry(320, 420), new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0, depthTest: false }));
        magMesh.renderOrder = 9998; scene.add(magMesh); magMesh.visible = false;
        
        // 3. Indicator Line
        const lineGeo = new THREE.BufferGeometry();
        const lineMesh = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.8, depthTest: false }));
        lineMesh.renderOrder = 9999; scene.add(lineMesh); lineMesh.visible = false;

        // 4. Center Cursor
        const centerCursor = new THREE.Mesh(new THREE.RingGeometry(160, 170, 64), new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, transparent:true, opacity:0.8 }));
        scene.add(centerCursor); centerCursor.visible = false;

        // 5. Exp3D Style Target Cursor (4 Layers)
        const reticleGroup = new THREE.Group();
        scene.add(reticleGroup); reticleGroup.visible = false;
        
        function createReticle() {
            // Layer 1: Inner Solid Ring
            const g1 = new THREE.RingGeometry(130, 133, 64);
            const m1 = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, transparent:true, opacity:0.9 });
            const r1 = new THREE.Mesh(g1, m1);
            r1.userData = { speed: 0.02 };
            reticleGroup.add(r1);
            
            // Layer 2: Segmented Ring
            const g2 = new THREE.RingGeometry(138, 145, 64, 1, 0, Math.PI * 1.5);
            const m2 = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, transparent:true, opacity:0.6 });
            const r2 = new THREE.Mesh(g2, m2);
            r2.userData = { speed: -0.03 };
            reticleGroup.add(r2);

            // Layer 3: Dashed Ring
            const g3 = new THREE.RingGeometry(150, 152, 32);
            const m3 = new THREE.MeshBasicMaterial({ color: 0xffaa00, side: THREE.DoubleSide, transparent:true, opacity:0.8, wireframe:false });
            // Create dashes by texture or multiple segments. Using segments here.
            const r3 = new THREE.Group();
            for(let i=0; i<8; i++){
                const s = new THREE.Mesh(new THREE.RingGeometry(150,152,8,1, i*(Math.PI/4), Math.PI/8), m3);
                r3.add(s);
            }
            r3.userData = { speed: 0.015 };
            reticleGroup.add(r3);

            // Layer 4: Outer Brackets
            const r4 = new THREE.Group();
            const m4 = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, transparent:true, opacity:1.0 });
            const s4a = new THREE.Mesh(new THREE.RingGeometry(160, 165, 32, 1, -0.5, 1.0), m4);
            const s4b = new THREE.Mesh(new THREE.RingGeometry(160, 165, 32, 1, Math.PI-0.5, 1.0), m4);
            r4.add(s4a); r4.add(s4b);
            r4.userData = { speed: -0.05 };
            reticleGroup.add(r4);
        }
        createReticle();

        // Particles
        let arrowTexture;
        function createArrowTexture() {
            const c=document.createElement('canvas'); c.width=64; c.height=64; const x=c.getContext('2d');
            x.fillStyle='#ffaa00'; x.beginPath(); x.moveTo(32,10); x.lineTo(54,54); x.lineTo(10,54); x.closePath(); x.fill();
            arrowTexture = new THREE.CanvasTexture(c);
        }
        createArrowTexture();
        const particles = [];
        function spawnArrow(startPos, endPos) {
            const m = new THREE.SpriteMaterial({ map: arrowTexture, color: 0xffffff, transparent: true, opacity: 1.0, depthTest: false });
            const s = new THREE.Sprite(m); s.scale.set(30, 30, 1); s.position.copy(startPos); scene.add(s);
            particles.push({ mesh: s, start: startPos.clone(), end: endPos.clone(), progress: 0, speed: 0.02+Math.random()*0.015 });
        }

        function init() {
            window.addEventListener('resize', onResize);
            document.getElementById('btn-init').onclick = selectRoot;
            document.addEventListener('keydown', e => {
                const v = document.getElementById('viewer-overlay');
                if(v.classList.contains('active')) {
                    const isEditor = document.getElementById('editor-area');
                    if(isEditor && document.activeElement === isEditor) { if(e.key === 'Escape') closeViewer(); return; }
                    if(['ArrowDown', 'Enter', 'Escape'].includes(e.key)) { e.preventDefault(); closeViewer(); }
                    return;
                }
                if(document.getElementById('dialog-overlay').style.display==='flex') return;
                onKey(e);
            });
            document.addEventListener('contextmenu', onContextMenu);
            window.addEventListener('click', e => { if(!e.target.closest('.ctx-menu')) document.querySelectorAll('.ctx-menu').forEach(m => m.style.display='none'); });
            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mousedown', onMouseDown);
            window.addEventListener('mouseup', onMouseUp);
            document.addEventListener('dblclick', onMouseDoubleClick);
            const container = document.body;
            container.ondragover = e => e.preventDefault();
            container.ondrop = async e => { e.preventDefault(); const idx = e.dataTransfer.getData('stockIndex'); if(idx) moveFromStock(parseInt(idx)); };
            bindMenu(); bindDialog(); bindViewer();
            animate();
        }

        async function selectRoot() {
            try {
                const h = await window.showDirectoryPicker();
                showLoad(true);
                state.rootNode = await scanNode(h, null, 0);
                state.activeNode = state.rootNode;
                state.targetChildIdx = 0;
                state.focusPointZ = state.rootNode.z;
                rebuildWorld();
                showLoad(false);
                updatePath();
            } catch(e) { console.error(e); showLoad(false); }
        }

        async function scanNode(handle, parent, depth) {
            const node = {
                handle: handle, name: handle.name, kind: handle.kind,
                parent: parent, depth: depth, children: [],
                angle: 0, z: -depth * CONFIG.layerDepth,
                obj: null, visualLine: null, thumbTex: null, sizeStr: '', dateStr: '',
                currentScale: 1.0, currentLift: 0.0
            };
            if(handle.kind === 'file') {
                handle.getFile().then(f => {
                    node.sizeStr = (f.size/1024).toFixed(1) + ' KB';
                    node.dateStr = new Date(f.lastModified).toLocaleDateString();
                    const fType = f.type.toLowerCase();
                    const loadT = (t) => {
                        node.thumbTex = t; node.thumbTex.colorSpace = THREE.SRGBColorSpace;
                        if(node.obj) updateNodeTexture(node);
                    };
                    if(fType.startsWith('image/')) {
                         if(fType === 'image/bmp' && window.createImageBitmap) createImageBitmap(f).then(bmp => loadT(new THREE.CanvasTexture(bmp)));
                         else { const url = URL.createObjectURL(f); new THREE.TextureLoader().load(url, (t) => { loadT(t); URL.revokeObjectURL(url); }); }
                    } else if(fType.startsWith('video/')) {
                        const v = document.createElement('video'); v.src = URL.createObjectURL(f); v.muted = true; v.preload = 'metadata'; v.playsInline = true; v.loop = true;
                        v.play().then(() => { const vTex = new THREE.VideoTexture(v); vTex.colorSpace = THREE.SRGBColorSpace; node.thumbTex = vTex; if(node.obj) updateNodeTexture(node); })
                        .catch(e => { v.onloadeddata = () => { v.currentTime = 0.5; }; v.onseeked = () => { const c = document.createElement('canvas'); c.width=320; c.height=180; c.getContext('2d').drawImage(v,0,0,320,180); loadT(new THREE.CanvasTexture(c)); }; });
                    }
                }).catch(()=>{});
            } else node.sizeStr = '<DIR>';
            if (handle.kind === 'directory') {
                try {
                    const entries = []; for await (const e of handle.values()) entries.push(e);
                    entries.sort((a,b) => (a.kind===b.kind ? a.name.localeCompare(b.name) : (a.kind==='directory'?-1:1)));
                    for (const e of entries) node.children.push(await scanNode(e, node, depth+1));
                } catch(e){}
            }
            return node;
        }

        function updateNodeTexture(node) {
            if(node.obj && node.kind === 'file' && node.thumbTex) {
                const thumbMesh = node.obj.getObjectByName('thumbMesh');
                if(thumbMesh) {
                    thumbMesh.material.map = node.thumbTex;
                    thumbMesh.material.needsUpdate = true;
                    thumbMesh.material.color.setHex(0xffffff); 
                }
            }
        }

        function calculateRadius(count) {
            const itemArc = CONFIG.nodeWidth * CONFIG.spacingFactor;
            const circumference = count * itemArc;
            const minRadius = circumference / (2 * Math.PI);
            return Math.max(CONFIG.baseRadius, minRadius);
        }

        function createCenterVisual() {
            if(state.centerMesh) { scene.remove(state.centerMesh); state.centerMesh = null; }
            if(!state.activeNode) return;

            const grp = new THREE.Group();
            grp.position.set(0, 0, state.activeNode.z);
            
            const geo = new THREE.BoxGeometry(100, 100, 100);
            const mat = new THREE.MeshBasicMaterial({ color: CONFIG.colMain, wireframe: true, transparent:true, opacity:0.3 });
            const cube = new THREE.Mesh(geo, mat);
            grp.add(cube);

            const inner = new THREE.Mesh(new THREE.BoxGeometry(80,80,80), new THREE.MeshBasicMaterial({ color: CONFIG.colMain, transparent:true, opacity:0.8 }));
            grp.add(inner);

            const cvs = document.createElement('canvas'); cvs.width=512; cvs.height=128; 
            const ctx = cvs.getContext('2d');
            ctx.font = "bold 80px 'Segoe UI'"; ctx.fillStyle = "#ffffff"; ctx.textAlign="center"; ctx.textBaseline="middle";
            ctx.shadowBlur=10; ctx.shadowColor="#ffcc00";
            ctx.fillText(state.activeNode.name, 256, 64);
            const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cvs), transparent: true, depthWrite: false }));
            sprite.scale.set(300, 75, 1);
            sprite.position.set(0, -100, 0);
            grp.add(sprite);

            scene.add(grp);
            state.centerMesh = grp;

            centerCursor.visible = true;
            centerCursor.position.set(0, 0, state.activeNode.z);
            centerCursor.rotation.set(0,0,0);
        }

        function rebuildWorld() {
            while(world.children.length) { const c = world.children[0]; world.remove(c); if(c.geometry) c.geometry.dispose(); }
            state.visibleNodes = [];
            
            createCenterVisual();

            if(!state.activeNode) return;
            let p = state.activeNode; while(p) { createNodeVisual(p, true); p = p.parent; }
            const children = state.activeNode.children;
            if (children.length > 0) {
                const childZ = state.activeNode.z - CONFIG.layerDepth;
                const radius = calculateRadius(children.length);
                const step = (Math.PI * 2) / Math.max(children.length, 1);
                
                children.forEach((child, i) => { 
                    child.angle = i * step; 
                    child.z = childZ; 
                    createNodeVisual(child, false, radius); 
                });
            }
            const target = state.activeNode.children[state.targetChildIdx];
            if(state.activeNode.parent && state.activeNode.parent.obj && state.activeNode.obj) createConnection(state.activeNode.parent, state.activeNode, true);
            children.forEach(c => { if(c.obj && state.activeNode.obj) createConnection(state.activeNode, c, (c === target)); });
            rotateToTarget();
        }

        function createNodeVisual(node, isCenter, overrideRadius) {
            if (node.obj && node.obj.parent === world) return; 
            const grp = new THREE.Group();
            let x=0, y=0;
            if (!isCenter) { 
                const r = overrideRadius || calculateRadius(1);
                x = Math.cos(node.angle) * r; 
                y = Math.sin(node.angle) * r; 
            }
            grp.position.set(x, y, node.z);
            
            if (!isCenter) {
                grp.rotation.z = node.angle + Math.PI/2;
            }

            const w = CONFIG.nodeWidth; const h = w * 1.3;
            
            // Increased Font Size and Scale
            const nCvs = document.createElement('canvas'); nCvs.width=1024; nCvs.height=256;
            const nCtx = nCvs.getContext('2d');
            nCtx.font = `bold 120px "Segoe UI", Arial`; nCtx.textAlign='center'; nCtx.textBaseline='middle';
            const tm = nCtx.measureText(node.name); 
            const maxW = nCvs.width * 0.95;
            nCtx.save(); nCtx.translate(nCvs.width/2, nCvs.height/2);
            if(tm.width > maxW) nCtx.scale(maxW/tm.width, 1);
            nCtx.shadowBlur=8; nCtx.shadowColor="#000";
            nCtx.fillStyle = '#ffffff'; nCtx.fillText(node.name, 0, 0); nCtx.restore();
            
            const nameSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(nCvs), transparent: true, depthWrite: false }));
            nameSprite.scale.set(w * 1.5, w * 0.4, 1); 
            nameSprite.position.set(0, 0, 0); 
            grp.add(nameSprite);

            if (node.kind === 'directory') {
                const isEmpty = node.children.length === 0;
                const depth = isEmpty ? 20 : w; 
                const geo = new THREE.BoxGeometry(w, w, depth);
                const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({ color: CONFIG.colMain }));
                grp.add(edges); grp.userData.rotMesh = edges;
                const hit = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ visible: false }));
                grp.add(hit); hit.userData.node = node; state.visibleNodes.push(hit);
                nameSprite.position.set(0, -w/2 - 80, 0);
            } else {
                const bgGeo = new THREE.PlaneGeometry(w, h);
                
                let mat;
                if(node.thumbTex) {
                    mat = new THREE.MeshBasicMaterial({ map: node.thumbTex, side: THREE.DoubleSide, color: 0xffffff });
                } else {
                    const pCvs = document.createElement('canvas'); pCvs.width=256; pCvs.height=320;
                    const pCtx = pCvs.getContext('2d');
                    pCtx.fillStyle='rgba(0, 20, 40, 0.95)'; pCtx.fillRect(0,0,256,320);
                    pCtx.fillStyle='#008800'; pCtx.font='14px monospace';
                    pCtx.fillText("> FILE", 10, 30); 
                    const defaultTex = new THREE.CanvasTexture(pCvs);
                    mat = new THREE.MeshBasicMaterial({ map: defaultTex, side: THREE.DoubleSide, color: 0x999999 });
                }

                const thumb = new THREE.Mesh(bgGeo, mat);
                thumb.name = 'thumbMesh'; 
                thumb.position.set(0, h/2 + 20, 0);
                thumb.rotation.z = Math.PI; 
                grp.add(thumb);
                
                const ext = node.name.split('.').pop().toUpperCase().slice(0,4);
                const eCvs = document.createElement('canvas'); eCvs.width=128; eCvs.height=64;
                const eCtx = eCvs.getContext('2d');
                eCtx.font = "bold 40px 'Segoe UI'";
                eCtx.textAlign='center'; eCtx.textBaseline='middle';
                eCtx.strokeStyle='#000'; eCtx.lineWidth=4; eCtx.strokeText(ext, 64,32);
                eCtx.fillStyle='#999999'; eCtx.fillText(ext, 64,32);
                const eTex = new THREE.CanvasTexture(eCvs);
                const extSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: eTex, transparent:true }));
                extSprite.scale.set(w/2, w/4, 1);
                extSprite.position.set(0, h/2 + 20, 10);
                extSprite.name = 'extSprite';
                grp.add(extSprite);

                const hit = new THREE.Mesh(new THREE.BoxGeometry(w,h,20), new THREE.MeshBasicMaterial({ visible: false }));
                hit.position.set(0, h/2 + 20, 0);
                grp.add(hit); hit.userData.node = node; state.visibleNodes.push(hit);
            }
            world.add(grp); node.obj = grp;
        }

        function createConnection(pNode, cNode, isBold) {
            const path = new THREE.LineCurve3(pNode.obj.position, cNode.obj.position);
            const tubGeo = new THREE.TubeGeometry(path, 1, isBold?6:2, 8, false); 
            const tubMat = new THREE.MeshBasicMaterial({ color: CONFIG.colLine, transparent: true, opacity: isBold?0.8:0.1, blending: THREE.AdditiveBlending });
            const mesh = new THREE.Mesh(tubGeo, tubMat);
            world.add(mesh); cNode.visualLine = mesh; cNode.isTube = true;
        }

        function rotateToTarget() {
            if (!state.activeNode.children.length) return;
            const target = state.activeNode.children[state.targetChildIdx];
            const dest = (Math.PI / 2) - target.angle;
            let delta = dest - state.currentRotation;
            while (delta <= -Math.PI) delta += Math.PI*2; while (delta > Math.PI) delta -= Math.PI*2;
            new TWEEN.Tween({r:state.currentRotation}).to({r:state.currentRotation+delta}, 500).easing(TWEEN.Easing.Cubic.Out).onUpdate(o=>state.currentRotation=o.r).start();
            updateConnectionStyles();
            updateInfoTexture(target);
        }

        function updateConnectionStyles() {
            const children = state.activeNode.children;
            const target = children[state.targetChildIdx];
            children.forEach(c => {
                if(c.visualLine) { world.remove(c.visualLine); if(c.visualLine.geometry) c.visualLine.geometry.dispose(); }
                if(c.obj && state.activeNode.obj) createConnection(state.activeNode, c, (c === target));
            });
        }

        async function diveInto(node) {
            if (node.kind === 'file') { openViewer(node.handle); return; }
            if (node.children.length === 0) {
                const temp = await scanNode(node.handle, node.parent, node.depth);
                node.children = temp.children;
            }
            triggerTransitionEffect();
            new TWEEN.Tween(state).to({ focusPointZ: node.z }, 800).easing(TWEEN.Easing.Quartic.InOut).onComplete(() => {
                state.isMoving = false; state.activeNode = node; state.targetChildIdx = 0; rebuildWorld(); updatePath();
            }).start();
        }

        function moveUp() {
            if (!state.activeNode.parent) return;
            const parent = state.activeNode.parent;
            const idx = parent.children.findIndex(c => c.name === state.activeNode.name);
            triggerTransitionEffect();
            new TWEEN.Tween(state).to({ focusPointZ: parent.z }, 800).easing(TWEEN.Easing.Quartic.InOut).onComplete(() => {
                state.isMoving = false; state.activeNode = parent; state.targetChildIdx = idx >= 0 ? idx : 0; rebuildWorld(); updatePath();
            }).start();
        }

        function triggerTransitionEffect() {
            state.isMoving = true;
            new TWEEN.Tween(streamGroup.children[0].material).to({ opacity: 0.8 }, 400).yoyo(true).repeat(1).start();
        }

        function updateCamera() {
            const currentZ = state.focusPointZ;
            const lookAtZ = currentZ - CONFIG.camLookAhead;
            const children = state.activeNode && state.activeNode.children ? state.activeNode.children : [];
            const r = children.length > 0 ? calculateRadius(children.length) : CONFIG.baseRadius;
            
            let camY = r + CONFIG.camHeight;
            let camZ = currentZ + CONFIG.camDist;
            if (state.isMoving) { camY += 50; camZ -= 100; }
            streamGroup.position.z = currentZ;
            
            if (Math.abs(state.targetFocusZ) > 0.1) {
                state.focusPointZ += state.targetFocusZ * 0.1;
                state.targetFocusZ *= 0.9;
            }

            camera.position.set(0, camY, camZ);
            camera.lookAt(0, r, lookAtZ); 
        }

        function updateVisuals() {
            world.rotation.z = state.currentRotation;
            
            // Stream Lines Logic
            const targetSpeed = state.isMoving ? CONFIG.flowSpeedFast : CONFIG.flowSpeedNormal;
            state.currentFlowSpeed += (targetSpeed - state.currentFlowSpeed) * 0.1;
            streamGroup.position.z -= state.currentFlowSpeed;
            if(streamGroup.position.z < -20000) streamGroup.position.z += 20000;

            if (state.centerMesh) {
                state.centerMesh.position.z = state.activeNode ? state.activeNode.z : 0;
                centerCursor.position.z = state.centerMesh.position.z;
                state.centerMesh.rotation.z += 0.01;
            }

            let targetNode = null;
            if (state.activeNode && state.activeNode.children.length > 0) {
                targetNode = state.activeNode.children[state.targetChildIdx];
            }

            // --- Overlays Update ---
            if (targetNode && targetNode.obj) {
                const p = new THREE.Vector3(); 
                targetNode.obj.getWorldPosition(p);
                
                const dirToCam = new THREE.Vector3().subVectors(camera.position, p).normalize();
                const popDist = 150; 
                const magPos = p.clone().add(dirToCam.multiplyScalar(popDist));
                
                // Show MagMesh ONLY for Media (Image/Video)
                const isMedia = targetNode.kind === 'file' && targetNode.name.match(/\.(jpg|png|webp|gif|bmp|mp4|webm)$/i);
                
                if (isMedia) {
                    magMesh.visible = true;
                    magMesh.position.copy(magPos);
                    magMesh.lookAt(camera.position);
                    
                    if (targetNode.thumbTex) {
                         magMesh.material.map = targetNode.thumbTex;
                         magMesh.material.color.setHex(0xffffff); 
                         magMesh.material.opacity = 1;
                         magMesh.material.needsUpdate = true; 
                    } else {
                         magMesh.material.map = null;
                         magMesh.material.color.setHex(0x333333); 
                         magMesh.material.opacity = 0.5;
                         magMesh.material.needsUpdate = true;
                    }
                } else {
                    magMesh.visible = false;
                }
                
                // Info Mesh (Visible only for Files)
                if (targetNode.kind === 'file') {
                    infoMesh.visible = true;
                    lineMesh.visible = true;
                    
                    const basePos = magMesh.visible ? magPos : p;
                    const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
                    const infoPos = basePos.clone().add(right.multiplyScalar(450));
                    infoMesh.position.copy(infoPos);
                    infoMesh.lookAt(camera.position);

                    const down = new THREE.Vector3(0, -1, 0).applyQuaternion(camera.quaternion);
                    const infoBottom = infoPos.clone().add(down.multiplyScalar(100)); 
                    const pts = [infoBottom, p];
                    lineMesh.geometry.setFromPoints(pts);
                } else {
                    infoMesh.visible = false;
                    lineMesh.visible = false;
                }
                
                // Yellow Cursor (Reticle) - Visible for ALL targets
                reticleGroup.visible = true;
                reticleGroup.position.copy(p);
                // Billboard to camera
                reticleGroup.lookAt(camera.position);
                
                // Rotate Layers
                reticleGroup.children.forEach(child => {
                    if(child.userData && child.userData.speed) {
                        child.rotation.z += child.userData.speed;
                    }
                });

            } else {
                magMesh.visible = false;
                infoMesh.visible = false;
                lineMesh.visible = false;
                reticleGroup.visible = false;
            }

            state.visibleNodes.forEach(obj => {
                const node = obj.userData.node;
                let tScale = 1.0; let tLift = 0.0;
                
                const isTarget = (node === targetNode);
                const isActive = (node === state.activeNode);

                if (isActive) { tScale = 2.5; tLift = 120; }
                else if (isTarget) { 
                    if (node.kind === 'directory') { tScale = 2.0; tLift = 80; }
                    else { tScale = 1.0; tLift = 0; } 
                } 
                
                node.currentScale += (tScale - node.currentScale) * 0.1;
                node.currentLift += (tLift - node.currentLift) * 0.1;
                node.obj.scale.set(node.currentScale, node.currentScale, node.currentScale);
                
                const childrenCount = (state.activeNode && state.activeNode.children) ? state.activeNode.children.length : 1;
                const baseR = calculateRadius(childrenCount);
                
                const r = baseR + node.currentLift;
                node.obj.position.x = Math.cos(node.angle) * r;
                node.obj.position.y = Math.sin(node.angle) * r;
                node.obj.position.z = node.z;
                
                node.obj.rotation.set(0,0,0);
                node.obj.rotation.z = node.angle + Math.PI/2;

                if (node.kind === 'file') {
                    const extSprite = node.obj.getObjectByName('extSprite');
                    if(extSprite) extSprite.visible = !isTarget;
                } else {
                    if(node.obj.userData.rotMesh) {
                        node.obj.userData.rotMesh.rotation.x += 0.01;
                        node.obj.userData.rotMesh.rotation.y += 0.02;
                    }
                }
                
                if (node.visualLine && node.parent && node.parent.obj) {
                    const pPos = node.parent.obj.position;
                    const cPos = node.obj.position;
                    if(node.isTube) {
                        node.visualLine.geometry.dispose();
                        const path = new THREE.LineCurve3(pPos, cPos);
                        node.visualLine.geometry = new THREE.TubeGeometry(path, 1, 6, 8, false);
                    } else {
                        const pos = node.visualLine.geometry.attributes.position.array;
                        pos[0]=pPos.x; pos[1]=pPos.y; pos[2]=pPos.z;
                        pos[3]=cPos.x; pos[4]=cPos.y; pos[5]=cPos.z;
                        node.visualLine.geometry.attributes.position.needsUpdate = true;
                    }
                }
            });
        }

        function updateParticles() {
            if (state.activeNode && state.activeNode.children.length > 0) {
                const target = state.activeNode.children[state.targetChildIdx];
                if (target && target.obj && state.activeNode.obj) {
                    const s = new THREE.Vector3(); state.activeNode.obj.getWorldPosition(s);
                    const e = new THREE.Vector3(); target.obj.getWorldPosition(e);
                    if (Math.random() < 0.4) spawnArrow(s, e);
                }
            }
            for (let i = particles.length - 1; i >= 0; i--) {
                const p = particles[i]; p.progress += p.speed;
                if (p.progress >= 1.0) { scene.remove(p.mesh); particles.splice(i, 1); } 
                else {
                    p.mesh.position.lerpVectors(p.start, p.end, p.progress);
                    if (p.progress > 0.8) p.mesh.material.opacity = (1.0 - p.progress) * 5;
                }
            }
        }

        function onKey(e) {
            if(!state.activeNode) return;
            switch(e.key) {
                case 'ArrowLeft': e.preventDefault(); changeTarget(1); break;
                case 'ArrowRight': e.preventDefault(); changeTarget(-1); break;
                case 'ArrowUp': case 'Enter': e.preventDefault(); if(state.activeNode.children[state.targetChildIdx]) diveInto(state.activeNode.children[state.targetChildIdx]); break;
                case 'ArrowDown': e.preventDefault(); moveUp(); break;
                case 'Home': e.preventDefault(); if(state.rootNode) { state.activeNode=state.rootNode; state.targetChildIdx=0; state.focusPointZ=state.rootNode.z; rebuildWorld(); updatePath(); } break;
            }
        }
        function changeTarget(dir) {
            const len = state.activeNode.children.length; if(len === 0) return;
            state.targetChildIdx = (state.targetChildIdx + dir + len) % len;
            rotateToTarget();
        }
        
        function onMouseMove(e) {
            state.mouse.x = (e.clientX/window.innerWidth)*2-1; state.mouse.y = -(e.clientY/window.innerHeight)*2+1;
            
            if(state.isCamDragging) {
                const dx = e.clientX - state.lastMouse.x; const dy = e.clientY - state.lastMouse.y;
                state.targetFocusZ += dy * 8.0; 
                state.currentRotation += dx * 0.005;
                state.targetRotation = state.currentRotation;
                state.lastMouse.set(e.clientX, e.clientY); return;
            }
            
            if(state.dragging) {
                const ghost = document.getElementById('drag-ghost'); ghost.style.display = 'block';
                ghost.style.left = (e.clientX + 10) + 'px'; ghost.style.top = (e.clientY + 10) + 'px'; return;
            }
            state.raycaster.setFromCamera(state.mouse, camera);
            const hits = state.raycaster.intersectObjects(state.visibleNodes);
            document.body.style.cursor = hits.length > 0 ? 'pointer' : 'default';
        }

        function onMouseDown(e) {
            if(e.button !== 0) return;
            if(e.target.closest('#stock') || e.target.closest('#stock-tabs') || e.target.closest('.ctx-menu')) return;
            state.raycaster.setFromCamera(state.mouse, camera);
            const hits = state.raycaster.intersectObjects(state.visibleNodes);
            if(hits.length > 0) {
                const node = hits[0].object.userData.node;
                const idx = state.activeNode.children.indexOf(node);
                if(idx >= 0) { state.targetChildIdx = idx; rotateToTarget(); state.dragging = { node: node, startX: e.clientX, startY: e.clientY }; }
                else if (node === state.activeNode.parent) moveUp();
            } else { 
                state.isCamDragging = true; 
                state.lastMouse.set(e.clientX, e.clientY); 
            }
        }

        async function onMouseUp(e) {
            state.isCamDragging = false;
            if(state.dragging) {
                const ghost = document.getElementById('drag-ghost'); ghost.style.display = 'none';
                if(e.clientY > window.innerHeight - 160) await moveToStock(state.dragging.node.handle);
                state.dragging = null;
            }
        }

        function onMouseDoubleClick(e) {
            state.raycaster.setFromCamera(state.mouse, camera);
            const hits = state.raycaster.intersectObjects(state.visibleNodes);
            if(hits.length > 0) diveInto(hits[0].object.userData.node);
        }

        function onContextMenu(e) {
            e.preventDefault();
            if(e.target.closest('.tab-btn')) { state.tabCtxIdx = parseInt(e.target.closest('.tab-btn').dataset.idx); const m = document.getElementById('ctx-tab'); m.style.display='block'; m.style.left=e.clientX+'px'; m.style.top=e.clientY+'px'; return; }
            state.raycaster.setFromCamera(state.mouse, camera);
            const hits = state.raycaster.intersectObjects(state.visibleNodes);
            if(hits.length > 0) {
                state.ctxTarget = hits[0].object.userData.node;
                const idx = state.activeNode.children.indexOf(state.ctxTarget);
                if(idx>=0) { state.targetChildIdx=idx; rotateToTarget(); }
                const m = document.getElementById('ctx-menu'); m.style.display='block'; m.style.left=e.clientX+'px'; m.style.top=e.clientY+'px';
                document.getElementById('cm-stock').style.display = state.ctxTarget.kind==='directory'?'block':'none';
            } else document.querySelectorAll('.ctx-menu').forEach(m => m.style.display='none');
        }

        async function moveToStock(handle) {
            if(state.activeStockIdx < 0) { alert("No active Stock Tab!"); return; }
            const dest = state.stockList[state.activeStockIdx].handle;
            if(handle.move) { try { await handle.move(dest); const t = await scanNode(state.activeNode.handle, state.activeNode.parent, state.activeNode.depth); state.activeNode.children = t.children; rebuildWorld(); renderStock(); } catch(e) { alert("Move failed: " + e.message); } }
        }

        async function moveFromStock(stockItemIdx) {
            if(state.activeStockIdx < 0) return;
            const currentStock = state.stockList[state.activeStockIdx];
            let entries = []; for await (const e of currentStock.handle.values()) entries.push(e);
            if(entries[stockItemIdx] && entries[stockItemIdx].move) { try { await entries[stockItemIdx].move(state.activeNode.handle); const t = await scanNode(state.activeNode.handle, state.activeNode.parent, state.activeNode.depth); state.activeNode.children = t.children; rebuildWorld(); renderStock(); } catch(e) { alert("Retrieve failed: " + e.message); } }
        }

        function addToStock(node){
            if(state.stockList.find(s=>s.name===node.name)) return; 
            state.stockList.push({name:node.name, handle:node.handle}); state.activeStockIdx = state.stockList.length-1; renderStock(); document.getElementById('stock').classList.remove('closed');
        }

        async function renderStock(){
            const t=document.getElementById('stock-tabs'); const d=document.getElementById('stock'); t.innerHTML=''; d.innerHTML=''; 
            if(state.stockList.length===0){ d.classList.add('closed'); return; } 
            state.stockList.forEach((s,i)=>{ const b=document.createElement('div'); b.className='tab-btn'+(i===state.activeStockIdx?' active':''); b.innerText=s.name; b.dataset.idx=i; b.onclick=()=>{ if(state.activeStockIdx === i) { if(d.classList.contains('closed')) d.classList.remove('closed'); else d.classList.add('closed'); } else { state.activeStockIdx=i; renderStock(); document.getElementById('stock').classList.remove('closed'); } }; t.appendChild(b); }); 
            if(state.activeStockIdx >= 0) { const cur=state.stockList[state.activeStockIdx]; try{ let idx = 0; for await(const e of cur.handle.values()){ const el=document.createElement('div'); el.className='stock-item'; el.draggable = true; const myIdx = idx; el.ondragstart = ev => { ev.dataTransfer.setData('stockIndex', myIdx); }; let iconHtml = `<div class="stock-thumb" style="font-size:30px; color:#555;">${e.kind==='directory'?'📁':'📄'}</div>`; if(e.kind === 'file' && e.name.match(/\.(jpg|png|webp|gif)$/i)) { e.getFile().then(f=>{ const u=URL.createObjectURL(f); el.querySelector('.stock-thumb').innerHTML=`<img src="${u}">`; }); } el.innerHTML=`${iconHtml}<div class="stock-name">${e.name}</div>`; d.appendChild(el); idx++; } } catch(e){ d.innerHTML='ERR'; } }
        }

        function bindMenu() {
            const hide=()=>document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
            document.getElementById('cm-open').onclick=()=>{if(state.ctxTarget)diveInto(state.ctxTarget);hide();};
            document.getElementById('cm-copy').onclick=()=>{if(state.ctxTarget)state.clipboard=state.ctxTarget.handle;hide();};
            document.getElementById('cm-paste').onclick=async()=>{if(state.clipboard&&state.activeNode){try{if(state.clipboard.kind==='file'){const f=await state.clipboard.getFile();const h=await state.activeNode.handle.getFileHandle(state.clipboard.name,{create:true});const w=await h.createWritable();await w.write(f);await w.close(); const t=await scanNode(state.activeNode.handle,state.activeNode.parent,state.activeNode.depth); state.activeNode.children=t.children; rebuildWorld();}}catch(e){alert(e);}}hide();};
            document.getElementById('cm-rename').onclick=async()=>{if(state.ctxTarget&&state.ctxTarget.handle.move){const n=await showDialog("RENAME",state.ctxTarget.name);if(n){await state.ctxTarget.handle.move(n);state.ctxTarget.name=n;rebuildWorld();}}hide();};
            document.getElementById('cm-new').onclick=async()=>{const n=await showDialog("NEW FOLDER","New Folder");if(n){await state.activeNode.handle.getDirectoryHandle(n,{create:true});const t=await scanNode(state.activeNode.handle,state.activeNode.parent,state.activeNode.depth);state.activeNode.children=t.children;rebuildWorld();}hide();};
            document.getElementById('cm-del').onclick=async()=>{if(state.ctxTarget&&confirm("Delete?")){await state.activeNode.handle.removeEntry(state.ctxTarget.name,{recursive:true});const t=await scanNode(state.activeNode.handle,state.activeNode.parent,state.activeNode.depth);state.activeNode.children=t.children;rebuildWorld();}hide();};
            document.getElementById('cm-stock').onclick=()=>{if(state.ctxTarget.kind==='directory')addToStock(state.ctxTarget);hide();};
            document.getElementById('cm-tab-hide').onclick = () => { document.getElementById('stock').classList.add('closed'); hide(); };
            document.getElementById('cm-tab-cancel').onclick = () => { if(state.tabCtxIdx >= 0) { state.stockList.splice(state.tabCtxIdx, 1); if(state.activeStockIdx >= state.stockList.length) state.activeStockIdx = state.stockList.length - 1; renderStock(); } hide(); };
        }

        let dRes=null; function showDialog(t,v){return new Promise(r=>{document.getElementById('dialog-overlay').style.display='flex';document.getElementById('dialog-title').innerText=t;document.getElementById('dialog-input').value=v;document.getElementById('dialog-input').focus();dRes=r;});}
        function bindDialog(){document.getElementById('dialog-ok').onclick=()=>{document.getElementById('dialog-overlay').style.display='none';if(dRes)dRes(document.getElementById('dialog-input').value);};document.getElementById('dialog-cancel').onclick=()=>{document.getElementById('dialog-overlay').style.display='none';if(dRes)dRes(null);};}
        
        async function openViewer(h){
            const v=document.getElementById('viewer-overlay'),b=document.getElementById('viewer-body'); 
            v.style.display='flex'; v.classList.remove('closing'); setTimeout(()=>v.classList.add('active'),10); 
            document.getElementById('viewer-title').innerText=h.name; b.innerHTML='LOADING...'; 
            try{
                const f=await h.getFile(),u=URL.createObjectURL(f); b.innerHTML=''; 
                if(f.type.startsWith('image'))b.innerHTML=`<img src="${u}">`; 
                else if(f.type.startsWith('video'))b.innerHTML=`<video src="${u}" controls autoplay></video>`; 
                else if(f.type.startsWith('text')||h.name.match(/\.(txt|js|json|html|css|md)$/)){
                    const t=await f.text(); const ta=document.createElement('textarea'); 
                    ta.className='editor'; ta.id='editor-area'; ta.value=t; b.appendChild(ta); 
                    // No save for now as simplified viewer
                } else b.innerText="BINARY FILE"; 
            }catch(e){console.error(e); b.innerText="ERROR: " + e.message;}
        }
        function closeViewer(){const v=document.getElementById('viewer-overlay'); v.classList.remove('active'); v.classList.add('closing'); setTimeout(()=>v.style.display='none',500);}
        function bindViewer(){document.getElementById('viewer-close').onclick=closeViewer;}
        
        function showLoad(b){document.getElementById('loading').style.display=b?'flex':'none';}
        function updatePath(){let p=state.activeNode,s=p.name;while(p.parent){p=p.parent;s=p.name+' / '+s;}document.getElementById('path-display').innerText=s.toUpperCase();}
        function onResize(){camera.aspect=window.innerWidth/window.innerHeight;camera.updateProjectionMatrix();renderer.setSize(window.innerWidth,window.innerHeight);composer.setSize(window.innerWidth,window.innerHeight);}

        function animate() {
            requestAnimationFrame(animate); TWEEN.update();
            updateCamera(); updateVisuals(); updateParticles();
            composer.render();
        }

        init();
    </script>
</body>
</html>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
円錐形のフォルダビューワ「Folder Viewer 3D」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word 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