feat: added save as option when renaming the project
This commit is contained in:
+41
-11
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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/.
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user