WIP: MacroRecorder. Component + parts + subcomponents for capturing keyboard input for macro recording.

This commit is contained in:
Jesse Malotaux 2025-03-10 12:51:50 +01:00
parent f4a4bc5c4a
commit 68a2eda491
14 changed files with 785 additions and 109 deletions

View file

@ -1,109 +0,0 @@
<template>
<div class="macro-input">
<div class="macro-input__output" ref="macroOutput"></div>
<input class="macro-input__input" type="text" ref="macroInput" @keydown.prevent="keyDown" />
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
const macroOutput = ref(null)
const macroInput = ref(null)
onMounted(() => {
macroInput.value.focus()
})
const keyDown = (e) => {
console.log(e)
const modKeys = {
Control: 'Ctrl',
Shift: 'Shift',
Alt: 'Alt',
Meta: 'Win',
}
const specialKeys = {
PageUp: 'PgUp',
PageDown: 'PgDn',
ScrollLock: 'Scr Lk',
}
let key = e.key
if (e.shiftKey) {
key = e.key.toLowerCase()
}
const newKeyEl = document.createElement('kbd')
if (e.code === 'Space') {
newKeyEl.innerHTML = 'Space'
} else if (e.location === 1 && Object.keys(modKeys).includes(key)) {
newKeyEl.innerHTML = '<sup>left</sup> ' + (modKeys[key] || key)
} else if (e.location === 2 && Object.keys(modKeys).includes(key)) {
newKeyEl.innerHTML = '<sup>right</sup> ' + (modKeys[key] || key)
} else if (e.location === 3) {
newKeyEl.innerHTML = '<sup>num</sup> ' + (modKeys[key] || key)
} else if (Object.keys(specialKeys).includes(key)) {
newKeyEl.innerHTML = specialKeys[key] || key
} else {
newKeyEl.innerHTML = key
}
macroOutput.value.appendChild(newKeyEl)
}
</script>
<style>
@reference "@/assets/main.css";
.macro-input {
@apply relative
w-full
h-96
my-4
rounded-lg
bg-slate-900/50
border
border-white/10
overflow-auto;
.macro-input__input,
.macro-input__output {
@apply absolute
inset-0
size-full;
}
.macro-input__output {
@apply flex
flex-wrap
items-start
gap-2
p-4
h-fit;
}
kbd {
@apply flex
items-center
gap-2
px-4 py-1
h-9
bg-white/10
font-sans
font-bold
text-lg
uppercase
rounded-md
border;
sup {
@apply text-xs font-light -ml-1.5 mt-1;
}
}
}
</style>

View file

@ -0,0 +1,76 @@
<template>
<div class="macro-recorder">
<!-- 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 />
</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'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import RecorderHeader from './parts/RecorderHeader.vue'
import RecorderFooter from './parts/RecorderFooter.vue'
const macroRecorder = useMacroRecorderStore()
</script>
<style>
@reference "@/assets/main.css";
.recorder-interface__container {
@apply relative
w-full
h-96
my-4
rounded-lg
bg-slate-900/50
border-2
border-white/10
overflow-auto
transition-colors;
&.record {
@apply border-rose-300 bg-rose-950/50;
}
&.edit {
@apply border-sky-300 bg-sky-900/50;
}
}
</style>

View file

@ -0,0 +1,39 @@
<template>
<span :class="`delay ${active ? 'active' : ''}`">
{{ value < 10000 ? value + 'ms' : '>10s' }}
</span>
</template>
<script setup>
defineProps({
value: Number,
active: Boolean,
})
</script>
<style scoped>
@reference "@/assets/main.css";
span.delay {
@apply flex
items-center
px-2 py-1
bg-slate-500
border
border-slate-400
text-slate-950
font-semibold
rounded-sm
text-sm
cursor-default;
}
.edit span.delay {
@apply cursor-pointer;
&:hover,
&.active {
@apply bg-lime-700 border-lime-500 text-lime-200;
}
}
</style>

View file

@ -0,0 +1,38 @@
<template>
<div id="delete-key-dialog" class="dialog__content">
<h4 class="text-slate-50 mb-4">Delete key</h4>
<div class="flex justify-center w-full mb-4">
<MacroKey v-if="keyObj" :key-obj="keyObj" />
</div>
<p class="text-sm text-slate-300">Are you sure you want to delete this key?</p>
<div class="flex justify-end gap-2 mt-6">
<ButtonComp variant="danger" size="sm" @click="macroRecorder.deleteEditKey()">
Delete key
</ButtonComp>
</div>
</div>
</template>
<script setup>
import ButtonComp from '@/components/base/ButtonComp.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import { onMounted, ref } from 'vue'
import MacroKey from './MacroKey.vue'
import { filterKey } from '@/services/MacroRecordService'
const macroRecorder = useMacroRecorderStore()
const keyObj = ref(null)
onMounted(() => {
keyObj.value = filterKey(macroRecorder.getEditKey())
console.log(macroRecorder.getEditKey());
console.log(keyObj.value);
console.log('---------');
})
</script>
<style scoped>
@reference "@/assets/main.css";
</style>
'

View file

@ -0,0 +1,57 @@
<template>
<div id="edit-delay-dialog" class="dialog__content">
<h4 class="text-slate-50 mb-4">Edit delay</h4>
<div v-if="editable.delay.value" class="flex justify-center">
<DelaySpan class="!text-lg" :value="editable.delay.value" />
</div>
<form class="grid gap-4 mt-6" submit.prevent>
<div v-if="editable.newDelay.value">
<input
type="number"
min="0"
max="3600000"
step="10"
v-model="editable.newDelay.value"
autofocus
/>
<span>ms</span>
</div>
<div class="flex justify-end">
<ButtonComp variant="primary" size="sm" @click.prevent="changeDelay()">
Change delay
</ButtonComp>
</div>
</form>
</div>
</template>
<script setup>
import ButtonComp from '@/components/base/ButtonComp.vue'
import { reactive, onMounted } from 'vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import DelaySpan from './DelaySpan.vue'
const macroRecorder = useMacroRecorderStore()
const editable = reactive({
delay: {},
newDelay: { value: 0 },
})
onMounted(() => {
editable.delay = macroRecorder.getEditDelay()
editable.newDelay.value = editable.delay.value
console.log(editable)
})
const changeDelay = () => {
if (!editable.newDelay.value) return
macroRecorder.recordStep(editable.newDelay.value, false, macroRecorder.state.editDelay)
macroRecorder.state.editDelay = false
}
</script>
<style scoped>
@reference "@/assets/main.css";
</style>

View file

@ -0,0 +1,103 @@
<template>
<div id="edit-key-dialog" class="dialog__content">
<h4 class="text-slate-50 mb-4">Press a key</h4>
<div class="flex justify-center" @click="$refs.newKeyInput.focus()">
<MacroKey
v-if="editable.key.keyObj"
:key-obj="editable.key.keyObj"
:direction="editable.key.direction"
/>
<template v-if="typeof editable.newKey.keyObj === 'object'">
<span class="px-4 flex items-center text-white"> >>> </span>
<MacroKey :key-obj="editable.newKey.keyObj" :direction="editable.newKey.direction" />
</template>
</div>
<form class="grid gap-4" submit.prevent>
<input
class="size-0 opacity-0"
type="text"
min="0"
max="1"
ref="newKeyInput"
placeholder="New key"
autofocus
@keydown.prevent="handleNewKey($event)"
/>
<div class="flex gap-2 justify-center">
<ButtonComp
variant="secondary"
:class="editable.newKey.direction === 'down' ? 'selected' : ''"
size="sm"
@click.prevent="handleNewDirection('down')"
>
&darr; Down
</ButtonComp>
<ButtonComp
variant="secondary"
:class="editable.newKey.direction === 'up' ? 'selected' : ''"
size="sm"
@click.prevent="handleNewDirection('up')"
>
&uarr; Up
</ButtonComp>
</div>
<div class="flex justify-end">
<ButtonComp variant="primary" size="sm" @click.prevent="changeKey()">
Change key
</ButtonComp>
</div>
</form>
</div>
</template>
<script setup>
import MacroKey from './MacroKey.vue'
import ButtonComp from '@/components/base/ButtonComp.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import { filterKey } from '@/services/MacroRecordService'
import { reactive, ref, onMounted } from 'vue'
const editable = reactive({
key: {},
newKey: {},
})
const macroRecorder = useMacroRecorderStore()
const newKeyInput = ref(null)
onMounted(() => {
editable.key = macroRecorder.getEditKey()
editable.newKey.direction = editable.key.direction
})
const handleNewKey = (e) => {
editable.newKey.e = e
editable.newKey.keyObj = filterKey(e)
}
const handleNewDirection = (direction) => {
editable.newKey.direction = direction
editable.newKey.keyObj = filterKey(editable.key)
}
const changeKey = () => {
macroRecorder.recordStep(
editable.newKey.e,
editable.newKey.direction,
macroRecorder.state.editKey,
)
macroRecorder.state.editKey = false
}
</script>
<style scoped>
@reference "@/assets/main.css";
button.selected {
@apply ring-2 ring-offset-1 ring-sky-500 bg-sky-500;
}
</style>

View file

@ -0,0 +1,64 @@
<template>
<ContextMenu>
<template #trigger>
<ButtonComp variant="secondary" size="sm"> <IconAlarmFilled />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>
<DialogComp>
<template #trigger>
<span>Custom delay</span>
</template>
<template #content>
<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))"
>
<div>
<input
type="number"
step="10"
min="0"
max="3600000"
ref="customDelayInput"
placeholder="100"
/>
<span>ms</span>
</div>
<div class="flex justify-end">
<ButtonComp variant="primary" size="sm">Set custom delay</ButtonComp>
</div>
</form>
</template>
</DialogComp>
</li>
</ul>
</template>
</ContextMenu>
</template>
<script setup>
import ContextMenu from '@/components/base/ContextMenu.vue'
import { IconAlarmFilled } from '@tabler/icons-vue'
import ButtonComp from '@/components/base/ButtonComp.vue'
import DialogComp from '@/components/base/DialogComp.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import { ref } from 'vue'
const macroRecorder = useMacroRecorderStore()
const delayMenu = ref(false)
</script>
<style scoped>
@reference "@/assets/main.css";
</style>

View file

@ -0,0 +1,41 @@
<template>
<div id="insert-key-dialog" class="dialog__content w-80">
<h4 class="text-slate-50 mb-4">Insert key {{ position }}</h4>
<div class="flex justify-center w-full mb-4">
<MacroKey v-if="keyObjs.selected" :key-obj="keyObjs.selected" />
<MacroKey v-if="keyObjs.adjacent" :key-obj="keyObjs.adjacent" />
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import MacroKey from './MacroKey.vue'
import { filterKey } from '@/services/MacroRecordService'
const props = defineProps({
position: String,
})
const macroRecorder = useMacroRecorderStore()
const keyObjs = reactive({
selected: null,
insert: null,
adjacent: null,
adjacentDelay: null,
})
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
})
</script>
<style scoped>
@reference "@/assets/main.css";
</style>

View file

@ -0,0 +1,77 @@
<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>
</template>
<script setup>
import { onMounted, reactive } from 'vue'
const props = defineProps({
keyObj: Object,
direction: String,
active: Boolean,
})
const dir = reactive({
value: false,
})
onMounted(() => {
if (props.direction) dir.value = props.direction
else dir.value = props.keyObj.direction
})
</script>
<style>
@reference "@/assets/main.css";
kbd {
@apply flex
items-center
gap-2
pl-4 pr-2 py-1
h-9
bg-slate-700
font-sans
font-bold
text-lg
text-white
uppercase
rounded-md
border
border-slate-500
transition-all
shadow-slate-500;
box-shadow: 0 0.2rem 0 0.2rem var(--tw-shadow-color);
&:has(sup) {
@apply pl-2;
}
sup {
@apply text-slate-200 text-xs font-light mt-1;
}
span.dir {
@apply text-slate-200 pl-1;
}
}
:has(kdb):not(.edit) kbd {
@apply pointer-events-none cursor-default;
}
.edit kbd {
@apply cursor-pointer pointer-events-auto;
&:hover,
&.active {
@apply bg-sky-900 border-sky-400 shadow-sky-700;
}
}
</style>

View file

@ -0,0 +1,95 @@
<template>
<div class="macro-edit__dialogs" v-if="macroRecorder.state.edit !== false">
<div
class="flex gap-2"
v-if="macroRecorder.state.editKey !== false && typeof macroRecorder.getEditKey() === 'object'"
>
<ContextMenu>
<template #trigger>
<ButtonComp variant="success" size="sm"> <IconPlus /> Insert key </ButtonComp>
</template>
<template #content>
<ul>
<li @click="insertPosition = 'before'"><IconArrowLeftCircle /> Before</li>
<li @click="insertPosition = 'after'"><IconArrowRightCircle /> After</li>
</ul>
</template>
</ContextMenu>
<DialogComp v-if="insertPosition !== null" :open="true" @on-close="insertPosition = null">
<template #content>
<InsertKeyDialog :position="insertPosition" />
</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"
>
<template #trigger>
<ButtonComp variant="primary" size="sm"> <IconKeyboard />Edit key </ButtonComp>
</template>
<template #content>
<EditKeyDialog />
</template>
</DialogComp>
</div>
<DialogComp
v-if="
macroRecorder.state.editDelay !== false && typeof macroRecorder.getEditDelay() === 'object'
"
@on-close="macroRecorder.state.editDelay = false"
>
<template #trigger>
<ButtonComp variant="primary" size="sm"> <IconAlarm />Edit delay </ButtonComp>
</template>
<template #content>
<EditDelayDialog />
</template>
</DialogComp>
</div>
</template>
<script setup>
import {
IconAlarm,
IconArrowLeftCircle,
IconArrowRightCircle,
IconKeyboard,
IconPlus,
IconTrash,
} from '@tabler/icons-vue'
import DialogComp from '@/components/base/DialogComp.vue'
import ButtonComp from '@/components/base/ButtonComp.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import EditKeyDialog from '../components/EditKeyDialog.vue'
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'
const macroRecorder = useMacroRecorderStore()
const insertPosition = ref(null)
</script>
<style scoped>
@reference "@/assets/main.css";
.macro-edit__dialogs {
@apply flex
flex-grow
justify-end;
}
</style>

View file

@ -0,0 +1,25 @@
<template>
<div class="macro-recorder__footer">
<ButtonComp
v-if="macroRecorder.steps.length > 0"
variant="danger"
size="sm"
@click="macroRecorder.reset()"
>
<IconRestore /> Reset
</ButtonComp>
</div>
</template>
<script setup>
import ButtonComp from '@/components/base/ButtonComp.vue'
import { IconRestore } from '@tabler/icons-vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
const macroRecorder = useMacroRecorderStore()
</script>
<style scoped>
@reference "@/assets/main.css";
</style>

View file

@ -0,0 +1,70 @@
<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>
<div :class="`edit__buttons ${macroRecorder.state.record ? 'disabled' : ''}`">
<div>
<ButtonComp
v-if="!macroRecorder.state.edit"
variant="secondary"
@click="macroRecorder.state.edit = true"
>
<IconPencil />Edit macro
</ButtonComp>
<ButtonComp
v-if="macroRecorder.state.edit"
variant="dark"
@click="macroRecorder.resetEdit()"
>
<IconPlayerStopFilled />Stop editing
</ButtonComp>
</div>
<FixedDelayMenu v-if="macroRecorder.state.edit" />
<EditDialogs />
</div>
</div>
</template>
<script setup>
import { IconPencil, IconPlayerRecordFilled, IconPlayerStopFilled } from '@tabler/icons-vue'
import ButtonComp from '@/components/base/ButtonComp.vue'
import FixedDelayMenu from '../components/FixedDelayMenu.vue'
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import EditDialogs from './EditDialogs.vue'
const macroRecorder = useMacroRecorderStore()
</script>
<style scoped>
@reference "@/assets/main.css";
.macro-recorder__header {
@apply grid
grid-cols-[auto_1fr]
items-end
gap-4;
> div {
@apply flex gap-4 items-end;
&.disabled {
@apply opacity-50 pointer-events-none cursor-not-allowed;
}
}
}
</style>

View file

@ -0,0 +1,46 @@
<template>
<div :class="`recorder-input__container ${macroRecorder.state.record && 'record'}`">
<input
v-if="macroRecorder.state.record"
:class="`macro-recorder__input ${macroRecorder.state.record && 'record'}`"
type="text"
ref="macroInput"
@focus="console.log('focus')"
@keydown.prevent="macroRecorder.recordStep($event, 'down')"
@keyup.prevent="macroRecorder.recordStep($event, 'up')"
/>
</div>
</template>
<script setup>
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import { ref, onUpdated } from 'vue'
const macroInput = ref(null)
const macroRecorder = useMacroRecorderStore()
onUpdated(() => {
if (macroRecorder.state.record) {
macroInput.value.focus()
if (macroRecorder.delay.start !== 0) macroRecorder.restartDelay()
}
})
</script>
<style scoped>
@reference "@/assets/main.css";
.recorder-input__container,
.macro-recorder__input {
@apply absolute
inset-0
size-full
opacity-0
hidden;
&.record {
@apply block;
}
}
</style>

View file

@ -0,0 +1,54 @@
<template>
<div
:class="`macro-recorder__output ${macroRecorder.state.record && 'record'} ${macroRecorder.state.edit && 'edit'}`"
>
<template v-for="(step, key) in macroRecorder.steps">
<!-- Key element -->
<template v-if="step.type === 'key'">
<MacroKey
:key="key"
:key-obj="step.keyObj"
:direction="step.direction"
:active="macroRecorder.state.editKey === key"
@click="macroRecorder.state.edit ? macroRecorder.toggleEdit('key', key) : false"
/>
</template>
<!-- Delay element -->
<template v-else-if="step.type === 'delay'">
<DelaySpan
:key="key"
:value="step.value"
:active="macroRecorder.state.editDelay === key"
@click="macroRecorder.toggleEdit('delay', key)"
/>
</template>
<!-- Spacer element -->
<hr class="spacer" />
</template>
</div>
</template>
<script setup>
import { useMacroRecorderStore } from '@/stores/macrorecorder'
import MacroKey from '../components/MacroKey.vue'
import DelaySpan from '../components/DelaySpan.vue'
const macroRecorder = useMacroRecorderStore()
</script>
<style scoped>
@reference "@/assets/main.css";
.macro-recorder__output {
@apply flex
flex-wrap
items-center
gap-y-4
p-4
absolute
top-0 left-0
h-fit;
}
</style>