Add support for closing document tabs (#215)

* Can only remove last document successfully

* Correctly update the layer tree panel

* Remove comments

* Add support for randomly closing docs

* Create new doc after closing last doc

* Update layer panel when creating new docs

* Fix bug that crashed the program when first doc was closed

* Refactor to make code simpler and increase readability

* Add shortcut to close active doc (Shift + C)

* Add a confirmation dialog box before closing tabs

* New docs get the correct title

* Remove comments and fix typos

* Disable 'eslint-no-alert'

* Refactor and fix document title bug

* Rename the FrontendMessage and ReponseType for showing close confirmation modal

* Change the message displayed in the close confirmation modal

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
akshay1992kalbhor 2021-06-22 14:28:02 +05:30 committed by Keavon Chambers
parent a3d96f1dae
commit b2b8c75e9e
7 changed files with 118 additions and 5 deletions

View File

@ -4,7 +4,7 @@
<div class="tab-group"> <div class="tab-group">
<div class="tab" :class="{ active: tabIndex === tabActiveIndex }" v-for="(tabLabel, tabIndex) in tabLabels" :key="tabLabel" @click="handleTabClick(tabIndex)"> <div class="tab" :class="{ active: tabIndex === tabActiveIndex }" v-for="(tabLabel, tabIndex) in tabLabels" :key="tabLabel" @click="handleTabClick(tabIndex)">
<span>{{ tabLabel }}</span> <span>{{ tabLabel }}</span>
<IconButton :icon="'CloseX'" :size="16" v-if="tabCloseButtons" /> <IconButton :icon="'CloseX'" :size="16" v-if="tabCloseButtons" @click.stop="closeTab(tabIndex)" />
</div> </div>
</div> </div>
<PopoverButton :icon="PopoverButtonIcon.VerticalEllipsis"> <PopoverButton :icon="PopoverButtonIcon.VerticalEllipsis">
@ -143,6 +143,7 @@ import Minimap from "../panels/Minimap.vue";
import IconButton from "../widgets/buttons/IconButton.vue"; import IconButton from "../widgets/buttons/IconButton.vue";
import PopoverButton, { PopoverButtonIcon } from "../widgets/buttons/PopoverButton.vue"; import PopoverButton, { PopoverButtonIcon } from "../widgets/buttons/PopoverButton.vue";
import { MenuDirection } from "../widgets/floating-menus/FloatingMenu.vue"; import { MenuDirection } from "../widgets/floating-menus/FloatingMenu.vue";
import { ResponseType, registerResponseHandler, Response } from "../../response-handler";
const wasm = import("../../../wasm/pkg"); const wasm = import("../../../wasm/pkg");
@ -160,6 +161,17 @@ export default defineComponent({
const { select_document } = await wasm; const { select_document } = await wasm;
select_document(tabIndex); select_document(tabIndex);
}, },
async closeTab(tabIndex: number) {
const { close_document } = await wasm;
// eslint-disable-next-line no-alert
const result = window.confirm("Closing this document will permanently discard all work. Continue?");
if (result) close_document(tabIndex);
},
},
mounted() {
registerResponseHandler(ResponseType.PromptCloseConfirmationModal, (_responseData: Response) => {
this.closeTab(this.tabActiveIndex);
});
}, },
props: { props: {
tabMinWidths: { type: Boolean, default: false }, tabMinWidths: { type: Boolean, default: false },

View File

@ -46,7 +46,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { ResponseType, registerResponseHandler, Response, SetActiveDocument, NewDocument } from "../../response-handler"; import { ResponseType, registerResponseHandler, Response, SetActiveDocument, NewDocument, CloseDocument } from "../../response-handler";
import LayoutRow from "../layout/LayoutRow.vue"; import LayoutRow from "../layout/LayoutRow.vue";
import LayoutCol from "../layout/LayoutCol.vue"; import LayoutCol from "../layout/LayoutCol.vue";
import Panel from "./Panel.vue"; import Panel from "./Panel.vue";
@ -64,6 +64,13 @@ export default defineComponent({
if (documentData) this.documents.push(documentData.document_name); if (documentData) this.documents.push(documentData.document_name);
}); });
registerResponseHandler(ResponseType.CloseDocument, (responseData: Response) => {
const documentData = responseData as CloseDocument;
if (documentData) {
this.documents.splice(documentData.document_index, 1);
}
});
registerResponseHandler(ResponseType.SetActiveDocument, (responseData: Response) => { registerResponseHandler(ResponseType.SetActiveDocument, (responseData: Response) => {
const documentData = responseData as SetActiveDocument; const documentData = responseData as SetActiveDocument;
if (documentData) this.activeDocument = documentData.document_index; if (documentData) this.activeDocument = documentData.document_index;

View File

@ -18,7 +18,9 @@ export enum ResponseType {
SetActiveTool = "SetActiveTool", SetActiveTool = "SetActiveTool",
SetActiveDocument = "SetActiveDocument", SetActiveDocument = "SetActiveDocument",
NewDocument = "NewDocument", NewDocument = "NewDocument",
CloseDocument = "CloseDocument",
UpdateWorkingColors = "UpdateWorkingColors", UpdateWorkingColors = "UpdateWorkingColors",
PromptCloseConfirmationModal = "PromptCloseConfirmationModal",
} }
export function attachResponseHandlerToPage() { export function attachResponseHandlerToPage() {
@ -58,12 +60,16 @@ function parseResponse(responseType: string, data: any): Response {
return newSetActiveDocument(data.SetActiveDocument); return newSetActiveDocument(data.SetActiveDocument);
case "NewDocument": case "NewDocument":
return newNewDocument(data.NewDocument); return newNewDocument(data.NewDocument);
case "CloseDocument":
return newCloseDocument(data.CloseDocument);
case "UpdateCanvas": case "UpdateCanvas":
return newUpdateCanvas(data.UpdateCanvas); return newUpdateCanvas(data.UpdateCanvas);
case "ExportDocument": case "ExportDocument":
return newExportDocument(data.ExportDocument); return newExportDocument(data.ExportDocument);
case "UpdateWorkingColors": case "UpdateWorkingColors":
return newUpdateWorkingColors(data.UpdateWorkingColors); return newUpdateWorkingColors(data.UpdateWorkingColors);
case "PromptCloseConfirmationModal":
return {};
default: default:
throw new Error(`Unrecognized origin/responseType pair: ${origin}, ${responseType}`); throw new Error(`Unrecognized origin/responseType pair: ${origin}, ${responseType}`);
} }
@ -71,6 +77,13 @@ function parseResponse(responseType: string, data: any): Response {
export type Response = SetActiveTool | UpdateCanvas | DocumentChanged | CollapseFolder | ExpandFolder | UpdateWorkingColors; export type Response = SetActiveTool | UpdateCanvas | DocumentChanged | CollapseFolder | ExpandFolder | UpdateWorkingColors;
export interface CloseDocument {
document_index: number;
}
function newCloseDocument(input: any): CloseDocument {
return { document_index: input.document_index };
}
export interface Color { export interface Color {
red: number; red: number;
green: number; green: number;

View File

@ -26,6 +26,11 @@ pub fn select_document(document: usize) -> Result<(), JsValue> {
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::SelectDocument(document)).map_err(convert_error)) EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::SelectDocument(document)).map_err(convert_error))
} }
#[wasm_bindgen]
pub fn close_document(document: usize) -> Result<(), JsValue> {
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::CloseDocument(document)).map_err(convert_error))
}
#[wasm_bindgen] #[wasm_bindgen]
pub fn new_document() -> Result<(), JsValue> { pub fn new_document() -> Result<(), JsValue> {
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::NewDocument).map_err(convert_error)) EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::NewDocument).map_err(convert_error))

View File

@ -17,6 +17,8 @@ pub enum DocumentMessage {
ToggleLayerVisibility(Vec<LayerId>), ToggleLayerVisibility(Vec<LayerId>),
ToggleLayerExpansion(Vec<LayerId>), ToggleLayerExpansion(Vec<LayerId>),
SelectDocument(usize), SelectDocument(usize),
CloseDocument(usize),
CloseActiveDocument,
NewDocument, NewDocument,
NextDocument, NextDocument,
PrevDocument, PrevDocument,
@ -97,9 +99,72 @@ impl MessageHandler<DocumentMessage, ()> for DocumentMessageHandler {
.into(), .into(),
); );
} }
CloseActiveDocument => {
responses.push_back(FrontendMessage::PromptCloseConfirmationModal.into());
}
CloseDocument(id) => {
assert!(id < self.documents.len(), "Tried to select a document that was not initialized");
// Remove doc from the backend store. Use 'id' as FE tabs and BE documents will be in sync.
self.documents.remove(id);
responses.push_back(FrontendMessage::CloseDocument { document_index: id }.into());
// Last tab was closed, so create a new blank tab
if self.documents.is_empty() {
self.active_document = 0;
responses.push_back(DocumentMessage::NewDocument.into());
}
// The currently selected doc is being closed
else if id == self.active_document {
// The currently selected tab was the rightmost tab
if id == self.documents.len() {
self.active_document -= 1;
}
let lp = self.active_document_mut().layer_panel(&[]).expect("Could not get panel for active doc");
responses.push_back(FrontendMessage::ExpandFolder { path: Vec::new(), children: lp }.into());
responses.push_back(FrontendMessage::SetActiveDocument { document_index: self.active_document }.into());
responses.push_back(
FrontendMessage::UpdateCanvas {
document: self.active_document_mut().document.render_root(),
}
.into(),
);
}
// Active doc will move one space to the left
else if id < self.active_document {
self.active_document -= 1;
responses.push_back(FrontendMessage::SetActiveDocument { document_index: self.active_document }.into());
}
}
NewDocument => { NewDocument => {
let digits = ('0'..='9').collect::<Vec<char>>();
let mut doc_title_numbers = self
.documents
.iter()
.map(|d| {
if d.name.ends_with(digits.as_slice()) {
let (_, number) = d.name.split_at(17);
number.trim().parse::<usize>().unwrap()
} else {
1
}
})
.collect::<Vec<usize>>();
doc_title_numbers.sort();
let mut new_doc_title_num = 1;
while new_doc_title_num <= self.documents.len() {
if new_doc_title_num != doc_title_numbers[new_doc_title_num - 1] {
break;
}
new_doc_title_num += 1;
}
let name = match new_doc_title_num {
1 => "Untitled Document".to_string(),
_ => format!("Untitled Document {}", new_doc_title_num),
};
self.active_document = self.documents.len(); self.active_document = self.documents.len();
let new_document = Document::with_name(format!("Untitled Document {}", self.active_document + 1)); let new_document = Document::with_name(name);
self.documents.push(new_document); self.documents.push(new_document);
responses.push_back( responses.push_back(
FrontendMessage::NewDocument { FrontendMessage::NewDocument {
@ -107,6 +172,14 @@ impl MessageHandler<DocumentMessage, ()> for DocumentMessageHandler {
} }
.into(), .into(),
); );
responses.push_back(
FrontendMessage::ExpandFolder {
path: Vec::new(),
children: Vec::new(),
}
.into(),
);
responses.push_back(FrontendMessage::SetActiveDocument { document_index: self.active_document }.into()); responses.push_back(FrontendMessage::SetActiveDocument { document_index: self.active_document }.into());
responses.push_back( responses.push_back(
FrontendMessage::UpdateCanvas { FrontendMessage::UpdateCanvas {
@ -214,9 +287,9 @@ impl MessageHandler<DocumentMessage, ()> for DocumentMessageHandler {
} }
fn actions(&self) -> ActionList { fn actions(&self) -> ActionList {
if self.active_document().layer_data.values().any(|data| data.selected) { if self.active_document().layer_data.values().any(|data| data.selected) {
actions!(DocumentMessageDiscriminant; Undo, DeleteSelectedLayers, DuplicateSelectedLayers, RenderDocument, ExportDocument, NewDocument, NextDocument, PrevDocument) actions!(DocumentMessageDiscriminant; Undo, DeleteSelectedLayers, DuplicateSelectedLayers, RenderDocument, ExportDocument, NewDocument, CloseActiveDocument, NextDocument, PrevDocument)
} else { } else {
actions!(DocumentMessageDiscriminant; Undo, RenderDocument, ExportDocument, NewDocument, NextDocument, PrevDocument) actions!(DocumentMessageDiscriminant; Undo, RenderDocument, ExportDocument, NewDocument, CloseActiveDocument, NextDocument, PrevDocument)
} }
} }
} }

View File

@ -12,12 +12,14 @@ pub enum FrontendMessage {
ExpandFolder { path: Vec<LayerId>, children: Vec<LayerPanelEntry> }, ExpandFolder { path: Vec<LayerId>, children: Vec<LayerPanelEntry> },
SetActiveTool { tool_name: String }, SetActiveTool { tool_name: String },
SetActiveDocument { document_index: usize }, SetActiveDocument { document_index: usize },
CloseDocument { document_index: usize },
NewDocument { document_name: String }, NewDocument { document_name: String },
UpdateCanvas { document: String }, UpdateCanvas { document: String },
ExportDocument { document: String }, ExportDocument { document: String },
EnableTextInput, EnableTextInput,
DisableTextInput, DisableTextInput,
UpdateWorkingColors { primary: Color, secondary: Color }, UpdateWorkingColors { primary: Color, secondary: Color },
PromptCloseConfirmationModal,
} }
pub struct FrontendMessageHandler { pub struct FrontendMessageHandler {

View File

@ -170,6 +170,7 @@ impl Default for Mapping {
entry! {action=DocumentMessage::ExportDocument, key_down=KeyE, modifiers=[KeyControl]}, entry! {action=DocumentMessage::ExportDocument, key_down=KeyE, modifiers=[KeyControl]},
entry! {action=DocumentMessage::NewDocument, key_down=KeyN, modifiers=[KeyShift]}, entry! {action=DocumentMessage::NewDocument, key_down=KeyN, modifiers=[KeyShift]},
entry! {action=DocumentMessage::NextDocument, key_down=KeyTab, modifiers=[KeyShift]}, entry! {action=DocumentMessage::NextDocument, key_down=KeyTab, modifiers=[KeyShift]},
entry! {action=DocumentMessage::CloseActiveDocument, key_down=KeyW, modifiers=[KeyShift]},
// Global Actions // Global Actions
entry! {action=GlobalMessage::LogInfo, key_down=Key1}, entry! {action=GlobalMessage::LogInfo, key_down=Key1},
entry! {action=GlobalMessage::LogDebug, key_down=Key2}, entry! {action=GlobalMessage::LogDebug, key_down=Key2},