I’m making a feature wherein the user can add product images and it has an undo-redo functionality. I kept it as minimal as possible to show my issue.
I Created a minimal example of my issue in the Vue Playground:
See it here
There’s a component called ImageItem
and it is rendered in a v-for
scope. It gets a prop imageModel
containing an object, and internally it uses that imageModel
prop to access property description
and shows/changes this using a textarea. When an item to the list is added, removed and added back, it seems that the binding to the textarea’s value
attribute is broken for some reason.
Try the following in my example app:
- Click the “Add image” button. A row with a textarea is added.
- Type some text in the textarea (the image description).
- Click “Undo” button. The description change is undone (textarea becomes empty).
- Click “Undo” button again. The image item is removed (The “add” action is undone).
- Until now the behavior is as intended.
- Now, click “Redo” button. The item is added back to where it was. The textarea containing the description is still empty, since typing in it is a separate action. Still intended behavior.
- Click “Redo” again, the description in the textarea should appear now, but it isn’t. That’s is the issue.
I included the code in this question for completeness. But it’s the same code as in the Vue Playground.
App.vue
<script setup>
import { ref } from 'vue'
import ImageItem from './ImageItem.vue';
import { ProductEditModel } from './ProductEditModel';
const productEditModel = ref(new ProductEditModel());
function addImage() {
productEditModel.value.addImage();
}
function handleChangeImageDescription(imageModel, description) {
productEditModel.value.changeImageDescription(imageModel, description);
}
function undo() { productEditModel.value.editHistory.undo(); }
function redo() { productEditModel.value.editHistory.redo(); }
</script>
<template>
<div v-for="productImage of productEditModel.images" :key="productImage.key">
<ImageItem :imageModel="productImage" @changeImageDescription="handleChangeImageDescription"></ImageItem>
</div>
<button @click="undo">Undo</button>
<button @click="redo">Redo</button>
<button @click="addImage">Add image</button>
</template>
ProductImage.js
export class ProductImage {
constructor()
{
this.key = Math.random();
this.description = '';
}
}
ImageItem.vue
<script setup>
import { defineProps, defineEmits } from 'vue';
const emit = defineEmits(['removeImage', 'changeImage', 'changeImageDescription']);
const props = defineProps({
imageModel: {
type: Object,
required: true
}
});
function handleDescriptionChange(description)
{
emit('changeImageDescription', props.imageModel, description);
}
</script>
<template>
<div class="image-item">
<img src="" alt="Example image here..." />
Description:
<textarea :value="props.imageModel.description" rows="4"
@change="(event) => handleDescriptionChange(event.target.value)" />
</div>
</template>
<style>
.image-item {
border: 1px solid #aaa;
padding: 10px;
}
.image-item * {
vertical-align: middle;
}
img {
min-width: 100px;
min-height: 100px;
border: 1px solid #d0d0d0;
}
</style>
EditHistory.js
export class EditHistory
{
constructor()
{
this.undoStack = [];
this.redoStack = [];
}
do(action)
{
action.execute();
this.undoStack.push(action);
this.redoStack = [];
}
undo(count = 1)
{
count = Math.min(this.undoStack.length, count);
for (let i = count; i > 0; i--)
{
const undoAction = this.undoStack.pop();
undoAction.unExecute();
this.redoStack.push(undoAction);
}
}
redo(count = 1)
{
count = Math.min(this.redoStack.length, count);
for (let i = count; i > 0; i--)
{
const redoAction = this.redoStack.pop();
redoAction.execute();
this.undoStack.push(redoAction);
}
}
canUndo()
{
return this.undoStack.length > 0;
}
canRedo()
{
return this.redoStack.length > 0;
}
}
ProductEditModel.js
import { AddImageAction } from "./AddImageAction";
import { ChangeImageDescriptionAction } from "./ChangeImageDescriptionAction";
import { EditHistory } from "./EditHistory";
export class ProductEditModel
{
constructor()
{
this.images = []; // Array of ProductImage instances.
this.editHistory = new EditHistory();
}
addImage()
{
this.editHistory.do(new AddImageAction(this));
}
changeImageDescription(imageModel, description)
{
this.editHistory.do(new ChangeImageDescriptionAction(this, imageModel, description));
}
}
AddImageAction.js
import { ProductImage } from './ProductImage.js';
export class AddImageAction
{
constructor(productEditModel)
{
this.productEditModel = productEditModel;
this.addedImageModel = null;
}
getDescription()
{
return "Afbeelding toevoegen";
}
execute()
{
this.addedImageModel = new ProductImage();
this.productEditModel.images.push(this.addedImageModel);
}
unExecute()
{
const index = this.productEditModel.images.indexOf(this.addedImageModel);
console.assert(index > -1);
this.productEditModel.images.splice(index, 1);
}
}
ChangeImageDescriptionAction.js
export class ChangeImageDescriptionAction
{
constructor(productEditModel, productImageEditModel, description)
{
this.productEditModel = productEditModel;
this.productImageEditModel = productImageEditModel;
this.description = description;
this.originalDescription = this.productImageEditModel.description;
}
getDescription()
{
return "Afbeelding omschrijving veranderen";
}
execute()
{
this.productImageEditModel.description = this.description;
console.log("Changed description to: " + this.description);
}
unExecute()
{
this.productImageEditModel.description = this.originalDescription;
console.log("Changed description back to original: " + this.originalDescription);
}
}