FileViewModel.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. using System.Collections.Generic;
  2. using System.Diagnostics;
  3. using System.IO;
  4. using System.Linq;
  5. using System.Reflection;
  6. using System.Threading.Tasks;
  7. using Avalonia;
  8. using Avalonia.Controls;
  9. using Avalonia.Controls.ApplicationLifetimes;
  10. using Avalonia.Input;
  11. using Avalonia.Platform.Storage;
  12. using Avalonia.Threading;
  13. using ChunkyImageLib;
  14. using Newtonsoft.Json.Linq;
  15. using PixiEditor.ChangeableDocument.Changeables.Graph;
  16. using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
  17. using PixiEditor.DrawingApi.Core;
  18. using PixiEditor.DrawingApi.Core.Numerics;
  19. using PixiEditor.Exceptions;
  20. using PixiEditor.Extensions.Common.Localization;
  21. using PixiEditor.Extensions.CommonApi.UserPreferences.Settings.PixiEditor;
  22. using PixiEditor.Extensions.Exceptions;
  23. using PixiEditor.Helpers;
  24. using PixiEditor.Models.AnalyticsAPI;
  25. using PixiEditor.Models.Commands.Attributes.Commands;
  26. using PixiEditor.Models.Controllers;
  27. using PixiEditor.Models.Dialogs;
  28. using PixiEditor.Models.Files;
  29. using PixiEditor.Models.IO;
  30. using PixiEditor.Models.UserData;
  31. using PixiEditor.Numerics;
  32. using PixiEditor.OperatingSystem;
  33. using PixiEditor.Parser;
  34. using PixiEditor.Parser.Graph;
  35. using PixiEditor.UI.Common.Fonts;
  36. using PixiEditor.ViewModels.Document;
  37. using PixiEditor.Views;
  38. using PixiEditor.Views.Dialogs;
  39. using PixiEditor.Views.Windows;
  40. namespace PixiEditor.ViewModels.SubViewModels;
  41. [Command.Group("PixiEditor.File", "FILE")]
  42. internal class FileViewModel : SubViewModel<ViewModelMain>
  43. {
  44. private bool hasRecent;
  45. public bool HasRecent
  46. {
  47. get => hasRecent;
  48. set
  49. {
  50. hasRecent = value;
  51. OnPropertyChanged(nameof(HasRecent));
  52. }
  53. }
  54. public RecentlyOpenedCollection RecentlyOpened { get; init; }
  55. public FileViewModel(ViewModelMain owner)
  56. : base(owner)
  57. {
  58. Owner.OnStartupEvent += Owner_OnStartupEvent;
  59. RecentlyOpened = new RecentlyOpenedCollection(GetRecentlyOpenedDocuments());
  60. if (RecentlyOpened.Count > 0)
  61. {
  62. HasRecent = true;
  63. }
  64. PixiEditorSettings.File.MaxOpenedRecently.ValueChanged += (_, value) => UpdateMaxRecentlyOpened(value);
  65. }
  66. public void AddRecentlyOpened(string path)
  67. {
  68. if (RecentlyOpened.Contains(path))
  69. {
  70. RecentlyOpened.Move(RecentlyOpened.IndexOf(path), 0);
  71. }
  72. else
  73. {
  74. RecentlyOpened.Insert(0, path);
  75. }
  76. int maxCount = PixiEditorSettings.File.MaxOpenedRecently.Value;
  77. while (RecentlyOpened.Count > maxCount)
  78. {
  79. RecentlyOpened.RemoveAt(RecentlyOpened.Count - 1);
  80. }
  81. PixiEditorSettings.File.RecentlyOpened.Value = RecentlyOpened.Select(x => x.FilePath);
  82. }
  83. [Command.Internal("PixiEditor.File.RemoveRecent")]
  84. public void RemoveRecentlyOpened(string path)
  85. {
  86. if (!RecentlyOpened.Contains(path))
  87. {
  88. return;
  89. }
  90. RecentlyOpened.Remove(path);
  91. PixiEditorSettings.File.RecentlyOpened.Value = RecentlyOpened.Select(x => x.FilePath);
  92. }
  93. private void OpenHelloTherePopup()
  94. {
  95. new HelloTherePopup(this).Show();
  96. }
  97. private void Owner_OnStartupEvent(object sender, System.EventArgs e)
  98. {
  99. List<string> args = StartupArgs.Args;
  100. string file = args.FirstOrDefault(x => Importer.IsSupportedFile(x) && File.Exists(x));
  101. if (file != null)
  102. {
  103. OpenFromPath(file);
  104. }
  105. else if ((Owner.DocumentManagerSubViewModel.Documents.Count == 0 && !args.Contains("--crash")) &&
  106. !args.Contains("--openedInExisting"))
  107. {
  108. if (PixiEditorSettings.StartupWindow.ShowStartupWindow.Value)
  109. {
  110. OpenHelloTherePopup();
  111. }
  112. }
  113. }
  114. [Command.Internal("PixiEditor.File.OpenRecent", AnalyticsTrack = true)]
  115. public void OpenRecent(string parameter)
  116. {
  117. string path = parameter;
  118. if (!File.Exists(path))
  119. {
  120. NoticeDialog.Show("FILE_NOT_FOUND", "FAILED_TO_OPEN_FILE");
  121. RecentlyOpened.Remove(path);
  122. PixiEditorSettings.File.RecentlyOpened.Value = RecentlyOpened.Select(x => x.FilePath);
  123. return;
  124. }
  125. OpenFromPath(path);
  126. }
  127. [Command.Basic("PixiEditor.File.Open", "OPEN", "OPEN_FILE", Key = Key.O, Modifiers = KeyModifiers.Control,
  128. MenuItemPath = "FILE/OPEN_FILE", MenuItemOrder = 1, Icon = PixiPerfectIcons.FileText, AnalyticsTrack = true)]
  129. public async Task OpenFromOpenFileDialog()
  130. {
  131. var filter = SupportedFilesHelper.BuildOpenFilter();
  132. if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
  133. {
  134. var dialog = await desktop.MainWindow.StorageProvider.OpenFilePickerAsync(
  135. new FilePickerOpenOptions { FileTypeFilter = filter });
  136. if (dialog.Count == 0 || !Importer.IsSupportedFile(dialog[0].Path.LocalPath))
  137. return;
  138. OpenFromPath(dialog[0].Path.LocalPath);
  139. }
  140. }
  141. [Command.Basic("PixiEditor.File.OpenFileFromClipboard", "OPEN_FILE_FROM_CLIPBOARD",
  142. "OPEN_FILE_FROM_CLIPBOARD_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.HasImageInClipboard",
  143. AnalyticsTrack = true)]
  144. public async Task OpenFromClipboard()
  145. {
  146. var images = await ClipboardController.GetImagesFromClipboard();
  147. foreach (var dataImage in images)
  148. {
  149. if (File.Exists(dataImage.Name))
  150. {
  151. OpenRegularImage(dataImage.Image, null);
  152. continue;
  153. }
  154. OpenRegularImage(dataImage.Image, null);
  155. }
  156. }
  157. private bool MakeExistingDocumentActiveIfOpened(string path)
  158. {
  159. foreach (DocumentViewModel document in Owner.DocumentManagerSubViewModel.Documents)
  160. {
  161. if (document.FullFilePath is not null &&
  162. System.IO.Path.GetFullPath(document.FullFilePath) == System.IO.Path.GetFullPath(path))
  163. {
  164. Owner.WindowSubViewModel.MakeDocumentViewportActive(document);
  165. return true;
  166. }
  167. }
  168. return false;
  169. }
  170. /// <summary>
  171. /// Tries to open the passed file if it isn't already open
  172. /// </summary>
  173. public void OpenFromPath(string path, bool associatePath = true)
  174. {
  175. if (MakeExistingDocumentActiveIfOpened(path))
  176. return;
  177. try
  178. {
  179. if (path.EndsWith(".pixi"))
  180. {
  181. OpenDotPixi(path, associatePath);
  182. }
  183. else
  184. {
  185. OpenRegularImage(path, associatePath);
  186. }
  187. }
  188. catch (RecoverableException ex)
  189. {
  190. NoticeDialog.Show(ex.DisplayMessage, "ERROR");
  191. }
  192. catch (OldFileFormatException)
  193. {
  194. NoticeDialog.Show("OLD_FILE_FORMAT_DESCRIPTION", "OLD_FILE_FORMAT");
  195. }
  196. }
  197. /// <summary>
  198. /// Opens a .pixi file from path, creates a document from it, and adds it to the system
  199. /// </summary>
  200. private void OpenDotPixi(string path, bool associatePath = true)
  201. {
  202. DocumentViewModel document = Importer.ImportDocument(path, associatePath);
  203. AddDocumentViewModelToTheSystem(document);
  204. AddRecentlyOpened(document.FullFilePath);
  205. var fileSize = new FileInfo(path).Length;
  206. Analytics.SendOpenFile(PixiFileType.PixiFile, fileSize, document.SizeBindable);
  207. }
  208. /// <summary>
  209. /// Opens a .pixi file from path, creates a document from it, and adds it to the system
  210. /// </summary>
  211. public void OpenRecoveredDotPixi(string? originalPath, byte[] dotPixiBytes)
  212. {
  213. DocumentViewModel document = Importer.ImportDocument(dotPixiBytes, originalPath);
  214. document.MarkAsUnsaved();
  215. AddDocumentViewModelToTheSystem(document);
  216. }
  217. /// <summary>
  218. /// Opens a regular image file from path, creates a document from it, and adds it to the system.
  219. /// </summary>
  220. private void OpenRegularImage(string path, bool associatePath)
  221. {
  222. var image = Importer.ImportImage(path, VecI.NegativeOne);
  223. if (image == null) return;
  224. var doc = NewDocument(b => b
  225. .WithSize(image.Size)
  226. .WithGraph(x => x
  227. .WithImageLayerNode(
  228. new LocalizedString("IMAGE"),
  229. image, out int id)
  230. .WithOutputNode(id, "Output")
  231. ));
  232. if (associatePath)
  233. {
  234. doc.FullFilePath = path;
  235. }
  236. AddRecentlyOpened(path);
  237. var fileType = SupportedFilesHelper.ParseImageFormat(path);
  238. if (fileType != null)
  239. {
  240. var fileSize = new FileInfo(path).Length;
  241. Analytics.SendOpenFile(fileType, fileSize, doc.SizeBindable);
  242. }
  243. else
  244. {
  245. CrashHelper.SendExceptionInfoToWebhook(new InvalidFileTypeException(default,
  246. $"Invalid file type '{fileType}'"));
  247. }
  248. }
  249. /// <summary>
  250. /// Opens a regular image file from path, creates a document from it, and adds it to the system.
  251. /// </summary>
  252. private void OpenRegularImage(Surface surface, string path)
  253. {
  254. DocumentViewModel doc = NewDocument(b => b
  255. .WithSize(surface.Size)
  256. .WithGraph(x => x
  257. .WithImageLayerNode(
  258. new LocalizedString("IMAGE"),
  259. surface, out int id)
  260. .WithOutputNode(id, "Output")
  261. ));
  262. if (path == null)
  263. {
  264. return;
  265. }
  266. doc.FullFilePath = path;
  267. AddRecentlyOpened(path);
  268. }
  269. [Command.Basic("PixiEditor.File.New", "NEW_IMAGE", "CREATE_NEW_IMAGE", Key = Key.N,
  270. Modifiers = KeyModifiers.Control,
  271. MenuItemPath = "FILE/NEW_FILE", MenuItemOrder = 0, Icon = PixiPerfectIcons.File)]
  272. public async Task CreateFromNewFileDialog()
  273. {
  274. Window mainWindow = (Application.Current.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)
  275. .MainWindow;
  276. NewFileDialog newFile = new NewFileDialog(mainWindow);
  277. if (await newFile.ShowDialog())
  278. {
  279. NewDocument(b => b
  280. .WithSize(newFile.Width, newFile.Height)
  281. .WithGraph(x => x
  282. .WithImageLayerNode(
  283. new LocalizedString("BASE_LAYER_NAME"),
  284. new VecI(newFile.Width, newFile.Height), out int id)
  285. .WithOutputNode(id, "Output")
  286. ));
  287. Analytics.SendCreateDocument(newFile.Width, newFile.Height);
  288. }
  289. }
  290. public DocumentViewModel NewDocument(Action<DocumentViewModelBuilder> builder)
  291. {
  292. var doc = DocumentViewModel.Build(builder);
  293. AddDocumentViewModelToTheSystem(doc);
  294. return doc;
  295. }
  296. private void AddDocumentViewModelToTheSystem(DocumentViewModel doc)
  297. {
  298. Owner.DocumentManagerSubViewModel.Documents.Add(doc);
  299. Owner.WindowSubViewModel.CreateNewViewport(doc);
  300. Owner.WindowSubViewModel.MakeDocumentViewportActive(doc);
  301. }
  302. [Command.Basic("PixiEditor.File.Save", false, "SAVE", "SAVE_IMAGE", CanExecute = "PixiEditor.HasDocument",
  303. Key = Key.S, Modifiers = KeyModifiers.Control, Icon = PixiPerfectIcons.Save,
  304. MenuItemPath = "FILE/SAVE_PIXI", MenuItemOrder = 3, AnalyticsTrack = true)]
  305. [Command.Basic("PixiEditor.File.SaveAsNew", true, "SAVE_AS", "SAVE_IMAGE_AS", CanExecute = "PixiEditor.HasDocument",
  306. Key = Key.S, Modifiers = KeyModifiers.Control | KeyModifiers.Shift, Icon = PixiPerfectIcons.Save,
  307. MenuItemPath = "FILE/SAVE_AS_PIXI", MenuItemOrder = 4, AnalyticsTrack = true)]
  308. public async Task<bool> SaveActiveDocument(bool asNew)
  309. {
  310. DocumentViewModel doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
  311. if (doc is null)
  312. return false;
  313. return await SaveDocument(doc, asNew);
  314. }
  315. public async Task<bool> SaveDocument(DocumentViewModel document, bool asNew)
  316. {
  317. string finalPath = null;
  318. if (asNew || string.IsNullOrEmpty(document.FullFilePath))
  319. {
  320. var result = await Exporter.TrySaveWithDialog(document, ExportConfig.Empty, null);
  321. if (result.Result == DialogSaveResult.Cancelled)
  322. return false;
  323. if (result.Result != DialogSaveResult.Success)
  324. {
  325. ShowSaveError(result.Result);
  326. return false;
  327. }
  328. finalPath = result.Path;
  329. AddRecentlyOpened(result.Path);
  330. }
  331. else
  332. {
  333. var result = await Exporter.TrySaveAsync(document, document.FullFilePath, ExportConfig.Empty, null);
  334. if (result != SaveResult.Success)
  335. {
  336. ShowSaveError((DialogSaveResult)result);
  337. return false;
  338. }
  339. finalPath = document.FullFilePath;
  340. }
  341. document.FullFilePath = finalPath;
  342. document.MarkAsSaved();
  343. return true;
  344. }
  345. /// <summary>
  346. /// Generates export dialog or saves directly if save data is known.
  347. /// </summary>
  348. /// <param name="parameter">CommandProperty.</param>
  349. [Command.Basic("PixiEditor.File.Export", "EXPORT", "EXPORT_IMAGE", CanExecute = "PixiEditor.HasDocument",
  350. Key = Key.E, Modifiers = KeyModifiers.Control,
  351. MenuItemPath = "FILE/EXPORT_IMG", MenuItemOrder = 5, Icon = PixiPerfectIcons.Image, AnalyticsTrack = true)]
  352. public async Task ExportFile()
  353. {
  354. try
  355. {
  356. DocumentViewModel doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
  357. if (doc is null)
  358. return;
  359. ExportFileDialog info = new ExportFileDialog(MainWindow.Current, doc.SizeBindable, doc)
  360. {
  361. SuggestedName = Path.GetFileNameWithoutExtension(doc.FileName)
  362. };
  363. if (await info.ShowDialog())
  364. {
  365. ExportJob job = new ExportJob();
  366. ProgressDialog dialog = new ProgressDialog(job, MainWindow.Current);
  367. Task.Run(async () =>
  368. {
  369. var result =
  370. await Exporter.TrySaveUsingDataFromDialog(doc, info.FilePath, info.ChosenFormat,
  371. info.ExportConfig,
  372. job);
  373. if (result.result == SaveResult.Success)
  374. {
  375. Dispatcher.UIThread.Post(() =>
  376. {
  377. IOperatingSystem.Current.OpenFolder(result.finalPath);
  378. });
  379. }
  380. else
  381. {
  382. Dispatcher.UIThread.Post(() =>
  383. {
  384. ShowSaveError((DialogSaveResult)result.result);
  385. });
  386. }
  387. });
  388. await dialog.ShowDialog();
  389. }
  390. }
  391. catch (RecoverableException e)
  392. {
  393. NoticeDialog.Show(e.DisplayMessage, "ERROR");
  394. }
  395. }
  396. private void ShowSaveError(DialogSaveResult result)
  397. {
  398. switch (result)
  399. {
  400. case DialogSaveResult.InvalidPath:
  401. NoticeDialog.Show("ERROR", "ERROR_SAVE_LOCATION");
  402. break;
  403. case DialogSaveResult.ConcurrencyError:
  404. NoticeDialog.Show("INTERNAL_ERROR", "ERROR_WHILE_SAVING");
  405. break;
  406. case DialogSaveResult.SecurityError:
  407. NoticeDialog.Show(title: "SECURITY_ERROR", message: "SECURITY_ERROR_MSG");
  408. break;
  409. case DialogSaveResult.IoError:
  410. NoticeDialog.Show(title: "IO_ERROR", message: "IO_ERROR_MSG");
  411. break;
  412. case DialogSaveResult.UnknownError:
  413. NoticeDialog.Show("ERROR", "UNKNOWN_ERROR_SAVING");
  414. break;
  415. }
  416. }
  417. private void UpdateMaxRecentlyOpened(int newAmount)
  418. {
  419. if (newAmount >= RecentlyOpened.Count)
  420. {
  421. return;
  422. }
  423. List<RecentlyOpenedDocument> recentlyOpenedDocuments =
  424. new List<RecentlyOpenedDocument>(RecentlyOpened.Take(newAmount));
  425. RecentlyOpened.Clear();
  426. foreach (RecentlyOpenedDocument recent in recentlyOpenedDocuments)
  427. {
  428. RecentlyOpened.Add(recent);
  429. }
  430. }
  431. private List<RecentlyOpenedDocument> GetRecentlyOpenedDocuments()
  432. {
  433. var paths = PixiEditorSettings.File.RecentlyOpened.Value.Take(PixiEditorSettings.File.MaxOpenedRecently.Value);
  434. List<RecentlyOpenedDocument> documents = new List<RecentlyOpenedDocument>();
  435. foreach (string path in paths)
  436. {
  437. if (!File.Exists(path))
  438. continue;
  439. documents.Add(new RecentlyOpenedDocument(path));
  440. }
  441. return documents;
  442. }
  443. }