From 2a29dd6546993514e8daab76c0ee6f85ff5a2d89 Mon Sep 17 00:00:00 2001
From: Igor Zinken <730069+igorski@users.noreply.github.com>
Date: Sat, 12 Apr 2025 11:52:52 +0200
Subject: [PATCH] Split menu into separate reusable sections, introduced
context menu actions to layer panel. Renamed tool-module to editor-module
---
src/bitmappery.vue | 6 +-
src/components/layer-panel/layer-panel.vue | 22 ++
.../menus/context-menu/context-menu.vue | 96 ++++++++
.../header-menu/header-menu.vue} | 224 +-----------------
.../header-menu}/messages.json | 11 -
.../menus/layer-menu/layer-menu.vue | 155 ++++++++++++
src/components/menus/layer-menu/messages.json | 15 ++
src/factories/brush-factory.ts | 2 +-
src/store/index.ts | 16 +-
.../{tool-module.ts => editor-module.ts} | 56 +++--
src/styles/ui.scss | 121 ++++++++++
tests/unit/mocks.ts | 4 +-
...l-module.spec.ts => editor-module.spec.ts} | 26 +-
13 files changed, 483 insertions(+), 271 deletions(-)
create mode 100644 src/components/menus/context-menu/context-menu.vue
rename src/components/{application-menu/application-menu.vue => menus/header-menu/header-menu.vue} (77%)
rename src/components/{application-menu => menus/header-menu}/messages.json (78%)
create mode 100644 src/components/menus/layer-menu/layer-menu.vue
create mode 100644 src/components/menus/layer-menu/messages.json
rename src/store/modules/{tool-module.ts => editor-module.ts} (66%)
rename tests/unit/store/modules/{tool-module.spec.ts => editor-module.spec.ts} (82%)
diff --git a/src/bitmappery.vue b/src/bitmappery.vue
index 102f9ff..a676334 100644
--- a/src/bitmappery.vue
+++ b/src/bitmappery.vue
@@ -22,7 +22,7 @@
*/
+
+
+
@@ -173,12 +182,19 @@ type IndexedLayer = Layer & { index: number, maskSelected: boolean };
export default {
i18n: { messages },
components: {
+ ContextMenu : defineAsyncComponent({ loader: () => import( "@/components/menus/context-menu/context-menu.vue" ) }),
Draggable : defineAsyncComponent({ loader: () => import( "vuedraggable" ) }),
LayerEffects : defineAsyncComponent({ loader: () => import( "@/components/layer-effects/layer-effects.vue" ) }),
+ LayerMenu : defineAsyncComponent({ loader: () => import( "@/components/menus/layer-menu/layer-menu.vue" ) }),
},
data: () => ({
editable: false,
showEffects: false,
+ contextMenu: {
+ show: false,
+ x: 0,
+ y: 0,
+ },
}),
computed: {
...mapState([
@@ -359,6 +375,12 @@ export default {
this.$refs.nameInput?.select();
}
},
+ showContextMenu( event: PointerEvent, layer: IndexedLayer ): void {
+ this.handleLayerClick( layer );
+ this.contextMenu.show = true;
+ this.contextMenu.x = event.clientX;
+ this.contextMenu.y = event.clientY;
+ },
},
};
diff --git a/src/components/menus/context-menu/context-menu.vue b/src/components/menus/context-menu/context-menu.vue
new file mode 100644
index 0000000..33a098e
--- /dev/null
+++ b/src/components/menus/context-menu/context-menu.vue
@@ -0,0 +1,96 @@
+/**
+ * The MIT License (MIT)
+ *
+ * Igor Zinken 2025 - https://www.igorski.nl
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+ * the Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/application-menu/application-menu.vue b/src/components/menus/header-menu/header-menu.vue
similarity index 77%
rename from src/components/application-menu/application-menu.vue
rename to src/components/menus/header-menu/header-menu.vue
index c666c05..e5a791b 100644
--- a/src/components/application-menu/application-menu.vue
+++ b/src/components/menus/header-menu/header-menu.vue
@@ -232,68 +232,10 @@
-
+ />
@@ -424,8 +366,8 @@
+
+
\ No newline at end of file
diff --git a/src/components/menus/layer-menu/messages.json b/src/components/menus/layer-menu/messages.json
new file mode 100644
index 0000000..6e63eb2
--- /dev/null
+++ b/src/components/menus/layer-menu/messages.json
@@ -0,0 +1,15 @@
+{
+ "en-US": {
+ "duplicateLayer": "Duplicate layer",
+ "commitEffects": "Commit effects and transforms",
+ "copyLayerFilters": "Copy layer filters",
+ "pasteLayerFilters": "Paste copied filters",
+ "disableLayerFilters": "Disable layer filters",
+ "enableLayerFilters": "Enable layer filters",
+ "toggleLayerFiltersTooltip": "(alt + F)",
+ "mergeDown": "Merge down",
+ "flattenImage": "Flatten image",
+ "mergedLayer": "Merged layer",
+ "filtersCopied": "Filters copied"
+ }
+}
diff --git a/src/factories/brush-factory.ts b/src/factories/brush-factory.ts
index 3c63845..522506e 100644
--- a/src/factories/brush-factory.ts
+++ b/src/factories/brush-factory.ts
@@ -39,7 +39,7 @@ const BrushFactory = {
radius,
colors,
pointers,
- options, // provided by tool-module
+ options, // provided by editor-module
halfRadius : radius * 0.5,
doubleRadius : radius * 2,
down : false
diff --git a/src/store/index.ts b/src/store/index.ts
index b9b6ff3..4921e25 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -39,12 +39,12 @@ import { copySelection, deleteSelectionContent } from "@/utils/document-util";
import { saveBlobAsFile, selectFile } from "@/utils/file-util";
import { replaceLayerSource } from "@/utils/layer-util";
import { truncate } from "@/utils/string-util";
-import canvas, { CanvasState } from "./modules/canvas-module";
-import document, { DocumentState } from "./modules/document-module";
-import history, { HistoryState } from "./modules/history-module";
-import image, { ImageState } from "./modules/image-module";
-import preferences, { PreferencesState } from "./modules/preferences-module";
-import tool, { ToolState } from "./modules/tool-module";
+import canvas, { type CanvasState } from "./modules/canvas-module";
+import document, { type DocumentState } from "./modules/document-module";
+import history, { type HistoryState } from "./modules/history-module";
+import image, { type ImageState } from "./modules/image-module";
+import preferences, { type PreferencesState } from "./modules/preferences-module";
+import editor, { type EditorState } from "./modules/editor-module";
export interface BitMapperyState {
menuOpened: boolean;
@@ -72,7 +72,7 @@ export interface BitMapperyState {
history: HistoryState;
image: ImageState;
preferences: PreferencesState;
- tool: ToolState;
+ editor: EditorState;
};
// cheat a little by exposing the vue-i18n translations directly to the
@@ -90,7 +90,7 @@ export default {
history,
image,
preferences,
- tool,
+ editor,
},
// @ts-expect-error sub module states are injected by Vuex on store creation
state: (): BitMapperyState => ({
diff --git a/src/store/modules/tool-module.ts b/src/store/modules/editor-module.ts
similarity index 66%
rename from src/store/modules/tool-module.ts
rename to src/store/modules/editor-module.ts
index ea52da1..e8af3c6 100644
--- a/src/store/modules/tool-module.ts
+++ b/src/store/modules/editor-module.ts
@@ -21,7 +21,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { Module } from "vuex";
-import type { Document } from "@/definitions/document";
+import type { Document, Filters } from "@/definitions/document";
import type {
ZoomToolOptions, BrushToolOptions, EraserToolOptions, CloneToolOptions,
SelectionToolOptions, FillToolOptions, WandToolOptions
@@ -30,7 +30,7 @@ import ToolTypes, { TOOL_SRC_MERGED } from "@/definitions/tool-types";
import BrushTypes from "@/definitions/brush-types";
import { runRendererFn } from "@/factories/renderer-factory";
-export interface ToolState {
+export interface EditorState {
activeTool: ToolTypes;
activeColor: string;
options: {
@@ -45,9 +45,10 @@ export interface ToolState {
snapAlign: boolean;
antiAlias: boolean;
pixelGrid: boolean;
+ clonedFilters: Filters | null;
};
-export const createToolState = ( props?: Partial ): ToolState => ({
+export const createEditorState = ( props?: Partial ): EditorState => ({
activeTool : null,
activeColor : "rgba(255,0,0,1)",
options : {
@@ -62,40 +63,42 @@ export const createToolState = ( props?: Partial ): ToolState => ({
snapAlign : true,
antiAlias : true,
pixelGrid : false,
+ clonedFilters: null,
...props,
});
-const ToolModule: Module = {
- state: (): ToolState => createToolState(),
+const EditorModule: Module = {
+ state: (): EditorState => createEditorState(),
getters: {
- activeTool : ( state: ToolState ): ToolTypes => state.activeTool,
- activeColor : ( state: ToolState ): string => state.activeColor,
+ activeTool : ( state: EditorState ): ToolTypes => state.activeTool,
+ activeColor : ( state: EditorState ): string => state.activeColor,
+ clonedFilters : ( state: EditorState ): Filters => state.clonedFilters,
// @ts-expect-error Element implicitly has an 'any' type because expression of type 'ToolTypes' can't be used to index type
- activeToolOptions : ( state: ToolState ): any => state.options[ state.activeTool ],
- selectionOptions : ( state: ToolState ): SelectionToolOptions => state.options[ ToolTypes.SELECTION ],
- zoomOptions : ( state: ToolState ): ZoomToolOptions => state.options[ ToolTypes.ZOOM ],
- brushOptions : ( state: ToolState ): BrushToolOptions => state.options[ ToolTypes.BRUSH ],
- eraserOptions : ( state: ToolState ): EraserToolOptions => state.options[ ToolTypes.ERASER ],
- cloneOptions : ( state: ToolState ): CloneToolOptions => state.options[ ToolTypes.CLONE ],
- fillOptions : ( state: ToolState ): FillToolOptions => state.options[ ToolTypes.FILL ],
- wandOptions : ( state: ToolState ): WandToolOptions => state.options[ ToolTypes.WAND ],
- snapAlign : ( state: ToolState ): boolean => state.snapAlign,
- antiAlias : ( state: ToolState ): boolean => state.antiAlias,
- pixelGrid : ( state: ToolState ): boolean => state.pixelGrid,
+ activeToolOptions : ( state: EditorState ): any => state.options[ state.activeTool ],
+ selectionOptions : ( state: EditorState ): SelectionToolOptions => state.options[ ToolTypes.SELECTION ],
+ zoomOptions : ( state: EditorState ): ZoomToolOptions => state.options[ ToolTypes.ZOOM ],
+ brushOptions : ( state: EditorState ): BrushToolOptions => state.options[ ToolTypes.BRUSH ],
+ eraserOptions : ( state: EditorState ): EraserToolOptions => state.options[ ToolTypes.ERASER ],
+ cloneOptions : ( state: EditorState ): CloneToolOptions => state.options[ ToolTypes.CLONE ],
+ fillOptions : ( state: EditorState ): FillToolOptions => state.options[ ToolTypes.FILL ],
+ wandOptions : ( state: EditorState ): WandToolOptions => state.options[ ToolTypes.WAND ],
+ snapAlign : ( state: EditorState ): boolean => state.snapAlign,
+ antiAlias : ( state: EditorState ): boolean => state.antiAlias,
+ pixelGrid : ( state: EditorState ): boolean => state.pixelGrid,
},
mutations: {
- setActiveTool( state: ToolState, { tool, document }: { tool: ToolTypes, document: Document }): void {
+ setActiveTool( state: EditorState, { tool, document }: { tool: ToolTypes, document: Document }): void {
state.activeTool = tool;
runRendererFn( renderer => {
// @ts-expect-error Element implicitly has an 'any' type because expression of type 'ToolTypes' can't be used to index type
renderer.handleActiveTool( tool, state.options[ state.activeTool ] as any, document );
});
},
- setActiveColor( state: ToolState, color: string ): void {
+ setActiveColor( state: EditorState, color: string ): void {
state.activeColor = color;
updateLayerRenderers( state.activeColor, state.options[ ToolTypes.BRUSH ] as BrushToolOptions );
},
- setToolOptionValue( state: ToolState, { tool, option, value }: { tool: ToolTypes, option: string, value: any }): void {
+ setToolOptionValue( state: EditorState, { tool, option, value }: { tool: ToolTypes, option: string, value: any }): void {
// @ts-expect-error Element implicitly has an 'any' type because expression of type 'ToolTypes' can't be used to index type
const toolOptions: any = state.options[ tool ];
toolOptions[ option ] = value;
@@ -111,18 +114,21 @@ const ToolModule: Module = {
break;
}
},
- setSnapAlign( state: ToolState, enabled: boolean ): void {
+ setSnapAlign( state: EditorState, enabled: boolean ): void {
state.snapAlign = enabled;
},
- setAntiAlias( state: ToolState, enabled: boolean ): void {
+ setAntiAlias( state: EditorState, enabled: boolean ): void {
state.antiAlias = enabled;
},
- setPixelGrid( state: ToolState, enabled: boolean ): void {
+ setPixelGrid( state: EditorState, enabled: boolean ): void {
state.pixelGrid = enabled;
},
+ setClonedFilters( state: EditorState, filters: Filters | null ): void {
+ state.clonedFilters = filters;
+ },
},
};
-export default ToolModule;
+export default EditorModule;
/* internal methods */
diff --git a/src/styles/ui.scss b/src/styles/ui.scss
index 5b44f4e..ca59aa1 100644
--- a/src/styles/ui.scss
+++ b/src/styles/ui.scss
@@ -170,3 +170,124 @@
margin: variables.$spacing-small;
}
}
+
+@mixin nestedMenu() {
+ .menu-list {
+ flex: 1;
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+ @include mixins.boxSize();
+
+ li {
+ display: inline-block;
+ padding: 0 variables.$spacing-medium 0 0;
+ margin: 0;
+ font-family: Montserrat, Helvetica, Verdana;
+ cursor: pointer;
+
+ a {
+ color: #b6b6b6;
+ text-decoration: none;
+ padding-bottom: variables.$spacing-large;
+ }
+
+ &:hover,
+ &:hover a {
+ color: colors.$color-1;
+ border-bottom: none;
+ text-decoration: none;
+ }
+
+ &.active {
+ a {
+ border-bottom: 3px solid #555;
+ }
+ }
+
+ button {
+ background: none;
+ cursor: pointer;
+ border: none;
+ color: #b6b6b6;
+ margin: 0;
+ padding: 0;
+ font-size: 95%;
+
+ &:disabled {
+ color: colors.$color-bg !important;
+ }
+
+ &.checked::before {
+ content: "\2713";
+ margin-right: variables.$spacing-small;
+ }
+
+ &:hover {
+ color: colors.$color-4;
+ }
+ }
+
+ ul {
+ list-style: none;
+ }
+
+ @include mixins.large() {
+ &:hover, &:focus {
+ a {
+ color: colors.$color-1;
+ }
+ ul {
+ display: block;
+ z-index: 2;
+ }
+ }
+ ul {
+ display: none;
+ position: absolute;
+ box-shadow: 0 0 5px rgba(0,0,0,.5);
+ padding: variables.$spacing-medium;
+ background-image: colors.$color-window-bg;
+ background-repeat: repeat-x;
+ @include mixins.boxSize();
+ }
+ }
+ }
+
+ @include mixins.mobile() {
+ position: absolute;
+ top: variables.$menu-height;
+ background-image: linear-gradient(to bottom,#fff 35%,#eee 90%);
+ background-repeat: repeat-x;
+ display: none;
+
+ .title {
+ padding: variables.$spacing-small variables.$spacing-medium;
+ }
+ }
+ }
+
+ .submenu {
+ @include mixins.large() {
+ li {
+ display: block;
+ color: #b6b6b6;
+ padding: variables.$spacing-xsmall variables.$spacing-small;
+
+ &:hover {
+ color: #FFF;
+ }
+ }
+ }
+
+ @include mixins.mobile() {
+ display: none;
+
+ &.opened {
+ display: block;
+ padding-left: variables.$spacing-medium;
+ background-image: colors.$color-window-bg;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/unit/mocks.ts b/tests/unit/mocks.ts
index 8006a8d..be583b9 100644
--- a/tests/unit/mocks.ts
+++ b/tests/unit/mocks.ts
@@ -9,7 +9,7 @@ import { createDocumentState } from "@/store/modules/document-module";
import { createHistoryState } from "@/store/modules/history-module";
import { createImageState } from "@/store/modules/image-module";
import { createPreferencesState } from "@/store/modules/preferences-module";
-import { createToolState } from "@/store/modules/tool-module";
+import { createEditorState } from "@/store/modules/editor-module";
// zCanvas mocks
// @todo should not be necessary when updating to zCanvas 6+
@@ -185,7 +185,7 @@ export function createState( props?: Partial ): BitMapperyState
history: createHistoryState(),
image: createImageState(),
preferences: createPreferencesState(),
- tool: createToolState(),
+ editor: createEditorState(),
...props,
};
}
diff --git a/tests/unit/store/modules/tool-module.spec.ts b/tests/unit/store/modules/editor-module.spec.ts
similarity index 82%
rename from tests/unit/store/modules/tool-module.spec.ts
rename to tests/unit/store/modules/editor-module.spec.ts
index 512d0de..638b1ba 100644
--- a/tests/unit/store/modules/tool-module.spec.ts
+++ b/tests/unit/store/modules/editor-module.spec.ts
@@ -1,14 +1,15 @@
import { it, describe, expect } from "vitest";
import { mockZCanvas } from "../../mocks";
-import storeModule, { createToolState } from "@/store/modules/tool-module";
import BrushTypes from "@/definitions/brush-types";
import ToolTypes, { TOOL_SRC_MERGED } from "@/definitions/tool-types";
+import FiltersFactory from "@/factories/filters-factory";
+import storeModule, { createEditorState } from "@/store/modules/editor-module";
const { getters, mutations } = storeModule;
mockZCanvas();
-describe( "Vuex tool module", () => {
+describe( "Vuex editor module", () => {
const BASE_OPTIONS = {
[ ToolTypes.ZOOM ] : { level: 1 },
[ ToolTypes.BRUSH ] : { size: 10, type: BrushTypes.LINE, opacity: 1, strokes: 1, thickness: .5 },
@@ -20,13 +21,15 @@ describe( "Vuex tool module", () => {
};
describe( "getters", () => {
- const state = createToolState({
+ const clonedFilters = FiltersFactory.create();
+ const state = createEditorState({
activeTool: ToolTypes.CLONE,
activeColor: "red",
options: { ...BASE_OPTIONS },
snapAlign: true,
antiAlias: true,
pixelGrid: false,
+ clonedFilters,
});
it( "should be able to return the active tool", () => {
@@ -70,21 +73,26 @@ describe( "Vuex tool module", () => {
});
it( "should be able to retrieve the current snap and alignment state", () => {
- expect( getters.snapAlign ( state, getters, {}, {} )).toBe( true );
+ expect( getters.snapAlign( state, getters, {}, {} )).toBe( true );
});
it( "should be able to retrieve the current anti-aliasing state", () => {
- expect( getters.antiAlias ( state, getters, {}, {} )).toBe( true );
+ expect( getters.antiAlias( state, getters, {}, {} )).toBe( true );
+ });
+
+ it( "should be able to retrieve the copied filters", () => {
+ expect( getters.clonedFilters( state, getters, {}, {} )).toEqual( clonedFilters );
});
});
describe( "mutations", () => {
- const state = createToolState({
+ const state = createEditorState({
activeTool: ToolTypes.ZOOM,
activeColor: "red",
options: { ...BASE_OPTIONS },
snapAlign: true,
antiAlias: true,
+ clonedFilters: null,
});
it( "should be able to set the active tool", () => {
@@ -114,5 +122,11 @@ describe( "Vuex tool module", () => {
mutations.setAntiAlias( state, false );
expect( state.antiAlias ).toBe( false );
});
+
+ it( "should be able to set the cloned filters", () => {
+ const filters = FiltersFactory.create({ opacity: 0.5 });
+ mutations.setClonedFilters( state, filters );
+ expect( state.clonedFilters ).toEqual( filters );
+ });
});
});