parent
3d646d2bc3
commit
d1bf68320e
|
|
@ -28,6 +28,7 @@ module.exports = {
|
||||||
"eol-last": ["error", "always"],
|
"eol-last": ["error", "always"],
|
||||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
|
"no-param-reassign": ["error", { props: false }],
|
||||||
"max-len": ["error", { code: 200, tabWidth: 4 }],
|
"max-len": ["error", { code: 200, tabWidth: 4 }],
|
||||||
"@typescript-eslint/camelcase": "off",
|
"@typescript-eslint/camelcase": "off",
|
||||||
"@typescript-eslint/no-use-before-define": "off",
|
"@typescript-eslint/no-use-before-define": "off",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
|
<path d="M7,0L2,5v11h12V0H7z M13,15H3V6h5V1h5V15z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 123 B |
|
|
@ -27,6 +27,7 @@
|
||||||
// See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color() and https://caniuse.com/css-color-function
|
// See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color() and https://caniuse.com/css-color-function
|
||||||
// E6 = 90% alpha
|
// E6 = 90% alpha
|
||||||
--floating-menu-opacity-color-2-mildblack: #222222e6;
|
--floating-menu-opacity-color-2-mildblack: #222222e6;
|
||||||
|
--floating-menu-shadow: rgba(0, 0, 0, 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="separator" :class="[direction.toLowerCase(), type.toLowerCase()]">
|
<div class="separator" :class="[direction.toLowerCase(), type.toLowerCase()]">
|
||||||
<div v-if="type === SeparatorType.Section"></div>
|
<div v-if="[SeparatorType.Section, SeparatorType.List].includes(type)"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -15,9 +15,9 @@
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.section {
|
&.section,
|
||||||
|
&.list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 8px 0;
|
|
||||||
|
|
||||||
div {
|
div {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
|
|
@ -26,6 +26,14 @@
|
||||||
background: var(--color-7-middlegray);
|
background: var(--color-7-middlegray);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.section {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.list {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.horizontal {
|
&.horizontal {
|
||||||
|
|
@ -37,9 +45,9 @@
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.section {
|
&.section,
|
||||||
|
&.list {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0 8px;
|
|
||||||
|
|
||||||
div {
|
div {
|
||||||
height: calc(100% - 8px);
|
height: calc(100% - 8px);
|
||||||
|
|
@ -48,6 +56,14 @@
|
||||||
background: var(--color-7-middlegray);
|
background: var(--color-7-middlegray);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.section {
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.list {
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -64,6 +80,7 @@ export enum SeparatorType {
|
||||||
"Related" = "Related",
|
"Related" = "Related",
|
||||||
"Unrelated" = "Unrelated",
|
"Unrelated" = "Unrelated",
|
||||||
"Section" = "Section",
|
"Section" = "Section",
|
||||||
|
"List" = "List",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
<div class="working-colors">
|
<div class="working-colors">
|
||||||
<SwatchPairInput />
|
<SwatchPairInput />
|
||||||
<div class="swap-and-reset">
|
<div class="swap-and-reset">
|
||||||
<IconButton :icon="'SwapButton'" :size="16" />
|
<IconButton @click="swapColors" :icon="'SwapButton'" :size="16" />
|
||||||
<IconButton :icon="'ResetColorsButton'" :size="16" />
|
<IconButton @click="resetColors" :icon="'ResetColorsButton'" :size="16" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -21,7 +21,19 @@ import { defineComponent } from "vue";
|
||||||
import SwatchPairInput from "./inputs/SwatchPairInput.vue";
|
import SwatchPairInput from "./inputs/SwatchPairInput.vue";
|
||||||
import IconButton from "./buttons/IconButton.vue";
|
import IconButton from "./buttons/IconButton.vue";
|
||||||
|
|
||||||
|
const wasm = import("../../../wasm/pkg");
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
methods: {
|
||||||
|
async swapColors() {
|
||||||
|
const { swap_colors } = await wasm;
|
||||||
|
swap_colors();
|
||||||
|
},
|
||||||
|
async resetColors() {
|
||||||
|
const { reset_colors } = await wasm;
|
||||||
|
reset_colors();
|
||||||
|
},
|
||||||
|
},
|
||||||
components: {
|
components: {
|
||||||
SwatchPairInput,
|
SwatchPairInput,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="popover-button">
|
<div class="popover-button">
|
||||||
<IconButton :icon="icon" :size="16" @click="clickButton" />
|
<IconButton :icon="icon" :size="16" @click="clickButton" data-hover-menu-spawner />
|
||||||
<FloatingMenu :type="MenuType.Popover" :direction="MenuDirection.Bottom" ref="floatingMenu">
|
<FloatingMenu :type="MenuType.Popover" :direction="MenuDirection.Bottom" ref="floatingMenu">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</FloatingMenu>
|
</FloatingMenu>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" v-if="open">
|
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" v-if="open" ref="floatingMenu">
|
||||||
<div class="tail" v-if="type === MenuType.Popover"></div>
|
<div class="tail" v-if="type === MenuType.Popover"></div>
|
||||||
<div class="floating-menu-container" ref="floatingMenuContainer">
|
<div class="floating-menu-container" ref="floatingMenuContainer">
|
||||||
<div class="floating-menu-content" ref="floatingMenuContent">
|
<div class="floating-menu-content" ref="floatingMenuContent">
|
||||||
|
|
@ -18,7 +18,11 @@
|
||||||
// Floating menus begin at a z-index of 1000
|
// Floating menus begin at a z-index of 1000
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
--floating-menu-content-offset: 0;
|
--floating-menu-content-offset: 0;
|
||||||
--floating-menu-content-border-radius: 0 0 4px 4px;
|
--floating-menu-content-border-radius: 4px;
|
||||||
|
|
||||||
|
&.bottom {
|
||||||
|
--floating-menu-content-border-radius: 0 0 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.tail {
|
.tail {
|
||||||
width: 0;
|
width: 0;
|
||||||
|
|
@ -35,7 +39,7 @@
|
||||||
|
|
||||||
.floating-menu-content {
|
.floating-menu-content {
|
||||||
background: var(--floating-menu-opacity-color-2-mildblack);
|
background: var(--floating-menu-opacity-color-2-mildblack);
|
||||||
box-shadow: var(--color-0-black) 0 0 4px;
|
box-shadow: var(--floating-menu-shadow) 0 2px 4px;
|
||||||
border-radius: var(--floating-menu-content-border-radius);
|
border-radius: var(--floating-menu-content-border-radius);
|
||||||
color: var(--color-e-nearwhite);
|
color: var(--color-e-nearwhite);
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
|
@ -48,6 +52,61 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.dropdown {
|
||||||
|
&.top {
|
||||||
|
width: 100%;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bottom {
|
||||||
|
width: 100%;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.topleft {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.topright {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.topleft {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
margin-bottom: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.topright {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-bottom: -4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.top.dropdown .floating-menu-container,
|
||||||
|
&.bottom.dropdown .floating-menu-container {
|
||||||
|
justify-content: left;
|
||||||
|
}
|
||||||
|
|
||||||
&.popover {
|
&.popover {
|
||||||
--floating-menu-content-offset: 10px;
|
--floating-menu-content-offset: 10px;
|
||||||
--floating-menu-content-border-radius: 4px;
|
--floating-menu-content-border-radius: 4px;
|
||||||
|
|
@ -116,6 +175,10 @@ export enum MenuDirection {
|
||||||
Bottom = "Bottom",
|
Bottom = "Bottom",
|
||||||
Left = "Left",
|
Left = "Left",
|
||||||
Right = "Right",
|
Right = "Right",
|
||||||
|
TopLeft = "TopLeft",
|
||||||
|
TopRight = "TopRight",
|
||||||
|
BottomLeft = "BottomLeft",
|
||||||
|
BottomRight = "BottomRight",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MenuType {
|
export enum MenuType {
|
||||||
|
|
@ -128,6 +191,7 @@ export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
direction: { type: String, default: MenuDirection.Bottom },
|
direction: { type: String, default: MenuDirection.Bottom },
|
||||||
type: { type: String, required: true },
|
type: { type: String, required: true },
|
||||||
|
windowEdgeMargin: { type: Number, default: 8 },
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -147,18 +211,18 @@ export default defineComponent({
|
||||||
const floatingMenuBounds = floatingMenuContent.getBoundingClientRect();
|
const floatingMenuBounds = floatingMenuContent.getBoundingClientRect();
|
||||||
|
|
||||||
if (this.direction === MenuDirection.Left || this.direction === MenuDirection.Right) {
|
if (this.direction === MenuDirection.Left || this.direction === MenuDirection.Right) {
|
||||||
const topOffset = floatingMenuBounds.top - workspaceBounds.top - 8;
|
const topOffset = floatingMenuBounds.top - workspaceBounds.top - this.windowEdgeMargin;
|
||||||
if (topOffset < 0) floatingMenuContainer.style.transform = `translate(0, ${-topOffset}px)`;
|
if (topOffset < 0) floatingMenuContainer.style.transform = `translate(0, ${-topOffset}px)`;
|
||||||
|
|
||||||
const bottomOffset = workspaceBounds.bottom - floatingMenuBounds.bottom - 8;
|
const bottomOffset = workspaceBounds.bottom - floatingMenuBounds.bottom - this.windowEdgeMargin;
|
||||||
if (bottomOffset < 0) floatingMenuContainer.style.transform = `translate(0, ${bottomOffset}px)`;
|
if (bottomOffset < 0) floatingMenuContainer.style.transform = `translate(0, ${bottomOffset}px)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.direction === MenuDirection.Top || this.direction === MenuDirection.Bottom) {
|
if (this.direction === MenuDirection.Top || this.direction === MenuDirection.Bottom) {
|
||||||
const leftOffset = floatingMenuBounds.left - workspaceBounds.left - 8;
|
const leftOffset = floatingMenuBounds.left - workspaceBounds.left - this.windowEdgeMargin;
|
||||||
if (leftOffset < 0) floatingMenuContainer.style.transform = `translate(${-leftOffset}px, 0)`;
|
if (leftOffset < 0) floatingMenuContainer.style.transform = `translate(${-leftOffset}px, 0)`;
|
||||||
|
|
||||||
const rightOffset = workspaceBounds.right - floatingMenuBounds.right - 8;
|
const rightOffset = workspaceBounds.right - floatingMenuBounds.right - this.windowEdgeMargin;
|
||||||
if (rightOffset < 0) floatingMenuContainer.style.transform = `translate(${rightOffset}px, 0)`;
|
if (rightOffset < 0) floatingMenuContainer.style.transform = `translate(${rightOffset}px, 0)`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -170,11 +234,28 @@ export default defineComponent({
|
||||||
setClosed() {
|
setClosed() {
|
||||||
this.open = false;
|
this.open = false;
|
||||||
},
|
},
|
||||||
|
isOpen(): boolean {
|
||||||
|
return this.open;
|
||||||
|
},
|
||||||
mouseMoveHandler(e: MouseEvent) {
|
mouseMoveHandler(e: MouseEvent) {
|
||||||
const MOUSE_STRAY_DISTANCE = 100;
|
const MOUSE_STRAY_DISTANCE = 100;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const mouseOverFloatingMenuKeepOpen = target && (target.closest("[data-hover-menu-keep-open]") as HTMLElement);
|
||||||
|
const mouseOverFloatingMenuSpawner = target && (target.closest("[data-hover-menu-spawner]") as HTMLElement);
|
||||||
|
// TODO: Simplify the following expression when optional chaining is supported by the build system
|
||||||
|
const mouseOverOwnFloatingMenuSpawner =
|
||||||
|
mouseOverFloatingMenuSpawner && mouseOverFloatingMenuSpawner.parentElement && mouseOverFloatingMenuSpawner.parentElement.contains(this.$refs.floatingMenu as HTMLElement);
|
||||||
|
|
||||||
|
// Swap this open floating menu with the one created by the floating menu spawner being hovered over
|
||||||
|
if (mouseOverFloatingMenuSpawner && !mouseOverOwnFloatingMenuSpawner) {
|
||||||
|
this.setClosed();
|
||||||
|
mouseOverFloatingMenuSpawner.click();
|
||||||
|
}
|
||||||
|
|
||||||
// Close the floating menu if the mouse has strayed far enough from its bounds
|
// Close the floating menu if the mouse has strayed far enough from its bounds
|
||||||
if (this.isMouseEventOutsideFloatingMenu(e, MOUSE_STRAY_DISTANCE)) {
|
if (this.isMouseEventOutsideFloatingMenu(e, MOUSE_STRAY_DISTANCE) && !mouseOverOwnFloatingMenuSpawner && !mouseOverFloatingMenuKeepOpen) {
|
||||||
|
// TODO: Extend this rectangle bounds check to all `data-hover-menu-keep-open` element bounds up the DOM tree since currently
|
||||||
|
// submenus disappear with zero stray distance if the cursor is further than the stray distance from only the top-level menu
|
||||||
this.setClosed();
|
this.setClosed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
<template>
|
||||||
|
<FloatingMenu :class="'menu-list'" :direction="direction" :type="MenuType.Dropdown" ref="floatingMenu" :windowEdgeMargin="0" data-hover-menu-keep-open>
|
||||||
|
<template v-for="(section, sectionIndex) in menuEntries" :key="sectionIndex">
|
||||||
|
<Separator :type="SeparatorType.List" :direction="SeparatorDirection.Vertical" v-if="sectionIndex > 0" />
|
||||||
|
<div
|
||||||
|
v-for="(entry, entryIndex) in section"
|
||||||
|
:key="entryIndex"
|
||||||
|
class="row"
|
||||||
|
:class="{ open: isMenuEntryOpen(entry) }"
|
||||||
|
@click="handleEntryClick(entry)"
|
||||||
|
@mouseenter="handleEntryMouseEnter(entry)"
|
||||||
|
@mouseleave="handleEntryMouseLeave(entry)"
|
||||||
|
:data-hover-menu-spawner-extend="entry.children && []"
|
||||||
|
>
|
||||||
|
<Icon :icon="entry.icon" v-if="entry.icon" />
|
||||||
|
<div class="no-icon" v-else />
|
||||||
|
<span class="label">{{ entry.label }}</span>
|
||||||
|
<UserInputLabel v-if="entry.shortcut && entry.shortcut.length" :inputKeys="[entry.shortcut]" />
|
||||||
|
<div class="submenu-arrow" v-if="entry.children && entry.children.length"></div>
|
||||||
|
<div class="no-submenu-arrow" v-else></div>
|
||||||
|
<MenuList v-if="entry.children" :menuEntries="entry.children" :direction="MenuDirection.TopRight" :ref="(ref) => setEntryRefs(entry, ref)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FloatingMenu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.menu-list {
|
||||||
|
.floating-menu-container .floating-menu-content {
|
||||||
|
min-width: 240px;
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
.row {
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon svg {
|
||||||
|
fill: var(--color-e-nearwhite);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-icon {
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon,
|
||||||
|
.no-icon,
|
||||||
|
.label {
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-input-label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-arrow {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 3px 0 3px 6px;
|
||||||
|
border-color: transparent transparent transparent var(--color-e-nearwhite);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-submenu-arrow {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-arrow,
|
||||||
|
.no-submenu-arrow {
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.open {
|
||||||
|
background: var(--color-6-lowergray);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-f-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--color-f-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType } from "vue";
|
||||||
|
import FloatingMenu, { MenuDirection, MenuType } from "./FloatingMenu.vue";
|
||||||
|
import Separator, { SeparatorDirection, SeparatorType } from "../Separator.vue";
|
||||||
|
import Icon from "../labels/Icon.vue";
|
||||||
|
import UserInputLabel from "../labels/UserInputLabel.vue";
|
||||||
|
|
||||||
|
export type MenuListEntries = Array<MenuListEntry>;
|
||||||
|
|
||||||
|
export interface MenuListEntry {
|
||||||
|
label?: string;
|
||||||
|
icon?: string;
|
||||||
|
// TODO: Add `checkbox` (which overrides any `icon`)
|
||||||
|
shortcut?: Array<string>;
|
||||||
|
action?: Function;
|
||||||
|
children?: Array<Array<MenuListEntry>>;
|
||||||
|
ref?: typeof FloatingMenu | typeof MenuList;
|
||||||
|
}
|
||||||
|
const MenuList = defineComponent({
|
||||||
|
props: {
|
||||||
|
direction: { type: String as PropType<MenuDirection>, value: MenuDirection.Bottom },
|
||||||
|
menuEntries: { type: Array as PropType<MenuListEntries>, required: true },
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setEntryRefs(menuEntry: MenuListEntry, ref: typeof FloatingMenu) {
|
||||||
|
if (ref) menuEntry.ref = ref;
|
||||||
|
},
|
||||||
|
handleEntryClick(menuEntry: MenuListEntry) {
|
||||||
|
if (menuEntry.action) menuEntry.action();
|
||||||
|
else alert("This action is not yet implemented");
|
||||||
|
},
|
||||||
|
handleEntryMouseEnter(menuEntry: MenuListEntry) {
|
||||||
|
if (!menuEntry.children || !menuEntry.children.length) return;
|
||||||
|
|
||||||
|
if (menuEntry.ref) {
|
||||||
|
menuEntry.ref.setOpen();
|
||||||
|
} else throw new Error("The menu bar floating menu has no associated ref");
|
||||||
|
},
|
||||||
|
handleEntryMouseLeave(menuEntry: MenuListEntry) {
|
||||||
|
if (!menuEntry.children || !menuEntry.children.length) return;
|
||||||
|
|
||||||
|
if (menuEntry.ref) {
|
||||||
|
menuEntry.ref.setClosed();
|
||||||
|
} else throw new Error("The menu bar floating menu has no associated ref");
|
||||||
|
},
|
||||||
|
isMenuEntryOpen(menuEntry: MenuListEntry): boolean {
|
||||||
|
if (!menuEntry.children || !menuEntry.children.length) return false;
|
||||||
|
|
||||||
|
if (menuEntry.ref) {
|
||||||
|
return menuEntry.ref.isOpen();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
setOpen() {
|
||||||
|
(this.$refs.floatingMenu as typeof FloatingMenu).setOpen();
|
||||||
|
},
|
||||||
|
setClosed() {
|
||||||
|
(this.$refs.floatingMenu as typeof FloatingMenu).setClosed();
|
||||||
|
},
|
||||||
|
isOpen(): boolean {
|
||||||
|
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
|
||||||
|
return Boolean(floatingMenu && floatingMenu.isOpen());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
SeparatorDirection,
|
||||||
|
SeparatorType,
|
||||||
|
MenuDirection,
|
||||||
|
MenuType,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
FloatingMenu,
|
||||||
|
Separator,
|
||||||
|
Icon,
|
||||||
|
UserInputLabel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export default MenuList;
|
||||||
|
</script>
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="menu-bar-input">
|
<div class="menu-bar-input">
|
||||||
<div class="entry"><Icon :icon="'GraphiteLogo'" /></div>
|
<div class="entry-container" v-for="entry in menuEntries" :key="entry">
|
||||||
<div class="entry"><span>File</span></div>
|
<div @click="handleEntryClick(entry)" class="entry" :class="{ open: entry.ref && entry.ref.isOpen() }" data-hover-menu-spawner>
|
||||||
<div class="entry"><span>Edit</span></div>
|
<Icon :icon="entry.icon" v-if="entry.icon" />
|
||||||
<div class="entry"><span>Document</span></div>
|
<span v-if="entry.label">{{ entry.label }}</span>
|
||||||
<div class="entry"><span>View</span></div>
|
</div>
|
||||||
<div class="entry"><span>Help</span></div>
|
<MenuList :menuEntries="entry.children" :direction="MenuDirection.Bottom" :ref="(ref) => setEntryRefs(entry, ref)" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -13,25 +14,31 @@
|
||||||
.menu-bar-input {
|
.menu-bar-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
.entry {
|
.entry-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
position: relative;
|
||||||
white-space: nowrap;
|
|
||||||
padding: 0 8px;
|
|
||||||
|
|
||||||
svg {
|
.entry {
|
||||||
fill: var(--color-e-nearwhite);
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
&:hover {
|
padding: 0 8px;
|
||||||
background: var(--color-6-lowergray);
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: var(--color-f-white);
|
fill: var(--color-e-nearwhite);
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
&:hover,
|
||||||
color: var(--color-f-white);
|
&.open {
|
||||||
|
background: var(--color-6-lowergray);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-f-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--color-f-white);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -42,13 +49,108 @@
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
import Icon from "../labels/Icon.vue";
|
import Icon from "../labels/Icon.vue";
|
||||||
import { ApplicationPlatform } from "../../window/MainWindow.vue";
|
import { ApplicationPlatform } from "../../window/MainWindow.vue";
|
||||||
|
import MenuList, { MenuListEntry, MenuListEntries } from "../floating-menus/MenuList.vue";
|
||||||
|
import { MenuDirection } from "../floating-menus/FloatingMenu.vue";
|
||||||
|
|
||||||
|
const wasm = import("../../../../wasm/pkg");
|
||||||
|
|
||||||
|
const menuEntries: MenuListEntries = [
|
||||||
|
{
|
||||||
|
icon: "GraphiteLogo",
|
||||||
|
ref: undefined,
|
||||||
|
children: [[{ label: "Visit project GitHub…", action: () => window.open("https://github.com/GraphiteEditor/Graphite", "_blank") }]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "File",
|
||||||
|
ref: undefined,
|
||||||
|
children: [
|
||||||
|
[
|
||||||
|
{ label: "New", icon: "FileNew", shortcut: ["Ctrl", "N"] },
|
||||||
|
{ label: "Open…", shortcut: ["Ctrl", "O"] },
|
||||||
|
{
|
||||||
|
label: "Open Recent",
|
||||||
|
shortcut: ["Ctrl", "⇧", "O"],
|
||||||
|
children: [
|
||||||
|
[{ label: "Reopen Last Closed", shortcut: ["Ctrl", "⇧", "T"] }, { label: "Clear Recently Opened" }],
|
||||||
|
[
|
||||||
|
{ label: "Some Recent File.gdd" },
|
||||||
|
{ label: "Another Recent File.gdd" },
|
||||||
|
{ label: "An Older File.gdd" },
|
||||||
|
{ label: "Some Other Older File.gdd" },
|
||||||
|
{ label: "Yet Another Older File.gdd" },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "Close", shortcut: ["Ctrl", "W"] },
|
||||||
|
{ label: "Close All", shortcut: ["Ctrl", "Alt", "W"] },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "Save", shortcut: ["Ctrl", "S"] },
|
||||||
|
{ label: "Save As…", shortcut: ["Ctrl", "⇧", "S"] },
|
||||||
|
{ label: "Save All", shortcut: ["Ctrl", "Alt", "S"] },
|
||||||
|
{ label: "Auto-Save", shortcut: undefined },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "Import…", shortcut: ["Ctrl", "I"] },
|
||||||
|
{ label: "Export…", shortcut: ["Ctrl", "E"] },
|
||||||
|
],
|
||||||
|
[{ label: "Quit", shortcut: ["Ctrl", "Q"] }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
ref: undefined,
|
||||||
|
children: [
|
||||||
|
[
|
||||||
|
{ label: "Undo", shortcut: ["Ctrl", "Z"], action: async () => (await wasm).undo()},
|
||||||
|
{ label: "Redo", shortcut: ["Ctrl", "⇧", "Z"] },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "Cut", shortcut: ["Ctrl", "X"] },
|
||||||
|
{ label: "Copy", shortcut: ["Ctrl", "C"] },
|
||||||
|
{ label: "Paste", shortcut: ["Ctrl", "V"] },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Document",
|
||||||
|
ref: undefined,
|
||||||
|
children: [[{ label: "Menu not yet populated" }]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "View",
|
||||||
|
ref: undefined,
|
||||||
|
children: [[{ label: "Menu not yet populated" }]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Help",
|
||||||
|
ref: undefined,
|
||||||
|
children: [[{ label: "Menu not yet populated" }]],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: { Icon },
|
methods: {
|
||||||
|
setEntryRefs(menuEntry: MenuListEntry, ref: typeof MenuList) {
|
||||||
|
if (ref) menuEntry.ref = ref;
|
||||||
|
},
|
||||||
|
handleEntryClick(menuEntry: MenuListEntry) {
|
||||||
|
if (menuEntry.ref) menuEntry.ref.setOpen();
|
||||||
|
else throw new Error("The menu bar floating menu has no associated ref");
|
||||||
|
},
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
ApplicationPlatform,
|
ApplicationPlatform,
|
||||||
|
menuEntries,
|
||||||
|
MenuDirection,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
components: {
|
||||||
|
Icon,
|
||||||
|
MenuList,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="swatch-pair">
|
<div class="swatch-pair">
|
||||||
<div class="secondary swatch">
|
<div class="secondary swatch">
|
||||||
<button @click="clickSecondarySwatch" ref="secondaryButton"></button>
|
<button @click="clickSecondarySwatch" ref="secondaryButton" data-hover-menu-spawner></button>
|
||||||
<FloatingMenu :type="MenuType.Popover" :direction="MenuDirection.Right" horizontal ref="secondarySwatchFloatingMenu">
|
<FloatingMenu :type="MenuType.Popover" :direction="MenuDirection.Right" horizontal ref="secondarySwatchFloatingMenu">
|
||||||
<ColorPicker v-model:color="secondaryColor" />
|
<ColorPicker v-model:color="secondaryColor" />
|
||||||
</FloatingMenu>
|
</FloatingMenu>
|
||||||
</div>
|
</div>
|
||||||
<div class="primary swatch">
|
<div class="primary swatch">
|
||||||
<button @click="clickPrimarySwatch" ref="primaryButton"></button>
|
<button @click="clickPrimarySwatch" ref="primaryButton" data-hover-menu-spawner></button>
|
||||||
<FloatingMenu :type="MenuType.Popover" :direction="MenuDirection.Right" horizontal ref="primarySwatchFloatingMenu">
|
<FloatingMenu :type="MenuType.Popover" :direction="MenuDirection.Right" horizontal ref="primarySwatchFloatingMenu">
|
||||||
<ColorPicker v-model:color="primaryColor" />
|
<ColorPicker v-model:color="primaryColor" />
|
||||||
</FloatingMenu>
|
</FloatingMenu>
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ import ViewModePixels from "../../../../assets/16px-solid/view-mode-pixels.svg";
|
||||||
import EyeVisible from "../../../../assets/16px-solid/visibility-eye-visible.svg";
|
import EyeVisible from "../../../../assets/16px-solid/visibility-eye-visible.svg";
|
||||||
import EyeHidden from "../../../../assets/16px-solid/visibility-eye-hidden.svg";
|
import EyeHidden from "../../../../assets/16px-solid/visibility-eye-hidden.svg";
|
||||||
import GraphiteLogo from "../../../../assets/16px-solid/graphite-logo.svg";
|
import GraphiteLogo from "../../../../assets/16px-solid/graphite-logo.svg";
|
||||||
|
import FileNew from "../../../../assets/16px-solid/file-new.svg";
|
||||||
|
|
||||||
import SwapButton from "../../../../assets/12px-solid/swap.svg";
|
import SwapButton from "../../../../assets/12px-solid/swap.svg";
|
||||||
import ResetColorsButton from "../../../../assets/12px-solid/reset-colors.svg";
|
import ResetColorsButton from "../../../../assets/12px-solid/reset-colors.svg";
|
||||||
|
|
@ -142,6 +143,7 @@ const icons = {
|
||||||
EyeVisible: { component: EyeVisible, size: 16 },
|
EyeVisible: { component: EyeVisible, size: 16 },
|
||||||
EyeHidden: { component: EyeHidden, size: 16 },
|
EyeHidden: { component: EyeHidden, size: 16 },
|
||||||
GraphiteLogo: { component: GraphiteLogo, size: 16 },
|
GraphiteLogo: { component: GraphiteLogo, size: 16 },
|
||||||
|
FileNew: { component: FileNew, size: 16 },
|
||||||
SwapButton: { component: SwapButton, size: 12 },
|
SwapButton: { component: SwapButton, size: 12 },
|
||||||
ResetColorsButton: { component: ResetColorsButton, size: 12 },
|
ResetColorsButton: { component: ResetColorsButton, size: 12 },
|
||||||
DropdownArrow: { component: DropdownArrow, size: 12 },
|
DropdownArrow: { component: DropdownArrow, size: 12 },
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="user-input-label">
|
<div class="user-input-label">
|
||||||
<span class="input-key" v-for="inputKey in inputKeys" :key="inputKey" :class="keyCapWidth(inputKey)">
|
<template v-for="(keyGroup, keyGroupIndex) in inputKeys" :key="keyGroupIndex">
|
||||||
{{ inputKey }}
|
<span class="group-gap" v-if="keyGroupIndex > 0"></span>
|
||||||
</span>
|
<span class="input-key" v-for="inputKey in keyGroup" :key="inputKey" :class="keyCapWidth(inputKey)">
|
||||||
|
{{ inputKey }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<span class="input-mouse" v-if="inputMouse">
|
<span class="input-mouse" v-if="inputMouse">
|
||||||
<Icon :icon="mouseInputInteractionToIcon(inputMouse)" />
|
<Icon :icon="mouseInputInteractionToIcon(inputMouse)" />
|
||||||
</span>
|
</span>
|
||||||
<span class="hint-text">
|
<span class="hint-text" v-if="hasSlotContent">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -20,17 +23,22 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.group-gap {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.input-key,
|
.input-key,
|
||||||
.input-mouse {
|
.input-mouse {
|
||||||
margin-right: 4px;
|
& + .input-key,
|
||||||
|
& + .input-mouse {
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-key {
|
.input-key {
|
||||||
font-family: "Inconsolata", monospace;
|
font-family: "Inconsolata", monospace;
|
||||||
font-weight: bold;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--color-2-mildblack);
|
color: var(--color-e-nearwhite);
|
||||||
background: var(--color-e-nearwhite);
|
|
||||||
border: 1px;
|
border: 1px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
|
|
@ -73,6 +81,10 @@
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
@ -99,6 +111,11 @@ export default defineComponent({
|
||||||
inputKeys: { type: Array, default: () => [] },
|
inputKeys: { type: Array, default: () => [] },
|
||||||
inputMouse: { type: String },
|
inputMouse: { type: String },
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
hasSlotContent(): boolean {
|
||||||
|
return Boolean(this.$slots.default);
|
||||||
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
keyCapWidth(keyText: string) {
|
keyCapWidth(keyText: string) {
|
||||||
return `width-${keyText.length * 8 + 8}`;
|
return `width-${keyText.length * 8 + 8}`;
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,26 @@
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<UserInputLabel :inputMouse="'LMBDrag'">Drag Selected</UserInputLabel>
|
<UserInputLabel :inputMouse="'LMBDrag'">Drag Selected</UserInputLabel>
|
||||||
<Separator :type="SeparatorType.Section" />
|
<Separator :type="SeparatorType.Section" />
|
||||||
<UserInputLabel :inputKeys="['G']">Grab Selected</UserInputLabel>
|
<UserInputLabel :inputKeys="[['G']]">Grab Selected</UserInputLabel>
|
||||||
<UserInputLabel :inputKeys="['R']">Rotate Selected</UserInputLabel>
|
<UserInputLabel :inputKeys="[['R']]">Rotate Selected</UserInputLabel>
|
||||||
<UserInputLabel :inputKeys="['S']">Scale Selected</UserInputLabel>
|
<UserInputLabel :inputKeys="[['S']]">Scale Selected</UserInputLabel>
|
||||||
<Separator :type="SeparatorType.Section" />
|
<Separator :type="SeparatorType.Section" />
|
||||||
<UserInputLabel :inputMouse="'LMB'">Select Object</UserInputLabel>
|
<UserInputLabel :inputMouse="'LMB'">Select Object</UserInputLabel>
|
||||||
<span class="plus">+</span>
|
<span class="plus">+</span>
|
||||||
<UserInputLabel :inputKeys="['Ctrl']">Innermost</UserInputLabel>
|
<UserInputLabel :inputKeys="[['Ctrl']]">Innermost</UserInputLabel>
|
||||||
<span class="plus">+</span>
|
<span class="plus">+</span>
|
||||||
<UserInputLabel :inputKeys="['⇧']">Grow/Shrink Selection</UserInputLabel>
|
<UserInputLabel :inputKeys="[['⇧']]">Grow/Shrink Selection</UserInputLabel>
|
||||||
<Separator :type="SeparatorType.Section" />
|
<Separator :type="SeparatorType.Section" />
|
||||||
<UserInputLabel :inputMouse="'LMBDrag'">Select Area</UserInputLabel>
|
<UserInputLabel :inputMouse="'LMBDrag'">Select Area</UserInputLabel>
|
||||||
<span class="plus">+</span>
|
<span class="plus">+</span>
|
||||||
<UserInputLabel :inputKeys="['⇧']">Grow/Shrink Selection</UserInputLabel>
|
<UserInputLabel :inputKeys="[['⇧']]">Grow/Shrink Selection</UserInputLabel>
|
||||||
<Separator :type="SeparatorType.Section" />
|
<Separator :type="SeparatorType.Section" />
|
||||||
<UserInputLabel :inputKeys="['↑', '→', '↓', '←']">Nudge Selected</UserInputLabel>
|
<UserInputLabel :inputKeys="[['↑'], ['→'], ['↓'], ['←']]">Nudge Selected</UserInputLabel>
|
||||||
<span class="plus">+</span>
|
<span class="plus">+</span>
|
||||||
<UserInputLabel :inputKeys="['⇧']">Big Increment Nudge</UserInputLabel>
|
<UserInputLabel :inputKeys="[['⇧']]">Big Increment Nudge</UserInputLabel>
|
||||||
<Separator :type="SeparatorType.Section" />
|
<Separator :type="SeparatorType.Section" />
|
||||||
<UserInputLabel :inputKeys="['Alt']" :inputMouse="'LMBDrag'">Move Duplicate</UserInputLabel>
|
<UserInputLabel :inputKeys="[['Alt']]" :inputMouse="'LMBDrag'">Move Duplicate</UserInputLabel>
|
||||||
<UserInputLabel :inputKeys="['Ctrl', 'D']">Duplicate</UserInputLabel>
|
<UserInputLabel :inputKeys="[['Ctrl', 'D']]">Duplicate</UserInputLabel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,12 @@ pub fn reset_colors() -> Result<(), JsValue> {
|
||||||
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(ToolMessage::ResetColors)).map_err(convert_error)
|
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(ToolMessage::ResetColors)).map_err(convert_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Undo history one step
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn undo() -> Result<(), JsValue> {
|
||||||
|
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::Undo)).map_err(convert_error)
|
||||||
|
}
|
||||||
|
|
||||||
/// Select a layer from the layer list
|
/// Select a layer from the layer list
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn select_layer(path: Vec<LayerId>) -> Result<(), JsValue> {
|
pub fn select_layer(path: Vec<LayerId>) -> Result<(), JsValue> {
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,8 @@ impl MessageHandler<ToolMessage, (&SvgDocument, &InputPreprocessor)> for ToolMes
|
||||||
}
|
}
|
||||||
ResetColors => {
|
ResetColors => {
|
||||||
let doc_data = &mut self.tool_state.document_tool_data;
|
let doc_data = &mut self.tool_state.document_tool_data;
|
||||||
doc_data.primary_color = Color::WHITE;
|
doc_data.primary_color = Color::BLACK;
|
||||||
doc_data.secondary_color = Color::BLACK;
|
doc_data.secondary_color = Color::WHITE;
|
||||||
}
|
}
|
||||||
message => {
|
message => {
|
||||||
let tool_type = match message {
|
let tool_type = match message {
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ Contributions welcome! If you think of something Graphite would be great for, su
|
||||||
- Creating a chart from a CSV
|
- Creating a chart from a CSV
|
||||||
- Rendering an always-up-to-date chart powered by real-time updates from a database
|
- Rendering an always-up-to-date chart powered by real-time updates from a database
|
||||||
- Data-driven infographics like an org chart that can be updated with text instead of manual design work
|
- Data-driven infographics like an org chart that can be updated with text instead of manual design work
|
||||||
|
- Request the weather from an API and render live visualizations which gets displayed on a monitor in your house or a museum (export to a Windows screen saver?)
|
||||||
|
|
||||||
## Digital painting
|
## Digital painting
|
||||||
- Creating a digital acrylic or oil painting using various brushes
|
- Creating a digital acrylic or oil painting using various brushes
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue