Add the range slider design to the NumberInput widget (#839)
* Add range slider to NumberInput * Cleanup * Fix event ordering causing bug in Firefox * Polish the code * Switch number input modes to range in relevant places
This commit is contained in:
parent
18507b78ac
commit
782f528279
|
|
@ -99,12 +99,13 @@ impl PropertyHolder for NewDocumentDialogMessageHandler {
|
||||||
direction: SeparatorDirection::Horizontal,
|
direction: SeparatorDirection::Horizontal,
|
||||||
})),
|
})),
|
||||||
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
||||||
value: Some(self.dimensions.x as f64),
|
|
||||||
label: "W".into(),
|
label: "W".into(),
|
||||||
unit: " px".into(),
|
unit: " px".into(),
|
||||||
disabled: self.infinite,
|
value: Some(self.dimensions.x as f64),
|
||||||
is_integer: true,
|
|
||||||
min: Some(0.),
|
min: Some(0.),
|
||||||
|
is_integer: true,
|
||||||
|
disabled: self.infinite,
|
||||||
|
min_width: 100,
|
||||||
on_update: WidgetCallback::new(|number_input: &NumberInput| NewDocumentDialogMessage::DimensionsX(number_input.value.unwrap()).into()),
|
on_update: WidgetCallback::new(|number_input: &NumberInput| NewDocumentDialogMessage::DimensionsX(number_input.value.unwrap()).into()),
|
||||||
..NumberInput::default()
|
..NumberInput::default()
|
||||||
})),
|
})),
|
||||||
|
|
@ -113,12 +114,13 @@ impl PropertyHolder for NewDocumentDialogMessageHandler {
|
||||||
direction: SeparatorDirection::Horizontal,
|
direction: SeparatorDirection::Horizontal,
|
||||||
})),
|
})),
|
||||||
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
||||||
value: Some(self.dimensions.y as f64),
|
|
||||||
label: "H".into(),
|
label: "H".into(),
|
||||||
unit: " px".into(),
|
unit: " px".into(),
|
||||||
disabled: self.infinite,
|
value: Some(self.dimensions.y as f64),
|
||||||
is_integer: true,
|
|
||||||
min: Some(0.),
|
min: Some(0.),
|
||||||
|
is_integer: true,
|
||||||
|
disabled: self.infinite,
|
||||||
|
min_width: 100,
|
||||||
on_update: WidgetCallback::new(|number_input: &NumberInput| NewDocumentDialogMessage::DimensionsY(number_input.value.unwrap()).into()),
|
on_update: WidgetCallback::new(|number_input: &NumberInput| NewDocumentDialogMessage::DimensionsY(number_input.value.unwrap()).into()),
|
||||||
..NumberInput::default()
|
..NumberInput::default()
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -153,8 +153,18 @@ pub struct InvisibleStandinInput {
|
||||||
#[derive(Clone, Serialize, Deserialize, Derivative)]
|
#[derive(Clone, Serialize, Deserialize, Derivative)]
|
||||||
#[derivative(Debug, PartialEq, Default)]
|
#[derivative(Debug, PartialEq, Default)]
|
||||||
pub struct NumberInput {
|
pub struct NumberInput {
|
||||||
|
// Label
|
||||||
pub label: String,
|
pub label: String,
|
||||||
|
|
||||||
|
pub tooltip: String,
|
||||||
|
|
||||||
|
#[serde(skip)]
|
||||||
|
pub tooltip_shortcut: Option<ActionKeys>,
|
||||||
|
|
||||||
|
// Disabled
|
||||||
|
pub disabled: bool,
|
||||||
|
|
||||||
|
// Value
|
||||||
pub value: Option<f64>,
|
pub value: Option<f64>,
|
||||||
|
|
||||||
pub min: Option<f64>,
|
pub min: Option<f64>,
|
||||||
|
|
@ -164,6 +174,7 @@ pub struct NumberInput {
|
||||||
#[serde(rename = "isInteger")]
|
#[serde(rename = "isInteger")]
|
||||||
pub is_integer: bool,
|
pub is_integer: bool,
|
||||||
|
|
||||||
|
// Number presentation
|
||||||
#[serde(rename = "displayDecimalPlaces")]
|
#[serde(rename = "displayDecimalPlaces")]
|
||||||
#[derivative(Default(value = "3"))]
|
#[derivative(Default(value = "3"))]
|
||||||
pub display_decimal_places: u32,
|
pub display_decimal_places: u32,
|
||||||
|
|
@ -174,23 +185,25 @@ pub struct NumberInput {
|
||||||
#[derivative(Default(value = "true"))]
|
#[derivative(Default(value = "true"))]
|
||||||
pub unit_is_hidden_when_editing: bool,
|
pub unit_is_hidden_when_editing: bool,
|
||||||
|
|
||||||
|
// Mode behavior
|
||||||
|
pub mode: NumberInputMode,
|
||||||
|
|
||||||
#[serde(rename = "incrementBehavior")]
|
#[serde(rename = "incrementBehavior")]
|
||||||
pub increment_behavior: NumberInputIncrementBehavior,
|
pub increment_behavior: NumberInputIncrementBehavior,
|
||||||
|
|
||||||
#[serde(rename = "incrementFactor")]
|
|
||||||
#[derivative(Default(value = "1."))]
|
#[derivative(Default(value = "1."))]
|
||||||
pub increment_factor: f64,
|
pub step: f64,
|
||||||
|
|
||||||
pub disabled: bool,
|
#[serde(rename = "rangeMin")]
|
||||||
|
pub range_min: Option<f64>,
|
||||||
|
|
||||||
|
#[serde(rename = "rangeMax")]
|
||||||
|
pub range_max: Option<f64>,
|
||||||
|
|
||||||
|
// Styling
|
||||||
#[serde(rename = "minWidth")]
|
#[serde(rename = "minWidth")]
|
||||||
pub min_width: u32,
|
pub min_width: u32,
|
||||||
|
|
||||||
pub tooltip: String,
|
|
||||||
|
|
||||||
#[serde(skip)]
|
|
||||||
pub tooltip_shortcut: Option<ActionKeys>,
|
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||||
|
|
@ -213,6 +226,13 @@ pub enum NumberInputIncrementBehavior {
|
||||||
Callback,
|
Callback,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum NumberInputMode {
|
||||||
|
#[default]
|
||||||
|
Increment,
|
||||||
|
Range,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default, Derivative, Serialize, Deserialize)]
|
#[derive(Clone, Default, Derivative, Serialize, Deserialize)]
|
||||||
#[derivative(Debug, PartialEq)]
|
#[derivative(Debug, PartialEq)]
|
||||||
pub struct OptionalInput {
|
pub struct OptionalInput {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ use crate::messages::input_mapper::utility_types::macros::action_keys;
|
||||||
use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, Widget, WidgetCallback, WidgetHolder, WidgetLayout};
|
use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, Widget, WidgetCallback, WidgetHolder, WidgetLayout};
|
||||||
use crate::messages::layout::utility_types::misc::LayoutTarget;
|
use crate::messages::layout::utility_types::misc::LayoutTarget;
|
||||||
use crate::messages::layout::utility_types::widgets::button_widgets::{IconButton, PopoverButton};
|
use crate::messages::layout::utility_types::widgets::button_widgets::{IconButton, PopoverButton};
|
||||||
use crate::messages::layout::utility_types::widgets::input_widgets::{DropdownEntryData, DropdownInput, NumberInput, NumberInputIncrementBehavior, OptionalInput, RadioEntryData, RadioInput};
|
use crate::messages::layout::utility_types::widgets::input_widgets::{
|
||||||
|
DropdownEntryData, DropdownInput, NumberInput, NumberInputIncrementBehavior, NumberInputMode, OptionalInput, RadioEntryData, RadioInput,
|
||||||
|
};
|
||||||
use crate::messages::layout::utility_types::widgets::label_widgets::{Separator, SeparatorDirection, SeparatorType};
|
use crate::messages::layout::utility_types::widgets::label_widgets::{Separator, SeparatorDirection, SeparatorType};
|
||||||
use crate::messages::portfolio::document::properties_panel::utility_types::PropertiesPanelMessageHandlerData;
|
use crate::messages::portfolio::document::properties_panel::utility_types::PropertiesPanelMessageHandlerData;
|
||||||
use crate::messages::portfolio::document::utility_types::clipboards::Clipboard;
|
use crate::messages::portfolio::document::utility_types::clipboards::Clipboard;
|
||||||
|
|
@ -1684,7 +1686,7 @@ impl DocumentMessageHandler {
|
||||||
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
||||||
unit: "°".into(),
|
unit: "°".into(),
|
||||||
value: Some(rotation_value),
|
value: Some(rotation_value),
|
||||||
increment_factor: 15.,
|
step: 15.,
|
||||||
on_update: WidgetCallback::new(|number_input: &NumberInput| {
|
on_update: WidgetCallback::new(|number_input: &NumberInput| {
|
||||||
NavigationMessage::SetCanvasRotation {
|
NavigationMessage::SetCanvasRotation {
|
||||||
angle_radians: number_input.value.unwrap() * (std::f64::consts::PI / 180.),
|
angle_radians: number_input.value.unwrap() * (std::f64::consts::PI / 180.),
|
||||||
|
|
@ -1834,6 +1836,7 @@ impl DocumentMessageHandler {
|
||||||
value: opacity.map(|opacity| opacity * 100.),
|
value: opacity.map(|opacity| opacity * 100.),
|
||||||
min: Some(0.),
|
min: Some(0.),
|
||||||
max: Some(100.),
|
max: Some(100.),
|
||||||
|
mode: NumberInputMode::Range,
|
||||||
on_update: WidgetCallback::new(|number_input: &NumberInput| {
|
on_update: WidgetCallback::new(|number_input: &NumberInput| {
|
||||||
if let Some(value) = number_input.value {
|
if let Some(value) = number_input.value {
|
||||||
DocumentMessage::SetOpacityForSelectedLayers { opacity: value / 100. }.into()
|
DocumentMessage::SetOpacityForSelectedLayers { opacity: value / 100. }.into()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use crate::messages::layout::utility_types::misc::LayoutTarget;
|
||||||
use crate::messages::layout::utility_types::widgets::assist_widgets::PivotAssist;
|
use crate::messages::layout::utility_types::widgets::assist_widgets::PivotAssist;
|
||||||
use crate::messages::layout::utility_types::widgets::button_widgets::{IconButton, PopoverButton, TextButton};
|
use crate::messages::layout::utility_types::widgets::button_widgets::{IconButton, PopoverButton, TextButton};
|
||||||
use crate::messages::layout::utility_types::widgets::input_widgets::{
|
use crate::messages::layout::utility_types::widgets::input_widgets::{
|
||||||
CheckboxInput, ColorInput, DropdownEntryData, DropdownInput, FontInput, NumberInput, RadioEntryData, RadioInput, TextAreaInput, TextInput,
|
CheckboxInput, ColorInput, DropdownEntryData, DropdownInput, FontInput, NumberInput, NumberInputMode, RadioEntryData, RadioInput, TextAreaInput, TextInput,
|
||||||
};
|
};
|
||||||
use crate::messages::layout::utility_types::widgets::label_widgets::{IconLabel, Separator, SeparatorDirection, SeparatorType, TextLabel};
|
use crate::messages::layout::utility_types::widgets::label_widgets::{IconLabel, Separator, SeparatorDirection, SeparatorType, TextLabel};
|
||||||
use crate::messages::portfolio::utility_types::{ImaginateServerStatus, PersistentData};
|
use crate::messages::portfolio::utility_types::{ImaginateServerStatus, PersistentData};
|
||||||
|
|
@ -405,8 +405,10 @@ fn node_section_transform(layer: &Layer, persistent_data: &PersistentData) -> La
|
||||||
})),
|
})),
|
||||||
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
||||||
value: Some(layer.transform.rotation() * 180. / PI),
|
value: Some(layer.transform.rotation() * 180. / PI),
|
||||||
label: "".into(),
|
|
||||||
unit: "°".into(),
|
unit: "°".into(),
|
||||||
|
mode: NumberInputMode::Range,
|
||||||
|
range_min: Some(-180.),
|
||||||
|
range_max: Some(180.),
|
||||||
on_update: WidgetCallback::new(|number_input: &NumberInput| {
|
on_update: WidgetCallback::new(|number_input: &NumberInput| {
|
||||||
PropertiesPanelMessage::ModifyTransform {
|
PropertiesPanelMessage::ModifyTransform {
|
||||||
value: number_input.value.unwrap() / 180. * PI,
|
value: number_input.value.unwrap() / 180. * PI,
|
||||||
|
|
@ -776,6 +778,10 @@ fn node_section_imaginate(imaginate_layer: &ImaginateLayer, layer: &Layer, persi
|
||||||
})),
|
})),
|
||||||
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
||||||
value: Some(imaginate_layer.samples.into()),
|
value: Some(imaginate_layer.samples.into()),
|
||||||
|
mode: NumberInputMode::Range,
|
||||||
|
range_min: Some(0.),
|
||||||
|
range_max: Some(150.),
|
||||||
|
is_integer: true,
|
||||||
min: Some(0.),
|
min: Some(0.),
|
||||||
max: Some(150.),
|
max: Some(150.),
|
||||||
tooltip,
|
tooltip,
|
||||||
|
|
@ -862,8 +868,12 @@ fn node_section_imaginate(imaginate_layer: &ImaginateLayer, layer: &Layer, persi
|
||||||
})),
|
})),
|
||||||
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
||||||
value: Some(imaginate_layer.denoising_strength),
|
value: Some(imaginate_layer.denoising_strength),
|
||||||
|
mode: NumberInputMode::Range,
|
||||||
|
range_min: Some(0.),
|
||||||
|
range_max: Some(1.),
|
||||||
min: Some(0.),
|
min: Some(0.),
|
||||||
max: Some(1.),
|
max: Some(1.),
|
||||||
|
display_decimal_places: 2,
|
||||||
disabled: !imaginate_layer.use_img2img,
|
disabled: !imaginate_layer.use_img2img,
|
||||||
tooltip,
|
tooltip,
|
||||||
on_update: WidgetCallback::new(move |number_input: &NumberInput| {
|
on_update: WidgetCallback::new(move |number_input: &NumberInput| {
|
||||||
|
|
@ -894,6 +904,9 @@ fn node_section_imaginate(imaginate_layer: &ImaginateLayer, layer: &Layer, persi
|
||||||
})),
|
})),
|
||||||
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
WidgetHolder::new(Widget::NumberInput(NumberInput {
|
||||||
value: Some(imaginate_layer.cfg_scale),
|
value: Some(imaginate_layer.cfg_scale),
|
||||||
|
mode: NumberInputMode::Range,
|
||||||
|
range_min: Some(0.),
|
||||||
|
range_max: Some(30.),
|
||||||
min: Some(0.),
|
min: Some(0.),
|
||||||
max: Some(30.),
|
max: Some(30.),
|
||||||
tooltip,
|
tooltip,
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@
|
||||||
<LayoutCol class="hue-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-hue-picker>
|
<LayoutCol class="hue-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-hue-picker>
|
||||||
<div class="selection-pincers" :style="{ top: `${(1 - hue) * 100}%` }" v-if="!isNone"></div>
|
<div class="selection-pincers" :style="{ top: `${(1 - hue) * 100}%` }" v-if="!isNone"></div>
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
<LayoutCol class="opacity-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-opacity-picker>
|
<LayoutCol class="alpha-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-alpha-picker>
|
||||||
<div class="selection-pincers" :style="{ top: `${(1 - opacity) * 100}%` }" v-if="!isNone"></div>
|
<div class="selection-pincers" :style="{ top: `${(1 - alpha) * 100}%` }" v-if="!isNone"></div>
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
<LayoutCol class="details">
|
<LayoutCol class="details">
|
||||||
<LayoutRow
|
<LayoutRow
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
:value="newColor.toHexOptionalAlpha() || '-'"
|
:value="newColor.toHexOptionalAlpha() || '-'"
|
||||||
@commitText="(value: string) => setColorCode(value)"
|
@commitText="(value: string) => setColorCode(value)"
|
||||||
:centered="true"
|
:centered="true"
|
||||||
:tooltip="'Color code in hexadecimal format. 6 digits if opaque, 8 with opacity.\nAccepts input of CSS color values including named colors.'"
|
:tooltip="'Color code in hexadecimal format. 6 digits if opaque, 8 with alpha.\nAccepts input of CSS color values including named colors.'"
|
||||||
/>
|
/>
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
|
|
@ -98,12 +98,13 @@
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
:label="'Opacity'"
|
:label="'Alpha'"
|
||||||
:value="!isNone ? opacity * 100 : undefined"
|
:value="!isNone ? alpha * 100 : undefined"
|
||||||
@update:value="(value: number) => setColorOpacityPercent(value)"
|
@update:value="(value: number) => setColorAlphaPercent(value)"
|
||||||
:min="0"
|
:min="0"
|
||||||
:max="100"
|
:max="100"
|
||||||
:unit="'%'"
|
:unit="'%'"
|
||||||
|
:mode="'Range'"
|
||||||
:tooltip="`Scale from transparent (0%) to opaque (100%) for the color's alpha channel`"
|
:tooltip="`Scale from transparent (0%) to opaque (100%) for the color's alpha channel`"
|
||||||
/>
|
/>
|
||||||
<LayoutRow class="leftover-space"></LayoutRow>
|
<LayoutRow class="leftover-space"></LayoutRow>
|
||||||
|
|
@ -141,14 +142,14 @@
|
||||||
|
|
||||||
.saturation-value-picker,
|
.saturation-value-picker,
|
||||||
.hue-picker,
|
.hue-picker,
|
||||||
.opacity-picker {
|
.alpha-picker {
|
||||||
height: 256px;
|
height: 256px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hue-picker,
|
.hue-picker,
|
||||||
.opacity-picker {
|
.alpha-picker {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -161,7 +162,7 @@
|
||||||
--selection-pincers-color: var(--hue-color-contrasting);
|
--selection-pincers-color: var(--hue-color-contrasting);
|
||||||
}
|
}
|
||||||
|
|
||||||
.opacity-picker {
|
.alpha-picker {
|
||||||
background: linear-gradient(to bottom, var(--opaque-color), transparent);
|
background: linear-gradient(to bottom, var(--opaque-color), transparent);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
|
|
@ -403,12 +404,12 @@ export default defineComponent({
|
||||||
hue: hsva.h,
|
hue: hsva.h,
|
||||||
saturation: hsva.s,
|
saturation: hsva.s,
|
||||||
value: hsva.v,
|
value: hsva.v,
|
||||||
opacity: hsva.a,
|
alpha: hsva.a,
|
||||||
isNone: hsvaOrNone === undefined,
|
isNone: hsvaOrNone === undefined,
|
||||||
initialHue: hsva.h,
|
initialHue: hsva.h,
|
||||||
initialSaturation: hsva.s,
|
initialSaturation: hsva.s,
|
||||||
initialValue: hsva.v,
|
initialValue: hsva.v,
|
||||||
initialOpacity: hsva.a,
|
initialAlpha: hsva.a,
|
||||||
initialIsNone: hsvaOrNone === undefined,
|
initialIsNone: hsvaOrNone === undefined,
|
||||||
draggingPickerTrack: undefined as HTMLDivElement | undefined,
|
draggingPickerTrack: undefined as HTMLDivElement | undefined,
|
||||||
colorSpaceChoices: COLOR_SPACE_CHOICES,
|
colorSpaceChoices: COLOR_SPACE_CHOICES,
|
||||||
|
|
@ -421,11 +422,11 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
newColor(): Color {
|
newColor(): Color {
|
||||||
if (this.isNone) return new Color("none");
|
if (this.isNone) return new Color("none");
|
||||||
return new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.opacity });
|
return new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.alpha });
|
||||||
},
|
},
|
||||||
initialColor(): Color {
|
initialColor(): Color {
|
||||||
if (this.initialIsNone) return new Color("none");
|
if (this.initialIsNone) return new Color("none");
|
||||||
return new Color({ h: this.initialHue, s: this.initialSaturation, v: this.initialValue, a: this.initialOpacity });
|
return new Color({ h: this.initialHue, s: this.initialSaturation, v: this.initialValue, a: this.initialAlpha });
|
||||||
},
|
},
|
||||||
black(): Color {
|
black(): Color {
|
||||||
return new Color(0, 0, 0, 1);
|
return new Color(0, 0, 0, 1);
|
||||||
|
|
@ -434,7 +435,7 @@ export default defineComponent({
|
||||||
watch: {
|
watch: {
|
||||||
// Called only when `open` is changed from outside this component (with v-model)
|
// Called only when `open` is changed from outside this component (with v-model)
|
||||||
open(isOpen: boolean) {
|
open(isOpen: boolean) {
|
||||||
if (isOpen) this.setInitialHsvAndOpacity(this.hue, this.saturation, this.value, this.opacity, this.isNone);
|
if (isOpen) this.setInitialHSVA(this.hue, this.saturation, this.value, this.alpha, this.isNone);
|
||||||
},
|
},
|
||||||
// Called only when `color` is changed from outside this component (with v-model)
|
// Called only when `color` is changed from outside this component (with v-model)
|
||||||
color(color: Color) {
|
color(color: Color) {
|
||||||
|
|
@ -451,19 +452,19 @@ export default defineComponent({
|
||||||
if (hsva.v !== 0) this.saturation = hsva.s;
|
if (hsva.v !== 0) this.saturation = hsva.s;
|
||||||
// Update the value
|
// Update the value
|
||||||
this.value = hsva.v;
|
this.value = hsva.v;
|
||||||
// Update the opacity
|
// Update the alpha
|
||||||
this.opacity = hsva.a;
|
this.alpha = hsva.a;
|
||||||
// Update the status of this not being a color
|
// Update the status of this not being a color
|
||||||
this.isNone = false;
|
this.isNone = false;
|
||||||
} else {
|
} else {
|
||||||
this.setNewHsvAndOpacity(0, 0, 0, 1, true);
|
this.setNewHSVA(0, 0, 0, 1, true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onPointerDown(e: PointerEvent) {
|
onPointerDown(e: PointerEvent) {
|
||||||
const target = (e.target || undefined) as HTMLElement | undefined;
|
const target = (e.target || undefined) as HTMLElement | undefined;
|
||||||
this.draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-opacity-picker]") || undefined;
|
this.draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-alpha-picker]") || undefined;
|
||||||
|
|
||||||
this.addEvents();
|
this.addEvents();
|
||||||
|
|
||||||
|
|
@ -484,14 +485,14 @@ export default defineComponent({
|
||||||
|
|
||||||
this.hue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
this.hue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||||
this.strayCloses = false;
|
this.strayCloses = false;
|
||||||
} else if (this.draggingPickerTrack?.hasAttribute("data-opacity-picker")) {
|
} else if (this.draggingPickerTrack?.hasAttribute("data-alpha-picker")) {
|
||||||
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
|
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
|
||||||
|
|
||||||
this.opacity = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
this.alpha = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||||
this.strayCloses = false;
|
this.strayCloses = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const color = new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.opacity });
|
const color = new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.alpha });
|
||||||
this.setColor(color);
|
this.setColor(color);
|
||||||
},
|
},
|
||||||
onPointerUp() {
|
onPointerUp() {
|
||||||
|
|
@ -512,7 +513,7 @@ export default defineComponent({
|
||||||
this.$emit("update:open", isOpen);
|
this.$emit("update:open", isOpen);
|
||||||
},
|
},
|
||||||
setColor(color?: Color) {
|
setColor(color?: Color) {
|
||||||
const colorToEmit = color || new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.opacity });
|
const colorToEmit = color || new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.alpha });
|
||||||
this.$emit("update:color", colorToEmit);
|
this.$emit("update:color", colorToEmit);
|
||||||
},
|
},
|
||||||
swapNewWithInitial() {
|
swapNewWithInitial() {
|
||||||
|
|
@ -521,11 +522,11 @@ export default defineComponent({
|
||||||
const tempHue = this.hue;
|
const tempHue = this.hue;
|
||||||
const tempSaturation = this.saturation;
|
const tempSaturation = this.saturation;
|
||||||
const tempValue = this.value;
|
const tempValue = this.value;
|
||||||
const tempOpacity = this.opacity;
|
const tempAlpha = this.alpha;
|
||||||
const tempIsNone = this.isNone;
|
const tempIsNone = this.isNone;
|
||||||
|
|
||||||
this.setNewHsvAndOpacity(this.initialHue, this.initialSaturation, this.initialValue, this.initialOpacity, this.initialIsNone);
|
this.setNewHSVA(this.initialHue, this.initialSaturation, this.initialValue, this.initialAlpha, this.initialIsNone);
|
||||||
this.setInitialHsvAndOpacity(tempHue, tempSaturation, tempValue, tempOpacity, tempIsNone);
|
this.setInitialHSVA(tempHue, tempSaturation, tempValue, tempAlpha, tempIsNone);
|
||||||
|
|
||||||
this.setColor(initial);
|
this.setColor(initial);
|
||||||
},
|
},
|
||||||
|
|
@ -545,8 +546,8 @@ export default defineComponent({
|
||||||
|
|
||||||
this.setColor();
|
this.setColor();
|
||||||
},
|
},
|
||||||
setColorOpacityPercent(opacity: number) {
|
setColorAlphaPercent(alpha: number) {
|
||||||
this.opacity = opacity / 100;
|
this.alpha = alpha / 100;
|
||||||
this.setColor();
|
this.setColor();
|
||||||
},
|
},
|
||||||
setColorPresetSubtile(e: MouseEvent) {
|
setColorPresetSubtile(e: MouseEvent) {
|
||||||
|
|
@ -557,7 +558,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
setColorPreset(preset: PresetColors) {
|
setColorPreset(preset: PresetColors) {
|
||||||
if (preset === "none") {
|
if (preset === "none") {
|
||||||
this.setNewHsvAndOpacity(0, 0, 0, 1, true);
|
this.setNewHSVA(0, 0, 0, 1, true);
|
||||||
this.setColor(new Color("none"));
|
this.setColor(new Color("none"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -565,21 +566,21 @@ export default defineComponent({
|
||||||
const presetColor = new Color(...PURE_COLORS[preset], 1);
|
const presetColor = new Color(...PURE_COLORS[preset], 1);
|
||||||
const hsva = presetColor.toHSVA() || { h: 0, s: 0, v: 0, a: 0 };
|
const hsva = presetColor.toHSVA() || { h: 0, s: 0, v: 0, a: 0 };
|
||||||
|
|
||||||
this.setNewHsvAndOpacity(hsva.h, hsva.s, hsva.v, hsva.a, false);
|
this.setNewHSVA(hsva.h, hsva.s, hsva.v, hsva.a, false);
|
||||||
this.setColor(presetColor);
|
this.setColor(presetColor);
|
||||||
},
|
},
|
||||||
setNewHsvAndOpacity(hue: number, saturation: number, value: number, opacity: number, isNone: boolean) {
|
setNewHSVA(hue: number, saturation: number, value: number, alpha: number, isNone: boolean) {
|
||||||
this.hue = hue;
|
this.hue = hue;
|
||||||
this.saturation = saturation;
|
this.saturation = saturation;
|
||||||
this.value = value;
|
this.value = value;
|
||||||
this.opacity = opacity;
|
this.alpha = alpha;
|
||||||
this.isNone = isNone;
|
this.isNone = isNone;
|
||||||
},
|
},
|
||||||
setInitialHsvAndOpacity(hue: number, saturation: number, value: number, opacity: number, isNone: boolean) {
|
setInitialHSVA(hue: number, saturation: number, value: number, alpha: number, isNone: boolean) {
|
||||||
this.initialHue = hue;
|
this.initialHue = hue;
|
||||||
this.initialSaturation = saturation;
|
this.initialSaturation = saturation;
|
||||||
this.initialValue = value;
|
this.initialValue = value;
|
||||||
this.initialOpacity = opacity;
|
this.initialAlpha = alpha;
|
||||||
this.initialIsNone = isNone;
|
this.initialIsNone = isNone;
|
||||||
},
|
},
|
||||||
async activateEyedropperSample() {
|
async activateEyedropperSample() {
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
<template>
|
<template>
|
||||||
<LayoutRow class="field-input" :class="{ disabled, 'sharp-right-corners': sharpRightCorners }" :title="tooltip">
|
<LayoutRow class="field-input" :class="{ disabled, 'sharp-right-corners': sharpRightCorners }" :title="tooltip">
|
||||||
<input
|
<input
|
||||||
|
type="text"
|
||||||
v-if="!textarea"
|
v-if="!textarea"
|
||||||
:class="{ 'has-label': label }"
|
:class="{ 'has-label': label }"
|
||||||
:id="`field-input-${id}`"
|
:id="`field-input-${id}`"
|
||||||
ref="input"
|
ref="input"
|
||||||
type="text"
|
|
||||||
v-model="inputValue"
|
v-model="inputValue"
|
||||||
:spellcheck="spellcheck"
|
:spellcheck="spellcheck"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
@change="() => $emit('textChanged')"
|
@change="() => $emit('textChanged')"
|
||||||
@keydown.enter="() => $emit('textChanged')"
|
@keydown.enter="() => $emit('textChanged')"
|
||||||
@keydown.esc="() => $emit('cancelTextChange')"
|
@keydown.esc="() => $emit('cancelTextChange')"
|
||||||
|
data-input-element
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
v-else
|
v-else
|
||||||
|
|
@ -49,10 +50,11 @@
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
label {
|
label {
|
||||||
flex: 1 1 100%;
|
flex: 0 0 auto;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
margin-left: 8px;
|
|
||||||
padding: 3px 0;
|
padding: 3px 0;
|
||||||
|
padding-right: 4px;
|
||||||
|
margin-left: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<FieldInput
|
<FieldInput
|
||||||
class="number-input"
|
class="number-input"
|
||||||
|
:class="mode.toLocaleLowerCase()"
|
||||||
v-model:value="text"
|
v-model:value="text"
|
||||||
:label="label"
|
:label="label"
|
||||||
:spellcheck="false"
|
:spellcheck="false"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:style="minWidth > 0 ? `min-width: ${minWidth}px` : ''"
|
:style="{ 'min-width': minWidth > 0 ? `${minWidth}px` : undefined, '--progress-factor': (rangeSliderValueAsRendered - rangeMin) / (rangeMax - rangeMin) }"
|
||||||
:tooltip="tooltip"
|
:tooltip="tooltip"
|
||||||
:sharpRightCorners="sharpRightCorners"
|
:sharpRightCorners="sharpRightCorners"
|
||||||
@textFocused="() => onTextFocused()"
|
@textFocused="() => onTextFocused()"
|
||||||
|
|
@ -13,21 +14,48 @@
|
||||||
@cancelTextChange="() => onCancelTextChange()"
|
@cancelTextChange="() => onCancelTextChange()"
|
||||||
ref="fieldInput"
|
ref="fieldInput"
|
||||||
>
|
>
|
||||||
<button v-if="value !== undefined" class="arrow left" @click="() => onIncrement('Decrease')" tabindex="-1"></button>
|
<button v-if="value !== undefined && mode === 'Increment' && incrementBehavior !== 'None'" class="arrow left" @click="() => onIncrement('Decrease')" tabindex="-1"></button>
|
||||||
<button v-if="value !== undefined" class="arrow right" @click="() => onIncrement('Increase')" tabindex="-1"></button>
|
<button v-if="value !== undefined && mode === 'Increment' && incrementBehavior !== 'None'" class="arrow right" @click="() => onIncrement('Increase')" tabindex="-1"></button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="slider"
|
||||||
|
:class="{ hidden: rangeSliderClickDragState === 'mousedown' }"
|
||||||
|
v-if="mode === 'Range' && value !== undefined"
|
||||||
|
v-model="rangeSliderValue"
|
||||||
|
:min="rangeMin"
|
||||||
|
:max="rangeMax"
|
||||||
|
:step="sliderStepValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
@input="() => sliderInput()"
|
||||||
|
@pointerdown="() => sliderPointerDown()"
|
||||||
|
@pointerup="() => sliderPointerUp()"
|
||||||
|
tabindex="-1"
|
||||||
|
/>
|
||||||
|
<div v-if="value !== undefined && rangeSliderClickDragState === 'mousedown'" class="fake-slider-thumb"></div>
|
||||||
|
<div v-if="value !== undefined" class="slider-progress"></div>
|
||||||
</FieldInput>
|
</FieldInput>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.number-input {
|
.number-input {
|
||||||
input:focus ~ .arrow {
|
&.increment {
|
||||||
display: none;
|
// Widen the label and input margins from the edges by an extra 8px to make room for the increment arrows
|
||||||
|
label {
|
||||||
|
margin-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="text"]:not(:focus).has-label {
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the increment arrows when entering text, disabled, or not hovered
|
||||||
|
input[type="text"]:focus ~ .arrow,
|
||||||
|
&.disabled .arrow,
|
||||||
&:not(:hover) .arrow {
|
&:not(:hover) .arrow {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Style the increment arrows
|
||||||
.arrow {
|
.arrow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -54,12 +82,12 @@
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: "";
|
content: "";
|
||||||
|
display: block;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 3px 0 3px 3px;
|
border-width: 3px 0 3px 3px;
|
||||||
border-color: transparent transparent transparent var(--color-e-nearwhite);
|
border-color: transparent transparent transparent var(--color-e-nearwhite);
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,47 +98,195 @@
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: "";
|
||||||
|
display: block;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 3px 3px 3px 0;
|
border-width: 3px 3px 3px 0;
|
||||||
border-color: transparent var(--color-e-nearwhite) transparent transparent;
|
border-color: transparent var(--color-e-nearwhite) transparent transparent;
|
||||||
display: block;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled .arrow {
|
&.range {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
label {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus ~ .slider,
|
||||||
|
input[type="text"]:focus ~ .fake-slider-thumb,
|
||||||
|
input[type="text"]:focus ~ .slider-progress {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
-webkit-appearance: none; // TODO: Prefix necessary? Test on Safari
|
||||||
|
appearance: none;
|
||||||
|
background: none;
|
||||||
|
cursor: default;
|
||||||
|
// Except when disabled, the range slider goes above the label and input so it's interactable.
|
||||||
|
// Then we use the blend mode to make it appear behind which works since the text is almost white and background almost black.
|
||||||
|
// When disabled, the blend mode trick doesn't work with the grayer colors. But we don't need it to be interactable, so it can actually go behind properly.
|
||||||
|
z-index: 2;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chromium and Safari
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none; // TODO: Prefix necessary? Test on Safari
|
||||||
|
appearance: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 4px;
|
||||||
|
height: 24px;
|
||||||
|
background: #494949; // Becomes var(--color-5-dullgray) with screen blend mode over var(--color-1-nearblack) background
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::-webkit-slider-thumb {
|
||||||
|
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
z-index: 0;
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
background: var(--color-4-dimgray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Firefox
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 4px;
|
||||||
|
height: 24px;
|
||||||
|
background: #494949; // Becomes var(--color-5-dullgray) with screen blend mode over var(--color-1-nearblack) background
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::-moz-range-thumb {
|
||||||
|
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover ~ .slider-progress::before {
|
||||||
|
background: var(--color-3-darkgray);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-track {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This fake slider thumb stays in the location of the real thumb while we have to hide the real slider between mousedown and mouseup or mousemove.
|
||||||
|
// That's because the range input element moves to the pressed location immediately upon mousedown, but we don't want to show that yet.
|
||||||
|
// Instead, we want to wait until the user does something:
|
||||||
|
// Releasing the mouse means we reset the slider to its previous location, thus canceling the slider move. In that case, we focus the text entry.
|
||||||
|
// Moving the mouse left/right means we have begun dragging, so then we hide this fake one and continue showing the actual drag of the real slider.
|
||||||
|
.fake-slider-thumb {
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-left: -2px;
|
||||||
|
left: calc(var(--progress-factor) * 100%);
|
||||||
|
width: 4px;
|
||||||
|
height: 24px;
|
||||||
|
background: #5b5b5b; // Becomes var(--color-6-lowergray) with screen blend mode over var(--color-1-nearblack) background
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-progress {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: calc(var(--progress-factor) * 100% - 2px);
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-2-mildblack);
|
||||||
|
border-radius: 1px 0 0 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, type PropType } from "vue";
|
import { defineComponent, type PropType } from "vue";
|
||||||
|
|
||||||
import { type IncrementBehavior } from "@/wasm-communication/messages";
|
import { type NumberInputMode, type NumberInputIncrementBehavior } from "@/wasm-communication/messages";
|
||||||
|
|
||||||
import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
|
import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
|
||||||
|
|
||||||
export type IncrementDirection = "Decrease" | "Increase";
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
emits: ["update:value"],
|
emits: ["update:value"],
|
||||||
props: {
|
props: {
|
||||||
|
// Label
|
||||||
label: { type: String as PropType<string>, required: false },
|
label: { type: String as PropType<string>, required: false },
|
||||||
|
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||||
|
|
||||||
|
// Disabled
|
||||||
|
disabled: { type: Boolean as PropType<boolean>, default: false },
|
||||||
|
|
||||||
|
// Value
|
||||||
value: { type: Number as PropType<number>, required: false }, // When not provided, a dash is displayed
|
value: { type: Number as PropType<number>, required: false }, // When not provided, a dash is displayed
|
||||||
min: { type: Number as PropType<number>, required: false },
|
min: { type: Number as PropType<number>, required: false },
|
||||||
max: { type: Number as PropType<number>, required: false },
|
max: { type: Number as PropType<number>, required: false },
|
||||||
isInteger: { type: Boolean as PropType<boolean>, default: false },
|
isInteger: { type: Boolean as PropType<boolean>, default: false },
|
||||||
|
|
||||||
|
// Number presentation
|
||||||
displayDecimalPlaces: { type: Number as PropType<number>, default: 3 },
|
displayDecimalPlaces: { type: Number as PropType<number>, default: 3 },
|
||||||
unit: { type: String as PropType<string>, default: "" },
|
unit: { type: String as PropType<string>, default: "" },
|
||||||
unitIsHiddenWhenEditing: { type: Boolean as PropType<boolean>, default: true },
|
unitIsHiddenWhenEditing: { type: Boolean as PropType<boolean>, default: true },
|
||||||
incrementBehavior: { type: String as PropType<IncrementBehavior>, default: "Add" },
|
|
||||||
incrementFactor: { type: Number as PropType<number>, default: 1 },
|
// Mode behavior
|
||||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
// "Increment" shows arrows and allows dragging left/right to change the value.
|
||||||
|
// "Range" shows a range slider between some minimum and maximum value.
|
||||||
|
mode: { type: String as PropType<NumberInputMode>, default: "Increment" },
|
||||||
|
// When `mode` is "Increment", `step` is the multiplier or addend used with `incrementBehavior`.
|
||||||
|
// When `mode` is "Range", `step` is the range slider's snapping increment if `isInteger` is `true`.
|
||||||
|
step: { type: Number as PropType<number>, default: 1 },
|
||||||
|
// `incrementBehavior` is only applicable with a `mode` of "Increment".
|
||||||
|
// "Add"/"Multiply": The value is added or multiplied by `step`.
|
||||||
|
// "None": the increment arrows are not shown.
|
||||||
|
// "Callback": the functions `incrementCallbackIncrease` and `incrementCallbackDecrease` call custom behavior.
|
||||||
|
incrementBehavior: { type: String as PropType<NumberInputIncrementBehavior>, default: "Add" },
|
||||||
|
// `rangeMin` and `rangeMax` are only applicable with a `mode` of "Range".
|
||||||
|
// They set the lower and upper values of the slider to drag between.
|
||||||
|
rangeMin: { type: Number as PropType<number>, default: 0 },
|
||||||
|
rangeMax: { type: Number as PropType<number>, default: 1 },
|
||||||
|
|
||||||
|
// Styling
|
||||||
minWidth: { type: Number as PropType<number>, default: 0 },
|
minWidth: { type: Number as PropType<number>, default: 0 },
|
||||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
|
||||||
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
|
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
|
|
@ -121,9 +297,76 @@ export default defineComponent({
|
||||||
return {
|
return {
|
||||||
text: this.displayText(this.value),
|
text: this.displayText(this.value),
|
||||||
editing: false,
|
editing: false,
|
||||||
|
// Stays in sync with a binding to the actual input range slider element.
|
||||||
|
rangeSliderValue: this.value !== undefined ? this.value : 0,
|
||||||
|
// Value used to render the position of the fake slider when applicable, and length of the progress colored region to the slider's left.
|
||||||
|
// This is the same as `rangeSliderValue` except in the "mousedown" state, when it has the previous location before the user's mousedown.
|
||||||
|
rangeSliderValueAsRendered: this.value !== undefined ? this.value : 0,
|
||||||
|
// "default": no interaction is happening.
|
||||||
|
// "mousedown": the user has pressed down the mouse and might next decide to either drag left/right or release without dragging.
|
||||||
|
// "dragging": the user is dragging the slider left/right.
|
||||||
|
rangeSliderClickDragState: "default" as "default" | "mousedown" | "dragging",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
sliderStepValue() {
|
||||||
|
const step = this.step === undefined ? 1 : this.step;
|
||||||
|
return this.isInteger ? step : "any";
|
||||||
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
sliderInput() {
|
||||||
|
// Keep only 4 digits after the decimal point
|
||||||
|
const ROUNDING_EXPONENT = 4;
|
||||||
|
const ROUNDING_MAGNITUDE = 10 ** ROUNDING_EXPONENT;
|
||||||
|
const roundedValue = Math.round(this.rangeSliderValue * ROUNDING_MAGNITUDE) / ROUNDING_MAGNITUDE;
|
||||||
|
|
||||||
|
// Exit if this is an extraneous event invocation that occurred after mouseup, which happens in Firefox
|
||||||
|
if (this.value !== undefined && Math.abs(this.value - roundedValue) < 1 / ROUNDING_MAGNITUDE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first event upon mousedown means we transition to a "mousedown" state
|
||||||
|
if (this.rangeSliderClickDragState === "default") {
|
||||||
|
this.rangeSliderClickDragState = "mousedown";
|
||||||
|
|
||||||
|
// Exit early because we don't want to use the value set by where on the track the user pressed
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The second event upon mousedown that occurs by moving left or right means the user has committed to dragging the slider
|
||||||
|
if (this.rangeSliderClickDragState === "mousedown") {
|
||||||
|
this.rangeSliderClickDragState = "dragging";
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're in a dragging state, we want to use the new slider value
|
||||||
|
this.rangeSliderValueAsRendered = roundedValue;
|
||||||
|
this.updateValue(roundedValue);
|
||||||
|
},
|
||||||
|
sliderPointerDown() {
|
||||||
|
// We want to render the fake slider thumb at the old position, which is still the number held by `value`
|
||||||
|
this.rangeSliderValueAsRendered = this.value || 0;
|
||||||
|
|
||||||
|
// Because an `input` event is fired right before or after this (depending on browser), that first
|
||||||
|
// invocation will transition the state machine to `mousedown`. That's why we don't do it here.
|
||||||
|
},
|
||||||
|
sliderPointerUp() {
|
||||||
|
// User clicked but didn't drag, so we focus the text input element
|
||||||
|
if (this.rangeSliderClickDragState === "mousedown") {
|
||||||
|
const fieldInput = this.$refs.fieldInput as typeof FieldInput | undefined;
|
||||||
|
const inputElement = fieldInput?.$el.querySelector("[data-input-element]") as HTMLInputElement | undefined;
|
||||||
|
if (!inputElement) return;
|
||||||
|
|
||||||
|
// Set the slider position back to the original position to undo the user moving it
|
||||||
|
this.rangeSliderValue = this.rangeSliderValueAsRendered;
|
||||||
|
|
||||||
|
// Begin editing the number text field
|
||||||
|
inputElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Releasing the mouse means we can reset the state machine
|
||||||
|
this.rangeSliderClickDragState = "default";
|
||||||
|
},
|
||||||
onTextFocused() {
|
onTextFocused() {
|
||||||
if (this.value === undefined) this.text = "";
|
if (this.value === undefined) this.text = "";
|
||||||
else if (this.unitIsHiddenWhenEditing) this.text = `${this.value}`;
|
else if (this.unitIsHiddenWhenEditing) this.text = `${this.value}`;
|
||||||
|
|
@ -155,16 +398,16 @@ export default defineComponent({
|
||||||
|
|
||||||
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus();
|
(this.$refs.fieldInput as typeof FieldInput | undefined)?.unFocus();
|
||||||
},
|
},
|
||||||
onIncrement(direction: IncrementDirection) {
|
onIncrement(direction: "Decrease" | "Increase") {
|
||||||
if (this.value === undefined) return;
|
if (this.value === undefined) return;
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
Add: (): void => {
|
Add: (): void => {
|
||||||
const directionAddend = direction === "Increase" ? this.incrementFactor : -this.incrementFactor;
|
const directionAddend = direction === "Increase" ? this.step : -this.step;
|
||||||
this.updateValue(this.value !== undefined ? this.value + directionAddend : undefined);
|
this.updateValue(this.value !== undefined ? this.value + directionAddend : undefined);
|
||||||
},
|
},
|
||||||
Multiply: (): void => {
|
Multiply: (): void => {
|
||||||
const directionMultiplier = direction === "Increase" ? this.incrementFactor : 1 / this.incrementFactor;
|
const directionMultiplier = direction === "Increase" ? this.step : 1 / this.step;
|
||||||
this.updateValue(this.value !== undefined ? this.value * directionMultiplier : undefined);
|
this.updateValue(this.value !== undefined ? this.value * directionMultiplier : undefined);
|
||||||
},
|
},
|
||||||
Callback: (): void => {
|
Callback: (): void => {
|
||||||
|
|
@ -207,11 +450,16 @@ export default defineComponent({
|
||||||
watch: {
|
watch: {
|
||||||
// Called only when `value` is changed from outside this component (with v-model)
|
// Called only when `value` is changed from outside this component (with v-model)
|
||||||
value(newValue: number | undefined) {
|
value(newValue: number | undefined) {
|
||||||
|
// Draw a dash if the value is undefined
|
||||||
if (newValue === undefined) {
|
if (newValue === undefined) {
|
||||||
this.text = "-";
|
this.text = "-";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the range slider with the new value
|
||||||
|
this.rangeSliderValue = newValue;
|
||||||
|
this.rangeSliderValueAsRendered = newValue;
|
||||||
|
|
||||||
// The simple `clamp()` function can't be used here since `undefined` values need to be boundless
|
// The simple `clamp()` function can't be used here since `undefined` values need to be boundless
|
||||||
let sanitized = newValue;
|
let sanitized = newValue;
|
||||||
if (typeof this.min === "number") sanitized = Math.max(sanitized, this.min);
|
if (typeof this.min === "number") sanitized = Math.max(sanitized, this.min);
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&.centered {
|
&.centered {
|
||||||
input {
|
input:not(:focus) {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -38,12 +38,19 @@ import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
emits: ["update:value", "commitText"],
|
emits: ["update:value", "commitText"],
|
||||||
props: {
|
props: {
|
||||||
value: { type: String as PropType<string>, required: true },
|
// Label
|
||||||
label: { type: String as PropType<string>, required: false },
|
label: { type: String as PropType<string>, required: false },
|
||||||
|
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||||
|
|
||||||
|
// Disabled
|
||||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
disabled: { type: Boolean as PropType<boolean>, default: false },
|
||||||
|
|
||||||
|
// Value
|
||||||
|
value: { type: String as PropType<string>, required: true },
|
||||||
|
|
||||||
|
// Styling
|
||||||
centered: { type: Boolean as PropType<boolean>, default: false },
|
centered: { type: Boolean as PropType<boolean>, default: false },
|
||||||
minWidth: { type: Number as PropType<number>, default: 0 },
|
minWidth: { type: Number as PropType<number>, default: 0 },
|
||||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
|
||||||
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
|
sharpRightCorners: { type: Boolean as PropType<boolean>, default: false },
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|
|
||||||
|
|
@ -816,11 +816,23 @@ export class IconLabel extends WidgetProps {
|
||||||
tooltip!: string | undefined;
|
tooltip!: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IncrementBehavior = "Add" | "Multiply" | "Callback" | "None";
|
export type NumberInputIncrementBehavior = "Add" | "Multiply" | "Callback" | "None";
|
||||||
|
export type NumberInputMode = "Increment" | "Range";
|
||||||
|
|
||||||
export class NumberInput extends WidgetProps {
|
export class NumberInput extends WidgetProps {
|
||||||
|
// Label
|
||||||
|
|
||||||
label!: string | undefined;
|
label!: string | undefined;
|
||||||
|
|
||||||
|
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
||||||
|
tooltip!: string | undefined;
|
||||||
|
|
||||||
|
// Disabled
|
||||||
|
|
||||||
|
disabled!: boolean;
|
||||||
|
|
||||||
|
// Value
|
||||||
|
|
||||||
value!: number | undefined;
|
value!: number | undefined;
|
||||||
|
|
||||||
min!: number | undefined;
|
min!: number | undefined;
|
||||||
|
|
@ -829,22 +841,29 @@ export class NumberInput extends WidgetProps {
|
||||||
|
|
||||||
isInteger!: boolean;
|
isInteger!: boolean;
|
||||||
|
|
||||||
|
// Number presentation
|
||||||
|
|
||||||
displayDecimalPlaces!: number;
|
displayDecimalPlaces!: number;
|
||||||
|
|
||||||
unit!: string;
|
unit!: string;
|
||||||
|
|
||||||
unitIsHiddenWhenEditing!: boolean;
|
unitIsHiddenWhenEditing!: boolean;
|
||||||
|
|
||||||
incrementBehavior!: IncrementBehavior;
|
// Mode behavior
|
||||||
|
|
||||||
incrementFactor!: number;
|
mode!: NumberInputMode;
|
||||||
|
|
||||||
disabled!: boolean;
|
incrementBehavior!: NumberInputIncrementBehavior;
|
||||||
|
|
||||||
|
step!: number;
|
||||||
|
|
||||||
|
rangeMin!: number | undefined;
|
||||||
|
|
||||||
|
rangeMax!: number | undefined;
|
||||||
|
|
||||||
|
// Styling
|
||||||
|
|
||||||
minWidth!: number;
|
minWidth!: number;
|
||||||
|
|
||||||
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
|
|
||||||
tooltip!: string | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OptionalInput extends WidgetProps {
|
export class OptionalInput extends WidgetProps {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue