Merge branch 'tweak-property-controls' into 'v3'

Improve property control UI

Closes #456

See merge request openflexure/openflexure-microscope-server!383
This commit is contained in:
Julian Stirling 2025-08-28 17:30:51 +00:00
commit bb29c50032
5 changed files with 159 additions and 39 deletions

View file

@ -6,14 +6,14 @@
<input <input
v-model="internalValue" v-model="internalValue"
class="uk-form-small numeric-setting-line-input" class="uk-form-small numeric-setting-line-input"
:class="{ edited: isEdited, flash: animateUpdate }"
type="number" type="number"
@focusin="focusIn" @focusin="focusIn"
@focusout="focusOut" @focusout="focusOut"
@keydown="keyDown" @keydown="keyDown"
@animationend="animationEnd"
/> />
<a class="button-next-to-input" @click="requestUpdate"> <sync-property-button @click="requestUpdate" />
<span class="material-symbols-outlined">refresh</span>
</a>
</div> </div>
</label> </label>
<div v-if="dataType == 'boolean'" class="input-and-buttons-container"> <div v-if="dataType == 'boolean'" class="input-and-buttons-container">
@ -27,9 +27,7 @@
/> />
{{ label }} {{ label }}
</label> </label>
<a class="button-next-to-input" @click="requestUpdate"> <sync-property-button @click="requestUpdate" />
<span class="material-symbols-outlined">refresh</span>
</a>
</div> </div>
<label v-if="dataType == 'number_array'" class="uk-form-label" <label v-if="dataType == 'number_array'" class="uk-form-label"
>{{ label }} >{{ label }}
@ -39,14 +37,15 @@
:key="i" :key="i"
v-model="internalValue[i - 1]" v-model="internalValue[i - 1]"
class="uk-form-small numeric-setting-line-input" class="uk-form-small numeric-setting-line-input"
:class="{ edited: isEdited, flash: animateUpdate }"
type="number" type="number"
@input="updateIsEdited"
@focusin="focusIn" @focusin="focusIn"
@focusout="focusOut" @focusout="focusOut"
@keydown="keyDown" @keydown="keyDown"
@animationend="animationEnd"
/> />
<a class="button-next-to-input" @click="requestUpdate"> <sync-property-button @click="requestUpdate" />
<span class="material-symbols-outlined">refresh</span>
</a>
</div> </div>
</label> </label>
<label v-if="dataType == 'number_object'" class="uk-form-label" <label v-if="dataType == 'number_object'" class="uk-form-label"
@ -57,14 +56,15 @@
<input <input
v-model="internalValue[key]" v-model="internalValue[key]"
class="uk-form-small numeric-setting-line-input" class="uk-form-small numeric-setting-line-input"
:class="{ edited: isEdited, flash: animateUpdate }"
type="number" type="number"
@input="updateIsEdited"
@focusin="focusIn" @focusin="focusIn"
@focusout="focusOut" @focusout="focusOut"
@keydown="keyDown" @keydown="keyDown"
@animationend="animationEnd"
/> />
<a class="button-next-to-input" @click="requestUpdate"> <sync-property-button @click="requestUpdate" />
<span class="material-symbols-outlined">refresh</span>
</a>
</div> </div>
</div> </div>
</label> </label>
@ -83,9 +83,15 @@
</template> </template>
<script> <script>
import syncPropertyButton from "./syncPropertyButton.vue";
export default { export default {
name: "InputFromSchema", name: "InputFromSchema",
components: {
syncPropertyButton
},
props: { props: {
dataSchema: { dataSchema: {
type: Object type: Object
@ -97,19 +103,49 @@ export default {
type: String, type: String,
default: "" default: ""
}, },
animate: {
type: Boolean,
default: false
}
}, },
data() { data() {
return { return {
internalValue: this.value, // Initialise with a copy to try to prevent the this.value prop being mutated if
valueOnEnter: undefined, // the value is an array or object. For future updates we stringify and parse
focused: false // (see resetInternalValue). If we do this here there is a chance we get errors
// as internalValue is still null when rendering starts.
internalValue: Array.isArray(this.value)
? [...this.value]
: typeof this.value === 'object'
? { ...this.value }
: this.value,
// Is edited can't be computed as we mutate internalValue
isEdited: false,
animateUpdate: false,
}; };
}, },
mounted() {
if (this.value !== undefined) {
this.resetInternalValue();
}
},
watch: { watch: {
value(newValue) { value() {
this.internalValue = newValue; // Fire updateIsEdited on both value and internal value change,
// as change in value may not causse internalValue to change.
this.updateIsEdited();
this.resetInternalValue();
},
internalValue() {
this.updateIsEdited();
},
animate(updated) {
if (updated) {
this.animateUpdate = true;
}
} }
}, },
computed: { computed: {
@ -172,6 +208,12 @@ export default {
}, },
methods: { methods: {
resetInternalValue: function() {
// Whenever updatirng th internal value stringify and parse as a form of deepcopy.
// This ensure that the this.value prop is not mutated for when elements of arrays
// or objects are updated.
this.internalValue = JSON.parse(JSON.stringify(this.value));
},
requestUpdate: async function() { requestUpdate: async function() {
this.$emit("requestUpdate") this.$emit("requestUpdate")
}, },
@ -197,7 +239,32 @@ export default {
if (event.keyCode == 13) { if (event.keyCode == 13) {
this.sendValue(); this.sendValue();
} }
},
updateIsEdited: function() {
this.isEdited = this.deepStringify(this.internalValue) !== this.deepStringify(this.value);
},
animationEnd: function() {
this.animateUpdate = false;
this.$emit('animationShown');
},
deepStringify: function(val) {
// Create a json string where all internal numbers are also JSON strings. This is
// needed to robustly check if the value is updated because the raw value may be a
// number but anything typed in the input is a string. In the case of arrays or
// objects even with JSON.stringify we end up comparing ["1", 3] with [1, 3] and
// find them as not equal.
if (Array.isArray(val)) {
return JSON.stringify(val.map(String));
}
if (val && typeof val === 'object') {
const normalized = Object.fromEntries(
Object.entries(val).map(([k, v]) => [k, String(v)])
);
return JSON.stringify(normalized);
}
return JSON.stringify(String(val));
} }
} }
}; };
</script> </script>
@ -217,11 +284,16 @@ export default {
margin-right: 5px; margin-right: 5px;
width: 6em; width: 6em;
} }
.button-next-to-input { .edited {
flex-grow: 0; background-color: #fff3cd;
padding-left: 5px; }
padding-right: 5px; @keyframes green-flash {
vertical-align: middle; 0% { background-color: #3fda63; }
cursor: pointer; 100% { background-color: white; }
}
.flash {
animation: green-flash 0.7s ease;
/*Without this background-colour chrome will ignore the animation colour.*/
background-color: white;
} }
</style> </style>

View file

@ -3,8 +3,10 @@
v-model="value" v-model="value"
:data-schema="propertyDescription" :data-schema="propertyDescription"
:label="label" :label="label"
:animate="animate"
@requestUpdate="readProperty" @requestUpdate="readProperty"
@sendValue="writeProperty" @sendValue="writeProperty"
@animationShown="resetAnimate"
/> />
</template> </template>
@ -45,7 +47,8 @@ export default {
data() { data() {
return { return {
value: undefined value: undefined,
animate: false
}; };
}, },
@ -98,18 +101,25 @@ export default {
await new Promise(r => setTimeout(r, this.readBackDelay)); await new Promise(r => setTimeout(r, this.readBackDelay));
let newVal = await this.readProperty(); let newVal = await this.readProperty();
if (newVal == requestedValue) { if (newVal == requestedValue) {
await this.modalNotify(`Set ${this.label} to ${newVal}.`); this.animate = true;
} else { } else {
this.animate = true;
await this.modalNotify( await this.modalNotify(
`Set ${this.label} to ${newVal} (requested ${requestedValue}).` `Set ${this.label} to ${newVal} (closest valid value to requested ${requestedValue}).`
); );
} }
} else { } else {
await this.modalNotify(`Set ${this.label} to ${this.value}.`); this.animate = true;
} }
} catch (error) { } catch (error) {
this.modalError(error); // Let mixin handle error // Use mixin to display error
this.modalError(error);
// Re-read property to try to update to server value
this.readProperty();
} }
},
resetAnimate: function() {
this.animate = false;
} }
} }
}; };

View file

@ -0,0 +1,37 @@
<template>
<a class="sync-button">
<span
class="material-symbols-outlined sync-icon"
title="Reread value from microscope. Required if microscope is updated externally"
>
sync
</span>
</a>
</template>
<script>
export default {
name: "syncPropertyButton"
};
</script>
<style scoped>
.sync-button {
flex-grow: 0;
padding-left: 5px;
padding-right: 5px;
vertical-align: middle;
cursor: pointer;
}
.material-symbols-outlined.sync-icon{
color: #888;
transition: transform 0.3s ease, color 0.3s ease;
}
.material-symbols-outlined.sync-icon:hover{
transform: rotate(-90deg);
color: #c5247f;
}
</style>

View file

@ -10,8 +10,10 @@
v-model="backgroundDetectorStatus.settings" v-model="backgroundDetectorStatus.settings"
:data-schema="backgroundDetectorStatus.settings_schema" :data-schema="backgroundDetectorStatus.settings_schema"
label="" label=""
:animate="animate"
@requestUpdate="readSettings" @requestUpdate="readSettings"
@sendValue="writeSettings" @sendValue="writeSettings"
@animationShown="resetAnimate"
/> />
</div> </div>
</li> </li>
@ -57,6 +59,7 @@ export default {
data() { data() {
return { return {
backgroundDetectorStatus: undefined, backgroundDetectorStatus: undefined,
animate: false
}; };
}, },
@ -87,6 +90,11 @@ export default {
"update_detector_settings", "update_detector_settings",
{"data": requestedValue} {"data": requestedValue}
); );
this.animate = true;
this.readSettings();
},
resetAnimate: function() {
this.animate = false;
} }
}, },
async created() { async created() {

View file

@ -85,9 +85,7 @@
type="number" type="number"
@keyup.enter="startMoveTask" @keyup.enter="startMoveTask"
/> />
<a class="button-next-to-input" @click="updatePosition"> <sync-property-button @click="updatePosition" />
<span class="material-symbols-outlined">refresh</span>
</a>
</div> </div>
<p> <p>
<action-button <action-button
@ -177,13 +175,15 @@
<script> <script>
import axios from "axios"; import axios from "axios";
import ActionButton from "../../labThingsComponents/actionButton.vue"; import ActionButton from "../../labThingsComponents/actionButton.vue";
import syncPropertyButton from "../../labThingsComponents/syncPropertyButton.vue";
// Export main app // Export main app
export default { export default {
name: "PaneControl", name: "PaneControl",
components: { components: {
ActionButton ActionButton,
syncPropertyButton
}, },
data: function() { data: function() {
@ -375,11 +375,4 @@ export default {
-webkit-appearance: none; -webkit-appearance: none;
margin: 0; margin: 0;
} }
.button-next-to-input {
flex-grow: 0;
padding-left: 5px;
padding-right: 5px;
vertical-align: middle;
cursor: pointer;
}
</style> </style>