Implement closing the current, and all, documents from the menu bar (#265)

Closes #261
Additional cleanup and refactoring with the way the backend relays the list of open documents to the frontend and prompts for confirmation.
This commit is contained in:
Keavon Chambers 2021-07-14 16:13:58 -07:00
parent f7e5dd1a4f
commit ccea88dfd7
9 changed files with 148 additions and 75 deletions

View File

@ -83,8 +83,8 @@ const menuEntries: MenuListEntries = [
}, },
], ],
[ [
{ label: "Close", shortcut: ["Ctrl", "W"] }, { label: "Close", shortcut: ["Ctrl", "W"], action: async () => (await wasm).close_active_document_with_confirmation() },
{ label: "Close All", shortcut: ["Ctrl", "Alt", "W"] }, { label: "Close All", shortcut: ["Ctrl", "Alt", "W"], action: async () => (await wasm).close_all_documents_with_confirmation() },
], ],
[ [
{ label: "Save", shortcut: ["Ctrl", "S"] }, { label: "Save", shortcut: ["Ctrl", "S"] },
@ -154,6 +154,7 @@ export default defineComponent({
window.open("https://www.graphite.design", "_blank"); window.open("https://www.graphite.design", "_blank");
}, },
actionNotImplemented() { actionNotImplemented() {
// eslint-disable-next-line no-alert
alert("This action is not yet implemented"); alert("This action is not yet implemented");
}, },
}, },

View File

@ -7,11 +7,11 @@
:class="{ active: tabIndex === tabActiveIndex }" :class="{ active: tabIndex === tabActiveIndex }"
v-for="(tabLabel, tabIndex) in tabLabels" v-for="(tabLabel, tabIndex) in tabLabels"
:key="tabLabel" :key="tabLabel"
@click.middle="closeTab(tabIndex)" @click.middle="handleTabClose(tabIndex)"
@click="handleTabClick(tabIndex)" @click="handleTabClick(tabIndex)"
> >
<span>{{ tabLabel }}</span> <span>{{ tabLabel }}</span>
<IconButton :icon="'CloseX'" :size="16" v-if="tabCloseButtons" @click.stop="closeTab(tabIndex)" /> <IconButton :icon="'CloseX'" :size="16" v-if="tabCloseButtons" @click.stop="handleTabClose(tabIndex)" />
</div> </div>
</div> </div>
<PopoverButton :icon="PopoverButtonIcon.VerticalEllipsis"> <PopoverButton :icon="PopoverButtonIcon.VerticalEllipsis">
@ -150,7 +150,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 "../../utilities/response-handler"; import { ResponseType, registerResponseHandler, Response, PromptConfirmationToCloseDocument } from "../../utilities/response-handler";
const wasm = import("../../../wasm/pkg"); const wasm = import("../../../wasm/pkg");
@ -164,24 +164,37 @@ export default defineComponent({
PopoverButton, PopoverButton,
}, },
methods: { methods: {
async handleTabClick(tabIndex: number) { handleTabClick(tabIndex: number) {
if (this.panelType !== "Document") return; if (this.panelType === "Document") this.selectDocument(tabIndex);
},
handleTabClose(tabIndex: number) {
if (this.panelType === "Document") this.closeDocumentWithConfirmation(tabIndex);
},
async selectDocument(tabIndex: number) {
const { select_document } = await wasm; const { select_document } = await wasm;
select_document(tabIndex); select_document(tabIndex);
}, },
async closeTab(tabIndex: number) { async closeDocumentWithConfirmation(tabIndex: number) {
if (this.panelType !== "Document") return;
const { close_document } = await wasm;
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
const result = window.confirm("Closing this document will permanently discard all work. Continue?"); const userConfirmation = window.confirm("Closing this document will permanently discard all work. Continue?");
if (result) close_document(tabIndex); if (userConfirmation) (await wasm).close_document(tabIndex);
},
async closeAllDocumentsWithConfirmation() {
// eslint-disable-next-line no-alert
const userConfirmation = window.confirm("Closing all documents will permanently discard all work in each of them. Continue?");
if (userConfirmation) (await wasm).close_all_documents();
}, },
}, },
mounted() { mounted() {
registerResponseHandler(ResponseType.PromptCloseConfirmationModal, (_responseData: Response) => { // TODO: Move these somewhere more appropriate to act upon all panels
this.closeTab(this.tabActiveIndex);
registerResponseHandler(ResponseType.PromptConfirmationToCloseDocument, (responseData: Response) => {
const promptData = responseData as PromptConfirmationToCloseDocument;
this.closeDocumentWithConfirmation(promptData.document_index);
});
registerResponseHandler(ResponseType.PromptConfirmationToCloseAllDocuments, (_responseData: Response) => {
this.closeAllDocumentsWithConfirmation();
}); });
}, },
props: { props: {

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, CloseDocument } from "../../utilities/response-handler"; import { ResponseType, registerResponseHandler, Response, SetActiveDocument, UpdateOpenDocumentsList } from "../../utilities/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";
@ -59,15 +59,10 @@ export default defineComponent({
}, },
mounted() { mounted() {
registerResponseHandler(ResponseType.NewDocument, (responseData: Response) => { registerResponseHandler(ResponseType.UpdateOpenDocumentsList, (responseData: Response) => {
const documentData = responseData as NewDocument; const documentListData = responseData as UpdateOpenDocumentsList;
if (documentData) this.documents.push(documentData.document_name); if (documentListData) {
}); this.documents = documentListData.open_documents;
registerResponseHandler(ResponseType.CloseDocument, (responseData: Response) => {
const documentData = responseData as CloseDocument;
if (documentData) {
this.documents.splice(documentData.document_index, 1);
} }
}); });
@ -80,7 +75,7 @@ export default defineComponent({
data() { data() {
return { return {
activeDocument: 0, activeDocument: 0,
documents: ["Untitled Document"], documents: ["Untitled Document"], // TODO: start as an empty list
}; };
}, },
}); });

View File

@ -18,12 +18,12 @@ export enum ResponseType {
CollapseFolder = "CollapseFolder", CollapseFolder = "CollapseFolder",
SetActiveTool = "SetActiveTool", SetActiveTool = "SetActiveTool",
SetActiveDocument = "SetActiveDocument", SetActiveDocument = "SetActiveDocument",
NewDocument = "NewDocument", UpdateOpenDocumentsList = "UpdateOpenDocumentsList",
CloseDocument = "CloseDocument",
UpdateWorkingColors = "UpdateWorkingColors", UpdateWorkingColors = "UpdateWorkingColors",
PromptCloseConfirmationModal = "PromptCloseConfirmationModal",
SetCanvasZoom = "SetCanvasZoom", SetCanvasZoom = "SetCanvasZoom",
SetRotation = "SetRotation", SetRotation = "SetRotation",
PromptConfirmationToCloseDocument = "PromptConfirmationToCloseDocument",
PromptConfirmationToCloseAllDocuments = "PromptConfirmationToCloseAllDocuments",
} }
export function registerResponseHandler(responseType: ResponseType, callback: ResponseCallback) { export function registerResponseHandler(responseType: ResponseType, callback: ResponseCallback) {
@ -57,10 +57,8 @@ function parseResponse(responseType: string, data: any): Response {
return newSetActiveTool(data.SetActiveTool); return newSetActiveTool(data.SetActiveTool);
case "SetActiveDocument": case "SetActiveDocument":
return newSetActiveDocument(data.SetActiveDocument); return newSetActiveDocument(data.SetActiveDocument);
case "NewDocument": case "UpdateOpenDocumentsList":
return newNewDocument(data.NewDocument); return newUpdateOpenDocumentsList(data.UpdateOpenDocumentsList);
case "CloseDocument":
return newCloseDocument(data.CloseDocument);
case "UpdateCanvas": case "UpdateCanvas":
return newUpdateCanvas(data.UpdateCanvas); return newUpdateCanvas(data.UpdateCanvas);
case "SetCanvasZoom": case "SetCanvasZoom":
@ -71,8 +69,10 @@ function parseResponse(responseType: string, data: any): Response {
return newExportDocument(data.ExportDocument); return newExportDocument(data.ExportDocument);
case "UpdateWorkingColors": case "UpdateWorkingColors":
return newUpdateWorkingColors(data.UpdateWorkingColors); return newUpdateWorkingColors(data.UpdateWorkingColors);
case "PromptCloseConfirmationModal": case "PromptConfirmationToCloseDocument":
return {}; return newPromptConfirmationToCloseDocument(data.PromptConfirmationToCloseDocument);
case "PromptConfirmationToCloseAllDocuments":
return newPromptConfirmationToCloseAllDocuments(data.PromptConfirmationToCloseAllDocuments);
default: default:
throw new Error(`Unrecognized origin/responseType pair: ${origin}, '${responseType}'`); throw new Error(`Unrecognized origin/responseType pair: ${origin}, '${responseType}'`);
} }
@ -80,11 +80,11 @@ function parseResponse(responseType: string, data: any): Response {
export type Response = SetActiveTool | UpdateCanvas | DocumentChanged | CollapseFolder | ExpandFolder | UpdateWorkingColors | SetCanvasZoom | SetRotation; export type Response = SetActiveTool | UpdateCanvas | DocumentChanged | CollapseFolder | ExpandFolder | UpdateWorkingColors | SetCanvasZoom | SetRotation;
export interface CloseDocument { export interface UpdateOpenDocumentsList {
document_index: number; open_documents: Array<string>;
} }
function newCloseDocument(input: any): CloseDocument { function newUpdateOpenDocumentsList(input: any): UpdateOpenDocumentsList {
return { document_index: input.document_index }; return { open_documents: input.open_documents };
} }
export interface Color { export interface Color {
@ -127,15 +127,19 @@ function newSetActiveDocument(input: any): SetActiveDocument {
}; };
} }
export interface NewDocument { export interface PromptConfirmationToCloseDocument {
document_name: string; document_index: number;
} }
function newNewDocument(input: any): NewDocument { function newPromptConfirmationToCloseDocument(input: any): PromptConfirmationToCloseDocument {
return { return {
document_name: input.document_name, document_index: input.document_index,
}; };
} }
function newPromptConfirmationToCloseAllDocuments(_input: any): {} {
return {};
}
export interface UpdateCanvas { export interface UpdateCanvas {
document: string; document: string;
} }

View File

@ -28,14 +28,29 @@ 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 new_document() -> Result<(), JsValue> {
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::NewDocument).map_err(convert_error))
}
#[wasm_bindgen] #[wasm_bindgen]
pub fn close_document(document: usize) -> Result<(), JsValue> { pub fn close_document(document: usize) -> Result<(), JsValue> {
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::CloseDocument(document)).map_err(convert_error)) 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 close_all_documents() -> 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::CloseAllDocuments).map_err(convert_error))
}
#[wasm_bindgen]
pub fn close_active_document_with_confirmation() -> Result<(), JsValue> {
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::CloseActiveDocumentWithConfirmation).map_err(convert_error))
}
#[wasm_bindgen]
pub fn close_all_documents_with_confirmation() -> Result<(), JsValue> {
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::CloseAllDocumentsWithConfirmation).map_err(convert_error))
} }
// TODO: Call event when the panels are resized // TODO: Call event when the panels are resized

View File

@ -1,12 +1,12 @@
use crate::{consts::ROTATE_SNAP_INTERVAL, frontend::layer_panel::*, EditorError}; use crate::{consts::ROTATE_SNAP_INTERVAL, frontend::layer_panel::*, EditorError};
use document_core::{document::Document as InteralDocument, layers::Layer, LayerId}; use document_core::{document::Document as InternalDocument, layers::Layer, LayerId};
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Document { pub struct Document {
pub document: InteralDocument, pub document: InternalDocument,
pub name: String, pub name: String,
pub layer_data: HashMap<Vec<LayerId>, LayerData>, pub layer_data: HashMap<Vec<LayerId>, LayerData>,
} }
@ -14,7 +14,7 @@ pub struct Document {
impl Default for Document { impl Default for Document {
fn default() -> Self { fn default() -> Self {
Self { Self {
document: InteralDocument::default(), document: InternalDocument::default(),
name: String::from("Untitled Document"), name: String::from("Untitled Document"),
layer_data: vec![(vec![], LayerData::new(true))].into_iter().collect(), layer_data: vec![(vec![], LayerData::new(true))].into_iter().collect(),
} }
@ -24,7 +24,7 @@ impl Default for Document {
impl Document { impl Document {
pub fn with_name(name: String) -> Self { pub fn with_name(name: String) -> Self {
Self { Self {
document: InteralDocument::default(), document: InternalDocument::default(),
name, name,
layer_data: vec![(vec![], LayerData::new(true))].into_iter().collect(), layer_data: vec![(vec![], LayerData::new(true))].into_iter().collect(),
} }

View File

@ -31,7 +31,9 @@ pub enum DocumentMessage {
ToggleLayerExpansion(Vec<LayerId>), ToggleLayerExpansion(Vec<LayerId>),
SelectDocument(usize), SelectDocument(usize),
CloseDocument(usize), CloseDocument(usize),
CloseActiveDocument, CloseActiveDocumentWithConfirmation,
CloseAllDocumentsWithConfirmation,
CloseAllDocuments,
NewDocument, NewDocument,
NextDocument, NextDocument,
PrevDocument, PrevDocument,
@ -187,14 +189,27 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
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(RenderDocument.into()); responses.push_back(RenderDocument.into());
} }
CloseActiveDocument => { CloseActiveDocumentWithConfirmation => {
responses.push_back(FrontendMessage::PromptCloseConfirmationModal.into()); responses.push_back(FrontendMessage::PromptConfirmationToCloseDocument { document_index: self.active_document }.into());
}
CloseAllDocumentsWithConfirmation => {
responses.push_back(FrontendMessage::PromptConfirmationToCloseAllDocuments.into());
}
CloseAllDocuments => {
// Empty the list of internal document data
self.documents.clear();
// Create a new blank document
responses.push_back(DocumentMessage::NewDocument.into());
} }
CloseDocument(id) => { CloseDocument(id) => {
assert!(id < self.documents.len(), "Tried to select a document that was not initialized"); 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. // Remove doc from the backend store; use `id` as client tabs and backend documents will be in sync
self.documents.remove(id); self.documents.remove(id);
responses.push_back(FrontendMessage::CloseDocument { document_index: id }.into());
// Send the new list of document tab names
let open_documents = self.documents.iter().map(|doc| doc.name.clone()).collect();
responses.push_back(FrontendMessage::UpdateOpenDocumentsList { open_documents }.into());
// Last tab was closed, so create a new blank tab // Last tab was closed, so create a new blank tab
if self.documents.is_empty() { if self.documents.is_empty() {
@ -254,12 +269,10 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
self.active_document = self.documents.len(); self.active_document = self.documents.len();
let new_document = Document::with_name(name); let new_document = Document::with_name(name);
self.documents.push(new_document); self.documents.push(new_document);
responses.push_back(
FrontendMessage::NewDocument { // Send the new list of document tab names
document_name: self.active_document().name.clone(), let open_documents = self.documents.iter().map(|doc| doc.name.clone()).collect();
} responses.push_back(FrontendMessage::UpdateOpenDocumentsList { open_documents }.into());
.into(),
);
responses.push_back( responses.push_back(
FrontendMessage::ExpandFolder { FrontendMessage::ExpandFolder {
@ -280,7 +293,7 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
} }
ExportDocument => responses.push_back( ExportDocument => responses.push_back(
FrontendMessage::ExportDocument { FrontendMessage::ExportDocument {
//TODO: Add canvas size instead of using 1080p per default //TODO: Add canvas size instead of using 1920x1080 by default
document: format!( document: format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080">{}{}</svg>"#, r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080">{}{}</svg>"#,
"\n", "\n",
@ -513,14 +526,45 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
} }
} }
fn actions(&self) -> ActionList { fn actions(&self) -> ActionList {
let mut common = actions!(DocumentMessageDiscriminant; Undo, SelectAllLayers, DeselectAllLayers, RenderDocument, ExportDocument, NewDocument, CloseActiveDocument, NextDocument, PrevDocument, MouseMove, TranslateCanvasEnd, TranslateCanvasBegin, PasteLayers, RotateCanvasBegin, ZoomCanvasBegin, SetCanvasZoom, MultiplyCanvasZoom, SetRotation, WheelCanvasZoom, WheelCanvasTranslate); let mut common = actions!(DocumentMessageDiscriminant;
Undo,
SelectAllLayers,
DeselectAllLayers,
RenderDocument,
ExportDocument,
NewDocument,
CloseActiveDocumentWithConfirmation,
CloseAllDocumentsWithConfirmation,
CloseAllDocuments,
NextDocument,
PrevDocument,
MouseMove,
TranslateCanvasEnd,
TranslateCanvasBegin,
PasteLayers,
RotateCanvasBegin,
ZoomCanvasBegin,
SetCanvasZoom,
MultiplyCanvasZoom,
SetRotation,
WheelCanvasZoom,
WheelCanvasTranslate,
);
if self.active_document().layer_data.values().any(|data| data.selected) { if self.active_document().layer_data.values().any(|data| data.selected) {
let select = actions!(DocumentMessageDiscriminant; DeleteSelectedLayers, DuplicateSelectedLayers, CopySelectedLayers, NudgeSelectedLayers ); let select = actions!(DocumentMessageDiscriminant;
DeleteSelectedLayers,
DuplicateSelectedLayers,
CopySelectedLayers,
NudgeSelectedLayers,
);
common.extend(select); common.extend(select);
} }
if self.rotating { if self.rotating {
let snapping = actions!(DocumentMessageDiscriminant; EnableSnapping, DisableSnapping); let snapping = actions!(DocumentMessageDiscriminant;
EnableSnapping,
DisableSnapping,
);
common.extend(snapping); common.extend(snapping);
} }
common common

View File

@ -12,14 +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 }, UpdateOpenDocumentsList { open_documents: Vec<String> },
NewDocument { document_name: String }, PromptConfirmationToCloseDocument { document_index: usize },
PromptConfirmationToCloseAllDocuments,
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,
SetCanvasZoom { new_zoom: f64 }, SetCanvasZoom { new_zoom: f64 },
SetRotation { new_radians: f64 }, SetRotation { new_radians: f64 },
} }
@ -44,7 +44,6 @@ impl MessageHandler<FrontendMessage, ()> for FrontendMessageHandler {
CollapseFolder, CollapseFolder,
ExpandFolder, ExpandFolder,
SetActiveTool, SetActiveTool,
NewDocument,
UpdateCanvas, UpdateCanvas,
EnableTextInput, EnableTextInput,
DisableTextInput, DisableTextInput,

View File

@ -204,9 +204,11 @@ impl Default for Mapping {
entry! {action=DocumentMessage::WheelCanvasZoom, message=InputMapperMessage::MouseScroll, modifiers=[KeyControl]}, entry! {action=DocumentMessage::WheelCanvasZoom, message=InputMapperMessage::MouseScroll, modifiers=[KeyControl]},
entry! {action=DocumentMessage::WheelCanvasTranslate{use_y_as_x: true}, message=InputMapperMessage::MouseScroll, modifiers=[KeyShift]}, entry! {action=DocumentMessage::WheelCanvasTranslate{use_y_as_x: true}, message=InputMapperMessage::MouseScroll, modifiers=[KeyShift]},
entry! {action=DocumentMessage::WheelCanvasTranslate{use_y_as_x: false}, message=InputMapperMessage::MouseScroll}, entry! {action=DocumentMessage::WheelCanvasTranslate{use_y_as_x: false}, message=InputMapperMessage::MouseScroll},
entry! {action=DocumentMessage::NewDocument, key_down=KeyN, modifiers=[KeyShift]}, entry! {action=DocumentMessage::NewDocument, key_down=KeyN, modifiers=[KeyControl]},
entry! {action=DocumentMessage::NextDocument, key_down=KeyTab, modifiers=[KeyShift]}, entry! {action=DocumentMessage::NextDocument, key_down=KeyTab, modifiers=[KeyControl]},
entry! {action=DocumentMessage::CloseActiveDocument, key_down=KeyW, modifiers=[KeyShift]}, entry! {action=DocumentMessage::PrevDocument, key_down=KeyTab, modifiers=[KeyControl, KeyShift]},
entry! {action=DocumentMessage::CloseAllDocumentsWithConfirmation, key_down=KeyW, modifiers=[KeyControl, KeyAlt]}, // TODO: Fix this, it's matching the one below
entry! {action=DocumentMessage::CloseActiveDocumentWithConfirmation, key_down=KeyW, modifiers=[KeyControl]},
entry! {action=DocumentMessage::DuplicateSelectedLayers, key_down=KeyD, modifiers=[KeyControl]}, entry! {action=DocumentMessage::DuplicateSelectedLayers, key_down=KeyD, modifiers=[KeyControl]},
entry! {action=DocumentMessage::CopySelectedLayers, key_down=KeyC, modifiers=[KeyControl]}, entry! {action=DocumentMessage::CopySelectedLayers, key_down=KeyC, modifiers=[KeyControl]},
entry! {action=DocumentMessage::NudgeSelectedLayers(-SHIFT_NUDGE_AMOUNT, -SHIFT_NUDGE_AMOUNT), key_down=KeyArrowUp, modifiers=[KeyShift, KeyArrowLeft]}, entry! {action=DocumentMessage::NudgeSelectedLayers(-SHIFT_NUDGE_AMOUNT, -SHIFT_NUDGE_AMOUNT), key_down=KeyArrowUp, modifiers=[KeyShift, KeyArrowLeft]},