From 5f248cd1761fdce725c54d81064af20281eab69f Mon Sep 17 00:00:00 2001 From: mfish33 <32677537+mfish33@users.noreply.github.com> Date: Tue, 30 Nov 2021 10:06:07 -0800 Subject: [PATCH] Display Asterisk on Unsaved Documents (#392) * ability to mark an open document as unsaved * unsaved detection now being triggered based on layer tree height * - rust implementation of unsaved markers - upgraded eslint * updated eslint in package.json * - Renamed GetOpenDocumentsList -> UpdateOpenDocumentsList - is not -> was not * changed hash to current identifier to better reflect its meaning * resolve some merge conflicts * removed console.log statement leftover from debuging --- editor/src/document/document_file.rs | 80 +++++++++++++------ .../src/document/document_message_handler.rs | 23 ++++-- .../src/frontend/frontend_message_handler.rs | 2 +- frontend/src/components/panels/LayerTree.vue | 1 - .../components/window/title-bar/TitleBar.vue | 2 +- frontend/src/utilities/documents.ts | 13 ++- frontend/src/utilities/response-handler.ts | 5 +- frontend/wasm/src/api.rs | 2 +- graphene/src/document.rs | 12 +-- 9 files changed, 88 insertions(+), 52 deletions(-) diff --git a/editor/src/document/document_file.rs b/editor/src/document/document_file.rs index 1a1bf2de..537bfcdd 100644 --- a/editor/src/document/document_file.rs +++ b/editor/src/document/document_file.rs @@ -59,8 +59,9 @@ pub struct VectorManipulatorShape { #[derive(Clone, Debug)] pub struct DocumentMessageHandler { pub graphene_document: GrapheneDocument, - pub document_history: Vec, + pub document_undo_history: Vec, pub document_redo_history: Vec, + pub saved_document_identifier: u64, pub name: String, pub layer_data: HashMap, LayerData>, movement_handler: MovementMessageHandler, @@ -72,9 +73,10 @@ impl Default for DocumentMessageHandler { fn default() -> Self { Self { graphene_document: GrapheneDocument::default(), - document_history: Vec::new(), + document_undo_history: Vec::new(), document_redo_history: Vec::new(), name: String::from("Untitled Document"), + saved_document_identifier: 0, layer_data: vec![(vec![], LayerData::new(true))].into_iter().collect(), movement_handler: MovementMessageHandler::default(), transform_layer_handler: TransformLayerMessageHandler::default(), @@ -305,8 +307,9 @@ impl DocumentMessageHandler { pub fn with_name(name: String) -> Self { Self { graphene_document: GrapheneDocument::default(), - document_history: Vec::new(), + document_undo_history: Vec::new(), document_redo_history: Vec::new(), + saved_document_identifier: 0, name, layer_data: vec![(vec![], LayerData::new(true))].into_iter().collect(), movement_handler: MovementMessageHandler::default(), @@ -332,23 +335,30 @@ impl DocumentMessageHandler { layer_data(&mut self.layer_data, path) } - pub fn backup(&mut self) { + pub fn backup(&mut self, responses: &mut VecDeque) { self.document_redo_history.clear(); let new_layer_data = self .layer_data .iter() .filter_map(|(key, value)| (!self.graphene_document.layer(key).unwrap().overlay).then(|| (key.clone(), *value))) .collect(); - self.document_history.push((self.graphene_document.clone_without_overlays(), new_layer_data)) + self.document_undo_history.push((self.graphene_document.clone_without_overlays(), new_layer_data)); + + // Push the UpdateOpenDocumentsList message to the bus in order to update the save status of the open documents + responses.push_back(DocumentsMessage::UpdateOpenDocumentsList.into()); } - pub fn rollback(&mut self) -> Result<(), EditorError> { - self.backup(); - self.undo() + pub fn rollback(&mut self, responses: &mut VecDeque) -> Result<(), EditorError> { + self.backup(responses); + self.undo(responses) + // TODO: Consider if we should check if the document is saved } - pub fn undo(&mut self) -> Result<(), EditorError> { - match self.document_history.pop() { + pub fn undo(&mut self, responses: &mut VecDeque) -> Result<(), EditorError> { + // Push the UpdateOpenDocumentsList message to the bus in order to update the save status of the open documents + responses.push_back(DocumentsMessage::UpdateOpenDocumentsList.into()); + + match self.document_undo_history.pop() { Some((document, layer_data)) => { let document = std::mem::replace(&mut self.graphene_document, document); let layer_data = std::mem::replace(&mut self.layer_data, layer_data); @@ -359,7 +369,10 @@ impl DocumentMessageHandler { } } - pub fn redo(&mut self) -> Result<(), EditorError> { + pub fn redo(&mut self, responses: &mut VecDeque) -> Result<(), EditorError> { + // Push the UpdateOpenDocumentsList message to the bus in order to update the save status of the open documents + responses.push_back(DocumentsMessage::UpdateOpenDocumentsList.into()); + match self.document_redo_history.pop() { Some((document, layer_data)) => { let document = std::mem::replace(&mut self.graphene_document, document); @@ -368,13 +381,26 @@ impl DocumentMessageHandler { .iter() .filter_map(|(key, value)| (!self.graphene_document.layer(key).unwrap().overlay).then(|| (key.clone(), *value))) .collect(); - self.document_history.push((document.clone_without_overlays(), new_layer_data)); + self.document_undo_history.push((document.clone_without_overlays(), new_layer_data)); Ok(()) } None => Err(EditorError::NoTransactionInProgress), } } + pub fn current_identifier(&self) -> u64 { + // We can use the last state of the document to serve as the identifier to compare against + // This is useful since when the document is empty the identifier will be 0 + self.document_undo_history + .last() + .map(|(graphene_document, _)| graphene_document.current_state_identifier()) + .unwrap_or(0) + } + + pub fn is_saved(&self) -> bool { + self.current_identifier() == self.saved_document_identifier + } + pub fn layer_panel_entry(&mut self, path: Vec) -> Result { let data: LayerData = *layer_data(&mut self.layer_data, &path); let layer = self.graphene_document.layer(&path)?; @@ -421,13 +447,13 @@ impl MessageHandler for DocumentMessageHand .transform_layer_handler .process_action(message, (&mut self.layer_data, &mut self.graphene_document, ipp), responses), DeleteLayer(path) => responses.push_back(DocumentOperation::DeleteLayer { path }.into()), - StartTransaction => self.backup(), + StartTransaction => self.backup(responses), RollbackTransaction => { - self.rollback().unwrap_or_else(|e| log::warn!("{}", e)); + self.rollback(responses).unwrap_or_else(|e| log::warn!("{}", e)); responses.extend([RenderDocument.into(), DocumentStructureChanged.into()]); } AbortTransaction => { - self.undo().unwrap_or_else(|e| log::warn!("{}", e)); + self.undo(responses).unwrap_or_else(|e| log::warn!("{}", e)); responses.extend([RenderDocument.into(), DocumentStructureChanged.into()]); } CommitTransaction => (), @@ -455,6 +481,10 @@ impl MessageHandler for DocumentMessageHand ) } SaveDocument => { + self.saved_document_identifier = self.current_identifier(); + // Update the save status of the just saved document + responses.push_back(DocumentsMessage::UpdateOpenDocumentsList.into()); + let name = match self.name.ends_with(FILE_SAVE_SUFFIX) { true => self.name.clone(), false => self.name.clone() + FILE_SAVE_SUFFIX, @@ -494,13 +524,13 @@ impl MessageHandler for DocumentMessageHand responses.push_back(DocumentMessage::SetSelectedLayers(vec![new_folder_path]).into()); } SetBlendModeForSelectedLayers(blend_mode) => { - self.backup(); + self.backup(responses); for path in self.layer_data.iter().filter_map(|(path, data)| data.selected.then(|| path.clone())) { responses.push_back(DocumentOperation::SetLayerBlendMode { path, blend_mode }.into()); } } SetOpacityForSelectedLayers(opacity) => { - self.backup(); + self.backup(responses); let opacity = opacity.clamp(0., 1.); for path in self.selected_layers().map(|path| path.to_vec()) { @@ -520,7 +550,7 @@ impl MessageHandler for DocumentMessageHand responses.push_back(ToolMessage::SelectedLayersChanged.into()); } DeleteSelectedLayers => { - self.backup(); + self.backup(responses); responses.push_front(ToolMessage::SelectedLayersChanged.into()); for path in self.selected_layers().map(|path| path.to_vec()) { responses.push_front(DocumentOperation::DeleteLayer { path }.into()); @@ -533,7 +563,7 @@ impl MessageHandler for DocumentMessageHand } } DuplicateSelectedLayers => { - self.backup(); + self.backup(responses); for path in self.selected_layers_sorted() { responses.push_back(DocumentOperation::DuplicateLayer { path }.into()); } @@ -564,8 +594,8 @@ impl MessageHandler for DocumentMessageHand responses.push_front(SetSelectedLayers(all_layer_paths).into()); } DeselectAllLayers => responses.push_front(SetSelectedLayers(vec![]).into()), - DocumentHistoryBackward => self.undo().unwrap_or_else(|e| log::warn!("{}", e)), - DocumentHistoryForward => self.redo().unwrap_or_else(|e| log::warn!("{}", e)), + DocumentHistoryBackward => self.undo(responses).unwrap_or_else(|e| log::warn!("{}", e)), + DocumentHistoryForward => self.redo(responses).unwrap_or_else(|e| log::warn!("{}", e)), Undo => { responses.push_back(SelectMessage::Abort.into()); responses.push_back(DocumentHistoryBackward.into()); @@ -666,7 +696,7 @@ impl MessageHandler for DocumentMessageHand } NudgeSelectedLayers(x, y) => { - self.backup(); + self.backup(responses); for path in self.selected_layers().map(|path| path.to_vec()) { let operation = DocumentOperation::TransformLayerInViewport { path, @@ -682,7 +712,7 @@ impl MessageHandler for DocumentMessageHand responses.push_back(DocumentsMessage::PasteIntoFolder { path, insert_index }.into()); } ReorderSelectedLayers(relative_position) => { - self.backup(); + self.backup(responses); let all_layer_paths = self.all_layers_sorted(); let selected_layers = self.selected_layers_sorted(); if let Some(pivot) = match relative_position.signum() { @@ -717,7 +747,7 @@ impl MessageHandler for DocumentMessageHand } } FlipSelectedLayers(axis) => { - self.backup(); + self.backup(responses); let scale = match axis { FlipAxis::X => DVec2::new(-1., 1.), FlipAxis::Y => DVec2::new(1., -1.), @@ -739,7 +769,7 @@ impl MessageHandler for DocumentMessageHand } } AlignSelectedLayers(axis, aggregate) => { - self.backup(); + self.backup(responses); let (paths, boxes): (Vec<_>, Vec<_>) = self .selected_layers() .filter_map(|path| self.graphene_document.viewport_bounding_box(path).ok()?.map(|b| (path, b))) diff --git a/editor/src/document/document_message_handler.rs b/editor/src/document/document_message_handler.rs index c90601a1..0fdf30a8 100644 --- a/editor/src/document/document_message_handler.rs +++ b/editor/src/document/document_message_handler.rs @@ -28,7 +28,7 @@ pub enum DocumentsMessage { NewDocument, OpenDocument, OpenDocumentFile(String, String), - GetOpenDocumentsList, + UpdateOpenDocumentsList, NextDocument, PrevDocument, } @@ -55,9 +55,7 @@ impl DocumentsMessageHandler { fn generate_new_document_name(&self) -> String { let mut doc_title_numbers = self - .document_ids - .iter() - .filter_map(|id| self.documents.get(&id)) + .ordered_document_iterator() .map(|doc| { doc.name .rsplit_once(DEFAULT_DOCUMENT_NAME) @@ -85,7 +83,11 @@ impl DocumentsMessageHandler { self.documents.insert(self.document_id_counter, new_document); // Send the new list of document tab names - let open_documents = self.document_ids.iter().filter_map(|id| self.documents.get(&id).map(|doc| doc.name.clone())).collect::>(); + let open_documents = self + .document_ids + .iter() + .filter_map(|id| self.documents.get(&id).map(|doc| (doc.name.clone(), doc.is_saved()))) + .collect::>(); responses.push_back(FrontendMessage::UpdateOpenDocumentsList { open_documents }.into()); @@ -96,6 +98,11 @@ impl DocumentsMessageHandler { responses.push_back(DocumentMessage::LayerChanged(layer.clone()).into()); } } + + // Returns an iterator over the open documents in order + pub fn ordered_document_iterator(&self) -> impl Iterator { + self.document_ids.iter().map(|id| self.documents.get(id).expect("document id was not found in the document hashmap")) + } } impl Default for DocumentsMessageHandler { @@ -170,7 +177,7 @@ impl MessageHandler for DocumentsMessageHa }; // Send the new list of document tab names - let open_documents = self.document_ids.iter().filter_map(|id| self.documents.get(&id).map(|doc| doc.name.clone())).collect(); + let open_documents = self.ordered_document_iterator().map(|doc| (doc.name.clone(), doc.is_saved())).collect(); // Update the list of new documents on the front end, active tab, and ensure that document renders responses.push_back(FrontendMessage::UpdateOpenDocumentsList { open_documents }.into()); @@ -209,9 +216,9 @@ impl MessageHandler for DocumentsMessageHa ), } } - GetOpenDocumentsList => { + UpdateOpenDocumentsList => { // Send the list of document tab names - let open_documents = self.documents.values().map(|doc| doc.name.clone()).collect(); + let open_documents = self.ordered_document_iterator().map(|doc| (doc.name.clone(), doc.is_saved())).collect(); responses.push_back(FrontendMessage::UpdateOpenDocumentsList { open_documents }.into()); } NextDocument => { diff --git a/editor/src/frontend/frontend_message_handler.rs b/editor/src/frontend/frontend_message_handler.rs index 80a8d17a..5d7aca16 100644 --- a/editor/src/frontend/frontend_message_handler.rs +++ b/editor/src/frontend/frontend_message_handler.rs @@ -10,7 +10,7 @@ pub enum FrontendMessage { DisplayFolderTreeStructure { data_buffer: RawBuffer }, SetActiveTool { tool_name: String, tool_options: Option }, SetActiveDocument { document_index: usize }, - UpdateOpenDocumentsList { open_documents: Vec }, + UpdateOpenDocumentsList { open_documents: Vec<(String, bool)> }, DisplayError { title: String, description: String }, DisplayPanic { panic_info: String, title: String, description: String }, DisplayConfirmationToCloseDocument { document_index: usize }, diff --git a/frontend/src/components/panels/LayerTree.vue b/frontend/src/components/panels/LayerTree.vue index 31cff45a..8a1b64a3 100644 --- a/frontend/src/components/panels/LayerTree.vue +++ b/frontend/src/components/panels/LayerTree.vue @@ -393,7 +393,6 @@ export default defineComponent({ registerResponseHandler(ResponseType.DisplayFolderTreeStructure, (responseData: Response) => { const expandData = responseData as DisplayFolderTreeStructure; if (!expandData) return; - console.log(expandData); const path = [] as Array; this.layers = [] as Array; diff --git a/frontend/src/components/window/title-bar/TitleBar.vue b/frontend/src/components/window/title-bar/TitleBar.vue index 9067d9c0..40bc480d 100644 --- a/frontend/src/components/window/title-bar/TitleBar.vue +++ b/frontend/src/components/window/title-bar/TitleBar.vue @@ -4,7 +4,7 @@
- +
diff --git a/frontend/src/utilities/documents.ts b/frontend/src/utilities/documents.ts index 65a76937..1cc5a3ee 100644 --- a/frontend/src/utilities/documents.ts +++ b/frontend/src/utilities/documents.ts @@ -17,10 +17,11 @@ import { panicProxy } from "@/utilities/panic-proxy"; const wasm = import("@/../wasm/pkg").then(panicProxy); const state = reactive({ - title: "", - unsaved: false, - documents: [] as Array, + documents: [] as string[], activeDocumentIndex: 0, + get activeDocument() { + return this.documents[this.activeDocumentIndex]; + }, }); export async function selectDocument(tabIndex: number) { @@ -83,17 +84,13 @@ export default readonly(state); registerResponseHandler(ResponseType.UpdateOpenDocumentsList, (responseData: Response) => { const documentListData = responseData as UpdateOpenDocumentsList; - if (documentListData) { - state.documents = documentListData.open_documents; - state.title = state.documents[state.activeDocumentIndex]; - } + state.documents = documentListData.open_documents.map(({ name, isSaved }) => `${name}${isSaved ? "" : "*"}`); }); registerResponseHandler(ResponseType.SetActiveDocument, (responseData: Response) => { const documentData = responseData as SetActiveDocument; if (documentData) { state.activeDocumentIndex = documentData.document_index; - state.title = state.documents[state.activeDocumentIndex]; } }); diff --git a/frontend/src/utilities/response-handler.ts b/frontend/src/utilities/response-handler.ts index f83656e4..558c9b4e 100644 --- a/frontend/src/utilities/response-handler.ts +++ b/frontend/src/utilities/response-handler.ts @@ -119,10 +119,11 @@ export type Response = | DisplayConfirmationToCloseAllDocuments; export interface UpdateOpenDocumentsList { - open_documents: Array; + open_documents: { name: string; isSaved: boolean }[]; } function newUpdateOpenDocumentsList(input: any): UpdateOpenDocumentsList { - return { open_documents: input.open_documents }; + const openDocuments = input.open_documents.map((docData: [string, boolean]) => ({ name: docData[0], isSaved: docData[1] })); + return { open_documents: openDocuments }; } export interface Color { diff --git a/frontend/wasm/src/api.rs b/frontend/wasm/src/api.rs index b246d805..79e93e7c 100644 --- a/frontend/wasm/src/api.rs +++ b/frontend/wasm/src/api.rs @@ -88,7 +88,7 @@ pub fn select_document(document: usize) { #[wasm_bindgen] pub fn get_open_documents_list() { - let message = DocumentsMessage::GetOpenDocumentsList; + let message = DocumentsMessage::UpdateOpenDocumentsList; dispatch(message); } diff --git a/graphene/src/document.rs b/graphene/src/document.rs index f4d34ddb..3d462e00 100644 --- a/graphene/src/document.rs +++ b/graphene/src/document.rs @@ -14,15 +14,17 @@ use crate::{ #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Document { pub root: Layer, + /// The state_identifier serves to provide a way to uniquely identify a particular state that the document is in. + /// This identifier is not a hash and is not guaranteed to be equal for equivalent documents. #[serde(skip)] - pub hasher: DefaultHasher, + pub state_identifier: DefaultHasher, } impl Default for Document { fn default() -> Self { Self { root: Layer::new(LayerDataType::Folder(Folder::default()), DAffine2::IDENTITY.to_cols_array()), - hasher: DefaultHasher::new(), + state_identifier: DefaultHasher::new(), } } } @@ -38,8 +40,8 @@ impl Document { self.root.cache.clone() } - pub fn hash(&self) -> u64 { - self.hasher.finish() + pub fn current_state_identifier(&self) -> u64 { + self.state_identifier.finish() } pub fn serialize_document(&self) -> String { @@ -309,7 +311,7 @@ impl Document { /// Mutate the document by applying the `operation` to it. If the operation necessitates a /// reaction from the frontend, responses may be returned. pub fn handle_operation(&mut self, operation: &Operation) -> Result>, DocumentError> { - operation.pseudo_hash().hash(&mut self.hasher); + operation.pseudo_hash().hash(&mut self.state_identifier); use DocumentResponse::*; let responses = match &operation {