Split menu into separate reusable sections, introduced context menu actions to layer panel. Renamed tool-module to editor-module

This commit is contained in:
Igor Zinken
2025-04-12 11:52:52 +02:00
parent bca9aa9145
commit 2a29dd6546
13 changed files with 483 additions and 271 deletions

View File

@@ -22,7 +22,7 @@
*/
<template>
<div id="app" ref="app">
<application-menu />
<header-menu />
<section class="main">
<toolbox
ref="toolbox"
@@ -75,7 +75,7 @@
import { type Component, defineAsyncComponent } from "vue";
import { mapState, mapGetters, mapMutations, mapActions } from "vuex";
import { createI18n } from "vue-i18n";
import ApplicationMenu from "@/components/application-menu/application-menu.vue";
import HeaderMenu from "@/components/menus/header-menu/header-menu.vue";
import ToolOptionsPanel from "@/components/tool-options-panel/tool-options-panel.vue";
import LayerPanel from "@/components/layer-panel/layer-panel.vue";
import Toolbox from "@/components/toolbox/toolbox.vue";
@@ -127,7 +127,7 @@ function asyncComponent( key: string, importFn: () => Promise<any> ): IAsyncComp
export default {
mixins: [ ImageToDocumentManager ],
components: {
ApplicationMenu,
HeaderMenu,
DialogWindow,
Loader,
Notifications,

View File

@@ -64,6 +64,7 @@
:class="{
'layer--active': element.index === activeLayerIndex
}"
@contextmenu.stop.prevent="showContextMenu( $event, element )"
>
<!-- layer name is an input on double click -->
<input
@@ -143,6 +144,14 @@
@click="requestMaskAdd()"
></button>
</div>
<context-menu
v-if="contextMenu.show"
@close="contextMenu.show = false"
:x="contextMenu.x"
:y="contextMenu.y"
>
<layer-menu :opened="true" />
</context-menu>
</template>
</div>
</template>
@@ -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;
},
},
};
</script>

View File

@@ -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.
*/
<template>
<div
class="context-menu menu-list"
:style="position"
>
<slot></slot>
</div>
</template>
<script lang="ts">
const MENU_WIDTH = 300; // see style
const MENU_HEIGHT = 205;
export default {
emits: [ "close" ],
props: {
x: {
type: Number,
required: true,
},
y: {
type: Number,
required: true,
},
},
computed: {
position(): { left: string, top: string } {
return {
left : `${this.x - ( MENU_WIDTH / 2 )}px`,
top : `${this.y - ( MENU_HEIGHT / 2 )}px`
};
},
},
mounted(): void {
this.closeHandler = this.handleClose.bind( this );
document.addEventListener( "click", this.closeHandler );
document.addEventListener( "contextmenu", this.closeHandler );
},
beforeUnmount(): void {
document.removeEventListener( "click", this.closeHandler );
document.removeEventListener( "contextmenu", this.closeHandler );
},
methods: {
handleClose(): void {
this.$emit( "close" );
},
},
}
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins";
@use "@/styles/colors";
@use "@/styles/ui";
.context-menu {
position: fixed;
background-image: colors.$color-window-bg;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
min-width: 300px;
@include ui.nestedMenu();
}
.context-menu div {
padding: 10px;
cursor: pointer;
&:hover {
background-color: #f0f0f0;
}
}
</style>

View File

@@ -232,68 +232,10 @@
<!-- layer menu -->
<li>
<a v-t="'layer'" class="title" @click.prevent="openSubMenu('layer')"></a>
<ul class="submenu"
:class="{ opened: activeSubMenu === 'layer' }"
<layer-menu
:opened="activeSubMenu === 'layer'"
@click="close()"
>
<li>
<button
v-t="'duplicateLayer'"
type="button"
:disabled="!activeLayer"
@click="requestDuplicateLayer()"
></button>
</li>
<li>
<button
v-t="'commitEffects'"
type="button"
:disabled="!activeLayerCanBeCommitted"
@click="commitLayerEffects()"
></button>
</li>
<li>
<button
v-t="'copyLayerFilters'"
type="button"
:disabled="!activeLayer"
@click="copyLayerFilters()"
></button>
</li>
<li>
<button
v-t="'pasteLayerFilters'"
type="button"
:disabled="!activeLayer || !clonedFilters"
@click="requestPasteLayerFilters()"
></button>
</li>
<li>
<button
v-t="activeLayerHasFiltersEnabled ? 'disableLayerFilters' : 'enableLayerFilters'"
v-tooltip.right="$t('toggleLayerFiltersTooltip')"
type="button"
:disabled="!activeLayer"
@click="requestToggleLayerFilters()"
></button>
</li>
<li>
<button
v-t="'mergeDown'"
type="button"
:disabled="!activeLayer || activeLayerIndex === 0"
@click="requestMergeLayerDown()"
></button>
</li>
<li>
<button
v-t="'flattenImage'"
type="button"
:disabled="!activeLayer || activeDocument.layers.length < 2"
@click="requestMergeLayerDown( true )"
></button>
</li>
</ul>
/>
</li>
<!-- selection menu -->
<li>
@@ -424,8 +366,8 @@
</template>
<script lang="ts">
import { defineAsyncComponent } from "vue";
import { mapState, mapGetters, mapMutations, mapActions } from "vuex";
import { LayerTypes } from "@/definitions/layer-types";
import { MAX_SPRITESHEET_WIDTH } from "@/definitions/editor-properties";
import {
CREATE_DOCUMENT, RESIZE_DOCUMENT, SAVE_DOCUMENT, EXPORT_WINDOW, LOAD_SELECTION, SAVE_SELECTION,
@@ -433,26 +375,21 @@ import {
} from "@/definitions/modal-windows";
import CloudServiceConnector from "@/mixins/cloud-service-connector";
import ImageToDocumentManager from "@/mixins/image-to-document-manager";
import { hasFilters } from "@/factories/filters-factory";
import { getCanvasInstance } from "@/services/canvas-service";
import { cropToSelection } from "@/store/actions/crop-to-selection";
import { commitLayerEffectsAndTransforms } from "@/store/actions/layer-commit-effects-and-transforms";
import { duplicateLayer } from "@/store/actions/layer-duplicate";
import { mergeLayerDown } from "@/store/actions/layer-merge-down";
import { pasteLayerFilters } from "@/store/actions/layer-paste-filters";
import { toggleLayerFilters } from "@/store/actions/layer-toggle-filters";
import { supportsFullscreen, setToggleButton } from "@/utils/environment-util";
import { supportsDropbox, supportsGoogleDrive, supportsS3 } from "@/utils/cloud-service-loader";
import { hasTransform } from "@/utils/layer-util";
import sharedMessages from "@/messages.json"; // for CloudServiceConnector
import messages from "./messages.json";
export default {
i18n: { messages, sharedMessages },
mixins: [ CloudServiceConnector, ImageToDocumentManager ],
components: {
LayerMenu : defineAsyncComponent({ loader: () => import( "@/components/menus/layer-menu/layer-menu.vue" ) }),
},
data: () => ({
activeSubMenu: null, // used for mobile views collapsed / expanded view
clonedFilters: null,
isFullscreen: false,
hasDropbox: supportsDropbox(),
hasDrive: supportsGoogleDrive(),
@@ -487,12 +424,6 @@ export default {
hasClipboard(): boolean {
return !!this.selectionContent;
},
activeLayerHasFiltersEnabled(): boolean {
return this.activeLayer?.filters?.enabled;
},
activeLayerCanBeCommitted(): boolean {
return !!this.activeLayer && ( hasTransform( this.activeLayer ) || hasFilters( this.activeLayer.filters )) && this.activeLayer.type !== LayerTypes.LAYER_TEXT;
},
canSnapAndAlign: {
get(): boolean {
return this.snapAlign;
@@ -554,7 +485,6 @@ export default {
methods: {
...mapMutations([
"setMenuOpened",
"showNotification",
"openModal",
"setActiveDocument",
"setActiveDocumentSize",
@@ -619,25 +549,6 @@ export default {
navigateHistory( action = "undo" ): void {
this.$store.dispatch( action );
},
requestDuplicateLayer(): void {
duplicateLayer( this.$store, this.activeLayer, this.activeLayerIndex + 1 );
},
commitLayerEffects(): void {
commitLayerEffectsAndTransforms( this.$store, this.activeDocument, this.activeLayer, this.activeLayerIndex );
},
async requestMergeLayerDown( allLayers = false ): Promise<void> {
await mergeLayerDown( this.$store, this.activeDocument, this.activeLayer, this.activeLayerIndex, this.$t( "mergedLayer" ), allLayers );
},
copyLayerFilters(): void {
this.clonedFilters = { ...this.activeLayer.filters };
this.showNotification({ message: this.$t( "filtersCopied" ) });
},
requestPasteLayerFilters(): void {
pasteLayerFilters( this.$store, this.clonedFilters, this.activeLayer, this.activeLayerIndex );
},
requestToggleLayerFilters(): void {
toggleLayerFilters( this.$store, this.activeLayer, this.activeLayerIndex );
},
selectAll(): void {
getCanvasInstance()?.interactionPane.selectAll();
},
@@ -656,6 +567,7 @@ export default {
@use "@/styles/_mixins";
@use "@/styles/_variables";
@use "@/styles/typography";
@use "@/styles/ui";
$toggle-width: 50px;
@@ -679,6 +591,7 @@ $toggle-width: 50px;
height: variables.$menu-height;
background-image: colors.$color-window-bg;
@include mixins.boxSize();
@include ui.nestedMenu();
@include mixins.large() {
min-width: 100%;
@@ -798,125 +711,6 @@ h1 {
}
}
.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 {
display: none;
&.opened {
display: block;
padding-left: variables.$spacing-medium;
background-image: colors.$color-window-bg;
}
}
}
}
.submenu {
@include mixins.large() {
li {
display: block;
color: #b6b6b6;
padding: variables.$spacing-xsmall variables.$spacing-medium;
&:hover {
color: #FFF;
}
}
}
}
.file-selector {
display: none;
}

View File

@@ -25,17 +25,6 @@
"canvasSize": "Canvas size",
"sliceGridToLayers": "Slice grid to layers",
"layer": "Layer",
"duplicateLayer": "Duplicate layer",
"commitEffects": "Commit effects and transforms",
"mergeDown": "Merge down",
"flattenImage": "Flatten image",
"mergedLayer": "Merged layer",
"copyLayerFilters": "Copy layer filters",
"pasteLayerFilters": "Paste copied filters",
"filtersCopied": "Filters copied",
"disableLayerFilters": "Disable layer filters",
"enableLayerFilters": "Enable layer filters",
"toggleLayerFiltersTooltip": "(alt + F)",
"selection": "Selection",
"deselectAll": "Deselect all",
"deselectAllTooltip": "(alt + D)",

View File

@@ -0,0 +1,155 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2020-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.
*/
<template>
<ul
class="submenu"
:class="{ opened: opened }"
>
<li>
<button
v-t="'duplicateLayer'"
type="button"
:disabled="!activeLayer"
@click="requestDuplicateLayer()"
></button>
</li>
<li>
<button
v-t="'commitEffects'"
type="button"
:disabled="!activeLayerCanBeCommitted"
@click="commitLayerEffects()"
></button>
</li>
<li>
<button
v-t="'copyLayerFilters'"
type="button"
:disabled="!activeLayer"
@click="copyLayerFilters()"
></button>
</li>
<li>
<button
v-t="'pasteLayerFilters'"
type="button"
:disabled="!activeLayer || !clonedFilters"
@click="requestPasteLayerFilters()"
></button>
</li>
<li>
<button
v-t="activeLayerHasFiltersEnabled ? 'disableLayerFilters' : 'enableLayerFilters'"
v-tooltip.right="$t('toggleLayerFiltersTooltip')"
type="button"
:disabled="!activeLayer"
@click="requestToggleLayerFilters()"
></button>
</li>
<li>
<button
v-t="'mergeDown'"
type="button"
:disabled="!activeLayer || activeLayerIndex === 0"
@click="requestMergeLayerDown()"
></button>
</li>
<li>
<button
v-t="'flattenImage'"
type="button"
:disabled="!activeLayer || activeDocument.layers.length < 2"
@click="requestMergeLayerDown( true )"
></button>
</li>
</ul>
</template>
<script lang="ts">
import { mapGetters, mapMutations } from "vuex";
import { LayerTypes } from "@/definitions/layer-types";
import { hasFilters } from "@/factories/filters-factory";
import { commitLayerEffectsAndTransforms } from "@/store/actions/layer-commit-effects-and-transforms";
import { duplicateLayer } from "@/store/actions/layer-duplicate";
import { mergeLayerDown } from "@/store/actions/layer-merge-down";
import { pasteLayerFilters } from "@/store/actions/layer-paste-filters";
import { toggleLayerFilters } from "@/store/actions/layer-toggle-filters";
import { hasTransform } from "@/utils/layer-util";
import messages from "./messages.json";
export default {
i18n: { messages },
props: {
opened: {
type: Boolean,
default: true,
},
},
computed: {
...mapGetters([
"activeDocument",
"activeLayer",
"activeLayerIndex",
"clonedFilters",
]),
activeLayerCanBeCommitted(): boolean {
return !!this.activeLayer && ( hasTransform( this.activeLayer ) || hasFilters( this.activeLayer.filters )) && this.activeLayer.type !== LayerTypes.LAYER_TEXT;
},
activeLayerHasFiltersEnabled(): boolean {
return this.activeLayer?.filters?.enabled;
},
},
methods: {
...mapMutations([
"setClonedFilters",
"showNotification",
]),
requestDuplicateLayer(): void {
duplicateLayer( this.$store, this.activeLayer, this.activeLayerIndex + 1 );
},
commitLayerEffects(): void {
commitLayerEffectsAndTransforms( this.$store, this.activeDocument, this.activeLayer, this.activeLayerIndex );
},
async requestMergeLayerDown( allLayers = false ): Promise<void> {
await mergeLayerDown( this.$store, this.activeDocument, this.activeLayer, this.activeLayerIndex, this.$t( "mergedLayer" ), allLayers );
},
copyLayerFilters(): void {
this.setClonedFilters({ ...this.activeLayer.filters });
this.showNotification({ message: this.$t( "filtersCopied" ) });
},
requestPasteLayerFilters(): void {
pasteLayerFilters( this.$store, this.clonedFilters, this.activeLayer, this.activeLayerIndex );
},
requestToggleLayerFilters(): void {
toggleLayerFilters( this.$store, this.activeLayer, this.activeLayerIndex );
},
},
}
</script>
<style lang="scss" scoped>
@use "@/styles/ui";
@include ui.nestedMenu();
</style>

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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 => ({

View File

@@ -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> ): ToolState => ({
export const createEditorState = ( props?: Partial<EditorState> ): EditorState => ({
activeTool : null,
activeColor : "rgba(255,0,0,1)",
options : {
@@ -62,40 +63,42 @@ export const createToolState = ( props?: Partial<ToolState> ): ToolState => ({
snapAlign : true,
antiAlias : true,
pixelGrid : false,
clonedFilters: null,
...props,
});
const ToolModule: Module<ToolState, any> = {
state: (): ToolState => createToolState(),
const EditorModule: Module<EditorState, any> = {
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<ToolState, any> = {
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 */

View File

@@ -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;
}
}
}
}

View File

@@ -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> ): BitMapperyState
history: createHistoryState(),
image: createImageState(),
preferences: createPreferencesState(),
tool: createToolState(),
editor: createEditorState(),
...props,
};
}

View File

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