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:
Keavon Chambers 2022-10-21 01:50:02 -07:00
parent fe1a03fac7
commit 58a53a995d
17 changed files with 454 additions and 131 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -78,6 +78,8 @@ impl DocumentToolData {
} }
.into(), .into(),
); );
responses.push_back(EyedropperToolMessage::PointerMove.into());
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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