feat(ui): replace native confirm() dialogs with reusable modal

Convert the remaining window.confirm() call sites to the in-app
MessageDialogHost, extended with a new confirm mode (Cancel + Confirm
buttons, optional danger styling) via showConfirmDialog().

Sites converted:
- New workspace (EditorPage)
- Load project / delete file (FileExplorer)
- Overwrite SPIFFS file (BoardOptionsModal)
- Delete VFS node (VirtualFileSystem)

All dialog strings are internationalized across the 9 supported locales
(en, es, pt-br, it, fr, zh-cn, de, ja, ru); the two previously
English-only modals now pull from i18n too.
This commit is contained in:
David Montero Crespo 2026-07-18 01:59:18 +02:00
parent d1d06a44d3
commit e72413a13f
15 changed files with 386 additions and 39 deletions

View File

@ -11,7 +11,7 @@ import {
import type { BoardKind } from '../../types/board';
import { boardDisplayName } from '../../types/board';
import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject';
import { showMessageDialog } from '../../store/useMessageDialogStore';
import { showMessageDialog, showConfirmDialog } from '../../store/useMessageDialogStore';
import './FileExplorer.css';
// SVG icons — same style as EditorToolbar (stroke-based, 16x16)
@ -237,6 +237,7 @@ interface FileExplorerProps {
}
export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewClick }) => {
const { t } = useTranslation();
// Hidden <input type="file"> we trigger via ref when the user clicks
// the Open project button. Accepts both .vlx (Velxio native) and .zip
// (Wokwi bundle); the dispatcher in utils/importProject.ts decides which
@ -252,14 +253,17 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
e.target.value = '';
if (!file) return;
const friendlyName = file.name.toLowerCase().endsWith('.zip') ? 'Wokwi .zip' : '.vlx';
if (
!window.confirm(
`Load this ${friendlyName} project? Your current workspace will be replaced. ` +
`This cannot be undone.`,
)
) {
return;
}
const confirmed = await showConfirmDialog(
t('editor.fileExplorer.confirmLoad.message', { type: friendlyName }),
{
kind: 'error',
title: t('editor.fileExplorer.confirmLoad.title'),
confirmLabel: t('editor.fileExplorer.confirmLoad.confirm'),
cancelLabel: t('editor.fileTabs.cancel'),
danger: true,
},
);
if (!confirmed) return;
try {
const result = await importProjectFile(file);
// .zip needs the caller to apply the payload to the stores (we keep
@ -287,9 +291,8 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
} catch (err) {
showMessageDialog((err as Error).message, { kind: 'error' });
}
}, []);
}, [t]);
const { t } = useTranslation();
const {
fileGroups,
activeFileId,
@ -503,11 +506,18 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
setRenamingId(null);
}, [renamingId, renameValue, renameFile]);
const handleDelete = (fileId: string, groupId: string) => {
const handleDelete = async (fileId: string, groupId: string) => {
setContextMenu(null);
const files = fileGroups[groupId] ?? [];
if (files.length <= 1) return;
if (!window.confirm(t('editor.fileExplorer.confirmDelete'))) return;
const confirmed = await showConfirmDialog(t('editor.fileExplorer.confirmDelete'), {
kind: 'error',
title: t('editor.fileExplorer.contextMenu.delete'),
confirmLabel: t('editor.fileExplorer.contextMenu.delete'),
cancelLabel: t('editor.fileTabs.cancel'),
danger: true,
});
if (!confirmed) return;
deleteFile(fileId);
};

View File

@ -5,9 +5,11 @@
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useVfsStore } from '../../store/useVfsStore';
import type { VfsNode } from '../../store/useVfsStore';
import { getBoardBridge, useSimulatorStore } from '../../store/useSimulatorStore';
import { showConfirmDialog } from '../../store/useMessageDialogStore';
/** Resolve true once the board's guest Linux has booted to a shell
* (board.piBooted), or false after timeoutMs. Polls the store. */
@ -244,6 +246,7 @@ interface VirtualFileSystemProps {
}
export const VirtualFileSystem: React.FC<VirtualFileSystemProps> = ({ boardId, onFileSelect }) => {
const { t } = useTranslation();
const {
initBoardVfs,
getRootId,
@ -335,11 +338,21 @@ export const VirtualFileSystem: React.FC<VirtualFileSystemProps> = ({ boardId, o
setNewNodeName('');
}, [boardId, creatingIn, newNodeName, newNodeType, createNode]);
const handleDelete = (nodeId: string) => {
const handleDelete = async (nodeId: string) => {
setCtxMenu(null);
const node = getNode(boardId, nodeId);
if (!node || node.parentId === null) return;
if (!window.confirm(`Delete "${node.name}"?`)) return;
const confirmed = await showConfirmDialog(
t('editor.pi.confirmDelete.message', { name: node.name }),
{
kind: 'error',
title: t('editor.pi.confirmDelete.title'),
confirmLabel: t('editor.pi.confirmDelete.confirm'),
cancelLabel: t('editor.pi.confirmDelete.cancel'),
danger: true,
},
);
if (!confirmed) return;
if (selectedNodeId === nodeId) setSelectedNode(boardId, null);
deleteNode(boardId, nodeId);
};

View File

@ -1,4 +1,5 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
import type {
@ -19,6 +20,7 @@ import {
boardSupportsPsram,
getDefaultOptionsForKind,
} from '../../types/boardOptions';
import { showConfirmDialog } from '../../store/useMessageDialogStore';
import './BoardOptionsModal.css';
interface BoardOptionsModalProps {
@ -80,6 +82,7 @@ export const BoardOptionsModal = ({
onApply,
onSpiffsChange,
}: BoardOptionsModalProps) => {
const { t } = useTranslation();
const seed = useMemo(
() => currentOptions ?? getDefaultOptionsForKind(boardKind),
[currentOptions, boardKind],
@ -121,8 +124,15 @@ export const BoardOptionsModal = ({
const next: SpiffsFile[] = [...spiffsFiles];
for (const f of Array.from(files)) {
if (next.some((existing) => existing.name === f.name)) {
const overwrite = window.confirm(
`A file named "${f.name}" already exists. Overwrite?`,
const overwrite = await showConfirmDialog(
t('editor.boardOptions.confirmOverwrite.message', { name: f.name }),
{
kind: 'error',
title: t('editor.boardOptions.confirmOverwrite.title'),
confirmLabel: t('editor.boardOptions.confirmOverwrite.confirm'),
cancelLabel: t('editor.boardOptions.confirmOverwrite.cancel'),
danger: true,
},
);
if (!overwrite) continue;
}

View File

@ -18,15 +18,18 @@ const ACCENTS: Record<MessageDialogKind, { bg: string; fg: string; icon: string
};
export const MessageDialogHost = () => {
const { open, kind, title, message, close } = useMessageDialogStore();
const { open, mode, kind, title, message, confirmLabel, cancelLabel, danger, close } =
useMessageDialogStore();
const okRef = useRef<HTMLButtonElement | null>(null);
const isConfirm = mode === 'confirm';
useEffect(() => {
if (!open) return;
// Focus OK so Enter dismisses, matching the native alert() flow.
// Focus the primary button so Enter confirms/dismisses, matching the
// native alert()/confirm() flow.
okRef.current?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
if (e.key === 'Escape') close(false);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
@ -40,7 +43,7 @@ export const MessageDialogHost = () => {
<div
role="dialog"
aria-modal="true"
onClick={close}
onClick={() => close(false)}
style={{
position: 'fixed',
inset: 0,
@ -92,24 +95,45 @@ export const MessageDialogHost = () => {
<span style={{ whiteSpace: 'pre-wrap', overflowY: 'auto' }}>{message}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
{isConfirm && (
<button
type="button"
onClick={() => close(false)}
style={{
padding: '7px 20px',
fontSize: 13,
fontWeight: 600,
color: '#e6e6e9',
background: '#2a2d35',
border: '1px solid #3a3d45',
borderRadius: 4,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
{cancelLabel}
</button>
)}
<button
ref={okRef}
type="button"
onClick={close}
onClick={() => close(true)}
style={{
padding: '7px 20px',
fontSize: 13,
fontWeight: 600,
color: 'white',
background: 'linear-gradient(135deg, #007acc 0%, #005ea1 100%)',
border: '1px solid #005ea1',
background: danger
? 'linear-gradient(135deg, #d64545 0%, #a12727 100%)'
: 'linear-gradient(135deg, #007acc 0%, #005ea1 100%)',
border: danger ? '1px solid #a12727' : '1px solid #005ea1',
borderRadius: 4,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
OK
{isConfirm ? confirmLabel : 'OK'}
</button>
</div>
</div>

View File

@ -36,6 +36,17 @@
"fileExplorer": {
"workspace": "ARBEITSBEREICH",
"newWorkspace": "Neuer Arbeitsbereich (löscht Boards, Komponenten, Leitungen und Dateien)",
"confirmNew": {
"title": "Neuen Arbeitsbereich starten?",
"message": "Dies löscht alle Boards, Komponenten, Leitungen und Dateien. Dies kann nicht rückgängig gemacht werden.",
"confirm": "Neu starten",
"cancel": "Abbrechen"
},
"confirmLoad": {
"title": "Projekt laden?",
"message": "Dieses {{type}}-Projekt laden? Dein aktueller Arbeitsbereich wird ersetzt. Dies kann nicht rückgängig gemacht werden.",
"confirm": "Laden"
},
"saveProject": "Projekt speichern (Strg+S)",
"clickToEdit": "zum Bearbeiten klicken",
"collapse": "Einklappen",
@ -267,6 +278,14 @@
"rotate": "Drehen",
"delete": "Löschen"
},
"boardOptions": {
"confirmOverwrite": {
"title": "Datei überschreiben?",
"message": "Eine Datei namens \"{{name}}\" existiert bereits. Überschreiben?",
"confirm": "Überschreiben",
"cancel": "Überspringen"
}
},
"pi": {
"connected": "Verbunden",
"starting": "Starte…",
@ -286,6 +305,12 @@
"offlineNote1": "Hinweis: Der Dateiexplorer zeigt einen Staging-Bereich an.",
"offlineNote2": "„Auf den Pi hochladen“ überträgt diese Dateien auf das laufende System und startet den Pi bei Bedarf automatisch.",
"loadingTerminal": "Terminal wird geladen…",
"confirmDelete": {
"title": "Löschen",
"message": "\"{{name}}\" löschen?",
"confirm": "Löschen",
"cancel": "Abbrechen"
},
"selectFile": "Wählen Sie eine Datei aus dem Explorer aus, um sie zu bearbeiten."
},
"sensorPanel": {

View File

@ -38,6 +38,17 @@
"fileExplorer": {
"workspace": "WORKSPACE",
"newWorkspace": "New workspace (clears boards, components, wires and files)",
"confirmNew": {
"title": "Start a new workspace?",
"message": "This clears every board, component, wire and file. This cannot be undone.",
"confirm": "Start new",
"cancel": "Cancel"
},
"confirmLoad": {
"title": "Load project?",
"message": "Load this {{type}} project? Your current workspace will be replaced. This cannot be undone.",
"confirm": "Load"
},
"saveProject": "Save project (Ctrl+S)",
"clickToEdit": "click to edit",
"collapse": "Collapse",
@ -269,6 +280,14 @@
"rotate": "Rotate",
"delete": "Delete"
},
"boardOptions": {
"confirmOverwrite": {
"title": "Overwrite file?",
"message": "A file named \"{{name}}\" already exists. Overwrite?",
"confirm": "Overwrite",
"cancel": "Skip"
}
},
"pi": {
"connected": "Connected",
"starting": "Starting…",
@ -288,6 +307,12 @@
"offlineNote1": "Note: The file explorer shows a staging area.",
"offlineNote2": "\"Upload to Pi\" sends these files to the running system, starting the Pi automatically if needed.",
"loadingTerminal": "Loading terminal…",
"confirmDelete": {
"title": "Delete",
"message": "Delete \"{{name}}\"?",
"confirm": "Delete",
"cancel": "Cancel"
},
"selectFile": "Select a file from the explorer to edit it."
},
"sensorPanel": {

View File

@ -38,6 +38,17 @@
"fileExplorer": {
"workspace": "ESPACIO DE TRABAJO",
"newWorkspace": "Nuevo espacio de trabajo (borra placas, componentes, cables y archivos)",
"confirmNew": {
"title": "¿Iniciar un nuevo espacio de trabajo?",
"message": "Esto borra todas las placas, componentes, cables y archivos. No se puede deshacer.",
"confirm": "Empezar de nuevo",
"cancel": "Cancelar"
},
"confirmLoad": {
"title": "¿Cargar proyecto?",
"message": "¿Cargar este proyecto {{type}}? Tu espacio de trabajo actual será reemplazado. No se puede deshacer.",
"confirm": "Cargar"
},
"saveProject": "Guardar proyecto (Ctrl+S)",
"clickToEdit": "clic para editar",
"collapse": "Contraer",
@ -269,6 +280,14 @@
"rotate": "Rotar",
"delete": "Eliminar"
},
"boardOptions": {
"confirmOverwrite": {
"title": "¿Sobrescribir archivo?",
"message": "Ya existe un archivo llamado \"{{name}}\". ¿Sobrescribir?",
"confirm": "Sobrescribir",
"cancel": "Omitir"
}
},
"pi": {
"connected": "Conectado",
"starting": "Iniciando…",
@ -288,6 +307,12 @@
"offlineNote1": "Nota: El explorador de archivos muestra un área de preparación.",
"offlineNote2": "\"Subir al Pi\" envía estos archivos al sistema en marcha; arranca el Pi automáticamente si hace falta.",
"loadingTerminal": "Cargando terminal…",
"confirmDelete": {
"title": "Eliminar",
"message": "¿Eliminar \"{{name}}\"?",
"confirm": "Eliminar",
"cancel": "Cancelar"
},
"selectFile": "Selecciona un archivo del explorador para editarlo."
},
"sensorPanel": {

View File

@ -36,6 +36,17 @@
"fileExplorer": {
"workspace": "ESPACE DE TRAVAIL",
"newWorkspace": "Nouvel espace de travail (efface cartes, composants, fils et fichiers)",
"confirmNew": {
"title": "Démarrer un nouvel espace de travail ?",
"message": "Ceci efface toutes les cartes, composants, fils et fichiers. Cette action est irréversible.",
"confirm": "Recommencer",
"cancel": "Annuler"
},
"confirmLoad": {
"title": "Charger le projet ?",
"message": "Charger ce projet {{type}} ? Votre espace de travail actuel sera remplacé. Cette action est irréversible.",
"confirm": "Charger"
},
"saveProject": "Enregistrer le projet (Ctrl+S)",
"clickToEdit": "cliquer pour éditer",
"collapse": "Réduire",
@ -267,6 +278,14 @@
"rotate": "Pivoter",
"delete": "Supprimer"
},
"boardOptions": {
"confirmOverwrite": {
"title": "Écraser le fichier ?",
"message": "Un fichier nommé « {{name}} » existe déjà. L'écraser ?",
"confirm": "Écraser",
"cancel": "Ignorer"
}
},
"pi": {
"connected": "Connecté",
"starting": "Démarrage…",
@ -286,6 +305,12 @@
"offlineNote1": "Remarque : L'explorateur de fichiers affiche une zone de préparation.",
"offlineNote2": "« Envoyer vers le Pi » transfère ces fichiers vers le système en cours d'exécution et démarre le Pi automatiquement si nécessaire.",
"loadingTerminal": "Chargement du terminal…",
"confirmDelete": {
"title": "Supprimer",
"message": "Supprimer « {{name}} » ?",
"confirm": "Supprimer",
"cancel": "Annuler"
},
"selectFile": "Sélectionnez un fichier dans l'explorateur pour l'éditer."
},
"sensorPanel": {

View File

@ -36,6 +36,17 @@
"fileExplorer": {
"workspace": "WORKSPACE",
"newWorkspace": "Nuovo workspace (cancella schede, componenti, cavi e file)",
"confirmNew": {
"title": "Avviare un nuovo workspace?",
"message": "Questo cancella tutte le schede, i componenti, i cavi e i file. Non può essere annullato.",
"confirm": "Ricomincia",
"cancel": "Annulla"
},
"confirmLoad": {
"title": "Caricare il progetto?",
"message": "Caricare questo progetto {{type}}? Il tuo workspace attuale sarà sostituito. Non può essere annullato.",
"confirm": "Carica"
},
"saveProject": "Salva progetto (Ctrl+S)",
"clickToEdit": "clicca per modificare",
"collapse": "Comprimi",
@ -267,6 +278,14 @@
"rotate": "Ruota",
"delete": "Elimina"
},
"boardOptions": {
"confirmOverwrite": {
"title": "Sovrascrivere il file?",
"message": "Esiste già un file di nome \"{{name}}\". Sovrascrivere?",
"confirm": "Sovrascrivi",
"cancel": "Salta"
}
},
"pi": {
"connected": "Connesso",
"starting": "Avvio…",
@ -286,6 +305,12 @@
"offlineNote1": "Nota: L'esplora file mostra un'area di staging.",
"offlineNote2": "\"Carica sul Pi\" invia questi file al sistema in esecuzione e avvia il Pi automaticamente se necessario.",
"loadingTerminal": "Caricamento terminale…",
"confirmDelete": {
"title": "Elimina",
"message": "Eliminare \"{{name}}\"?",
"confirm": "Elimina",
"cancel": "Annulla"
},
"selectFile": "Seleziona un file dall'esplora per modificarlo."
},
"sensorPanel": {

View File

@ -36,6 +36,17 @@
"fileExplorer": {
"workspace": "ワークスペース",
"newWorkspace": "新規ワークスペース (ボード、コンポーネント、配線、ファイルをクリア)",
"confirmNew": {
"title": "新しいワークスペースを開始しますか?",
"message": "すべてのボード、コンポーネント、配線、ファイルが消去されます。この操作は元に戻せません。",
"confirm": "新規開始",
"cancel": "キャンセル"
},
"confirmLoad": {
"title": "プロジェクトを読み込みますか?",
"message": "この {{type}} プロジェクトを読み込みますか?現在のワークスペースが置き換えられます。この操作は元に戻せません。",
"confirm": "読み込む"
},
"saveProject": "プロジェクトを保存 (Ctrl+S)",
"clickToEdit": "クリックして編集",
"collapse": "折りたたむ",
@ -267,6 +278,14 @@
"rotate": "回転",
"delete": "削除"
},
"boardOptions": {
"confirmOverwrite": {
"title": "ファイルを上書きしますか?",
"message": "\"{{name}}\" という名前のファイルは既に存在します。上書きしますか?",
"confirm": "上書き",
"cancel": "スキップ"
}
},
"pi": {
"connected": "接続済み",
"starting": "起動中…",
@ -286,6 +305,12 @@
"offlineNote1": "注: ファイルエクスプローラはステージング領域を表示します。",
"offlineNote2": "「Pi にアップロード」は実行中のシステムにこれらのファイルを転送します。必要に応じて Pi を自動的に起動します。",
"loadingTerminal": "端末を読み込み中…",
"confirmDelete": {
"title": "削除",
"message": "\"{{name}}\" を削除しますか?",
"confirm": "削除",
"cancel": "キャンセル"
},
"selectFile": "エクスプローラからファイルを選択して編集します。"
},
"sensorPanel": {

View File

@ -36,6 +36,17 @@
"fileExplorer": {
"workspace": "WORKSPACE",
"newWorkspace": "Novo workspace (limpa placas, componentes, fios e arquivos)",
"confirmNew": {
"title": "Iniciar um novo workspace?",
"message": "Isso limpa todas as placas, componentes, fios e arquivos. Não pode ser desfeito.",
"confirm": "Começar novo",
"cancel": "Cancelar"
},
"confirmLoad": {
"title": "Carregar projeto?",
"message": "Carregar este projeto {{type}}? Seu workspace atual será substituído. Não pode ser desfeito.",
"confirm": "Carregar"
},
"saveProject": "Salvar projeto (Ctrl+S)",
"clickToEdit": "clique para editar",
"collapse": "Recolher",
@ -267,6 +278,14 @@
"rotate": "Girar",
"delete": "Excluir"
},
"boardOptions": {
"confirmOverwrite": {
"title": "Substituir arquivo?",
"message": "Já existe um arquivo chamado \"{{name}}\". Substituir?",
"confirm": "Substituir",
"cancel": "Ignorar"
}
},
"pi": {
"connected": "Conectado",
"starting": "Iniciando…",
@ -286,6 +305,12 @@
"offlineNote1": "Nota: O explorador de arquivos mostra uma área de preparação.",
"offlineNote2": "\"Enviar para o Pi\" transfere esses arquivos para o sistema em execução e inicia o Pi automaticamente se necessário.",
"loadingTerminal": "Carregando terminal…",
"confirmDelete": {
"title": "Excluir",
"message": "Excluir \"{{name}}\"?",
"confirm": "Excluir",
"cancel": "Cancelar"
},
"selectFile": "Selecione um arquivo no explorador para editá-lo."
},
"sensorPanel": {

View File

@ -36,6 +36,17 @@
"fileExplorer": {
"workspace": "РАБОЧАЯ ОБЛАСТЬ",
"newWorkspace": "Новая рабочая область (очищает платы, компоненты, провода и файлы)",
"confirmNew": {
"title": "Начать новую рабочую область?",
"message": "Это очистит все платы, компоненты, провода и файлы. Это действие нельзя отменить.",
"confirm": "Начать заново",
"cancel": "Отмена"
},
"confirmLoad": {
"title": "Загрузить проект?",
"message": "Загрузить этот проект {{type}}? Ваша текущая рабочая область будет заменена. Это действие нельзя отменить.",
"confirm": "Загрузить"
},
"saveProject": "Сохранить проект (Ctrl+S)",
"clickToEdit": "нажмите для редактирования",
"collapse": "Свернуть",
@ -267,6 +278,14 @@
"rotate": "Повернуть",
"delete": "Удалить"
},
"boardOptions": {
"confirmOverwrite": {
"title": "Перезаписать файл?",
"message": "Файл с именем \"{{name}}\" уже существует. Перезаписать?",
"confirm": "Перезаписать",
"cancel": "Пропустить"
}
},
"pi": {
"connected": "Подключено",
"starting": "Запуск…",
@ -286,6 +305,12 @@
"offlineNote1": "Примечание: Проводник файлов показывает область подготовки.",
"offlineNote2": "«Загрузить на Pi» отправляет эти файлы в работающую систему и при необходимости запускает Pi автоматически.",
"loadingTerminal": "Загрузка терминала…",
"confirmDelete": {
"title": "Удалить",
"message": "Удалить \"{{name}}\"?",
"confirm": "Удалить",
"cancel": "Отмена"
},
"selectFile": "Выберите файл в проводнике для редактирования."
},
"sensorPanel": {

View File

@ -36,6 +36,17 @@
"fileExplorer": {
"workspace": "工作区",
"newWorkspace": "新建工作区 (清除板、元件、导线和文件)",
"confirmNew": {
"title": "开始新的工作区?",
"message": "这将清除所有板、元件、导线和文件。此操作无法撤销。",
"confirm": "新建",
"cancel": "取消"
},
"confirmLoad": {
"title": "加载项目?",
"message": "加载此 {{type}} 项目?您当前的工作区将被替换。此操作无法撤销。",
"confirm": "加载"
},
"saveProject": "保存项目 (Ctrl+S)",
"clickToEdit": "点击编辑",
"collapse": "折叠",
@ -267,6 +278,14 @@
"rotate": "旋转",
"delete": "删除"
},
"boardOptions": {
"confirmOverwrite": {
"title": "覆盖文件?",
"message": "已存在名为 \"{{name}}\" 的文件。是否覆盖?",
"confirm": "覆盖",
"cancel": "跳过"
}
},
"pi": {
"connected": "已连接",
"starting": "启动中…",
@ -286,6 +305,12 @@
"offlineNote1": "注意:文件浏览器显示暂存区域。",
"offlineNote2": "“上传到 Pi”会将这些文件传输到正在运行的系统并在需要时自动启动 Pi。",
"loadingTerminal": "加载终端中…",
"confirmDelete": {
"title": "删除",
"message": "删除 \"{{name}}\"",
"confirm": "删除",
"cancel": "取消"
},
"selectFile": "从浏览器中选择一个文件进行编辑。"
},
"sensorPanel": {

View File

@ -29,6 +29,7 @@ import { useEditorStore } from '../store/useEditorStore';
import { useCompileLogsStore } from '../store/useCompileLogsStore';
import { useOscilloscopeStore } from '../store/useOscilloscopeStore';
import { useProjectStore } from '../store/useProjectStore';
import { showConfirmDialog } from '../store/useMessageDialogStore';
import { useAutoSaveProject } from '../hooks/useAutoSaveProject';
import type { CompilationLog } from '../utils/compilationLogger';
import { isPiBoardKind } from '../types/board';
@ -195,14 +196,18 @@ export const EditorPage: React.FC = () => {
triggerSaveAction();
}, []);
const handleNewClick = useCallback(() => {
if (
!window.confirm(
'Start a new workspace? This clears every board, component, wire and file. This cannot be undone.',
)
) {
return;
}
const handleNewClick = useCallback(async () => {
const confirmed = await showConfirmDialog(
t('editor.fileExplorer.confirmNew.message'),
{
kind: 'error',
title: t('editor.fileExplorer.confirmNew.title'),
confirmLabel: t('editor.fileExplorer.confirmNew.confirm'),
cancelLabel: t('editor.fileExplorer.confirmNew.cancel'),
danger: true,
},
);
if (!confirmed) return;
const sim = useSimulatorStore.getState();
sim.boards.forEach((b) => sim.stopBoard(b.id));
const ids = sim.boards.map((b) => b.id);
@ -214,7 +219,7 @@ export const EditorPage: React.FC = () => {
.getState()
.addBoard('arduino-uno', DEFAULT_BOARD_POSITION.x, DEFAULT_BOARD_POSITION.y);
useSimulatorStore.getState().setActiveBoardId(newId);
}, []);
}, [t]);
// Track mobile breakpoint
useEffect(() => {

View File

@ -14,37 +14,97 @@ import { create } from 'zustand';
export type MessageDialogKind = 'info' | 'success' | 'error';
/** 'alert' shows a single OK button; 'confirm' shows Cancel + Confirm. */
export type MessageDialogMode = 'alert' | 'confirm';
export interface MessageDialogOptions {
kind?: MessageDialogKind;
/** Optional header line. Callers pass an already-translated string. */
title?: string;
}
export interface ConfirmDialogOptions extends MessageDialogOptions {
/** Label for the confirming button. Callers pass an already-translated string. */
confirmLabel?: string;
/** Label for the dismissing button. Callers pass an already-translated string. */
cancelLabel?: string;
/** Style the confirm button as a destructive action (red). */
danger?: boolean;
}
interface MessageDialogState {
open: boolean;
mode: MessageDialogMode;
kind: MessageDialogKind;
title: string | null;
message: string;
confirmLabel: string;
cancelLabel: string;
danger: boolean;
/** Set while a confirm dialog is open; called with the user's choice. */
resolve: ((confirmed: boolean) => void) | null;
show: (message: string, opts?: MessageDialogOptions) => void;
close: () => void;
confirm: (message: string, opts?: ConfirmDialogOptions) => Promise<boolean>;
/** Resolve/close. `result` is only meaningful for confirm dialogs. */
close: (result?: boolean) => void;
}
export const useMessageDialogStore = create<MessageDialogState>((set) => ({
export const useMessageDialogStore = create<MessageDialogState>((set, get) => ({
open: false,
mode: 'alert',
kind: 'info',
title: null,
message: '',
show: (message, opts) =>
confirmLabel: 'OK',
cancelLabel: 'Cancel',
danger: false,
resolve: null,
show: (message, opts) => {
// Reject any in-flight confirm before replacing it with an alert.
get().resolve?.(false);
set({
open: true,
mode: 'alert',
message,
kind: opts?.kind ?? 'info',
title: opts?.title ?? null,
resolve: null,
});
},
confirm: (message, opts) =>
new Promise<boolean>((resolve) => {
// Reject any in-flight confirm before replacing it.
get().resolve?.(false);
set({
open: true,
mode: 'confirm',
message,
kind: opts?.kind ?? 'info',
title: opts?.title ?? null,
confirmLabel: opts?.confirmLabel ?? 'OK',
cancelLabel: opts?.cancelLabel ?? 'Cancel',
danger: opts?.danger ?? false,
resolve,
});
}),
close: () => set({ open: false }),
close: (result = false) => {
get().resolve?.(result);
set({ open: false, resolve: null });
},
}));
/** Imperative helper so non-React callers don't need to know zustand. */
export function showMessageDialog(message: string, opts?: MessageDialogOptions): void {
useMessageDialogStore.getState().show(message, opts);
}
/**
* In-app replacement for window.confirm(). Returns a promise resolving to
* true when the user confirms, false when they cancel/dismiss.
*/
export function showConfirmDialog(
message: string,
opts?: ConfirmDialogOptions,
): Promise<boolean> {
return useMessageDialogStore.getState().confirm(message, opts);
}