fix save/load bugs

This commit is contained in:
Skyler Lehmkuhl 2025-11-03 04:38:31 -05:00
parent 1ee86af94d
commit 3b0e5b7ada
5 changed files with 261 additions and 18 deletions

View File

@ -104,7 +104,48 @@ function createPlaceholderPane(paneName) {
* @returns {Object} Layout definition object * @returns {Object} Layout definition object
*/ */
export function serializeLayout(rootElement) { export function serializeLayout(rootElement) {
const layoutNode = serializeLayoutNode(rootElement.firstChild); if (!rootElement.firstChild) {
throw new Error("No layout to serialize");
}
console.log('[serializeLayout] rootElement has', rootElement.children.length, 'children:');
for (let i = 0; i < rootElement.children.length; i++) {
console.log(` [${i}]:`, rootElement.children[i].className);
}
let layoutNode;
// Check if rootElement itself acts as a grid (has 2 children from a split)
if (rootElement.children.length === 2) {
// rootElement is acting as a grid container
// Check if it has grid attributes
const isHorizontal = rootElement.classList.contains("horizontal-grid");
const isVertical = rootElement.classList.contains("vertical-grid");
const percent = parseFloat(rootElement.getAttribute("lb-percent")) || 50;
if (isHorizontal || isVertical) {
console.log('[serializeLayout] rootElement is a grid, serializing both children');
layoutNode = {
type: isHorizontal ? "horizontal-grid" : "vertical-grid",
percent: percent,
children: [
serializeLayoutNode(rootElement.children[0]),
serializeLayoutNode(rootElement.children[1])
]
};
} else {
// No grid classes, but has 2 children - this shouldn't happen but handle it
console.warn('[serializeLayout] rootElement has 2 children but no grid classes, serializing first child only');
layoutNode = serializeLayoutNode(rootElement.firstChild);
}
} else {
// Single child, serialize it directly
console.log('[serializeLayout] Starting from element:', rootElement.firstChild.className);
layoutNode = serializeLayoutNode(rootElement.firstChild);
}
console.log('[serializeLayout] Serialized layout:', JSON.stringify(layoutNode, null, 2));
return { return {
name: "Custom Layout", name: "Custom Layout",
description: "User-created layout", description: "User-created layout",
@ -116,26 +157,39 @@ export function serializeLayout(rootElement) {
* Recursively serializes a layout node * Recursively serializes a layout node
* @private * @private
*/ */
function serializeLayoutNode(element) { function serializeLayoutNode(element, depth = 0) {
const indent = ' '.repeat(depth);
console.log(`${indent}[serializeLayoutNode depth=${depth}] element:`, element.className, 'children:', element.children.length);
if (!element) { if (!element) {
throw new Error("Cannot serialize null element"); throw new Error("Cannot serialize null element");
} }
// Check if this is a pane // Check if this is a pane (has data-pane-name attribute)
if (element.classList.contains("pane") && !element.classList.contains("horizontal-grid") && !element.classList.contains("vertical-grid")) { // This check must come first, as panes may also have grid classes for internal layout
// Extract pane name from the element (stored in data attribute or class) if (element.hasAttribute("data-pane-name")) {
const paneName = element.getAttribute("data-pane-name") || "stage"; // The data-pane-name is kebab-case, but we need to save the camelCase key
// that matches the panes object keys, not the name property
const dataName = element.getAttribute("data-pane-name");
// Convert kebab-case to camelCase (e.g., "timeline-v2" -> "timelineV2")
const camelCaseName = dataName.replace(/-([a-z0-9])/g, (g) => g[1].toUpperCase());
console.log(`${indent} -> Found pane: ${camelCaseName}`);
return { return {
type: "pane", type: "pane",
name: paneName name: camelCaseName
}; };
} }
// Check if this is a grid // Check if this is a grid (split pane structure)
if (element.classList.contains("horizontal-grid") || element.classList.contains("vertical-grid")) { if (element.classList.contains("horizontal-grid") || element.classList.contains("vertical-grid")) {
const isHorizontal = element.classList.contains("horizontal-grid"); const isHorizontal = element.classList.contains("horizontal-grid");
const percent = parseFloat(element.getAttribute("lb-percent")) || 50; const percent = parseFloat(element.getAttribute("lb-percent")) || 50;
console.log(`${indent} -> Found ${isHorizontal ? 'horizontal' : 'vertical'} grid with ${percent}% split`);
if (element.children.length !== 2) { if (element.children.length !== 2) {
throw new Error("Grid must have exactly 2 children"); throw new Error("Grid must have exactly 2 children");
} }
@ -144,15 +198,24 @@ function serializeLayoutNode(element) {
type: isHorizontal ? "horizontal-grid" : "vertical-grid", type: isHorizontal ? "horizontal-grid" : "vertical-grid",
percent: percent, percent: percent,
children: [ children: [
serializeLayoutNode(element.children[0]), serializeLayoutNode(element.children[0], depth + 1),
serializeLayoutNode(element.children[1]) serializeLayoutNode(element.children[1], depth + 1)
] ]
}; };
} }
// Check if this is a panecontainer wrapper - recurse into it
if (element.classList.contains("panecontainer")) {
console.log(`${indent} -> Found panecontainer, recursing into child`);
if (element.children.length === 1) {
return serializeLayoutNode(element.children[0], depth);
}
}
// If element has only one child, recurse into it // If element has only one child, recurse into it
if (element.children.length === 1) { if (element.children.length === 1) {
return serializeLayoutNode(element.children[0]); console.log(`${indent} -> Element has 1 child, recursing`);
return serializeLayoutNode(element.children[0], depth);
} }
throw new Error(`Cannot serialize element: ${element.className}`); throw new Error(`Cannot serialize element: ${element.className}`);

View File

@ -112,7 +112,7 @@ import { actions, initializeActions, updateAutomationName } from "./actions/inde
// Layout system // Layout system
import { defaultLayouts, getLayout, getLayoutNames } from "./layouts.js"; import { defaultLayouts, getLayout, getLayoutNames } from "./layouts.js";
import { buildLayout, loadLayoutByKeyOrName, saveCustomLayout } from "./layoutmanager.js"; import { buildLayout, loadLayoutByKeyOrName, saveCustomLayout, serializeLayout } from "./layoutmanager.js";
const { const {
writeTextFile: writeTextFile, writeTextFile: writeTextFile,
@ -1669,11 +1669,15 @@ async function _save(path) {
} }
} }
// Serialize current layout structure (panes, splits, sizes)
const serializedLayout = serializeLayout(rootPane);
const fileData = { const fileData = {
version: "2.0.0", version: "2.0.0",
width: config.fileWidth, width: config.fileWidth,
height: config.fileHeight, height: config.fileHeight,
fps: config.framerate, fps: config.framerate,
layoutState: serializedLayout, // Save current layout structure
actions: undoStack, actions: undoStack,
json: root.toJSON(), json: root.toJSON(),
// Audio pool at the end for human readability // Audio pool at the end for human readability
@ -2094,6 +2098,37 @@ async function _open(path, returnJson = false) {
context.activeObject.activeLayer = context.activeObject.layers[0]; context.activeObject.activeLayer = context.activeObject.layers[0];
} }
// Restore layout if saved and preference is enabled
console.log('[JS] Layout restoration check:', {
restoreLayoutFromFile: config.restoreLayoutFromFile,
hasLayoutState: !!file.layoutState,
layoutState: file.layoutState
});
if (config.restoreLayoutFromFile && file.layoutState) {
try {
console.log('[JS] Restoring saved layout:', file.layoutState);
// Clear existing layout
while (rootPane.firstChild) {
rootPane.removeChild(rootPane.firstChild);
}
layoutElements.length = 0;
canvases.length = 0;
// Build layout from saved state
buildLayout(rootPane, file.layoutState, panes, createPane, splitPane);
// Update UI after layout change
updateAll();
updateUI();
console.log('[JS] Layout restored successfully');
} catch (error) {
console.error('[JS] Failed to restore layout, using default:', error);
}
} else {
console.log('[JS] Skipping layout restoration');
}
// Restore audio tracks and clips to the Rust backend // Restore audio tracks and clips to the Rust backend
// The fromJSON method only creates JavaScript objects, // The fromJSON method only creates JavaScript objects,
// but doesn't initialize them in the audio engine // but doesn't initialize them in the audio engine
@ -4994,15 +5029,25 @@ async function startup() {
} }
}); });
console.log('[startup] window.openedFiles:', window.openedFiles);
console.log('[startup] config.reopenLastSession:', config.reopenLastSession);
console.log('[startup] config.recentFiles:', config.recentFiles);
// Always update start screen data so it's ready when needed
await updateStartScreen(config);
if (!window.openedFiles?.length) { if (!window.openedFiles?.length) {
if (config.reopenLastSession && config.recentFiles?.length) { if (config.reopenLastSession && config.recentFiles?.length) {
console.log('[startup] Reopening last session:', config.recentFiles[0]);
document.body.style.cursor = "wait" document.body.style.cursor = "wait"
setTimeout(()=>_open(config.recentFiles[0]), 10) setTimeout(()=>_open(config.recentFiles[0]), 10)
} else { } else {
// Show start screen instead of new file dialog console.log('[startup] Showing start screen');
await updateStartScreen(config); // Show start screen
showStartScreen(); showStartScreen();
} }
} else {
console.log('[startup] Files already opened, skipping start screen');
} }
} }
@ -6441,6 +6486,11 @@ async function renderMenu() {
action: actions.selectNone.create, action: actions.selectNone.create,
accelerator: getShortcut("selectNone"), accelerator: getShortcut("selectNone"),
}, },
{
text: "Preferences",
enabled: true,
action: showPreferencesDialog,
},
], ],
}); });
@ -10231,6 +10281,100 @@ function showSavePresetDialog(container) {
}); });
} }
// Show preferences dialog
function showPreferencesDialog() {
const dialog = document.createElement('div');
dialog.className = 'modal-overlay';
dialog.innerHTML = `
<div class="modal-dialog preferences-dialog">
<h3>Preferences</h3>
<form id="preferences-form">
<div class="form-group">
<label>Default BPM</label>
<input type="number" id="pref-bpm" min="20" max="300" value="${config.bpm}" />
</div>
<div class="form-group">
<label>Default Framerate</label>
<input type="number" id="pref-framerate" min="1" max="120" value="${config.framerate}" />
</div>
<div class="form-group">
<label>Default File Width</label>
<input type="number" id="pref-width" min="100" max="10000" value="${config.fileWidth}" />
</div>
<div class="form-group">
<label>Default File Height</label>
<input type="number" id="pref-height" min="100" max="10000" value="${config.fileHeight}" />
</div>
<div class="form-group">
<label>Scroll Speed</label>
<input type="number" id="pref-scroll-speed" min="0.1" max="10" step="0.1" value="${config.scrollSpeed}" />
</div>
<div class="form-group">
<label>
<input type="checkbox" id="pref-reopen-session" ${config.reopenLastSession ? 'checked' : ''} />
Reopen last session on startup
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="pref-restore-layout" ${config.restoreLayoutFromFile ? 'checked' : ''} />
Restore layout when opening files
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="pref-debug" ${config.debug ? 'checked' : ''} />
Enable debug mode
</label>
</div>
<div class="form-actions">
<button type="button" class="btn-cancel">Cancel</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div>
`;
document.body.appendChild(dialog);
// Focus first input
setTimeout(() => dialog.querySelector('#pref-bpm')?.focus(), 100);
// Handle cancel
dialog.querySelector('.btn-cancel').addEventListener('click', () => {
dialog.remove();
});
// Handle save
dialog.querySelector('#preferences-form').addEventListener('submit', async (e) => {
e.preventDefault();
// Update config values
config.bpm = parseInt(dialog.querySelector('#pref-bpm').value);
config.framerate = parseInt(dialog.querySelector('#pref-framerate').value);
config.fileWidth = parseInt(dialog.querySelector('#pref-width').value);
config.fileHeight = parseInt(dialog.querySelector('#pref-height').value);
config.scrollSpeed = parseFloat(dialog.querySelector('#pref-scroll-speed').value);
config.reopenLastSession = dialog.querySelector('#pref-reopen-session').checked;
config.restoreLayoutFromFile = dialog.querySelector('#pref-restore-layout').checked;
config.debug = dialog.querySelector('#pref-debug').checked;
// Save config to localStorage
await saveConfig();
dialog.remove();
console.log('Preferences saved:', config);
});
// Close on background click
dialog.addEventListener('click', (e) => {
if (e.target === dialog) {
dialog.remove();
}
});
}
// Helper function to convert MIDI note number to note name // Helper function to convert MIDI note number to note name
function midiToNoteName(midiNote) { function midiToNoteName(midiNote) {
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];

View File

@ -14,6 +14,7 @@ export function createStartScreen(callback) {
startScreenContainer = document.createElement('div'); startScreenContainer = document.createElement('div');
startScreenContainer.id = 'startScreen'; startScreenContainer.id = 'startScreen';
startScreenContainer.className = 'start-screen'; startScreenContainer.className = 'start-screen';
startScreenContainer.style.display = 'none'; // Hidden by default
// Create welcome title // Create welcome title
const title = document.createElement('h1'); const title = document.createElement('h1');
@ -170,6 +171,8 @@ function createFocusCard(focus) {
export async function updateStartScreen(config) { export async function updateStartScreen(config) {
if (!startScreenContainer) return; if (!startScreenContainer) return;
console.log('[updateStartScreen] config.recentFiles:', config.recentFiles);
// Update last session // Update last session
const lastSessionDiv = document.getElementById('lastSessionFile'); const lastSessionDiv = document.getElementById('lastSessionFile');
if (lastSessionDiv) { if (lastSessionDiv) {

View File

@ -125,9 +125,27 @@ export async function loadConfig() {
// Merge loaded config with defaults // Merge loaded config with defaults
Object.assign(config, deepMerge({ ...config }, loaded)); Object.assign(config, deepMerge({ ...config }, loaded));
// Ensure recentFiles is always an array (fix legacy string format)
let needsResave = false;
if (typeof config.recentFiles === 'string') {
config.recentFiles = config.recentFiles.split(',').filter(f => f.length > 0);
needsResave = true;
} else if (!Array.isArray(config.recentFiles)) {
config.recentFiles = [];
needsResave = true;
}
// Make config accessible to widgets via context // Make config accessible to widgets via context
context.config = config; context.config = config;
console.log('[loadConfig] Loaded config.recentFiles:', config.recentFiles);
// Re-save config if we had to fix the format
if (needsResave) {
console.log('[loadConfig] Re-saving config to fix array format');
await saveConfig();
}
return config; return config;
} catch (error) { } catch (error) {
console.log("Error loading config, using defaults:", error); console.log("Error loading config, using defaults:", error);
@ -154,6 +172,7 @@ export async function addRecentFile(filePath) {
filePath, filePath,
...config.recentFiles.filter(file => file !== filePath) ...config.recentFiles.filter(file => file !== filePath)
].slice(0, 10); ].slice(0, 10);
console.log('[addRecentFile] Added file, recentFiles now:', config.recentFiles);
await saveConfig(); await saveConfig();
} }

View File

@ -1992,7 +1992,8 @@ button {
font-weight: 500; font-weight: 500;
} }
.form-group input, .form-group input[type="text"],
.form-group input[type="number"],
.form-group textarea { .form-group textarea {
width: 100%; width: 100%;
background: #1e1e1e; background: #1e1e1e;
@ -2005,6 +2006,19 @@ button {
box-sizing: border-box; box-sizing: border-box;
} }
.form-group input[type="checkbox"] {
width: auto;
margin-right: 8px;
cursor: pointer;
}
.form-group label:has(input[type="checkbox"]) {
display: flex;
align-items: center;
cursor: pointer;
color: #ddd;
}
.form-group input:focus, .form-group input:focus,
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
@ -2219,7 +2233,7 @@ button {
font-size: 1.1em; font-size: 1.1em;
padding: 12px; padding: 12px;
background: var(--surface-light); background: var(--surface-light);
border: 1px solid var(--border-light); border: 1px solid var(--shadow);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s; transition: all 0.2s;
@ -2267,7 +2281,7 @@ button {
width: 180px; width: 180px;
padding: 24px; padding: 24px;
background: var(--surface-light); background: var(--surface-light);
border: 2px solid var(--border-light); border: 2px solid var(--shadow);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
cursor: pointer; cursor: pointer;
@ -2290,7 +2304,7 @@ button {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 3px solid var(--button-hover); border: 3px solid var(--shadow);
border-radius: 8px; border-radius: 8px;
background: var(--surface); background: var(--surface);
} }