Polish and add aborting to several input widgets: no Esc closing parent menus; color picker axis align; repeat on arrow buttons (#2276)

* Remove color input outline; reduce antialiasing compositing artifacts in color widgets

* Rename ColorButton to ColorInput

* Add features and aborting to several other widgets

- Prevent Esc from closing parent floating menus when aborting
- Fix missing icon regression
- Gutter resizing abort
- Color picker aborts, Shift axis alignment, improve click/drag behavior for gradient spectrum
- Scrollbar abort, repeat when held, fix directional arrows when viewport is zoomed
- Number input abort, repeat when held

* Move ColorInput into the inputs folder

* Fix tiny logo
This commit is contained in:
Keavon Chambers 2025-02-10 05:46:41 -08:00 committed by GitHub
parent 0037f5158c
commit ec8c8d6485
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1031 additions and 452 deletions

View File

@ -80,7 +80,7 @@ impl LayoutMessageHandler {
};
responses.add(callback_message);
}
Widget::ColorButton(color_button) => {
Widget::ColorInput(color_button) => {
let callback_message = match action {
WidgetValueAction::Commit => (color_button.on_commit.callback)(&()),
WidgetValueAction::Update => {
@ -99,7 +99,7 @@ impl LayoutMessageHandler {
};
(|| {
let update_value = value.as_object().expect("ColorButton update was not of type: object");
let update_value = value.as_object().expect("ColorInput update was not of type: object");
// None
let is_none = update_value.get("none").and_then(|x| x.as_bool());
@ -139,7 +139,7 @@ impl LayoutMessageHandler {
return (color_button.on_update.callback)(color_button);
}
panic!("ColorButton update was not able to be parsed with color data: {color_button:?}");
panic!("ColorInput update was not able to be parsed with color data: {color_button:?}");
})()
}
};

View File

@ -327,7 +327,7 @@ impl LayoutGroup {
for widget in &mut widgets {
let val = match &mut widget.widget {
Widget::CheckboxInput(x) => &mut x.tooltip,
Widget::ColorButton(x) => &mut x.tooltip,
Widget::ColorInput(x) => &mut x.tooltip,
Widget::CurveInput(x) => &mut x.tooltip,
Widget::DropdownInput(x) => &mut x.tooltip,
Widget::FontInput(x) => &mut x.tooltip,
@ -498,7 +498,7 @@ impl<T> Default for WidgetCallback<T> {
pub enum Widget {
BreadcrumbTrailButtons(BreadcrumbTrailButtons),
CheckboxInput(CheckboxInput),
ColorButton(ColorButton),
ColorInput(ColorInput),
CurveInput(CurveInput),
DropdownInput(DropdownInput),
FontInput(FontInput),
@ -571,7 +571,7 @@ impl DiffUpdate {
let mut tooltip_shortcut = match &mut widget_holder.widget {
Widget::BreadcrumbTrailButtons(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),
Widget::CheckboxInput(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),
Widget::ColorButton(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),
Widget::ColorInput(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),
Widget::DropdownInput(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),
Widget::FontInput(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),
Widget::IconButton(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),

View File

@ -148,7 +148,7 @@ pub struct ImageButton {
#[derive(Clone, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder, specta::Type)]
#[derivative(Debug, PartialEq, Default)]
pub struct ColorButton {
pub struct ColorInput {
#[widget_builder(constructor)]
pub value: FillChoice,
@ -174,7 +174,7 @@ pub struct ColorButton {
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<ColorButton>,
pub on_update: WidgetCallback<ColorInput>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]

View File

@ -11,6 +11,8 @@ pub enum NavigationMessage {
BeginCanvasTilt { was_dispatched_from_menu: bool },
BeginCanvasZoom,
CanvasPan { delta: DVec2 },
CanvasPanAbortPrepare { x_not_y_axis: bool },
CanvasPanAbort { x_not_y_axis: bool },
CanvasPanByViewportFraction { delta: DVec2 },
CanvasPanMouseWheel { use_y_as_x: bool },
CanvasTiltResetAndZoomTo100Percent,

View File

@ -29,6 +29,7 @@ pub struct NavigationMessageHandler {
navigation_operation: NavigationOperation,
mouse_position: ViewportPosition,
finish_operation_with_click: bool,
abortable_pan_start: Option<f64>,
}
impl MessageHandler<NavigationMessage, NavigationMessageData<'_>> for NavigationMessageHandler {
@ -141,6 +142,28 @@ impl MessageHandler<NavigationMessage, NavigationMessageData<'_>> for Navigation
responses.add(BroadcastEvent::CanvasTransformed);
responses.add(DocumentMessage::PTZUpdate);
}
NavigationMessage::CanvasPanAbortPrepare { x_not_y_axis } => {
let Some(ptz) = get_ptz_mut(document_ptz, network_interface, graph_view_overlay_open, breadcrumb_network_path) else {
log::error!("Could not get PTZ in CanvasPanAbortPrepare");
return;
};
self.abortable_pan_start = Some(if x_not_y_axis { ptz.pan.x } else { ptz.pan.y });
}
NavigationMessage::CanvasPanAbort { x_not_y_axis } => {
let Some(ptz) = get_ptz_mut(document_ptz, network_interface, graph_view_overlay_open, breadcrumb_network_path) else {
log::error!("Could not get PTZ in CanvasPanAbort");
return;
};
if let Some(abortable_pan_start) = self.abortable_pan_start {
if x_not_y_axis {
ptz.pan.x = abortable_pan_start;
} else {
ptz.pan.y = abortable_pan_start;
}
}
self.abortable_pan_start = None;
responses.add(DocumentMessage::PTZUpdate);
}
NavigationMessage::CanvasPanByViewportFraction { delta } => {
let Some(ptz) = get_ptz_mut(document_ptz, network_interface, graph_view_overlay_open, breadcrumb_network_path) else {
log::error!("Could not get node graph PTZ in CanvasPanByViewportFraction");

View File

@ -3303,7 +3303,7 @@ fn static_input_properties() -> InputProperties {
"assign_colors_gradient".to_string(),
Box::new(|node_id, index, context| {
let (document_node, input_name) = node_properties::query_node_and_input_name(node_id, index, context)?;
let gradient_row = node_properties::color_widget(document_node, node_id, index, input_name, ColorButton::default().allow_none(false), true);
let gradient_row = node_properties::color_widget(document_node, node_id, index, input_name, ColorInput::default().allow_none(false), true);
Ok(vec![gradient_row])
}),
);
@ -3329,7 +3329,7 @@ fn static_input_properties() -> InputProperties {
"mask_stencil".to_string(),
Box::new(|node_id, index, context| {
let (document_node, input_name) = node_properties::query_node_and_input_name(node_id, index, context)?;
let mask = node_properties::color_widget(document_node, node_id, index, input_name, ColorButton::default(), true);
let mask = node_properties::color_widget(document_node, node_id, index, input_name, ColorInput::default(), true);
Ok(vec![mask])
}),
);
@ -3403,7 +3403,7 @@ fn static_input_properties() -> InputProperties {
node_id,
index,
input_name,
ColorButton::default().allow_none(false),
ColorInput::default().allow_none(false),
true,
)])
}),

View File

@ -1822,7 +1822,7 @@ impl NodeGraphMessageHandler {
// If only one node is selected then show the preview or stop previewing button
if let Some(node_id) = previewing {
let button = TextButton::new("End Preview")
.icon(Some("Rescale".to_string()))
.icon(Some("FrameAll".to_string()))
.tooltip("Restore preview to the graph output")
.on_update(move |_| NodeGraphMessage::TogglePreview { node_id }.into())
.widget_holder();
@ -1834,7 +1834,7 @@ impl NodeGraphMessageHandler {
.any(|export| matches!(export, NodeInput::Node { node_id: export_node_id, .. } if *export_node_id == node_id));
if selection_is_not_already_the_output && no_other_selections {
let button = TextButton::new("Preview")
.icon(Some("Rescale".to_string()))
.icon(Some("FrameAll".to_string()))
.tooltip("Preview selected node/layer (Shortcut: Alt-click node/layer)")
.on_update(move |_| NodeGraphMessage::TogglePreview { node_id }.into())
.widget_holder();

View File

@ -140,8 +140,8 @@ pub(crate) fn property_from_type(node_id: NodeId, index: usize, ty: &Type, numbe
Some(x) if x == TypeId::of::<u32>() => number_widget(document_node, node_id, index, name, number_input.int().min(min(0.)).max(max(f64::from(u32::MAX))), true).into(),
Some(x) if x == TypeId::of::<u64>() => number_widget(document_node, node_id, index, name, number_input.int().min(min(0.)), true).into(),
Some(x) if x == TypeId::of::<String>() => text_widget(document_node, node_id, index, name, true).into(),
Some(x) if x == TypeId::of::<Color>() => color_widget(document_node, node_id, index, name, ColorButton::default().allow_none(false), true),
Some(x) if x == TypeId::of::<Option<Color>>() => color_widget(document_node, node_id, index, name, ColorButton::default().allow_none(true), true),
Some(x) if x == TypeId::of::<Color>() => color_widget(document_node, node_id, index, name, ColorInput::default().allow_none(false), true),
Some(x) if x == TypeId::of::<Option<Color>>() => color_widget(document_node, node_id, index, name, ColorInput::default().allow_none(true), true),
Some(x) if x == TypeId::of::<DVec2>() => vec2_widget(document_node, node_id, index, name, "X", "Y", "", None, add_blank_assist),
Some(x) if x == TypeId::of::<UVec2>() => vec2_widget(document_node, node_id, index, name, "X", "Y", "", Some(0.), add_blank_assist),
Some(x) if x == TypeId::of::<IVec2>() => vec2_widget(document_node, node_id, index, name, "X", "Y", "", None, add_blank_assist),
@ -152,7 +152,7 @@ pub(crate) fn property_from_type(node_id: NodeId, index: usize, ty: &Type, numbe
font_widgets.into_iter().chain(style_widgets.unwrap_or_default()).collect::<Vec<_>>().into()
}
Some(x) if x == TypeId::of::<Curve>() => curves_widget(document_node, node_id, index, name, true),
Some(x) if x == TypeId::of::<GradientStops>() => color_widget(document_node, node_id, index, name, ColorButton::default().allow_none(false), true),
Some(x) if x == TypeId::of::<GradientStops>() => color_widget(document_node, node_id, index, name, ColorInput::default().allow_none(false), true),
Some(x) if x == TypeId::of::<VectorDataTable>() => vector_widget(document_node, node_id, index, name, true).into(),
Some(x) if x == TypeId::of::<RasterFrame>() || x == TypeId::of::<ImageFrameTable<Color>>() || x == TypeId::of::<TextureFrame>() => {
raster_widget(document_node, node_id, index, name, true).into()
@ -1067,7 +1067,7 @@ pub fn line_join_widget(document_node: &DocumentNode, node_id: NodeId, index: us
LayoutGroup::Row { widgets }
}
pub fn color_widget(document_node: &DocumentNode, node_id: NodeId, index: usize, name: &str, color_button: ColorButton, blank_assist: bool) -> LayoutGroup {
pub fn color_widget(document_node: &DocumentNode, node_id: NodeId, index: usize, name: &str, color_button: ColorInput, blank_assist: bool) -> LayoutGroup {
let mut widgets = start_widgets(document_node, node_id, index, name, FrontendGraphDataType::General, blank_assist);
// Return early with just the label if the input is exposed to the graph, meaning we don't want to show the color picker widget in the Properties panel
@ -1080,7 +1080,7 @@ pub fn color_widget(document_node: &DocumentNode, node_id: NodeId, index: usize,
TaggedValue::Color(color) => widgets.push(
color_button
.value(FillChoice::Solid(*color))
.on_update(update_value(|x: &ColorButton| TaggedValue::Color(x.value.as_solid().unwrap_or_default()), node_id, index))
.on_update(update_value(|x: &ColorInput| TaggedValue::Color(x.value.as_solid().unwrap_or_default()), node_id, index))
.on_commit(commit_value)
.widget_holder(),
),
@ -1090,7 +1090,7 @@ pub fn color_widget(document_node: &DocumentNode, node_id: NodeId, index: usize,
Some(color) => FillChoice::Solid(*color),
None => FillChoice::None,
})
.on_update(update_value(|x: &ColorButton| TaggedValue::OptionalColor(x.value.as_solid()), node_id, index))
.on_update(update_value(|x: &ColorInput| TaggedValue::OptionalColor(x.value.as_solid()), node_id, index))
.on_commit(commit_value)
.widget_holder(),
),
@ -1098,7 +1098,7 @@ pub fn color_widget(document_node: &DocumentNode, node_id: NodeId, index: usize,
color_button
.value(FillChoice::Gradient(x.clone()))
.on_update(update_value(
|x: &ColorButton| TaggedValue::GradientStops(x.value.as_gradient().cloned().unwrap_or_default()),
|x: &ColorInput| TaggedValue::GradientStops(x.value.as_gradient().cloned().unwrap_or_default()),
node_id,
index,
))
@ -1776,7 +1776,7 @@ pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodeProperties
// if let Some(&TaggedValue::F64(seed)) = &input.as_non_exposed_value() {
// widgets.extend_from_slice(&[
// Separator::new(SeparatorType::Unrelated).widget_holder(),
// IconButton::new("Regenerate", 24)
// IconButton::new("Resync", 24)
// .tooltip("Set a new random seed")
// .on_update({
// let imaginate_node = imaginate_node.clone();
@ -1856,7 +1856,7 @@ pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodeProperties
// widgets.extend_from_slice(&[
// Separator::new(SeparatorType::Unrelated).widget_holder(),
// IconButton::new("Rescale", 24)
// IconButton::new("FrameAll", 24)
// .tooltip("Set the layer dimensions to this resolution")
// .on_update(move |_| DialogMessage::RequestComingSoonDialog { issue: None }.into())
// .widget_holder(),
@ -2211,9 +2211,9 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
widgets_first_row.push(Separator::new(SeparatorType::Unrelated).widget_holder());
widgets_first_row.push(
ColorButton::default()
ColorInput::default()
.value(fill.clone().into())
.on_update(move |x: &ColorButton| {
.on_update(move |x: &ColorInput| {
Message::Batched(Box::new([
match &fill2 {
Fill::None => NodeGraphMessage::SetInputValue {
@ -2380,7 +2380,7 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) -
let line_join_index = 6;
let miter_limit_index = 7;
let color = color_widget(document_node, node_id, color_index, "Color", ColorButton::default(), true);
let color = color_widget(document_node, node_id, color_index, "Color", ColorInput::default(), true);
let weight = number_widget(document_node, node_id, weight_index, "Weight", NumberInput::default().unit(" px").min(0.), true);
let dash_lengths_val = match &document_node.inputs[dash_lengths_index].as_value() {

View File

@ -229,7 +229,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {
})
};
let update_color = |grid, update: fn(&mut GridSnapping) -> Option<&mut Color>| {
update_val::<ColorButton>(grid, move |grid, color| {
update_val::<ColorInput>(grid, move |grid, color| {
if let FillChoice::Solid(color) = color.value {
if let Some(update_color) = update(grid) {
*update_color = color;
@ -280,7 +280,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {
Separator::new(SeparatorType::Related).widget_holder(),
]);
color_widgets.push(
ColorButton::new(FillChoice::Solid(grid.grid_color))
ColorInput::new(FillChoice::Solid(grid.grid_color))
.tooltip("Grid display color")
.allow_none(false)
.on_update(update_color(grid, |grid| Some(&mut grid.grid_color)))

View File

@ -79,7 +79,7 @@ impl ToolColorOptions {
color_allow_none: bool,
reset_callback: impl Fn(&IconButton) -> Message + 'static + Send + Sync,
radio_callback: fn(ToolColorType) -> WidgetCallback<()>,
color_callback: impl Fn(&ColorButton) -> Message + 'static + Send + Sync,
color_callback: impl Fn(&ColorInput) -> Message + 'static + Send + Sync,
) -> Vec<WidgetHolder> {
let mut widgets = vec![TextLabel::new(label_text).widget_holder()];
@ -112,7 +112,7 @@ impl ToolColorOptions {
widgets.push(radio);
widgets.push(Separator::new(SeparatorType::Related).widget_holder());
let color_button = ColorButton::new(FillChoice::from_optional_color(self.active_color()))
let color_button = ColorInput::new(FillChoice::from_optional_color(self.active_color()))
.allow_none(color_allow_none)
.on_update(color_callback);
widgets.push(color_button.widget_holder());

View File

@ -156,7 +156,7 @@ impl LayoutHolder for BrushTool {
false,
|_| BrushToolMessage::UpdateOptions(BrushToolMessageOptionsUpdate::Color(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| BrushToolMessage::UpdateOptions(BrushToolMessageOptionsUpdate::ColorType(color_type.clone())).into()),
|color: &ColorButton| BrushToolMessage::UpdateOptions(BrushToolMessageOptionsUpdate::Color(color.value.as_solid())).into(),
|color: &ColorInput| BrushToolMessage::UpdateOptions(BrushToolMessageOptionsUpdate::Color(color.value.as_solid())).into(),
));
widgets.push(Separator::new(SeparatorType::Related).widget_holder());

View File

@ -91,7 +91,7 @@ impl LayoutHolder for EllipseTool {
true,
|_| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::FillColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::FillColorType(color_type.clone())).into()),
|color: &ColorButton| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::FillColor(color.value.as_solid())).into(),
|color: &ColorInput| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::FillColor(color.value.as_solid())).into(),
);
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
@ -101,7 +101,7 @@ impl LayoutHolder for EllipseTool {
true,
|_| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::StrokeColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::StrokeColorType(color_type.clone())).into()),
|color: &ColorButton| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
|color: &ColorInput| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
));
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
widgets.push(create_weight_widget(self.options.line_weight));

View File

@ -99,7 +99,7 @@ impl LayoutHolder for FreehandTool {
true,
|_| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::FillColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::FillColorType(color_type.clone())).into()),
|color: &ColorButton| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::FillColor(color.value.as_solid())).into(),
|color: &ColorInput| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::FillColor(color.value.as_solid())).into(),
);
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
@ -109,7 +109,7 @@ impl LayoutHolder for FreehandTool {
true,
|_| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::StrokeColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::StrokeColorType(color_type.clone())).into()),
|color: &ColorButton| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
|color: &ColorInput| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
));
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
widgets.push(create_weight_widget(self.options.line_weight));

View File

@ -85,7 +85,7 @@ impl LayoutHolder for LineTool {
true,
|_| LineToolMessage::UpdateOptions(LineOptionsUpdate::StrokeColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| LineToolMessage::UpdateOptions(LineOptionsUpdate::StrokeColorType(color_type.clone())).into()),
|color: &ColorButton| LineToolMessage::UpdateOptions(LineOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
|color: &ColorInput| LineToolMessage::UpdateOptions(LineOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
);
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
widgets.push(create_weight_widget(self.options.line_weight));

View File

@ -114,7 +114,7 @@ impl LayoutHolder for PenTool {
true,
|_| PenToolMessage::UpdateOptions(PenOptionsUpdate::FillColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| PenToolMessage::UpdateOptions(PenOptionsUpdate::FillColorType(color_type.clone())).into()),
|color: &ColorButton| PenToolMessage::UpdateOptions(PenOptionsUpdate::FillColor(color.value.as_solid())).into(),
|color: &ColorInput| PenToolMessage::UpdateOptions(PenOptionsUpdate::FillColor(color.value.as_solid())).into(),
);
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
@ -124,7 +124,7 @@ impl LayoutHolder for PenTool {
true,
|_| PenToolMessage::UpdateOptions(PenOptionsUpdate::StrokeColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| PenToolMessage::UpdateOptions(PenOptionsUpdate::StrokeColorType(color_type.clone())).into()),
|color: &ColorButton| PenToolMessage::UpdateOptions(PenOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
|color: &ColorInput| PenToolMessage::UpdateOptions(PenOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
));
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
widgets.push(create_weight_widget(self.options.line_weight));

View File

@ -135,7 +135,7 @@ impl LayoutHolder for PolygonTool {
true,
|_| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::FillColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::FillColorType(color_type.clone())).into()),
|color: &ColorButton| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::FillColor(color.value.as_solid())).into(),
|color: &ColorInput| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::FillColor(color.value.as_solid())).into(),
));
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
@ -145,7 +145,7 @@ impl LayoutHolder for PolygonTool {
true,
|_| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::StrokeColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::StrokeColorType(color_type.clone())).into()),
|color: &ColorButton| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
|color: &ColorInput| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
));
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
widgets.push(create_weight_widget(self.options.line_weight));

View File

@ -77,7 +77,7 @@ impl LayoutHolder for RectangleTool {
true,
|_| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::FillColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::FillColorType(color_type.clone())).into()),
|color: &ColorButton| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::FillColor(color.value.as_solid())).into(),
|color: &ColorInput| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::FillColor(color.value.as_solid())).into(),
);
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
@ -87,7 +87,7 @@ impl LayoutHolder for RectangleTool {
true,
|_| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::StrokeColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::StrokeColorType(color_type.clone())).into()),
|color: &ColorButton| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
|color: &ColorInput| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
));
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
widgets.push(create_weight_widget(self.options.line_weight));

View File

@ -102,7 +102,7 @@ impl LayoutHolder for SplineTool {
true,
|_| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::FillColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::FillColorType(color_type.clone())).into()),
|color: &ColorButton| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::FillColor(color.value.as_solid())).into(),
|color: &ColorInput| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::FillColor(color.value.as_solid())).into(),
);
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
@ -112,7 +112,7 @@ impl LayoutHolder for SplineTool {
true,
|_| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::StrokeColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::StrokeColorType(color_type.clone())).into()),
|color: &ColorButton| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
|color: &ColorInput| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
));
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
widgets.push(create_weight_widget(self.options.line_weight));

View File

@ -160,7 +160,7 @@ impl LayoutHolder for TextTool {
true,
|_| TextToolMessage::UpdateOptions(TextOptionsUpdate::FillColor(None)).into(),
|color_type: ToolColorType| WidgetCallback::new(move |_| TextToolMessage::UpdateOptions(TextOptionsUpdate::FillColorType(color_type.clone())).into()),
|color: &ColorButton| TextToolMessage::UpdateOptions(TextOptionsUpdate::FillColor(color.value.as_solid())).into(),
|color: &ColorInput| TextToolMessage::UpdateOptions(TextOptionsUpdate::FillColor(color.value.as_solid())).into(),
));
Layout::WidgetLayout(WidgetLayout::new(vec![LayoutGroup::Row { widgets }]))

View File

@ -138,6 +138,7 @@
linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(#ffffff, #ffffff);
--color-transparent-checkered-background-size: 16px 16px, 16px 16px, 16px 16px;
--color-transparent-checkered-background-position: 0 0, 8px 8px, 8px 8px;
--color-transparent-checkered-background-position-plus-one: 1px 1px, 9px 9px, 9px 9px;
--color-transparent-checkered-background-size-mini: 8px 8px, 8px 8px, 8px 8px;
--color-transparent-checkered-background-position-mini: 0 0, 4px 4px, 4px 4px;
--color-transparent-checkered-background-repeat: repeat, repeat, repeat;

View File

@ -3,10 +3,11 @@
import type { Editor } from "@graphite/editor";
import type { HSV, RGB, FillChoice } from "@graphite/messages";
import { Color, Gradient } from "@graphite/messages";
import { Color, contrastingOutlineFactor, Gradient } from "@graphite/messages";
import { clamp } from "@graphite/utility-functions/math";
import FloatingMenu, { type MenuDirection } from "@graphite/components/layout/FloatingMenu.svelte";
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte";
@ -65,7 +66,19 @@
// Transient state
let draggingPickerTrack: HTMLDivElement | undefined = undefined;
let strayCloses = true;
let gradientSpectrumDragging = false;
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;
let self: FloatingMenu | undefined;
let hexCodeInputWidget: TextInput | undefined;
let gradientSpectrumInputWidget: SpectrumInput | undefined;
@ -77,6 +90,9 @@
$: rgbChannels = Object.entries(newColor.toRgb255() || { r: undefined, g: undefined, b: undefined }) as [keyof RGB, number | undefined][];
$: hsvChannels = Object.entries(!isNone ? { h: hue * 360, s: saturation * 100, v: value * 100 } : { h: undefined, s: undefined, v: undefined }) as [keyof HSV, number | undefined][];
$: opaqueHueColor = new Color({ h: hue, s: 1, v: 1, a: 1 });
$: outlineFactor = Math.max(contrastingOutlineFactor(newColor, "--color-2-mildblack", 0.01), contrastingOutlineFactor(oldColor, "--color-2-mildblack", 0.01));
$: outlined = outlineFactor > 0.0001;
$: transparency = newColor.alpha < 1 || oldColor.alpha < 1;
function generateColor(h: number, s: number, v: number, a: number, none: boolean) {
if (none) return new Color("none");
@ -119,6 +135,14 @@
const target = (e.target || undefined) as HTMLElement | 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;
addEvents();
onPointerMove(e);
@ -134,6 +158,8 @@
saturation = clamp((e.clientX - rectangle.left) / rectangle.width, 0, 1);
value = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
strayCloses = false;
if (shiftPressed) updateAxisLock();
} else if (draggingPickerTrack?.hasAttribute("data-hue-picker")) {
const rectangle = draggingPickerTrack.getBoundingClientRect();
@ -148,25 +174,102 @@
const color = new Color({ h: hue, s: saturation, v: value, a: alpha });
setColor(color);
if (!e.shiftKey) {
shiftPressed = false;
alignedAxis = undefined;
} else if (!shiftPressed && draggingPickerTrack) {
shiftPressed = true;
saturationStartOfAxisAlign = saturation;
valueStartOfAxisAlign = value;
}
}
function onPointerUp() {
removeEvents();
}
function onMouseDown(e: MouseEvent) {
const BUTTONS_RIGHT = 0b0000_0010;
if (e.buttons & BUTTONS_RIGHT) abortDrag();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
const element = self?.div();
if (element) preventEscapeClosingParentFloatingMenu(element);
abortDrag();
}
}
function onKeyUp(e: KeyboardEvent) {
if (e.key === "Shift") {
shiftPressed = false;
alignedAxis = undefined;
if (saturationRestoreWhenShiftReleased !== undefined && valueRestoreWhenShiftReleased !== undefined) {
saturation = saturationRestoreWhenShiftReleased;
value = valueRestoreWhenShiftReleased;
const color = new Color({ h: hue, s: saturation, v: value, a: alpha });
setColor(color);
}
}
}
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;
strayCloses = true;
// The setTimeout is necessary to prevent the FloatingMenu's `escapeCloses` from becoming true immediately upon pressing the Escape key, and thus closing
setTimeout(() => (strayCloses = true), 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 updateAxisLock() {
if (!saturationStartOfAxisAlign || !valueStartOfAxisAlign) return;
const deltaSaturation = saturation - saturationStartOfAxisAlign;
const deltaValue = value - valueStartOfAxisAlign;
saturationRestoreWhenShiftReleased = saturation;
valueRestoreWhenShiftReleased = value;
if (Math.abs(deltaSaturation) < Math.abs(deltaValue)) {
alignedAxis = "saturation";
saturation = saturationStartOfAxisAlign;
} else {
alignedAxis = "value";
value = valueStartOfAxisAlign;
}
}
function abortDrag() {
removeEvents();
hue = hueBeforeDrag;
saturation = saturationBeforeDrag;
value = valueBeforeDrag;
alpha = alphaBeforeDrag;
const color = new Color({ h: hue, s: saturation, v: value, a: alpha });
setColor(color);
}
function setColor(color?: Color) {
@ -299,7 +402,7 @@
});
</script>
<FloatingMenu class="color-picker" {open} on:open {strayCloses} {direction} type="Popover">
<FloatingMenu class="color-picker" {open} on:open {strayCloses} escapeCloses={strayCloses && !gradientSpectrumDragging} {direction} type="Popover" bind:this={self}>
<LayoutRow
styles={{
"--new-color": newColor.toHexOptionalAlpha(),
@ -318,6 +421,15 @@
{#if !isNone}
<div class="selection-circle" style:top={`${(1 - value) * 100}%`} style:left={`${saturation * 100}%`} />
{/if}
{#if alignedAxis}
<div
class="selection-circle-alignment"
class:saturation={alignedAxis === "saturation"}
class:value={alignedAxis === "value"}
style:top={`${(1 - value) * 100}%`}
style:left={`${saturation * 100}%`}
/>
{/if}
</LayoutCol>
<LayoutCol class="hue-picker" on:pointerdown={onPointerDown} data-hue-picker>
{#if !isNone}
@ -340,6 +452,7 @@
}}
on:activeMarkerIndexChange={gradientActiveMarkerIndexChange}
activeMarkerIndex={activeIndex}
on:dragging={({ detail }) => (gradientSpectrumDragging = detail)}
bind:this={gradientSpectrumInputWidget}
/>
{#if gradientSpectrumInputWidget && activeIndex !== undefined}
@ -360,6 +473,8 @@
<LayoutCol class="details">
<LayoutRow
class="choice-preview"
classes={{ outlined, transparency }}
styles={{ "--outline-amount": outlineFactor }}
tooltip={!newColor.equals(oldColor) ? "Comparison between the present color choice (left) and the color before any change was made (right)" : "The present color choice"}
>
{#if !newColor.equals(oldColor)}
@ -453,7 +568,7 @@
</LayoutRow>
</LayoutRow>
<LayoutRow>
<TextLabel tooltip="Scale from transparent (0%) to opaque (100%) for the color's alpha channel">Alpha</TextLabel>
<TextLabel tooltip="Scale of translucency, from transparent (0%) to opaque (100%), for the color's alpha channel">Alpha</TextLabel>
<Separator type="Related" />
<NumberInput
value={!isNone ? alpha * 100 : undefined}
@ -471,18 +586,18 @@
unit="%"
mode="Range"
displayDecimalPlaces={1}
tooltip={`Scale from transparent (0%) to opaque (100%) for the color's alpha channel`}
tooltip={`Scale of translucency, from transparent (0%) to opaque (100%), for the color's alpha channel`}
/>
</LayoutRow>
<LayoutRow class="leftover-space" />
<LayoutRow>
{#if allowNone && !gradient}
<button class="preset-color none" on:click={() => setColorPreset("none")} title="Set to no color" tabindex="0" />
<button class="preset-color none" on:click={() => setColorPreset("none")} title="Set to no color" tabindex="0"></button>
<Separator type="Related" />
{/if}
<button class="preset-color black" on:click={() => setColorPreset("black")} title="Set to black" tabindex="0" />
<button class="preset-color black" on:click={() => setColorPreset("black")} title="Set to black" tabindex="0"></button>
<Separator type="Related" />
<button class="preset-color white" on:click={() => setColorPreset("white")} title="Set to white" tabindex="0" />
<button class="preset-color white" on:click={() => setColorPreset("white")} title="Set to white" tabindex="0"></button>
<Separator type="Related" />
<button class="preset-color pure" on:click={setColorPresetSubtile} tabindex="-1">
<div data-pure-tile="red" style="--pure-color: #ff0000; --pure-color-gray: #4c4c4c" title="Set to red" />
@ -501,10 +616,13 @@
<style lang="scss" global>
.color-picker {
--picker-size: 256px;
--picker-circle-radius: 6px;
.pickers-and-gradient {
.pickers {
.saturation-value-picker {
width: 256px;
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;
@ -513,7 +631,7 @@
.saturation-value-picker,
.hue-picker,
.alpha-picker {
height: 256px;
height: var(--picker-size);
border-radius: 2px;
position: relative;
overflow: hidden;
@ -552,8 +670,8 @@
.selection-circle {
position: absolute;
left: 0%;
top: 0%;
left: 0;
top: 0;
width: 0;
height: 0;
pointer-events: none;
@ -562,19 +680,59 @@
content: "";
display: block;
position: relative;
left: -6px;
top: -6px;
width: 12px;
height: 12px;
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);
box-sizing: border-box;
}
}
.selection-circle-alignment {
position: absolute;
pointer-events: none;
&.saturation::before,
&.saturation::after,
&.value::before,
&.value::after {
content: "";
position: absolute;
background: var(--opaque-color-contrasting);
width: 1px;
height: 1px;
}
&.saturation {
&::before {
height: var(--picker-size);
margin-top: calc(-1 * var(--picker-size) - var(--picker-circle-radius));
}
&::after {
height: var(--picker-size);
margin-top: var(--picker-circle-radius);
}
}
&.value {
&::before {
width: var(--picker-size);
margin-left: var(--picker-circle-radius);
}
&::after {
width: var(--picker-size);
margin-left: calc(-1 * var(--picker-size) - var(--picker-circle-radius));
}
}
}
.selection-needle {
position: absolute;
top: 0%;
top: 0;
width: 100%;
height: 0;
pointer-events: none;
@ -642,14 +800,27 @@
width: 100%;
height: 32px;
border-radius: 2px;
border: 1px solid var(--color-1-nearblack);
box-sizing: border-box;
overflow: hidden;
position: relative;
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);
&.outlined::after {
content: "";
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));
pointer-events: none;
}
&.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;

View File

@ -1,6 +1,22 @@
<script lang="ts" context="module">
export type MenuDirection = "Top" | "Bottom" | "Left" | "Right" | "TopLeft" | "TopRight" | "BottomLeft" | "BottomRight" | "Center";
export type MenuType = "Popover" | "Dropdown" | "Dialog" | "Cursor";
/// Prevents the escape key from closing the parent floating menu of the given element.
/// This works by momentarily setting the `data-escape-does-not-close` attribute on the parent floating menu element.
/// After checking for the Escape key, it checks (in one `setTimeout`) for the attribute and ignores the key if it's present.
/// Then after two calls of `setTimeout`, we can safely remove the attribute here.
export function preventEscapeClosingParentFloatingMenu(element: HTMLElement) {
const floatingMenuParent = element.closest("[data-floating-menu-content]") || undefined;
if (floatingMenuParent instanceof HTMLElement) {
floatingMenuParent.setAttribute("data-escape-does-not-close", "");
setTimeout(() => {
setTimeout(() => {
floatingMenuParent.removeAttribute("data-escape-does-not-close");
}, 0);
}, 0);
}
}
</script>
<script lang="ts">
@ -10,6 +26,7 @@
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
const BUTTON_LEFT = 0;
const POINTER_STRAY_DISTANCE = 100;
const dispatch = createEventDispatcher<{ open: boolean; naturalWidth: number }>();
@ -302,7 +319,8 @@
}
// Clean up any messes from lost pointerup events
const eventIncludesLmb = Boolean(e.buttons & 1);
const BUTTONS_LEFT = 0b0000_0001;
const eventIncludesLmb = Boolean(e.buttons & BUTTONS_LEFT);
if (!open && !eventIncludesLmb) {
pointerStillDown = false;
window.removeEventListener("pointerup", pointerUpHandler);
@ -377,8 +395,15 @@
}
function keyDownHandler(e: KeyboardEvent) {
if (escapeCloses && e.key.toLowerCase() === "escape") {
dispatch("open", false);
if (escapeCloses && e.key === "Escape") {
setTimeout(() => {
if (!floatingMenuContainer?.querySelector("[data-floating-menu-content][data-escape-does-not-close]")) {
dispatch("open", false);
}
}, 0);
// Find the parent floating menu and prevent it from also closing with the escape key when this floating menu does
if (self) preventEscapeClosingParentFloatingMenu(self);
}
}
@ -388,13 +413,13 @@
dispatch("open", false);
// Track if the left pointer button is now down so its later click event can be canceled
const eventIsForLmb = e.button === 0;
const eventIsForLmb = e.button === BUTTON_LEFT;
if (eventIsForLmb) pointerStillDown = true;
}
}
function pointerUpHandler(e: PointerEvent) {
const eventIsForLmb = e.button === 0;
const eventIsForLmb = e.button === BUTTON_LEFT;
if (pointerStillDown && eventIsForLmb) {
// Clean up self
pointerStillDown = false;

View File

@ -169,16 +169,6 @@
editor.handle.panCanvas(0, -delta * scrollbarMultiplier.y);
}
function pageX(delta: number) {
const move = delta < 0 ? 1 : -1;
editor.handle.panCanvasByFraction(move, 0);
}
function pageY(delta: number) {
const move = delta < 0 ? 1 : -1;
editor.handle.panCanvasByFraction(0, move);
}
function canvasPointerDown(e: PointerEvent) {
const onEditbox = e.target instanceof HTMLDivElement && e.target.contentEditable;
@ -549,21 +539,25 @@
<LayoutCol class="ruler-or-scrollbar right-scrollbar">
<ScrollbarInput
direction="Vertical"
handleLength={scrollbarSize.y}
handlePosition={scrollbarPos.y}
on:handlePosition={({ detail }) => panCanvasY(detail)}
on:pressTrack={({ detail }) => pageY(detail)}
thumbLength={scrollbarSize.y}
thumbPosition={scrollbarPos.y}
on:trackShift={({ detail }) => editor.handle.panCanvasByFraction(0, detail)}
on:thumbPosition={({ detail }) => panCanvasY(detail)}
on:thumbDragStart={() => editor.handle.panCanvasAbortPrepare(false)}
on:thumbDragAbort={() => editor.handle.panCanvasAbort(false)}
/>
</LayoutCol>
</LayoutRow>
<LayoutRow class="ruler-or-scrollbar bottom-scrollbar">
<ScrollbarInput
direction="Horizontal"
handleLength={scrollbarSize.x}
handlePosition={scrollbarPos.x}
on:handlePosition={({ detail }) => panCanvasX(detail)}
on:pressTrack={({ detail }) => pageX(detail)}
on:pointerup={() => editor.handle.setGridAlignedEdges()}
thumbLength={scrollbarSize.x}
thumbPosition={scrollbarPos.x}
on:trackShift={({ detail }) => editor.handle.panCanvasByFraction(detail, 0)}
on:thumbPosition={({ detail }) => panCanvasX(detail)}
on:thumbDragEnd={() => editor.handle.setGridAlignedEdges()}
on:thumbDragStart={() => editor.handle.panCanvasAbortPrepare(true)}
on:thumbDragAbort={() => editor.handle.panCanvasAbort(true)}
/>
</LayoutRow>
</LayoutCol>

View File

@ -435,7 +435,7 @@
title={listing.entry.expanded ? "Collapse" : `Expand${listing.entry.ancestorOfSelected ? "\n(A selected layer is contained within)" : ""}`}
on:click|stopPropagation={() => handleExpandArrowClick(listing.entry.id)}
tabindex="0"
/>
></button>
{/if}
<div class="thumbnail">
{#if $nodeGraph.thumbnails.has(listing.entry.id)}

View File

@ -8,13 +8,13 @@
import NodeCatalog from "@graphite/components/floating-menus/NodeCatalog.svelte";
import BreadcrumbTrailButtons from "@graphite/components/widgets/buttons/BreadcrumbTrailButtons.svelte";
import ColorButton from "@graphite/components/widgets/buttons/ColorButton.svelte";
import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte";
import ImageButton from "@graphite/components/widgets/buttons/ImageButton.svelte";
import ParameterExposeButton from "@graphite/components/widgets/buttons/ParameterExposeButton.svelte";
import PopoverButton from "@graphite/components/widgets/buttons/PopoverButton.svelte";
import TextButton from "@graphite/components/widgets/buttons/TextButton.svelte";
import CheckboxInput from "@graphite/components/widgets/inputs/CheckboxInput.svelte";
import ColorInput from "@graphite/components/widgets/inputs/ColorInput.svelte";
import CurveInput from "@graphite/components/widgets/inputs/CurveInput.svelte";
import DropdownInput from "@graphite/components/widgets/inputs/DropdownInput.svelte";
import FontInput from "@graphite/components/widgets/inputs/FontInput.svelte";
@ -87,9 +87,9 @@
{#if checkboxInput}
<CheckboxInput {...exclude(checkboxInput)} on:checked={({ detail }) => widgetValueCommitAndUpdate(index, detail)} />
{/if}
{@const colorInput = narrowWidgetProps(component.props, "ColorButton")}
{@const colorInput = narrowWidgetProps(component.props, "ColorInput")}
{#if colorInput}
<ColorButton {...exclude(colorInput)} on:value={({ detail }) => widgetValueUpdate(index, detail)} on:startHistoryTransaction={() => widgetValueCommit(index, colorInput.value)} />
<ColorInput {...exclude(colorInput)} on:value={({ detail }) => widgetValueUpdate(index, detail)} on:startHistoryTransaction={() => widgetValueCommit(index, colorInput.value)} />
{/if}
{@const curvesInput = narrowWidgetProps(component.props, "CurveInput")}
{#if curvesInput}

View File

@ -1,129 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import type { FillChoice } from "@graphite/messages";
import { Color, Gradient } from "@graphite/messages";
import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
const dispatch = createEventDispatcher<{ value: FillChoice; startHistoryTransaction: undefined }>();
let open = false;
export let value: FillChoice;
export let disabled = false;
export let allowNone = false;
// export let allowTransparency = false; // TODO: Implement
export let tooltip: string | undefined = undefined;
$: chosenGradient = value instanceof Gradient ? value.toLinearGradientCSS() : `linear-gradient(${value.toHexOptionalAlpha()}, ${value.toHexOptionalAlpha()})`;
</script>
<LayoutCol class="color-button" classes={{ disabled, none: value instanceof Color ? value.none : false, open }} {tooltip}>
<button {disabled} style:--chosen-gradient={chosenGradient} on:click={() => (open = true)} tabindex="0" data-floating-menu-spawner></button>
{#if disabled && value instanceof Color && !value.none}
<TextLabel>sRGB</TextLabel>
{/if}
<ColorPicker
{open}
on:open={({ detail }) => (open = detail)}
colorOrGradient={value}
on:colorOrGradient={({ detail }) => {
value = detail;
dispatch("value", detail);
}}
on:startHistoryTransaction={() => {
// This event is sent to the backend so it knows to start a transaction for the history system. See discussion for some explanation:
// <https://github.com/GraphiteEditor/Graphite/pull/1584#discussion_r1477592483>
dispatch("startHistoryTransaction");
}}
{allowNone}
/>
</LayoutCol>
<style lang="scss" global>
.color-button {
position: relative;
min-width: 80px;
border-radius: 2px;
background: var(--color-5-dullgray);
&:hover,
&.open {
&,
> .text-label {
background: rgba(var(--color-6-lowergray-rgb), 0.5);
}
}
&.disabled {
&,
> .text-label {
background: var(--color-4-dimgray);
color: var(--color-8-uppergray);
}
}
> button {
border: none;
padding: 0;
margin: 0;
margin-left: 2px;
margin-top: 2px;
width: calc(100% - 4px);
height: calc(100% - 4px);
background-image: var(--chosen-gradient), 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);
}
&.none {
> button {
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);
}
&.disabled {
> button::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgba(var(--color-4-dimgray-rgb), 0.5);
}
}
}
> .text-label {
background: rgba(var(--color-5-dullgray-rgb), 0.5);
font-size: 10px;
line-height: 12px;
height: 12px;
border-radius: 6px 0 0 6px;
padding-right: 2px;
padding-left: 4px;
margin: auto;
position: absolute;
right: 0;
top: 0;
bottom: 0;
}
> .floating-menu {
left: 50%;
bottom: 0;
}
}
</style>

View File

@ -0,0 +1,141 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import type { FillChoice } from "@graphite/messages";
import { Color, contrastingOutlineFactor, Gradient } from "@graphite/messages";
import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
const dispatch = createEventDispatcher<{ value: FillChoice; startHistoryTransaction: undefined }>();
let open = false;
export let value: FillChoice;
export let disabled = false;
export let allowNone = false;
// export let allowTransparency = false; // TODO: Implement
export let tooltip: string | undefined = undefined;
$: outlineFactor = contrastingOutlineFactor(value, ["--color-1-nearblack", "--color-3-darkgray"], 0.01);
$: outlined = outlineFactor > 0.0001;
$: chosenGradient = value instanceof Gradient ? value.toLinearGradientCSS() : `linear-gradient(${value.toHexOptionalAlpha()}, ${value.toHexOptionalAlpha()})`;
$: none = value instanceof Color ? value.none : false;
$: transparency = value instanceof Gradient ? value.stops.some((stop) => stop.color.alpha < 1) : value.alpha < 1;
</script>
<LayoutCol class="color-button" classes={{ open, disabled, none, transparency, outlined }} {tooltip}>
<button {disabled} style:--chosen-gradient={chosenGradient} style:--outline-amount={outlineFactor} on:click={() => (open = true)} tabindex="0" data-floating-menu-spawner>
{#if disabled && value instanceof Color && !value.none}
<TextLabel>sRGB</TextLabel>
{/if}
</button>
<ColorPicker
{open}
on:open={({ detail }) => (open = detail)}
colorOrGradient={value}
on:colorOrGradient={({ detail }) => {
value = detail;
dispatch("value", detail);
}}
on:startHistoryTransaction={() => {
// This event is sent to the backend so it knows to start a transaction for the history system. See discussion for some explanation:
// <https://github.com/GraphiteEditor/Graphite/pull/1584#discussion_r1477592483>
dispatch("startHistoryTransaction");
}}
{allowNone}
/>
</LayoutCol>
<style lang="scss" global>
.color-button {
position: relative;
min-width: 80px;
> button {
border: none;
border-radius: 2px;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: var(--chosen-gradient);
}
.text-label {
background: var(--color-5-dullgray);
font-size: 10px;
line-height: 12px;
height: 12px;
border-radius: 0 0 0 2px;
padding-right: 4px;
padding-left: 4px;
position: absolute;
right: 0;
top: 0;
}
}
&.outlined > button::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
box-shadow: inset 0 0 0 1px rgba(var(--color-5-dullgray-rgb), var(--outline-amount));
}
&.transparency > button {
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);
}
&:not(.disabled).none > button {
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);
}
&.disabled.none > button::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: var(--color-4-dimgray);
}
&:not(.disabled):hover > button .text-label,
&:not(.disabled).open > button .text-label {
background: var(--color-6-lowergray);
color: var(--color-f-white);
}
&.disabled > button .text-label {
background: var(--color-4-dimgray);
color: var(--color-8-uppergray);
}
> .floating-menu {
left: 50%;
bottom: 0;
}
}
</style>

View File

@ -3,6 +3,7 @@
import { platformIsMac } from "@graphite/utility-functions/platform";
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
const dispatch = createEventDispatcher<{
@ -65,6 +66,12 @@
export function element(): HTMLInputElement | HTMLTextAreaElement | undefined {
return inputOrTextarea;
}
function cancel() {
dispatch("textChangeCanceled");
if (inputOrTextarea) preventEscapeClosingParentFloatingMenu(inputOrTextarea);
}
</script>
<!-- This is a base component, extended by others like NumberInput and TextInput. It should not be used directly. -->
@ -83,7 +90,7 @@
on:blur={() => dispatch("textChanged")}
on:change={() => dispatch("textChanged")}
on:keydown={(e) => e.key === "Enter" && dispatch("textChanged")}
on:keydown={(e) => e.key === "Escape" && dispatch("textChangeCanceled")}
on:keydown={(e) => e.key === "Escape" && cancel()}
on:pointerdown
on:contextmenu={(e) => hideContextMenu && e.preventDefault()}
data-input-element
@ -102,7 +109,7 @@
on:blur={() => dispatch("textChanged")}
on:change={() => dispatch("textChanged")}
on:keydown={(e) => (macKeyboardLayout ? e.metaKey : e.ctrlKey) && e.key === "Enter" && dispatch("textChanged")}
on:keydown={(e) => e.key === "Escape" && dispatch("textChangeCanceled")}
on:keydown={(e) => e.key === "Escape" && cancel()}
on:pointerdown
on:contextmenu={(e) => hideContextMenu && e.preventDefault()}
/>

View File

@ -1,9 +1,11 @@
<script lang="ts">
import { createEventDispatcher, onMount, onDestroy } from "svelte";
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "@graphite/io-managers/input";
import { type NumberInputMode, type NumberInputIncrementBehavior } from "@graphite/messages";
import { evaluateMathExpression } from "@graphite-frontend/wasm/pkg/graphite_wasm.js";
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
import FieldInput from "@graphite/components/widgets/inputs/FieldInput.svelte";
const BUTTONS_LEFT = 0b0000_0001;
@ -59,8 +61,10 @@
let self: FieldInput | undefined;
let inputRangeElement: HTMLInputElement | undefined;
let text = displayText(value, unit);
let isDragging = false;
let editing = false;
let isDragging = false;
let pressingArrow = false;
let repeatTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
// Stays in sync with a binding to the actual input range slider element.
let rangeSliderValue = value !== undefined ? value : 0;
// Value used to render the position of the fake slider when applicable, and length of the progress colored region to the slider's left.
@ -230,8 +234,8 @@
// INCREMENT MODE: ARROW BUTTONS
// =============================
function onIncrement(direction: "Decrease" | "Increase") {
if (value === undefined) return;
function onIncrementPointerDown(e: PointerEvent, direction: "Decrease" | "Increase") {
if (value === undefined || e.button !== BUTTON_LEFT) return;
const actions: Record<NumberInputIncrementBehavior, () => void> = {
Add: () => {
@ -250,7 +254,41 @@
},
None: () => {},
};
actions[incrementBehavior]();
const sendAction = () => {
if (!pressingArrow) return;
actions[incrementBehavior]();
if (afterInitialDelay) repeatTimeout = setTimeout(sendAction, PRESS_REPEAT_INTERVAL_MS);
afterInitialDelay = true;
};
pressingArrow = true;
initialValueBeforeDragging = value;
let afterInitialDelay = false;
sendAction();
repeatTimeout = setTimeout(sendAction, PRESS_REPEAT_DELAY_MS);
addEventListener("keydown", incrementPressAbort);
}
function onIncrementPointerUp() {
pressingArrow = false;
clearTimeout(repeatTimeout);
}
function onIncrementMouseDown(e: MouseEvent) {
if (e.button === BUTTON_RIGHT) incrementPressAbort();
}
function incrementPressAbort() {
const element = self?.element() || undefined;
if (element) preventEscapeClosingParentFloatingMenu(element);
pressingArrow = false;
clearTimeout(repeatTimeout);
updateValue(initialValueBeforeDragging);
removeEventListener("keydown", onIncrementPointerUp);
}
// =======================================
@ -399,8 +437,8 @@
if (rangeSliderClickDragState === "Aborted") {
// If we've just aborted the drag by right clicking, but the user hasn't yet released the left mouse button, Firefox treats
// some subsequent interactions with the slider (like that right mouse button release, or maybe mouse movement in some cases)
// as input changes to the slider position. Thus, until we leave the "Aborted" state by releasing all mouse buttons,
// we have to set the slider position back to currently intended value to fight against Firefox's attempts to let the user move it.
// as input changes to the slider position. Thus, until we leave the "Aborted" state by releasing all mouse buttons, we have
// to set the slider position back to the currently intended value to fight against Firefox's attempts to let the user move it.
updateValue(rangeSliderValueAsRendered);
// Now we exit early because we're ignoring further user input until the user releases all mouse buttons, which gets us back to the "Ready" state.
@ -511,10 +549,7 @@
// Logic for aborting from pressing Escape.
if (e instanceof KeyboardEvent) {
// Detect if the user pressed Escape and abort the slider drag.
if (e.key === "Escape") {
// Call the abort helper function.
sliderAbort();
}
if (e.key === "Escape") sliderAbort(true);
}
// Logic for aborting from a right click.
@ -522,7 +557,7 @@
// This handler's "pointermove" event will be fired upon right click even if the cursor didn't move, which is why it's okay to check in this event handler.
if (e instanceof PointerEvent && e.buttons & BUTTONS_RIGHT) {
// Call the abort helper function
sliderAbort();
sliderAbort(false);
}
// Recovery from the window losing focus while dragging the slider.
@ -543,9 +578,11 @@
// During this momentary step, the slider hasn't moved yet but we want to allow aborting from this limbo state.
function sliderAbortFromMousedown(e: MouseEvent | KeyboardEvent) {
// Logic for aborting from a right click or pressing Escape.
if ((e instanceof KeyboardEvent && e.key === "Escape") || (e instanceof MouseEvent && e.button === BUTTON_RIGHT)) {
const abortWithEscape = e instanceof KeyboardEvent && e.key === "Escape";
const abortWithRightClick = e instanceof MouseEvent && e.button === BUTTON_RIGHT;
if (abortWithEscape || abortWithRightClick) {
// Call the abort helper function
sliderAbort();
sliderAbort(abortWithEscape);
// Clean up these event listeners because they were for getting us into this function and now we're done with them.
removeEventListener("mousedown", sliderAbortFromMousedown);
@ -554,7 +591,10 @@
}
// Helper function that performs the state management and cleanup for aborting the slider drag.
function sliderAbort() {
function sliderAbort(abortWithEscape: boolean) {
const element = self?.element() || undefined;
if (abortWithEscape && element) preventEscapeClosingParentFloatingMenu(element);
// End the user's drag by instantaneously disabling and re-enabling the range input element
if (inputRangeElement) inputRangeElement.disabled = true;
setTimeout(() => {
@ -580,9 +620,7 @@
// dragging the slider, hitting Escape, then releasing the mouse button. This results in being transferred by `onSliderInput()` to the
// "Deciding" state when we should remain in the "Ready" state as set here. (For debugging, this can be visualized in CSS by
// recoloring the fake slider handle, which is shown in the "Deciding" state.)
setTimeout(() => {
rangeSliderClickDragState = "Ready";
}, 0);
setTimeout(() => (rangeSliderClickDragState = "Ready"), 0);
// Clean up the event listener that was used to call this function.
removeEventListener("pointerup", sliderResetAbort);
@ -617,8 +655,22 @@
>
{#if value !== undefined}
{#if mode === "Increment" && incrementBehavior !== "None"}
<button class="arrow left" on:click={() => onIncrement("Decrease")} tabindex="-1" />
<button class="arrow right" on:click={() => onIncrement("Increase")} tabindex="-1" />
<button
class="arrow left"
on:pointerdown={(e) => onIncrementPointerDown(e, "Decrease")}
on:mousedown={onIncrementMouseDown}
on:pointerup={onIncrementPointerUp}
on:pointerleave={onIncrementPointerUp}
tabindex="-1"
></button>
<button
class="arrow right"
on:pointerdown={(e) => onIncrementPointerDown(e, "Increase")}
on:mousedown={onIncrementMouseDown}
on:pointerup={onIncrementPointerUp}
on:pointerleave={onIncrementPointerUp}
tabindex="-1"
></button>
{/if}
{#if mode === "Range"}
<input
@ -684,6 +736,8 @@
border: none;
border-radius: 2px;
background: rgba(var(--color-1-nearblack-rgb), 0.5);
// An outline can appear when pressing the arrow button with left click then hitting Escape, so this stops that from showing
outline: none;
&:hover {
background: var(--color-4-dimgray);

View File

@ -3,38 +3,46 @@
</script>
<script lang="ts">
import { createEventDispatcher, onMount, onDestroy } from "svelte";
import { createEventDispatcher } from "svelte";
// Linear Interpolation
const lerp = (x: number, y: number, a: number): number => x * (1 - a) + y * a;
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS, PRESS_REPEAT_INTERVAL_RAPID_MS } from "@graphite/io-managers/input";
// Convert the position of the handle (0-1) to the position on the track (0-1).
// This includes the 1/2 handle length gap of the possible handle positionson each side so the end of the handle doesn't go off the track.
const handleToTrack = (handleLen: number, handlePos: number): number => lerp(handleLen / 2, 1 - handleLen / 2, handlePos);
const ARROW_CLICK_DISTANCE = 0.05;
const ARROW_REPEAT_DISTANCE = 0.01;
const pointerPosition = (direction: ScrollbarDirection, e: PointerEvent): number => (direction === "Vertical" ? e.clientY : e.clientX);
// Convert the position of the thumb (0-1) to the position on the track (0-1).
// This includes the 1/2 thumb length gap of the possible thumb position each side so the end of the thumb doesn't go off the track.
const lerp = (a: number, b: number, t: number): number => a * (1 - t) + b * t;
const thumbToTrack = (thumbLength: number, thumbPosition: number): number => lerp(thumbLength / 2, 1 - thumbLength / 2, thumbPosition);
const dispatch = createEventDispatcher<{ handlePosition: number; pressTrack: number; pointerup: undefined }>();
const pointerPosition = (e: PointerEvent): number => (direction === "Vertical" ? e.clientY : e.clientX);
const clamp01 = (value: number): number => Math.min(Math.max(value, 0), 1);
const dispatch = createEventDispatcher<{ trackShift: number; thumbPosition: number; thumbDragStart: undefined; thumbDragEnd: undefined; thumbDragAbort: undefined }>();
export let direction: ScrollbarDirection = "Vertical";
export let handlePosition = 0.5;
export let handleLength = 0.5;
export let thumbPosition = 0.5;
export let thumbLength = 0.5;
let scrollTrack: HTMLDivElement | undefined;
let dragging = false;
let pointerPos = 0;
let pressingTrack = false;
let pressingArrow = false;
let repeatTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
let pointerPositionLastFrame = 0;
let thumbTop: string | undefined = undefined;
let thumbBottom: string | undefined = undefined;
let thumbLeft: string | undefined = undefined;
let thumbRight: string | undefined = undefined;
$: start = handleToTrack(handleLength, handlePosition) - handleLength / 2;
$: end = 1 - handleToTrack(handleLength, handlePosition) - handleLength / 2;
$: start = thumbToTrack(thumbLength, thumbPosition) - thumbLength / 2;
$: end = 1 - thumbToTrack(thumbLength, thumbPosition) - thumbLength / 2;
$: [thumbTop, thumbBottom, thumbLeft, thumbRight] = direction === "Vertical" ? [`${start * 100}%`, `${end * 100}%`, "0%", "0%"] : ["0%", "0%", `${start * 100}%`, `${end * 100}%`];
function trackLength(): number | undefined {
if (scrollTrack === undefined) return undefined;
return direction === "Vertical" ? scrollTrack.clientHeight - handleLength : scrollTrack.clientWidth;
return direction === "Vertical" ? scrollTrack.clientHeight - thumbLength : scrollTrack.clientWidth;
}
function trackOffset(): number | undefined {
@ -42,72 +50,168 @@
return direction === "Vertical" ? scrollTrack.getBoundingClientRect().top : scrollTrack.getBoundingClientRect().left;
}
function clampHandlePosition(newPos: number) {
const clampedPosition = Math.min(Math.max(newPos, 0), 1);
dispatch("handlePosition", clampedPosition);
function dragThumb(e: PointerEvent) {
if (dragging) return;
dragging = true;
dispatch("thumbDragStart");
pointerPositionLastFrame = pointerPosition(e);
addEvents();
}
function updateHandlePosition(e: PointerEvent) {
function pressArrow(direction: number) {
const sendMove = () => {
if (!pressingArrow) return;
const distance = afterInitialDelay ? ARROW_REPEAT_DISTANCE : ARROW_CLICK_DISTANCE;
dispatch("trackShift", -direction * distance);
if (afterInitialDelay) repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_INTERVAL_RAPID_MS);
afterInitialDelay = true;
};
pressingArrow = true;
dispatch("thumbDragStart");
let afterInitialDelay = false;
sendMove();
repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_DELAY_MS);
addEvents();
}
function pressTrack(e: PointerEvent) {
if (dragging) return;
const length = trackLength();
if (length === undefined) return;
const offset = trackOffset();
if (length === undefined || offset === undefined) return;
const position = pointerPosition(direction, e);
const sendMove = () => {
if (!pressingTrack) return;
clampHandlePosition(handlePosition + (position - pointerPos) / (length * (1 - handleLength)));
pointerPos = position;
const oldPointer = thumbToTrack(thumbLength, thumbPosition) * length + offset;
const newPointer = pointerPosition(e);
// Check if the thumb has reached the cursor position
const proposedThumbPosition = (newPointer - offset) / length;
if (proposedThumbPosition >= start && proposedThumbPosition <= 1 - end) {
// End pressing the track
pressingTrack = false;
clearTimeout(repeatTimeout);
// Begin dragging the thumb
dragging = true;
pointerPositionLastFrame = newPointer;
return;
}
const move = newPointer - oldPointer < 0 ? 1 : -1;
dispatch("trackShift", move);
if (afterInitialDelay) repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_INTERVAL_MS);
afterInitialDelay = true;
};
dispatch("thumbDragStart");
pressingTrack = true;
let afterInitialDelay = false;
sendMove();
repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_DELAY_MS);
addEvents();
}
function grabHandle(e: PointerEvent) {
if (!dragging) {
dragging = true;
pointerPos = pointerPosition(direction, e);
function abortInteraction() {
if (pressingTrack || pressingArrow) {
pressingTrack = false;
pressingArrow = false;
clearTimeout(repeatTimeout);
dispatch("thumbDragAbort");
}
if (dragging) {
dragging = false;
dispatch("thumbDragAbort");
}
}
function grabArea(e: PointerEvent) {
if (!dragging) {
const length = trackLength();
const offset = trackOffset();
if (length === undefined || offset === undefined) return;
function onPointerUp() {
if (dragging) dispatch("thumbDragEnd");
const oldPointer = handleToTrack(handleLength, handlePosition) * length + offset;
const pointerPos = pointerPosition(direction, e);
dispatch("pressTrack", pointerPos - oldPointer);
}
}
function pointerUp() {
dragging = false;
pressingTrack = false;
pressingArrow = false;
clearTimeout(repeatTimeout);
removeEvents();
}
function pointerMove(e: PointerEvent) {
if (dragging) updateHandlePosition(e);
function onPointerMove(e: PointerEvent) {
if (pressingTrack) {
return;
}
if (pressingArrow) {
const target = e.target || undefined;
if (!target || !(target instanceof Element)) return;
if (!target?.closest?.("[data-scrollbar-arrow]")) {
pressingArrow = false;
clearTimeout(repeatTimeout);
removeEvents();
}
return;
}
if (dragging) {
const length = trackLength();
if (length === undefined) return;
const positionPositionThisFrame = pointerPosition(e);
const dragDelta = positionPositionThisFrame - pointerPositionLastFrame;
const movement = dragDelta / (length * (1 - thumbLength));
const newThumbPosition = clamp01(thumbPosition + movement);
dispatch("thumbPosition", newThumbPosition);
pointerPositionLastFrame = positionPositionThisFrame;
return;
}
removeEvents();
}
function changePosition(difference: number) {
const length = trackLength();
if (length === undefined) return;
clampHandlePosition(handlePosition + difference / length);
function onMouseDown(e: MouseEvent) {
const BUTTONS_RIGHT = 0b0000_0010;
if (e.buttons & BUTTONS_RIGHT) abortInteraction();
}
onMount(() => {
window.addEventListener("pointerup", pointerUp);
window.addEventListener("pointermove", pointerMove);
});
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") abortInteraction();
}
onDestroy(() => {
window.removeEventListener("pointerup", pointerUp);
window.removeEventListener("pointermove", pointerMove);
});
function addEvents() {
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("mousedown", onMouseDown);
window.addEventListener("keydown", onKeyDown);
}
function removeEvents() {
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("mousedown", onMouseDown);
window.removeEventListener("keydown", onKeyDown);
}
</script>
<div class={`scrollbar-input ${direction.toLowerCase()}`} on:pointerup={() => dispatch("pointerup")}>
<button class="arrow decrease" on:pointerdown={() => changePosition(-50)} tabindex="-1" />
<div class="scroll-track" bind:this={scrollTrack} on:pointerdown={grabArea}>
<div class="scroll-thumb" on:pointerdown={grabHandle} class:dragging style:top={thumbTop} style:bottom={thumbBottom} style:left={thumbLeft} style:right={thumbRight} />
<div class={`scrollbar-input ${direction.toLowerCase()}`}>
<button class="arrow decrease" on:pointerdown={() => pressArrow(-1)} tabindex="-1" data-scrollbar-arrow></button>
<div class="scroll-track" on:pointerdown={pressTrack} bind:this={scrollTrack}>
<div class="scroll-thumb" on:pointerdown={dragThumb} class:dragging style:top={thumbTop} style:bottom={thumbBottom} style:left={thumbLeft} style:right={thumbRight} />
</div>
<button class="arrow increase" on:click={() => changePosition(50)} tabindex="-1" />
<button class="arrow increase" on:pointerdown={() => pressArrow(1)} tabindex="-1" data-scrollbar-arrow></button>
</div>
<style lang="scss" global>
@ -115,20 +219,66 @@
display: flex;
flex: 1 1 100%;
&.vertical {
flex-direction: column;
}
&.horizontal {
flex-direction: row;
}
.arrow {
--arrow-color: var(--color-5-dullgray);
flex: 0 0 auto;
background: none;
border: none;
border-style: solid;
width: 0;
height: 0;
margin: 0;
padding: 0;
width: 16px;
height: 16px;
&:hover {
--arrow-color: var(--color-6-lowergray);
}
&:hover:active {
--arrow-color: var(--color-c-brightgray);
}
&::after {
content: "";
display: block;
border-style: solid;
}
}
&.vertical .arrow.decrease::after {
margin: 4px 3px;
border-width: 0 5px 8px 5px;
border-color: transparent transparent var(--arrow-color) transparent;
}
&.vertical .arrow.increase::after {
margin: 4px 3px;
border-width: 8px 5px 0 5px;
border-color: var(--arrow-color) transparent transparent transparent;
}
&.horizontal .arrow.decrease::after {
margin: 3px 4px;
border-width: 5px 8px 5px 0;
border-color: transparent var(--arrow-color) transparent transparent;
}
&.horizontal .arrow.increase::after {
margin: 3px 4px;
border-width: 5px 0 5px 8px;
border-color: transparent transparent transparent var(--arrow-color);
}
.scroll-track {
flex: 1 1 100%;
position: relative;
flex: 1 1 100%;
.scroll-thumb {
position: absolute;
@ -140,70 +290,6 @@
background: var(--color-6-lowergray);
}
}
.scroll-click-area {
position: absolute;
}
}
&.vertical {
flex-direction: column;
.arrow.decrease {
margin: 4px 3px;
border-width: 0 5px 8px 5px;
border-color: transparent transparent var(--color-5-dullgray) transparent;
&:hover {
border-color: transparent transparent var(--color-6-lowergray) transparent;
}
&:active {
border-color: transparent transparent var(--color-c-brightgray) transparent;
}
}
.arrow.increase {
margin: 4px 3px;
border-width: 8px 5px 0 5px;
border-color: var(--color-5-dullgray) transparent transparent transparent;
&:hover {
border-color: var(--color-6-lowergray) transparent transparent transparent;
}
&:active {
border-color: var(--color-c-brightgray) transparent transparent transparent;
}
}
}
&.horizontal {
flex-direction: row;
.arrow.decrease {
margin: 3px 4px;
border-width: 5px 8px 5px 0;
border-color: transparent var(--color-5-dullgray) transparent transparent;
&:hover {
border-color: transparent var(--color-6-lowergray) transparent transparent;
}
&:active {
border-color: transparent var(--color-c-brightgray) transparent transparent;
}
}
.arrow.increase {
margin: 3px 4px;
border-width: 5px 0 5px 8px;
border-color: transparent transparent transparent var(--color-5-dullgray);
&:hover {
border-color: transparent transparent transparent var(--color-6-lowergray);
}
&:active {
border-color: transparent transparent transparent var(--color-c-brightgray);
}
}
}
}
</style>

View File

@ -3,21 +3,27 @@
import { Color, type Gradient } from "@graphite/messages";
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
const dispatch = createEventDispatcher<{ activeMarkerIndexChange: number | undefined; gradient: Gradient }>();
const BUTTON_LEFT = 0;
const BUTTON_RIGHT = 2;
const dispatch = createEventDispatcher<{ activeMarkerIndexChange: number | undefined; gradient: Gradient; dragging: boolean }>();
export let gradient: Gradient;
export let activeMarkerIndex = 0 as number | undefined;
// export let disabled = false;
// export let tooltip: string | undefined = undefined;
let markerTrack: LayoutRow | undefined;
let markerTrack: LayoutRow | undefined = undefined;
let positionRestore: number | undefined = undefined;
let deletionRestore: boolean | undefined = undefined;
function markerPointerDown(e: PointerEvent, index: number) {
// Left-click to select and begin potentially dragging
if (e.button === 0) {
if (e.button === BUTTON_LEFT) {
activeMarkerIndex = index;
dispatch("activeMarkerIndexChange", index);
addEvents();
@ -25,7 +31,7 @@
}
// Right-click to delete
if (e.button === 2) {
if (e.button === BUTTON_RIGHT && deletionRestore === undefined) {
deleteStopByIndex(index);
return;
}
@ -41,6 +47,8 @@
}
function insertStop(e: MouseEvent) {
if (e.button !== BUTTON_LEFT) return;
let position = markerPosition(e);
if (position === undefined) return;
@ -62,14 +70,20 @@
gradient.stops.splice(index, 0, { position, color });
activeMarkerIndex = index;
deletionRestore = true;
dispatch("activeMarkerIndexChange", index);
dispatch("gradient", gradient);
addEvents();
}
function deleteStop(e: KeyboardEvent) {
if (e.key.toLowerCase() !== "delete" && e.key.toLowerCase() !== "backspace") return;
if (e.key !== "Delete" && e.key !== "Backspace") return;
if (activeMarkerIndex === undefined) return;
if (positionRestore !== undefined) stopDrag();
deleteStopByIndex(activeMarkerIndex);
}
@ -82,6 +96,7 @@
} else {
activeMarkerIndex = Math.max(0, Math.min(gradient.stops.length - 1, index));
}
deletionRestore = undefined;
dispatch("activeMarkerIndexChange", activeMarkerIndex);
dispatch("gradient", gradient);
@ -89,10 +104,18 @@
function moveMarker(e: PointerEvent, index: number) {
// Just in case the mouseup event is lost
if (e.buttons === 0) removeEvents();
if (e.buttons === 0) stopDrag();
let position = markerPosition(e);
if (position === undefined) return;
if (positionRestore === undefined) positionRestore = position;
if (deletionRestore === undefined) {
deletionRestore = false;
dispatch("dragging", true);
}
setPosition(index, position);
}
@ -107,24 +130,61 @@
dispatch("gradient", gradient);
}
function onPointerMove(e: PointerEvent) {
if (activeMarkerIndex !== undefined) {
moveMarker(e, activeMarkerIndex);
function abortDrag() {
if (activeMarkerIndex === undefined) return;
if (deletionRestore) {
deleteStopByIndex(activeMarkerIndex);
} else if (positionRestore !== undefined) {
setPosition(activeMarkerIndex, positionRestore);
}
stopDrag();
}
function stopDrag() {
removeEvents();
positionRestore = undefined;
deletionRestore = undefined;
dispatch("dragging", false);
}
function onPointerMove(e: PointerEvent) {
if (activeMarkerIndex !== undefined) moveMarker(e, activeMarkerIndex);
}
function onPointerUp() {
removeEvents();
stopDrag();
}
function onMouseDown(e: MouseEvent) {
const BUTTONS_RIGHT = 0b0000_0010;
if (e.buttons & BUTTONS_RIGHT) abortDrag();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
const element = markerTrack?.div();
if (element) preventEscapeClosingParentFloatingMenu(element);
abortDrag();
}
}
function addEvents() {
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKeyDown);
}
function removeEvents() {
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKeyDown);
}
document.addEventListener("keydown", deleteStop);
@ -158,24 +218,22 @@
"--gradient-stops": gradient.toLinearGradientCSS(),
}}
>
<LayoutRow class="gradient-strip" on:click={insertStop}></LayoutRow>
<LayoutRow class="gradient-strip" on:pointerdown={insertStop}></LayoutRow>
<LayoutRow class="marker-track" bind:this={markerTrack}>
{#each gradient.stops as marker, index}
<svg
style:--marker-position={marker.position}
style:--marker-color={marker.color.toRgbCSS()}
class="marker"
class:active={index === activeMarkerIndex}
style:--marker-position={marker.position}
style:--marker-color={marker.color.toRgbCSS()}
on:pointerdown={(e) => markerPointerDown(e, index)}
data-gradient-marker
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
>
<path class="inner-fill" d="M10,11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1L6,0.7l5.1,5.1c0.3,0.3,0.4,0.7,0.4,1.1V10C11.5,10.8,10.8,11.5,10,11.5z" />
<path
on:pointerdown={(e) => markerPointerDown(e, index)}
d="M10,11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1L6,0.7l5.1,5.1c0.3,0.3,0.4,0.7,0.4,1.1V10C11.5,10.8,10.8,11.5,10,11.5z"
/>
<path
on:pointerdown={(e) => markerPointerDown(e, index)}
class="outer-border"
d="M6,1.4L1.3,6.1C1.1,6.3,1,6.6,1,6.8V10c0,0.6,0.4,1,1,1h8c0.6,0,1-0.4,1-1V6.8c0-0.3-0.1-0.5-0.3-0.7L6,1.4M6,0l5.4,5.4C11.8,5.8,12,6.3,12,6.8V10c0,1.1-0.9,2-2,2H2c-1.1,0-2-0.9-2-2V6.8c0-0.5,0.2-1,0.6-1.4L6,0z"
/>
</svg>
@ -216,6 +274,7 @@
margin-left: var(--marker-half-width);
width: calc(100% - 2 * var(--marker-half-width));
position: relative;
pointer-events: none;
.marker {
position: absolute;
@ -223,30 +282,40 @@
left: calc(var(--marker-position) * 100%);
width: 12px;
height: 12px;
pointer-events: auto;
overflow: visible;
padding-top: 12px;
margin-top: -12px;
// Inner fill
path:first-child {
.inner-fill {
fill: var(--marker-color);
}
// Outer border
path:last-child {
.outer-border {
fill: var(--color-5-dullgray);
}
&:not(.active) path:first-child:hover + path:last-child,
&:not(.active) path:last-child:hover {
fill: var(--color-6-lowergray);
&:not(.active) {
.inner-fill:hover + .outer-border,
.outer-border:hover {
fill: var(--color-6-lowergray);
}
}
// Outer border when active
&.active path:last-child {
fill: var(--color-e-nearwhite);
}
&.active {
.inner-fill {
filter: drop-shadow(0 0 1px var(--color-2-mildblack)) drop-shadow(0 0 1px var(--color-2-mildblack));
}
&.active path:first-child:hover + path:last-child,
&.active path:last-child:hover {
fill: var(--color-f-white);
// Outer border when active
.outer-border {
fill: var(--color-e-nearwhite);
}
.inner-fill:hover + .outer-border,
.outer-border:hover {
fill: var(--color-f-white);
}
}
}
}

View File

@ -37,7 +37,7 @@
<LayoutCol class="working-colors-button">
<LayoutRow class="primary swatch">
<button on:click={clickPrimarySwatch} class:open={primaryOpen} style:--swatch-color={primary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
<button on:click={clickPrimarySwatch} class:open={primaryOpen} style:--swatch-color={primary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0"></button>
<ColorPicker
open={primaryOpen}
on:open={({ detail }) => (primaryOpen = detail)}
@ -47,7 +47,7 @@
/>
</LayoutRow>
<LayoutRow class="secondary swatch">
<button on:click={clickSecondarySwatch} class:open={secondaryOpen} style:--swatch-color={secondary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
<button on:click={clickSecondarySwatch} class:open={secondaryOpen} style:--swatch-color={secondary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0"></button>
<ColorPicker
open={secondaryOpen}
on:open={({ detail }) => (secondaryOpen = detail)}
@ -70,27 +70,55 @@
> button {
--swatch-color: #ffffff;
--ring-color: var(--color-5-dullgray);
width: 100%;
height: 100%;
border-radius: 50%;
border: 2px var(--color-5-dullgray) solid;
box-shadow: 0 0 0 2px var(--color-3-darkgray);
margin: 0;
padding: 0;
border: none;
outline: none;
border-radius: 50%;
box-sizing: border-box;
background: linear-gradient(var(--swatch-color), var(--swatch-color)), 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);
overflow: hidden;
position: relative;
// Color of the panel background, used to extend outside the ring and appear to cut out a crescent from the lower circle (by covering it up with the panel background color)
box-shadow: 0 0 0 2px var(--color-3-darkgray);
background: var(--color-3-darkgray);
// Main color and checked transparency pattern (inset by 1px to begin inside/below the ring to avoid antialiasing artifacts)
&::before {
content: "";
position: absolute;
top: 1px;
bottom: 1px;
left: 1px;
right: 1px;
border-radius: 50%;
background: linear-gradient(var(--swatch-color), var(--swatch-color)), 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-plus-one);
background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
}
// Gray ring outline
&::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-radius: 50%;
box-shadow: inset 0 0 0 2px var(--ring-color);
}
&:hover,
&.open {
border-color: var(--color-6-lowergray);
--ring-color: var(--color-6-lowergray);
}
}

View File

@ -12,7 +12,14 @@
export let tooltip: string | undefined = undefined;
$: iconSizeClass = ((icon: IconName) => {
return `size-${iconSizeOverride || ICONS[icon].size}`;
const iconData = ICONS[icon];
// eslint-disable-next-line no-console
if (!iconData) {
console.warn(`Icon "${icon}" does not exist.`);
return "size-24";
}
if (iconData.size === undefined) return "";
return `size-${iconSizeOverride || iconData.size}`;
})(icon);
$: extraClasses = Object.entries(classes)
.flatMap(([className, stateName]) => (stateName ? [className] : []))
@ -20,7 +27,7 @@
</script>
<LayoutRow class={`icon-label ${iconSizeClass} ${className} ${extraClasses}`.trim()} classes={{ disabled }} {tooltip}>
{@html ICON_SVG_STRINGS[icon]}
{@html ICON_SVG_STRINGS[icon] || "<22>"}
</LayoutRow>
<style lang="scss" global>

View File

@ -28,6 +28,8 @@
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
import UserInputLabel from "@graphite/components/widgets/labels/UserInputLabel.svelte";
const BUTTON_MIDDLE = 1;
const editor = getContext<Editor>("editor");
export let tabMinWidths = false;
@ -100,7 +102,7 @@
}}
on:auxclick={(e) => {
// Middle mouse button click
if (e.button === 1) {
if (e.button === BUTTON_MIDDLE) {
e.stopPropagation();
closeAction?.(tabIndex);
}
@ -110,7 +112,7 @@
// https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event#browser_compatibility
// The downside of using mouseup is that the mousedown didn't have to originate in the same element.
// A possible future improvement could save the target element during mousedown and check if it's the same here.
if (!isEventSupported("auxclick") && e.button === 1) {
if (!isEventSupported("auxclick") && e.button === BUTTON_MIDDLE) {
e.stopPropagation();
closeAction?.(tabIndex);
}

View File

@ -14,15 +14,17 @@
const MIN_PANEL_SIZE = 100;
const PANEL_SIZES = {
/**/ root: 100,
/* ├─ */ content: 80,
/* │ ├── */ document: 100,
/* └─ */ details: 20,
/* ─ */ properties: 45,
/* ─ */ layers: 55,
/* ├─ */ content: 80,
/* │ ─ */ document: 100,
/* └─ */ details: 20,
/* ├─ */ properties: 45,
/* └─ */ layers: 55,
};
let panelSizes = PANEL_SIZES;
let documentPanel: Panel | undefined;
let gutterResizeRestore: [number, number] | undefined = undefined;
let pointerCaptureId: number | undefined = undefined;
$: documentPanel?.scrollTabIntoView($portfolio.activeDocumentIndex);
@ -64,32 +66,68 @@
const proportionBeingResized = totalResizingSpaceOccupied / parentElementSize;
// Prevent cursor flicker as mouse temporarily leaves the gutter
gutter.setPointerCapture(e.pointerId);
pointerCaptureId = e.pointerId;
gutter.setPointerCapture(pointerCaptureId);
const mouseStart = isHorizontal ? e.clientX : e.clientY;
const updatePosition = (e: PointerEvent) => {
const abortResize = () => {
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
removeListeners();
pointerCaptureId = e.pointerId;
gutter.setPointerCapture(pointerCaptureId);
if (gutterResizeRestore !== undefined) {
panelSizes[nextSiblingName] = gutterResizeRestore[0];
panelSizes[prevSiblingName] = gutterResizeRestore[1];
gutterResizeRestore = undefined;
}
};
const onPointerMove = (e: PointerEvent) => {
const mouseCurrent = isHorizontal ? e.clientX : e.clientY;
let mouseDelta = mouseStart - mouseCurrent;
mouseDelta = Math.max(nextSiblingSize + mouseDelta, MIN_PANEL_SIZE) - nextSiblingSize;
mouseDelta = prevSiblingSize - Math.max(prevSiblingSize - mouseDelta, MIN_PANEL_SIZE);
if (gutterResizeRestore === undefined) gutterResizeRestore = [panelSizes[nextSiblingName], panelSizes[prevSiblingName]];
panelSizes[nextSiblingName] = ((nextSiblingSize + mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
panelSizes[prevSiblingName] = ((prevSiblingSize - mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
};
const cleanup = (e: PointerEvent) => {
gutter.releasePointerCapture(e.pointerId);
document.removeEventListener("pointermove", updatePosition);
document.removeEventListener("pointerleave", cleanup);
document.removeEventListener("pointerup", cleanup);
const onPointerUp = () => {
gutterResizeRestore = undefined;
if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
removeListeners();
};
document.addEventListener("pointermove", updatePosition);
document.addEventListener("pointerleave", cleanup);
document.addEventListener("pointerup", cleanup);
const onMouseDown = (e: MouseEvent) => {
const BUTTONS_RIGHT = 0b0000_0010;
if (e.buttons & BUTTONS_RIGHT) abortResize();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") abortResize();
};
const addListeners = () => {
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKeyDown);
};
const removeListeners = () => {
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKeyDown);
};
addListeners();
}
</script>

View File

@ -12,6 +12,16 @@ import { extractPixelData } from "@graphite/utility-functions/rasterization";
import { stripIndents } from "@graphite/utility-functions/strip-indents";
import { updateBoundsOfViewports } from "@graphite/utility-functions/viewports";
const BUTTON_LEFT = 0;
const BUTTON_MIDDLE = 1;
const BUTTON_RIGHT = 2;
const BUTTON_BACK = 3;
const BUTTON_FORWARD = 4;
export const PRESS_REPEAT_DELAY_MS = 400;
export const PRESS_REPEAT_INTERVAL_MS = 72;
export const PRESS_REPEAT_INTERVAL_RAPID_MS = 10;
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield";
type EventListenerTarget = {
addEventListener: typeof window.addEventListener;
@ -155,7 +165,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
function onMouseDown(e: MouseEvent) {
// Block middle mouse button auto-scroll mode (the circlar gizmo that appears and allows quick scrolling by moving the cursor above or below it)
if (e.button === 1) e.preventDefault();
if (e.button === BUTTON_MIDDLE) e.preventDefault();
}
function onPointerDown(e: PointerEvent) {
@ -172,7 +182,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
}
if (!inTextInput && !inContextMenu) {
const isLeftOrRightClick = e.button === 2 || e.button === 0;
const isLeftOrRightClick = e.button === BUTTON_RIGHT || e.button === BUTTON_LEFT;
if (textToolInteractiveInputElement) editor.handle.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText), isLeftOrRightClick);
else viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element;
}
@ -188,7 +198,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
// TODO: This works in Chrome but not in Firefox
// TODO: Possible workaround: use the browser's history API to block navigation:
// TODO: <https://stackoverflow.com/questions/57102502/preventing-mouse-fourth-and-fifth-buttons-from-navigating-back-forward-in-browse>
if (e.button === 3 || e.button === 4) e.preventDefault();
if (e.button === BUTTON_BACK || e.button === BUTTON_FORWARD) e.preventDefault();
if (!e.buttons) viewportPointerInteractionOngoing = false;
@ -206,11 +216,11 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
// `e.buttons` is always 0 in the `mouseup` event, so we have to convert from `e.button` instead
let buttons = 1;
if (e.button === 0) buttons = 1; // Left
if (e.button === 2) buttons = 2; // Right
if (e.button === 1) buttons = 4; // Middle
if (e.button === 3) buttons = 8; // Back
if (e.button === 4) buttons = 16; // Forward
if (e.button === BUTTON_LEFT) buttons = 1; // Left
if (e.button === BUTTON_RIGHT) buttons = 2; // Right
if (e.button === BUTTON_MIDDLE) buttons = 4; // Middle
if (e.button === BUTTON_BACK) buttons = 8; // Back
if (e.button === BUTTON_FORWARD) buttons = 16; // Forward
const modifiers = makeKeyboardModifiersBitfield(e);
editor.handle.onDoubleClick(e.clientX, e.clientY, buttons, modifiers);

View File

@ -667,8 +667,8 @@ export class Color {
return new Color(this.red, this.green, this.blue, 1);
}
contrastingColor(): "black" | "white" {
if (this.none) return "black";
luminance(): number | undefined {
if (this.none) return undefined;
// Convert alpha into white
const r = this.red * this.alpha + (1 - this.alpha);
@ -681,9 +681,15 @@ export class Color {
const linearG = g <= 0.04045 ? g / 12.92 : ((g + 0.055) / 1.055) ** 2.4;
const linearB = b <= 0.04045 ? b / 12.92 : ((b + 0.055) / 1.055) ** 2.4;
const linear = linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722;
return linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722;
}
return linear > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white";
contrastingColor(): "black" | "white" {
if (this.none) return "black";
const luminance = this.luminance();
return luminance && luminance > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white";
}
}
@ -944,7 +950,7 @@ export class CheckboxInput extends WidgetProps {
tooltip!: string | undefined;
}
export class ColorButton extends WidgetProps {
export class ColorInput extends WidgetProps {
@Transform(({ value }) => {
if (value instanceof Gradient) return value;
const gradient = value["Gradient"];
@ -978,6 +984,37 @@ export class ColorButton extends WidgetProps {
export type FillChoice = Color | Gradient;
export function contrastingOutlineFactor(value: FillChoice, proximityColor: string | [string, string], proximityRange: number): number {
const pair = Array.isArray(proximityColor) ? [proximityColor[0], proximityColor[1]] : [proximityColor, proximityColor];
const [range1, range2] = pair.map((color) => Color.fromCSS(window.getComputedStyle(document.body).getPropertyValue(color)) || new Color("none"));
const contrast = (color: Color): number => {
const colorLuminance = color.luminance() || 0;
let rangeLuminance1 = range1.luminance() || 0;
let rangeLuminance2 = range2.luminance() || 0;
[rangeLuminance1, rangeLuminance2] = [Math.min(rangeLuminance1, rangeLuminance2), Math.max(rangeLuminance1, rangeLuminance2)];
const distance = (() => {
if (colorLuminance < rangeLuminance1) return rangeLuminance1 - colorLuminance;
if (colorLuminance > rangeLuminance2) return colorLuminance - rangeLuminance2;
return 0;
})();
return (1 - Math.min(distance / proximityRange, 1)) * (1 - (color.toHSV()?.s || 0));
};
if (value instanceof Gradient) {
if (value.stops.length === 0) return 0;
const first = contrast(value.stops[0].color);
const last = contrast(value.stops[value.stops.length - 1].color);
return Math.min(first, last);
}
return contrast(value);
}
type MenuEntryCommon = {
label: string;
icon?: IconName;
@ -1314,7 +1351,7 @@ export class PivotInput extends WidgetProps {
const widgetSubTypes = [
{ value: BreadcrumbTrailButtons, name: "BreadcrumbTrailButtons" },
{ value: CheckboxInput, name: "CheckboxInput" },
{ value: ColorButton, name: "ColorButton" },
{ value: ColorInput, name: "ColorInput" },
{ value: CurveInput, name: "CurveInput" },
{ value: DropdownInput, name: "DropdownInput" },
{ value: FontInput, name: "FontInput" },

View File

@ -557,16 +557,29 @@ impl EditorHandle {
self.dispatch(message);
}
/// Translates document (in viewport coords)
#[wasm_bindgen(js_name = panCanvasAbortPrepare)]
pub fn pan_canvas_abort_prepare(&self, x_not_y_axis: bool) {
let message = NavigationMessage::CanvasPanAbortPrepare { x_not_y_axis };
self.dispatch(message);
}
#[wasm_bindgen(js_name = panCanvasAbort)]
pub fn pan_canvas_abort(&self, x_not_y_axis: bool) {
let message = NavigationMessage::CanvasPanAbort { x_not_y_axis };
self.dispatch(message);
}
/// Translates document (in viewport coords)
#[wasm_bindgen(js_name = panCanvas)]
pub fn translate_canvas(&self, delta_x: f64, delta_y: f64) {
pub fn pan_canvas(&self, delta_x: f64, delta_y: f64) {
let message = NavigationMessage::CanvasPan { delta: (delta_x, delta_y).into() };
self.dispatch(message);
}
/// Translates document (in viewport coords)
#[wasm_bindgen(js_name = panCanvasByFraction)]
pub fn translate_canvas_by_fraction(&self, delta_x: f64, delta_y: f64) {
pub fn pan_canvas_by_fraction(&self, delta_x: f64, delta_y: f64) {
let message = NavigationMessage::CanvasPanByViewportFraction { delta: (delta_x, delta_y).into() };
self.dispatch(message);
}