Revamp the ColorPicker popover and ColorInput widget (#830)
* Add cancel hint to Eyedropper tool * Improve eyedropper overlay CSS * Make CSS for transparent checkered background reusable * Add color choice preview to color picker * Draw text and markers as contrasting white or black * Add reactive color updating and new/initial swapping * Add Hex, RGB, HSV, and Opacity inputs * Add none color and preset buttons * Add eyedropper button and fix alignment (now visually done) * Wire up none colors through the backend and style the ColorInput widget * Add color info chip to ColorInput widget * Fix all UX bugs * Add more tooltips * Fix FloatingMenu recursive loop * Prevent mouse stray from closing color picker while dragging pickers Closes #703 * Fix deselect all layers shortcut * Add temporary eyedropper for Chromium browsers and a coming soon fallback
This commit is contained in:
parent
fa7116133b
commit
85c635f92d
|
|
@ -201,7 +201,7 @@ pub fn default_mapping() -> Mapping {
|
||||||
entry!(KeyDown(KeyP); modifiers=[Alt], action_dispatch=DocumentMessage::DebugPrintDocument),
|
entry!(KeyDown(KeyP); modifiers=[Alt], action_dispatch=DocumentMessage::DebugPrintDocument),
|
||||||
entry!(KeyDown(KeyZ); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::Redo),
|
entry!(KeyDown(KeyZ); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::Redo),
|
||||||
entry!(KeyDown(KeyZ); modifiers=[Accel], action_dispatch=DocumentMessage::Undo),
|
entry!(KeyDown(KeyZ); modifiers=[Accel], action_dispatch=DocumentMessage::Undo),
|
||||||
entry!(KeyDown(KeyA); modifiers=[Accel, Alt], action_dispatch=DocumentMessage::DeselectAllLayers),
|
entry!(KeyDown(KeyA); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::DeselectAllLayers),
|
||||||
entry!(KeyDown(KeyA); modifiers=[Accel], action_dispatch=DocumentMessage::SelectAllLayers),
|
entry!(KeyDown(KeyA); modifiers=[Accel], action_dispatch=DocumentMessage::SelectAllLayers),
|
||||||
entry!(KeyDown(KeyS); modifiers=[Accel], action_dispatch=DocumentMessage::SaveDocument),
|
entry!(KeyDown(KeyS); modifiers=[Accel], action_dispatch=DocumentMessage::SaveDocument),
|
||||||
entry!(KeyDown(KeyD); modifiers=[Accel], action_dispatch=DocumentMessage::DuplicateSelectedLayers),
|
entry!(KeyDown(KeyD); modifiers=[Accel], action_dispatch=DocumentMessage::DuplicateSelectedLayers),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use crate::messages::layout::utility_types::layout_widget::Layout;
|
||||||
use crate::messages::layout::utility_types::layout_widget::Widget;
|
use crate::messages::layout::utility_types::layout_widget::Widget;
|
||||||
use crate::messages::prelude::*;
|
use crate::messages::prelude::*;
|
||||||
|
|
||||||
|
use graphene::color::Color;
|
||||||
use graphene::layers::text_layer::Font;
|
use graphene::layers::text_layer::Font;
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
@ -60,8 +61,23 @@ impl<F: Fn(&MessageDiscriminant) -> Vec<KeysGroup>> MessageHandler<LayoutMessage
|
||||||
responses.push_back(callback_message);
|
responses.push_back(callback_message);
|
||||||
}
|
}
|
||||||
Widget::ColorInput(color_input) => {
|
Widget::ColorInput(color_input) => {
|
||||||
let update_value = value.as_str().map(String::from);
|
let update_value = value.as_object().expect("ColorInput update was not of type: object");
|
||||||
color_input.value = update_value;
|
let parsed_color = (|| {
|
||||||
|
let is_none = update_value.get("none")?.as_bool()?;
|
||||||
|
|
||||||
|
if !is_none {
|
||||||
|
Some(Some(Color::from_rgbaf32(
|
||||||
|
update_value.get("red")?.as_f64()? as f32,
|
||||||
|
update_value.get("green")?.as_f64()? as f32,
|
||||||
|
update_value.get("blue")?.as_f64()? as f32,
|
||||||
|
update_value.get("alpha")?.as_f64()? as f32,
|
||||||
|
)?))
|
||||||
|
} else {
|
||||||
|
Some(None)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.unwrap_or_else(|| panic!("ColorInput update was not able to be parsed with color data: {:?}", color_input));
|
||||||
|
color_input.value = parsed_color;
|
||||||
let callback_message = (color_input.on_update.callback)(color_input);
|
let callback_message = (color_input.on_update.callback)(color_input);
|
||||||
responses.push_back(callback_message);
|
responses.push_back(callback_message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,13 +39,12 @@ impl Default for CheckboxInput {
|
||||||
#[derive(Clone, Derivative, Serialize, Deserialize)]
|
#[derive(Clone, Derivative, Serialize, Deserialize)]
|
||||||
#[derivative(Debug, PartialEq, Default)]
|
#[derivative(Debug, PartialEq, Default)]
|
||||||
pub struct ColorInput {
|
pub struct ColorInput {
|
||||||
pub value: Option<String>,
|
pub value: Option<Color>,
|
||||||
|
|
||||||
pub label: Option<String>,
|
|
||||||
|
|
||||||
|
// TODO: Add allow_none
|
||||||
#[serde(rename = "noTransparency")]
|
#[serde(rename = "noTransparency")]
|
||||||
#[derivative(Default(value = "true"))]
|
#[derivative(Default(value = "true"))]
|
||||||
pub no_transparency: bool,
|
pub no_transparency: bool, // TODO: Rename allow_transparency (and invert usages)
|
||||||
|
|
||||||
pub disabled: bool,
|
pub disabled: bool,
|
||||||
|
|
||||||
|
|
@ -295,6 +294,8 @@ pub struct TextInput {
|
||||||
|
|
||||||
pub tooltip: String,
|
pub tooltip: String,
|
||||||
|
|
||||||
|
pub centered: bool,
|
||||||
|
|
||||||
#[serde(rename = "minWidth")]
|
#[serde(rename = "minWidth")]
|
||||||
pub min_width: u32,
|
pub min_width: u32,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ use crate::messages::layout::utility_types::widgets::label_widgets::{IconLabel,
|
||||||
use crate::messages::portfolio::utility_types::{ImaginateServerStatus, PersistentData};
|
use crate::messages::portfolio::utility_types::{ImaginateServerStatus, PersistentData};
|
||||||
use crate::messages::prelude::*;
|
use crate::messages::prelude::*;
|
||||||
|
|
||||||
use graphene::color::Color;
|
|
||||||
use graphene::document::pick_layer_safe_imaginate_resolution;
|
use graphene::document::pick_layer_safe_imaginate_resolution;
|
||||||
use graphene::layers::imaginate_layer::{ImaginateLayer, ImaginateSamplingMethod, ImaginateStatus};
|
use graphene::layers::imaginate_layer::{ImaginateLayer, ImaginateSamplingMethod, ImaginateStatus};
|
||||||
use graphene::layers::layer_info::{Layer, LayerDataType, LayerDataTypeDiscriminant};
|
use graphene::layers::layer_info::{Layer, LayerDataType, LayerDataTypeDiscriminant};
|
||||||
|
|
@ -190,18 +189,10 @@ pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDequ
|
||||||
direction: SeparatorDirection::Horizontal,
|
direction: SeparatorDirection::Horizontal,
|
||||||
})),
|
})),
|
||||||
WidgetHolder::new(Widget::ColorInput(ColorInput {
|
WidgetHolder::new(Widget::ColorInput(ColorInput {
|
||||||
value: Some(color.rgba_hex()),
|
value: Some(*color),
|
||||||
on_update: WidgetCallback::new(|text_input: &ColorInput| {
|
on_update: WidgetCallback::new(|text_input: &ColorInput| {
|
||||||
if let Some(value) = &text_input.value {
|
let fill = if let Some(value) = text_input.value { Fill::Solid(value) } else { Fill::None };
|
||||||
if let Some(color) = Color::from_rgba_str(value).or_else(|| Color::from_rgb_str(value)) {
|
PropertiesPanelMessage::ModifyFill { fill }.into()
|
||||||
let new_fill = Fill::Solid(color);
|
|
||||||
PropertiesPanelMessage::ModifyFill { fill: new_fill }.into()
|
|
||||||
} else {
|
|
||||||
PropertiesPanelMessage::ResendActiveProperties.into()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
PropertiesPanelMessage::ModifyFill { fill: Fill::None }.into()
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
no_transparency: true,
|
no_transparency: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
@ -1186,21 +1177,11 @@ fn node_gradient_color(gradient: &Gradient, percent_label: &'static str, positio
|
||||||
direction: SeparatorDirection::Horizontal,
|
direction: SeparatorDirection::Horizontal,
|
||||||
})),
|
})),
|
||||||
WidgetHolder::new(Widget::ColorInput(ColorInput {
|
WidgetHolder::new(Widget::ColorInput(ColorInput {
|
||||||
value: gradient_clone.positions[position].1.map(|color| color.rgba_hex()),
|
value: gradient_clone.positions[position].1,
|
||||||
on_update: WidgetCallback::new(move |text_input: &ColorInput| {
|
on_update: WidgetCallback::new(move |text_input: &ColorInput| {
|
||||||
if let Some(value) = &text_input.value {
|
let mut new_gradient = (*gradient_clone).clone();
|
||||||
if let Some(color) = Color::from_rgba_str(value).or_else(|| Color::from_rgb_str(value)) {
|
new_gradient.positions[position].1 = text_input.value;
|
||||||
let mut new_gradient = (*gradient_clone).clone();
|
send_fill_message(new_gradient)
|
||||||
new_gradient.positions[position].1 = Some(color);
|
|
||||||
send_fill_message(new_gradient)
|
|
||||||
} else {
|
|
||||||
PropertiesPanelMessage::ResendActiveProperties.into()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let mut new_gradient = (*gradient_clone).clone();
|
|
||||||
new_gradient.positions[position].1 = None;
|
|
||||||
send_fill_message(new_gradient)
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
..ColorInput::default()
|
..ColorInput::default()
|
||||||
})),
|
})),
|
||||||
|
|
@ -1223,18 +1204,10 @@ fn node_section_fill(fill: &Fill) -> Option<LayoutGroup> {
|
||||||
direction: SeparatorDirection::Horizontal,
|
direction: SeparatorDirection::Horizontal,
|
||||||
})),
|
})),
|
||||||
WidgetHolder::new(Widget::ColorInput(ColorInput {
|
WidgetHolder::new(Widget::ColorInput(ColorInput {
|
||||||
value: if let Fill::Solid(color) = fill { Some(color.rgba_hex()) } else { None },
|
value: if let Fill::Solid(color) = fill { Some(*color) } else { None },
|
||||||
on_update: WidgetCallback::new(|text_input: &ColorInput| {
|
on_update: WidgetCallback::new(|text_input: &ColorInput| {
|
||||||
if let Some(value) = &text_input.value {
|
let fill = if let Some(value) = text_input.value { Fill::Solid(value) } else { Fill::None };
|
||||||
if let Some(color) = Color::from_rgba_str(value).or_else(|| Color::from_rgb_str(value)) {
|
PropertiesPanelMessage::ModifyFill { fill }.into()
|
||||||
let new_fill = Fill::Solid(color);
|
|
||||||
PropertiesPanelMessage::ModifyFill { fill: new_fill }.into()
|
|
||||||
} else {
|
|
||||||
PropertiesPanelMessage::ResendActiveProperties.into()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
PropertiesPanelMessage::ModifyFill { fill: Fill::None }.into()
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
..ColorInput::default()
|
..ColorInput::default()
|
||||||
})),
|
})),
|
||||||
|
|
@ -1276,7 +1249,7 @@ fn node_section_stroke(stroke: &Stroke) -> LayoutGroup {
|
||||||
direction: SeparatorDirection::Horizontal,
|
direction: SeparatorDirection::Horizontal,
|
||||||
})),
|
})),
|
||||||
WidgetHolder::new(Widget::ColorInput(ColorInput {
|
WidgetHolder::new(Widget::ColorInput(ColorInput {
|
||||||
value: stroke.color().map(|color| color.rgba_hex()),
|
value: stroke.color(),
|
||||||
on_update: WidgetCallback::new(move |text_input: &ColorInput| {
|
on_update: WidgetCallback::new(move |text_input: &ColorInput| {
|
||||||
internal_stroke1
|
internal_stroke1
|
||||||
.clone()
|
.clone()
|
||||||
|
|
@ -1324,6 +1297,7 @@ fn node_section_stroke(stroke: &Stroke) -> LayoutGroup {
|
||||||
})),
|
})),
|
||||||
WidgetHolder::new(Widget::TextInput(TextInput {
|
WidgetHolder::new(Widget::TextInput(TextInput {
|
||||||
value: stroke.dash_lengths(),
|
value: stroke.dash_lengths(),
|
||||||
|
centered: true,
|
||||||
on_update: WidgetCallback::new(move |text_input: &TextInput| {
|
on_update: WidgetCallback::new(move |text_input: &TextInput| {
|
||||||
internal_stroke3
|
internal_stroke3
|
||||||
.clone()
|
.clone()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::messages::frontend::utility_types::MouseCursorIcon;
|
use crate::messages::frontend::utility_types::MouseCursorIcon;
|
||||||
use crate::messages::input_mapper::utility_types::input_keyboard::MouseMotion;
|
use crate::messages::input_mapper::utility_types::input_keyboard::{Key, KeysGroup, MouseMotion};
|
||||||
use crate::messages::layout::utility_types::layout_widget::PropertyHolder;
|
use crate::messages::layout::utility_types::layout_widget::PropertyHolder;
|
||||||
use crate::messages::prelude::*;
|
use crate::messages::prelude::*;
|
||||||
use crate::messages::tool::utility_types::{DocumentToolData, EventToMessageMap, Fsm, ToolActionHandlerData, ToolMetadata, ToolTransition, ToolType};
|
use crate::messages::tool::utility_types::{DocumentToolData, EventToMessageMap, Fsm, ToolActionHandlerData, ToolMetadata, ToolTransition, ToolType};
|
||||||
|
|
@ -177,8 +177,13 @@ impl Fsm for EyedropperToolFsmState {
|
||||||
plus: false,
|
plus: false,
|
||||||
},
|
},
|
||||||
])]),
|
])]),
|
||||||
EyedropperToolFsmState::SamplingPrimary => HintData(vec![]),
|
EyedropperToolFsmState::SamplingPrimary | EyedropperToolFsmState::SamplingSecondary => HintData(vec![HintGroup(vec![HintInfo {
|
||||||
EyedropperToolFsmState::SamplingSecondary => HintData(vec![]),
|
key_groups: vec![KeysGroup(vec![Key::Escape])],
|
||||||
|
key_groups_mac: None,
|
||||||
|
mouse: None,
|
||||||
|
label: String::from("Cancel"),
|
||||||
|
plus: false,
|
||||||
|
}])]),
|
||||||
};
|
};
|
||||||
|
|
||||||
responses.push_back(FrontendMessage::UpdateInputHints { hint_data }.into());
|
responses.push_back(FrontendMessage::UpdateInputHints { hint_data }.into());
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.2,5.2l1.7-1.7c0.6-0.6,0.7-1.6,0.1-2.3c0,0,0,0-0.1-0.1l0,0c-0.7-0.7-1.7-0.7-2.4,0c0,0,0,0,0,0l-1.7,1.7l-0.7-0.7c-0.2-0.3-0.7-0.3-0.9,0c0,0,0,0,0,0l-1,1l4.7,4.7l1-1c0.3-0.2,0.3-0.7,0-0.9c0,0,0,0,0,0L13.2,5.2z" />
|
||||||
|
<path d="M10.4,6.6l-6.5,6.5c-0.1,0.1-0.4,0.1-0.6,0.1c-0.2,0-0.5,0.1-0.7,0.1c0.1-0.3,0.1-0.5,0.1-0.7s0.1-0.5,0.1-0.6l6.5-6.5L8.7,4.9l-6.5,6.5c-0.6,0.6-0.2,1.9-1.1,2.9l0.6,0.6c0.9-0.9,2.3-0.5,2.9-1.1s6.5-6.5,6.5-6.5L10.4,6.6z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 522 B |
|
|
@ -67,6 +67,21 @@
|
||||||
--color-data-unused1-rgb: 214, 83, 110;
|
--color-data-unused1-rgb: 214, 83, 110;
|
||||||
--color-data-unused2: #70a898;
|
--color-data-unused2: #70a898;
|
||||||
--color-data-unused2-rgb: 112, 168, 152;
|
--color-data-unused2-rgb: 112, 168, 152;
|
||||||
|
|
||||||
|
--color-none: white;
|
||||||
|
--color-none-repeat: no-repeat;
|
||||||
|
--color-none-position: center center;
|
||||||
|
// 24px tall, 48px wide
|
||||||
|
--color-none-size-24px: 60px 24px;
|
||||||
|
--color-none-image-24px: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 24"><line stroke="red" stroke-width="4px" x1="0" y1="27" x2="60" y2="-3" /></svg>');
|
||||||
|
// 32px tall, 64px wide
|
||||||
|
--color-none-size-32px: 80px 32px;
|
||||||
|
--color-none-image-32px: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 32"><line stroke="red" stroke-width="4px" x1="0" y1="36" x2="80" y2="-4" /></svg>');
|
||||||
|
|
||||||
|
--transparent-checkered-background: linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%),
|
||||||
|
linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(#ffffff, #ffffff);
|
||||||
|
--transparent-checkered-background-size: 16px 16px;
|
||||||
|
--transparent-checkered-background-position: 0 0, 8px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,130 @@
|
||||||
<template>
|
<template>
|
||||||
<FloatingMenu :open="open" @update:open="(isOpen) => emitOpenState(isOpen)" :direction="direction" :type="'Popover'">
|
<FloatingMenu class="color-picker" :open="open" @update:open="(isOpen) => emitOpenState(isOpen)" :strayCloses="strayCloses" :direction="direction" :type="'Popover'">
|
||||||
<LayoutRow class="color-picker">
|
<LayoutRow
|
||||||
<LayoutCol class="saturation-value-picker" :style="{ '--saturation-value-picker-hue': hueColorCSS }" @pointerdown="(e: PointerEvent) => beginDrag(e)" data-saturation-value-picker>
|
:style="{
|
||||||
<div class="selection-circle" :style="{ top: `${(1 - value) * 100}%`, left: `${saturation * 100}%` }"></div>
|
'--new-color': newColor.toHexOptionalAlpha(),
|
||||||
|
'--new-color-contrasting': newColor.contrastingColor(),
|
||||||
|
'--initial-color': initialColor.toHexOptionalAlpha(),
|
||||||
|
'--initial-color-contrasting': initialColor.contrastingColor(),
|
||||||
|
'--hue-color': opaqueHueColor.toRgbCSS(),
|
||||||
|
'--hue-color-contrasting': opaqueHueColor.contrastingColor(),
|
||||||
|
'--opaque-color': (newColor.opaque() || black).toHexNoAlpha(),
|
||||||
|
'--opaque-color-contrasting': (newColor.opaque() || black).contrastingColor(),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<LayoutCol class="saturation-value-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-saturation-value-picker>
|
||||||
|
<div class="selection-circle" :style="{ top: `${(1 - value) * 100}%`, left: `${saturation * 100}%` }" v-if="!isNone"></div>
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
<LayoutCol class="hue-picker" @pointerdown="(e: PointerEvent) => beginDrag(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}%` }"></div>
|
<div class="selection-pincers" :style="{ top: `${(1 - hue) * 100}%` }" v-if="!isNone"></div>
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
<LayoutCol class="opacity-picker" :style="{ '--opacity-picker-color': color.toRgbCSS() }" @pointerdown="(e: PointerEvent) => beginDrag(e)" data-opacity-picker>
|
<LayoutCol class="opacity-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-opacity-picker>
|
||||||
<div class="selection-pincers" :style="{ top: `${(1 - opacity) * 100}%` }"></div>
|
<div class="selection-pincers" :style="{ top: `${(1 - opacity) * 100}%` }" v-if="!isNone"></div>
|
||||||
|
</LayoutCol>
|
||||||
|
<LayoutCol class="details">
|
||||||
|
<LayoutRow
|
||||||
|
class="choice-preview"
|
||||||
|
@click="() => swapNewWithInitial()"
|
||||||
|
:tooltip="'Comparison views of the present color choice (left) and the color before any change (right). Click to swap sides.'"
|
||||||
|
>
|
||||||
|
<LayoutCol class="new-color" :class="{ none: isNone }">
|
||||||
|
<TextLabel>New</TextLabel>
|
||||||
|
</LayoutCol>
|
||||||
|
<LayoutCol class="initial-color" :class="{ none: initialIsNone }">
|
||||||
|
<TextLabel>Initial</TextLabel>
|
||||||
|
</LayoutCol>
|
||||||
|
</LayoutRow>
|
||||||
|
<DropdownInput :entries="colorSpaceChoices" :selectedIndex="0" :disabled="true" :tooltip="'Color Space and HDR (coming soon)'" />
|
||||||
|
<LayoutRow>
|
||||||
|
<TextLabel :tooltip="'Color code in hexadecimal format'">Hex</TextLabel>
|
||||||
|
<Separator />
|
||||||
|
<LayoutRow>
|
||||||
|
<TextInput
|
||||||
|
:value="newColor.toHexOptionalAlpha() || '-'"
|
||||||
|
@commitText="(value: string) => setColorCode(value)"
|
||||||
|
:centered="true"
|
||||||
|
:tooltip="'Color code in hexadecimal format. 6 digits if opaque, 8 with opacity.\nAccepts input of CSS color values including named colors.'"
|
||||||
|
/>
|
||||||
|
</LayoutRow>
|
||||||
|
</LayoutRow>
|
||||||
|
<LayoutRow>
|
||||||
|
<TextLabel :tooltip="'Red/Green/Blue channels of the color, integers 0–255'">RGB</TextLabel>
|
||||||
|
<Separator />
|
||||||
|
<LayoutRow>
|
||||||
|
<template v-for="([channel, strength], index) in Object.entries(newColor.toRgb255() || { r: undefined, g: undefined, b: undefined })" :key="channel">
|
||||||
|
<Separator :type="'Related'" v-if="index > 0" />
|
||||||
|
<NumberInput
|
||||||
|
:value="strength"
|
||||||
|
@update:value="(value: number) => setColorRGB(channel as keyof RGB, value)"
|
||||||
|
:min="0"
|
||||||
|
:max="255"
|
||||||
|
:centered="true"
|
||||||
|
:minWidth="56"
|
||||||
|
:tooltip="`${{ r: 'Red', g: 'Green', b: 'Blue' }[channel]} channel, integers 0–255`"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</LayoutRow>
|
||||||
|
</LayoutRow>
|
||||||
|
<LayoutRow>
|
||||||
|
<TextLabel :tooltip="'Hue/Saturation/Value, also known as Hue/Saturation/Brightness (HSB).\nNot to be confused with Hue/Saturation/Lightness (HSL), a different color model.'"
|
||||||
|
>HSV</TextLabel
|
||||||
|
>
|
||||||
|
<Separator />
|
||||||
|
<LayoutRow>
|
||||||
|
<template
|
||||||
|
v-for="([channel, strength], index) in !isNone
|
||||||
|
? Object.entries({ h: hue * 360, s: saturation * 100, v: value * 100 })
|
||||||
|
: Object.entries({ h: undefined, s: undefined, v: undefined })"
|
||||||
|
:key="channel"
|
||||||
|
>
|
||||||
|
<Separator :type="'Related'" v-if="index > 0" />
|
||||||
|
<NumberInput
|
||||||
|
:value="strength"
|
||||||
|
@update:value="(value: number) => setColorHSV(channel as keyof HSV, value)"
|
||||||
|
:min="0"
|
||||||
|
:max="channel === 'h' ? 360 : 100"
|
||||||
|
:unit="channel === 'h' ? '°' : '%'"
|
||||||
|
:centered="true"
|
||||||
|
:minWidth="56"
|
||||||
|
:tooltip="
|
||||||
|
{
|
||||||
|
h: 'Hue component, the "color" along the rainbow',
|
||||||
|
s: 'Saturation component, the "colorfulness" from gray to vivid',
|
||||||
|
v: 'Value (or Brightness), the distance away from being darkened to black',
|
||||||
|
}[channel]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</LayoutRow>
|
||||||
|
</LayoutRow>
|
||||||
|
<NumberInput
|
||||||
|
:label="'Opacity'"
|
||||||
|
:value="!isNone ? opacity * 100 : undefined"
|
||||||
|
@update:value="(value: number) => setColorOpacityPercent(value)"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:unit="'%'"
|
||||||
|
:tooltip="`Scale from transparent (0%) to opaque (100%) for the color's alpha channel`"
|
||||||
|
/>
|
||||||
|
<LayoutRow class="leftover-space"></LayoutRow>
|
||||||
|
<LayoutRow>
|
||||||
|
<button class="preset-color none" @click="() => setColorPreset('none')" v-if="allowNone" title="Set none"></button>
|
||||||
|
<Separator :type="'Related'" v-if="allowNone" />
|
||||||
|
<button class="preset-color black" @click="() => setColorPreset('black')" title="Set black"></button>
|
||||||
|
<Separator :type="'Related'" />
|
||||||
|
<button class="preset-color white" @click="() => setColorPreset('white')" title="Set white"></button>
|
||||||
|
<Separator :type="'Related'" />
|
||||||
|
<button class="preset-color pure" @click="(e: MouseEvent) => setColorPresetSubtile(e)">
|
||||||
|
<div data-pure-tile="red" style="--pure-color: #ff0000; --pure-color-gray: #4c4c4c" title="Set red"></div>
|
||||||
|
<div data-pure-tile="yellow" style="--pure-color: #ffff00; --pure-color-gray: #e3e3e3" title="Set yellow"></div>
|
||||||
|
<div data-pure-tile="green" style="--pure-color: #00ff00; --pure-color-gray: #969696" title="Set green"></div>
|
||||||
|
<div data-pure-tile="cyan" style="--pure-color: #00ffff; --pure-color-gray: #b2b2b2" title="Set cyan"></div>
|
||||||
|
<div data-pure-tile="blue" style="--pure-color: #0000ff; --pure-color-gray: #1c1c1c" title="Set blue"></div>
|
||||||
|
<div data-pure-tile="magenta" style="--pure-color: #ff00ff; --pure-color-gray: #696969" title="Set magenta"></div>
|
||||||
|
</button>
|
||||||
|
<Separator :type="'Related'" />
|
||||||
|
<IconButton :icon="'Eyedropper'" :size="24" :action="() => activateEyedropperSample()" :tooltip="'Sample a pixel color from the document'" />
|
||||||
|
</LayoutRow>
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
</FloatingMenu>
|
</FloatingMenu>
|
||||||
|
|
@ -19,7 +135,7 @@
|
||||||
.saturation-value-picker {
|
.saturation-value-picker {
|
||||||
width: 256px;
|
width: 256px;
|
||||||
background-blend-mode: multiply;
|
background-blend-mode: multiply;
|
||||||
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--saturation-value-picker-hue));
|
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--hue-color));
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,10 +158,11 @@
|
||||||
background-blend-mode: screen;
|
background-blend-mode: screen;
|
||||||
background: linear-gradient(to top, #ff0000ff 16.666%, #ff000000 33.333%, #ff000000 66.666%, #ff0000ff 83.333%),
|
background: linear-gradient(to top, #ff0000ff 16.666%, #ff000000 33.333%, #ff000000 66.666%, #ff0000ff 83.333%),
|
||||||
linear-gradient(to top, #00ff0000 0%, #00ff00ff 16.666%, #00ff00ff 50%, #00ff0000 66.666%), linear-gradient(to top, #0000ff00 33.333%, #0000ffff 50%, #0000ffff 83.333%, #0000ff00 100%);
|
linear-gradient(to top, #00ff0000 0%, #00ff00ff 16.666%, #00ff00ff 50%, #00ff0000 66.666%), linear-gradient(to top, #0000ff00 33.333%, #0000ffff 50%, #0000ffff 83.333%, #0000ff00 100%);
|
||||||
|
--selection-pincers-color: var(--hue-color-contrasting);
|
||||||
}
|
}
|
||||||
|
|
||||||
.opacity-picker {
|
.opacity-picker {
|
||||||
background: linear-gradient(to bottom, var(--opacity-picker-color), transparent);
|
background: linear-gradient(to bottom, var(--opaque-color), transparent);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: "";
|
content: "";
|
||||||
|
|
@ -53,12 +170,11 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
position: relative;
|
position: relative;
|
||||||
// Checkered transparent pattern
|
background: var(--transparent-checkered-background);
|
||||||
background: linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%),
|
background-size: var(--transparent-checkered-background-size);
|
||||||
linear-gradient(#ffffff, #ffffff);
|
background-position: var(--transparent-checkered-background-position);
|
||||||
background-size: 16px 16px;
|
|
||||||
background-position: 0 0, 8px 8px;
|
|
||||||
}
|
}
|
||||||
|
--selection-pincers-color: var(--new-color-contrasting);
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-circle {
|
.selection-circle {
|
||||||
|
|
@ -78,9 +194,8 @@
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid white;
|
border: 2px solid var(--opaque-color-contrasting);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
mix-blend-mode: difference;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,7 +213,7 @@
|
||||||
left: 0;
|
left: 0;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 4px 0 4px 4px;
|
border-width: 4px 0 4px 4px;
|
||||||
border-color: transparent transparent transparent #000000;
|
border-color: transparent transparent transparent var(--selection-pincers-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
|
|
@ -108,7 +223,129 @@
|
||||||
right: 0;
|
right: 0;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 4px 4px 4px 0;
|
border-width: 4px 4px 4px 0;
|
||||||
border-color: transparent #000000 transparent transparent;
|
border-color: transparent var(--selection-pincers-color) transparent transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
margin-left: 16px;
|
||||||
|
width: 208px;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
> .layout-row {
|
||||||
|
height: 24px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
> .text-label {
|
||||||
|
width: 24px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.leftover-space {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-preview {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 208px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid var(--color-0-black);
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.new-color {
|
||||||
|
background: linear-gradient(var(--new-color), var(--new-color)), var(--transparent-checkered-background);
|
||||||
|
|
||||||
|
.text-label {
|
||||||
|
text-align: left;
|
||||||
|
margin: 2px 8px;
|
||||||
|
color: var(--new-color-contrasting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.initial-color {
|
||||||
|
background: linear-gradient(var(--initial-color), var(--initial-color)), var(--transparent-checkered-background);
|
||||||
|
|
||||||
|
.text-label {
|
||||||
|
text-align: right;
|
||||||
|
margin: 2px 8px;
|
||||||
|
color: var(--initial-color-contrasting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-color,
|
||||||
|
.initial-color {
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
background-size: var(--transparent-checkered-background-size);
|
||||||
|
background-position: var(--transparent-checkered-background-position);
|
||||||
|
|
||||||
|
&.none {
|
||||||
|
background: var(--color-none);
|
||||||
|
background-repeat: var(--color-none-repeat);
|
||||||
|
background-position: var(--color-none-position);
|
||||||
|
background-size: var(--color-none-size-32px);
|
||||||
|
background-image: var(--color-none-image-32px);
|
||||||
|
|
||||||
|
.text-label {
|
||||||
|
// Many stacked white shadows helps to increase the opacity and approximate shadow spread which does not exist for text shadows
|
||||||
|
text-shadow: 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-color {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: calc(48px + (48px + 4px) / 2);
|
||||||
|
height: 24px;
|
||||||
|
|
||||||
|
&.none {
|
||||||
|
background: var(--color-none);
|
||||||
|
background-repeat: var(--color-none-repeat);
|
||||||
|
background-position: var(--color-none-position);
|
||||||
|
background-size: var(--color-none-size-24px);
|
||||||
|
background-image: var(--color-none-image-24px);
|
||||||
|
|
||||||
|
&,
|
||||||
|
& ~ .black,
|
||||||
|
& ~ .white {
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.black {
|
||||||
|
background: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.white {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.pure {
|
||||||
|
width: 24px;
|
||||||
|
font-size: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: background-color 0.5s ease;
|
||||||
|
|
||||||
|
div {
|
||||||
|
display: inline-block;
|
||||||
|
width: calc(100% / 3);
|
||||||
|
height: 50%;
|
||||||
|
// For the least jarring luminance conversion, these colors are derived by placing a black layer with the "desaturate" blend mode over the colors.
|
||||||
|
// We don't use the CSS `filter: grayscale(1);` property because it produces overly dark tones for bright colors with a noticeable jump on hover.
|
||||||
|
background: var(--pure-color-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover div {
|
||||||
|
background: var(--pure-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -118,87 +355,261 @@
|
||||||
import { defineComponent, type PropType } from "vue";
|
import { defineComponent, type PropType } from "vue";
|
||||||
|
|
||||||
import { clamp } from "@/utility-functions/math";
|
import { clamp } from "@/utility-functions/math";
|
||||||
|
import type { HSV, RGB } from "@/wasm-communication/messages";
|
||||||
import { Color } from "@/wasm-communication/messages";
|
import { Color } from "@/wasm-communication/messages";
|
||||||
|
|
||||||
import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.vue";
|
import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.vue";
|
||||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||||
|
import IconButton from "@/components/widgets/buttons/IconButton.vue";
|
||||||
|
import DropdownInput from "@/components/widgets/inputs/DropdownInput.vue";
|
||||||
|
import NumberInput from "@/components/widgets/inputs/NumberInput.vue";
|
||||||
|
import TextInput from "@/components/widgets/inputs/TextInput.vue";
|
||||||
|
import Separator from "@/components/widgets/labels/Separator.vue";
|
||||||
|
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
|
||||||
|
|
||||||
|
type PresetColors = "none" | "black" | "white" | "red" | "yellow" | "green" | "cyan" | "blue" | "magenta";
|
||||||
|
|
||||||
|
const PURE_COLORS: Record<PresetColors, [number, number, number]> = {
|
||||||
|
none: [0, 0, 0],
|
||||||
|
black: [0, 0, 0],
|
||||||
|
white: [1, 1, 1],
|
||||||
|
red: [1, 0, 0],
|
||||||
|
yellow: [1, 1, 0],
|
||||||
|
green: [0, 1, 0],
|
||||||
|
cyan: [0, 1, 1],
|
||||||
|
blue: [0, 0, 1],
|
||||||
|
magenta: [1, 0, 1],
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_SPACE_CHOICES = [[{ label: "sRGB" }]];
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
inject: ["editor"],
|
||||||
emits: ["update:color", "update:open"],
|
emits: ["update:color", "update:open"],
|
||||||
props: {
|
props: {
|
||||||
color: { type: Object as PropType<Color>, required: true },
|
color: { type: Object as PropType<Color>, required: true },
|
||||||
open: { type: Boolean as PropType<boolean>, required: true },
|
allowNone: { type: Boolean as PropType<boolean>, default: false },
|
||||||
|
allowTransparency: { type: Boolean as PropType<boolean>, default: false }, // TODO: Implement this
|
||||||
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
|
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
|
||||||
|
// TODO: See if this should be made to follow the pattern of DropdownInput.vue so this could be removed
|
||||||
|
open: { type: Boolean as PropType<boolean>, required: true },
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
const hsva = this.color.toHSVA();
|
const hsvaOrNone = this.color.toHSVA();
|
||||||
|
const hsva = hsvaOrNone || { h: 0, s: 0, v: 0, a: 1 };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
draggingPickerTrack: undefined as HTMLDivElement | undefined,
|
|
||||||
hue: hsva.h,
|
hue: hsva.h,
|
||||||
saturation: hsva.s,
|
saturation: hsva.s,
|
||||||
value: hsva.v,
|
value: hsva.v,
|
||||||
opacity: hsva.a,
|
opacity: hsva.a,
|
||||||
|
isNone: hsvaOrNone === undefined,
|
||||||
|
initialHue: hsva.h,
|
||||||
|
initialSaturation: hsva.s,
|
||||||
|
initialValue: hsva.v,
|
||||||
|
initialOpacity: hsva.a,
|
||||||
|
initialIsNone: hsvaOrNone === undefined,
|
||||||
|
draggingPickerTrack: undefined as HTMLDivElement | undefined,
|
||||||
|
colorSpaceChoices: COLOR_SPACE_CHOICES,
|
||||||
|
strayCloses: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
hueColorCSS() {
|
opaqueHueColor(): Color {
|
||||||
return new Color({ h: this.hue, s: 1, v: 1, a: 1 }).toRgbCSS();
|
return new Color({ h: this.hue, s: 1, v: 1, a: 1 });
|
||||||
|
},
|
||||||
|
newColor(): Color {
|
||||||
|
if (this.isNone) return new Color("none");
|
||||||
|
return new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.opacity });
|
||||||
|
},
|
||||||
|
initialColor(): Color {
|
||||||
|
if (this.initialIsNone) return new Color("none");
|
||||||
|
return new Color({ h: this.initialHue, s: this.initialSaturation, v: this.initialValue, a: this.initialOpacity });
|
||||||
|
},
|
||||||
|
black(): Color {
|
||||||
|
return new Color(0, 0, 0, 1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
// Called only when `open` is changed from outside this component (with v-model)
|
||||||
|
open(isOpen: boolean) {
|
||||||
|
if (isOpen) this.setInitialHsvAndOpacity(this.hue, this.saturation, this.value, this.opacity, this.isNone);
|
||||||
|
},
|
||||||
|
// Called only when `color` is changed from outside this component (with v-model)
|
||||||
|
color(color: Color) {
|
||||||
|
const hsva = color.toHSVA();
|
||||||
|
|
||||||
|
if (hsva !== undefined) {
|
||||||
|
// Update the hue, but only if it is necessary so we don't:
|
||||||
|
// - ...jump the user's hue from 360° (top) to the equivalent 0° (bottom)
|
||||||
|
// - ...reset the hue to 0° if the color is fully desaturated, where all hues are equivalent
|
||||||
|
// - ...reset the hue to 0° if the color's value is black, where all hues are equivalent
|
||||||
|
if (!(hsva.h === 0 && this.hue === 1) && hsva.s > 0 && hsva.v > 0) this.hue = hsva.h;
|
||||||
|
// Update the saturation, but only if it is necessary so we don't:
|
||||||
|
// - ...reset the saturation to the left is the color's value is black along the bottom edge, where all saturations are equivalent
|
||||||
|
if (hsva.v !== 0) this.saturation = hsva.s;
|
||||||
|
// Update the value
|
||||||
|
this.value = hsva.v;
|
||||||
|
// Update the opacity
|
||||||
|
this.opacity = hsva.a;
|
||||||
|
// Update the status of this not being a color
|
||||||
|
this.isNone = false;
|
||||||
|
} else {
|
||||||
|
this.setNewHsvAndOpacity(0, 0, 0, 1, true);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
beginDrag(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-opacity-picker]") || undefined;
|
||||||
|
|
||||||
this.addEvents();
|
this.addEvents();
|
||||||
|
|
||||||
this.onPointerMove(e);
|
this.onPointerMove(e);
|
||||||
},
|
},
|
||||||
onPointerMove(e: PointerEvent) {
|
onPointerMove(e: PointerEvent) {
|
||||||
|
// Just in case the mouseup event is lost
|
||||||
|
if (e.buttons === 0) this.removeEvents();
|
||||||
|
|
||||||
if (this.draggingPickerTrack?.hasAttribute("data-saturation-value-picker")) {
|
if (this.draggingPickerTrack?.hasAttribute("data-saturation-value-picker")) {
|
||||||
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
|
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
|
||||||
|
|
||||||
this.saturation = clamp((e.clientX - rectangle.left) / rectangle.width, 0, 1);
|
this.saturation = clamp((e.clientX - rectangle.left) / rectangle.width, 0, 1);
|
||||||
this.value = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
this.value = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||||
|
this.strayCloses = false;
|
||||||
} else if (this.draggingPickerTrack?.hasAttribute("data-hue-picker")) {
|
} else if (this.draggingPickerTrack?.hasAttribute("data-hue-picker")) {
|
||||||
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
|
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
|
||||||
|
|
||||||
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;
|
||||||
} else if (this.draggingPickerTrack?.hasAttribute("data-opacity-picker")) {
|
} else if (this.draggingPickerTrack?.hasAttribute("data-opacity-picker")) {
|
||||||
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
|
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
|
||||||
|
|
||||||
this.opacity = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
this.opacity = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||||
|
this.strayCloses = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Just in case the mouseup event is lost
|
const color = new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.opacity });
|
||||||
if (e.buttons === 0) this.removeEvents();
|
this.setColor(color);
|
||||||
|
|
||||||
// The `color` prop's watcher calls `this.updateColor()`
|
|
||||||
this.$emit("update:color", new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.opacity }));
|
|
||||||
},
|
},
|
||||||
onPointerUp() {
|
onPointerUp() {
|
||||||
this.removeEvents();
|
this.removeEvents();
|
||||||
},
|
},
|
||||||
emitOpenState(isOpen: boolean) {
|
|
||||||
this.$emit("update:open", isOpen);
|
|
||||||
},
|
|
||||||
addEvents() {
|
addEvents() {
|
||||||
document.addEventListener("pointermove", this.onPointerMove);
|
document.addEventListener("pointermove", this.onPointerMove);
|
||||||
document.addEventListener("pointerup", this.onPointerUp);
|
document.addEventListener("pointerup", this.onPointerUp);
|
||||||
},
|
},
|
||||||
removeEvents() {
|
removeEvents() {
|
||||||
|
this.draggingPickerTrack = undefined;
|
||||||
|
this.strayCloses = true;
|
||||||
|
|
||||||
document.removeEventListener("pointermove", this.onPointerMove);
|
document.removeEventListener("pointermove", this.onPointerMove);
|
||||||
document.removeEventListener("pointerup", this.onPointerUp);
|
document.removeEventListener("pointerup", this.onPointerUp);
|
||||||
},
|
},
|
||||||
|
emitOpenState(isOpen: boolean) {
|
||||||
|
this.$emit("update:open", isOpen);
|
||||||
|
},
|
||||||
|
setColor(color?: Color) {
|
||||||
|
const colorToEmit = color || new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.opacity });
|
||||||
|
this.$emit("update:color", colorToEmit);
|
||||||
|
},
|
||||||
|
swapNewWithInitial() {
|
||||||
|
const initial = this.initialColor;
|
||||||
|
|
||||||
|
const tempHue = this.hue;
|
||||||
|
const tempSaturation = this.saturation;
|
||||||
|
const tempValue = this.value;
|
||||||
|
const tempOpacity = this.opacity;
|
||||||
|
const tempIsNone = this.isNone;
|
||||||
|
|
||||||
|
this.setNewHsvAndOpacity(this.initialHue, this.initialSaturation, this.initialValue, this.initialOpacity, this.initialIsNone);
|
||||||
|
this.setInitialHsvAndOpacity(tempHue, tempSaturation, tempValue, tempOpacity, tempIsNone);
|
||||||
|
|
||||||
|
this.setColor(initial);
|
||||||
|
},
|
||||||
|
setColorCode(colorCode: string) {
|
||||||
|
const color = Color.fromCSS(colorCode);
|
||||||
|
if (color) this.setColor(color);
|
||||||
|
},
|
||||||
|
setColorRGB(channel: keyof RGB, strength: number) {
|
||||||
|
if (channel === "r") this.setColor(new Color(strength / 255, this.newColor.green, this.newColor.blue, this.newColor.alpha));
|
||||||
|
else if (channel === "g") this.setColor(new Color(this.newColor.red, strength / 255, this.newColor.blue, this.newColor.alpha));
|
||||||
|
else if (channel === "b") this.setColor(new Color(this.newColor.red, this.newColor.green, strength / 255, this.newColor.alpha));
|
||||||
|
},
|
||||||
|
setColorHSV(channel: keyof HSV, strength: number) {
|
||||||
|
if (channel === "h") this.hue = strength / 360;
|
||||||
|
else if (channel === "s") this.saturation = strength / 100;
|
||||||
|
else if (channel === "v") this.value = strength / 100;
|
||||||
|
|
||||||
|
this.setColor();
|
||||||
|
},
|
||||||
|
setColorOpacityPercent(opacity: number) {
|
||||||
|
this.opacity = opacity / 100;
|
||||||
|
this.setColor();
|
||||||
|
},
|
||||||
|
setColorPresetSubtile(e: MouseEvent) {
|
||||||
|
const clickedTile = e.target as HTMLDivElement | undefined;
|
||||||
|
const tileColor = clickedTile?.getAttribute("data-pure-tile") || undefined;
|
||||||
|
|
||||||
|
if (tileColor) this.setColorPreset(tileColor as PresetColors);
|
||||||
|
},
|
||||||
|
setColorPreset(preset: PresetColors) {
|
||||||
|
if (preset === "none") {
|
||||||
|
this.setNewHsvAndOpacity(0, 0, 0, 1, true);
|
||||||
|
this.setColor(new Color("none"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetColor = new Color(...PURE_COLORS[preset], 1);
|
||||||
|
const hsva = presetColor.toHSVA() || { h: 0, s: 0, v: 0, a: 0 };
|
||||||
|
|
||||||
|
this.setNewHsvAndOpacity(hsva.h, hsva.s, hsva.v, hsva.a, false);
|
||||||
|
this.setColor(presetColor);
|
||||||
|
},
|
||||||
|
setNewHsvAndOpacity(hue: number, saturation: number, value: number, opacity: number, isNone: boolean) {
|
||||||
|
this.hue = hue;
|
||||||
|
this.saturation = saturation;
|
||||||
|
this.value = value;
|
||||||
|
this.opacity = opacity;
|
||||||
|
this.isNone = isNone;
|
||||||
|
},
|
||||||
|
setInitialHsvAndOpacity(hue: number, saturation: number, value: number, opacity: number, isNone: boolean) {
|
||||||
|
this.initialHue = hue;
|
||||||
|
this.initialSaturation = saturation;
|
||||||
|
this.initialValue = value;
|
||||||
|
this.initialOpacity = opacity;
|
||||||
|
this.initialIsNone = isNone;
|
||||||
|
},
|
||||||
|
async activateEyedropperSample() {
|
||||||
|
// TODO: Replace this temporary solution that only works in Chromium-based browsers with the custom color sampler used by the Eyedropper tool
|
||||||
|
if (!(window as any).EyeDropper) {
|
||||||
|
this.editor.instance.eyedropperSampleForColorPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await new (window as any).EyeDropper().open();
|
||||||
|
this.setColorCode(result.sRGBHex);
|
||||||
|
} catch {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
unmounted() {
|
unmounted() {
|
||||||
this.removeEvents();
|
this.removeEvents();
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
DropdownInput,
|
||||||
FloatingMenu,
|
FloatingMenu,
|
||||||
|
IconButton,
|
||||||
LayoutCol,
|
LayoutCol,
|
||||||
LayoutRow,
|
LayoutRow,
|
||||||
|
NumberInput,
|
||||||
|
Separator,
|
||||||
|
TextInput,
|
||||||
|
TextLabel,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,11 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
top: -9px;
|
top: -8px;
|
||||||
left: -9px;
|
left: -8px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5), 0 0 8px rgba(0, 0, 0, 0.25);
|
||||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-container {
|
.canvas-container {
|
||||||
|
|
@ -52,18 +51,17 @@
|
||||||
height: 110px;
|
height: 110px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 1px;
|
top: 0;
|
||||||
left: 1px;
|
left: 0;
|
||||||
width: calc(100% - 2px);
|
width: 100%;
|
||||||
height: calc(100% - 2px);
|
height: 100%;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.25);
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5), inset 0 0 8px rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pixel-outline {
|
.pixel-outline {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]">
|
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]">
|
||||||
<div class="tail" :style="tailStyle" v-if="displayTail"></div>
|
<div class="tail" v-if="displayTail" ref="tail"></div>
|
||||||
<div class="floating-menu-container" v-if="open || measuringOngoing" ref="floatingMenuContainer">
|
<div class="floating-menu-container" v-if="displayContainer" ref="floatingMenuContainer">
|
||||||
<LayoutCol class="floating-menu-content" :style="{ minWidth: minWidthStyleValue }" :scrollableY="scrollableY" ref="floatingMenuContent" data-floating-menu-content>
|
<LayoutCol class="floating-menu-content" :style="{ minWidth: minWidthStyleValue }" :scrollableY="scrollableY" ref="floatingMenuContent" data-floating-menu-content>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
|
|
@ -201,10 +201,9 @@ export default defineComponent({
|
||||||
scrollableY: { type: Boolean as PropType<boolean>, default: false },
|
scrollableY: { type: Boolean as PropType<boolean>, default: false },
|
||||||
minWidth: { type: Number as PropType<number>, default: 0 },
|
minWidth: { type: Number as PropType<number>, default: 0 },
|
||||||
escapeCloses: { type: Boolean as PropType<boolean>, default: true },
|
escapeCloses: { type: Boolean as PropType<boolean>, default: true },
|
||||||
|
strayCloses: { type: Boolean as PropType<boolean>, default: true },
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
const tailStyle: { top?: string; bottom?: string; left?: string; right?: string } = {};
|
|
||||||
|
|
||||||
// The resize observer is attached to the floating menu container, which is the zero-height div of the width of the parent element's floating menu spawner.
|
// The resize observer is attached to the floating menu container, which is the zero-height div of the width of the parent element's floating menu spawner.
|
||||||
// Since CSS doesn't let us make the floating menu (with `position: fixed`) have a 100% width of this container, we need to use JS to observe its size and
|
// Since CSS doesn't let us make the floating menu (with `position: fixed`) have a 100% width of this container, we need to use JS to observe its size and
|
||||||
// tell the floating menu content to use it as a min-width so the floating menu is at least the width of the parent element's floating menu spawner.
|
// tell the floating menu content to use it as a min-width so the floating menu is at least the width of the parent element's floating menu spawner.
|
||||||
|
|
@ -218,7 +217,6 @@ export default defineComponent({
|
||||||
measuringOngoing: false,
|
measuringOngoing: false,
|
||||||
measuringOngoingGuard: false,
|
measuringOngoingGuard: false,
|
||||||
minWidthParentWidth: 0,
|
minWidthParentWidth: 0,
|
||||||
tailStyle,
|
|
||||||
containerResizeObserver,
|
containerResizeObserver,
|
||||||
pointerStillDown: false,
|
pointerStillDown: false,
|
||||||
workspaceBounds: new DOMRect(),
|
workspaceBounds: new DOMRect(),
|
||||||
|
|
@ -234,6 +232,9 @@ export default defineComponent({
|
||||||
displayTail() {
|
displayTail() {
|
||||||
return this.open && this.type === "Popover";
|
return this.open && this.type === "Popover";
|
||||||
},
|
},
|
||||||
|
displayContainer() {
|
||||||
|
return this.open || this.measuringOngoing;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// Gets the client bounds of the elements and apply relevant styles to them
|
// Gets the client bounds of the elements and apply relevant styles to them
|
||||||
// TODO: Use the Vue :style attribute more whilst not causing recursive updates
|
// TODO: Use the Vue :style attribute more whilst not causing recursive updates
|
||||||
|
|
@ -273,10 +274,12 @@ export default defineComponent({
|
||||||
if (this.direction === "Left") floatingMenuContent.style.right = `${tailOffset + this.floatingMenuBounds.right}px`;
|
if (this.direction === "Left") floatingMenuContent.style.right = `${tailOffset + this.floatingMenuBounds.right}px`;
|
||||||
|
|
||||||
// Required to correctly position tail when scrolled (it has a `position: fixed` to prevent clipping)
|
// Required to correctly position tail when scrolled (it has a `position: fixed` to prevent clipping)
|
||||||
if (this.direction === "Bottom") this.tailStyle = { top: `${this.floatingMenuBounds.top}px` };
|
// We use a ref here, instead of a `:style` binding, because that causes the `updated()` hook to call the function we're in recursively forever
|
||||||
if (this.direction === "Top") this.tailStyle = { bottom: `${this.floatingMenuBounds.bottom}px` };
|
const tail = this.$refs.tail as HTMLElement;
|
||||||
if (this.direction === "Right") this.tailStyle = { left: `${this.floatingMenuBounds.left}px` };
|
if (tail && this.direction === "Bottom") tail.style.top = `${this.floatingMenuBounds.top}px`;
|
||||||
if (this.direction === "Left") this.tailStyle = { right: `${this.floatingMenuBounds.right}px` };
|
if (tail && this.direction === "Top") tail.style.bottom = `${this.floatingMenuBounds.bottom}px`;
|
||||||
|
if (tail && this.direction === "Right") tail.style.left = `${this.floatingMenuBounds.left}px`;
|
||||||
|
if (tail && this.direction === "Left") tail.style.right = `${this.floatingMenuBounds.right}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Edge = "Top" | "Bottom" | "Left" | "Right";
|
type Edge = "Top" | "Bottom" | "Left" | "Right";
|
||||||
|
|
@ -369,13 +372,14 @@ export default defineComponent({
|
||||||
// Get the spawner element containing whatever element the user is hovering over now, if there is one
|
// Get the spawner element containing whatever element the user is hovering over now, if there is one
|
||||||
const targetSpawner: HTMLElement | undefined = target?.closest("[data-floating-menu-spawner]") || undefined;
|
const targetSpawner: HTMLElement | undefined = target?.closest("[data-floating-menu-spawner]") || undefined;
|
||||||
|
|
||||||
// Hover transfer
|
// HOVER TRANSFER
|
||||||
// Transfer from this open floating menu to a sibling floating menu if the pointer hovers to a valid neighboring floating menu spawner
|
// Transfer from this open floating menu to a sibling floating menu if the pointer hovers to a valid neighboring floating menu spawner
|
||||||
this.hoverTransfer(self, ownSpawner, targetSpawner);
|
this.hoverTransfer(self, ownSpawner, targetSpawner);
|
||||||
|
|
||||||
// Pointer stray
|
// POINTER STRAY
|
||||||
// Close the floating menu if the pointer has strayed far enough from its bounds (and it's not hovering over its own spawner)
|
// Close the floating menu if the pointer has strayed far enough from its bounds (and it's not hovering over its own spawner)
|
||||||
if (ownSpawner !== targetSpawner && this.isPointerEventOutsideFloatingMenu(e, POINTER_STRAY_DISTANCE)) {
|
const notHoveringOverOwnSpawner = ownSpawner !== targetSpawner;
|
||||||
|
if (this.strayCloses && notHoveringOverOwnSpawner && this.isPointerEventOutsideFloatingMenu(e, POINTER_STRAY_DISTANCE)) {
|
||||||
// TODO: Extend this rectangle bounds check to all submenu bounds up the DOM tree since currently submenus disappear
|
// TODO: Extend this rectangle bounds check to all submenu bounds up the DOM tree since currently submenus disappear
|
||||||
// TODO: with zero stray distance if the cursor is further than the stray distance from only the top-level menu
|
// TODO: with zero stray distance if the cursor is further than the stray distance from only the top-level menu
|
||||||
this.$emit("update:open", false);
|
this.$emit("update:open", false);
|
||||||
|
|
@ -486,11 +490,11 @@ export default defineComponent({
|
||||||
window.removeEventListener("click", this.clickHandlerCapture, true);
|
window.removeEventListener("click", this.clickHandlerCapture, true);
|
||||||
},
|
},
|
||||||
isPointerEventOutsideFloatingMenu(e: PointerEvent, extraDistanceAllowed = 0): boolean {
|
isPointerEventOutsideFloatingMenu(e: PointerEvent, extraDistanceAllowed = 0): boolean {
|
||||||
// Considers all child menus as well as the top-level one.
|
// Consider all child menus as well as the top-level one
|
||||||
const floatingMenu: HTMLDivElement | undefined = this.$el;
|
const floatingMenu: HTMLDivElement | undefined = this.$el;
|
||||||
if (!floatingMenu) return true;
|
if (!floatingMenu) return true;
|
||||||
|
|
||||||
const allContainedFloatingMenus = [...floatingMenu.querySelectorAll("[data-floating-menu-content]")];
|
const allContainedFloatingMenus = [...floatingMenu.querySelectorAll("[data-floating-menu-content]")];
|
||||||
|
|
||||||
return !allContainedFloatingMenus.find((element) => !this.isPointerEventOutsideMenuElement(e, element, extraDistanceAllowed));
|
return !allContainedFloatingMenus.find((element) => !this.isPointerEventOutsideMenuElement(e, element, extraDistanceAllowed));
|
||||||
},
|
},
|
||||||
isPointerEventOutsideMenuElement(e: PointerEvent, element: Element, extraDistanceAllowed = 0): boolean {
|
isPointerEventOutsideMenuElement(e: PointerEvent, element: Element, extraDistanceAllowed = 0): boolean {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="layout-col" :class="{ 'scrollable-x': scrollableX, 'scrollable-y': scrollableY }" :data-scrollable-x="scrollableX || undefined" :data-scrollable-y="scrollableY || undefined">
|
<div
|
||||||
|
class="layout-col"
|
||||||
|
:class="{ 'scrollable-x': scrollableX, 'scrollable-y': scrollableY }"
|
||||||
|
:data-scrollable-x="scrollableX || undefined"
|
||||||
|
:data-scrollable-y="scrollableY || undefined"
|
||||||
|
:title="tooltip"
|
||||||
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -25,6 +31,7 @@ export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
scrollableX: { type: Boolean as PropType<boolean>, default: false },
|
scrollableX: { type: Boolean as PropType<boolean>, default: false },
|
||||||
scrollableY: { type: Boolean as PropType<boolean>, default: false },
|
scrollableY: { type: Boolean as PropType<boolean>, default: false },
|
||||||
|
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="layout-row" :class="{ 'scrollable-x': scrollableX, 'scrollable-y': scrollableY }" :data-scrollable-x="scrollableX || undefined" :data-scrollable-y="scrollableY || undefined">
|
<div
|
||||||
|
class="layout-row"
|
||||||
|
:class="{ 'scrollable-x': scrollableX, 'scrollable-y': scrollableY }"
|
||||||
|
:data-scrollable-x="scrollableX || undefined"
|
||||||
|
:data-scrollable-y="scrollableY || undefined"
|
||||||
|
:title="tooltip"
|
||||||
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -25,6 +31,7 @@ export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
scrollableX: { type: Boolean as PropType<boolean>, default: false },
|
scrollableX: { type: Boolean as PropType<boolean>, default: false },
|
||||||
scrollableY: { type: Boolean as PropType<boolean>, default: false },
|
scrollableY: { type: Boolean as PropType<boolean>, default: false },
|
||||||
|
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -457,7 +457,7 @@ export default defineComponent({
|
||||||
this.editor.instance.onChangeText(textCleaned);
|
this.editor.instance.onChangeText(textCleaned);
|
||||||
},
|
},
|
||||||
displayEditableTextbox(displayEditableTextbox: DisplayEditableTextbox) {
|
displayEditableTextbox(displayEditableTextbox: DisplayEditableTextbox) {
|
||||||
this.textInput = document.createElement("DIV") as HTMLDivElement;
|
this.textInput = document.createElement("div") as HTMLDivElement;
|
||||||
|
|
||||||
if (displayEditableTextbox.text === "") this.textInput.textContent = "";
|
if (displayEditableTextbox.text === "") this.textInput.textContent = "";
|
||||||
else this.textInput.textContent = `${displayEditableTextbox.text}\n`;
|
else this.textInput.textContent = `${displayEditableTextbox.text}\n`;
|
||||||
|
|
@ -466,7 +466,7 @@ export default defineComponent({
|
||||||
this.textInput.style.width = displayEditableTextbox.lineWidth ? `${displayEditableTextbox.lineWidth}px` : "max-content";
|
this.textInput.style.width = displayEditableTextbox.lineWidth ? `${displayEditableTextbox.lineWidth}px` : "max-content";
|
||||||
this.textInput.style.height = "auto";
|
this.textInput.style.height = "auto";
|
||||||
this.textInput.style.fontSize = `${displayEditableTextbox.fontSize}px`;
|
this.textInput.style.fontSize = `${displayEditableTextbox.fontSize}px`;
|
||||||
this.textInput.style.color = displayEditableTextbox.color.toRgbaCSS();
|
this.textInput.style.color = displayEditableTextbox.color.toHexOptionalAlpha() || "transparent";
|
||||||
|
|
||||||
this.textInput.oninput = (): void => {
|
this.textInput.oninput = (): void => {
|
||||||
if (!this.textInput) return;
|
if (!this.textInput) return;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<div :class="`widget-${direction}`">
|
<div :class="`widget-${direction}`">
|
||||||
<template v-for="(component, index) in widgets" :key="index">
|
<template v-for="(component, index) in widgets" :key="index">
|
||||||
<CheckboxInput v-if="component.props.kind === 'CheckboxInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(component.widgetId, value)" />
|
<CheckboxInput v-if="component.props.kind === 'CheckboxInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(component.widgetId, value)" />
|
||||||
<ColorInput v-if="component.props.kind === 'ColorInput'" v-bind="component.props" v-model:open="open" @update:value="(value: string) => updateLayout(component.widgetId, value)" />
|
<ColorInput v-if="component.props.kind === 'ColorInput'" v-bind="component.props" v-model:open="open" @update:value="(value: unknown) => updateLayout(component.widgetId, value)" />
|
||||||
<DropdownInput
|
<DropdownInput
|
||||||
v-if="component.props.kind === 'DropdownInput'"
|
v-if="component.props.kind === 'DropdownInput'"
|
||||||
v-bind="component.props"
|
v-bind="component.props"
|
||||||
|
|
|
||||||
|
|
@ -61,13 +61,15 @@
|
||||||
.body {
|
.body {
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
|
|
||||||
.text-label:first-of-type {
|
.widget-row {
|
||||||
flex: 0 0 30%;
|
> .text-label:first-of-type {
|
||||||
text-align: right;
|
flex: 0 0 30%;
|
||||||
}
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.text-button {
|
> .text-button {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,70 @@
|
||||||
<template>
|
<template>
|
||||||
<LayoutRow class="color-input" :title="tooltip">
|
<LayoutRow class="color-input" :title="tooltip">
|
||||||
<OptionalInput v-if="!noTransparency" :icon="'CloseX'" :checked="Boolean(value)" @update:checked="(state: boolean) => updateEnabled(state)"></OptionalInput>
|
<button :class="{ none: value.none }" :style="{ '--color': value.toHexOptionalAlpha() }" @click="() => $emit('update:open', true)" data-floating-menu-spawner>
|
||||||
<TextInput :value="displayValue" :label="label" :disabled="disabled || !value" @commitText="(value: string) => textInputUpdated(value)" :center="true" />
|
<TextLabel :bold="true" class="chip" v-if="chip">{{ chip }}</TextLabel>
|
||||||
<Separator :type="'Related'" />
|
</button>
|
||||||
<LayoutRow class="swatch">
|
<ColorPicker v-model:open="isOpen" :color="value" @update:color="(color: Color) => colorPickerUpdated(color)" :allowNone="true" />
|
||||||
<button class="swatch-button" :class="{ 'disabled-swatch': !value }" :style="`--swatch-color: #${value}`" @click="() => $emit('update:open', true)"></button>
|
|
||||||
<ColorPicker v-model:open="isOpen" :color="color" @update:color="(color: Color) => colorPickerUpdated(color)" :direction="'Bottom'" />
|
|
||||||
</LayoutRow>
|
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.color-input {
|
.color-input {
|
||||||
.text-input input {
|
box-sizing: border-box;
|
||||||
text-align: center;
|
position: relative;
|
||||||
|
border: 1px solid var(--color-7-middlegray);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
> button {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 1px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 2px;
|
||||||
|
top: -2px;
|
||||||
|
left: -2px;
|
||||||
|
background: linear-gradient(var(--color), var(--color)), var(--transparent-checkered-background);
|
||||||
|
background-size: var(--transparent-checkered-background-size);
|
||||||
|
background-position: var(--transparent-checkered-background-position);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.none {
|
||||||
|
background: var(--color-none);
|
||||||
|
background-repeat: var(--color-none-repeat);
|
||||||
|
background-position: var(--color-none-position);
|
||||||
|
background-size: var(--color-none-size-24px);
|
||||||
|
background-image: var(--color-none-image-24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
right: 0;
|
||||||
|
height: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
background: var(--color-f-white);
|
||||||
|
color: var(--color-2-mildblack);
|
||||||
|
border-radius: 4px 0 0 0;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
box-shadow: 0 0 2px var(--color-3-darkgray);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.swatch {
|
> .floating-menu {
|
||||||
flex: 0 0 auto;
|
left: 50%;
|
||||||
position: relative;
|
bottom: 0;
|
||||||
|
|
||||||
.swatch-button {
|
|
||||||
--swatch-color: #ffffff;
|
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
bottom: 0;
|
|
||||||
left: 50%;
|
|
||||||
padding: 0;
|
|
||||||
outline: none;
|
|
||||||
border: none;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%),
|
|
||||||
linear-gradient(#ffffff, #ffffff);
|
|
||||||
background-size: 16px 16px;
|
|
||||||
background-position: 0 0, 8px 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--swatch-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled-swatch::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
border-top: 4px solid red;
|
|
||||||
width: 33px;
|
|
||||||
left: 22px;
|
|
||||||
top: -4px;
|
|
||||||
transform: rotate(135deg);
|
|
||||||
transform-origin: 0% 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-menu {
|
|
||||||
margin-top: 24px;
|
|
||||||
left: 50%;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -72,16 +76,13 @@ import { Color } from "@/wasm-communication/messages";
|
||||||
|
|
||||||
import ColorPicker from "@/components/floating-menus/ColorPicker.vue";
|
import ColorPicker from "@/components/floating-menus/ColorPicker.vue";
|
||||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||||
import OptionalInput from "@/components/widgets/inputs/OptionalInput.vue";
|
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
|
||||||
import TextInput from "@/components/widgets/inputs/TextInput.vue";
|
|
||||||
import Separator from "@/components/widgets/labels/Separator.vue";
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
emits: ["update:value", "update:open"],
|
emits: ["update:value", "update:open"],
|
||||||
props: {
|
props: {
|
||||||
value: { type: String as PropType<string | undefined>, required: false },
|
value: { type: Color as PropType<Color>, required: true },
|
||||||
label: { type: String as PropType<string>, required: false },
|
noTransparency: { type: Boolean as PropType<boolean>, default: false }, // TODO: Rename to allowTransparency, also implement allowNone
|
||||||
noTransparency: { type: Boolean as PropType<boolean>, default: false },
|
|
||||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
disabled: { type: Boolean as PropType<boolean>, default: false },
|
||||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||||
|
|
||||||
|
|
@ -94,26 +95,6 @@ export default defineComponent({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
|
||||||
color(): Color {
|
|
||||||
// TODO: Validate length of value, and allow shorthand versions like single-digit or three-digit hex codes
|
|
||||||
if (!this.value) return new Color(0, 0, 0, 1);
|
|
||||||
|
|
||||||
const r = parseInt(this.value.slice(0, 2), 16) / 255;
|
|
||||||
const g = parseInt(this.value.slice(2, 4), 16) / 255;
|
|
||||||
const b = parseInt(this.value.slice(4, 6), 16) / 255;
|
|
||||||
const a = parseInt(this.value.slice(6, 8), 16) / 255;
|
|
||||||
|
|
||||||
return new Color(r, g, b, a);
|
|
||||||
},
|
|
||||||
displayValue(): string {
|
|
||||||
if (!this.value) return "";
|
|
||||||
|
|
||||||
const value = this.value.toLowerCase();
|
|
||||||
const shortenedIfOpaque = value.slice(-2) === "ff" ? value.slice(0, 6) : value;
|
|
||||||
return `#${shortenedIfOpaque}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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(newOpen: boolean) {
|
open(newOpen: boolean) {
|
||||||
|
|
@ -125,46 +106,18 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
colorPickerUpdated(color: Color) {
|
colorPickerUpdated(color: Color) {
|
||||||
const twoDigitHex = (value: number): string =>
|
this.$emit("update:value", color);
|
||||||
Math.floor(value * 255)
|
|
||||||
.toString(16)
|
|
||||||
.padStart(2, "0");
|
|
||||||
const newValue = `${twoDigitHex(color.red)}${twoDigitHex(color.green)}${twoDigitHex(color.blue)}${twoDigitHex(color.alpha)}`;
|
|
||||||
this.$emit("update:value", newValue);
|
|
||||||
},
|
},
|
||||||
textInputUpdated(newValue: string) {
|
},
|
||||||
const sanitizedMatch = newValue.match(/^\s*#?([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\s*$/);
|
computed: {
|
||||||
if (!sanitizedMatch) return;
|
chip() {
|
||||||
|
return undefined;
|
||||||
let sanitized;
|
|
||||||
const match = sanitizedMatch[1];
|
|
||||||
if (match.length === 3) {
|
|
||||||
sanitized = match
|
|
||||||
.split("")
|
|
||||||
.map((byte) => `${byte}${byte}`)
|
|
||||||
.concat("ff")
|
|
||||||
.join("");
|
|
||||||
} else if (match.length === 6) {
|
|
||||||
sanitized = `${match}ff`;
|
|
||||||
} else if (match.length === 8) {
|
|
||||||
sanitized = match;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$emit("update:value", sanitized);
|
|
||||||
},
|
|
||||||
updateEnabled(value: boolean) {
|
|
||||||
if (value) this.$emit("update:value", "000000");
|
|
||||||
else this.$emit("update:value", undefined);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
ColorPicker,
|
ColorPicker,
|
||||||
LayoutRow,
|
LayoutRow,
|
||||||
OptionalInput,
|
TextLabel,
|
||||||
Separator,
|
|
||||||
TextInput,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<!-- This is a base component, extended by others like NumberInput and TextInput. It should not be used directly. -->
|
<!-- This is a base component, extended by others like NumberInput and TextInput. It should not be used directly. -->
|
||||||
<template>
|
<template>
|
||||||
<LayoutRow class="field-input" :class="{ disabled }">
|
<LayoutRow class="field-input" :class="{ disabled }" :title="tooltip">
|
||||||
<input
|
<input
|
||||||
v-if="!textarea"
|
v-if="!textarea"
|
||||||
:class="{ 'has-label': label }"
|
:class="{ 'has-label': label }"
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
v-model="inputValue"
|
v-model="inputValue"
|
||||||
:spellcheck="spellcheck"
|
:spellcheck="spellcheck"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:title="tooltip"
|
|
||||||
@focus="() => $emit('textFocused')"
|
@focus="() => $emit('textFocused')"
|
||||||
@blur="() => $emit('textChanged')"
|
@blur="() => $emit('textChanged')"
|
||||||
@change="() => $emit('textChanged')"
|
@change="() => $emit('textChanged')"
|
||||||
|
|
@ -27,7 +26,6 @@
|
||||||
v-model="inputValue"
|
v-model="inputValue"
|
||||||
:spellcheck="spellcheck"
|
:spellcheck="spellcheck"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:title="tooltip"
|
|
||||||
@focus="() => $emit('textFocused')"
|
@focus="() => $emit('textFocused')"
|
||||||
@blur="() => $emit('textChanged')"
|
@blur="() => $emit('textChanged')"
|
||||||
@change="() => $emit('textChanged')"
|
@change="() => $emit('textChanged')"
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
button {
|
> button {
|
||||||
--swatch-color: #ffffff;
|
--swatch-color: #ffffff;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -34,19 +34,10 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%),
|
background: linear-gradient(var(--swatch-color), var(--swatch-color)), var(--transparent-checkered-background);
|
||||||
linear-gradient(#ffffff, #ffffff);
|
background-size: var(--transparent-checkered-background-size);
|
||||||
background-size: 16px 16px;
|
background-position: var(--transparent-checkered-background-position);
|
||||||
background-position: 0 0, 8px 8px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--swatch-color);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-menu {
|
.floating-menu {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<FieldInput
|
<FieldInput
|
||||||
class="text-input"
|
class="text-input"
|
||||||
|
:class="{ centered }"
|
||||||
v-model:value="text"
|
v-model:value="text"
|
||||||
:label="label"
|
:label="label"
|
||||||
:spellcheck="true"
|
:spellcheck="true"
|
||||||
|
|
@ -19,6 +20,12 @@
|
||||||
input {
|
input {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.centered {
|
||||||
|
input {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
@ -33,6 +40,7 @@ export default defineComponent({
|
||||||
value: { type: String as PropType<string>, required: true },
|
value: { type: String as PropType<string>, required: true },
|
||||||
label: { type: String as PropType<string>, required: false },
|
label: { type: String as PropType<string>, required: false },
|
||||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
disabled: { 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 },
|
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ import CheckboxUnchecked from "@/../assets/icon-16px-solid/checkbox-unchecked.sv
|
||||||
import Copy from "@/../assets/icon-16px-solid/copy.svg";
|
import Copy from "@/../assets/icon-16px-solid/copy.svg";
|
||||||
import EyeHidden from "@/../assets/icon-16px-solid/eye-hidden.svg";
|
import EyeHidden from "@/../assets/icon-16px-solid/eye-hidden.svg";
|
||||||
import EyeVisible from "@/../assets/icon-16px-solid/eye-visible.svg";
|
import EyeVisible from "@/../assets/icon-16px-solid/eye-visible.svg";
|
||||||
|
import Eyedropper from "@/../assets/icon-16px-solid/eyedropper.svg";
|
||||||
import File from "@/../assets/icon-16px-solid/file.svg";
|
import File from "@/../assets/icon-16px-solid/file.svg";
|
||||||
import FlipHorizontal from "@/../assets/icon-16px-solid/flip-horizontal.svg";
|
import FlipHorizontal from "@/../assets/icon-16px-solid/flip-horizontal.svg";
|
||||||
import FlipVertical from "@/../assets/icon-16px-solid/flip-vertical.svg";
|
import FlipVertical from "@/../assets/icon-16px-solid/flip-vertical.svg";
|
||||||
|
|
@ -142,6 +143,7 @@ const SOLID_16PX = {
|
||||||
CheckboxChecked: { component: CheckboxChecked, size: 16 },
|
CheckboxChecked: { component: CheckboxChecked, size: 16 },
|
||||||
CheckboxUnchecked: { component: CheckboxUnchecked, size: 16 },
|
CheckboxUnchecked: { component: CheckboxUnchecked, size: 16 },
|
||||||
Copy: { component: Copy, size: 16 },
|
Copy: { component: Copy, size: 16 },
|
||||||
|
Eyedropper: { component: Eyedropper, size: 16 },
|
||||||
EyeHidden: { component: EyeHidden, size: 16 },
|
EyeHidden: { component: EyeHidden, size: 16 },
|
||||||
EyeVisible: { component: EyeVisible, size: 16 },
|
EyeVisible: { component: EyeVisible, size: 16 },
|
||||||
File: { component: File, size: 16 },
|
File: { component: File, size: 16 },
|
||||||
|
|
@ -149,7 +151,6 @@ const SOLID_16PX = {
|
||||||
FlipVertical: { component: FlipVertical, size: 16 },
|
FlipVertical: { component: FlipVertical, size: 16 },
|
||||||
Folder: { component: Folder, size: 16 },
|
Folder: { component: Folder, size: 16 },
|
||||||
GraphiteLogo: { component: GraphiteLogo, size: 16 },
|
GraphiteLogo: { component: GraphiteLogo, size: 16 },
|
||||||
NodeImaginate: { component: NodeImaginate, size: 16 },
|
|
||||||
NodeArtboard: { component: NodeArtboard, size: 16 },
|
NodeArtboard: { component: NodeArtboard, size: 16 },
|
||||||
NodeBlur: { component: NodeBlur, size: 16 },
|
NodeBlur: { component: NodeBlur, size: 16 },
|
||||||
NodeBrushwork: { component: NodeBrushwork, size: 16 },
|
NodeBrushwork: { component: NodeBrushwork, size: 16 },
|
||||||
|
|
@ -157,6 +158,7 @@ const SOLID_16PX = {
|
||||||
NodeFolder: { component: NodeFolder, size: 16 },
|
NodeFolder: { component: NodeFolder, size: 16 },
|
||||||
NodeGradient: { component: NodeGradient, size: 16 },
|
NodeGradient: { component: NodeGradient, size: 16 },
|
||||||
NodeImage: { component: NodeImage, size: 16 },
|
NodeImage: { component: NodeImage, size: 16 },
|
||||||
|
NodeImaginate: { component: NodeImaginate, size: 16 },
|
||||||
NodeMagicWand: { component: NodeMagicWand, size: 16 },
|
NodeMagicWand: { component: NodeMagicWand, size: 16 },
|
||||||
NodeMask: { component: NodeMask, size: 16 },
|
NodeMask: { component: NodeMask, size: 16 },
|
||||||
NodeMotionBlur: { component: NodeMotionBlur, size: 16 },
|
NodeMotionBlur: { component: NodeMotionBlur, size: 16 },
|
||||||
|
|
|
||||||
|
|
@ -100,32 +100,40 @@ export type ActionKeys = { keys: KeysGroup };
|
||||||
|
|
||||||
export type MouseMotion = string;
|
export type MouseMotion = string;
|
||||||
|
|
||||||
export type HSVA = {
|
// Channels can have any range (0-1, 0-255, 0-100, 0-360) in the context they are being used in, these are just containers for the numbers
|
||||||
h: number;
|
export type HSVA = { h: number; s: number; v: number; a: number };
|
||||||
s: number;
|
export type HSV = { h: number; s: number; v: number };
|
||||||
v: number;
|
export type RGBA = { r: number; g: number; b: number; a: number };
|
||||||
a: number;
|
export type RGB = { r: number; g: number; b: number };
|
||||||
};
|
|
||||||
|
|
||||||
// All channels range from 0 to 1
|
// All channels range from 0 to 1
|
||||||
export class Color {
|
export class Color {
|
||||||
constructor();
|
constructor();
|
||||||
|
|
||||||
|
constructor(none: "none");
|
||||||
|
|
||||||
constructor(hsva: HSVA);
|
constructor(hsva: HSVA);
|
||||||
|
|
||||||
constructor(red: number, green: number, blue: number, alpha: number);
|
constructor(red: number, green: number, blue: number, alpha: number);
|
||||||
|
|
||||||
constructor(hsvaOrRed?: HSVA | number, green?: number, blue?: number, alpha?: number) {
|
constructor(firstArg?: "none" | HSVA | number, green?: number, blue?: number, alpha?: number) {
|
||||||
// Empty constructor
|
// Empty constructor
|
||||||
if (hsvaOrRed === undefined) {
|
if (firstArg === undefined) {
|
||||||
this.red = 0;
|
this.red = 0;
|
||||||
this.green = 0;
|
this.green = 0;
|
||||||
this.blue = 0;
|
this.blue = 0;
|
||||||
this.alpha = 0;
|
this.alpha = 1;
|
||||||
|
this.none = false;
|
||||||
|
} else if (firstArg === "none") {
|
||||||
|
this.red = 0;
|
||||||
|
this.green = 0;
|
||||||
|
this.blue = 0;
|
||||||
|
this.alpha = 1;
|
||||||
|
this.none = true;
|
||||||
}
|
}
|
||||||
// HSVA constructor
|
// HSVA constructor
|
||||||
else if (typeof hsvaOrRed !== "number" && green === undefined && blue === undefined && alpha === undefined) {
|
else if (typeof firstArg === "object" && green === undefined && blue === undefined && alpha === undefined) {
|
||||||
const { h, s, v } = hsvaOrRed;
|
const { h, s, v } = firstArg;
|
||||||
const convert = (n: number): number => {
|
const convert = (n: number): number => {
|
||||||
const k = (n + h * 6) % 6;
|
const k = (n + h * 6) % 6;
|
||||||
return v - v * s * Math.max(Math.min(...[k, 4 - k, 1]), 0);
|
return v - v * s * Math.max(Math.min(...[k, 4 - k, 1]), 0);
|
||||||
|
|
@ -134,14 +142,16 @@ export class Color {
|
||||||
this.red = convert(5);
|
this.red = convert(5);
|
||||||
this.green = convert(3);
|
this.green = convert(3);
|
||||||
this.blue = convert(1);
|
this.blue = convert(1);
|
||||||
this.alpha = hsvaOrRed.a;
|
this.alpha = firstArg.a;
|
||||||
|
this.none = false;
|
||||||
}
|
}
|
||||||
// RGBA constructor
|
// RGBA constructor
|
||||||
else if (typeof hsvaOrRed === "number" && typeof green === "number" && typeof blue === "number" && typeof alpha === "number") {
|
else if (typeof firstArg === "number" && typeof green === "number" && typeof blue === "number" && typeof alpha === "number") {
|
||||||
this.red = hsvaOrRed;
|
this.red = firstArg;
|
||||||
this.green = green;
|
this.green = green;
|
||||||
this.blue = blue;
|
this.blue = blue;
|
||||||
this.alpha = alpha;
|
this.alpha = alpha;
|
||||||
|
this.none = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,15 +163,117 @@ export class Color {
|
||||||
|
|
||||||
readonly alpha!: number;
|
readonly alpha!: number;
|
||||||
|
|
||||||
toRgbCSS(): string {
|
readonly none!: boolean;
|
||||||
return `rgb(${this.red * 255}, ${this.green * 255}, ${this.blue * 255})`;
|
|
||||||
|
static fromCSS(colorCode: string): Color | undefined {
|
||||||
|
// Allow single-digit hex value inputs
|
||||||
|
let colorValue = colorCode.trim();
|
||||||
|
if (colorValue.length === 2 && colorValue.charAt(0) === "#" && /[0-9a-f]/i.test(colorValue.charAt(1))) {
|
||||||
|
const digit = colorValue.charAt(1);
|
||||||
|
colorValue = `#${digit}${digit}${digit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 1;
|
||||||
|
canvas.height = 1;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return undefined;
|
||||||
|
|
||||||
|
context.clearRect(0, 0, 1, 1);
|
||||||
|
|
||||||
|
context.fillStyle = "black";
|
||||||
|
context.fillStyle = colorValue;
|
||||||
|
const comparisonA = context.fillStyle;
|
||||||
|
|
||||||
|
context.fillStyle = "white";
|
||||||
|
context.fillStyle = colorValue;
|
||||||
|
const comparisonB = context.fillStyle;
|
||||||
|
|
||||||
|
// Invalid color
|
||||||
|
if (comparisonA !== comparisonB) {
|
||||||
|
// If this color code didn't start with a #, add it and try again
|
||||||
|
if (colorValue.trim().charAt(0) !== "#") return Color.fromCSS(`#${colorValue.trim()}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.fillRect(0, 0, 1, 1);
|
||||||
|
|
||||||
|
const [r, g, b, a] = [...context.getImageData(0, 0, 1, 1).data];
|
||||||
|
return new Color(r / 255, g / 255, b / 255, a / 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
toRgbaCSS(): string {
|
toHexNoAlpha(): string | undefined {
|
||||||
return `rgba(${this.red * 255}, ${this.green * 255}, ${this.blue * 255}, ${this.alpha})`;
|
if (this.none) return undefined;
|
||||||
|
|
||||||
|
const r = Math.round(this.red * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
const g = Math.round(this.green * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
const b = Math.round(this.blue * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
|
||||||
|
return `#${r}${g}${b}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
toHSVA(): HSVA {
|
toHexOptionalAlpha(): string | undefined {
|
||||||
|
if (this.none) return undefined;
|
||||||
|
|
||||||
|
const hex = this.toHexNoAlpha();
|
||||||
|
const a = Math.round(this.alpha * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
|
||||||
|
return a === "ff" ? hex : `${hex}${a}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toRgb255(): RGB | undefined {
|
||||||
|
if (this.none) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: Math.round(this.red * 255),
|
||||||
|
g: Math.round(this.green * 255),
|
||||||
|
b: Math.round(this.blue * 255),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
toRgba255(): RGBA | undefined {
|
||||||
|
if (this.none) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: Math.round(this.red * 255),
|
||||||
|
g: Math.round(this.green * 255),
|
||||||
|
b: Math.round(this.blue * 255),
|
||||||
|
a: Math.round(this.alpha * 255),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
toRgbCSS(): string | undefined {
|
||||||
|
const rgba = this.toRgba255();
|
||||||
|
if (!rgba) return undefined;
|
||||||
|
|
||||||
|
return `rgb(${rgba.r}, ${rgba.g}, ${rgba.b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toRgbaCSS(): string | undefined {
|
||||||
|
const rgba = this.toRgba255();
|
||||||
|
if (!rgba) return undefined;
|
||||||
|
|
||||||
|
return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toHSV(): HSV | undefined {
|
||||||
|
const hsva = this.toHSVA();
|
||||||
|
if (!hsva) return undefined;
|
||||||
|
|
||||||
|
return { h: hsva.h, s: hsva.s, v: hsva.v };
|
||||||
|
}
|
||||||
|
|
||||||
|
toHSVA(): HSVA | undefined {
|
||||||
|
if (this.none) return undefined;
|
||||||
|
|
||||||
const { red: r, green: g, blue: b, alpha: a } = this;
|
const { red: r, green: g, blue: b, alpha: a } = this;
|
||||||
|
|
||||||
const max = Math.max(r, g, b);
|
const max = Math.max(r, g, b);
|
||||||
|
|
@ -190,6 +302,45 @@ export class Color {
|
||||||
|
|
||||||
return { h, s, v, a };
|
return { h, s, v, a };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toHsvDegreesAndPercent(): HSV | undefined {
|
||||||
|
const hsva = this.toHSVA();
|
||||||
|
if (!hsva) return undefined;
|
||||||
|
|
||||||
|
return { h: hsva.h * 360, s: hsva.s * 100, v: hsva.v * 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
toHsvaDegreesAndPercent(): HSVA | undefined {
|
||||||
|
const hsva = this.toHSVA();
|
||||||
|
if (!hsva) return undefined;
|
||||||
|
|
||||||
|
return { h: hsva.h * 360, s: hsva.s * 100, v: hsva.v * 100, a: hsva.a * 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
opaque(): Color | undefined {
|
||||||
|
if (this.none) return undefined;
|
||||||
|
|
||||||
|
return new Color(this.red, this.green, this.blue, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
contrastingColor(): "black" | "white" {
|
||||||
|
if (this.none) return "black";
|
||||||
|
|
||||||
|
// Convert alpha into white
|
||||||
|
const r = this.red * this.alpha + (1 - this.alpha);
|
||||||
|
const g = this.green * this.alpha + (1 - this.alpha);
|
||||||
|
const b = this.blue * this.alpha + (1 - this.alpha);
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/3943023/775283
|
||||||
|
|
||||||
|
const linearR = r <= 0.04045 ? r / 12.92 : ((r + 0.055) / 1.055) ** 2.4;
|
||||||
|
const linearG = g <= 0.04045 ? g / 12.92 : ((g + 0.055) / 1.055) ** 2.4;
|
||||||
|
const linearB = b <= 0.04045 ? b / 12.92 : ((b + 0.055) / 1.055) ** 2.4;
|
||||||
|
|
||||||
|
const linear = linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722;
|
||||||
|
|
||||||
|
return linear > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateActiveDocument extends JsMessage {
|
export class UpdateActiveDocument extends JsMessage {
|
||||||
|
|
@ -562,9 +713,10 @@ export class CheckboxInput extends WidgetProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ColorInput extends WidgetProps {
|
export class ColorInput extends WidgetProps {
|
||||||
value!: string | undefined;
|
@Transform(({ value }: { value: { red: number; green: number; blue: number; alpha: number } | undefined }) =>
|
||||||
|
value === undefined ? new Color("none") : new Color(value.red, value.green, value.blue, value.alpha)
|
||||||
label!: string | undefined;
|
)
|
||||||
|
value!: Color;
|
||||||
|
|
||||||
noTransparency!: boolean;
|
noTransparency!: boolean;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,15 @@ impl JsEditorHandle {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Begin sampling a pixel color from the document by entering eyedropper sampling mode
|
||||||
|
#[wasm_bindgen(js_name = eyedropperSampleForColorPicker)]
|
||||||
|
pub fn eyedropper_sample_for_color_picker(&self) -> Result<(), JsValue> {
|
||||||
|
let message = DialogMessage::RequestComingSoonDialog { issue: Some(832) };
|
||||||
|
self.dispatch(message);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Update primary color with values on a scale from 0 to 1.
|
/// Update primary color with values on a scale from 0 to 1.
|
||||||
#[wasm_bindgen(js_name = updatePrimaryColor)]
|
#[wasm_bindgen(js_name = updatePrimaryColor)]
|
||||||
pub fn update_primary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
|
pub fn update_primary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
|
||||||
|
|
|
||||||
|
|
@ -300,16 +300,10 @@ impl Stroke {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_color(mut self, color: &Option<String>) -> Option<Self> {
|
pub fn with_color(mut self, color: &Option<Color>) -> Option<Self> {
|
||||||
if let Some(color) = color {
|
self.color = *color;
|
||||||
Color::from_rgba_str(color).or_else(|| Color::from_rgb_str(color)).map(|color| {
|
|
||||||
self.color = Some(color);
|
Some(self)
|
||||||
self
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
self.color = None;
|
|
||||||
Some(self)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_weight(mut self, weight: f64) -> Self {
|
pub fn with_weight(mut self, weight: f64) -> Self {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue