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(KeyZ); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::Redo),
|
||||
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(KeyS); modifiers=[Accel], action_dispatch=DocumentMessage::SaveDocument),
|
||||
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::prelude::*;
|
||||
|
||||
use graphene::color::Color;
|
||||
use graphene::layers::text_layer::Font;
|
||||
|
||||
use serde_json::Value;
|
||||
|
|
@ -60,8 +61,23 @@ impl<F: Fn(&MessageDiscriminant) -> Vec<KeysGroup>> MessageHandler<LayoutMessage
|
|||
responses.push_back(callback_message);
|
||||
}
|
||||
Widget::ColorInput(color_input) => {
|
||||
let update_value = value.as_str().map(String::from);
|
||||
color_input.value = update_value;
|
||||
let update_value = value.as_object().expect("ColorInput update was not of type: object");
|
||||
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);
|
||||
responses.push_back(callback_message);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,13 +39,12 @@ impl Default for CheckboxInput {
|
|||
#[derive(Clone, Derivative, Serialize, Deserialize)]
|
||||
#[derivative(Debug, PartialEq, Default)]
|
||||
pub struct ColorInput {
|
||||
pub value: Option<String>,
|
||||
|
||||
pub label: Option<String>,
|
||||
pub value: Option<Color>,
|
||||
|
||||
// TODO: Add allow_none
|
||||
#[serde(rename = "noTransparency")]
|
||||
#[derivative(Default(value = "true"))]
|
||||
pub no_transparency: bool,
|
||||
pub no_transparency: bool, // TODO: Rename allow_transparency (and invert usages)
|
||||
|
||||
pub disabled: bool,
|
||||
|
||||
|
|
@ -295,6 +294,8 @@ pub struct TextInput {
|
|||
|
||||
pub tooltip: String,
|
||||
|
||||
pub centered: bool,
|
||||
|
||||
#[serde(rename = "minWidth")]
|
||||
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::prelude::*;
|
||||
|
||||
use graphene::color::Color;
|
||||
use graphene::document::pick_layer_safe_imaginate_resolution;
|
||||
use graphene::layers::imaginate_layer::{ImaginateLayer, ImaginateSamplingMethod, ImaginateStatus};
|
||||
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,
|
||||
})),
|
||||
WidgetHolder::new(Widget::ColorInput(ColorInput {
|
||||
value: Some(color.rgba_hex()),
|
||||
value: Some(*color),
|
||||
on_update: WidgetCallback::new(|text_input: &ColorInput| {
|
||||
if let Some(value) = &text_input.value {
|
||||
if let Some(color) = Color::from_rgba_str(value).or_else(|| Color::from_rgb_str(value)) {
|
||||
let new_fill = Fill::Solid(color);
|
||||
PropertiesPanelMessage::ModifyFill { fill: new_fill }.into()
|
||||
} else {
|
||||
PropertiesPanelMessage::ResendActiveProperties.into()
|
||||
}
|
||||
} else {
|
||||
PropertiesPanelMessage::ModifyFill { fill: Fill::None }.into()
|
||||
}
|
||||
let fill = if let Some(value) = text_input.value { Fill::Solid(value) } else { Fill::None };
|
||||
PropertiesPanelMessage::ModifyFill { fill }.into()
|
||||
}),
|
||||
no_transparency: true,
|
||||
..Default::default()
|
||||
|
|
@ -1186,21 +1177,11 @@ fn node_gradient_color(gradient: &Gradient, percent_label: &'static str, positio
|
|||
direction: SeparatorDirection::Horizontal,
|
||||
})),
|
||||
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| {
|
||||
if let Some(value) = &text_input.value {
|
||||
if let Some(color) = Color::from_rgba_str(value).or_else(|| Color::from_rgb_str(value)) {
|
||||
let mut new_gradient = (*gradient_clone).clone();
|
||||
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)
|
||||
}
|
||||
let mut new_gradient = (*gradient_clone).clone();
|
||||
new_gradient.positions[position].1 = text_input.value;
|
||||
send_fill_message(new_gradient)
|
||||
}),
|
||||
..ColorInput::default()
|
||||
})),
|
||||
|
|
@ -1223,18 +1204,10 @@ fn node_section_fill(fill: &Fill) -> Option<LayoutGroup> {
|
|||
direction: SeparatorDirection::Horizontal,
|
||||
})),
|
||||
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| {
|
||||
if let Some(value) = &text_input.value {
|
||||
if let Some(color) = Color::from_rgba_str(value).or_else(|| Color::from_rgb_str(value)) {
|
||||
let new_fill = Fill::Solid(color);
|
||||
PropertiesPanelMessage::ModifyFill { fill: new_fill }.into()
|
||||
} else {
|
||||
PropertiesPanelMessage::ResendActiveProperties.into()
|
||||
}
|
||||
} else {
|
||||
PropertiesPanelMessage::ModifyFill { fill: Fill::None }.into()
|
||||
}
|
||||
let fill = if let Some(value) = text_input.value { Fill::Solid(value) } else { Fill::None };
|
||||
PropertiesPanelMessage::ModifyFill { fill }.into()
|
||||
}),
|
||||
..ColorInput::default()
|
||||
})),
|
||||
|
|
@ -1276,7 +1249,7 @@ fn node_section_stroke(stroke: &Stroke) -> LayoutGroup {
|
|||
direction: SeparatorDirection::Horizontal,
|
||||
})),
|
||||
WidgetHolder::new(Widget::ColorInput(ColorInput {
|
||||
value: stroke.color().map(|color| color.rgba_hex()),
|
||||
value: stroke.color(),
|
||||
on_update: WidgetCallback::new(move |text_input: &ColorInput| {
|
||||
internal_stroke1
|
||||
.clone()
|
||||
|
|
@ -1324,6 +1297,7 @@ fn node_section_stroke(stroke: &Stroke) -> LayoutGroup {
|
|||
})),
|
||||
WidgetHolder::new(Widget::TextInput(TextInput {
|
||||
value: stroke.dash_lengths(),
|
||||
centered: true,
|
||||
on_update: WidgetCallback::new(move |text_input: &TextInput| {
|
||||
internal_stroke3
|
||||
.clone()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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::prelude::*;
|
||||
use crate::messages::tool::utility_types::{DocumentToolData, EventToMessageMap, Fsm, ToolActionHandlerData, ToolMetadata, ToolTransition, ToolType};
|
||||
|
|
@ -177,8 +177,13 @@ impl Fsm for EyedropperToolFsmState {
|
|||
plus: false,
|
||||
},
|
||||
])]),
|
||||
EyedropperToolFsmState::SamplingPrimary => HintData(vec![]),
|
||||
EyedropperToolFsmState::SamplingSecondary => HintData(vec![]),
|
||||
EyedropperToolFsmState::SamplingPrimary | EyedropperToolFsmState::SamplingSecondary => HintData(vec![HintGroup(vec![HintInfo {
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -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-unused2: #70a898;
|
||||
--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,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,130 @@
|
|||
<template>
|
||||
<FloatingMenu :open="open" @update:open="(isOpen) => emitOpenState(isOpen)" :direction="direction" :type="'Popover'">
|
||||
<LayoutRow class="color-picker">
|
||||
<LayoutCol class="saturation-value-picker" :style="{ '--saturation-value-picker-hue': hueColorCSS }" @pointerdown="(e: PointerEvent) => beginDrag(e)" data-saturation-value-picker>
|
||||
<div class="selection-circle" :style="{ top: `${(1 - value) * 100}%`, left: `${saturation * 100}%` }"></div>
|
||||
<FloatingMenu class="color-picker" :open="open" @update:open="(isOpen) => emitOpenState(isOpen)" :strayCloses="strayCloses" :direction="direction" :type="'Popover'">
|
||||
<LayoutRow
|
||||
:style="{
|
||||
'--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 class="hue-picker" @pointerdown="(e: PointerEvent) => beginDrag(e)" data-hue-picker>
|
||||
<div class="selection-pincers" :style="{ top: `${(1 - hue) * 100}%` }"></div>
|
||||
<LayoutCol class="hue-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-hue-picker>
|
||||
<div class="selection-pincers" :style="{ top: `${(1 - hue) * 100}%` }" v-if="!isNone"></div>
|
||||
</LayoutCol>
|
||||
<LayoutCol class="opacity-picker" :style="{ '--opacity-picker-color': color.toRgbCSS() }" @pointerdown="(e: PointerEvent) => beginDrag(e)" data-opacity-picker>
|
||||
<div class="selection-pincers" :style="{ top: `${(1 - opacity) * 100}%` }"></div>
|
||||
<LayoutCol class="opacity-picker" @pointerdown="(e: PointerEvent) => onPointerDown(e)" data-opacity-picker>
|
||||
<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>
|
||||
</LayoutRow>
|
||||
</FloatingMenu>
|
||||
|
|
@ -19,7 +135,7 @@
|
|||
.saturation-value-picker {
|
||||
width: 256px;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -42,10 +158,11 @@
|
|||
background-blend-mode: screen;
|
||||
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%);
|
||||
--selection-pincers-color: var(--hue-color-contrasting);
|
||||
}
|
||||
|
||||
.opacity-picker {
|
||||
background: linear-gradient(to bottom, var(--opacity-picker-color), transparent);
|
||||
background: linear-gradient(to bottom, var(--opaque-color), transparent);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
|
|
@ -53,12 +170,11 @@
|
|||
height: 100%;
|
||||
z-index: -1;
|
||||
position: relative;
|
||||
// Checkered transparent pattern
|
||||
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;
|
||||
background: var(--transparent-checkered-background);
|
||||
background-size: var(--transparent-checkered-background-size);
|
||||
background-position: var(--transparent-checkered-background-position);
|
||||
}
|
||||
--selection-pincers-color: var(--new-color-contrasting);
|
||||
}
|
||||
|
||||
.selection-circle {
|
||||
|
|
@ -78,9 +194,8 @@
|
|||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
border: 2px solid var(--opaque-color-contrasting);
|
||||
box-sizing: border-box;
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +213,7 @@
|
|||
left: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 0 4px 4px;
|
||||
border-color: transparent transparent transparent #000000;
|
||||
border-color: transparent transparent transparent var(--selection-pincers-color);
|
||||
}
|
||||
|
||||
&::after {
|
||||
|
|
@ -108,7 +223,129 @@
|
|||
right: 0;
|
||||
border-style: solid;
|
||||
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 { clamp } from "@/utility-functions/math";
|
||||
import type { HSV, RGB } from "@/wasm-communication/messages";
|
||||
import { Color } from "@/wasm-communication/messages";
|
||||
|
||||
import FloatingMenu, { type MenuDirection } from "@/components/layout/FloatingMenu.vue";
|
||||
import LayoutCol from "@/components/layout/LayoutCol.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({
|
||||
inject: ["editor"],
|
||||
emits: ["update:color", "update:open"],
|
||||
props: {
|
||||
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" },
|
||||
// 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() {
|
||||
const hsva = this.color.toHSVA();
|
||||
const hsvaOrNone = this.color.toHSVA();
|
||||
const hsva = hsvaOrNone || { h: 0, s: 0, v: 0, a: 1 };
|
||||
|
||||
return {
|
||||
draggingPickerTrack: undefined as HTMLDivElement | undefined,
|
||||
hue: hsva.h,
|
||||
saturation: hsva.s,
|
||||
value: hsva.v,
|
||||
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: {
|
||||
hueColorCSS() {
|
||||
return new Color({ h: this.hue, s: 1, v: 1, a: 1 }).toRgbCSS();
|
||||
opaqueHueColor(): Color {
|
||||
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: {
|
||||
beginDrag(e: PointerEvent) {
|
||||
onPointerDown(e: PointerEvent) {
|
||||
const target = (e.target || undefined) as HTMLElement | undefined;
|
||||
this.draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-opacity-picker]") || undefined;
|
||||
|
||||
this.addEvents();
|
||||
|
||||
this.onPointerMove(e);
|
||||
},
|
||||
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")) {
|
||||
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
this.saturation = clamp((e.clientX - rectangle.left) / rectangle.width, 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")) {
|
||||
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
this.hue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
this.strayCloses = false;
|
||||
} else if (this.draggingPickerTrack?.hasAttribute("data-opacity-picker")) {
|
||||
const rectangle = this.draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
this.opacity = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
this.strayCloses = false;
|
||||
}
|
||||
|
||||
// Just in case the mouseup event is lost
|
||||
if (e.buttons === 0) this.removeEvents();
|
||||
|
||||
// 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 }));
|
||||
const color = new Color({ h: this.hue, s: this.saturation, v: this.value, a: this.opacity });
|
||||
this.setColor(color);
|
||||
},
|
||||
onPointerUp() {
|
||||
this.removeEvents();
|
||||
},
|
||||
emitOpenState(isOpen: boolean) {
|
||||
this.$emit("update:open", isOpen);
|
||||
},
|
||||
addEvents() {
|
||||
document.addEventListener("pointermove", this.onPointerMove);
|
||||
document.addEventListener("pointerup", this.onPointerUp);
|
||||
},
|
||||
removeEvents() {
|
||||
this.draggingPickerTrack = undefined;
|
||||
this.strayCloses = true;
|
||||
|
||||
document.removeEventListener("pointermove", this.onPointerMove);
|
||||
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() {
|
||||
this.removeEvents();
|
||||
},
|
||||
components: {
|
||||
DropdownInput,
|
||||
FloatingMenu,
|
||||
IconButton,
|
||||
LayoutCol,
|
||||
LayoutRow,
|
||||
NumberInput,
|
||||
Separator,
|
||||
TextInput,
|
||||
TextLabel,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -35,12 +35,11 @@
|
|||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: -9px;
|
||||
left: -9px;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5), 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
|
|
@ -52,18 +51,17 @@
|
|||
height: 110px;
|
||||
border-radius: 50%;
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: calc(100% - 2px);
|
||||
height: calc(100% - 2px);
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]">
|
||||
<div class="tail" :style="tailStyle" v-if="displayTail"></div>
|
||||
<div class="floating-menu-container" v-if="open || measuringOngoing" ref="floatingMenuContainer">
|
||||
<div class="tail" v-if="displayTail" ref="tail"></div>
|
||||
<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>
|
||||
<slot></slot>
|
||||
</LayoutCol>
|
||||
|
|
@ -201,10 +201,9 @@ export default defineComponent({
|
|||
scrollableY: { type: Boolean as PropType<boolean>, default: false },
|
||||
minWidth: { type: Number as PropType<number>, default: 0 },
|
||||
escapeCloses: { type: Boolean as PropType<boolean>, default: true },
|
||||
strayCloses: { type: Boolean as PropType<boolean>, default: true },
|
||||
},
|
||||
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.
|
||||
// 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.
|
||||
|
|
@ -218,7 +217,6 @@ export default defineComponent({
|
|||
measuringOngoing: false,
|
||||
measuringOngoingGuard: false,
|
||||
minWidthParentWidth: 0,
|
||||
tailStyle,
|
||||
containerResizeObserver,
|
||||
pointerStillDown: false,
|
||||
workspaceBounds: new DOMRect(),
|
||||
|
|
@ -234,6 +232,9 @@ export default defineComponent({
|
|||
displayTail() {
|
||||
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
|
||||
// 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`;
|
||||
|
||||
// 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` };
|
||||
if (this.direction === "Top") this.tailStyle = { bottom: `${this.floatingMenuBounds.bottom}px` };
|
||||
if (this.direction === "Right") this.tailStyle = { left: `${this.floatingMenuBounds.left}px` };
|
||||
if (this.direction === "Left") this.tailStyle = { right: `${this.floatingMenuBounds.right}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
|
||||
const tail = this.$refs.tail as HTMLElement;
|
||||
if (tail && this.direction === "Bottom") tail.style.top = `${this.floatingMenuBounds.top}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";
|
||||
|
|
@ -369,13 +372,14 @@ export default defineComponent({
|
|||
// 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;
|
||||
|
||||
// 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
|
||||
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)
|
||||
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: with zero stray distance if the cursor is further than the stray distance from only the top-level menu
|
||||
this.$emit("update:open", false);
|
||||
|
|
@ -486,11 +490,11 @@ export default defineComponent({
|
|||
window.removeEventListener("click", this.clickHandlerCapture, true);
|
||||
},
|
||||
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;
|
||||
if (!floatingMenu) return true;
|
||||
|
||||
const allContainedFloatingMenus = [...floatingMenu.querySelectorAll("[data-floating-menu-content]")];
|
||||
|
||||
return !allContainedFloatingMenus.find((element) => !this.isPointerEventOutsideMenuElement(e, element, extraDistanceAllowed));
|
||||
},
|
||||
isPointerEventOutsideMenuElement(e: PointerEvent, element: Element, extraDistanceAllowed = 0): boolean {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -25,6 +31,7 @@ export default defineComponent({
|
|||
props: {
|
||||
scrollableX: { 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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -25,6 +31,7 @@ export default defineComponent({
|
|||
props: {
|
||||
scrollableX: { 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>
|
||||
|
|
|
|||
|
|
@ -457,7 +457,7 @@ export default defineComponent({
|
|||
this.editor.instance.onChangeText(textCleaned);
|
||||
},
|
||||
displayEditableTextbox(displayEditableTextbox: DisplayEditableTextbox) {
|
||||
this.textInput = document.createElement("DIV") as HTMLDivElement;
|
||||
this.textInput = document.createElement("div") as HTMLDivElement;
|
||||
|
||||
if (displayEditableTextbox.text === "") this.textInput.textContent = "";
|
||||
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.height = "auto";
|
||||
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 => {
|
||||
if (!this.textInput) return;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<div :class="`widget-${direction}`">
|
||||
<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)" />
|
||||
<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
|
||||
v-if="component.props.kind === 'DropdownInput'"
|
||||
v-bind="component.props"
|
||||
|
|
|
|||
|
|
@ -61,13 +61,15 @@
|
|||
.body {
|
||||
margin: 0 4px;
|
||||
|
||||
.text-label:first-of-type {
|
||||
flex: 0 0 30%;
|
||||
text-align: right;
|
||||
}
|
||||
.widget-row {
|
||||
> .text-label:first-of-type {
|
||||
flex: 0 0 30%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
flex-grow: 1;
|
||||
> .text-button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +1,70 @@
|
|||
<template>
|
||||
<LayoutRow class="color-input" :title="tooltip">
|
||||
<OptionalInput v-if="!noTransparency" :icon="'CloseX'" :checked="Boolean(value)" @update:checked="(state: boolean) => updateEnabled(state)"></OptionalInput>
|
||||
<TextInput :value="displayValue" :label="label" :disabled="disabled || !value" @commitText="(value: string) => textInputUpdated(value)" :center="true" />
|
||||
<Separator :type="'Related'" />
|
||||
<LayoutRow class="swatch">
|
||||
<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>
|
||||
<button :class="{ none: value.none }" :style="{ '--color': value.toHexOptionalAlpha() }" @click="() => $emit('update:open', true)" data-floating-menu-spawner>
|
||||
<TextLabel :bold="true" class="chip" v-if="chip">{{ chip }}</TextLabel>
|
||||
</button>
|
||||
<ColorPicker v-model:open="isOpen" :color="value" @update:color="(color: Color) => colorPickerUpdated(color)" :allowNone="true" />
|
||||
</LayoutRow>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.color-input {
|
||||
.text-input input {
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
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 {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
|
||||
.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;
|
||||
}
|
||||
> .floating-menu {
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -72,16 +76,13 @@ import { Color } from "@/wasm-communication/messages";
|
|||
|
||||
import ColorPicker from "@/components/floating-menus/ColorPicker.vue";
|
||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||
import OptionalInput from "@/components/widgets/inputs/OptionalInput.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";
|
||||
|
||||
export default defineComponent({
|
||||
emits: ["update:value", "update:open"],
|
||||
props: {
|
||||
value: { type: String as PropType<string | undefined>, required: false },
|
||||
label: { type: String as PropType<string>, required: false },
|
||||
noTransparency: { type: Boolean as PropType<boolean>, default: false },
|
||||
value: { type: Color as PropType<Color>, required: true },
|
||||
noTransparency: { type: Boolean as PropType<boolean>, default: false }, // TODO: Rename to allowTransparency, also implement allowNone
|
||||
disabled: { type: Boolean as PropType<boolean>, default: false },
|
||||
tooltip: { type: String as PropType<string | undefined>, required: false },
|
||||
|
||||
|
|
@ -94,26 +95,6 @@ export default defineComponent({
|
|||
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: {
|
||||
// Called only when `open` is changed from outside this component (with v-model)
|
||||
open(newOpen: boolean) {
|
||||
|
|
@ -125,46 +106,18 @@ export default defineComponent({
|
|||
},
|
||||
methods: {
|
||||
colorPickerUpdated(color: Color) {
|
||||
const twoDigitHex = (value: number): string =>
|
||||
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);
|
||||
this.$emit("update:value", color);
|
||||
},
|
||||
textInputUpdated(newValue: string) {
|
||||
const sanitizedMatch = newValue.match(/^\s*#?([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\s*$/);
|
||||
if (!sanitizedMatch) return;
|
||||
|
||||
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);
|
||||
},
|
||||
computed: {
|
||||
chip() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
ColorPicker,
|
||||
LayoutRow,
|
||||
OptionalInput,
|
||||
Separator,
|
||||
TextInput,
|
||||
TextLabel,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<!-- This is a base component, extended by others like NumberInput and TextInput. It should not be used directly. -->
|
||||
<template>
|
||||
<LayoutRow class="field-input" :class="{ disabled }">
|
||||
<LayoutRow class="field-input" :class="{ disabled }" :title="tooltip">
|
||||
<input
|
||||
v-if="!textarea"
|
||||
:class="{ 'has-label': label }"
|
||||
|
|
@ -10,7 +10,6 @@
|
|||
v-model="inputValue"
|
||||
:spellcheck="spellcheck"
|
||||
:disabled="disabled"
|
||||
:title="tooltip"
|
||||
@focus="() => $emit('textFocused')"
|
||||
@blur="() => $emit('textChanged')"
|
||||
@change="() => $emit('textChanged')"
|
||||
|
|
@ -27,7 +26,6 @@
|
|||
v-model="inputValue"
|
||||
:spellcheck="spellcheck"
|
||||
:disabled="disabled"
|
||||
:title="tooltip"
|
||||
@focus="() => $emit('textFocused')"
|
||||
@blur="() => $emit('textChanged')"
|
||||
@change="() => $emit('textChanged')"
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
margin: 0 2px;
|
||||
position: relative;
|
||||
|
||||
button {
|
||||
> button {
|
||||
--swatch-color: #ffffff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
|
@ -34,19 +34,10 @@
|
|||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
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%),
|
||||
linear-gradient(#ffffff, #ffffff);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 8px 8px;
|
||||
background: linear-gradient(var(--swatch-color), var(--swatch-color)), var(--transparent-checkered-background);
|
||||
background-size: var(--transparent-checkered-background-size);
|
||||
background-position: var(--transparent-checkered-background-position);
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--swatch-color);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-menu {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<template>
|
||||
<FieldInput
|
||||
class="text-input"
|
||||
:class="{ centered }"
|
||||
v-model:value="text"
|
||||
:label="label"
|
||||
:spellcheck="true"
|
||||
|
|
@ -19,6 +20,12 @@
|
|||
input {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&.centered {
|
||||
input {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -33,6 +40,7 @@ export default defineComponent({
|
|||
value: { type: String as PropType<string>, required: true },
|
||||
label: { type: String as PropType<string>, required: 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 },
|
||||
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 EyeHidden from "@/../assets/icon-16px-solid/eye-hidden.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 FlipHorizontal from "@/../assets/icon-16px-solid/flip-horizontal.svg";
|
||||
import FlipVertical from "@/../assets/icon-16px-solid/flip-vertical.svg";
|
||||
|
|
@ -142,6 +143,7 @@ const SOLID_16PX = {
|
|||
CheckboxChecked: { component: CheckboxChecked, size: 16 },
|
||||
CheckboxUnchecked: { component: CheckboxUnchecked, size: 16 },
|
||||
Copy: { component: Copy, size: 16 },
|
||||
Eyedropper: { component: Eyedropper, size: 16 },
|
||||
EyeHidden: { component: EyeHidden, size: 16 },
|
||||
EyeVisible: { component: EyeVisible, size: 16 },
|
||||
File: { component: File, size: 16 },
|
||||
|
|
@ -149,7 +151,6 @@ const SOLID_16PX = {
|
|||
FlipVertical: { component: FlipVertical, size: 16 },
|
||||
Folder: { component: Folder, size: 16 },
|
||||
GraphiteLogo: { component: GraphiteLogo, size: 16 },
|
||||
NodeImaginate: { component: NodeImaginate, size: 16 },
|
||||
NodeArtboard: { component: NodeArtboard, size: 16 },
|
||||
NodeBlur: { component: NodeBlur, size: 16 },
|
||||
NodeBrushwork: { component: NodeBrushwork, size: 16 },
|
||||
|
|
@ -157,6 +158,7 @@ const SOLID_16PX = {
|
|||
NodeFolder: { component: NodeFolder, size: 16 },
|
||||
NodeGradient: { component: NodeGradient, size: 16 },
|
||||
NodeImage: { component: NodeImage, size: 16 },
|
||||
NodeImaginate: { component: NodeImaginate, size: 16 },
|
||||
NodeMagicWand: { component: NodeMagicWand, size: 16 },
|
||||
NodeMask: { component: NodeMask, size: 16 },
|
||||
NodeMotionBlur: { component: NodeMotionBlur, size: 16 },
|
||||
|
|
|
|||
|
|
@ -100,32 +100,40 @@ export type ActionKeys = { keys: KeysGroup };
|
|||
|
||||
export type MouseMotion = string;
|
||||
|
||||
export type HSVA = {
|
||||
h: number;
|
||||
s: number;
|
||||
v: number;
|
||||
a: number;
|
||||
};
|
||||
// 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
|
||||
export type HSVA = { h: number; s: number; v: number; a: number };
|
||||
export type HSV = { h: number; s: number; v: number };
|
||||
export type RGBA = { r: number; g: number; b: number; a: number };
|
||||
export type RGB = { r: number; g: number; b: number };
|
||||
|
||||
// All channels range from 0 to 1
|
||||
export class Color {
|
||||
constructor();
|
||||
|
||||
constructor(none: "none");
|
||||
|
||||
constructor(hsva: HSVA);
|
||||
|
||||
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
|
||||
if (hsvaOrRed === undefined) {
|
||||
if (firstArg === undefined) {
|
||||
this.red = 0;
|
||||
this.green = 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
|
||||
else if (typeof hsvaOrRed !== "number" && green === undefined && blue === undefined && alpha === undefined) {
|
||||
const { h, s, v } = hsvaOrRed;
|
||||
else if (typeof firstArg === "object" && green === undefined && blue === undefined && alpha === undefined) {
|
||||
const { h, s, v } = firstArg;
|
||||
const convert = (n: number): number => {
|
||||
const k = (n + h * 6) % 6;
|
||||
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.green = convert(3);
|
||||
this.blue = convert(1);
|
||||
this.alpha = hsvaOrRed.a;
|
||||
this.alpha = firstArg.a;
|
||||
this.none = false;
|
||||
}
|
||||
// RGBA constructor
|
||||
else if (typeof hsvaOrRed === "number" && typeof green === "number" && typeof blue === "number" && typeof alpha === "number") {
|
||||
this.red = hsvaOrRed;
|
||||
else if (typeof firstArg === "number" && typeof green === "number" && typeof blue === "number" && typeof alpha === "number") {
|
||||
this.red = firstArg;
|
||||
this.green = green;
|
||||
this.blue = blue;
|
||||
this.alpha = alpha;
|
||||
this.none = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -153,15 +163,117 @@ export class Color {
|
|||
|
||||
readonly alpha!: number;
|
||||
|
||||
toRgbCSS(): string {
|
||||
return `rgb(${this.red * 255}, ${this.green * 255}, ${this.blue * 255})`;
|
||||
readonly none!: boolean;
|
||||
|
||||
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 {
|
||||
return `rgba(${this.red * 255}, ${this.green * 255}, ${this.blue * 255}, ${this.alpha})`;
|
||||
toHexNoAlpha(): string | undefined {
|
||||
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 max = Math.max(r, g, b);
|
||||
|
|
@ -190,6 +302,45 @@ export class Color {
|
|||
|
||||
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 {
|
||||
|
|
@ -562,9 +713,10 @@ export class CheckboxInput extends WidgetProps {
|
|||
}
|
||||
|
||||
export class ColorInput extends WidgetProps {
|
||||
value!: string | undefined;
|
||||
|
||||
label!: 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)
|
||||
)
|
||||
value!: Color;
|
||||
|
||||
noTransparency!: boolean;
|
||||
|
||||
|
|
|
|||
|
|
@ -353,6 +353,15 @@ impl JsEditorHandle {
|
|||
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.
|
||||
#[wasm_bindgen(js_name = updatePrimaryColor)]
|
||||
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> {
|
||||
if let Some(color) = color {
|
||||
Color::from_rgba_str(color).or_else(|| Color::from_rgb_str(color)).map(|color| {
|
||||
self.color = Some(color);
|
||||
self
|
||||
})
|
||||
} else {
|
||||
self.color = None;
|
||||
Some(self)
|
||||
}
|
||||
pub fn with_color(mut self, color: &Option<Color>) -> Option<Self> {
|
||||
self.color = *color;
|
||||
|
||||
Some(self)
|
||||
}
|
||||
|
||||
pub fn with_weight(mut self, weight: f64) -> Self {
|
||||
|
|
|
|||
Loading…
Reference in New Issue