feat: canvas scroll preview, giữ state khi toggle, restore session folder/files

This commit is contained in:
2026-08-02 17:47:44 +07:00
parent b65188fcc8
commit 249e2afea2
4 changed files with 194 additions and 23 deletions
+177 -17
View File
@@ -9104,6 +9104,138 @@ const MediaExplorerPanel = ({ height }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Session persistence: keep loaded folder/files/tree across panel toggles & reloads
const SESSION_KEY = 'studio_media_explorer_session_v1';
const saveClientRootHandle = () => {
if (!clientRoot || !window.indexedDB) return;
try {
const req = indexedDB.open('sonicforge_media_explorer', 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('root_handle')) db.createObjectStore('root_handle');
};
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction('root_handle', 'readwrite');
tx.objectStore('root_handle').put(clientRoot, 'client_root');
};
} catch (e) {}
};
const loadClientRootHandle = () => {
return new Promise((resolve) => {
if (!window.indexedDB) { resolve(null); return; }
try {
const req = indexedDB.open('sonicforge_media_explorer', 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('root_handle')) db.createObjectStore('root_handle');
};
req.onsuccess = () => {
const db = req.result;
try {
const tx = db.transaction('root_handle', 'readonly');
const g = tx.objectStore('root_handle').get('client_root');
g.onsuccess = () => resolve(g.result || null);
g.onerror = () => resolve(null);
} catch (e2) { resolve(null); }
};
req.onerror = () => resolve(null);
} catch (e) { resolve(null); }
});
};
const saveSession = React.useCallback(() => {
try {
const stripHandle = (o) => {
if (Array.isArray(o)) return o.map(stripHandle);
if (o && typeof o === 'object') {
const out = {};
for (const k of Object.keys(o)) {
if (k === 'handle') continue;
out[k] = stripHandle(o[k]);
}
return out;
}
return o;
};
const treeSnapshot = {};
Object.keys(computerTree).forEach(path => {
const node = computerTree[path];
treeSnapshot[path] = {
dirs: stripHandle(node ? node.dirs : []),
expanded: !!(node && node.expanded)
};
});
const snap = {
folder,
computerMode,
computerPath,
clientRootName: clientRoot ? clientRoot.name : null,
computerRoots: stripHandle(computerRoots || []),
computerTree: treeSnapshot,
computerFiles: stripHandle(computerFiles || []),
selected: selected ? stripHandle({ name: selected.name, path: selected.path, kind: selected.kind, is_dir: selected.is_dir, size_mb: selected.size_mb }) : null,
savedAt: Date.now()
};
localStorage.setItem(SESSION_KEY, JSON.stringify(snap));
} catch (e) {}
}, [folder, computerMode, computerPath, clientRoot, computerRoots, computerTree, computerFiles, selected]);
React.useEffect(() => {
saveSession();
if (computerMode === 'client' && clientRoot && clientRoot.kind === 'directory') saveClientRootHandle();
}, [saveSession, computerMode, clientRoot]);
const restoreSession = React.useCallback(() => {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return;
const snap = JSON.parse(raw);
if (snap.folder) setFolder(snap.folder);
if (snap.computerMode) setComputerMode(snap.computerMode);
if (snap.clientRootName) setClientRoot({ name: snap.clientRootName });
if (snap.computerRoots && snap.computerRoots.length) setComputerRoots(snap.computerRoots);
if (snap.computerPath) setComputerPath(snap.computerPath);
if (snap.computerFiles) setComputerFiles(snap.computerFiles);
if (snap.selected) setSelected(snap.selected);
if (snap.computerTree) {
const tree = {};
Object.keys(snap.computerTree).forEach(path => {
tree[path] = {
dirs: snap.computerTree[path].dirs || [],
expanded: snap.computerTree[path].expanded
};
});
setComputerTree(tree);
}
} catch (e) {}
}, []);
React.useEffect(() => {
(async () => {
await restoreSession();
// Restore the FileSystemDirectoryHandle so client-mode browsing still works
const savedHandle = await loadClientRootHandle();
if (savedHandle && savedHandle.kind === 'directory') {
setClientRoot(savedHandle);
const restoredPath = (() => { try { return JSON.parse(localStorage.getItem(SESSION_KEY) || '{}').computerPath; } catch (e) { return null; } })();
if (restoredPath) {
setComputerRoots([{ name: savedHandle.name, path: 'root', is_dir: true, handle: savedHandle }]);
// Re-browse current dir to re-attach real handles (metadata-only tree from localStorage)
const storedTree = (() => { try { return JSON.parse(localStorage.getItem(SESSION_KEY) || '{}').computerTree; } catch (e) { return null; } })();
if (storedTree) {
const rebuilt = {};
Object.keys(storedTree).forEach(p => { rebuilt[p] = { dirs: storedTree[p].dirs || [], expanded: storedTree[p].expanded }; });
setComputerTree(rebuilt);
}
setComputerMode('client');
// Rebuild handles for the current folder + ancestors by walking from root
browseComputerDir({ name: savedHandle.name, path: 'root', is_dir: true, handle: savedHandle });
}
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isMidiFile = f => f && (f.kind === 'midi' || /\.(mid|midi)$/i.test(f.name || f.original_name || '')); const isMidiFile = f => f && (f.kind === 'midi' || /\.(mid|midi)$/i.test(f.name || f.original_name || ''));
const fileDuration = f => { const fileDuration = f => {
if (!f) return 0; if (!f) return 0;
@@ -9496,10 +9628,20 @@ const MediaExplorerPanel = ({ height }) => {
ctx.strokeStyle = '#333'; ctx.strokeStyle = '#333';
for (let y = 0; y < h - 14; y += 10) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } for (let y = 0; y < h - 14; y += 10) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
ctx.strokeStyle = '#444'; ctx.strokeStyle = '#444';
const totalBeats = curMidiNotes && curMidiNotes.length ? Math.max(curMidiTotal * (curTempo || 120) / 60, 4) : (f.lengthQn || 16); // Content scrolls when wider than frame: playhead stays at frame center
const bpmV = curTempo || 120;
const pxPerBeat = 42;
const totalBeats = curMidiNotes && curMidiNotes.length ? Math.max(curMidiTotal * bpmV / 60, 4) : (f.lengthQn || 16);
const beats = totalBeats; const beats = totalBeats;
const contentW = Math.max(w, beats * pxPerBeat);
const pxPerSec = pxPerBeat * bpmV / 60;
let offset = 0;
if (playing && contentW > w && dur > 0) {
offset = Math.max(0, Math.min(contentW - w, t * pxPerSec - w / 2));
}
for (let b = 0; b <= beats; b += 4) { for (let b = 0; b <= beats; b += 4) {
const x = (b / beats) * w; const x = b * pxPerBeat - offset;
if (x < -10 || x > w + 10) continue;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h - 14); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h - 14); ctx.stroke();
} }
ctx.fillStyle = '#9ca3af'; ctx.fillStyle = '#9ca3af';
@@ -9508,29 +9650,48 @@ const MediaExplorerPanel = ({ height }) => {
const pitchMin = 48, pitchMax = 84; const pitchMin = 48, pitchMax = 84;
const pitchRange = Math.max(1, pitchMax - pitchMin); const pitchRange = Math.max(1, pitchMax - pitchMin);
curMidiNotes.forEach(n => { curMidiNotes.forEach(n => {
const x = (n.start_beat / beats) * w; const x = (n.start_beat * pxPerBeat) - offset;
const nw = Math.max(3, (n.duration_beats / beats) * w); if (x < -30 || x > w + 30) return;
const nw = Math.max(3, (n.duration_beats || 1) * pxPerBeat);
const y = h - 14 - 8 - (((Math.min(pitchMax, Math.max(pitchMin, n.pitch)) - pitchMin) / pitchRange) * (h - 30)); const y = h - 14 - 8 - (((Math.min(pitchMax, Math.max(pitchMin, n.pitch)) - pitchMin) / pitchRange) * (h - 30));
ctx.fillRect(x, y, nw, 5); ctx.fillRect(x, y, nw, 5);
}); });
} else { } else {
const events = f.events || 76; const events = f.events || 76;
for (let i = 0; i < events; i++) { for (let i = 0; i < events; i++) {
const nx = ((i * 17 + 76) % 95) / 100 * (w - 20); const nx = ((i * 17 + 76) % 95) / 100 * contentW - offset;
const ny = ((i * 13 + 76) % (h - 30)) + 4; const ny = ((i * 13 + 76) % (h - 30)) + 4;
ctx.fillRect(nx, ny, Math.max(8, (i % 5 + 1) * 10), 3); ctx.fillRect(nx, ny, Math.max(8, (i % 5 + 1) * 10), 3);
} }
} }
// playhead (drawn over MIDI): moves to frame center, then stops there & content scrolls
const midiPlayheadX = contentW > w ? Math.min(w / 2, t * pxPerSec) : Math.min(w, t * pxPerSec);
if (playing && dur > 0) {
ctx.fillStyle = '#ef4444';
ctx.fillRect(midiPlayheadX - 1, 0, 2, h - 14);
}
} else { } else {
const pk = curPeaks && curPeaks.length > 0 ? curPeaks : null; const pk = curPeaks && curPeaks.length > 0 ? curPeaks : null;
if (pk) { if (pk) {
const pxPerSec = 50;
const contentW = Math.max(w, dur * pxPerSec);
let offset = 0;
if (playing && contentW > w && dur > 0) {
offset = Math.max(0, Math.min(contentW - w, t * pxPerSec - w / 2));
}
const mid = h / 2; const mid = h / 2;
ctx.fillStyle = '#22c55e'; ctx.fillStyle = '#22c55e';
for (let i = 0; i < pk.length; i++) { for (let i = 0; i < pk.length; i++) {
const x = (i / pk.length) * w; const x = (i / pk.length) * contentW - offset;
if (x < -3 || x > w + 3) continue;
const ph = Math.max(2, pk[i] * (h / 2 - 4)); const ph = Math.max(2, pk[i] * (h / 2 - 4));
ctx.fillRect(x, mid - ph, Math.max(1, w / pk.length), ph * 2); ctx.fillRect(x, mid - ph, Math.max(1, w / pk.length), ph * 2);
} }
const audioPlayheadX = contentW > w ? Math.min(w / 2, t * pxPerSec) : Math.min(w, t * pxPerSec);
if (playing && dur > 0) {
ctx.fillStyle = '#ef4444';
ctx.fillRect(audioPlayheadX - 1, 0, 2, h - 14);
}
} else { } else {
ctx.fillStyle = '#666'; ctx.font = '11px monospace'; ctx.fillStyle = '#666'; ctx.font = '11px monospace';
ctx.fillText('Waveform unavailable', 10, h / 2); ctx.fillText('Waveform unavailable', 10, h / 2);
@@ -9539,17 +9700,16 @@ const MediaExplorerPanel = ({ height }) => {
// ruler // ruler
ctx.fillStyle = '#111'; ctx.fillRect(0, h - 14, w, 14); ctx.fillStyle = '#111'; ctx.fillRect(0, h - 14, w, 14);
ctx.fillStyle = '#888'; ctx.font = '9px JetBrains Mono, monospace'; ctx.fillStyle = '#888'; ctx.font = '9px JetBrains Mono, monospace';
const rulerBeats = Math.max(4, Math.ceil(dur * (curTempo || 120) / 60) || 16); const rulerBpm = curTempo || 120;
for (let b = 0; b <= rulerBeats; b += 4) { const rulerPxPerBeat = isMidiFile(f) ? 42 : (50 * 60 / rulerBpm);
const x = (b / rulerBeats) * w; const rulerContentW = Math.max(w, dur * rulerPxPerBeat * rulerBpm / 60);
const rulerTotalBeats = Math.max(4, Math.ceil(dur * rulerBpm / 60) || 16);
const rulerOffset = (playing && rulerContentW > w && dur > 0) ? Math.max(0, Math.min(rulerContentW - w, t * (rulerPxPerBeat * rulerBpm / 60) - w / 2)) : 0;
for (let b = 0; b <= rulerTotalBeats; b += 4) {
const x = b * rulerPxPerBeat - rulerOffset;
if (x < -20 || x > w + 20) continue;
ctx.fillText(String(Math.floor(b / 4)), x + 2, h - 3); ctx.fillText(String(Math.floor(b / 4)), x + 2, h - 3);
} }
// playhead
if (playing && dur > 0) {
const px = (t / dur) * w;
ctx.fillStyle = '#ef4444';
ctx.fillRect(px, 0, 2, h - 14);
}
}; };
React.useEffect(() => { drawCanvas(currentTime); }, [peaks, audioBuffer, midiNotes, selected, folder, isPlaying]); React.useEffect(() => { drawCanvas(currentTime); }, [peaks, audioBuffer, midiNotes, selected, folder, isPlaying]);
@@ -22095,9 +22255,9 @@ const App = () => {
onUpdateTrack: updateTrackProp, onUpdateTrack: updateTrackProp,
trackVuRefs: trackVuRefs trackVuRefs: trackVuRefs
}); });
}))), showMediaExplorer && /*#__PURE__*/React.createElement("div", { }))), /*#__PURE__*/React.createElement("div", {
className: "flex flex-col border-t border-zinc-700 bg-slate-900 shrink-0 select-none", className: "flex flex-col border-t border-zinc-700 bg-slate-900 shrink-0 select-none",
style: { height: mediaExplorerPanelHeight + 'px' } style: { height: mediaExplorerPanelHeight + 'px', display: showMediaExplorer ? 'flex' : 'none' }
}, /*#__PURE__*/React.createElement("div", { }, /*#__PURE__*/React.createElement("div", {
className: "flex items-center justify-between px-2 py-0.5 bg-zinc-800/80 shrink-0 cursor-ns-resize", className: "flex items-center justify-between px-2 py-0.5 bg-zinc-800/80 shrink-0 cursor-ns-resize",
onMouseDown: e => { onMouseDown: e => {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script> <script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script> <script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script> <script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608021735" defer></script> <script src="/static/js/app.precompiled.js?v=202608021805" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016"> <link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style> <style>
:root { :root {
+5
View File
@@ -1124,3 +1124,8 @@
- **Tóm tắt thay đổi:** (1) Fix canvas hiển thị sai lần đầu khi click file MIDI: `startCanvasClock` rAF tick giữ closure cũ của `drawCanvas` (đọc `selected` cũ = null) và vẽ đè "No file selected" mỗi frame. Chuyển `drawCanvas`/`fileDuration`/clock sang đọc state mới nhất qua refs (`selectedRef`, `peaksRef`, `audioBufferRef`, `midiNotesRef`, `midiTotalRef`, `isPlayingRef`, `isPausedRef`, `currentTimeRef`, `tempoRef`). (2) Thêm **Tempo** control (BPM, +/- input, min 40 max 300) cạnh nút Synth — ảnh hưởng tốc độ preview MIDI (`playMidiPreview` dùng `tempoRef` thay `synthInst.bpm`), ruler canvas, footer status; lưu `localStorage['studio_media_explorer_tempo']`. - **Tóm tắt thay đổi:** (1) Fix canvas hiển thị sai lần đầu khi click file MIDI: `startCanvasClock` rAF tick giữ closure cũ của `drawCanvas` (đọc `selected` cũ = null) và vẽ đè "No file selected" mỗi frame. Chuyển `drawCanvas`/`fileDuration`/clock sang đọc state mới nhất qua refs (`selectedRef`, `peaksRef`, `audioBufferRef`, `midiNotesRef`, `midiTotalRef`, `isPlayingRef`, `isPausedRef`, `currentTimeRef`, `tempoRef`). (2) Thêm **Tempo** control (BPM, +/- input, min 40 max 300) cạnh nút Synth — ảnh hưởng tốc độ preview MIDI (`playMidiPreview` dùng `tempoRef` thay `synthInst.bpm`), ruler canvas, footer status; lưu `localStorage['studio_media_explorer_tempo']`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html` - **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: click a.mid lần đầu → canvas vẽ ruler (0,1,2..) không còn "No file selected", audio play; Tempo 120 → +1 → localStorage `121`. Hard reload. - **Ghi chú/Test (nếu có):** Smoke jsdom: click a.mid lần đầu → canvas vẽ ruler (0,1,2..) không còn "No file selected", audio play; Tempo 120 → +1 → localStorage `121`. Hard reload.
### [2026-08-02 18:05] Task: Canvas scroll + giữ state folder/files khi toggle + restore session
- **Tóm tắt thay đổi:** (1) `drawCanvas` scroll khi content rộng hơn khung: pxPerBeat 42 / pxPerSec 50, playhead di chuyển tới giữa khung (`min(w/2, t*pxPerSec)`) rồi dừng, content scroll trái với offset `max(0, min(contentW-w, t*pxPerSec - w/2))`; ruler scroll đồng bộ. (2) Panel giờ **luôn mounted** (display:none khi ẩn) thay vì `showMediaExplorer &&` → toggle F6 giữ nguyên folder/files/tree/selection. (3) Session persistence `localStorage['studio_media_explorer_session_v1']` (folder, mode, path, roots, tree, files, selected — bỏ handle) + lưu `FileSystemDirectoryHandle` vào IndexedDB `sonicforge_media_explorer/root_handle` để client mode restore được handle thật sau reload; `restoreSession` + re-browse lại folder đã load → cây tree + file list hiển thị đúng thư mục cũ, tên thư mục loaded luôn hiện trong tree.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: toggle F6 off/on → tree+files+selection giữ nguyên; reload (fresh DOM + IndexedDB stub) → folder MyMusic, files a.mid/b.mid, selection restore. Scroll math: t=10s → offset 350, playhead 150 (center), t=29s clamp 1200. Hard reload.