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
+41 -11
View File
@@ -36,7 +36,7 @@ import { clearChatHistoryAndUI } from '../util/chatUtil';
import PianoIcon from './common/icons/PianoIcon';
import MetronomeIcon from './common/icons/MetronomeIcon';
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 {
@@ -112,8 +112,28 @@ const Toolbar: React.FC = () => {
return;
}
// Conflict check: only relevant when targeting a different OPFS folder
if (newName !== savedProjectName) {
// 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
const storage = KGProjectStorage.getInstance();
const exists = await storage.exists(newName);
if (exists) {
@@ -121,7 +141,6 @@ const Toolbar: React.FC = () => {
`Project "${newName}" already exists. Do you want to overwrite it?`
);
if (!confirmed) return;
// Confirmed: update in-memory name then save immediately, overwriting the existing project
setProjectName(newName);
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
@@ -129,14 +148,25 @@ const Toolbar: React.FC = () => {
}, true /* forceOverwrite */);
return;
}
setProjectName(newName);
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(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}`);
}
}
// Name is available — update and save immediately
setProjectName(newName);
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
if (finalName !== newName) setProjectName(finalName);
});
};
// Common project loading logic extracted for reuse
+11
View File
@@ -183,4 +183,15 @@
.dialog-btn-cancel:hover {
background-color: #4a4a4a;
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);
}
+34 -12
View File
@@ -2,14 +2,15 @@ import React, { useState, useCallback, useRef } from 'react';
import './DialogProvider.css';
import { FaTimes } from 'react-icons/fa';
import { registerDialogFns } from '../../util/dialogUtil';
import type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
import type { ChoiceOption, ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
interface DialogInfo {
type: 'alert' | 'confirm' | 'prompt' | 'timesig';
type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice';
message: string;
options?: ConfirmOptions | PromptOptions;
defaultValue?: string;
defaultTimeSig?: TimeSigResult;
choices?: ChoiceOption[];
}
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) => {
pendingValueRef.current = value;
setIsClosing(true);
@@ -77,7 +85,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const registered = useRef(false);
if (!registered.current) {
registered.current = true;
registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig);
registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig, openChoice);
}
if (!dialog) {
@@ -87,6 +95,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const isAlert = dialog.type === 'alert';
const isPrompt = dialog.type === 'prompt';
const isTimeSig = dialog.type === 'timesig';
const isChoice = dialog.type === 'choice';
const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined;
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) => {
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 = () => {
if (isAlert) { close(undefined); return; }
@@ -182,13 +191,26 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
{(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? 'Cancel'}
</button>
)}
<button
className="dialog-btn dialog-btn-primary"
onClick={handleConfirm}
autoFocus={!isPrompt && !isTimeSig}
>
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : 'Yes'))}
</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
className="dialog-btn dialog-btn-primary"
onClick={handleConfirm}
autoFocus={!isPrompt && !isTimeSig}
>
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : 'Yes'))}
</button>
)}
</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).
* Includes project.json, meta.json, and all files in media/.
+15
View File
@@ -14,21 +14,29 @@ export interface TimeSigResult {
denominator: number;
}
export interface ChoiceOption {
label: string;
value: string;
}
let _showAlertFn: ((message: string) => Promise<void>) | 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 _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null;
let _showChoiceFn: ((message: string, choices: ChoiceOption[]) => Promise<string | null>) | null = null;
export function registerDialogFns(
alertFn: (message: string) => Promise<void>,
confirmFn: (message: string, options?: ConfirmOptions) => Promise<boolean>,
promptFn: (message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>,
timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>,
choiceFn?: (message: string, choices: ChoiceOption[]) => Promise<string | null>,
) {
_showAlertFn = alertFn;
_showConfirmFn = confirmFn;
_showPromptFn = promptFn;
_showTimeSigFn = timeSigFn;
if (choiceFn) _showChoiceFn = choiceFn;
}
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);
}
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> {
if (!_showTimeSigFn) {
const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4');