Port the color picker popover to a Rust-defined layout (#4102)
* Break out VisualColorPickersInput.svelte * Break out ColorComparisonInput.svelte and ColorPresetsInput.svelte * Add backend definitions and plumbing for the 4 new widgets * Port the ColorPicker.svelte layout and business logic to Rust * Port more ColorComparisonInput.svelte logic to Rust * Port more SpectrumInput.svelte logic to Rust * Port more frontend logic to Rust * Code review * Code review * Fix some CSS
This commit is contained in:
parent
62203cb171
commit
e59612c4ce
|
|
@ -2162,6 +2162,7 @@ version = "0.0.0"
|
|||
dependencies = [
|
||||
"base64",
|
||||
"bitflags 2.11.0",
|
||||
"color",
|
||||
"derivative",
|
||||
"dyn-any",
|
||||
"env_logger",
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ usvg = "0.47"
|
|||
parley = "0.6"
|
||||
skrifa = "0.40"
|
||||
polycool = "0.4"
|
||||
color = "0.3"
|
||||
# Linebender ecosystem (END)
|
||||
rand = { version = "0.9", default-features = false, features = ["std_rng"] }
|
||||
rand_chacha = "0.9"
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ vello = { workspace = true }
|
|||
base64 = { workspace = true }
|
||||
spin = { workspace = true }
|
||||
image = { workspace = true }
|
||||
color = { workspace = true }
|
||||
|
||||
# Optional local dependencies
|
||||
wgpu-executor = { workspace = true, optional = true }
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ pub struct DispatcherMessageHandlers {
|
|||
app_window_message_handler: AppWindowMessageHandler,
|
||||
broadcast_message_handler: BroadcastMessageHandler,
|
||||
clipboard_message_handler: ClipboardMessageHandler,
|
||||
color_picker_message_handler: ColorPickerMessageHandler,
|
||||
debug_message_handler: DebugMessageHandler,
|
||||
defer_message_handler: DeferMessageHandler,
|
||||
dialog_message_handler: DialogMessageHandler,
|
||||
|
|
@ -164,6 +165,7 @@ impl Dispatcher {
|
|||
}
|
||||
Message::Broadcast(message) => self.message_handlers.broadcast_message_handler.process_message(message, &mut queue, ()),
|
||||
Message::Clipboard(message) => self.message_handlers.clipboard_message_handler.process_message(message, &mut queue, ()),
|
||||
Message::ColorPicker(message) => self.message_handlers.color_picker_message_handler.process_message(message, &mut queue, ()),
|
||||
Message::Debug(message) => {
|
||||
self.message_handlers.debug_message_handler.process_message(message, &mut queue, ());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
use crate::messages::layout::utility_types::widgets::input_widgets::{SpectrumInputUpdate, VisualColorPickersInputUpdate};
|
||||
use crate::messages::prelude::*;
|
||||
use graphene_std::vector::style::FillChoice;
|
||||
|
||||
/// Identifies which RGB channel a numeric input change targets.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum RgbChannel {
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
}
|
||||
|
||||
/// Identifies which HSV channel a numeric input change targets.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum HsvChannel {
|
||||
Hue,
|
||||
Saturation,
|
||||
Value,
|
||||
}
|
||||
|
||||
#[impl_message(Message, ColorPicker)]
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ColorPickerMessage {
|
||||
/// Initialize the picker state from an external color/gradient and announce its options. Called by the frontend when a `<ColorPicker>` opens.
|
||||
Open { initial_value: FillChoice, allow_none: bool, disabled: bool },
|
||||
/// Clear the picker state. Called by the frontend when the popover closes.
|
||||
Close,
|
||||
|
||||
/// Visual sat/val/hue/alpha drag updates from `VisualColorPickersInput`.
|
||||
VisualUpdate { update: VisualColorPickersInputUpdate },
|
||||
/// Numeric RGB channel update.
|
||||
SetChannelRgb { channel: RgbChannel, value: Option<f64> },
|
||||
/// Numeric HSV channel update.
|
||||
SetChannelHsv { channel: HsvChannel, value: Option<f64> },
|
||||
/// Alpha percentage update from the alpha slider numeric input.
|
||||
SetAlphaPercent { value: Option<f64> },
|
||||
/// CSS / hex color string from the hex `TextInput`.
|
||||
SetHexCode { code: String },
|
||||
|
||||
/// Pick a preset (specific solid color or "None").
|
||||
PickPreset { preset: FillChoice },
|
||||
/// Color picked via the browser-native eyedropper. The string is the eyedropper's returned hex code.
|
||||
EyedropperColorCode { code: String },
|
||||
|
||||
/// Swap the current "new" color with the captured "old" color.
|
||||
SwapNewWithOld,
|
||||
|
||||
/// `SpectrumInput` change: marker move/insert/delete, midpoint move/reset, or active marker selection changed.
|
||||
GradientUpdate { update: SpectrumInputUpdate },
|
||||
|
||||
/// Tell the frontend to start an undo transaction (forwarded as a `FrontendMessage` it bridges out to the picker's parent).
|
||||
StartTransaction,
|
||||
/// Tell the frontend to commit the in-flight undo transaction.
|
||||
CommitTransaction,
|
||||
}
|
||||
|
|
@ -0,0 +1,700 @@
|
|||
use crate::messages::color_picker::color_picker_message::{HsvChannel, RgbChannel};
|
||||
use crate::messages::layout::utility_types::widget_prelude::*;
|
||||
use crate::messages::layout::utility_types::widgets::input_widgets::{ColorPresetsInputUpdate, SpectrumInputUpdate, SpectrumMarker, VisualColorPickersInputUpdate};
|
||||
use crate::messages::prelude::*;
|
||||
use color::{AlphaColor, Srgb};
|
||||
use graphene_std::Color;
|
||||
use graphene_std::vector::style::{FillChoice, GradientStops};
|
||||
|
||||
/// Bounds for a midpoint position (relative to the interval between two adjacent gradient stops).
|
||||
const MIN_MIDPOINT: f64 = 0.01;
|
||||
const MAX_MIDPOINT: f64 = 0.99;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, ExtractField)]
|
||||
pub struct ColorPickerMessageHandler {
|
||||
// HSV is the source of truth so the hue is preserved when the user desaturates the color (or drives the value to black) and back.
|
||||
hue: f64,
|
||||
saturation: f64,
|
||||
value: f64,
|
||||
alpha: f64,
|
||||
is_none: bool,
|
||||
|
||||
// Snapshot of the color when the picker opened, used by the new/old comparison swatch and the swap button.
|
||||
old_hue: f64,
|
||||
old_saturation: f64,
|
||||
old_value: f64,
|
||||
old_alpha: f64,
|
||||
old_is_none: bool,
|
||||
|
||||
// When set, the picker is editing a gradient: the visual pickers and inputs target the active stop's color.
|
||||
gradient: Option<GradientStops>,
|
||||
active_marker_index: Option<u32>,
|
||||
active_marker_is_midpoint: bool,
|
||||
|
||||
allow_none: bool,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
impl Default for ColorPickerMessageHandler {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hue: 0.,
|
||||
saturation: 0.,
|
||||
value: 0.,
|
||||
alpha: 1.,
|
||||
is_none: true,
|
||||
old_hue: 0.,
|
||||
old_saturation: 0.,
|
||||
old_value: 0.,
|
||||
old_alpha: 1.,
|
||||
old_is_none: true,
|
||||
gradient: None,
|
||||
active_marker_index: None,
|
||||
active_marker_is_midpoint: false,
|
||||
allow_none: true,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[message_handler_data]
|
||||
impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
|
||||
fn process_message(&mut self, message: ColorPickerMessage, responses: &mut VecDeque<Message>, _context: ()) {
|
||||
match message {
|
||||
ColorPickerMessage::Open { initial_value, allow_none, disabled } => {
|
||||
self.allow_none = allow_none;
|
||||
self.disabled = disabled;
|
||||
|
||||
// Each `<ColorPicker>` Svelte instance maintains its own local layout state, but the Rust `LayoutMessageHandler` keeps a single shared layout per target. When a new picker instance opens after a previous one closed, the new instance's layout starts empty and a diff from the previously-shared state would not apply. Destroying the stored layouts here forces the next `SendLayout` to send the full layout instead of a diff.
|
||||
responses.add(LayoutMessage::DestroyLayout {
|
||||
layout_target: LayoutTarget::ColorPickerPickersAndGradient,
|
||||
});
|
||||
responses.add(LayoutMessage::DestroyLayout {
|
||||
layout_target: LayoutTarget::ColorPickerDetails,
|
||||
});
|
||||
|
||||
match initial_value {
|
||||
FillChoice::None => {
|
||||
self.set_new_hsva(0., 0., 0., 1., true);
|
||||
self.gradient = None;
|
||||
self.active_marker_index = None;
|
||||
self.active_marker_is_midpoint = false;
|
||||
}
|
||||
FillChoice::Solid(color) => {
|
||||
self.gradient = None;
|
||||
self.active_marker_index = None;
|
||||
self.active_marker_is_midpoint = false;
|
||||
self.adopt_color(color);
|
||||
}
|
||||
FillChoice::Gradient(stops) => {
|
||||
self.active_marker_index = Some(0);
|
||||
self.active_marker_is_midpoint = false;
|
||||
let first_color = stops.color.first().copied().unwrap_or(Color::BLACK);
|
||||
self.gradient = Some(stops);
|
||||
self.adopt_color(first_color);
|
||||
}
|
||||
}
|
||||
|
||||
self.snapshot_old();
|
||||
self.send_layouts(responses);
|
||||
}
|
||||
ColorPickerMessage::Close => {
|
||||
self.gradient = None;
|
||||
self.active_marker_index = None;
|
||||
self.active_marker_is_midpoint = false;
|
||||
}
|
||||
ColorPickerMessage::VisualUpdate { update } => {
|
||||
self.hue = update.hue;
|
||||
self.saturation = update.saturation;
|
||||
self.value = update.value;
|
||||
self.alpha = update.alpha;
|
||||
self.is_none = false;
|
||||
self.emit_color(responses);
|
||||
self.send_layouts(responses);
|
||||
}
|
||||
ColorPickerMessage::SetChannelRgb { channel, value } => {
|
||||
let Some(strength) = value else { return };
|
||||
let Some(current) = self.current_color() else { return };
|
||||
let updated = match channel {
|
||||
RgbChannel::Red => Color::from_rgbaf32_unchecked((strength / 255.) as f32, current.g(), current.b(), current.a()),
|
||||
RgbChannel::Green => Color::from_rgbaf32_unchecked(current.r(), (strength / 255.) as f32, current.b(), current.a()),
|
||||
RgbChannel::Blue => Color::from_rgbaf32_unchecked(current.r(), current.g(), (strength / 255.) as f32, current.a()),
|
||||
};
|
||||
self.adopt_color(updated);
|
||||
self.emit_color(responses);
|
||||
self.send_layouts(responses);
|
||||
}
|
||||
ColorPickerMessage::SetChannelHsv { channel, value } => {
|
||||
let Some(strength) = value else { return };
|
||||
match channel {
|
||||
HsvChannel::Hue => self.hue = strength / 360.,
|
||||
HsvChannel::Saturation => self.saturation = strength / 100.,
|
||||
HsvChannel::Value => self.value = strength / 100.,
|
||||
}
|
||||
self.is_none = false;
|
||||
self.emit_color(responses);
|
||||
self.send_layouts(responses);
|
||||
}
|
||||
ColorPickerMessage::SetAlphaPercent { value } => {
|
||||
let Some(strength) = value else { return };
|
||||
self.alpha = strength / 100.;
|
||||
self.is_none = false;
|
||||
self.emit_color(responses);
|
||||
self.send_layouts(responses);
|
||||
}
|
||||
ColorPickerMessage::SetHexCode { code } => {
|
||||
let Some(color) = parse_css_color(&code) else {
|
||||
// Parse failed: re-send the layouts so the TextInput's displayed value reverts from the user's bad input
|
||||
// back to the current color's hex string. The TextInput dispatch arm has already mutated the stored
|
||||
// widget's `value` to the bad input, so the diff between (stored = bad) and (new = correct) sends an update.
|
||||
self.send_layouts(responses);
|
||||
return;
|
||||
};
|
||||
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
|
||||
self.adopt_color(color);
|
||||
self.emit_color(responses);
|
||||
self.send_layouts(responses);
|
||||
}
|
||||
ColorPickerMessage::PickPreset { preset } => {
|
||||
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
|
||||
match preset {
|
||||
FillChoice::None => {
|
||||
self.set_new_hsva(0., 0., 0., 1., true);
|
||||
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::None });
|
||||
}
|
||||
FillChoice::Solid(color) => {
|
||||
self.adopt_color(color);
|
||||
self.emit_color(responses);
|
||||
}
|
||||
FillChoice::Gradient(_) => {
|
||||
// The presets row only emits solid colors or "None"; the gradient case is unreachable, so safely ignore.
|
||||
}
|
||||
}
|
||||
self.send_layouts(responses);
|
||||
}
|
||||
ColorPickerMessage::EyedropperColorCode { code } => {
|
||||
let Some(color) = parse_css_color(&code) else { return };
|
||||
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
|
||||
self.adopt_color(color);
|
||||
self.emit_color(responses);
|
||||
self.send_layouts(responses);
|
||||
}
|
||||
ColorPickerMessage::SwapNewWithOld => {
|
||||
let temp = (self.hue, self.saturation, self.value, self.alpha, self.is_none);
|
||||
self.set_new_hsva(self.old_hue, self.old_saturation, self.old_value, self.old_alpha, self.old_is_none);
|
||||
self.set_old_hsva(temp.0, temp.1, temp.2, temp.3, temp.4);
|
||||
|
||||
if self.is_none {
|
||||
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::None });
|
||||
} else {
|
||||
self.emit_color(responses);
|
||||
}
|
||||
self.send_layouts(responses);
|
||||
}
|
||||
ColorPickerMessage::GradientUpdate { update } => self.apply_gradient_update(update, responses),
|
||||
ColorPickerMessage::StartTransaction => {
|
||||
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
|
||||
}
|
||||
ColorPickerMessage::CommitTransaction => {
|
||||
responses.add(FrontendMessage::ColorPickerCommitHistoryTransaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn actions(&self) -> ActionList {
|
||||
actions!(ColorPickerMessageDiscriminant;)
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorPickerMessageHandler {
|
||||
fn current_color(&self) -> Option<Color> {
|
||||
if self.is_none {
|
||||
None
|
||||
} else {
|
||||
Some(Color::from_hsva(self.hue as f32, self.saturation as f32, self.value as f32, self.alpha as f32))
|
||||
}
|
||||
}
|
||||
|
||||
fn old_color(&self) -> Option<Color> {
|
||||
if self.old_is_none {
|
||||
None
|
||||
} else {
|
||||
Some(Color::from_hsva(self.old_hue as f32, self.old_saturation as f32, self.old_value as f32, self.old_alpha as f32))
|
||||
}
|
||||
}
|
||||
|
||||
fn set_new_hsva(&mut self, h: f64, s: f64, v: f64, a: f64, is_none: bool) {
|
||||
self.hue = h;
|
||||
self.saturation = s;
|
||||
self.value = v;
|
||||
self.alpha = a;
|
||||
self.is_none = is_none;
|
||||
}
|
||||
|
||||
fn set_old_hsva(&mut self, h: f64, s: f64, v: f64, a: f64, is_none: bool) {
|
||||
self.old_hue = h;
|
||||
self.old_saturation = s;
|
||||
self.old_value = v;
|
||||
self.old_alpha = a;
|
||||
self.old_is_none = is_none;
|
||||
}
|
||||
|
||||
fn snapshot_old(&mut self) {
|
||||
self.old_hue = self.hue;
|
||||
self.old_saturation = self.saturation;
|
||||
self.old_value = self.value;
|
||||
self.old_alpha = self.alpha;
|
||||
self.old_is_none = self.is_none;
|
||||
}
|
||||
|
||||
/// Set HSV state from a Color, preserving hue and saturation in degenerate cases.
|
||||
fn adopt_color(&mut self, color: Color) {
|
||||
let [target_h, target_s, target_v] = rgb_to_hsv(color.r() as f64, color.g() as f64, color.b() as f64);
|
||||
|
||||
// Preserve hue: avoid jumping from 360° (top) to 0° (bottom) and don't reset hue when the color is desaturated or fully dark.
|
||||
if !(target_h == 0. && self.hue == 1.) && target_s > 0. && target_v > 0. {
|
||||
self.hue = target_h;
|
||||
}
|
||||
// Preserve saturation when value is black (saturation is meaningless on the bottom edge of the saturation-value box).
|
||||
if target_v != 0. {
|
||||
self.saturation = target_s;
|
||||
}
|
||||
self.value = target_v;
|
||||
self.alpha = color.a() as f64;
|
||||
self.is_none = false;
|
||||
}
|
||||
|
||||
/// Compute the FillChoice and forward it via `FrontendMessage::ColorPickerColorChanged`. In gradient mode, the active stop's color is updated in place.
|
||||
fn emit_color(&mut self, responses: &mut VecDeque<Message>) {
|
||||
let Some(color) = self.current_color() else { return };
|
||||
|
||||
if let Some(gradient) = &mut self.gradient
|
||||
&& let Some(active_index) = self.active_marker_index
|
||||
&& let Some(stop_color) = gradient.color.get_mut(active_index as usize)
|
||||
{
|
||||
*stop_color = color;
|
||||
let stops = gradient.clone();
|
||||
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Gradient(stops) });
|
||||
} else {
|
||||
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Solid(color) });
|
||||
}
|
||||
}
|
||||
|
||||
fn send_layouts(&self, responses: &mut VecDeque<Message>) {
|
||||
responses.add(LayoutMessage::SendLayout {
|
||||
layout: self.pickers_and_gradient_layout(),
|
||||
layout_target: LayoutTarget::ColorPickerPickersAndGradient,
|
||||
});
|
||||
responses.add(LayoutMessage::SendLayout {
|
||||
layout: self.details_layout(),
|
||||
layout_target: LayoutTarget::ColorPickerDetails,
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply an incoming `SpectrumInput` intent to the gradient state and broadcast the result.
|
||||
fn apply_gradient_update(&mut self, update: SpectrumInputUpdate, responses: &mut VecDeque<Message>) {
|
||||
// Active marker selection is the one update that doesn't mutate the gradient
|
||||
if let SpectrumInputUpdate::ActiveMarker {
|
||||
active_marker_index,
|
||||
active_marker_is_midpoint,
|
||||
} = update
|
||||
{
|
||||
self.active_marker_index = active_marker_index;
|
||||
self.active_marker_is_midpoint = active_marker_is_midpoint;
|
||||
if let Some(index) = active_marker_index
|
||||
&& let Some(gradient) = &self.gradient
|
||||
&& let Some(color) = gradient.color.get(index as usize).copied()
|
||||
{
|
||||
self.adopt_color(color);
|
||||
self.snapshot_old();
|
||||
}
|
||||
self.send_layouts(responses);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(mut gradient) = self.gradient.clone() else { return };
|
||||
|
||||
match update {
|
||||
SpectrumInputUpdate::MoveMarker { index, position } => {
|
||||
let new_index = gradient.move_stop(index as usize, position);
|
||||
if Some(index) == self.active_marker_index {
|
||||
self.active_marker_index = Some(new_index as u32);
|
||||
}
|
||||
}
|
||||
SpectrumInputUpdate::MoveMidpoint { index, position } => {
|
||||
if let Some(midpoint) = gradient.midpoint.get_mut(index as usize) {
|
||||
*midpoint = position.clamp(MIN_MIDPOINT, MAX_MIDPOINT);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
SpectrumInputUpdate::InsertMarker { position } => {
|
||||
let new_index = gradient.insert_stop(position);
|
||||
self.active_marker_index = Some(new_index as u32);
|
||||
self.active_marker_is_midpoint = false;
|
||||
if let Some(color) = gradient.color.get(new_index).copied() {
|
||||
self.adopt_color(color);
|
||||
self.snapshot_old();
|
||||
}
|
||||
}
|
||||
SpectrumInputUpdate::DeleteMarker { index } => {
|
||||
// Enforce minimum stop count. The gradient editor needs at least 2 stops to remain meaningful.
|
||||
if gradient.position.len() <= 2 || (index as usize) >= gradient.position.len() {
|
||||
return;
|
||||
}
|
||||
gradient.remove(index as usize);
|
||||
let new_active = (index as usize).min(gradient.position.len() - 1);
|
||||
self.active_marker_index = Some(new_active as u32);
|
||||
self.active_marker_is_midpoint = false;
|
||||
if let Some(color) = gradient.color.get(new_active).copied() {
|
||||
self.adopt_color(color);
|
||||
self.snapshot_old();
|
||||
}
|
||||
}
|
||||
SpectrumInputUpdate::ResetMidpoint { index } => {
|
||||
gradient.reset_midpoint(index as usize);
|
||||
}
|
||||
SpectrumInputUpdate::ActiveMarker { .. } => unreachable!("handled above"),
|
||||
}
|
||||
|
||||
self.gradient = Some(gradient.clone());
|
||||
responses.add(FrontendMessage::ColorPickerColorChanged {
|
||||
value: FillChoice::Gradient(gradient),
|
||||
});
|
||||
self.send_layouts(responses);
|
||||
}
|
||||
|
||||
fn pickers_and_gradient_layout(&self) -> Layout {
|
||||
let mut groups = Vec::new();
|
||||
|
||||
// Visual H/S/V/A pickers
|
||||
groups.push(LayoutGroup::row(vec![
|
||||
VisualColorPickersInput::default()
|
||||
.hue(self.hue)
|
||||
.saturation(self.saturation)
|
||||
.value(self.value)
|
||||
.alpha(self.alpha)
|
||||
.is_none(self.is_none)
|
||||
.disabled(self.disabled)
|
||||
.on_update(|update: &VisualColorPickersInputUpdate| ColorPickerMessage::VisualUpdate { update: update.clone() }.into())
|
||||
.on_commit(|_| ColorPickerMessage::CommitTransaction.into())
|
||||
.widget_instance(),
|
||||
]));
|
||||
|
||||
// Gradient editor (only present when the picker is in gradient mode)
|
||||
if let Some(gradient) = &self.gradient {
|
||||
// For gradient editing, the markers' handle colors mirror their gradient stop colors
|
||||
let markers = gradient.iter().map(|stop| SpectrumMarker::new(stop.position, stop.midpoint, stop.color)).collect();
|
||||
let mut row_widgets = vec![
|
||||
SpectrumInput::new(gradient.clone())
|
||||
.markers(markers)
|
||||
.active_marker_index(self.active_marker_index)
|
||||
.active_marker_is_midpoint(self.active_marker_is_midpoint)
|
||||
.show_midpoints(true)
|
||||
.allow_insert(!self.disabled)
|
||||
.allow_delete(!self.disabled)
|
||||
.allow_swap(true)
|
||||
.disabled(self.disabled)
|
||||
.on_update(|update: &SpectrumInputUpdate| ColorPickerMessage::GradientUpdate { update: update.clone() }.into())
|
||||
.widget_instance(),
|
||||
];
|
||||
|
||||
if let Some(active) = self.active_marker_index {
|
||||
let active_index = active as usize;
|
||||
let position_value = if self.active_marker_is_midpoint {
|
||||
gradient.midpoint.get(active_index).copied().unwrap_or(0.)
|
||||
} else {
|
||||
gradient.position.get(active_index).copied().unwrap_or(0.)
|
||||
};
|
||||
let is_midpoint = self.active_marker_is_midpoint;
|
||||
let captured_index = active;
|
||||
row_widgets.push(
|
||||
NumberInput::new(Some(position_value * 100.))
|
||||
.disabled(self.disabled)
|
||||
.display_decimal_places(0)
|
||||
.min(if is_midpoint { 1. } else { 0. })
|
||||
.max(if is_midpoint { 99. } else { 100. })
|
||||
.unit("%")
|
||||
.on_update(move |widget: &NumberInput| {
|
||||
let Some(new_value) = widget.value else {
|
||||
return Message::NoOp;
|
||||
};
|
||||
let update = if is_midpoint {
|
||||
SpectrumInputUpdate::MoveMidpoint {
|
||||
index: captured_index,
|
||||
position: new_value / 100.,
|
||||
}
|
||||
} else {
|
||||
SpectrumInputUpdate::MoveMarker {
|
||||
index: captured_index,
|
||||
position: new_value / 100.,
|
||||
}
|
||||
};
|
||||
ColorPickerMessage::GradientUpdate { update }.into()
|
||||
})
|
||||
.widget_instance(),
|
||||
);
|
||||
}
|
||||
|
||||
groups.push(LayoutGroup::row(row_widgets));
|
||||
}
|
||||
|
||||
Layout(groups)
|
||||
}
|
||||
|
||||
fn details_layout(&self) -> Layout {
|
||||
let new_color = self.current_color();
|
||||
let old_color = self.old_color();
|
||||
|
||||
let hex_value = new_color.map(|c| color_to_hex_optional_alpha(&c)).unwrap_or_else(|| "-".to_string());
|
||||
let rgb_255 = new_color.map(|c| (c.r() as f64 * 255., c.g() as f64 * 255., c.b() as f64 * 255.));
|
||||
|
||||
// Epsilon comparison since the picker round-trips through HSV
|
||||
let differs = match (new_color, old_color) {
|
||||
(Some(a), Some(b)) => {
|
||||
const EPSILON: f32 = 1e-6;
|
||||
(a.r() - b.r()).abs() >= EPSILON || (a.g() - b.g()).abs() >= EPSILON || (a.b() - b.b()).abs() >= EPSILON || (a.a() - b.a()).abs() >= EPSILON
|
||||
}
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
};
|
||||
let outline_amount = contrasting_outline_factor(new_color).max(contrasting_outline_factor(old_color));
|
||||
|
||||
let mut groups = Vec::new();
|
||||
|
||||
// New/old comparison swatch with swap button
|
||||
groups.push(LayoutGroup::row(vec![
|
||||
ColorComparisonInput::new(new_color, old_color)
|
||||
.is_none(self.is_none)
|
||||
.old_is_none(self.old_is_none)
|
||||
.disabled(self.disabled)
|
||||
.differs(differs)
|
||||
.outline_amount(outline_amount)
|
||||
.on_update(|_: &()| ColorPickerMessage::SwapNewWithOld.into())
|
||||
.widget_instance(),
|
||||
]));
|
||||
|
||||
// Hex
|
||||
groups.push(LayoutGroup::row(vec![
|
||||
TextLabel::new("Hex").tooltip_label("Hex Color Code").tooltip_description(HEX_DESCRIPTION).widget_instance(),
|
||||
Separator::new(SeparatorStyle::Related).widget_instance(),
|
||||
TextInput::new(hex_value)
|
||||
.centered(true)
|
||||
.disabled(self.disabled)
|
||||
.tooltip_label("Hex Color Code")
|
||||
.tooltip_description(HEX_DESCRIPTION)
|
||||
.on_update(|widget: &TextInput| ColorPickerMessage::SetHexCode { code: widget.value.clone() }.into())
|
||||
.widget_instance(),
|
||||
]));
|
||||
|
||||
// RGB
|
||||
groups.push(LayoutGroup::row(vec![
|
||||
TextLabel::new("RGB").tooltip_label("Red/Green/Blue").tooltip_description("Integers 0–255.").widget_instance(),
|
||||
Separator::new(SeparatorStyle::Related).widget_instance(),
|
||||
rgb_input(RgbChannel::Red, rgb_255.map(|(r, _, _)| r), "Red Channel", self.disabled),
|
||||
Separator::new(SeparatorStyle::Related).widget_instance(),
|
||||
rgb_input(RgbChannel::Green, rgb_255.map(|(_, g, _)| g), "Green Channel", self.disabled),
|
||||
Separator::new(SeparatorStyle::Related).widget_instance(),
|
||||
rgb_input(RgbChannel::Blue, rgb_255.map(|(_, _, b)| b), "Blue Channel", self.disabled),
|
||||
]));
|
||||
|
||||
// HSV
|
||||
groups.push(LayoutGroup::row(vec![
|
||||
TextLabel::new("HSV")
|
||||
.tooltip_label("Hue/Saturation/Value")
|
||||
.tooltip_description("Also known as Hue/Saturation/Brightness (HSB). Not to be confused with Hue/Saturation/Lightness (HSL), a different color model.")
|
||||
.widget_instance(),
|
||||
Separator::new(SeparatorStyle::Related).widget_instance(),
|
||||
hsv_input(
|
||||
HsvChannel::Hue,
|
||||
if self.is_none { None } else { Some(self.hue * 360.) },
|
||||
360.,
|
||||
"°",
|
||||
"Hue Component",
|
||||
HUE_DESCRIPTION,
|
||||
self.disabled,
|
||||
),
|
||||
Separator::new(SeparatorStyle::Related).widget_instance(),
|
||||
hsv_input(
|
||||
HsvChannel::Saturation,
|
||||
if self.is_none { None } else { Some(self.saturation * 100.) },
|
||||
100.,
|
||||
"%",
|
||||
"Saturation Component",
|
||||
SATURATION_DESCRIPTION,
|
||||
self.disabled,
|
||||
),
|
||||
Separator::new(SeparatorStyle::Related).widget_instance(),
|
||||
hsv_input(
|
||||
HsvChannel::Value,
|
||||
if self.is_none { None } else { Some(self.value * 100.) },
|
||||
100.,
|
||||
"%",
|
||||
"Value Component",
|
||||
VALUE_DESCRIPTION,
|
||||
self.disabled,
|
||||
),
|
||||
]));
|
||||
|
||||
// Alpha
|
||||
groups.push(LayoutGroup::row(vec![
|
||||
TextLabel::new("Alpha").tooltip_label("Alpha").tooltip_description(ALPHA_DESCRIPTION).widget_instance(),
|
||||
Separator::new(SeparatorStyle::Related).widget_instance(),
|
||||
NumberInput::new(if self.is_none { None } else { Some(self.alpha * 100.) })
|
||||
.disabled(self.disabled)
|
||||
.min(0.)
|
||||
.max(100.)
|
||||
.mode_range()
|
||||
.unit("%")
|
||||
.display_decimal_places(1)
|
||||
.tooltip_label("Alpha")
|
||||
.tooltip_description(ALPHA_DESCRIPTION)
|
||||
.on_update(|widget: &NumberInput| ColorPickerMessage::SetAlphaPercent { value: widget.value }.into())
|
||||
.on_commit(|_| ColorPickerMessage::StartTransaction.into())
|
||||
.widget_instance(),
|
||||
]));
|
||||
|
||||
// Color presets (None / Black / White / pure colors / eyedropper)
|
||||
groups.push(LayoutGroup::row(vec![
|
||||
ColorPresetsInput::default()
|
||||
.disabled(self.disabled)
|
||||
.show_none_option(self.allow_none && self.gradient.is_none())
|
||||
.on_update(|update: &ColorPresetsInputUpdate| match update {
|
||||
ColorPresetsInputUpdate::Preset(fill_choice) => ColorPickerMessage::PickPreset { preset: fill_choice.clone() }.into(),
|
||||
ColorPresetsInputUpdate::EyedropperColorCode(code) => ColorPickerMessage::EyedropperColorCode { code: code.clone() }.into(),
|
||||
})
|
||||
.widget_instance(),
|
||||
]));
|
||||
|
||||
Layout(groups)
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb_input(channel: RgbChannel, value: Option<f64>, tooltip_label: &'static str, disabled: bool) -> WidgetInstance {
|
||||
NumberInput::new(value)
|
||||
.disabled(disabled)
|
||||
.min(0.)
|
||||
.max(255.)
|
||||
.min_width(1)
|
||||
.display_decimal_places(0)
|
||||
.tooltip_label(tooltip_label)
|
||||
.tooltip_description("Integers 0–255.")
|
||||
.on_update(move |widget: &NumberInput| ColorPickerMessage::SetChannelRgb { channel, value: widget.value }.into())
|
||||
.on_commit(|_| ColorPickerMessage::StartTransaction.into())
|
||||
.widget_instance()
|
||||
}
|
||||
|
||||
fn hsv_input(channel: HsvChannel, value: Option<f64>, max: f64, unit: &'static str, tooltip_label: &'static str, tooltip_description: &'static str, disabled: bool) -> WidgetInstance {
|
||||
NumberInput::new(value)
|
||||
.disabled(disabled)
|
||||
.min(0.)
|
||||
.max(max)
|
||||
.min_width(1)
|
||||
.unit(unit)
|
||||
.display_decimal_places(1)
|
||||
.tooltip_label(tooltip_label)
|
||||
.tooltip_description(tooltip_description)
|
||||
.on_update(move |widget: &NumberInput| ColorPickerMessage::SetChannelHsv { channel, value: widget.value }.into())
|
||||
.on_commit(|_| ColorPickerMessage::StartTransaction.into())
|
||||
.widget_instance()
|
||||
}
|
||||
|
||||
const HEX_DESCRIPTION: &str = "Color code in hexadecimal format. 6 digits if opaque, 8 with alpha. Accepts input of CSS color values including named colors.";
|
||||
const HUE_DESCRIPTION: &str = "The shade along the spectrum of the rainbow.";
|
||||
const SATURATION_DESCRIPTION: &str = "The vividness from grayscale to full color.";
|
||||
const VALUE_DESCRIPTION: &str = "The brightness from black to full color.";
|
||||
const ALPHA_DESCRIPTION: &str = "The level of translucency, from transparent (0%) to opaque (100%).";
|
||||
|
||||
/// Convert an `rgb(0..1)` triple to `hsv(0..1)`. Mirrors the legacy frontend `colorToHSV`.
|
||||
fn rgb_to_hsv(red: f64, green: f64, blue: f64) -> [f64; 3] {
|
||||
let max = red.max(green).max(blue);
|
||||
let min = red.min(green).min(blue);
|
||||
let delta = max - min;
|
||||
|
||||
let mut hue = if delta == 0. {
|
||||
0.
|
||||
} else if max == red {
|
||||
((green - blue) / delta).rem_euclid(6.)
|
||||
} else if max == green {
|
||||
(blue - red) / delta + 2.
|
||||
} else {
|
||||
(red - green) / delta + 4.
|
||||
};
|
||||
hue = (hue * 60. + 360.).rem_euclid(360.) / 360.;
|
||||
|
||||
let saturation = if max == 0. { 0. } else { delta / max };
|
||||
let value = max;
|
||||
|
||||
[hue, saturation, value]
|
||||
}
|
||||
|
||||
/// The popover's background color (the `--color-2-mildblack` design token, `#222`). Used by the comparison swatch's
|
||||
/// outline computation to brighten the inset border for colors close to this background.
|
||||
const POPOVER_BACKGROUND: Color = Color::from_rgbaf32_unchecked(0x22 as f32 / 255., 0x22 as f32 / 255., 0x22 as f32 / 255., 1.);
|
||||
/// The luminance window (in linear-light) within which a color is considered close enough to the popover background
|
||||
/// to warrant an outline. Mirrors the `proximityRange` argument the legacy frontend passed to `contrastingOutlineFactor`.
|
||||
const OUTLINE_PROXIMITY_RANGE: f64 = 0.01;
|
||||
|
||||
/// Returns a 0..1 outline strength for the comparison swatch, growing toward 1 as the color's luminance and saturation
|
||||
/// both approach the popover background's luminance, when a color would otherwise visually blend into the popover.
|
||||
fn contrasting_outline_factor(color: Option<Color>) -> f64 {
|
||||
let Some(color) = color else { return 0. };
|
||||
|
||||
// WCAG-style relative luminance, with alpha composited over white in gamma space
|
||||
let luminance = |color: Color| {
|
||||
// TODO: Remove the `.to_linear_srgb()` once we move to correctly treating `Color` as linear.
|
||||
Color::WHITE
|
||||
.alpha_blend(Color::from_unassociated_alpha(color.r(), color.g(), color.b(), color.a()))
|
||||
.to_linear_srgb()
|
||||
.luminance_srgb() as f64
|
||||
};
|
||||
|
||||
let distance = (luminance(POPOVER_BACKGROUND) - luminance(color)).abs().max(0.);
|
||||
let proximity = 1. - (distance / OUTLINE_PROXIMITY_RANGE).min(1.);
|
||||
let [_, saturation, _] = rgb_to_hsv(color.r() as f64, color.g() as f64, color.b() as f64);
|
||||
proximity * (1. - saturation)
|
||||
}
|
||||
|
||||
/// Format a Color as a `#`-prefixed hex string, including the alpha component only if it's not fully opaque.
|
||||
fn color_to_hex_optional_alpha(color: &Color) -> String {
|
||||
format!(
|
||||
"#{}",
|
||||
if color.a() >= 1. {
|
||||
color.to_rgb_hex_srgb_from_gamma()
|
||||
} else {
|
||||
color.to_rgba_hex_srgb_from_gamma()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse a CSS color string (named color, hex, `rgb(...)`, etc.) into a `Color` using the `color` crate's CSS Color 4 parser.
|
||||
/// Tries the input as-is first (catches CSS named colors like `red`, `rgb(...)`, and well-formed hex like `#abcdef`), then falls back to treating the input as bare hex with length-based expansion to a CSS-parseable form:
|
||||
/// - 1 char `f` → `#fff` (CSS 3-char shorthand)
|
||||
/// - 2 char `ab` → `#ababab` (repeated to 6 chars)
|
||||
/// - 4 char `abcd` → `#00abcd` (left-padded with `00`)
|
||||
/// - 5 char `abcde` → `#0abcde` (left-padded with `0`)
|
||||
/// - 3, 6, 8 char inputs are passed through with a `#` prefix.
|
||||
fn parse_css_color(input: &str) -> Option<Color> {
|
||||
let trimmed = input.trim();
|
||||
|
||||
let parsed = color::parse_color(trimmed).ok().or_else(|| {
|
||||
let bare = trimmed.strip_prefix('#').unwrap_or(trimmed);
|
||||
if bare.is_empty() || !bare.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let expanded = match bare.len() {
|
||||
1 => bare.repeat(3),
|
||||
2 => bare.repeat(3),
|
||||
4 => format!("00{bare}"),
|
||||
5 => format!("0{bare}"),
|
||||
_ => bare.to_string(),
|
||||
};
|
||||
let candidate = format!("#{expanded}");
|
||||
// Avoid retrying the exact same string we just failed to parse.
|
||||
(candidate != trimmed).then(|| color::parse_color(&candidate).ok()).flatten()
|
||||
})?;
|
||||
|
||||
let srgb: AlphaColor<Srgb> = parsed.to_alpha_color();
|
||||
let [red, green, blue, alpha] = srgb.components;
|
||||
Color::from_rgbaf32(red, green, blue, alpha)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
mod color_picker_message;
|
||||
pub mod color_picker_message_handler;
|
||||
|
||||
#[doc(inline)]
|
||||
pub use color_picker_message::{ColorPickerMessage, ColorPickerMessageDiscriminant};
|
||||
#[doc(inline)]
|
||||
pub use color_picker_message_handler::ColorPickerMessageHandler;
|
||||
|
|
@ -16,6 +16,7 @@ use graph_craft::document::NodeId;
|
|||
use graphene_std::raster::Image;
|
||||
use graphene_std::raster::color::Color;
|
||||
use graphene_std::text::{Font, TextAlign};
|
||||
use graphene_std::vector::style::FillChoice;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
|
|
@ -167,6 +168,14 @@ pub enum FrontendMessage {
|
|||
color: Color, // TODO: Color (without `none`) -> Color (with `none`)
|
||||
position: (f64, f64),
|
||||
},
|
||||
/// The Rust color picker handler picked a new color/gradient. The frontend `<ColorPicker>` forwards this as its `colorOrGradient` event.
|
||||
ColorPickerColorChanged {
|
||||
value: FillChoice,
|
||||
},
|
||||
/// The Rust color picker handler is starting an undo transaction. The frontend `<ColorPicker>` forwards this as its `startHistoryTransaction` event.
|
||||
ColorPickerStartHistoryTransaction,
|
||||
/// The Rust color picker handler is committing the in-flight undo transaction. The frontend `<ColorPicker>` forwards this as its `commitHistoryTransaction` event.
|
||||
ColorPickerCommitHistoryTransaction,
|
||||
UpdateImportsExports {
|
||||
/// If the primary import is not visible, then it is None.
|
||||
imports: Vec<Option<FrontendGraphOutput>>,
|
||||
|
|
|
|||
|
|
@ -167,6 +167,14 @@ impl LayoutMessageHandler {
|
|||
};
|
||||
responses.add(callback_message);
|
||||
}
|
||||
Widget::ColorComparisonInput(color_comparison_input) => {
|
||||
let callback_message = match action {
|
||||
WidgetValueAction::Commit => (color_comparison_input.on_commit.callback)(&()),
|
||||
WidgetValueAction::Update => (color_comparison_input.on_update.callback)(&()),
|
||||
};
|
||||
|
||||
responses.add(callback_message);
|
||||
}
|
||||
Widget::ColorInput(color_button) => {
|
||||
let callback_message = match action {
|
||||
WidgetValueAction::Commit => (color_button.on_commit.callback)(&()),
|
||||
|
|
@ -182,6 +190,20 @@ impl LayoutMessageHandler {
|
|||
|
||||
responses.add(callback_message);
|
||||
}
|
||||
Widget::ColorPresetsInput(color_presets_input) => {
|
||||
let callback_message = match action {
|
||||
WidgetValueAction::Commit => (color_presets_input.on_commit.callback)(&()),
|
||||
WidgetValueAction::Update => {
|
||||
let Ok(update) = serde_json::from_value::<ColorPresetsInputUpdate>(value) else {
|
||||
warn!("ColorPresetsInput update was not able to be parsed as ColorPresetsInputUpdate");
|
||||
return;
|
||||
};
|
||||
(color_presets_input.on_update.callback)(&update)
|
||||
}
|
||||
};
|
||||
|
||||
responses.add(callback_message);
|
||||
}
|
||||
Widget::CurveInput(curve_input) => {
|
||||
let callback_message = match action {
|
||||
WidgetValueAction::Commit => (curve_input.on_commit.callback)(&()),
|
||||
|
|
@ -226,6 +248,25 @@ impl LayoutMessageHandler {
|
|||
|
||||
responses.add(callback_message);
|
||||
}
|
||||
Widget::SpectrumInput(spectrum_input) => {
|
||||
let callback_message = match action {
|
||||
WidgetValueAction::Commit => (spectrum_input.on_commit.callback)(&()),
|
||||
WidgetValueAction::Update => {
|
||||
let Ok(update) = serde_json::from_value::<SpectrumInputUpdate>(value) else {
|
||||
warn!("SpectrumInput update was not able to be parsed as SpectrumInputUpdate");
|
||||
return;
|
||||
};
|
||||
// Don't mutate the stored widget here: leaving its old values lets the layout diff detect a change
|
||||
// when the new layout is rebuilt with the updated state. Otherwise the frontend's stored layout
|
||||
// keeps stale values for `activeMarkerIndex`, etc., and any other widget's diff (e.g. the position
|
||||
// NumberInput) will trigger Svelte to re-spread those stale props onto SpectrumInput, clobbering
|
||||
// its local `activeMarkerIndex` and making subsequent drags target the wrong stop.
|
||||
(spectrum_input.on_update.callback)(&update)
|
||||
}
|
||||
};
|
||||
|
||||
responses.add(callback_message);
|
||||
}
|
||||
Widget::IconButton(icon_button) => {
|
||||
let callback_message = match action {
|
||||
WidgetValueAction::Commit => (icon_button.on_commit.callback)(&()),
|
||||
|
|
@ -383,6 +424,23 @@ impl LayoutMessageHandler {
|
|||
responses.add(callback_message);
|
||||
}
|
||||
Widget::TextLabel(_) => {}
|
||||
Widget::VisualColorPickersInput(visual_color_pickers_input) => {
|
||||
let callback_message = match action {
|
||||
WidgetValueAction::Commit => (visual_color_pickers_input.on_commit.callback)(&()),
|
||||
WidgetValueAction::Update => {
|
||||
let Ok(update) = serde_json::from_value::<VisualColorPickersInputUpdate>(value) else {
|
||||
warn!("VisualColorPickersInput update was not able to be parsed as VisualColorPickersInputUpdate");
|
||||
return;
|
||||
};
|
||||
// Don't mutate the stored widget here: leaving its old values lets the layout diff detect a change
|
||||
// when the new layout is rebuilt with the updated state, so the visual indicators (selection circle,
|
||||
// hue/alpha needles, saturation-val ue gradient background) actually re-render after a drag.
|
||||
(visual_color_pickers_input.on_update.callback)(&update)
|
||||
}
|
||||
};
|
||||
|
||||
responses.add(callback_message);
|
||||
}
|
||||
Widget::WorkingColorsInput(_) => {}
|
||||
};
|
||||
}
|
||||
|
|
@ -395,14 +453,17 @@ impl LayoutMessageHandler {
|
|||
responses: &mut VecDeque<Message>,
|
||||
action_input_mapping: &impl Fn(&MessageDiscriminant) -> Option<KeysGroup>,
|
||||
) {
|
||||
// Step 1: Collect CheckboxId mappings from new layout
|
||||
// Collect CheckboxId mappings from new layout
|
||||
let mut checkbox_map = HashMap::new();
|
||||
new_layout.collect_checkbox_ids(layout_target, &mut Vec::new(), &mut checkbox_map);
|
||||
|
||||
// Step 2: Replace all IDs in new layout with deterministic ones
|
||||
// Replace all IDs in new layout with deterministic ones
|
||||
new_layout.replace_widget_ids(layout_target, &mut Vec::new(), &checkbox_map);
|
||||
|
||||
// Step 3: Diff with deterministic IDs
|
||||
// Populate computed display fields on widgets that need derived values
|
||||
populate_computed_display_fields(&mut new_layout);
|
||||
|
||||
// Diff with deterministic IDs
|
||||
let mut widget_diffs = Vec::new();
|
||||
|
||||
self.layouts[layout_target as usize].diff(new_layout, &mut Vec::new(), &mut widget_diffs);
|
||||
|
|
@ -440,3 +501,38 @@ enum WidgetValueAction {
|
|||
Commit,
|
||||
Update,
|
||||
}
|
||||
|
||||
/// Walk all widgets in the layout and populate computed display fields (e.g., precomputed CSS gradient strings) so the frontend can render them without making Wasm round-trip calls. Mutates fields in place.
|
||||
fn populate_computed_display_fields(layout: &mut Layout) {
|
||||
for instance in layout.iter_mut() {
|
||||
match &mut *instance.widget {
|
||||
Widget::ColorInput(color_input) => {
|
||||
color_input.chosen_gradient = color_input.value.to_css_background_image();
|
||||
}
|
||||
Widget::SpectrumInput(spectrum_input) => {
|
||||
spectrum_input.track_css = spectrum_input.track.to_css_linear_gradient();
|
||||
spectrum_input.track_start_css = spectrum_input
|
||||
.track
|
||||
.color
|
||||
.first()
|
||||
.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma()))
|
||||
.unwrap_or_else(|| "black".to_string());
|
||||
spectrum_input.track_end_css = spectrum_input
|
||||
.track
|
||||
.color
|
||||
.last()
|
||||
.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma()))
|
||||
.unwrap_or_else(|| "black".to_string());
|
||||
}
|
||||
Widget::ColorComparisonInput(comparison) => {
|
||||
use graphene_std::Color;
|
||||
let contrasting = |color: Option<Color>| format!("#{}", color.map_or(Color::BLACK, |color| color.contrasting_text_color_from_gamma()).to_rgba_hex_srgb_from_gamma());
|
||||
comparison.new_color_css = comparison.new_color.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma())).unwrap_or_default();
|
||||
comparison.new_color_contrasting = contrasting(comparison.new_color);
|
||||
comparison.old_color_css = comparison.old_color.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma())).unwrap_or_default();
|
||||
comparison.old_color_contrasting = contrasting(comparison.old_color);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ macro_rules! define_layout_target {
|
|||
};
|
||||
}
|
||||
define_layout_target!(
|
||||
/// Left column of the color picker popover, containing the visual H/S/V/A sliders and (optionally) the gradient editor.
|
||||
ColorPickerPickersAndGradient,
|
||||
/// Right column of the color picker popover, containing the new/old color comparison swatch, hex/RGB/HSV/alpha numeric inputs, and color preset buttons.
|
||||
ColorPickerDetails,
|
||||
/// The Data panel visualizes the output data flowing through the selected node in the graph.
|
||||
DataPanel,
|
||||
/// Contains the action buttons at the bottom of the dialog. Must be shown with the `FrontendMessage::DisplayDialog` message.
|
||||
|
|
@ -443,7 +447,11 @@ impl LayoutGroup {
|
|||
| Widget::ShortcutLabel(_)
|
||||
| Widget::WorkingColorsInput(_)
|
||||
| Widget::NodeCatalog(_)
|
||||
| Widget::ParameterExposeButton(_) => continue,
|
||||
| Widget::ParameterExposeButton(_)
|
||||
| Widget::ColorComparisonInput(_)
|
||||
| Widget::ColorPresetsInput(_)
|
||||
| Widget::SpectrumInput(_)
|
||||
| Widget::VisualColorPickersInput(_) => continue,
|
||||
};
|
||||
if val.is_empty() {
|
||||
val.clone_from(&description);
|
||||
|
|
@ -758,7 +766,9 @@ impl<T> Default for WidgetCallback<T> {
|
|||
pub enum Widget {
|
||||
BreadcrumbTrailButtons(BreadcrumbTrailButtons),
|
||||
CheckboxInput(CheckboxInput),
|
||||
ColorComparisonInput(ColorComparisonInput),
|
||||
ColorInput(ColorInput),
|
||||
ColorPresetsInput(ColorPresetsInput),
|
||||
CurveInput(CurveInput),
|
||||
DropdownInput(DropdownInput),
|
||||
IconButton(IconButton),
|
||||
|
|
@ -773,10 +783,12 @@ pub enum Widget {
|
|||
PopoverButton(PopoverButton),
|
||||
RadioInput(RadioInput),
|
||||
Separator(Separator),
|
||||
SpectrumInput(SpectrumInput),
|
||||
TextAreaInput(TextAreaInput),
|
||||
TextButton(TextButton),
|
||||
TextInput(TextInput),
|
||||
TextLabel(TextLabel),
|
||||
VisualColorPickersInput(VisualColorPickersInput),
|
||||
WorkingColorsInput(WorkingColorsInput),
|
||||
}
|
||||
|
||||
|
|
@ -834,7 +846,11 @@ impl DiffUpdate {
|
|||
| Widget::TextAreaInput(_)
|
||||
| Widget::TextInput(_)
|
||||
| Widget::TextLabel(_)
|
||||
| Widget::WorkingColorsInput(_) => None,
|
||||
| Widget::WorkingColorsInput(_)
|
||||
| Widget::ColorComparisonInput(_)
|
||||
| Widget::ColorPresetsInput(_)
|
||||
| Widget::SpectrumInput(_)
|
||||
| Widget::VisualColorPickersInput(_) => None,
|
||||
};
|
||||
|
||||
// Convert `ActionShortcut::Action` to `ActionShortcut::Shortcut`
|
||||
|
|
|
|||
|
|
@ -190,6 +190,11 @@ pub struct ColorInput {
|
|||
/// WARNING: The colors are gamma, not linear!
|
||||
#[widget_builder(constructor)]
|
||||
pub value: FillChoice,
|
||||
/// CSS `linear-gradient(...)` (or solid-color stand-in) for the swatch's `background-image`. Auto-populated from `value` at layout-send time.
|
||||
/// `None` when `value` is `FillChoice::None`, in which case the frontend uses its "none" fallback styling.
|
||||
#[serde(rename = "chosenGradient")]
|
||||
#[widget_builder(skip)]
|
||||
pub chosen_gradient: Option<String>,
|
||||
#[serde(rename = "allowNone")]
|
||||
#[derivative(Default(value = "true"))]
|
||||
pub allow_none: bool,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use derivative::*;
|
|||
use graphene_std::Color;
|
||||
use graphene_std::raster::curve::Curve;
|
||||
use graphene_std::transform::ReferencePoint;
|
||||
use graphene_std::vector::style::{FillChoice, GradientStops};
|
||||
use graphite_proc_macros::WidgetBuilder;
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
|
|
@ -444,6 +445,206 @@ pub struct CurveInput {
|
|||
pub on_commit: WidgetCallback<()>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
|
||||
#[derivative(Debug, PartialEq)]
|
||||
pub struct VisualColorPickersInput {
|
||||
// Content
|
||||
pub hue: f64,
|
||||
pub saturation: f64,
|
||||
pub value: f64,
|
||||
pub alpha: f64,
|
||||
#[serde(rename = "isNone")]
|
||||
pub is_none: bool,
|
||||
pub disabled: bool,
|
||||
|
||||
// Callbacks
|
||||
// `on_update` receives the raw `VisualColorPickersInputUpdate` (not the mutated widget) so the layout diffing can still detect a change between the stored layout and the rebuilt one, otherwise the selection circle and slider needles never re-render after a drag.
|
||||
#[serde(skip)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||
pub on_update: WidgetCallback<VisualColorPickersInputUpdate>,
|
||||
#[serde(skip)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||
pub on_commit: WidgetCallback<()>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct VisualColorPickersInputUpdate {
|
||||
pub hue: f64,
|
||||
pub saturation: f64,
|
||||
pub value: f64,
|
||||
pub alpha: f64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
|
||||
#[derivative(Debug, PartialEq)]
|
||||
pub struct ColorComparisonInput {
|
||||
// Content
|
||||
#[widget_builder(constructor)]
|
||||
#[serde(rename = "newColor")]
|
||||
pub new_color: Option<Color>,
|
||||
#[widget_builder(constructor)]
|
||||
#[serde(rename = "oldColor")]
|
||||
pub old_color: Option<Color>,
|
||||
#[serde(rename = "isNone")]
|
||||
pub is_none: bool,
|
||||
#[serde(rename = "oldIsNone")]
|
||||
pub old_is_none: bool,
|
||||
pub disabled: bool,
|
||||
pub differs: bool,
|
||||
#[serde(rename = "outlineAmount")]
|
||||
pub outline_amount: f64,
|
||||
/// Hex CSS string for the new color (with alpha if not fully opaque), or empty when `is_none`. Auto-populated from `new_color` at layout-send time.
|
||||
#[serde(rename = "newColorCSS")]
|
||||
#[widget_builder(skip)]
|
||||
pub new_color_css: String,
|
||||
/// Black or white, whichever contrasts the new color for legible label text. Auto-populated.
|
||||
#[serde(rename = "newColorContrasting")]
|
||||
#[widget_builder(skip)]
|
||||
pub new_color_contrasting: String,
|
||||
/// Hex CSS string for the old color (with alpha if not fully opaque), or empty when `old_is_none`. Auto-populated.
|
||||
#[serde(rename = "oldColorCSS")]
|
||||
#[widget_builder(skip)]
|
||||
pub old_color_css: String,
|
||||
/// Black or white, whichever contrasts the old color for legible label text. Auto-populated.
|
||||
#[serde(rename = "oldColorContrasting")]
|
||||
#[widget_builder(skip)]
|
||||
pub old_color_contrasting: String,
|
||||
|
||||
// Callbacks
|
||||
// The `swap` event has no payload — it just signals the user requested a swap.
|
||||
#[serde(skip)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||
pub on_update: WidgetCallback<()>,
|
||||
#[serde(skip)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||
pub on_commit: WidgetCallback<()>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
|
||||
#[derivative(Debug, PartialEq)]
|
||||
pub struct ColorPresetsInput {
|
||||
// Content
|
||||
pub disabled: bool,
|
||||
#[serde(rename = "showNoneOption")]
|
||||
pub show_none_option: bool,
|
||||
|
||||
// Callbacks
|
||||
#[serde(skip)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||
pub on_update: WidgetCallback<ColorPresetsInputUpdate>,
|
||||
#[serde(skip)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||
pub on_commit: WidgetCallback<()>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ColorPresetsInputUpdate {
|
||||
Preset(FillChoice),
|
||||
EyedropperColorCode(String),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Clone, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
|
||||
#[derivative(Debug, PartialEq, Default)]
|
||||
pub struct SpectrumInput {
|
||||
// Content
|
||||
/// The colored gradient drawn behind the markers (display-only, caller-owned).
|
||||
#[widget_builder(constructor)]
|
||||
pub track: GradientStops,
|
||||
/// CSS `linear-gradient(...)` string for the track strip's `background-image`. Auto-populated from `track` at layout-send time.
|
||||
#[serde(rename = "trackCSS")]
|
||||
#[widget_builder(skip)]
|
||||
pub track_css: String,
|
||||
/// Hex string for the track strip's leftmost solid-color end-cap. Auto-populated from `track`'s first stop.
|
||||
#[serde(rename = "trackStartCSS")]
|
||||
#[widget_builder(skip)]
|
||||
pub track_start_css: String,
|
||||
/// Hex string for the track strip's rightmost solid-color end-cap. Auto-populated from `track`'s last stop.
|
||||
#[serde(rename = "trackEndCSS")]
|
||||
#[widget_builder(skip)]
|
||||
pub track_end_css: String,
|
||||
/// The handles the user can drag along the track. Their handle colors are caller-owned (e.g., for a gradient editor they follow the stop colors, for a "Shadows/Midpoints/Highlights" widget they're hardcoded).
|
||||
pub markers: Vec<SpectrumMarker>,
|
||||
#[serde(rename = "activeMarkerIndex")]
|
||||
pub active_marker_index: Option<u32>,
|
||||
#[serde(rename = "activeMarkerIsMidpoint")]
|
||||
pub active_marker_is_midpoint: bool,
|
||||
/// Whether to render midpoint diamonds between adjacent markers (only meaningful for gradient-like uses).
|
||||
#[serde(rename = "showMidpoints")]
|
||||
pub show_midpoints: bool,
|
||||
/// Whether clicking the track inserts a new marker at the click position.
|
||||
#[serde(rename = "allowInsert")]
|
||||
pub allow_insert: bool,
|
||||
/// Whether right-click or pressing Delete removes a marker. The handler still has the final say on whether the deletion goes through (e.g., enforcing a minimum count).
|
||||
#[serde(rename = "allowDelete")]
|
||||
pub allow_delete: bool,
|
||||
/// Whether dragging a marker past another reorders them. If false, the dragged marker is clamped between its neighbors.
|
||||
#[serde(rename = "allowSwap")]
|
||||
pub allow_swap: bool,
|
||||
/// Whether the input is disabled (dimmed and read-only).
|
||||
pub disabled: bool,
|
||||
|
||||
// Callbacks
|
||||
#[serde(skip)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||
pub on_update: WidgetCallback<SpectrumInputUpdate>,
|
||||
#[serde(skip)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore")]
|
||||
pub on_commit: WidgetCallback<()>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SpectrumMarker {
|
||||
/// Position (0..1) of the marker along the spectrum track.
|
||||
position: f64,
|
||||
/// Position (0..1) of the midpoint between this marker and the next, used only if `show_midpoints` is true. The last marker's value is ignored.
|
||||
midpoint: f64,
|
||||
/// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a [`Color`] (gamma space).
|
||||
#[serde(rename = "handleColorCSS")]
|
||||
handle_color_css: String,
|
||||
}
|
||||
|
||||
impl SpectrumMarker {
|
||||
pub fn new(position: f64, midpoint: f64, handle_color: Color) -> Self {
|
||||
let handle_color_css = format!("#{}", handle_color.to_rgba_hex_srgb_from_gamma());
|
||||
Self { position, midpoint, handle_color_css }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum SpectrumInputUpdate {
|
||||
MoveMarker {
|
||||
index: u32,
|
||||
position: f64,
|
||||
},
|
||||
MoveMidpoint {
|
||||
index: u32,
|
||||
position: f64,
|
||||
},
|
||||
InsertMarker {
|
||||
position: f64,
|
||||
},
|
||||
DeleteMarker {
|
||||
index: u32,
|
||||
},
|
||||
ResetMidpoint {
|
||||
index: u32,
|
||||
},
|
||||
ActiveMarker {
|
||||
#[serde(rename = "activeMarkerIndex")]
|
||||
active_marker_index: Option<u32>,
|
||||
#[serde(rename = "activeMarkerIsMidpoint")]
|
||||
active_marker_is_midpoint: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
|
||||
#[derivative(Debug, PartialEq)]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ pub enum Message {
|
|||
#[child]
|
||||
Clipboard(ClipboardMessage),
|
||||
#[child]
|
||||
ColorPicker(ColorPickerMessage),
|
||||
#[child]
|
||||
Debug(DebugMessage),
|
||||
#[child]
|
||||
Defer(DeferMessage),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ pub mod animation;
|
|||
pub mod app_window;
|
||||
pub mod broadcast;
|
||||
pub mod clipboard;
|
||||
pub mod color_picker;
|
||||
pub mod debug;
|
||||
pub mod defer;
|
||||
pub mod dialog;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub use crate::messages::app_window::{AppWindowMessage, AppWindowMessageDiscrimi
|
|||
pub use crate::messages::broadcast::event::{EventMessage, EventMessageContext, EventMessageDiscriminant, EventMessageHandler};
|
||||
pub use crate::messages::broadcast::{BroadcastMessage, BroadcastMessageDiscriminant, BroadcastMessageHandler};
|
||||
pub use crate::messages::clipboard::{ClipboardMessage, ClipboardMessageDiscriminant, ClipboardMessageHandler};
|
||||
pub use crate::messages::color_picker::{ColorPickerMessage, ColorPickerMessageDiscriminant, ColorPickerMessageHandler};
|
||||
pub use crate::messages::debug::{DebugMessage, DebugMessageDiscriminant, DebugMessageHandler};
|
||||
pub use crate::messages::defer::{DeferMessage, DeferMessageDiscriminant, DeferMessageHandler};
|
||||
pub use crate::messages::dialog::export_dialog::{ExportDialogMessage, ExportDialogMessageContext, ExportDialogMessageDiscriminant, ExportDialogMessageHandler};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import { createPanicManager, destroyPanicManager } from "/src/managers/panic";
|
||||
import { createPersistenceManager, destroyPersistenceManager } from "/src/managers/persistence";
|
||||
import { createAppWindowStore, destroyAppWindowStore } from "/src/stores/app-window";
|
||||
import { createColorPickerStore, destroyColorPickerStore } from "/src/stores/color-picker";
|
||||
import { createDialogStore, destroyDialogStore } from "/src/stores/dialog";
|
||||
import { createDocumentStore, destroyDocumentStore } from "/src/stores/document";
|
||||
import { createFullscreenStore, destroyFullscreenStore } from "/src/stores/fullscreen";
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
nodeGraph: createNodeGraphStore(subscriptions),
|
||||
portfolio: createPortfolioStore(subscriptions, editor),
|
||||
appWindow: createAppWindowStore(subscriptions),
|
||||
colorPicker: createColorPickerStore(subscriptions),
|
||||
};
|
||||
Object.entries(stores).forEach(([key, store]) => setContext(key, store));
|
||||
|
||||
|
|
@ -61,6 +63,7 @@
|
|||
destroyNodeGraphStore();
|
||||
destroyPortfolioStore();
|
||||
destroyAppWindowStore();
|
||||
destroyColorPickerStore();
|
||||
|
||||
// Managers
|
||||
destroyClipboardManager();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -141,7 +141,7 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
.body {
|
||||
> .body {
|
||||
padding: 0 7px;
|
||||
padding-top: 1px;
|
||||
margin-top: -1px;
|
||||
|
|
@ -150,7 +150,7 @@
|
|||
border-radius: 0 0 4px 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.widget-span.row {
|
||||
> .widget-span.row {
|
||||
&:first-child {
|
||||
margin-top: calc(4px - 1px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,20 +8,25 @@
|
|||
import PopoverButton from "/src/components/widgets/buttons/PopoverButton.svelte";
|
||||
import TextButton from "/src/components/widgets/buttons/TextButton.svelte";
|
||||
import CheckboxInput from "/src/components/widgets/inputs/CheckboxInput.svelte";
|
||||
import ColorComparisonInput from "/src/components/widgets/inputs/ColorComparisonInput.svelte";
|
||||
import ColorInput from "/src/components/widgets/inputs/ColorInput.svelte";
|
||||
import ColorPresetsInput from "/src/components/widgets/inputs/ColorPresetsInput.svelte";
|
||||
import CurveInput from "/src/components/widgets/inputs/CurveInput.svelte";
|
||||
import DropdownInput from "/src/components/widgets/inputs/DropdownInput.svelte";
|
||||
import NumberInput from "/src/components/widgets/inputs/NumberInput.svelte";
|
||||
import RadioInput from "/src/components/widgets/inputs/RadioInput.svelte";
|
||||
import ReferencePointInput from "/src/components/widgets/inputs/ReferencePointInput.svelte";
|
||||
import SpectrumInput from "/src/components/widgets/inputs/SpectrumInput.svelte";
|
||||
import TextAreaInput from "/src/components/widgets/inputs/TextAreaInput.svelte";
|
||||
import TextInput from "/src/components/widgets/inputs/TextInput.svelte";
|
||||
import VisualColorPickersInput from "/src/components/widgets/inputs/VisualColorPickersInput.svelte";
|
||||
import WorkingColorsInput from "/src/components/widgets/inputs/WorkingColorsInput.svelte";
|
||||
import IconLabel from "/src/components/widgets/labels/IconLabel.svelte";
|
||||
import ImageLabel from "/src/components/widgets/labels/ImageLabel.svelte";
|
||||
import Separator from "/src/components/widgets/labels/Separator.svelte";
|
||||
import ShortcutLabel from "/src/components/widgets/labels/ShortcutLabel.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import type { ColorPickerStore } from "/src/stores/color-picker";
|
||||
import { parseFillChoice } from "/src/utility-functions/colors";
|
||||
import type { EditorWrapper, LayoutTarget, Widget, WidgetInstance } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
|
|
@ -33,6 +38,7 @@
|
|||
type UnwrappedWidget = { [K in WidgetKind]: [kind: K, props: WidgetProps<K>] }[WidgetKind];
|
||||
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
const colorPickerStore = getContext<ColorPickerStore>("colorPicker");
|
||||
|
||||
export let widgets: WidgetInstance[];
|
||||
export let direction: "row" | "column";
|
||||
|
|
@ -116,6 +122,15 @@
|
|||
$$events: { checked: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) },
|
||||
}),
|
||||
},
|
||||
ColorComparisonInput: {
|
||||
component: ColorComparisonInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: {
|
||||
swap: () => widgetValueCommitAndUpdate(index, undefined, true),
|
||||
},
|
||||
}),
|
||||
},
|
||||
ColorInput: {
|
||||
component: ColorInput,
|
||||
getProps: (props, index) => ({
|
||||
|
|
@ -127,6 +142,17 @@
|
|||
},
|
||||
}),
|
||||
},
|
||||
ColorPresetsInput: {
|
||||
component: ColorPresetsInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: {
|
||||
// The widget dispatches `"None"` or a bare `Color`, wrap the color in `{ Solid: ... }` so the payload matches Rust's `FillChoice` shape (which the `Preset` variant expects).
|
||||
preset: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { Preset: e.detail === "None" ? "None" : { Solid: e.detail } }, true),
|
||||
eyedropperColorCode: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { EyedropperColorCode: e.detail }, true),
|
||||
},
|
||||
}),
|
||||
},
|
||||
CurveInput: {
|
||||
// TODO: CurvesInput is currently unused
|
||||
component: CurveInput,
|
||||
|
|
@ -210,6 +236,28 @@
|
|||
$$events: { value: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) },
|
||||
}),
|
||||
},
|
||||
SpectrumInput: {
|
||||
component: SpectrumInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: {
|
||||
update: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
|
||||
dragging: (e: CustomEvent<boolean>) => colorPickerStore.setDragging(e.detail),
|
||||
},
|
||||
}),
|
||||
},
|
||||
VisualColorPickersInput: {
|
||||
component: VisualColorPickersInput,
|
||||
getProps: (props, index) => ({
|
||||
...props,
|
||||
$$events: {
|
||||
update: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
|
||||
startHistoryTransaction: () => widgetValueCommit(index, undefined),
|
||||
commitHistoryTransaction: () => widgetValueCommit(index, undefined),
|
||||
dragStateChange: (e: CustomEvent<boolean>) => colorPickerStore.setDragging(e.detail),
|
||||
},
|
||||
}),
|
||||
},
|
||||
PopoverButton: {
|
||||
component: PopoverButton,
|
||||
getProps: (props) => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
||||
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
|
||||
import type { Color } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const dispatch = createEventDispatcher<{ swap: undefined }>();
|
||||
|
||||
export let newColor: Color | undefined;
|
||||
export let oldColor: Color | undefined;
|
||||
export let newColorCSS: string;
|
||||
export let newColorContrasting: string;
|
||||
export let oldColorCSS: string;
|
||||
export let oldColorContrasting: string;
|
||||
export let isNone: boolean;
|
||||
export let oldIsNone: boolean;
|
||||
export let disabled = false;
|
||||
export let differs: boolean;
|
||||
export let outlineAmount: number;
|
||||
|
||||
$: outlined = outlineAmount > 0.0001;
|
||||
$: transparency = (newColor?.alpha ?? 1) < 1 || (oldColor?.alpha ?? 1) < 1;
|
||||
</script>
|
||||
|
||||
<LayoutRow
|
||||
class="color-comparison-input"
|
||||
classes={{ outlined, transparency, disabled }}
|
||||
styles={{
|
||||
"--outline-amount": outlineAmount,
|
||||
"--new-color": newColorCSS || undefined,
|
||||
"--new-color-contrasting": newColorContrasting,
|
||||
"--old-color": oldColorCSS || undefined,
|
||||
"--old-color-contrasting": oldColorContrasting,
|
||||
}}
|
||||
tooltipDescription={differs ? "Comparison between the present color choice (left) and the color before it was changed (right)." : "The present color choice."}
|
||||
>
|
||||
{#if differs && !disabled}
|
||||
<div class="swap-button-background"></div>
|
||||
<IconButton class="swap-button" icon="SwapHorizontal" size={16} action={() => dispatch("swap")} tooltipLabel="Swap" />
|
||||
{/if}
|
||||
<LayoutCol class="new-color" classes={{ none: isNone }}>
|
||||
{#if differs}
|
||||
<TextLabel>New</TextLabel>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
{#if differs}
|
||||
<LayoutCol class="old-color" classes={{ none: oldIsNone }}>
|
||||
<TextLabel>Old</TextLabel>
|
||||
</LayoutCol>
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.color-comparison-input {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&.outlined::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--color-0-black-rgb), var(--outline-amount));
|
||||
}
|
||||
|
||||
&.transparency {
|
||||
background-image: var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
background-repeat: var(--color-transparent-checkered-background-repeat);
|
||||
}
|
||||
|
||||
.swap-button-background {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0.25;
|
||||
border-radius: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
background: var(--new-color-contrasting);
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
background: var(--old-color-contrasting);
|
||||
}
|
||||
}
|
||||
|
||||
.swap-button {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.new-color {
|
||||
background: var(--new-color);
|
||||
|
||||
.text-label {
|
||||
text-align: left;
|
||||
margin: 2px 8px;
|
||||
color: var(--new-color-contrasting);
|
||||
}
|
||||
}
|
||||
|
||||
.old-color {
|
||||
background: var(--old-color);
|
||||
|
||||
.text-label {
|
||||
text-align: right;
|
||||
margin: 2px 8px;
|
||||
color: var(--old-color-contrasting);
|
||||
}
|
||||
}
|
||||
|
||||
.new-color,
|
||||
.old-color {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
transition: opacity 0.1s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,13 +2,14 @@
|
|||
import { createEventDispatcher } from "svelte";
|
||||
import ColorPicker from "/src/components/floating-menus/ColorPicker.svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import { contrastingOutlineFactor, fillChoiceColor, fillChoiceGradientStops, colorToHexOptionalAlpha, gradientToLinearGradientCSS } from "/src/utility-functions/colors";
|
||||
import { contrastingOutlineFactor, fillChoiceColor, fillChoiceGradientStops } from "/src/utility-functions/colors";
|
||||
import type { FillChoice, MenuDirection, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const dispatch = createEventDispatcher<{ value: FillChoice; startHistoryTransaction: undefined }>();
|
||||
|
||||
// Content
|
||||
export let value: FillChoice;
|
||||
export let chosenGradient: string | undefined = undefined;
|
||||
export let allowNone = false;
|
||||
// export let allowTransparency = false; // TODO: Implement
|
||||
export let menuDirection: MenuDirection = "Bottom";
|
||||
|
|
@ -26,11 +27,6 @@
|
|||
$: outlined = outlineFactor > 0.0001;
|
||||
$: gradientStops = fillChoiceGradientStops(value);
|
||||
$: solidColor = fillChoiceColor(value);
|
||||
$: chosenGradient = gradientStops
|
||||
? gradientToLinearGradientCSS(gradientStops)
|
||||
: solidColor
|
||||
? `linear-gradient(${colorToHexOptionalAlpha(solidColor)}, ${colorToHexOptionalAlpha(solidColor)})`
|
||||
: undefined;
|
||||
$: none = value === "None";
|
||||
$: transparency = gradientStops ? gradientStops.color.some((color) => color.alpha < 1) : solidColor ? solidColor.alpha < 1 : false;
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
|
||||
import Separator from "/src/components/widgets/labels/Separator.svelte";
|
||||
import { createColor } from "/src/utility-functions/colors";
|
||||
import type { Color } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
type PresetColor = "Black" | "White" | "Red" | "Yellow" | "Green" | "Cyan" | "Blue" | "Magenta";
|
||||
|
||||
const PURE_COLORS: Record<PresetColor, [number, number, number]> = {
|
||||
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 PURE_COLORS_GRAYABLE: [PresetColor, string, string][] = [
|
||||
["Red", "#ff0000", "#4c4c4c"],
|
||||
["Yellow", "#ffff00", "#e3e3e3"],
|
||||
["Green", "#00ff00", "#969696"],
|
||||
["Cyan", "#00ffff", "#b2b2b2"],
|
||||
["Blue", "#0000ff", "#1c1c1c"],
|
||||
["Magenta", "#ff00ff", "#696969"],
|
||||
];
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
preset: Color | "None";
|
||||
eyedropperColorCode: string;
|
||||
}>();
|
||||
|
||||
export let disabled = false;
|
||||
export let showNoneOption = false;
|
||||
|
||||
function pickPreset(preset: PresetColor | "None") {
|
||||
if (disabled) return;
|
||||
dispatch("preset", preset === "None" ? "None" : createColor(...PURE_COLORS[preset], 1));
|
||||
}
|
||||
|
||||
// TODO: Replace this temporary usage of the browser eyedropper API, that only works in Chromium-based browsers, with the custom color sampler system used by the Eyedropper tool
|
||||
function eyedropperSupported(): boolean {
|
||||
// TODO: Implement support in the desktop app for OS-level color picking
|
||||
if (import.meta.env.MODE === "native") return false;
|
||||
|
||||
return window.EyeDropper !== undefined;
|
||||
}
|
||||
|
||||
async function activateEyedropperSample() {
|
||||
if (!eyedropperSupported()) return;
|
||||
|
||||
try {
|
||||
const result = await new EyeDropper().open();
|
||||
dispatch("eyedropperColorCode", result.sRGBHex);
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutRow class="color-presets-input" classes={{ disabled }}>
|
||||
{#if showNoneOption}
|
||||
<button
|
||||
class="preset-color none"
|
||||
{disabled}
|
||||
on:click={() => pickPreset("None")}
|
||||
data-tooltip-label="Set to No Color"
|
||||
data-tooltip-description={disabled ? "Disabled (read-only)." : ""}
|
||||
tabindex="0"
|
||||
></button>
|
||||
<Separator style="Related" />
|
||||
{/if}
|
||||
<button class="preset-color black" {disabled} on:click={() => pickPreset("Black")} data-tooltip-label="Set to Black" data-tooltip-description={disabled ? "Disabled (read-only)." : ""} tabindex="0"
|
||||
></button>
|
||||
<Separator style="Related" />
|
||||
<button class="preset-color white" {disabled} on:click={() => pickPreset("White")} data-tooltip-label="Set to White" data-tooltip-description={disabled ? "Disabled (read-only)." : ""} tabindex="0"
|
||||
></button>
|
||||
<Separator style="Related" />
|
||||
<button class="preset-color pure" {disabled} tabindex="-1">
|
||||
{#each PURE_COLORS_GRAYABLE as [preset, color, gray]}
|
||||
<div
|
||||
on:click={() => pickPreset(preset)}
|
||||
style:--pure-color={color}
|
||||
style:--pure-color-gray={gray}
|
||||
data-tooltip-label={`Set to ${preset}`}
|
||||
data-tooltip-description={disabled ? "Disabled (read-only)." : ""}
|
||||
></div>
|
||||
{/each}
|
||||
</button>
|
||||
{#if eyedropperSupported()}
|
||||
<Separator style="Related" />
|
||||
<IconButton icon="Eyedropper" size={24} {disabled} action={activateEyedropperSample} tooltipLabel="Eyedropper" tooltipDescription="Sample a pixel color from the document." />
|
||||
{/if}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.color-presets-input {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
|
||||
.preset-color {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
height: 24px;
|
||||
flex: 1 1 100%;
|
||||
|
||||
&.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;
|
||||
flex: 0 0 auto;
|
||||
|
||||
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);
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
&:hover div {
|
||||
background: var(--pure-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
.preset-color {
|
||||
transition: opacity 0.1s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.preset-color.pure:hover div {
|
||||
background: var(--pure-color-gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,323 +1,212 @@
|
|||
<script lang="ts" context="module">
|
||||
export const MIN_MIDPOINT = 0.01;
|
||||
export const MAX_MIDPOINT = 0.99;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount, onDestroy } from "svelte";
|
||||
import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import { colorToHexOptionalAlpha, colorToRgbCSS, gradientFirstColor, gradientLastColor, gradientToLinearGradientCSS } from "/src/utility-functions/colors";
|
||||
import { evaluateGradientAtPosition } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
import type { Color, GradientStops } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
import type { SpectrumInputUpdate, SpectrumMarker } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const BUTTON_LEFT = 0;
|
||||
const BUTTON_RIGHT = 2;
|
||||
|
||||
const dispatch = createEventDispatcher<{ activeMarkerIndexChange: { activeMarkerIndex: number | undefined; activeMarkerIsMidpoint: boolean }; gradient: GradientStops; dragging: boolean }>();
|
||||
const dispatch = createEventDispatcher<{ update: SpectrumInputUpdate; dragging: boolean }>();
|
||||
|
||||
export let gradient: GradientStops;
|
||||
export let disabled = false;
|
||||
export let trackCSS: string;
|
||||
export let trackStartCSS: string;
|
||||
export let trackEndCSS: string;
|
||||
export let markers: SpectrumMarker[];
|
||||
export let activeMarkerIndex: number | undefined = 0;
|
||||
export let activeMarkerIsMidpoint = false;
|
||||
// export let disabled = false;
|
||||
// export let tooltipLabel: string | undefined = undefined;
|
||||
// export let tooltipDescription: string | undefined = undefined;
|
||||
// export let tooltipShortcut: ActionShortcut | undefined = undefined;
|
||||
export let showMidpoints = true;
|
||||
export let allowInsert = true;
|
||||
export let allowDelete = true;
|
||||
export let allowSwap = true;
|
||||
export let disabled = false;
|
||||
|
||||
/// Reference to the marker track element so we can access its div.
|
||||
let markerTrack: LayoutRow | undefined = undefined;
|
||||
/// When dragging, stores the original value of the marker or midpoint being dragged, so we can restore it if the drag is cancelled.
|
||||
let dragRestore: number | undefined = undefined;
|
||||
/// When dragging, indicates whether this maker was inserted during the drag, so we know whether to remove it again if the drag is cancelled.
|
||||
let deletionRestore: boolean | undefined = undefined;
|
||||
/// When dragging, stores the previous active marker (or its midpoint) index, so we can restore active selection to that one if the drag is cancelled on a different marker.
|
||||
// Reference to the marker track DOM element so we can convert pointer coordinates to a 0..1 position along the track.
|
||||
let markerTrackElement: LayoutRow | undefined = undefined;
|
||||
|
||||
// Drag state — only TS-local; Rust owns authoritative marker data.
|
||||
// Position the dragged marker (or midpoint) had at drag start, restored if the drag is cancelled.
|
||||
let dragRestorePosition: number | undefined = undefined;
|
||||
// True if this drag began with an insertion (so cancel must delete the inserted marker).
|
||||
let dragInsertedMarker = false;
|
||||
// Active marker selection at drag start, restored if the drag is cancelled.
|
||||
let activeMarkerIndexRestore: number | undefined = undefined;
|
||||
/// When dragging, stores whether the previously active drag item was a midpoint (matching the index kept in `activeMarkerIndexRestore`), so we can restore its active selection if cancelled.
|
||||
let activeMarkerIsMidpointRestore = false;
|
||||
/// When dragging a midpoint, tracks whether the midpoint has actually moved by at least a pixel, to tell between a click-then-click-and-drag (just a drag) or a double-click (reset the midpoint).
|
||||
// Tracks whether a midpoint drag has actually moved by at least one frame, to distinguish click-to-select from drag.
|
||||
let midpointDragged = false;
|
||||
|
||||
function emit(intent: SpectrumInputUpdate) {
|
||||
dispatch("update", intent);
|
||||
}
|
||||
|
||||
function setActive(index: number | undefined, isMidpoint: boolean) {
|
||||
activeMarkerIndex = index;
|
||||
activeMarkerIsMidpoint = isMidpoint;
|
||||
emit({ ActiveMarker: { activeMarkerIndex: index, activeMarkerIsMidpoint: isMidpoint } });
|
||||
}
|
||||
|
||||
function pointerPosition(e: MouseEvent): number | undefined {
|
||||
const rect = markerTrackElement?.div()?.getBoundingClientRect();
|
||||
if (!rect) return undefined;
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
return Math.max(0, Math.min(1, ratio));
|
||||
}
|
||||
|
||||
function clampToNeighbors(index: number, position: number): number {
|
||||
const lower = markers[index - 1]?.position ?? 0;
|
||||
const upper = markers[index + 1]?.position ?? 1;
|
||||
return Math.max(lower, Math.min(upper, position));
|
||||
}
|
||||
|
||||
function markerPointerDown(e: PointerEvent, index: number) {
|
||||
if (disabled) return;
|
||||
|
||||
// Left-click to select and begin potentially dragging
|
||||
if (e.button === BUTTON_LEFT) {
|
||||
// Set restore values at this time, so later the user can cancel the drag and restore to these values
|
||||
activeMarkerIndexRestore = activeMarkerIndex;
|
||||
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
|
||||
|
||||
// Update the parent component with the newly activated marker or midpoint drag item
|
||||
activeMarkerIndex = index;
|
||||
activeMarkerIsMidpoint = false;
|
||||
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
|
||||
|
||||
dragRestorePosition = markers[index].position;
|
||||
dragInsertedMarker = false;
|
||||
setActive(index, false);
|
||||
addEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
// Right-click to delete
|
||||
if (e.button === BUTTON_RIGHT && deletionRestore === undefined) {
|
||||
deleteStopByIndex(index);
|
||||
return;
|
||||
if (e.button === BUTTON_RIGHT && allowDelete) {
|
||||
emit({ DeleteMarker: { index } });
|
||||
}
|
||||
}
|
||||
|
||||
function markerPosition(e: MouseEvent): number | undefined {
|
||||
const markerTrackRect = markerTrack?.div()?.getBoundingClientRect();
|
||||
if (!markerTrackRect) return;
|
||||
|
||||
const ratio = (e.clientX - markerTrackRect.left) / markerTrackRect.width;
|
||||
|
||||
return Math.max(0, Math.min(1, ratio));
|
||||
}
|
||||
|
||||
function midpointPointerDown(e: PointerEvent, index: number) {
|
||||
if (disabled) return;
|
||||
if (e.button !== BUTTON_LEFT) return;
|
||||
|
||||
// Since we just pressed the mouse button down, the midpoint has not been dragged by any distance
|
||||
midpointDragged = false;
|
||||
|
||||
// Set restore values at this time, so later the user can cancel the drag and restore to these values
|
||||
activeMarkerIndexRestore = activeMarkerIndex;
|
||||
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
|
||||
|
||||
// Update the parent component with the newly activated marker or midpoint drag item
|
||||
activeMarkerIndex = index;
|
||||
activeMarkerIsMidpoint = true;
|
||||
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
|
||||
|
||||
dragRestorePosition = markers[index].midpoint;
|
||||
setActive(index, true);
|
||||
addEvents();
|
||||
}
|
||||
|
||||
function resetMidpoint(index: number) {
|
||||
function midpointDoubleClick(index: number) {
|
||||
if (disabled || midpointDragged) return;
|
||||
|
||||
gradient.midpoint[index] = 0.5;
|
||||
dispatch("gradient", gradient);
|
||||
emit({ ResetMidpoint: { index } });
|
||||
}
|
||||
|
||||
function insertStop(e: MouseEvent) {
|
||||
function trackPointerDown(e: PointerEvent) {
|
||||
if (disabled) return;
|
||||
if (e.button !== BUTTON_LEFT) return;
|
||||
if (!allowInsert) return;
|
||||
|
||||
// Determine the position along the gradient (0-1) based on the click position in the marker track
|
||||
let position = markerPosition(e);
|
||||
const position = pointerPosition(e);
|
||||
if (position === undefined) return;
|
||||
|
||||
// Determine which index the new stop should be inserted at based on its position
|
||||
let index = gradient.position.findIndex((item) => item > position);
|
||||
if (index === -1) index = gradient.position.length;
|
||||
// Compute the index this marker will land at after Rust inserts it (matches Rust's `insert_stop` logic).
|
||||
let insertIndex = markers.findIndex((m) => m.position > position);
|
||||
if (insertIndex === -1) insertIndex = markers.length;
|
||||
|
||||
// Determine the color of the new stop by evaluating the gradient at the position of the new stop
|
||||
const color: Color = evaluateGradientAtPosition(position, new Float64Array(gradient.position), new Float64Array(gradient.midpoint), gradient.color);
|
||||
emit({ InsertMarker: { position } });
|
||||
|
||||
// Insert the new stop into the gradient
|
||||
gradient.position.splice(index, 0, position);
|
||||
// Duplicate the midpoint ratio position of the interval we're inserting into, so both new intervals have the same midpoint position ratio
|
||||
gradient.midpoint.splice(index, 0, gradient.midpoint[index - 1] || 0.5);
|
||||
gradient.color.splice(index, 0, color);
|
||||
dispatch("gradient", gradient);
|
||||
|
||||
// Set restore values at this time, so later the user can cancel the drag and restore to these values
|
||||
activeMarkerIndexRestore = activeMarkerIndex;
|
||||
activeMarkerIsMidpointRestore = activeMarkerIsMidpoint;
|
||||
|
||||
// Update the parent component with the newly activated marker or midpoint drag item
|
||||
activeMarkerIndex = index;
|
||||
dragRestorePosition = position;
|
||||
dragInsertedMarker = true;
|
||||
// Don't dispatch an `ActiveMarker` here — the Rust handler already updates the active marker in response to `InsertMarker` and a duplicate `ActiveMarker` would race the layout update.
|
||||
activeMarkerIndex = insertIndex;
|
||||
activeMarkerIsMidpoint = false;
|
||||
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
|
||||
|
||||
// Since this stop insertion can happen as part of the beginning of a drag, we set this to indicate that it should be removed again if the drag is cancelled
|
||||
deletionRestore = true;
|
||||
|
||||
addEvents();
|
||||
}
|
||||
|
||||
function deleteStop(e: KeyboardEvent) {
|
||||
function deleteShortcut(e: KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
|
||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||
if (activeMarkerIndex === undefined) return;
|
||||
if (gradient.position.length <= 2 && !activeMarkerIsMidpoint) return;
|
||||
|
||||
// Stop dragging the marker or midpoint
|
||||
stopDrag();
|
||||
|
||||
// Either reset the midpoint to 50% or delete the marker, based on which type is currently active
|
||||
if (activeMarkerIsMidpoint) resetMidpoint(activeMarkerIndex);
|
||||
else deleteStopByIndex(activeMarkerIndex);
|
||||
if (activeMarkerIsMidpoint) emit({ ResetMidpoint: { index: activeMarkerIndex } });
|
||||
else if (allowDelete) emit({ DeleteMarker: { index: activeMarkerIndex } });
|
||||
}
|
||||
|
||||
function deleteStopByIndex(index: number) {
|
||||
if (disabled) return;
|
||||
|
||||
if (gradient.position.length <= 2) return;
|
||||
|
||||
gradient.position.splice(index, 1);
|
||||
gradient.midpoint.splice(index, 1);
|
||||
gradient.color.splice(index, 1);
|
||||
dispatch("gradient", gradient);
|
||||
|
||||
deletionRestore = undefined;
|
||||
|
||||
if (gradient.position.length === 0) {
|
||||
activeMarkerIndex = undefined;
|
||||
} else {
|
||||
activeMarkerIndex = Math.max(0, Math.min(gradient.position.length - 1, index));
|
||||
}
|
||||
activeMarkerIsMidpoint = false;
|
||||
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
|
||||
}
|
||||
|
||||
function moveMarker(e: PointerEvent, index: number) {
|
||||
if (disabled) return;
|
||||
|
||||
// Just in case the mouseup event is lost
|
||||
if (e.buttons === 0) stopDrag();
|
||||
|
||||
let position = markerPosition(e);
|
||||
if (position === undefined) return;
|
||||
|
||||
if (dragRestore === undefined) dragRestore = position;
|
||||
if (deletionRestore === undefined) {
|
||||
deletionRestore = false;
|
||||
|
||||
dispatch("dragging", true);
|
||||
}
|
||||
|
||||
setPosition(index, position, false);
|
||||
}
|
||||
|
||||
function moveMidpoint(e: PointerEvent, index: number) {
|
||||
if (disabled) return;
|
||||
|
||||
// Guard in case the mouseup event is lost
|
||||
function moveActiveMarker(e: PointerEvent) {
|
||||
if (disabled || activeMarkerIndex === undefined) return;
|
||||
if (e.buttons === 0) {
|
||||
stopDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
let position = markerPosition(e);
|
||||
let position = pointerPosition(e);
|
||||
if (position === undefined) return;
|
||||
if (!allowSwap) position = clampToNeighbors(activeMarkerIndex, position);
|
||||
|
||||
if (dragRestore === undefined) {
|
||||
dragRestore = gradient.midpoint[index];
|
||||
midpointDragged = true;
|
||||
dispatch("dragging", true);
|
||||
if (!dragInsertedMarker) dispatch("dragging", true);
|
||||
emit({ MoveMarker: { index: activeMarkerIndex, position } });
|
||||
}
|
||||
|
||||
function moveActiveMidpoint(e: PointerEvent) {
|
||||
if (disabled || activeMarkerIndex === undefined) return;
|
||||
if (e.buttons === 0) {
|
||||
stopDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
const leftStop = gradient.position[index];
|
||||
const rightStop = gradient.position[index + 1];
|
||||
const range = rightStop - leftStop;
|
||||
const absolute = pointerPosition(e);
|
||||
if (absolute === undefined) return;
|
||||
|
||||
const left = markers[activeMarkerIndex]?.position;
|
||||
const right = markers[activeMarkerIndex + 1]?.position;
|
||||
if (left === undefined || right === undefined) return;
|
||||
const range = right - left;
|
||||
if (range <= 0) return;
|
||||
|
||||
gradient.midpoint[index] = Math.max(MIN_MIDPOINT, Math.min(MAX_MIDPOINT, (position - leftStop) / range));
|
||||
dispatch("gradient", gradient);
|
||||
}
|
||||
|
||||
export function setPosition(index: number, position: number, isMidpoint: boolean) {
|
||||
if (disabled) return;
|
||||
|
||||
const markers = toMarkers(gradient);
|
||||
const active = markers[index];
|
||||
|
||||
if (isMidpoint) active.midpoint = position;
|
||||
else active.position = position;
|
||||
|
||||
markers.sort((a, b) => a.position - b.position);
|
||||
if (markers.indexOf(active) !== activeMarkerIndex) {
|
||||
activeMarkerIndex = markers.indexOf(active);
|
||||
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
|
||||
}
|
||||
|
||||
gradient.position = markers.map((stop) => stop.position);
|
||||
gradient.midpoint = markers.map((stop) => stop.midpoint);
|
||||
gradient.color = markers.map((stop) => stop.color);
|
||||
dispatch("gradient", gradient);
|
||||
}
|
||||
|
||||
function toMarkers(gradient: GradientStops): { position: number; midpoint: number; color: Color }[] {
|
||||
return gradient.position.map((position, i) => ({
|
||||
position,
|
||||
midpoint: gradient.midpoint[i],
|
||||
color: gradient.color[i],
|
||||
}));
|
||||
}
|
||||
|
||||
function toMidpoints(gradient: GradientStops): number[] {
|
||||
if (gradient.position.length < 2) return [];
|
||||
|
||||
return gradient.midpoint.slice(0, -1).map((midpoint, i) => {
|
||||
const leftMarker = gradient.position[i];
|
||||
const rightMarker = gradient.position[i + 1];
|
||||
return leftMarker + midpoint * (rightMarker - leftMarker);
|
||||
});
|
||||
midpointDragged = true;
|
||||
dispatch("dragging", true);
|
||||
emit({ MoveMidpoint: { index: activeMarkerIndex, position: (absolute - left) / range } });
|
||||
}
|
||||
|
||||
function abortDrag() {
|
||||
if (disabled) return;
|
||||
if (disabled || activeMarkerIndex === undefined) return;
|
||||
|
||||
if (activeMarkerIndex !== undefined) {
|
||||
if (activeMarkerIsMidpoint && dragRestore !== undefined) {
|
||||
gradient.midpoint[activeMarkerIndex] = dragRestore;
|
||||
dispatch("gradient", gradient);
|
||||
} else {
|
||||
if (deletionRestore) deleteStopByIndex(activeMarkerIndex);
|
||||
else if (dragRestore !== undefined) setPosition(activeMarkerIndex, dragRestore, false);
|
||||
}
|
||||
// Restore the dragged value, or delete the marker if it was inserted as part of this drag.
|
||||
if (dragInsertedMarker) {
|
||||
emit({ DeleteMarker: { index: activeMarkerIndex } });
|
||||
} else if (dragRestorePosition !== undefined) {
|
||||
if (activeMarkerIsMidpoint) emit({ MoveMidpoint: { index: activeMarkerIndex, position: dragRestorePosition } });
|
||||
else emit({ MoveMarker: { index: activeMarkerIndex, position: dragRestorePosition } });
|
||||
}
|
||||
|
||||
activeMarkerIndex = activeMarkerIndexRestore;
|
||||
activeMarkerIsMidpoint = activeMarkerIsMidpointRestore;
|
||||
dispatch("activeMarkerIndexChange", { activeMarkerIndex, activeMarkerIsMidpoint });
|
||||
|
||||
setActive(activeMarkerIndexRestore, activeMarkerIsMidpointRestore);
|
||||
stopDrag();
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
if (disabled) return;
|
||||
|
||||
removeEvents();
|
||||
|
||||
dragRestore = undefined;
|
||||
deletionRestore = undefined;
|
||||
dragRestorePosition = undefined;
|
||||
dragInsertedMarker = false;
|
||||
activeMarkerIndexRestore = undefined;
|
||||
activeMarkerIsMidpointRestore = false;
|
||||
midpointDragged = false;
|
||||
|
||||
dispatch("dragging", false);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (disabled) return;
|
||||
|
||||
if (activeMarkerIsMidpoint && activeMarkerIndex !== undefined) moveMidpoint(e, activeMarkerIndex);
|
||||
else if (activeMarkerIndex !== undefined) moveMarker(e, activeMarkerIndex);
|
||||
if (activeMarkerIsMidpoint) moveActiveMidpoint(e);
|
||||
else moveActiveMarker(e);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (disabled) return;
|
||||
|
||||
stopDrag();
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (disabled) return;
|
||||
|
||||
const BUTTONS_RIGHT = 0b0000_0010;
|
||||
if (e.buttons & BUTTONS_RIGHT) abortDrag();
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
const element = markerTrack?.div();
|
||||
const element = markerTrackElement?.div();
|
||||
if (element) preventEscapeClosingParentFloatingMenu(element);
|
||||
|
||||
abortDrag();
|
||||
}
|
||||
}
|
||||
|
|
@ -336,51 +225,36 @@
|
|||
document.removeEventListener("keydown", onKeyDown);
|
||||
}
|
||||
|
||||
// Map midpoint pairs to absolute track positions for rendering the diamond markers.
|
||||
$: midpointPositions = !showMidpoints || markers.length < 2 ? [] : markers.slice(0, -1).map((marker, i) => marker.position + marker.midpoint * (markers[i + 1].position - marker.position));
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", deleteStop);
|
||||
document.addEventListener("keydown", deleteShortcut);
|
||||
});
|
||||
onDestroy(() => {
|
||||
removeEvents();
|
||||
document.removeEventListener("keydown", deleteStop);
|
||||
document.removeEventListener("keydown", deleteShortcut);
|
||||
});
|
||||
|
||||
// Future design notes:
|
||||
//
|
||||
// # Backend -> Frontend
|
||||
// Populate(gradient, { position, color }[], active) // The only way indexes get changed. Frontend drops marker if it's being dragged.
|
||||
// UpdateGradient(gradient)
|
||||
// UpdateMarkers({ index, position, color }[])
|
||||
//
|
||||
// # Frontend -> Backend
|
||||
// SendNewActive(index)
|
||||
// SendPositions({ index, position }[])
|
||||
// AddMarker(position)
|
||||
// RemoveMarkers(index[])
|
||||
// ResetMarkerToDefault(index)
|
||||
//
|
||||
// We need a way to encode constraints on some markers, like locking them in place or preventing reordering
|
||||
// We need a way to encode the allowability of adding new markers between certain markers, or preventing the deletion of certain markers
|
||||
// We need the ability to multi-select markers and move them all at once
|
||||
</script>
|
||||
|
||||
<LayoutCol
|
||||
class="spectrum-input"
|
||||
classes={{ disabled }}
|
||||
styles={{
|
||||
"--gradient-start": ((color) => (color ? colorToHexOptionalAlpha(color) : "black"))(gradientFirstColor(gradient)),
|
||||
"--gradient-end": ((color) => (color ? colorToHexOptionalAlpha(color) : "black"))(gradientLastColor(gradient)),
|
||||
"--gradient-stops": gradientToLinearGradientCSS(gradient),
|
||||
"--gradient-start": trackStartCSS,
|
||||
"--gradient-end": trackEndCSS,
|
||||
"--gradient-stops": trackCSS,
|
||||
}}
|
||||
>
|
||||
<LayoutRow class="gradient-strip" on:pointerdown={insertStop}></LayoutRow>
|
||||
<LayoutRow class="gradient-strip" on:pointerdown={trackPointerDown}></LayoutRow>
|
||||
<LayoutRow class="midpoint-track">
|
||||
{#each toMidpoints(gradient) as midpoint, index}
|
||||
{#each midpointPositions as midpoint, index}
|
||||
<svg
|
||||
class="midpoint"
|
||||
class:active={index === activeMarkerIndex && activeMarkerIsMidpoint}
|
||||
style:--midpoint-position={midpoint}
|
||||
on:pointerdown={(e) => midpointPointerDown(e, index)}
|
||||
on:dblclick={() => resetMidpoint(index)}
|
||||
on:dblclick={() => midpointDoubleClick(index)}
|
||||
data-gradient-midpoint
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 8 8"
|
||||
|
|
@ -389,13 +263,13 @@
|
|||
</svg>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
<LayoutRow class="marker-track" bind:this={markerTrack}>
|
||||
{#each toMarkers(gradient) as marker, index}
|
||||
<LayoutRow class="marker-track" bind:this={markerTrackElement}>
|
||||
{#each markers as marker, index}
|
||||
<svg
|
||||
class="marker"
|
||||
class:active={index === activeMarkerIndex && !activeMarkerIsMidpoint}
|
||||
style:--marker-position={marker.position}
|
||||
style:--marker-color={colorToRgbCSS(marker.color)}
|
||||
style:--marker-color={marker.handleColorCSS}
|
||||
on:pointerdown={(e) => markerPointerDown(e, index)}
|
||||
data-gradient-marker
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,377 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext, onDestroy } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
|
||||
import type { TooltipStore } from "/src/stores/tooltip";
|
||||
import { colorContrastingColor, colorOpaque, colorToHexNoAlpha, colorToRgbCSS, createColor, createColorFromHSVA } from "/src/utility-functions/colors";
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
update: { hue: number; saturation: number; value: number; alpha: number };
|
||||
startHistoryTransaction: undefined;
|
||||
commitHistoryTransaction: undefined;
|
||||
dragStateChange: boolean;
|
||||
}>();
|
||||
const tooltip = getContext<TooltipStore>("tooltip");
|
||||
|
||||
export let hue: number;
|
||||
export let saturation: number;
|
||||
export let value: number;
|
||||
export let alpha: number;
|
||||
export let isNone: boolean;
|
||||
export let disabled = false;
|
||||
|
||||
// Transient drag state
|
||||
let draggingPickerTrack: HTMLDivElement | undefined = undefined;
|
||||
let shiftPressed = false;
|
||||
let alignedAxis: "saturation" | "value" | undefined = undefined;
|
||||
let hueBeforeDrag = 0;
|
||||
let saturationBeforeDrag = 0;
|
||||
let valueBeforeDrag = 0;
|
||||
let alphaBeforeDrag = 0;
|
||||
let saturationStartOfAxisAlign: number | undefined = undefined;
|
||||
let valueStartOfAxisAlign: number | undefined = undefined;
|
||||
let saturationRestoreWhenShiftReleased: number | undefined = undefined;
|
||||
let valueRestoreWhenShiftReleased: number | undefined = undefined;
|
||||
|
||||
function emitUpdate(h: number, s: number, v: number, a: number) {
|
||||
dispatch("update", { hue: h, saturation: s, value: v, alpha: a });
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (disabled) return;
|
||||
|
||||
const target = e.target instanceof HTMLElement ? e.target : undefined;
|
||||
draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-alpha-picker]") || undefined;
|
||||
|
||||
hueBeforeDrag = hue;
|
||||
saturationBeforeDrag = saturation;
|
||||
valueBeforeDrag = value;
|
||||
alphaBeforeDrag = alpha;
|
||||
|
||||
saturationStartOfAxisAlign = undefined;
|
||||
valueStartOfAxisAlign = undefined;
|
||||
saturationRestoreWhenShiftReleased = undefined;
|
||||
valueRestoreWhenShiftReleased = undefined;
|
||||
|
||||
addEvents();
|
||||
|
||||
onPointerMove(e);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
// Just in case the mouseup event is lost
|
||||
if (e.buttons === 0) removeEvents();
|
||||
|
||||
let nextHue = hue;
|
||||
let nextSaturation = saturation;
|
||||
let nextValue = value;
|
||||
let nextAlpha = alpha;
|
||||
|
||||
if (draggingPickerTrack?.hasAttribute("data-saturation-value-picker")) {
|
||||
const rectangle = draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
nextSaturation = clamp((e.clientX - rectangle.left) / rectangle.width, 0, 1);
|
||||
nextValue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
dispatch("dragStateChange", true);
|
||||
|
||||
if (shiftPressed) {
|
||||
const locked = applyAxisLock(nextSaturation, nextValue);
|
||||
nextSaturation = locked.saturation;
|
||||
nextValue = locked.value;
|
||||
}
|
||||
} else if (draggingPickerTrack?.hasAttribute("data-hue-picker")) {
|
||||
const rectangle = draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
nextHue = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
dispatch("dragStateChange", true);
|
||||
} else if (draggingPickerTrack?.hasAttribute("data-alpha-picker")) {
|
||||
const rectangle = draggingPickerTrack.getBoundingClientRect();
|
||||
|
||||
nextAlpha = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
|
||||
dispatch("dragStateChange", true);
|
||||
}
|
||||
|
||||
emitUpdate(nextHue, nextSaturation, nextValue, nextAlpha);
|
||||
|
||||
if (!e.shiftKey) {
|
||||
shiftPressed = false;
|
||||
alignedAxis = undefined;
|
||||
} else if (!shiftPressed && draggingPickerTrack) {
|
||||
shiftPressed = true;
|
||||
saturationStartOfAxisAlign = saturationBeforeDrag;
|
||||
valueStartOfAxisAlign = valueBeforeDrag;
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (draggingPickerTrack) dispatch("commitHistoryTransaction");
|
||||
removeEvents();
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
const BUTTONS_RIGHT = 0b0000_0010;
|
||||
if (e.buttons & BUTTONS_RIGHT) abortDrag();
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") abortDrag();
|
||||
}
|
||||
|
||||
function onKeyUp(e: KeyboardEvent) {
|
||||
if (e.key === "Shift") {
|
||||
shiftPressed = false;
|
||||
alignedAxis = undefined;
|
||||
|
||||
if (saturationRestoreWhenShiftReleased !== undefined && valueRestoreWhenShiftReleased !== undefined) {
|
||||
emitUpdate(hue, saturationRestoreWhenShiftReleased, valueRestoreWhenShiftReleased, alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addEvents() {
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("keyup", onKeyUp);
|
||||
|
||||
dispatch("startHistoryTransaction");
|
||||
}
|
||||
|
||||
function removeEvents() {
|
||||
draggingPickerTrack = undefined;
|
||||
// The setTimeout is necessary to prevent the FloatingMenu's `escapeCloses` from becoming true immediately upon pressing the Escape key, and thus closing
|
||||
setTimeout(() => dispatch("dragStateChange", false), 0);
|
||||
shiftPressed = false;
|
||||
alignedAxis = undefined;
|
||||
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("keyup", onKeyUp);
|
||||
}
|
||||
|
||||
function applyAxisLock(s: number, v: number): { saturation: number; value: number } {
|
||||
if (saturationStartOfAxisAlign === undefined || valueStartOfAxisAlign === undefined) return { saturation: s, value: v };
|
||||
|
||||
const deltaSaturation = s - saturationStartOfAxisAlign;
|
||||
const deltaValue = v - valueStartOfAxisAlign;
|
||||
|
||||
saturationRestoreWhenShiftReleased = s;
|
||||
valueRestoreWhenShiftReleased = v;
|
||||
|
||||
if (Math.abs(deltaSaturation) < Math.abs(deltaValue)) {
|
||||
alignedAxis = "saturation";
|
||||
return { saturation: saturationStartOfAxisAlign, value: v };
|
||||
} else {
|
||||
alignedAxis = "value";
|
||||
return { saturation: s, value: valueStartOfAxisAlign };
|
||||
}
|
||||
}
|
||||
|
||||
function abortDrag() {
|
||||
removeEvents();
|
||||
|
||||
emitUpdate(hueBeforeDrag, saturationBeforeDrag, valueBeforeDrag, alphaBeforeDrag);
|
||||
}
|
||||
|
||||
function clamp(input: number, min = 0, max = 1): number {
|
||||
return Math.max(min, Math.min(input, max));
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
removeEvents();
|
||||
});
|
||||
|
||||
$: newColor = isNone ? undefined : createColorFromHSVA(hue, saturation, value, alpha);
|
||||
$: opaqueHueColor = createColorFromHSVA(hue, 1, 1, 1);
|
||||
$: opaqueColorOnly = newColor ? colorOpaque(newColor) : createColor(0, 0, 0, 1);
|
||||
</script>
|
||||
|
||||
<LayoutRow
|
||||
class="visual-color-pickers-input"
|
||||
classes={{ disabled }}
|
||||
styles={{
|
||||
"--hue-color": colorToRgbCSS(opaqueHueColor),
|
||||
"--hue-color-contrasting": colorContrastingColor(opaqueHueColor),
|
||||
"--opaque-color": colorToHexNoAlpha(opaqueColorOnly),
|
||||
"--opaque-color-contrasting": colorContrastingColor(opaqueColorOnly),
|
||||
"--new-color-contrasting": colorContrastingColor(newColor),
|
||||
}}
|
||||
>
|
||||
{@const hueDescription = "The shade along the spectrum of the rainbow."}
|
||||
<LayoutCol
|
||||
class="saturation-value-picker"
|
||||
data-tooltip-label="Saturation and Value"
|
||||
data-tooltip-description={`To move only along the saturation (X) or value (Y) axis, perform the shortcut shown.${disabled ? "\n\nDisabled (read-only)." : ""}`}
|
||||
data-tooltip-shortcut={$tooltip.shiftClickShortcut?.shortcut ? JSON.stringify($tooltip.shiftClickShortcut.shortcut) : undefined}
|
||||
on:pointerdown={onPointerDown}
|
||||
data-saturation-value-picker
|
||||
>
|
||||
{#if alignedAxis}
|
||||
<div
|
||||
class="selection-circle-axis-snap-line"
|
||||
style:width={alignedAxis === "value" ? "100%" : undefined}
|
||||
style:height={alignedAxis === "saturation" ? "100%" : undefined}
|
||||
style:top={alignedAxis === "value" ? `${(1 - value) * 100}%` : undefined}
|
||||
style:left={alignedAxis === "saturation" ? `${saturation * 100}%` : undefined}
|
||||
></div>
|
||||
<div
|
||||
class="selection-circle-axis-snap-line"
|
||||
style:width={alignedAxis === "saturation" ? "100%" : undefined}
|
||||
style:height={alignedAxis === "value" ? "100%" : undefined}
|
||||
style:top={alignedAxis === "saturation" ? `${(1 - valueBeforeDrag) * 100}%` : undefined}
|
||||
style:left={alignedAxis === "value" ? `${saturationBeforeDrag * 100}%` : undefined}
|
||||
></div>
|
||||
{/if}
|
||||
{#if !isNone}
|
||||
<div class="selection-circle" style:top={`${(1 - value) * 100}%`} style:left={`${saturation * 100}%`}></div>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol class="hue-picker" data-tooltip-label="Hue" data-tooltip-description={`${hueDescription}${disabled ? "\n\nDisabled (read-only)." : ""}`} on:pointerdown={onPointerDown} data-hue-picker>
|
||||
{#if !isNone}
|
||||
<div class="selection-needle" style:top={`${(1 - hue) * 100}%`}></div>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
<LayoutCol
|
||||
class="alpha-picker"
|
||||
data-tooltip-label="Alpha"
|
||||
data-tooltip-description={`The level of translucency.${disabled ? "\n\nDisabled (read-only)." : ""}`}
|
||||
on:pointerdown={onPointerDown}
|
||||
data-alpha-picker
|
||||
>
|
||||
{#if !isNone}
|
||||
<div class="selection-needle" style:top={`${(1 - alpha) * 100}%`}></div>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss">
|
||||
.visual-color-pickers-input {
|
||||
--picker-size: 256px;
|
||||
--picker-circle-radius: 6px;
|
||||
|
||||
.saturation-value-picker {
|
||||
width: var(--picker-size);
|
||||
background-blend-mode: multiply;
|
||||
background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--hue-color));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.saturation-value-picker,
|
||||
.hue-picker,
|
||||
.alpha-picker {
|
||||
height: var(--picker-size);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hue-picker,
|
||||
.alpha-picker {
|
||||
width: 24px;
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hue-picker {
|
||||
--selection-needle-color: var(--hue-color-contrasting);
|
||||
background-blend-mode: screen;
|
||||
background:
|
||||
// Reds
|
||||
linear-gradient(to top, #ff0000ff calc(100% / 6), #ff000000 calc(200% / 6), #ff000000 calc(400% / 6), #ff0000ff calc(500% / 6)),
|
||||
// Greens
|
||||
linear-gradient(to top, #00ff0000 0%, #00ff00ff calc(100% / 6), #00ff00ff 50%, #00ff0000 calc(400% / 6)),
|
||||
// Blues
|
||||
linear-gradient(to top, #0000ff00 calc(200% / 6), #0000ffff 50%, #0000ffff calc(500% / 6), #0000ff00 100%);
|
||||
}
|
||||
|
||||
.alpha-picker {
|
||||
--selection-needle-color: var(--new-color-contrasting);
|
||||
background-image: linear-gradient(to bottom, var(--opaque-color), transparent), var(--color-transparent-checkered-background);
|
||||
background-size:
|
||||
100% 100%,
|
||||
var(--color-transparent-checkered-background-size);
|
||||
background-position:
|
||||
0 0,
|
||||
var(--color-transparent-checkered-background-position);
|
||||
background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
|
||||
}
|
||||
|
||||
.selection-circle {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: relative;
|
||||
left: calc(-1 * var(--picker-circle-radius));
|
||||
top: calc(-1 * var(--picker-circle-radius));
|
||||
width: calc(var(--picker-circle-radius) * 2 + 1px);
|
||||
height: calc(var(--picker-circle-radius) * 2 + 1px);
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--opaque-color-contrasting);
|
||||
background: var(--opaque-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-circle-axis-snap-line {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: var(--opaque-color-contrasting);
|
||||
|
||||
+ .selection-circle-axis-snap-line {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-needle {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 0 4px 4px;
|
||||
border-color: transparent transparent transparent var(--selection-needle-color);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: 0;
|
||||
border-style: solid;
|
||||
border-width: 4px 4px 4px 0;
|
||||
border-color: transparent var(--selection-needle-color) transparent transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled :is(.saturation-value-picker, .hue-picker, .alpha-picker) {
|
||||
transition: opacity 0.1s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// paddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpaddingpadding
|
||||
</style>
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { SubscriptionsRouter } from "/src/subscriptions-router";
|
||||
import { patchLayout } from "/src/utility-functions/widgets";
|
||||
import type { FillChoice, Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
export type ColorPickerCallbacks = {
|
||||
onColorChanged?: (value: FillChoice) => void;
|
||||
onStartTransaction?: () => void;
|
||||
onCommitTransaction?: () => void;
|
||||
};
|
||||
|
||||
export type ColorPickerStoreState = {
|
||||
pickersAndGradient: Layout;
|
||||
details: Layout;
|
||||
callbacks: ColorPickerCallbacks;
|
||||
// True while the user is actively dragging one of the visual H/S/V/A pickers, so the popover knows to suppress its stray-pointer-close behavior until the drag ends.
|
||||
isDragging: boolean;
|
||||
};
|
||||
|
||||
const initialState: ColorPickerStoreState = {
|
||||
pickersAndGradient: [],
|
||||
details: [],
|
||||
callbacks: {},
|
||||
isDragging: false,
|
||||
};
|
||||
|
||||
let subscriptionsRouter: SubscriptionsRouter | undefined = undefined;
|
||||
|
||||
// Persist the store across HMR so subscriptions stay live.
|
||||
const store: Writable<ColorPickerStoreState> = import.meta.hot?.data?.store || writable<ColorPickerStoreState>(initialState);
|
||||
if (import.meta.hot) import.meta.hot.data.store = store;
|
||||
const { subscribe, update } = store;
|
||||
|
||||
export type ColorPickerStore = {
|
||||
subscribe: typeof subscribe;
|
||||
setCallbacks: (callbacks: ColorPickerCallbacks) => void;
|
||||
clearCallbacks: () => void;
|
||||
setDragging: (dragging: boolean) => void;
|
||||
};
|
||||
|
||||
// The Rust handler keeps a single shared layout per target, but multiple `<ColorPicker>` Svelte instances may be mounted across
|
||||
// the app (one per `ColorInput`/`WorkingColorsInput`/etc.). Subscribing to the layout target from each instance is destructive,
|
||||
// only the last-registered callback wins. So we maintain a single global subscription here and let each `<ColorPicker>` instance
|
||||
// read from the resulting store and register its own per-open callbacks for color/transaction events.
|
||||
export function createColorPickerStore(subscriptions: SubscriptionsRouter): ColorPickerStore {
|
||||
destroyColorPickerStore();
|
||||
|
||||
subscriptionsRouter = subscriptions;
|
||||
|
||||
subscriptions.subscribeFrontendMessage("ColorPickerColorChanged", (data) => {
|
||||
update((state) => {
|
||||
state.callbacks.onColorChanged?.(data.value);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
subscriptions.subscribeFrontendMessage("ColorPickerStartHistoryTransaction", () => {
|
||||
update((state) => {
|
||||
state.callbacks.onStartTransaction?.();
|
||||
return state;
|
||||
});
|
||||
});
|
||||
subscriptions.subscribeFrontendMessage("ColorPickerCommitHistoryTransaction", () => {
|
||||
update((state) => {
|
||||
state.callbacks.onCommitTransaction?.();
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
subscriptions.subscribeLayoutUpdate("ColorPickerPickersAndGradient", (diffs) => {
|
||||
update((state) => {
|
||||
patchLayout(state.pickersAndGradient, diffs);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
subscriptions.subscribeLayoutUpdate("ColorPickerDetails", (diffs) => {
|
||||
update((state) => {
|
||||
patchLayout(state.details, diffs);
|
||||
return state;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setCallbacks: (callbacks: ColorPickerCallbacks) => {
|
||||
update((state) => {
|
||||
state.callbacks = callbacks;
|
||||
return state;
|
||||
});
|
||||
},
|
||||
clearCallbacks: () => {
|
||||
update((state) => {
|
||||
state.callbacks = {};
|
||||
state.isDragging = false;
|
||||
return state;
|
||||
});
|
||||
},
|
||||
setDragging: (dragging: boolean) => {
|
||||
update((state) => {
|
||||
state.isDragging = dragging;
|
||||
return state;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function destroyColorPickerStore() {
|
||||
const subscriptions = subscriptionsRouter;
|
||||
if (!subscriptions) return;
|
||||
|
||||
subscriptions.unsubscribeFrontendMessage("ColorPickerColorChanged");
|
||||
subscriptions.unsubscribeFrontendMessage("ColorPickerStartHistoryTransaction");
|
||||
subscriptions.unsubscribeFrontendMessage("ColorPickerCommitHistoryTransaction");
|
||||
subscriptions.unsubscribeLayoutUpdate("ColorPickerPickersAndGradient");
|
||||
subscriptions.unsubscribeLayoutUpdate("ColorPickerDetails");
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { sampleInterpolatedGradient } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
import type { Color, FillChoice, GradientStops } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
// 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
|
||||
|
|
@ -63,12 +62,6 @@ export function colorFromCSS(colorCode: string): Color | undefined {
|
|||
return createColor(r / 255, g / 255, b / 255, a / 255);
|
||||
}
|
||||
|
||||
export function colorEquals(c1: Color | undefined, c2: Color | undefined): boolean {
|
||||
if (c1 === undefined && c2 === undefined) return true;
|
||||
if (c1 === undefined || c2 === undefined) return false;
|
||||
return Math.abs(c1.red - c2.red) < 1e-6 && Math.abs(c1.green - c2.green) < 1e-6 && Math.abs(c1.blue - c2.blue) < 1e-6 && Math.abs(c1.alpha - c2.alpha) < 1e-6;
|
||||
}
|
||||
|
||||
export function colorToHexNoAlpha(color: Color): string {
|
||||
const r = Math.round(color.red * 255)
|
||||
.toString(16)
|
||||
|
|
@ -83,15 +76,6 @@ export function colorToHexNoAlpha(color: Color): string {
|
|||
return `#${r}${g}${b}`;
|
||||
}
|
||||
|
||||
export function colorToHexOptionalAlpha(color: Color): string {
|
||||
const hex = colorToHexNoAlpha(color);
|
||||
const a = Math.round(color.alpha * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
|
||||
return a === "ff" ? hex : `${hex}${a}`;
|
||||
}
|
||||
|
||||
export function colorToRgb255(color: Color): RGB {
|
||||
return {
|
||||
r: Math.round(color.red * 255),
|
||||
|
|
@ -205,23 +189,6 @@ export function isGradientStops(value: unknown): value is GradientStops {
|
|||
return typeof value === "object" && value !== null && "position" in value && "midpoint" in value && "color" in value;
|
||||
}
|
||||
|
||||
export function gradientToLinearGradientCSS(gradient: GradientStops): string {
|
||||
if (gradient.position.length === 1) {
|
||||
return `linear-gradient(to right, ${colorToHexOptionalAlpha(gradient.color[0])} 0%, ${colorToHexOptionalAlpha(gradient.color[0])} 100%)`;
|
||||
}
|
||||
|
||||
const pieces = sampleInterpolatedGradient(new Float64Array(gradient.position), new Float64Array(gradient.midpoint), gradient.color, false);
|
||||
return `linear-gradient(to right, ${pieces})`;
|
||||
}
|
||||
|
||||
export function gradientFirstColor(gradient: GradientStops): Color | undefined {
|
||||
return gradient.color[0];
|
||||
}
|
||||
|
||||
export function gradientLastColor(gradient: GradientStops): Color | undefined {
|
||||
return gradient.color[gradient.color.length - 1];
|
||||
}
|
||||
|
||||
// FILL CHOICE UTILITY FUNCTIONS
|
||||
|
||||
export function fillChoiceColor(value: FillChoice): Color | undefined {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
|
|||
use graph_craft::document::NodeId;
|
||||
use graphene_std::graphene_hash::CacheHashWrapper;
|
||||
use graphene_std::raster::color::Color;
|
||||
use graphene_std::vector::GradientStops;
|
||||
use serde::Serialize;
|
||||
use serde_wasm_bindgen::{self, from_value};
|
||||
use std::cell::RefCell;
|
||||
|
|
@ -703,6 +702,20 @@ impl EditorWrapper {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize the Rust color picker handler with a starting value (used when the frontend `<ColorPicker>` opens).
|
||||
#[wasm_bindgen(js_name = openColorPicker)]
|
||||
pub fn open_color_picker(&self, initial_value: JsValue, allow_none: bool, disabled: bool) -> Result<(), JsValue> {
|
||||
let initial_value = serde_wasm_bindgen::from_value(initial_value).map_err(|e| Error::new(&format!("Invalid initial picker value: {e}")))?;
|
||||
self.dispatch(ColorPickerMessage::Open { initial_value, allow_none, disabled });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tell the Rust color picker handler that the popover is closing.
|
||||
#[wasm_bindgen(js_name = closeColorPicker)]
|
||||
pub fn close_color_picker(&self) {
|
||||
self.dispatch(ColorPickerMessage::Close);
|
||||
}
|
||||
|
||||
/// Update the color of the currently-edited gradient stop
|
||||
#[wasm_bindgen(js_name = updateGradientStopColor)]
|
||||
pub fn update_gradient_stop_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> {
|
||||
|
|
@ -999,26 +1012,3 @@ pub fn evaluate_math_expression(expression: &str) -> Option<f64> {
|
|||
};
|
||||
Some(real)
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = sampleInterpolatedGradient)]
|
||||
pub fn sample_interpolated_gradient(position: Vec<f64>, midpoint: Vec<f64>, color: Vec<JsValue>, omit_alpha: bool) -> String {
|
||||
let color = color.into_iter().filter_map(|c| serde_wasm_bindgen::from_value(c).ok()).collect();
|
||||
GradientStops { position, midpoint, color }
|
||||
.interpolated_samples()
|
||||
.into_iter()
|
||||
.map(|(position, color, _)| {
|
||||
let hex = if omit_alpha { color.to_rgb_hex_srgb_from_gamma() } else { color.to_rgba_hex_srgb_from_gamma() };
|
||||
let percent = ((position * 100.) * 1e2).round() / 1e2;
|
||||
format!("#{hex} {percent}%")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = evaluateGradientAtPosition)]
|
||||
pub fn evaluate_gradient_at_position(t: f64, position: Vec<f64>, midpoint: Vec<f64>, color: Vec<JsValue>) -> JsValue {
|
||||
let color = color.into_iter().filter_map(|c| serde_wasm_bindgen::from_value(c).ok()).collect();
|
||||
let color = GradientStops { position, midpoint, color }.evaluate(t);
|
||||
|
||||
serde_wasm_bindgen::to_value(&color).unwrap()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ pub enum NodeInput {
|
|||
/// Input that is extracted from the parent scopes the node resides in. The string argument is the key.
|
||||
Scope(Cow<'static, str>),
|
||||
|
||||
/// Input that is extracted from the parent scopes the node resides in. The string argument is the key.
|
||||
/// Input that is replaced at graph compilation with introspective metadata about this node's location.
|
||||
Reflection(DocumentNodeMetadata),
|
||||
|
||||
/// A Rust source code string. Allows us to insert literal Rust code. Only used for GPU compilation.
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ impl Pixel for Luma {}
|
|||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny, serde::Serialize, serde::Deserialize))]
|
||||
#[cfg_attr(feature = "std", derive(graphene_hash::CacheHash))]
|
||||
#[derive(Debug, Default, Clone, Copy, Pod, Zeroable, BufferStruct)]
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable, BufferStruct)]
|
||||
pub struct Color {
|
||||
red: f32,
|
||||
green: f32,
|
||||
|
|
@ -228,12 +228,7 @@ pub struct Color {
|
|||
alpha: f32,
|
||||
}
|
||||
|
||||
impl PartialEq for Color {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.red == other.red && self.green == other.green && self.blue == other.blue && self.alpha == other.alpha
|
||||
}
|
||||
}
|
||||
|
||||
// `f32` channels mean `Color` doesn't qualify for a derived `Eq`, but in practice we never store NaN here, and the renderer's `HashMap<CacheHashWrapper<Image<Color>>, _>` deduplication needs `Color: Eq` to propagate up through the wrapper.
|
||||
impl Eq for Color {}
|
||||
|
||||
impl RGB for Color {
|
||||
|
|
@ -869,6 +864,15 @@ impl Color {
|
|||
)
|
||||
}
|
||||
|
||||
/// [`Color::BLACK`] or [`Color::WHITE`], whichever gives more legible text against this color (alpha composited over white, WCAG-style luminance threshold). Use this if this [`Color`] is in gamma space.
|
||||
pub fn contrasting_text_color_from_gamma(&self) -> Color {
|
||||
let composited = Self::WHITE.alpha_blend(Self::from_unassociated_alpha(self.r(), self.g(), self.b(), self.a()));
|
||||
let luminance = composited.to_linear_srgb().luminance_srgb();
|
||||
// WCAG-derived perceptual midpoint between black and white (~0.179)
|
||||
let threshold = (1.05_f32 * 0.05).sqrt() - 0.05;
|
||||
if luminance > threshold { Self::BLACK } else { Self::WHITE }
|
||||
}
|
||||
|
||||
/// Return the all components as a u8 slice, first component is red, followed by green, followed by blue, followed by alpha. Use this if the [`Color`] is in gamma space.
|
||||
#[inline(always)]
|
||||
pub fn to_rgba8(&self) -> [u8; 4] {
|
||||
|
|
|
|||
|
|
@ -197,6 +197,47 @@ impl GradientStops {
|
|||
self.color.pop()
|
||||
}
|
||||
|
||||
/// Move the stop at `index` to a new position, re-sorting the stops by position. Returns the new index of the moved stop.
|
||||
pub fn move_stop(&mut self, index: usize, position: f64) -> usize {
|
||||
if index >= self.position.len() {
|
||||
return index;
|
||||
}
|
||||
self.position[index] = position;
|
||||
self.sort_returning_new_index(index)
|
||||
}
|
||||
|
||||
/// Insert a new stop at the given position, sampling the gradient at that position to determine the new stop's color.
|
||||
/// The new stop's midpoint is inherited from the interval it splits (or `0.5` if inserting at the very start).
|
||||
/// Returns the index where the new stop was inserted.
|
||||
pub fn insert_stop(&mut self, position: f64) -> usize {
|
||||
let color = self.evaluate(position);
|
||||
let index = self.position.iter().position(|p| *p > position).unwrap_or(self.position.len());
|
||||
let midpoint = index.checked_sub(1).and_then(|i| self.midpoint.get(i).copied()).unwrap_or(0.5);
|
||||
self.position.insert(index, position);
|
||||
self.midpoint.insert(index, midpoint);
|
||||
self.color.insert(index, color);
|
||||
index
|
||||
}
|
||||
|
||||
/// Reset the midpoint for the interval starting at `index` to its default `0.5`.
|
||||
pub fn reset_midpoint(&mut self, index: usize) {
|
||||
if let Some(midpoint) = self.midpoint.get_mut(index) {
|
||||
*midpoint = 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort the stops in place by position; returns the new index of the stop that was at `previous_index` before sorting.
|
||||
fn sort_returning_new_index(&mut self, previous_index: usize) -> usize {
|
||||
let len = self.position.len();
|
||||
let mut indices: Vec<usize> = (0..len).collect();
|
||||
indices.sort_by(|&a, &b| self.position[a].total_cmp(&self.position[b]));
|
||||
let new_index = indices.iter().position(|&i| i == previous_index).unwrap_or(previous_index);
|
||||
self.position = indices.iter().map(|&i| self.position[i]).collect();
|
||||
self.midpoint = indices.iter().map(|&i| self.midpoint[i]).collect();
|
||||
self.color = indices.iter().map(|&i| self.color[i]).collect();
|
||||
new_index
|
||||
}
|
||||
|
||||
pub fn evaluate(&self, t: f64) -> Color {
|
||||
if self.position.is_empty() {
|
||||
return Color::BLACK;
|
||||
|
|
@ -250,6 +291,24 @@ impl GradientStops {
|
|||
}
|
||||
}
|
||||
|
||||
/// Build a CSS `linear-gradient(...)` string suitable for use as a `background-image`. Samples the midpoint curves so the rendered gradient matches Graphite's interpolation rather than browser defaults.
|
||||
pub fn to_css_linear_gradient(&self) -> String {
|
||||
if self.position.len() <= 1 {
|
||||
let hex = self.color.first().map(|c| c.to_rgba_hex_srgb_from_gamma()).unwrap_or_else(|| "000000ff".to_string());
|
||||
return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)");
|
||||
}
|
||||
let pieces = self
|
||||
.interpolated_samples()
|
||||
.into_iter()
|
||||
.map(|(position, color, _)| {
|
||||
let percent = ((position * 100.) * 1e2).round() / 1e2;
|
||||
format!("#{} {percent}%", color.to_rgba_hex_srgb_from_gamma())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("linear-gradient(to right, {pieces})")
|
||||
}
|
||||
|
||||
/// Produce a set of linearly-interpolated color samples that approximate the gradient's midpoint curves.
|
||||
///
|
||||
/// Each sample is `(position, color, original_midpoint)` where `original_midpoint` is `Some(f64)` with the corresponding
|
||||
|
|
|
|||
|
|
@ -185,6 +185,18 @@ impl FillChoice {
|
|||
Some(gradient)
|
||||
}
|
||||
|
||||
/// Build a CSS `background-image` string (always a `linear-gradient(...)`) representing this fill, or `None` if the fill is [`FillChoice::None`]. Solid colors become a degenerate gradient between the same color so the CSS variable can always be assigned to a `background-image`.
|
||||
pub fn to_css_background_image(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::Solid(color) => {
|
||||
let hex = color.to_rgba_hex_srgb_from_gamma();
|
||||
Some(format!("linear-gradient(#{hex}, #{hex})"))
|
||||
}
|
||||
Self::Gradient(stops) => Some(stops.to_css_linear_gradient()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert this [`FillChoice`] to a [`Fill`] using the provided [`Gradient`] as a base for the positional information of the gradient.
|
||||
/// If a gradient isn't provided, default gradient positional information is used in cases where the [`FillChoice`] is a [`Gradient`].
|
||||
pub fn to_fill(&self, existing_gradient: Option<&Gradient>) -> Fill {
|
||||
|
|
|
|||
Loading…
Reference in New Issue