Remake Eyedropper tool to sample pixel colors from viewport canvas (#801)
* Remake Eyedropper tool to sample pixel colors from viewport canvas * Bug fixes * Reorder export buttons * Remove the larger primary/secondary ring * Add aborting with Escape
This commit is contained in:
parent
fe1a03fac7
commit
58a53a995d
|
|
@ -63,7 +63,7 @@ impl PropertyHolder for ExportDialogMessageHandler {
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
let entries = [(FileType::Svg, "SVG"), (FileType::Png, "PNG"), (FileType::Jpg, "JPG")]
|
let entries = [(FileType::Png, "PNG"), (FileType::Jpg, "JPG"), (FileType::Svg, "SVG")]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(val, name)| RadioEntryData {
|
.map(|(val, name)| RadioEntryData {
|
||||||
label: name.into(),
|
label: name.into(),
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,16 @@ pub enum FrontendMessage {
|
||||||
size: (f64, f64),
|
size: (f64, f64),
|
||||||
multiplier: (f64, f64),
|
multiplier: (f64, f64),
|
||||||
},
|
},
|
||||||
|
UpdateEyedropperSamplingState {
|
||||||
|
#[serde(rename = "mousePosition")]
|
||||||
|
mouse_position: Option<(f64, f64)>,
|
||||||
|
#[serde(rename = "primaryColor")]
|
||||||
|
primary_color: String,
|
||||||
|
#[serde(rename = "secondaryColor")]
|
||||||
|
secondary_color: String,
|
||||||
|
#[serde(rename = "setColorChoice")]
|
||||||
|
set_color_choice: Option<String>,
|
||||||
|
},
|
||||||
UpdateImageData {
|
UpdateImageData {
|
||||||
#[serde(rename = "documentId")]
|
#[serde(rename = "documentId")]
|
||||||
document_id: u64,
|
document_id: u64,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ pub struct FrontendImageData {
|
||||||
pub enum MouseCursorIcon {
|
pub enum MouseCursorIcon {
|
||||||
#[default]
|
#[default]
|
||||||
Default,
|
Default,
|
||||||
|
None,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
Grabbing,
|
Grabbing,
|
||||||
|
|
@ -36,17 +37,17 @@ pub enum MouseCursorIcon {
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum FileType {
|
pub enum FileType {
|
||||||
#[default]
|
#[default]
|
||||||
Svg,
|
|
||||||
Png,
|
Png,
|
||||||
Jpg,
|
Jpg,
|
||||||
|
Svg,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileType {
|
impl FileType {
|
||||||
pub fn to_mime(self) -> &'static str {
|
pub fn to_mime(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
FileType::Svg => "image/svg+xml",
|
|
||||||
FileType::Png => "image/png",
|
FileType::Png => "image/png",
|
||||||
FileType::Jpg => "image/jpeg",
|
FileType::Jpg => "image/jpeg",
|
||||||
|
FileType::Svg => "image/svg+xml",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,8 +92,12 @@ pub fn default_mapping() -> Mapping {
|
||||||
entry!(KeyUp(Mmb); action_dispatch=NavigateToolMessage::TransformCanvasEnd),
|
entry!(KeyUp(Mmb); action_dispatch=NavigateToolMessage::TransformCanvasEnd),
|
||||||
//
|
//
|
||||||
// EyedropperToolMessage
|
// EyedropperToolMessage
|
||||||
entry!(KeyDown(Lmb); action_dispatch=EyedropperToolMessage::LeftMouseDown),
|
entry!(PointerMove; action_dispatch=EyedropperToolMessage::PointerMove),
|
||||||
entry!(KeyDown(Rmb); action_dispatch=EyedropperToolMessage::RightMouseDown),
|
entry!(KeyDown(Lmb); action_dispatch=EyedropperToolMessage::LeftPointerDown),
|
||||||
|
entry!(KeyDown(Rmb); action_dispatch=EyedropperToolMessage::RightPointerDown),
|
||||||
|
entry!(KeyUp(Lmb); action_dispatch=EyedropperToolMessage::LeftPointerUp),
|
||||||
|
entry!(KeyUp(Rmb); action_dispatch=EyedropperToolMessage::RightPointerUp),
|
||||||
|
entry!(KeyDown(Escape); action_dispatch=EyedropperToolMessage::Abort),
|
||||||
//
|
//
|
||||||
// TextToolMessage
|
// TextToolMessage
|
||||||
entry!(KeyUp(Lmb); action_dispatch=TextToolMessage::Interact),
|
entry!(KeyUp(Lmb); action_dispatch=TextToolMessage::Interact),
|
||||||
|
|
@ -170,8 +174,8 @@ pub fn default_mapping() -> Mapping {
|
||||||
entry!(KeyDown(Enter); action_dispatch=SplineToolMessage::Confirm),
|
entry!(KeyDown(Enter); action_dispatch=SplineToolMessage::Confirm),
|
||||||
//
|
//
|
||||||
// FillToolMessage
|
// FillToolMessage
|
||||||
entry!(KeyDown(Lmb); action_dispatch=FillToolMessage::LeftMouseDown),
|
entry!(KeyDown(Lmb); action_dispatch=FillToolMessage::LeftPointerDown),
|
||||||
entry!(KeyDown(Rmb); action_dispatch=FillToolMessage::RightMouseDown),
|
entry!(KeyDown(Rmb); action_dispatch=FillToolMessage::RightPointerDown),
|
||||||
//
|
//
|
||||||
// ToolMessage
|
// ToolMessage
|
||||||
entry!(KeyDown(KeyV); action_dispatch=ToolMessage::ActivateToolSelect),
|
entry!(KeyDown(KeyV); action_dispatch=ToolMessage::ActivateToolSelect),
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ impl ViewportBounds {
|
||||||
pub fn center(&self) -> DVec2 {
|
pub fn center(&self) -> DVec2 {
|
||||||
self.bottom_right.lerp(self.top_left, 0.5)
|
self.bottom_right.lerp(self.top_left, 0.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn in_bounds(&self, position: ViewportPosition) -> bool {
|
||||||
|
position.x >= 0. && position.y >= 0. && position.x <= self.bottom_right.x && position.y <= self.bottom_right.y
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -910,8 +910,8 @@ fn node_section_imaginate(imaginate_layer: &ImaginateLayer, layer: &Layer, persi
|
||||||
\n\
|
\n\
|
||||||
Include an artist name like \"Rembrandt\" or art medium like \"watercolor\" or \"photography\" to influence the look. List multiple to meld styles.\n\
|
Include an artist name like \"Rembrandt\" or art medium like \"watercolor\" or \"photography\" to influence the look. List multiple to meld styles.\n\
|
||||||
\n\
|
\n\
|
||||||
To boost the importance of a word or phrase, wrap it in quotes ending with a colon and a multiplier, for example:\n\
|
To boost (or lessen) the importance of a word or phrase, wrap it in quotes ending with a colon and a multiplier, for example:\n\
|
||||||
\"(colorless:0.7) green (ideas sleep:1.3) furiously\"
|
\"Colorless green ideas (sleep:1.3) furiously\"
|
||||||
"
|
"
|
||||||
.trim()
|
.trim()
|
||||||
.into(),
|
.into(),
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ impl MessageHandler<ToolMessage, (&DocumentMessageHandler, u64, &InputPreprocess
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the Abort state transition to the tool
|
// Send the old and new tools a transition to their FSM Abort states
|
||||||
let mut send_abort_to_tool = |tool_type, update_hints_and_cursor: bool| {
|
let mut send_abort_to_tool = |tool_type, update_hints_and_cursor: bool| {
|
||||||
if let Some(tool) = tool_data.tools.get_mut(&tool_type) {
|
if let Some(tool) = tool_data.tools.get_mut(&tool_type) {
|
||||||
if let Some(tool_abort_message) = tool.event_to_message_map().tool_abort {
|
if let Some(tool_abort_message) = tool.event_to_message_map().tool_abort {
|
||||||
|
|
@ -82,8 +82,6 @@ impl MessageHandler<ToolMessage, (&DocumentMessageHandler, u64, &InputPreprocess
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send the old and new tools a transition to their FSM Abort states
|
|
||||||
send_abort_to_tool(tool_type, true);
|
send_abort_to_tool(tool_type, true);
|
||||||
send_abort_to_tool(old_tool, false);
|
send_abort_to_tool(old_tool, false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
use crate::consts::SELECTION_TOLERANCE;
|
|
||||||
use crate::messages::frontend::utility_types::MouseCursorIcon;
|
use crate::messages::frontend::utility_types::MouseCursorIcon;
|
||||||
use crate::messages::input_mapper::utility_types::input_keyboard::MouseMotion;
|
use crate::messages::input_mapper::utility_types::input_keyboard::MouseMotion;
|
||||||
use crate::messages::layout::utility_types::layout_widget::PropertyHolder;
|
use crate::messages::layout::utility_types::layout_widget::PropertyHolder;
|
||||||
use crate::messages::prelude::*;
|
use crate::messages::prelude::*;
|
||||||
use crate::messages::tool::utility_types::{EventToMessageMap, Fsm, ToolActionHandlerData, ToolMetadata, ToolTransition, ToolType};
|
use crate::messages::tool::utility_types::{DocumentToolData, EventToMessageMap, Fsm, ToolActionHandlerData, ToolMetadata, ToolTransition, ToolType};
|
||||||
use crate::messages::tool::utility_types::{HintData, HintGroup, HintInfo};
|
use crate::messages::tool::utility_types::{HintData, HintGroup, HintInfo};
|
||||||
|
|
||||||
use graphene::intersection::Quad;
|
|
||||||
use graphene::layers::layer_info::LayerDataType;
|
|
||||||
|
|
||||||
use glam::DVec2;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
|
@ -27,8 +22,11 @@ pub enum EyedropperToolMessage {
|
||||||
Abort,
|
Abort,
|
||||||
|
|
||||||
// Tool-specific messages
|
// Tool-specific messages
|
||||||
LeftMouseDown,
|
LeftPointerDown,
|
||||||
RightMouseDown,
|
LeftPointerUp,
|
||||||
|
PointerMove,
|
||||||
|
RightPointerDown,
|
||||||
|
RightPointerUp,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolMetadata for EyedropperTool {
|
impl ToolMetadata for EyedropperTool {
|
||||||
|
|
@ -67,8 +65,12 @@ impl<'a> MessageHandler<ToolMessage, ToolActionHandlerData<'a>> for EyedropperTo
|
||||||
}
|
}
|
||||||
|
|
||||||
advertise_actions!(EyedropperToolMessageDiscriminant;
|
advertise_actions!(EyedropperToolMessageDiscriminant;
|
||||||
LeftMouseDown,
|
LeftPointerDown,
|
||||||
RightMouseDown,
|
LeftPointerUp,
|
||||||
|
PointerMove,
|
||||||
|
RightPointerDown,
|
||||||
|
RightPointerUp,
|
||||||
|
Abort,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,6 +87,8 @@ impl ToolTransition for EyedropperTool {
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
enum EyedropperToolFsmState {
|
enum EyedropperToolFsmState {
|
||||||
Ready,
|
Ready,
|
||||||
|
SamplingPrimary,
|
||||||
|
SamplingSecondary,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for EyedropperToolFsmState {
|
impl Default for EyedropperToolFsmState {
|
||||||
|
|
@ -104,7 +108,7 @@ impl Fsm for EyedropperToolFsmState {
|
||||||
self,
|
self,
|
||||||
event: ToolMessage,
|
event: ToolMessage,
|
||||||
_tool_data: &mut Self::ToolData,
|
_tool_data: &mut Self::ToolData,
|
||||||
(document, _document_id, _global_tool_data, input, font_cache): ToolActionHandlerData,
|
(_document, _document_id, global_tool_data, input, _font_cache): ToolActionHandlerData,
|
||||||
_tool_options: &Self::ToolOptions,
|
_tool_options: &Self::ToolOptions,
|
||||||
responses: &mut VecDeque<Message>,
|
responses: &mut VecDeque<Message>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
|
@ -113,28 +117,41 @@ impl Fsm for EyedropperToolFsmState {
|
||||||
|
|
||||||
if let ToolMessage::Eyedropper(event) = event {
|
if let ToolMessage::Eyedropper(event) = event {
|
||||||
match (self, event) {
|
match (self, event) {
|
||||||
(Ready, lmb_or_rmb) if lmb_or_rmb == LeftMouseDown || lmb_or_rmb == RightMouseDown => {
|
// Ready -> Sampling
|
||||||
let mouse_pos = input.mouse.position;
|
(Ready, mouse_down) | (Ready, mouse_down) if mouse_down == LeftPointerDown || mouse_down == RightPointerDown => {
|
||||||
let tolerance = DVec2::splat(SELECTION_TOLERANCE);
|
update_cursor_preview(responses, input, global_tool_data, None);
|
||||||
let quad = Quad::from_box([mouse_pos - tolerance, mouse_pos + tolerance]);
|
|
||||||
|
|
||||||
// TODO: Destroy this pyramid
|
if mouse_down == LeftPointerDown {
|
||||||
if let Some(path) = document.graphene_document.intersects_quad_root(quad, font_cache).last() {
|
SamplingPrimary
|
||||||
if let Ok(layer) = document.graphene_document.layer(path) {
|
} else {
|
||||||
if let LayerDataType::Shape(shape) = &layer.data {
|
SamplingSecondary
|
||||||
if shape.style.fill().is_some() {
|
|
||||||
match lmb_or_rmb {
|
|
||||||
EyedropperToolMessage::LeftMouseDown => responses.push_back(ToolMessage::SelectPrimaryColor { color: shape.style.fill().color() }.into()),
|
|
||||||
EyedropperToolMessage::RightMouseDown => responses.push_back(ToolMessage::SelectSecondaryColor { color: shape.style.fill().color() }.into()),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Sampling -> Sampling
|
||||||
|
(SamplingPrimary, PointerMove) | (SamplingSecondary, PointerMove) => {
|
||||||
|
if input.viewport_bounds.in_bounds(input.mouse.position) {
|
||||||
|
update_cursor_preview(responses, input, global_tool_data, None);
|
||||||
|
} else {
|
||||||
|
disable_cursor_preview(responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
self
|
||||||
|
}
|
||||||
|
// Sampling -> Ready
|
||||||
|
(SamplingPrimary, mouse_up) | (SamplingSecondary, mouse_up) if mouse_up == LeftPointerUp || mouse_up == RightPointerUp => {
|
||||||
|
let set_color_choice = if self == SamplingPrimary { "Primary".to_string() } else { "Secondary".to_string() };
|
||||||
|
update_cursor_preview(responses, input, global_tool_data, Some(set_color_choice));
|
||||||
|
disable_cursor_preview(responses);
|
||||||
|
|
||||||
Ready
|
Ready
|
||||||
}
|
}
|
||||||
|
// Any -> Ready
|
||||||
|
(_, Abort) => {
|
||||||
|
disable_cursor_preview(responses);
|
||||||
|
|
||||||
|
Ready
|
||||||
|
}
|
||||||
|
// Ready -> Ready
|
||||||
_ => self,
|
_ => self,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -160,12 +177,43 @@ impl Fsm for EyedropperToolFsmState {
|
||||||
plus: false,
|
plus: false,
|
||||||
},
|
},
|
||||||
])]),
|
])]),
|
||||||
|
EyedropperToolFsmState::SamplingPrimary => HintData(vec![]),
|
||||||
|
EyedropperToolFsmState::SamplingSecondary => HintData(vec![]),
|
||||||
};
|
};
|
||||||
|
|
||||||
responses.push_back(FrontendMessage::UpdateInputHints { hint_data }.into());
|
responses.push_back(FrontendMessage::UpdateInputHints { hint_data }.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_cursor(&self, responses: &mut VecDeque<Message>) {
|
fn update_cursor(&self, responses: &mut VecDeque<Message>) {
|
||||||
responses.push_back(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default }.into());
|
let cursor = match *self {
|
||||||
|
EyedropperToolFsmState::Ready => MouseCursorIcon::Default,
|
||||||
|
EyedropperToolFsmState::SamplingPrimary | EyedropperToolFsmState::SamplingSecondary => MouseCursorIcon::None,
|
||||||
|
};
|
||||||
|
|
||||||
|
responses.push_back(FrontendMessage::UpdateMouseCursor { cursor }.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn disable_cursor_preview(responses: &mut VecDeque<Message>) {
|
||||||
|
responses.push_back(
|
||||||
|
FrontendMessage::UpdateEyedropperSamplingState {
|
||||||
|
mouse_position: None,
|
||||||
|
primary_color: "".into(),
|
||||||
|
secondary_color: "".into(),
|
||||||
|
set_color_choice: None,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_cursor_preview(responses: &mut VecDeque<Message>, input: &InputPreprocessorMessageHandler, global_tool_data: &DocumentToolData, set_color_choice: Option<String>) {
|
||||||
|
responses.push_back(
|
||||||
|
FrontendMessage::UpdateEyedropperSamplingState {
|
||||||
|
mouse_position: Some(input.mouse.position.into()),
|
||||||
|
primary_color: "#".to_string() + global_tool_data.primary_color.rgb_hex().as_str(),
|
||||||
|
secondary_color: "#".to_string() + global_tool_data.secondary_color.rgb_hex().as_str(),
|
||||||
|
set_color_choice,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ pub enum FillToolMessage {
|
||||||
Abort,
|
Abort,
|
||||||
|
|
||||||
// Tool-specific messages
|
// Tool-specific messages
|
||||||
LeftMouseDown,
|
LeftPointerDown,
|
||||||
RightMouseDown,
|
RightPointerDown,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolMetadata for FillTool {
|
impl ToolMetadata for FillTool {
|
||||||
|
|
@ -68,8 +68,8 @@ impl<'a> MessageHandler<ToolMessage, ToolActionHandlerData<'a>> for FillTool {
|
||||||
}
|
}
|
||||||
|
|
||||||
advertise_actions!(FillToolMessageDiscriminant;
|
advertise_actions!(FillToolMessageDiscriminant;
|
||||||
LeftMouseDown,
|
LeftPointerDown,
|
||||||
RightMouseDown,
|
RightPointerDown,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,15 +114,15 @@ impl Fsm for FillToolFsmState {
|
||||||
|
|
||||||
if let ToolMessage::Fill(event) = event {
|
if let ToolMessage::Fill(event) = event {
|
||||||
match (self, event) {
|
match (self, event) {
|
||||||
(Ready, lmb_or_rmb) if lmb_or_rmb == LeftMouseDown || lmb_or_rmb == RightMouseDown => {
|
(Ready, lmb_or_rmb) if lmb_or_rmb == LeftPointerDown || lmb_or_rmb == RightPointerDown => {
|
||||||
let mouse_pos = input.mouse.position;
|
let mouse_pos = input.mouse.position;
|
||||||
let tolerance = DVec2::splat(SELECTION_TOLERANCE);
|
let tolerance = DVec2::splat(SELECTION_TOLERANCE);
|
||||||
let quad = Quad::from_box([mouse_pos - tolerance, mouse_pos + tolerance]);
|
let quad = Quad::from_box([mouse_pos - tolerance, mouse_pos + tolerance]);
|
||||||
|
|
||||||
if let Some(path) = document.graphene_document.intersects_quad_root(quad, font_cache).last() {
|
if let Some(path) = document.graphene_document.intersects_quad_root(quad, font_cache).last() {
|
||||||
let color = match lmb_or_rmb {
|
let color = match lmb_or_rmb {
|
||||||
LeftMouseDown => global_tool_data.primary_color,
|
LeftPointerDown => global_tool_data.primary_color,
|
||||||
RightMouseDown => global_tool_data.secondary_color,
|
RightPointerDown => global_tool_data.secondary_color,
|
||||||
Abort => unreachable!(),
|
Abort => unreachable!(),
|
||||||
};
|
};
|
||||||
let fill = Fill::Solid(color);
|
let fill = Fill::Solid(color);
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ impl DocumentToolData {
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
responses.push_back(EyedropperToolMessage::PointerMove.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
<template>
|
||||||
|
<FloatingMenu
|
||||||
|
:open="true"
|
||||||
|
class="eyedropper-preview"
|
||||||
|
:type="'Cursor'"
|
||||||
|
:style="{ '--ring-color-primary': primaryColor, '--ring-color-secondary': secondaryColor, '--ring-color-choice': colorChoice }"
|
||||||
|
>
|
||||||
|
<div class="ring">
|
||||||
|
<div class="canvas-container">
|
||||||
|
<canvas ref="zoomPreviewCanvas"></canvas>
|
||||||
|
<div class="pixel-outline"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FloatingMenu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.eyedropper-preview {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.ring {
|
||||||
|
transform: translate(0, -50%) rotate(45deg);
|
||||||
|
position: relative;
|
||||||
|
background: var(--ring-color-choice);
|
||||||
|
padding: 16px;
|
||||||
|
border: 8px solid;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--ring-color-primary);
|
||||||
|
border-left-color: var(--ring-color-primary);
|
||||||
|
border-bottom-color: var(--ring-color-secondary);
|
||||||
|
border-right-color: var(--ring-color-secondary);
|
||||||
|
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
width: 110px;
|
||||||
|
height: 110px;
|
||||||
|
border-radius: 50%;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-outline {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
--outline-width: 2;
|
||||||
|
margin-top: calc(-1px * (var(--outline-width) / 2));
|
||||||
|
width: calc(10px - (var(--outline-width) * 1px));
|
||||||
|
height: calc(10px - var(--outline-width) * 1px);
|
||||||
|
border: calc(var(--outline-width) * 1px) solid var(--color-0-black);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, type PropType } from "vue";
|
||||||
|
|
||||||
|
import FloatingMenu from "@/components/floating-menus/FloatingMenu.vue";
|
||||||
|
|
||||||
|
// Should be equal to the width and height of the canvas in the CSS above
|
||||||
|
const ZOOM_WINDOW_DIMENSIONS_EXPANDED = 110;
|
||||||
|
// SHould be equal to the width and height of the `.pixel-outline` div in the CSS above, and should be evenly divisible into the number above
|
||||||
|
const UPSCALE_FACTOR = 10;
|
||||||
|
|
||||||
|
export const ZOOM_WINDOW_DIMENSIONS = ZOOM_WINDOW_DIMENSIONS_EXPANDED / UPSCALE_FACTOR;
|
||||||
|
|
||||||
|
const temporaryCanvas = document.createElement("canvas");
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
imageData: { type: Object as PropType<ImageData> },
|
||||||
|
colorChoice: { type: String as PropType<string>, required: true },
|
||||||
|
primaryColor: { type: String as PropType<string>, required: true },
|
||||||
|
secondaryColor: { type: String as PropType<string>, required: true },
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.displayImageDataPreview(this.imageData);
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
imageData(imageData: ImageData | undefined) {
|
||||||
|
this.displayImageDataPreview(imageData);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
displayImageDataPreview(imageData: ImageData | undefined) {
|
||||||
|
const canvas = this.$refs.zoomPreviewCanvas as HTMLCanvasElement;
|
||||||
|
canvas.width = ZOOM_WINDOW_DIMENSIONS;
|
||||||
|
canvas.height = ZOOM_WINDOW_DIMENSIONS;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
|
||||||
|
temporaryCanvas.width = ZOOM_WINDOW_DIMENSIONS;
|
||||||
|
temporaryCanvas.height = ZOOM_WINDOW_DIMENSIONS;
|
||||||
|
const temporaryContext = temporaryCanvas.getContext("2d");
|
||||||
|
|
||||||
|
if (!imageData || !context || !temporaryContext) return;
|
||||||
|
|
||||||
|
temporaryContext.putImageData(imageData, 0, 0, 0, 0, ZOOM_WINDOW_DIMENSIONS, ZOOM_WINDOW_DIMENSIONS);
|
||||||
|
|
||||||
|
context.fillStyle = "black";
|
||||||
|
context.fillRect(0, 0, ZOOM_WINDOW_DIMENSIONS, ZOOM_WINDOW_DIMENSIONS);
|
||||||
|
|
||||||
|
context.drawImage(temporaryCanvas, 0, 0);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
FloatingMenu,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -110,6 +110,13 @@
|
||||||
--floating-menu-content-border-radius: 4px;
|
--floating-menu-content-border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.cursor .floating-menu-container .floating-menu-content {
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&.center {
|
&.center {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -180,7 +187,7 @@ import { defineComponent, nextTick, type PropType } from "vue";
|
||||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||||
|
|
||||||
export type MenuDirection = "Top" | "Bottom" | "Left" | "Right" | "TopLeft" | "TopRight" | "BottomLeft" | "BottomRight" | "Center";
|
export type MenuDirection = "Top" | "Bottom" | "Left" | "Right" | "TopLeft" | "TopRight" | "BottomLeft" | "BottomRight" | "Center";
|
||||||
export type MenuType = "Popover" | "Dropdown" | "Dialog";
|
export type MenuType = "Popover" | "Dropdown" | "Dialog" | "Cursor";
|
||||||
|
|
||||||
const POINTER_STRAY_DISTANCE = 100;
|
const POINTER_STRAY_DISTANCE = 100;
|
||||||
|
|
||||||
|
|
@ -235,6 +242,8 @@ export default defineComponent({
|
||||||
this.minWidthParentWidth = entries[0].contentRect.width;
|
this.minWidthParentWidth = entries[0].contentRect.width;
|
||||||
},
|
},
|
||||||
positionAndStyleFloatingMenu() {
|
positionAndStyleFloatingMenu() {
|
||||||
|
if (this.type === "Cursor") return;
|
||||||
|
|
||||||
const workspace = document.querySelector("[data-workspace]");
|
const workspace = document.querySelector("[data-workspace]");
|
||||||
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
|
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
|
||||||
const floatingMenuContentComponent = this.$refs.floatingMenuContent as typeof LayoutCol;
|
const floatingMenuContentComponent = this.$refs.floatingMenuContent as typeof LayoutCol;
|
||||||
|
|
|
||||||
|
|
@ -28,25 +28,25 @@
|
||||||
<LayoutCol class="bar-area">
|
<LayoutCol class="bar-area">
|
||||||
<CanvasRuler :origin="rulerOrigin.y" :majorMarkSpacing="rulerSpacing" :numberInterval="rulerInterval" :direction="'Vertical'" ref="rulerVertical" />
|
<CanvasRuler :origin="rulerOrigin.y" :majorMarkSpacing="rulerSpacing" :numberInterval="rulerInterval" :direction="'Vertical'" ref="rulerVertical" />
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
<LayoutCol class="canvas-area">
|
<LayoutCol class="canvas-area" :style="{ cursor: canvasCursor }">
|
||||||
<div
|
<EyedropperPreview
|
||||||
class="canvas"
|
v-if="cursorEyedropper"
|
||||||
:style="{ cursor: canvasCursor }"
|
:colorChoice="cursorEyedropperPreviewColorChoice"
|
||||||
@pointerdown="(e: PointerEvent) => canvasPointerDown(e)"
|
:primaryColor="cursorEyedropperPreviewColorPrimary"
|
||||||
@dragover="(e) => e.preventDefault()"
|
:secondaryColor="cursorEyedropperPreviewColorSecondary"
|
||||||
@drop="(e) => pasteFile(e)"
|
:imageData="cursorEyedropperPreviewImageData"
|
||||||
ref="canvas"
|
:style="{ left: cursorLeft + 'px', top: cursorTop + 'px' }"
|
||||||
data-canvas
|
/>
|
||||||
>
|
<div class="canvas" @pointerdown="(e: PointerEvent) => canvasPointerDown(e)" @dragover="(e) => e.preventDefault()" @drop="(e) => pasteFile(e)" ref="canvas" data-canvas>
|
||||||
<svg class="artboards" v-html="artboardSvg" :style="{ width: canvasSvgWidth, height: canvasSvgHeight }"></svg>
|
<svg class="artboards" v-html="artboardSvg" :style="{ width: canvasWidthCSS, height: canvasHeightCSS }"></svg>
|
||||||
<svg
|
<svg
|
||||||
class="artwork"
|
class="artwork"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
v-html="artworkSvg"
|
v-html="artworkSvg"
|
||||||
:style="{ width: canvasSvgWidth, height: canvasSvgHeight }"
|
:style="{ width: canvasWidthCSS, height: canvasHeightCSS }"
|
||||||
></svg>
|
></svg>
|
||||||
<svg class="overlays" v-html="overlaysSvg" :style="{ width: canvasSvgWidth, height: canvasSvgHeight }"></svg>
|
<svg class="overlays" v-html="overlaysSvg" :style="{ width: canvasWidthCSS, height: canvasHeightCSS }"></svg>
|
||||||
</div>
|
</div>
|
||||||
</LayoutCol>
|
</LayoutCol>
|
||||||
<LayoutCol class="bar-area">
|
<LayoutCol class="bar-area">
|
||||||
|
|
@ -149,6 +149,7 @@
|
||||||
|
|
||||||
.canvas-area {
|
.canvas-area {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-area {
|
.bar-area {
|
||||||
|
|
@ -224,6 +225,7 @@
|
||||||
import { defineComponent, nextTick } from "vue";
|
import { defineComponent, nextTick } from "vue";
|
||||||
|
|
||||||
import { textInputCleanup } from "@/utility-functions/keyboard-entry";
|
import { textInputCleanup } from "@/utility-functions/keyboard-entry";
|
||||||
|
import { rasterizeSVGCanvas } from "@/utility-functions/rasterization";
|
||||||
import {
|
import {
|
||||||
defaultWidgetLayout,
|
defaultWidgetLayout,
|
||||||
type DisplayEditableTextbox,
|
type DisplayEditableTextbox,
|
||||||
|
|
@ -236,6 +238,7 @@ import {
|
||||||
type XY,
|
type XY,
|
||||||
} from "@/wasm-communication/messages";
|
} from "@/wasm-communication/messages";
|
||||||
|
|
||||||
|
import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@/components/floating-menus/EyedropperPreview.vue";
|
||||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||||
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
import LayoutRow from "@/components/layout/LayoutRow.vue";
|
||||||
import CanvasRuler from "@/components/widgets/metrics/CanvasRuler.vue";
|
import CanvasRuler from "@/components/widgets/metrics/CanvasRuler.vue";
|
||||||
|
|
@ -244,6 +247,64 @@ import WidgetLayout from "@/components/widgets/WidgetLayout.vue";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
inject: ["editor", "panels"],
|
inject: ["editor", "panels"],
|
||||||
|
data() {
|
||||||
|
const scrollbarPos: XY = { x: 0.5, y: 0.5 };
|
||||||
|
const scrollbarSize: XY = { x: 0.5, y: 0.5 };
|
||||||
|
const scrollbarMultiplier: XY = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
const rulerOrigin: XY = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Interactive text editing
|
||||||
|
textInput: undefined as undefined | HTMLDivElement,
|
||||||
|
|
||||||
|
// CSS properties
|
||||||
|
canvasSvgWidth: undefined as number | undefined,
|
||||||
|
canvasSvgHeight: undefined as number | undefined,
|
||||||
|
canvasCursor: "default" as MouseCursorIcon,
|
||||||
|
|
||||||
|
// Scrollbars
|
||||||
|
scrollbarPos,
|
||||||
|
scrollbarSize,
|
||||||
|
scrollbarMultiplier,
|
||||||
|
|
||||||
|
// Rulers
|
||||||
|
rulerOrigin,
|
||||||
|
rulerSpacing: 100 as number,
|
||||||
|
rulerInterval: 100 as number,
|
||||||
|
|
||||||
|
// Rendered SVG viewport data
|
||||||
|
artworkSvg: "" as string,
|
||||||
|
artboardSvg: "" as string,
|
||||||
|
overlaysSvg: "" as string,
|
||||||
|
|
||||||
|
// Rasterized SVG viewport data, or none if it's not up-to-date
|
||||||
|
rasterizedCanvas: undefined as HTMLCanvasElement | undefined,
|
||||||
|
rasterizedContext: undefined as CanvasRenderingContext2D | undefined,
|
||||||
|
|
||||||
|
// Cursor position for cursor floating menus like the Eyedropper tool zoom
|
||||||
|
cursorLeft: 0,
|
||||||
|
cursorTop: 0,
|
||||||
|
cursorEyedropper: false,
|
||||||
|
cursorEyedropperPreviewImageData: undefined as ImageData | undefined,
|
||||||
|
cursorEyedropperPreviewColorChoice: "",
|
||||||
|
cursorEyedropperPreviewColorPrimary: "",
|
||||||
|
cursorEyedropperPreviewColorSecondary: "",
|
||||||
|
|
||||||
|
// Layouts
|
||||||
|
documentModeLayout: defaultWidgetLayout(),
|
||||||
|
toolOptionsLayout: defaultWidgetLayout(),
|
||||||
|
documentBarLayout: defaultWidgetLayout(),
|
||||||
|
toolShelfLayout: defaultWidgetLayout(),
|
||||||
|
workingColorsLayout: defaultWidgetLayout(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.panels.registerPanel("Document", this);
|
||||||
|
|
||||||
|
// Once this component is mounted, we want to resend the document bounds to the backend via the resize event handler which does that
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
pasteFile(e: DragEvent) {
|
pasteFile(e: DragEvent) {
|
||||||
const { dataTransfer } = e;
|
const { dataTransfer } = e;
|
||||||
|
|
@ -288,6 +349,7 @@ export default defineComponent({
|
||||||
// Update rendered SVGs
|
// Update rendered SVGs
|
||||||
async updateDocumentArtwork(svg: string) {
|
async updateDocumentArtwork(svg: string) {
|
||||||
this.artworkSvg = svg;
|
this.artworkSvg = svg;
|
||||||
|
this.rasterizedCanvas = undefined;
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
|
|
@ -321,6 +383,57 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
updateDocumentArtboards(svg: string) {
|
updateDocumentArtboards(svg: string) {
|
||||||
this.artboardSvg = svg;
|
this.artboardSvg = svg;
|
||||||
|
this.rasterizedCanvas = undefined;
|
||||||
|
},
|
||||||
|
async updateEyedropperSamplingState(mousePosition: XY | undefined, colorPrimary: string, colorSecondary: string): Promise<[number, number, number] | undefined> {
|
||||||
|
if (mousePosition === undefined) {
|
||||||
|
this.cursorEyedropper = false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
this.cursorEyedropper = true;
|
||||||
|
|
||||||
|
if (this.canvasSvgWidth === undefined || this.canvasSvgHeight === undefined) return undefined;
|
||||||
|
|
||||||
|
this.cursorLeft = mousePosition.x;
|
||||||
|
this.cursorTop = mousePosition.y;
|
||||||
|
|
||||||
|
// This works nearly perfectly, but sometimes at odd DPI scale factors like 1.25, the anti-aliasing color can yield slightly incorrect colors (potential room for future improvement)
|
||||||
|
const dpiFactor = window.devicePixelRatio;
|
||||||
|
const [width, height] = [this.canvasSvgWidth, this.canvasSvgHeight];
|
||||||
|
|
||||||
|
const outsideArtboardsColor = getComputedStyle(document.documentElement).getPropertyValue("--color-2-mildblack");
|
||||||
|
const outsideArtboards = `<rect x="0" y="0" width="100%" height="100%" fill="${outsideArtboardsColor}" />`;
|
||||||
|
const artboards = this.artboardSvg;
|
||||||
|
const artwork = this.artworkSvg;
|
||||||
|
const svg = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${outsideArtboards}${artboards}${artwork}</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
if (!this.rasterizedCanvas) {
|
||||||
|
this.rasterizedCanvas = await rasterizeSVGCanvas(svg, width * dpiFactor, height * dpiFactor, "image/png");
|
||||||
|
this.rasterizedContext = this.rasterizedCanvas.getContext("2d") || undefined;
|
||||||
|
}
|
||||||
|
if (!this.rasterizedContext) return undefined;
|
||||||
|
|
||||||
|
const rgbToHex = (r: number, g: number, b: number): string => `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
|
||||||
|
const pixel = this.rasterizedContext.getImageData(mousePosition.x * dpiFactor, mousePosition.y * dpiFactor, 1, 1).data;
|
||||||
|
const hex = rgbToHex(pixel[0], pixel[1], pixel[2]);
|
||||||
|
const rgb: [number, number, number] = [pixel[0] / 255, pixel[1] / 255, pixel[2] / 255];
|
||||||
|
|
||||||
|
this.cursorEyedropperPreviewColorChoice = hex;
|
||||||
|
this.cursorEyedropperPreviewColorPrimary = colorPrimary;
|
||||||
|
this.cursorEyedropperPreviewColorSecondary = colorSecondary;
|
||||||
|
|
||||||
|
const previewRegion = this.rasterizedContext.getImageData(
|
||||||
|
mousePosition.x * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
|
||||||
|
mousePosition.y * dpiFactor - (ZOOM_WINDOW_DIMENSIONS - 1) / 2,
|
||||||
|
ZOOM_WINDOW_DIMENSIONS,
|
||||||
|
ZOOM_WINDOW_DIMENSIONS
|
||||||
|
);
|
||||||
|
this.cursorEyedropperPreviewImageData = previewRegion;
|
||||||
|
|
||||||
|
return rgb;
|
||||||
},
|
},
|
||||||
// Update scrollbars and rulers
|
// Update scrollbars and rulers
|
||||||
updateDocumentScrollbars(position: XY, size: XY, multiplier: XY) {
|
updateDocumentScrollbars(position: XY, size: XY, multiplier: XY) {
|
||||||
|
|
@ -383,12 +496,11 @@ export default defineComponent({
|
||||||
// Resize elements to render the new viewport size
|
// Resize elements to render the new viewport size
|
||||||
viewportResize() {
|
viewportResize() {
|
||||||
// Resize the canvas
|
// Resize the canvas
|
||||||
// Width and height are rounded up to the nearest even number because resizing is centered, and dividing an odd number by 2 for centering causes antialiasing
|
|
||||||
const canvas = this.$refs.canvas as HTMLElement;
|
const canvas = this.$refs.canvas as HTMLElement;
|
||||||
const width = Math.ceil(parseFloat(getComputedStyle(canvas).width));
|
const width = Math.ceil(parseFloat(getComputedStyle(canvas).width));
|
||||||
const height = Math.ceil(parseFloat(getComputedStyle(canvas).height));
|
const height = Math.ceil(parseFloat(getComputedStyle(canvas).height));
|
||||||
this.canvasSvgWidth = `${width % 2 === 1 ? width + 1 : width}px`;
|
this.canvasSvgWidth = width;
|
||||||
this.canvasSvgHeight = `${height % 2 === 1 ? height + 1 : height}px`;
|
this.canvasSvgHeight = height;
|
||||||
|
|
||||||
// Resize the rulers
|
// Resize the rulers
|
||||||
const rulerHorizontal = this.$refs.rulerHorizontal as typeof CanvasRuler;
|
const rulerHorizontal = this.$refs.rulerHorizontal as typeof CanvasRuler;
|
||||||
|
|
@ -396,51 +508,22 @@ export default defineComponent({
|
||||||
rulerHorizontal?.resize();
|
rulerHorizontal?.resize();
|
||||||
rulerVertical?.resize();
|
rulerVertical?.resize();
|
||||||
},
|
},
|
||||||
|
canvasDimensionCSS(dimension: number | undefined): string {
|
||||||
|
// Temporary placeholder until the first actual value is populated
|
||||||
|
// This at least gets close to the correct value but an actual number is required to prevent CSS from causing non-integer sizing making the SVG render with anti-aliasing
|
||||||
|
if (dimension === undefined) return "100%";
|
||||||
|
|
||||||
|
// Dimension is rounded up to the nearest even number because resizing is centered, and dividing an odd number by 2 for centering causes antialiasing
|
||||||
|
return `${dimension % 2 === 1 ? dimension + 1 : dimension}px`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
computed: {
|
||||||
this.panels.registerPanel("Document", this);
|
canvasWidthCSS(): string {
|
||||||
|
return this.canvasDimensionCSS(this.canvasSvgWidth);
|
||||||
// Once this component is mounted, we want to resend the document bounds to the backend via the resize event handler which does that
|
},
|
||||||
window.dispatchEvent(new Event("resize"));
|
canvasHeightCSS(): string {
|
||||||
},
|
return this.canvasDimensionCSS(this.canvasSvgHeight);
|
||||||
data() {
|
},
|
||||||
const scrollbarPos: XY = { x: 0.5, y: 0.5 };
|
|
||||||
const scrollbarSize: XY = { x: 0.5, y: 0.5 };
|
|
||||||
const scrollbarMultiplier: XY = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
const rulerOrigin: XY = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Interactive text editing
|
|
||||||
textInput: undefined as undefined | HTMLDivElement,
|
|
||||||
|
|
||||||
// CSS properties
|
|
||||||
canvasSvgWidth: "100%" as string,
|
|
||||||
canvasSvgHeight: "100%" as string,
|
|
||||||
canvasCursor: "default" as MouseCursorIcon,
|
|
||||||
|
|
||||||
// Scrollbars
|
|
||||||
scrollbarPos,
|
|
||||||
scrollbarSize,
|
|
||||||
scrollbarMultiplier,
|
|
||||||
|
|
||||||
// Rulers
|
|
||||||
rulerOrigin,
|
|
||||||
rulerSpacing: 100 as number,
|
|
||||||
rulerInterval: 100 as number,
|
|
||||||
|
|
||||||
// Rendered SVG viewport data
|
|
||||||
artworkSvg: "" as string,
|
|
||||||
artboardSvg: "" as string,
|
|
||||||
overlaysSvg: "" as string,
|
|
||||||
|
|
||||||
// Layouts
|
|
||||||
documentModeLayout: defaultWidgetLayout(),
|
|
||||||
toolOptionsLayout: defaultWidgetLayout(),
|
|
||||||
documentBarLayout: defaultWidgetLayout(),
|
|
||||||
toolShelfLayout: defaultWidgetLayout(),
|
|
||||||
workingColorsLayout: defaultWidgetLayout(),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
CanvasRuler,
|
CanvasRuler,
|
||||||
|
|
@ -448,6 +531,7 @@ export default defineComponent({
|
||||||
LayoutRow,
|
LayoutRow,
|
||||||
PersistentScrollbar,
|
PersistentScrollbar,
|
||||||
WidgetLayout,
|
WidgetLayout,
|
||||||
|
EyedropperPreview,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
UpdateDocumentOverlays,
|
UpdateDocumentOverlays,
|
||||||
UpdateDocumentRulers,
|
UpdateDocumentRulers,
|
||||||
UpdateDocumentScrollbars,
|
UpdateDocumentScrollbars,
|
||||||
|
UpdateEyedropperSamplingState,
|
||||||
UpdateMouseCursor,
|
UpdateMouseCursor,
|
||||||
UpdateToolOptionsLayout,
|
UpdateToolOptionsLayout,
|
||||||
UpdateToolShelfLayout,
|
UpdateToolShelfLayout,
|
||||||
|
|
@ -48,6 +49,16 @@ export function createPanelsState(editor: Editor) {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
state.documentPanel.updateDocumentArtboards(updateDocumentArtboards.svg);
|
state.documentPanel.updateDocumentArtboards(updateDocumentArtboards.svg);
|
||||||
});
|
});
|
||||||
|
editor.subscriptions.subscribeJsMessage(UpdateEyedropperSamplingState, async (updateEyedropperSamplingState) => {
|
||||||
|
await nextTick();
|
||||||
|
const { mousePosition, primaryColor, secondaryColor, setColorChoice } = updateEyedropperSamplingState;
|
||||||
|
const rgb = (await state.documentPanel.updateEyedropperSamplingState(mousePosition, primaryColor, secondaryColor)) as [number, number, number] | undefined;
|
||||||
|
|
||||||
|
if (setColorChoice && rgb) {
|
||||||
|
if (setColorChoice === "Primary") editor.instance.updatePrimaryColor(...rgb, 1);
|
||||||
|
if (setColorChoice === "Secondary") editor.instance.updateSecondaryColor(...rgb, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Update scrollbars and rulers
|
// Update scrollbars and rulers
|
||||||
editor.subscriptions.subscribeJsMessage(UpdateDocumentScrollbars, async (updateDocumentScrollbars) => {
|
editor.subscriptions.subscribeJsMessage(UpdateDocumentScrollbars, async (updateDocumentScrollbars) => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { blobToBase64 } from "@/utility-functions/files";
|
||||||
import { type RequestResult, requestWithUploadDownloadProgress } from "@/utility-functions/network";
|
import { type RequestResult, requestWithUploadDownloadProgress } from "@/utility-functions/network";
|
||||||
import { stripIndents } from "@/utility-functions/strip-indents";
|
import { stripIndents } from "@/utility-functions/strip-indents";
|
||||||
import { type Editor } from "@/wasm-communication/editor";
|
import { type Editor } from "@/wasm-communication/editor";
|
||||||
|
import type { XY } from "@/wasm-communication/messages";
|
||||||
import { type ImaginateGenerationParameters } from "@/wasm-communication/messages";
|
import { type ImaginateGenerationParameters } from "@/wasm-communication/messages";
|
||||||
|
|
||||||
const MAX_POLLING_RETRIES = 4;
|
const MAX_POLLING_RETRIES = 4;
|
||||||
|
|
@ -73,7 +74,7 @@ export async function imaginateGenerate(
|
||||||
|
|
||||||
// Send the backend a blob URL for the final image
|
// Send the backend a blob URL for the final image
|
||||||
const blobURL = URL.createObjectURL(blob);
|
const blobURL = URL.createObjectURL(blob);
|
||||||
editor.instance.setImaginateBlobURL(documentId, layerPath, blobURL, parameters.resolution[0], parameters.resolution[1]);
|
editor.instance.setImaginateBlobURL(documentId, layerPath, blobURL, parameters.resolution.x, parameters.resolution.y);
|
||||||
|
|
||||||
// Send the backend the blob data to be stored persistently in the layer
|
// Send the backend the blob data to be stored persistently in the layer
|
||||||
const u8Array = new Uint8Array(await blob.arrayBuffer());
|
const u8Array = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
|
@ -134,7 +135,7 @@ function scheduleNextPollingUpdate(
|
||||||
hostname: string,
|
hostname: string,
|
||||||
documentId: bigint,
|
documentId: bigint,
|
||||||
layerPath: BigUint64Array,
|
layerPath: BigUint64Array,
|
||||||
resolution: [number, number]
|
resolution: XY
|
||||||
): void {
|
): void {
|
||||||
// Pick a future time that keeps to the user-requested interval if possible, but on slower connections will go as fast as possible without overlapping itself
|
// Pick a future time that keeps to the user-requested interval if possible, but on slower connections will go as fast as possible without overlapping itself
|
||||||
const nextPollTimeGoal = timeoutBegan + interval;
|
const nextPollTimeGoal = timeoutBegan + interval;
|
||||||
|
|
@ -148,7 +149,7 @@ function scheduleNextPollingUpdate(
|
||||||
if (terminated) return;
|
if (terminated) return;
|
||||||
|
|
||||||
const blobURL = URL.createObjectURL(blob);
|
const blobURL = URL.createObjectURL(blob);
|
||||||
editor.instance.setImaginateBlobURL(documentId, layerPath, blobURL, resolution[0], resolution[1]);
|
editor.instance.setImaginateBlobURL(documentId, layerPath, blobURL, resolution.x, resolution.y);
|
||||||
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, percentComplete, "Generating");
|
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, percentComplete, "Generating");
|
||||||
|
|
||||||
scheduleNextPollingUpdate(interval, nextTimeoutBegan, 0, editor, hostname, documentId, layerPath, resolution);
|
scheduleNextPollingUpdate(interval, nextTimeoutBegan, 0, editor, hostname, documentId, layerPath, resolution);
|
||||||
|
|
@ -244,8 +245,8 @@ async function generate(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
false,
|
false,
|
||||||
${parameters.resolution[1]},
|
${parameters.resolution.y},
|
||||||
${parameters.resolution[0]},
|
${parameters.resolution.x},
|
||||||
false,
|
false,
|
||||||
0.7,
|
0.7,
|
||||||
0,
|
0,
|
||||||
|
|
@ -301,8 +302,8 @@ async function generate(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
false,
|
false,
|
||||||
${parameters.resolution[1]},
|
${parameters.resolution.y},
|
||||||
${parameters.resolution[0]},
|
${parameters.resolution.x},
|
||||||
"Just resize",
|
"Just resize",
|
||||||
false,
|
false,
|
||||||
32,
|
32,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import { replaceBlobURLsWithBase64 } from "@/utility-functions/files";
|
import { replaceBlobURLsWithBase64 } from "@/utility-functions/files";
|
||||||
|
|
||||||
// Rasterize the string of an SVG document at a given width and height and turn it into the blob data of an image file matching the given MIME type
|
// Rasterize the string of an SVG document at a given width and height and turn it into the blob data of an image file matching the given MIME type
|
||||||
export async function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise<Blob> {
|
export async function rasterizeSVGCanvas(svg: string, width: number, height: number, backgroundColor?: string): Promise<HTMLCanvasElement> {
|
||||||
let promiseResolve: (value: Blob | PromiseLike<Blob>) => void | undefined;
|
let promiseResolve: (value: HTMLCanvasElement | PromiseLike<HTMLCanvasElement>) => void | undefined;
|
||||||
let promiseReject: () => void | undefined;
|
const promise = new Promise<HTMLCanvasElement>((resolve) => {
|
||||||
const promise = new Promise<Blob>((resolve, reject) => {
|
|
||||||
promiseResolve = resolve;
|
promiseResolve = resolve;
|
||||||
promiseReject = reject;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// A canvas to render our svg to in order to get a raster image
|
// A canvas to render our svg to in order to get a raster image
|
||||||
|
|
@ -14,8 +12,8 @@ export async function rasterizeSVG(svg: string, width: number, height: number, m
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
if (!context) return Promise.reject();
|
if (!context) throw new Error("Can't create 2D context from canvas during SVG rasterization");
|
||||||
|
|
||||||
// Apply a background fill color if one is given
|
// Apply a background fill color if one is given
|
||||||
if (backgroundColor) {
|
if (backgroundColor) {
|
||||||
|
|
@ -37,13 +35,28 @@ export async function rasterizeSVG(svg: string, width: number, height: number, m
|
||||||
// Clean up the SVG blob URL (once the URL is revoked, the SVG blob data itself is garbage collected after `svgBlob` goes out of scope)
|
// Clean up the SVG blob URL (once the URL is revoked, the SVG blob data itself is garbage collected after `svgBlob` goes out of scope)
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
// Convert the canvas to an image of the correct MIME type
|
promiseResolve(canvas);
|
||||||
canvas.toBlob((blob) => {
|
|
||||||
if (blob !== null) promiseResolve(blob);
|
|
||||||
else promiseReject();
|
|
||||||
}, mime);
|
|
||||||
};
|
};
|
||||||
image.src = url;
|
image.src = url;
|
||||||
|
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise<Blob> {
|
||||||
|
let promiseResolve: (value: Blob | PromiseLike<Blob>) => void | undefined;
|
||||||
|
let promiseReject: () => void | undefined;
|
||||||
|
const promise = new Promise<Blob>((resolve, reject) => {
|
||||||
|
promiseResolve = resolve;
|
||||||
|
promiseReject = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
rasterizeSVGCanvas(svg, width, height, backgroundColor).then((canvas) => {
|
||||||
|
// Convert the canvas to an image of the correct MIME type
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob !== null) promiseResolve(blob);
|
||||||
|
else promiseReject();
|
||||||
|
}, mime);
|
||||||
|
});
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ export class UpdateDocumentArtboards extends JsMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
const TupleToVec2 = Transform(({ value }: { value: [number, number] | undefined }) => (value === undefined ? undefined : { x: value[0], y: value[1] }));
|
const TupleToVec2 = Transform(({ value }: { value: [number, number] | undefined }) => (value === undefined ? undefined : { x: value[0], y: value[1] }));
|
||||||
const BigIntTupleToNumberTuple = Transform(({ value }: { value: [bigint, bigint] | undefined }) => (value === undefined ? undefined : [Number(value[0]), Number(value[1])]));
|
const BigIntTupleToVec2 = Transform(({ value }: { value: [bigint, bigint] | undefined }) => (value === undefined ? undefined : { x: Number(value[0]), y: Number(value[1]) }));
|
||||||
|
|
||||||
export type XY = { x: number; y: number };
|
export type XY = { x: number; y: number };
|
||||||
|
|
||||||
|
|
@ -190,7 +190,19 @@ export class UpdateDocumentRulers extends JsMessage {
|
||||||
readonly interval!: number;
|
readonly interval!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateEyedropperSamplingState extends JsMessage {
|
||||||
|
@TupleToVec2
|
||||||
|
readonly mousePosition!: XY | undefined;
|
||||||
|
|
||||||
|
readonly primaryColor!: string;
|
||||||
|
|
||||||
|
readonly secondaryColor!: string;
|
||||||
|
|
||||||
|
readonly setColorChoice!: "Primary" | "Secondary" | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const mouseCursorIconCSSNames = {
|
const mouseCursorIconCSSNames = {
|
||||||
|
None: "none",
|
||||||
ZoomIn: "zoom-in",
|
ZoomIn: "zoom-in",
|
||||||
ZoomOut: "zoom-out",
|
ZoomOut: "zoom-out",
|
||||||
Grabbing: "grabbing",
|
Grabbing: "grabbing",
|
||||||
|
|
@ -278,8 +290,8 @@ export class ImaginateGenerationParameters {
|
||||||
|
|
||||||
readonly negativePrompt!: string;
|
readonly negativePrompt!: string;
|
||||||
|
|
||||||
@BigIntTupleToNumberTuple
|
@BigIntTupleToVec2
|
||||||
readonly resolution!: [number, number];
|
readonly resolution!: XY;
|
||||||
|
|
||||||
readonly restoreFaces!: boolean;
|
readonly restoreFaces!: boolean;
|
||||||
|
|
||||||
|
|
@ -1008,6 +1020,7 @@ export const messageMakers: Record<string, MessageMaker> = {
|
||||||
UpdateDocumentModeLayout,
|
UpdateDocumentModeLayout,
|
||||||
UpdateDocumentOverlays,
|
UpdateDocumentOverlays,
|
||||||
UpdateDocumentRulers,
|
UpdateDocumentRulers,
|
||||||
|
UpdateEyedropperSamplingState,
|
||||||
UpdateDocumentScrollbars,
|
UpdateDocumentScrollbars,
|
||||||
UpdateImageData,
|
UpdateImageData,
|
||||||
UpdateInputHints,
|
UpdateInputHints,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue