Macro Recorder added

This commit is contained in:
Jesse Malotaux 2025-03-23 14:46:50 +01:00
parent c514ba151e
commit a6024f22e7
12 changed files with 510 additions and 122 deletions

View file

@ -0,0 +1,71 @@
<template>
<div class="macro-overview mcrm-block block__dark">
<h4 class="border-b-2 border-transparent">Saved Macros</h4>
<div class="macro-overview__list">
<div class="macro-item" v-for="(macro, i) in macros.list" :key="i">
<ButtonComp variant="dark" class="w-full" size="sm" @click.prevent="runMacro(macro)">
<IconKeyboard /> {{ macro }}
</ButtonComp>
</div>
</div>
</div>
</template>
<script setup>
import { IconKeyboard } from '@tabler/icons-vue'
import ButtonComp from '../base/ButtonComp.vue'
import { onMounted, reactive } from 'vue'
import axios from 'axios'
import { appUrl } from '@/services/ApiService'
const macros = reactive({
list: [],
})
onMounted(() => {
axios.post(appUrl() + '/macro/list').then((data) => {
if (data.data.length > 0) macros.list = data.data
})
})
function runMacro(macro) {
console.log(macro)
axios.post(appUrl() + '/macro/play', { macro: macro }).then((data) => {
console.log(data)
})
}
</script>
<style scoped>
@reference "@/assets/main.css";
.macro-overview {
@apply relative
grid
grid-rows-[auto_1fr];
&::after {
@apply content-['']
absolute
top-0
left-full
h-full
w-px
bg-slate-600;
}
.macro-overview__list {
@apply grid
gap-1
content-start;
}
.macro-item {
@apply flex items-center;
button {
@apply w-full;
}
}
}
</style>

View file

@ -1,45 +1,25 @@
<template>
<div class="macro-recorder">
<!-- Recorder buttons -->
<RecorderHeader />
<div class="macro-recorder mcrm-block block__light">
<div class="recorder-interface">
<!-- Recorder buttons -->
<RecorderHeader />
<!-- Recorder interface container -->
<div
:class="`recorder-interface__container ${macroRecorder.state.record && 'record'} ${macroRecorder.state.edit && 'edit'}`"
>
<!-- Shows the macro steps as kbd elements with delay and spacers-->
<RecorderOutput />
<!-- Input for recording macro steps -->
<RecorderInput />
<!-- Recorder interface container -->
<div
:class="`recorder-interface__container ${macroRecorder.state.record && 'record'} ${macroRecorder.state.edit && 'edit'}`"
>
<!-- Shows the macro steps as kbd elements with delay and spacers-->
<RecorderOutput />
<!-- Input for recording macro steps -->
<RecorderInput />
</div>
<RecorderFooter />
</div>
<RecorderFooter />
</div>
</template>
<script setup>
// TODO:
// X refactor filtering the keys
// X add keyup functionality
// X add delay between steps
// X restyle keys and delay elements
// X add lines between steps
// X record macro to object
// X refactor macro output based on object?
// X Make sure keydown is not spamming steps
// X Make record button work as a toggle
// X Make edit button work
// X Make fixed/custom delay work
// X Refactor into multiple components and state store
// X Make edit key function
// X Make edit delay function
// X Make delete key function
// X Make sure delay is paused when not recording.
// X Refactor macro recorder parts.
// X X Layout parts should be parts, smaller parts should be components.
// X Make reset function
// - Make insert button, menu and function
import RecorderOutput from './parts/RecorderOutput.vue'
import RecorderInput from './parts/RecorderInput.vue'
@ -53,24 +33,53 @@ const macroRecorder = useMacroRecorderStore()
<style>
@reference "@/assets/main.css";
.macro-recorder {
@apply h-full;
}
.recorder-interface {
@apply grid
grid-rows-[auto_1fr_auto]
gap-4
h-full
transition-[grid-template-rows];
}
.recorder-interface__container {
@apply relative
w-full
h-96
my-4
rounded-lg
bg-slate-900/50
border-2
border-white/10
bg-slate-950/50
border
border-slate-600
overflow-auto
transition-colors;
&.record {
@apply border-rose-300 bg-rose-950/50;
@apply border-rose-300 bg-rose-400/10;
}
&.edit {
@apply border-sky-300 bg-sky-900/50;
@apply border-sky-300 bg-sky-900/10;
}
}
#macro-name {
@apply w-full
bg-transparent
py-0
outline-0
border-transparent
border-b-slate-300
focus:border-transparent
focus:border-b-sky-400
focus:bg-sky-400/10
transition-colors
text-lg
rounded-none;
}
.disabled {
@apply opacity-50 pointer-events-none cursor-not-allowed;
}
</style>

View file

@ -1,13 +1,17 @@
<template>
<span :class="`delay ${active ? 'active' : ''}`">
{{ value < 10000 ? value + 'ms' : '>10s' }}
<span :class="`delay ${active ? 'active' : ''} ${preset ? 'preset' : ''}`">
<template v-if="value < 10000"> {{ value }} <i>ms</i> </template>
<template v-else> >10 <i>s</i> </template>
</span>
</template>
<script setup>
import { IconTimeDuration10 } from '@tabler/icons-vue'
defineProps({
value: Number,
active: Boolean,
preset: Boolean,
})
</script>
@ -22,10 +26,24 @@ span.delay {
border
border-slate-400
text-slate-950
font-sans
font-semibold
rounded-sm
text-sm
cursor-default;
&.preset {
@apply text-amber-400
border-amber-300/80
bg-amber-100/60;
}
i {
@apply pl-1
font-normal
not-italic
opacity-80;
}
}
.edit span.delay {

View file

@ -26,9 +26,9 @@ const keyObj = ref(null)
onMounted(() => {
keyObj.value = filterKey(macroRecorder.getEditKey())
console.log(macroRecorder.getEditKey());
console.log(keyObj.value);
console.log('---------');
// console.log(macroRecorder.getEditKey());
// console.log(keyObj.value);
// console.log('---------');
})
</script>

View file

@ -1,14 +1,14 @@
<template>
<ContextMenu>
<ContextMenu ref="ctxtMenu">
<template #trigger>
<ButtonComp variant="secondary" size="sm"> <IconAlarmFilled />Fixed delay </ButtonComp>
<ButtonComp variant="secondary" size="sm"> <IconTimeDuration15 />Fixed delay </ButtonComp>
</template>
<template #content>
<ul>
<li @click="macroRecorder.changeDelay(0)">0ms</li>
<li @click="macroRecorder.changeDelay(15)">15ms</li>
<li @click="macroRecorder.changeDelay(50)">50ms</li>
<li @click="macroRecorder.changeDelay(100)">100ms</li>
<li @click="changeDelay(0)">0ms</li>
<li @click="changeDelay(15)">15ms</li>
<li @click="changeDelay(50)">50ms</li>
<li @click="changeDelay(100)">100ms</li>
<li>
<DialogComp>
<template #trigger>
@ -19,7 +19,7 @@
<h4 class="text-slate-50 mb-4">Custom delay</h4>
<form
class="grid gap-4 w-44"
@submit.prevent="macroRecorder.changeDelay(parseInt($refs.customDelayInput.value))"
@submit.prevent="changeDelay(parseInt($refs.customDelayInput.value))"
>
<div>
<input
@ -46,7 +46,7 @@
<script setup>
import ContextMenu from '@/components/base/ContextMenu.vue'
import { IconAlarmFilled } from '@tabler/icons-vue'
import { IconTimeDuration15 } from '@tabler/icons-vue'
import ButtonComp from '@/components/base/ButtonComp.vue'
import DialogComp from '@/components/base/DialogComp.vue'
@ -56,7 +56,12 @@ import { ref } from 'vue'
const macroRecorder = useMacroRecorderStore()
const delayMenu = ref(false)
const ctxtMenu = ref()
function changeDelay(num) {
macroRecorder.changeDelay(num)
ctxtMenu.value.toggle()
}
</script>
<style scoped>

View file

@ -1,17 +1,72 @@
<template>
<div id="insert-key-dialog" class="dialog__content w-80">
<div id="insert-key-dialog" class="dialog__content w-96">
<h4 class="text-slate-50 mb-4">Insert key {{ position }}</h4>
<div class="flex justify-center w-full mb-4">
<p v-if="inputFocus" class="text-center">[Press a key]</p>
<input
class="size-0 opacity-0"
type="text"
min="0"
max="1"
ref="insertKeyInput"
placeholder="New key"
@focusin="inputFocus = true"
@focusout="inputFocus = false"
@keydown.prevent="handleInsertKey($event)"
autofocus
/>
<div class="insert-output" :class="position == 'before' ? 'flex-row-reverse' : ''">
<MacroKey v-if="keyObjs.selected" :key-obj="keyObjs.selected" />
<MacroKey v-if="keyObjs.adjacent" :key-obj="keyObjs.adjacent" />
<hr class="spacer" />
<DelaySpan :preset="true" :value="10" />
<hr class="spacer" />
<MacroKey
v-if="keyObjs.insert"
class="insert"
:key-obj="keyObjs.insert"
:direction="keyObjs.insertDirection"
@click="insertKeyInput.focus()"
/>
<MacroKey v-if="!keyObjs.insert" :empty="true" @click="insertKeyInput.focus()" />
<template v-if="keyObjs.adjacentDelay">
<hr class="spacer" />
<DelaySpan :value="keyObjs.adjacentDelay.value" />
</template>
<template v-if="keyObjs.adjacent">
<hr class="spacer" />
<MacroKey :key-obj="keyObjs.adjacent" />
</template>
</div>
<div class="insert-key__direction">
<ButtonComp
variant="secondary"
:class="keyObjs.insertDirection === 'down' ? 'selected' : ''"
size="sm"
@click.prevent="keyObjs.insertDirection = 'down'"
>
&darr; Down
</ButtonComp>
<ButtonComp
variant="secondary"
:class="keyObjs.insertDirection === 'up' ? 'selected' : ''"
size="sm"
@click.prevent="keyObjs.insertDirection = 'up'"
>
&uarr; Up
</ButtonComp>
</div>
<div class="flex justify-end">
<ButtonComp variant="primary" size="sm" @click="insertKey()">Insert key</ButtonComp>
</div>
</div>
</template>
<script setup>
import MacroKey from './MacroKey.vue'
import DelaySpan from './DelaySpan.vue'
import ButtonComp from '@/components/base/ButtonComp.vue'
import { onMounted, reactive, ref } from 'vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import MacroKey from './MacroKey.vue'
import { filterKey } from '@/services/MacroRecordService'
const props = defineProps({
@ -23,19 +78,57 @@ const macroRecorder = useMacroRecorderStore()
const keyObjs = reactive({
selected: null,
insert: null,
insertEvent: null,
insertDirection: 'down',
adjacent: null,
adjacentDelay: null,
adjacentDelayIndex: null,
})
const insertKeyInput = ref(null)
const inputFocus = ref(false)
onMounted(() => {
keyObjs.selected = filterKey(macroRecorder.getEditKey())
const adjacentKey = macroRecorder.getAdjacentKey(props.position, true)
if (adjacentKey) keyObjs.adjacent = filterKey(adjacentKey.key)
if (adjacentKey.delay) keyObjs.adjacentDelay = adjacentKey.delay
if (adjacentKey.delay) {
keyObjs.adjacentDelay = adjacentKey.delay
keyObjs.adjacentDelayIndex = adjacentKey.delayIndex
}
})
const handleInsertKey = (e) => {
keyObjs.insert = filterKey(e)
keyObjs.insertEvent = e
}
const insertKey = () => {
macroRecorder.insertKey(keyObjs.insertEvent, keyObjs.insertDirection, keyObjs.adjacentDelayIndex)
}
</script>
<style scoped>
@reference "@/assets/main.css";
.insert-output {
@apply flex
justify-center
items-center
w-full
mb-4;
}
.insert-key__direction {
@apply flex
justify-center
gap-2
mt-6;
}
button.selected {
@apply bg-sky-500
ring-2
ring-offset-1
ring-sky-500;
}
</style>

View file

@ -1,20 +1,27 @@
<template>
<kbd :class="active ? 'active' : ''">
<sup v-if="keyObj.loc">
{{ keyObj.loc }}
</sup>
<span :innerHTML="keyObj.str" />
<span class="dir">{{ dir.value === 'down' ? '&darr;' : '&uarr;' }}</span>
<kbd :class="`${active ? 'active' : ''} ${empty ? 'empty' : ''}`">
<template v-if="keyObj">
<sup v-if="keyObj.loc">
{{ keyObj.loc }}
</sup>
<span :innerHTML="keyObj.str" />
<span class="dir">{{ dir.value === 'down' ? '&darr;' : '&uarr;' }}</span>
</template>
<template v-else-if="empty">
<span>[ ]</span>
</template>
</kbd>
</template>
<script setup>
import { onMounted, reactive } from 'vue'
import { onMounted, onUpdated, reactive } from 'vue'
const props = defineProps({
keyObj: Object,
direction: String,
active: Boolean,
empty: Boolean,
})
const dir = reactive({
@ -22,9 +29,18 @@ const dir = reactive({
})
onMounted(() => {
if (props.empty) return
setDirection()
})
onUpdated(() => {
setDirection()
})
const setDirection = () => {
if (props.direction) dir.value = props.direction
else dir.value = props.keyObj.direction
})
}
</script>
<style>
@ -37,10 +53,11 @@ kbd {
pl-4 pr-2 py-1
h-9
bg-slate-700
font-sans
font-mono
font-bold
text-lg
text-white
whitespace-nowrap
uppercase
rounded-md
border
@ -60,6 +77,21 @@ kbd {
span.dir {
@apply text-slate-200 pl-1;
}
&.empty {
@apply pl-3 pr-3
bg-sky-400/50
border-sky-300
shadow-sky-600
tracking-widest
cursor-pointer;
}
&.insert {
@apply bg-yellow-500/50
border-yellow-300
shadow-yellow-600
cursor-pointer;
}
}
:has(kdb):not(.edit) kbd {

View file

@ -0,0 +1,60 @@
<template>
<div id="validation-error__dialog" class="dialog__content">
<h4 class="text-slate-50 mb-4">There's an error in your macro</h4>
<div class="grid gap-4" v-if="(errors && errors.up.length > 0) || errors.down.length > 0">
<div v-if="errors.down.length > 0">
<p>
The following keys have been <strong>pressed</strong> down, but
<strong>not released</strong>.
</p>
<ul>
<li v-for="key in errors.down" :key="key">{{ key.toUpperCase() }}</li>
</ul>
</div>
<div v-if="errors.up.length > 0">
<p>
The following keys have been <strong>released</strong>, but
<strong>not pressed</strong> down.
</p>
<ul>
<li v-for="key in errors.up" :key="key">{{ key.toUpperCase() }}</li>
</ul>
</div>
</div>
<div class="flex justify-end mt-4">
<ButtonComp size="sm" variant="danger" @click="macroRecorder.state.validationErrors = false">
Close
</ButtonComp>
</div>
</div>
</template>
<script setup>
import ButtonComp from '@/components/base/ButtonComp.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import { onMounted, reactive } from 'vue'
const macroRecorder = useMacroRecorderStore()
const errors = reactive({
up: [],
down: [],
})
onMounted(() => {
macroRecorder.$subscribe((mutation) => {
if (mutation.events && mutation.events.key == 'validationErrors') {
errors.up = mutation.events.newValue !== false ? macroRecorder.state.validationErrors.up : []
errors.down =
mutation.events.newValue !== false ? macroRecorder.state.validationErrors.down : []
}
console.log(mutation)
})
})
</script>
<style scoped>
@reference "@/assets/main.css";
</style>

View file

@ -4,53 +4,60 @@
class="flex gap-2"
v-if="macroRecorder.state.editKey !== false && typeof macroRecorder.getEditKey() === 'object'"
>
<ContextMenu>
<ContextMenu ref="ctxtMenu">
<template #trigger>
<ButtonComp variant="success" size="sm"> <IconPlus /> Insert key </ButtonComp>
<ButtonComp variant="dark" size="sm"> <IconPlus /> Insert </ButtonComp>
</template>
<template #content>
<ul>
<li @click="insertPosition = 'before'"><IconArrowLeftCircle /> Before</li>
<li @click="insertPosition = 'after'"><IconArrowRightCircle /> After</li>
<li @click="insert.position = 'before'"><IconArrowLeftCircle /> Before</li>
<li @click="insert.position = 'after'"><IconArrowRightCircle /> After</li>
</ul>
</template>
</ContextMenu>
<DialogComp v-if="insertPosition !== null" :open="true" @on-close="insertPosition = null">
<DialogComp
v-if="insert.position !== null"
:open="insert.position !== null"
@on-open="onOpenDialog"
@on-close="onCloseDialog"
>
<template #content>
<InsertKeyDialog :position="insertPosition" />
<InsertKeyDialog :position="insert.position" />
</template>
</DialogComp>
<DialogComp>
<template #trigger>
<ButtonComp size="sm" variant="danger" @click="console.log('delete')">
<IconTrash />Delete key
</ButtonComp>
</template>
<template #content>
<DeleteKeyDialog />
</template>
</DialogComp>
<DialogComp
:id="`edit-key-${macroRecorder.state.editKey}`"
@on-close="macroRecorder.state.editKey = false"
@on-open="onOpenDialog"
@on-close="onCloseDialog"
>
<template #trigger>
<ButtonComp variant="primary" size="sm"> <IconKeyboard />Edit key </ButtonComp>
<ButtonComp variant="secondary" size="sm"> <IconPencil />Edit </ButtonComp>
</template>
<template #content>
<EditKeyDialog />
</template>
</DialogComp>
<DialogComp @on-open="onOpenDialog" @on-close="onCloseDialog">
<template #trigger>
<ButtonComp size="sm" variant="danger"> <IconTrash />Delete </ButtonComp>
</template>
<template #content>
<DeleteKeyDialog />
</template>
</DialogComp>
</div>
<DialogComp
v-if="
macroRecorder.state.editDelay !== false && typeof macroRecorder.getEditDelay() === 'object'
"
@on-close="macroRecorder.state.editDelay = false"
@on-open="onOpenDialog"
@on-close="onCloseDialog"
>
<template #trigger>
<ButtonComp variant="primary" size="sm"> <IconAlarm />Edit delay </ButtonComp>
<ButtonComp variant="secondary" size="sm"> <IconAlarm />Edit </ButtonComp>
</template>
<template #content>
<EditDelayDialog />
@ -64,7 +71,7 @@ import {
IconAlarm,
IconArrowLeftCircle,
IconArrowRightCircle,
IconKeyboard,
IconPencil,
IconPlus,
IconTrash,
} from '@tabler/icons-vue'
@ -77,11 +84,30 @@ import EditDelayDialog from '../components/EditDelayDialog.vue'
import DeleteKeyDialog from '../components/DeleteKeyDialog.vue'
import ContextMenu from '@/components/base/ContextMenu.vue'
import InsertKeyDialog from '../components/InsertKeyDialog.vue'
import { ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
const macroRecorder = useMacroRecorderStore()
const insertPosition = ref(null)
const insert = reactive({ position: null })
const ctxtMenu = ref()
onMounted(() => {
macroRecorder.$subscribe((mutation) => {
if (mutation.events && mutation.events.key == 'editKey' && mutation.events.newValue === false) {
insert.position = null
}
})
})
function onOpenDialog() {
if (insert.position !== null) ctxtMenu.value.toggle()
}
function onCloseDialog() {
macroRecorder.state.editKey = false
macroRecorder.state.editDelay = false
insert.position = null
}
</script>
<style scoped>

View file

@ -8,18 +8,58 @@
>
<IconRestore /> Reset
</ButtonComp>
<DialogComp ref="errorDialog">
<template #content>
<ValidationErrorDialog />
</template>
</DialogComp>
<ButtonComp
v-if="macroRecorder.steps.length > 0"
:disabled="macroRecorder.state.record || macroRecorder.state.edit"
variant="success"
size="sm"
@click="toggleSave()"
>
<IconDeviceFloppy />
Save
</ButtonComp>
</div>
</template>
<script setup>
import ButtonComp from '@/components/base/ButtonComp.vue'
import { IconRestore } from '@tabler/icons-vue'
import { IconDeviceFloppy, IconRestore } from '@tabler/icons-vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import DialogComp from '@/components/base/DialogComp.vue'
import ValidationErrorDialog from '../components/ValidationErrorDialog.vue'
import { onMounted, ref } from 'vue'
const macroRecorder = useMacroRecorderStore()
const errorDialog = ref()
onMounted(() => {
macroRecorder.$subscribe((mutation) => {
if (mutation.events && mutation.events.key == 'validationErrors') {
errorDialog.value.toggleDialog(mutation.events.newValue !== false)
}
})
})
const toggleSave = () => {
if (!macroRecorder.save()) errorDialog.value.toggleDialog(true)
}
</script>
<style scoped>
@reference "@/assets/main.css";
.macro-recorder__footer {
@apply flex
justify-between
gap-2;
}
</style>

View file

@ -1,39 +1,59 @@
<template>
<div class="macro-recorder__header">
<div :class="`recording__buttons ${macroRecorder.state.edit ? 'disabled' : ''}`">
<ButtonComp
v-if="!macroRecorder.state.record"
variant="primary"
@click="macroRecorder.state.record = true"
>
<IconPlayerRecordFilled class="text-red-500" />Start recording
</ButtonComp>
<ButtonComp
v-if="macroRecorder.state.record"
variant="danger"
@click="macroRecorder.state.record = false"
>
<IconPlayerStopFilled class="text-white" />Stop recording
</ButtonComp>
<div class="w-full grid grid-cols-[auto_1fr_auto] gap-2">
<h4 class="">Name:</h4>
<input
id="macro-name"
type="text"
@input.prevent="changeName($event.target.value)"
placeholder="New macro"
/>
<div :class="`recording__buttons ${!nameSet || macroRecorder.state.edit ? 'disabled' : ''}`">
{{ macroRecorder.name }}
<ButtonComp
v-if="!macroRecorder.state.record"
variant="primary"
size="sm"
@click="macroRecorder.state.record = true"
>
<IconPlayerRecordFilled class="text-red-500" />Record
</ButtonComp>
<ButtonComp
v-if="macroRecorder.state.record"
variant="danger"
size="sm"
@click="macroRecorder.state.record = false"
>
<IconPlayerStopFilled class="text-white" />Stop
</ButtonComp>
</div>
</div>
<div :class="`edit__buttons ${macroRecorder.state.record ? 'disabled' : ''}`">
<div
v-if="macroRecorder.steps.length > 0"
:class="`edit__buttons ${macroRecorder.state.record ? 'disabled' : ''}`"
>
<div>
<ButtonComp
v-if="!macroRecorder.state.edit"
variant="secondary"
size="sm"
@click="macroRecorder.state.edit = true"
>
<IconPencil />Edit macro
<IconPencil />Edit
</ButtonComp>
<ButtonComp
v-if="macroRecorder.state.edit"
variant="dark"
variant="danger"
size="sm"
@click="macroRecorder.resetEdit()"
>
<IconPlayerStopFilled />Stop editing
<IconPlayerStopFilled />Stop
</ButtonComp>
</div>
<FixedDelayMenu v-if="macroRecorder.state.edit" />
<EditDialogs />
</div>
</div>
@ -46,8 +66,16 @@ import FixedDelayMenu from '../components/FixedDelayMenu.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import EditDialogs from './EditDialogs.vue'
import { computed, onMounted, onUpdated, ref } from 'vue'
const macroRecorder = useMacroRecorderStore()
const nameSet = ref(false)
function changeName(name) {
macroRecorder.changeName(name)
nameSet.value = name.length > 0
}
</script>
<style scoped>
@ -55,16 +83,18 @@ const macroRecorder = useMacroRecorderStore()
.macro-recorder__header {
@apply grid
grid-cols-[auto_1fr]
items-end
gap-4;
gap-4
w-full;
.edit__buttons {
@apply flex
justify-between
gap-2
w-full;
}
> div {
@apply flex gap-4 items-end;
&.disabled {
@apply opacity-50 pointer-events-none cursor-not-allowed;
}
@apply flex gap-2 items-end;
}
}
</style>

View file

@ -51,4 +51,8 @@ const macroRecorder = useMacroRecorderStore()
top-0 left-0
h-fit;
}
hr.spacer:last-of-type {
@apply hidden;
}
</style>