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:
Keavon Chambers 2022-10-28 18:36:04 -07:00
parent fa7116133b
commit 85c635f92d
23 changed files with 830 additions and 279 deletions

View File

@ -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),

View File

@ -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);
} }

View File

@ -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,

View File

@ -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()

View File

@ -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());

View File

@ -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

View File

@ -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,

View File

@ -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 0255'">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 0255`"
/>
</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 &quot;color&quot; along the rainbow',
s: 'Saturation component, the &quot;colorfulness&quot; 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>

View File

@ -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 {

View File

@ -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 {

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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"

View File

@ -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;
}
} }
} }
} }

View File

@ -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>

View File

@ -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')"

View File

@ -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 {

View File

@ -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 },
}, },

View File

@ -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 },

View File

@ -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;

View File

@ -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> {

View File

@ -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 {