feat: added save as option when renaming the project

This commit is contained in:
Xiaohan-Tian
2026-05-10 13:25:12 -07:00
parent 578be7b726
commit 8fa2c16395
5 changed files with 115 additions and 23 deletions
+36 -6
View File
@@ -36,7 +36,7 @@ import { clearChatHistoryAndUI } from '../util/chatUtil';
import PianoIcon from './common/icons/PianoIcon'; import PianoIcon from './common/icons/PianoIcon';
import MetronomeIcon from './common/icons/MetronomeIcon'; import MetronomeIcon from './common/icons/MetronomeIcon';
import { ConfigManager } from '../core/config/ConfigManager'; import { ConfigManager } from '../core/config/ConfigManager';
import { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil'; import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
const Toolbar: React.FC = () => { const Toolbar: React.FC = () => {
const { const {
@@ -112,8 +112,28 @@ const Toolbar: React.FC = () => {
return; return;
} }
// If the name hasn't changed from the saved state, just save without prompting
if (newName === savedProjectName) {
setProjectName(newName);
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
if (finalName !== newName) setProjectName(finalName);
});
return;
}
// Ask whether the user wants to rename or save as a copy
const choice = await showChoice(
"Would you like to rename this project, or save it as a new copy?",
[
{ label: 'Save as Copy', value: 'saveas' },
{ label: 'Rename', value: 'rename' },
]
);
if (!choice) return;
if (choice === 'rename') {
// Conflict check: only relevant when targeting a different OPFS folder // Conflict check: only relevant when targeting a different OPFS folder
if (newName !== savedProjectName) {
const storage = KGProjectStorage.getInstance(); const storage = KGProjectStorage.getInstance();
const exists = await storage.exists(newName); const exists = await storage.exists(newName);
if (exists) { if (exists) {
@@ -121,7 +141,6 @@ const Toolbar: React.FC = () => {
`Project "${newName}" already exists. Do you want to overwrite it?` `Project "${newName}" already exists. Do you want to overwrite it?`
); );
if (!confirmed) return; if (!confirmed) return;
// Confirmed: update in-memory name then save immediately, overwriting the existing project
setProjectName(newName); setProjectName(newName);
await saveProject(newName, savedProjectName, setStatus, (finalName) => { await saveProject(newName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName); setSavedProjectName(finalName);
@@ -129,14 +148,25 @@ const Toolbar: React.FC = () => {
}, true /* forceOverwrite */); }, true /* forceOverwrite */);
return; return;
} }
}
// Name is available — update and save immediately
setProjectName(newName); setProjectName(newName);
await saveProject(newName, savedProjectName, setStatus, (finalName) => { await saveProject(newName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName); setSavedProjectName(finalName);
if (finalName !== newName) setProjectName(finalName); if (finalName !== newName) setProjectName(finalName);
}); });
} else {
// Save as Copy: save current state under a unique new name, then switch to it
const storage = KGProjectStorage.getInstance();
const finalName = await storage.resolveUniqueName(newName);
try {
await storage.saveAs(savedProjectName, finalName, KGCore.instance().getCurrentProject());
setProjectName(finalName);
setSavedProjectName(finalName);
setStatus(`Saved as "${finalName}"`);
} catch (error) {
console.error('Error saving project as copy:', error);
await showAlert(`An error occurred while saving: ${error}`);
}
}
}; };
// Common project loading logic extracted for reuse // Common project loading logic extracted for reuse
+11
View File
@@ -184,3 +184,14 @@
background-color: #4a4a4a; background-color: #4a4a4a;
color: #e0e0e0; color: #e0e0e0;
} }
.dialog-btn-secondary {
background-color: transparent;
color: #5a9fd4;
border: 1px solid #5a9fd4;
}
.dialog-btn-secondary:hover {
background-color: rgba(90, 159, 212, 0.12);
transform: translateY(-1px);
}
+27 -5
View File
@@ -2,14 +2,15 @@ import React, { useState, useCallback, useRef } from 'react';
import './DialogProvider.css'; import './DialogProvider.css';
import { FaTimes } from 'react-icons/fa'; import { FaTimes } from 'react-icons/fa';
import { registerDialogFns } from '../../util/dialogUtil'; import { registerDialogFns } from '../../util/dialogUtil';
import type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil'; import type { ChoiceOption, ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
interface DialogInfo { interface DialogInfo {
type: 'alert' | 'confirm' | 'prompt' | 'timesig'; type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice';
message: string; message: string;
options?: ConfirmOptions | PromptOptions; options?: ConfirmOptions | PromptOptions;
defaultValue?: string; defaultValue?: string;
defaultTimeSig?: TimeSigResult; defaultTimeSig?: TimeSigResult;
choices?: ChoiceOption[];
} }
const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -53,6 +54,13 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
}); });
}, []); }, []);
const openChoice = useCallback((message: string, choices: ChoiceOption[]): Promise<string | null> => {
return new Promise<string | null>((resolve) => {
resolveRef.current = resolve;
setDialog({ type: 'choice', message, choices });
});
}, []);
const close = useCallback((value: unknown) => { const close = useCallback((value: unknown) => {
pendingValueRef.current = value; pendingValueRef.current = value;
setIsClosing(true); setIsClosing(true);
@@ -77,7 +85,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const registered = useRef(false); const registered = useRef(false);
if (!registered.current) { if (!registered.current) {
registered.current = true; registered.current = true;
registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig); registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig, openChoice);
} }
if (!dialog) { if (!dialog) {
@@ -87,6 +95,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const isAlert = dialog.type === 'alert'; const isAlert = dialog.type === 'alert';
const isPrompt = dialog.type === 'prompt'; const isPrompt = dialog.type === 'prompt';
const isTimeSig = dialog.type === 'timesig'; const isTimeSig = dialog.type === 'timesig';
const isChoice = dialog.type === 'choice';
const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined; const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined;
const title = isAlert ? 'Notice' : isTimeSig ? 'Time Signature' : isPrompt ? 'Input' : 'Confirm'; const title = isAlert ? 'Notice' : isTimeSig ? 'Time Signature' : isPrompt ? 'Input' : 'Confirm';
@@ -97,11 +106,11 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const handleOverlayClick = (e: React.MouseEvent) => { const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && mouseDownOnOverlay.current) { if (e.target === e.currentTarget && mouseDownOnOverlay.current) {
close(isAlert ? undefined : (isPrompt || isTimeSig) ? null : false); close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice) ? null : false);
} }
}; };
const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig) ? null : false); const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice) ? null : false);
const handleConfirm = () => { const handleConfirm = () => {
if (isAlert) { close(undefined); return; } if (isAlert) { close(undefined); return; }
@@ -182,6 +191,18 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
{(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? 'Cancel'} {(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? 'Cancel'}
</button> </button>
)} )}
{isChoice ? (
dialog.choices?.map((choice, i) => (
<button
key={choice.value}
className={`dialog-btn ${i === (dialog.choices!.length - 1) ? 'dialog-btn-primary' : 'dialog-btn-secondary'}`}
onClick={() => close(choice.value)}
autoFocus={i === dialog.choices!.length - 1}
>
{choice.label}
</button>
))
) : (
<button <button
className="dialog-btn dialog-btn-primary" className="dialog-btn dialog-btn-primary"
onClick={handleConfirm} onClick={handleConfirm}
@@ -189,6 +210,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
> >
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : 'Yes'))} {isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : 'Yes'))}
</button> </button>
)}
</div> </div>
</div> </div>
</div> </div>
+14
View File
@@ -371,6 +371,20 @@ export class KGProjectStorage {
} }
} }
/**
* Save the current in-memory project under a new name without removing the source folder.
* Used for "Save as Copy": the original project remains intact in OPFS.
*/
public async saveAs(sourceName: string, targetName: string, data: KGProject): Promise<void> {
this.ensureInitialized();
await this.save(targetName, data, false);
if (await this.exists(sourceName)) {
await this.copyMediaFiles(sourceName, targetName);
}
}
/** /**
* Export a project folder as a zip Blob (.kgstudio bundle). * Export a project folder as a zip Blob (.kgstudio bundle).
* Includes project.json, meta.json, and all files in media/. * Includes project.json, meta.json, and all files in media/.
+15
View File
@@ -14,21 +14,29 @@ export interface TimeSigResult {
denominator: number; denominator: number;
} }
export interface ChoiceOption {
label: string;
value: string;
}
let _showAlertFn: ((message: string) => Promise<void>) | null = null; let _showAlertFn: ((message: string) => Promise<void>) | null = null;
let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise<boolean>) | null = null; let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise<boolean>) | null = null;
let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>) | null = null; let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>) | null = null;
let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null; let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null;
let _showChoiceFn: ((message: string, choices: ChoiceOption[]) => Promise<string | null>) | null = null;
export function registerDialogFns( export function registerDialogFns(
alertFn: (message: string) => Promise<void>, alertFn: (message: string) => Promise<void>,
confirmFn: (message: string, options?: ConfirmOptions) => Promise<boolean>, confirmFn: (message: string, options?: ConfirmOptions) => Promise<boolean>,
promptFn: (message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>, promptFn: (message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>,
timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>, timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>,
choiceFn?: (message: string, choices: ChoiceOption[]) => Promise<string | null>,
) { ) {
_showAlertFn = alertFn; _showAlertFn = alertFn;
_showConfirmFn = confirmFn; _showConfirmFn = confirmFn;
_showPromptFn = promptFn; _showPromptFn = promptFn;
_showTimeSigFn = timeSigFn; _showTimeSigFn = timeSigFn;
if (choiceFn) _showChoiceFn = choiceFn;
} }
export function showAlert(message: string): Promise<void> { export function showAlert(message: string): Promise<void> {
@@ -53,6 +61,13 @@ export function showPrompt(message: string, defaultValue?: string, options?: Pro
return _showPromptFn(message, defaultValue, options); return _showPromptFn(message, defaultValue, options);
} }
export function showChoice(message: string, choices: ChoiceOption[]): Promise<string | null> {
if (!_showChoiceFn) {
return Promise.resolve(window.confirm(message) ? choices[0]?.value ?? null : null);
}
return _showChoiceFn(message, choices);
}
export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult): Promise<TimeSigResult | null> { export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult): Promise<TimeSigResult | null> {
if (!_showTimeSigFn) { if (!_showTimeSigFn) {
const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4'); const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4');