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:
parent
a3d96f1dae
commit
b2b8c75e9e
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue