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 ); + }); }); });