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()
.map(|(val, name)| RadioEntryData {
label: name.into(),

View File

@ -157,6 +157,16 @@ pub enum FrontendMessage {
size: (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 {
#[serde(rename = "documentId")]
document_id: u64,

View File

@ -21,6 +21,7 @@ pub struct FrontendImageData {
pub enum MouseCursorIcon {
#[default]
Default,
None,
ZoomIn,
ZoomOut,
Grabbing,
@ -36,17 +37,17 @@ pub enum MouseCursorIcon {
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub enum FileType {
#[default]
Svg,
Png,
Jpg,
Svg,
}
impl FileType {
pub fn to_mime(self) -> &'static str {
match self {
FileType::Svg => "image/svg+xml",
FileType::Png => "image/png",
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),
//
// EyedropperToolMessage
entry!(KeyDown(Lmb); action_dispatch=EyedropperToolMessage::LeftMouseDown),
entry!(KeyDown(Rmb); action_dispatch=EyedropperToolMessage::RightMouseDown),
entry!(PointerMove; action_dispatch=EyedropperToolMessage::PointerMove),
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
entry!(KeyUp(Lmb); action_dispatch=TextToolMessage::Interact),
@ -170,8 +174,8 @@ pub fn default_mapping() -> Mapping {
entry!(KeyDown(Enter); action_dispatch=SplineToolMessage::Confirm),
//
// FillToolMessage
entry!(KeyDown(Lmb); action_dispatch=FillToolMessage::LeftMouseDown),
entry!(KeyDown(Rmb); action_dispatch=FillToolMessage::RightMouseDown),
entry!(KeyDown(Lmb); action_dispatch=FillToolMessage::LeftPointerDown),
entry!(KeyDown(Rmb); action_dispatch=FillToolMessage::RightPointerDown),
//
// ToolMessage
entry!(KeyDown(KeyV); action_dispatch=ToolMessage::ActivateToolSelect),

View File

@ -27,6 +27,10 @@ impl ViewportBounds {
pub fn center(&self) -> DVec2 {
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)]

View File

@ -910,8 +910,8 @@ fn node_section_imaginate(imaginate_layer: &ImaginateLayer, layer: &Layer, persi
\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\
To boost 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\"
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 green ideas (sleep:1.3) furiously\"
"
.trim()
.into(),

View File

@ -69,7 +69,7 @@ impl MessageHandler<ToolMessage, (&DocumentMessageHandler, u64, &InputPreprocess
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| {
if let Some(tool) = tool_data.tools.get_mut(&tool_type) {
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(old_tool, false);

View File

@ -1,15 +1,10 @@
use crate::consts::SELECTION_TOLERANCE;
use crate::messages::frontend::utility_types::MouseCursorIcon;
use crate::messages::input_mapper::utility_types::input_keyboard::MouseMotion;
use crate::messages::layout::utility_types::layout_widget::PropertyHolder;
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 graphene::intersection::Quad;
use graphene::layers::layer_info::LayerDataType;
use glam::DVec2;
use serde::{Deserialize, Serialize};
#[derive(Default)]
@ -27,8 +22,11 @@ pub enum EyedropperToolMessage {
Abort,
// Tool-specific messages
LeftMouseDown,
RightMouseDown,
LeftPointerDown,
LeftPointerUp,
PointerMove,
RightPointerDown,
RightPointerUp,
}
impl ToolMetadata for EyedropperTool {
@ -67,8 +65,12 @@ impl<'a> MessageHandler<ToolMessage, ToolActionHandlerData<'a>> for EyedropperTo
}
advertise_actions!(EyedropperToolMessageDiscriminant;
LeftMouseDown,
RightMouseDown,
LeftPointerDown,
LeftPointerUp,
PointerMove,
RightPointerDown,
RightPointerUp,
Abort,
);
}
@ -85,6 +87,8 @@ impl ToolTransition for EyedropperTool {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum EyedropperToolFsmState {
Ready,
SamplingPrimary,
SamplingSecondary,
}
impl Default for EyedropperToolFsmState {
@ -104,7 +108,7 @@ impl Fsm for EyedropperToolFsmState {
self,
event: ToolMessage,
_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,
responses: &mut VecDeque<Message>,
) -> Self {
@ -113,28 +117,41 @@ impl Fsm for EyedropperToolFsmState {
if let ToolMessage::Eyedropper(event) = event {
match (self, event) {
(Ready, lmb_or_rmb) if lmb_or_rmb == LeftMouseDown || lmb_or_rmb == RightMouseDown => {
let mouse_pos = input.mouse.position;
let tolerance = DVec2::splat(SELECTION_TOLERANCE);
let quad = Quad::from_box([mouse_pos - tolerance, mouse_pos + tolerance]);
// Ready -> Sampling
(Ready, mouse_down) | (Ready, mouse_down) if mouse_down == LeftPointerDown || mouse_down == RightPointerDown => {
update_cursor_preview(responses, input, global_tool_data, None);
// TODO: Destroy this pyramid
if let Some(path) = document.graphene_document.intersects_quad_root(quad, font_cache).last() {
if let Ok(layer) = document.graphene_document.layer(path) {
if let LayerDataType::Shape(shape) = &layer.data {
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()),
_ => {}
}
}
}
}
if mouse_down == LeftPointerDown {
SamplingPrimary
} else {
SamplingSecondary
}
}
// 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
}
// Any -> Ready
(_, Abort) => {
disable_cursor_preview(responses);
Ready
}
// Ready -> Ready
_ => self,
}
} else {
@ -160,12 +177,43 @@ impl Fsm for EyedropperToolFsmState {
plus: false,
},
])]),
EyedropperToolFsmState::SamplingPrimary => HintData(vec![]),
EyedropperToolFsmState::SamplingSecondary => HintData(vec![]),
};
responses.push_back(FrontendMessage::UpdateInputHints { hint_data }.into());
}
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,
// Tool-specific messages
LeftMouseDown,
RightMouseDown,
LeftPointerDown,
RightPointerDown,
}
impl ToolMetadata for FillTool {
@ -68,8 +68,8 @@ impl<'a> MessageHandler<ToolMessage, ToolActionHandlerData<'a>> for FillTool {
}
advertise_actions!(FillToolMessageDiscriminant;
LeftMouseDown,
RightMouseDown,
LeftPointerDown,
RightPointerDown,
);
}
@ -114,15 +114,15 @@ impl Fsm for FillToolFsmState {
if let ToolMessage::Fill(event) = 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 tolerance = DVec2::splat(SELECTION_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() {
let color = match lmb_or_rmb {
LeftMouseDown => global_tool_data.primary_color,
RightMouseDown => global_tool_data.secondary_color,
LeftPointerDown => global_tool_data.primary_color,
RightPointerDown => global_tool_data.secondary_color,
Abort => unreachable!(),
};
let fill = Fill::Solid(color);

View File

@ -78,6 +78,8 @@ impl DocumentToolData {
}
.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;
}
&.cursor .floating-menu-container .floating-menu-content {
background: none;
box-shadow: none;
border-radius: 0;
padding: 0;
}
&.center {
justify-content: center;
align-items: center;
@ -180,7 +187,7 @@ import { defineComponent, nextTick, type PropType } from "vue";
import LayoutCol from "@/components/layout/LayoutCol.vue";
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;
@ -235,6 +242,8 @@ export default defineComponent({
this.minWidthParentWidth = entries[0].contentRect.width;
},
positionAndStyleFloatingMenu() {
if (this.type === "Cursor") return;
const workspace = document.querySelector("[data-workspace]");
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
const floatingMenuContentComponent = this.$refs.floatingMenuContent as typeof LayoutCol;

View File

@ -28,25 +28,25 @@
<LayoutCol class="bar-area">
<CanvasRuler :origin="rulerOrigin.y" :majorMarkSpacing="rulerSpacing" :numberInterval="rulerInterval" :direction="'Vertical'" ref="rulerVertical" />
</LayoutCol>
<LayoutCol class="canvas-area">
<div
class="canvas"
:style="{ cursor: canvasCursor }"
@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>
<LayoutCol class="canvas-area" :style="{ cursor: canvasCursor }">
<EyedropperPreview
v-if="cursorEyedropper"
:colorChoice="cursorEyedropperPreviewColorChoice"
:primaryColor="cursorEyedropperPreviewColorPrimary"
:secondaryColor="cursorEyedropperPreviewColorSecondary"
:imageData="cursorEyedropperPreviewImageData"
:style="{ left: cursorLeft + 'px', top: cursorTop + 'px' }"
/>
<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: canvasWidthCSS, height: canvasHeightCSS }"></svg>
<svg
class="artwork"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
v-html="artworkSvg"
:style="{ width: canvasSvgWidth, height: canvasSvgHeight }"
:style="{ width: canvasWidthCSS, height: canvasHeightCSS }"
></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>
</LayoutCol>
<LayoutCol class="bar-area">
@ -149,6 +149,7 @@
.canvas-area {
flex: 1 1 100%;
position: relative;
}
.bar-area {
@ -224,6 +225,7 @@
import { defineComponent, nextTick } from "vue";
import { textInputCleanup } from "@/utility-functions/keyboard-entry";
import { rasterizeSVGCanvas } from "@/utility-functions/rasterization";
import {
defaultWidgetLayout,
type DisplayEditableTextbox,
@ -236,6 +238,7 @@ import {
type XY,
} from "@/wasm-communication/messages";
import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@/components/floating-menus/EyedropperPreview.vue";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import CanvasRuler from "@/components/widgets/metrics/CanvasRuler.vue";
@ -244,6 +247,64 @@ import WidgetLayout from "@/components/widgets/WidgetLayout.vue";
export default defineComponent({
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: {
pasteFile(e: DragEvent) {
const { dataTransfer } = e;
@ -288,6 +349,7 @@ export default defineComponent({
// Update rendered SVGs
async updateDocumentArtwork(svg: string) {
this.artworkSvg = svg;
this.rasterizedCanvas = undefined;
await nextTick();
@ -321,6 +383,57 @@ export default defineComponent({
},
updateDocumentArtboards(svg: string) {
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
updateDocumentScrollbars(position: XY, size: XY, multiplier: XY) {
@ -383,12 +496,11 @@ export default defineComponent({
// Resize elements to render the new viewport size
viewportResize() {
// 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 width = Math.ceil(parseFloat(getComputedStyle(canvas).width));
const height = Math.ceil(parseFloat(getComputedStyle(canvas).height));
this.canvasSvgWidth = `${width % 2 === 1 ? width + 1 : width}px`;
this.canvasSvgHeight = `${height % 2 === 1 ? height + 1 : height}px`;
this.canvasSvgWidth = width;
this.canvasSvgHeight = height;
// Resize the rulers
const rulerHorizontal = this.$refs.rulerHorizontal as typeof CanvasRuler;
@ -396,51 +508,22 @@ export default defineComponent({
rulerHorizontal?.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() {
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"));
},
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(),
};
computed: {
canvasWidthCSS(): string {
return this.canvasDimensionCSS(this.canvasSvgWidth);
},
canvasHeightCSS(): string {
return this.canvasDimensionCSS(this.canvasSvgHeight);
},
},
components: {
CanvasRuler,
@ -448,6 +531,7 @@ export default defineComponent({
LayoutRow,
PersistentScrollbar,
WidgetLayout,
EyedropperPreview,
},
});
</script>

View File

@ -14,6 +14,7 @@ import {
UpdateDocumentOverlays,
UpdateDocumentRulers,
UpdateDocumentScrollbars,
UpdateEyedropperSamplingState,
UpdateMouseCursor,
UpdateToolOptionsLayout,
UpdateToolShelfLayout,
@ -48,6 +49,16 @@ export function createPanelsState(editor: Editor) {
await nextTick();
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
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 { stripIndents } from "@/utility-functions/strip-indents";
import { type Editor } from "@/wasm-communication/editor";
import type { XY } from "@/wasm-communication/messages";
import { type ImaginateGenerationParameters } from "@/wasm-communication/messages";
const MAX_POLLING_RETRIES = 4;
@ -73,7 +74,7 @@ export async function imaginateGenerate(
// Send the backend a blob URL for the final image
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
const u8Array = new Uint8Array(await blob.arrayBuffer());
@ -134,7 +135,7 @@ function scheduleNextPollingUpdate(
hostname: string,
documentId: bigint,
layerPath: BigUint64Array,
resolution: [number, number]
resolution: XY
): 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
const nextPollTimeGoal = timeoutBegan + interval;
@ -148,7 +149,7 @@ function scheduleNextPollingUpdate(
if (terminated) return;
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");
scheduleNextPollingUpdate(interval, nextTimeoutBegan, 0, editor, hostname, documentId, layerPath, resolution);
@ -244,8 +245,8 @@ async function generate(
0,
0,
false,
${parameters.resolution[1]},
${parameters.resolution[0]},
${parameters.resolution.y},
${parameters.resolution.x},
false,
0.7,
0,
@ -301,8 +302,8 @@ async function generate(
0,
0,
false,
${parameters.resolution[1]},
${parameters.resolution[0]},
${parameters.resolution.y},
${parameters.resolution.x},
"Just resize",
false,
32,

View File

@ -1,12 +1,10 @@
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
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) => {
export async function rasterizeSVGCanvas(svg: string, width: number, height: number, backgroundColor?: string): Promise<HTMLCanvasElement> {
let promiseResolve: (value: HTMLCanvasElement | PromiseLike<HTMLCanvasElement>) => void | undefined;
const promise = new Promise<HTMLCanvasElement>((resolve) => {
promiseResolve = resolve;
promiseReject = reject;
});
// 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");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) return Promise.reject();
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("Can't create 2D context from canvas during SVG rasterization");
// Apply a background fill color if one is given
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)
URL.revokeObjectURL(url);
// Convert the canvas to an image of the correct MIME type
canvas.toBlob((blob) => {
if (blob !== null) promiseResolve(blob);
else promiseReject();
}, mime);
promiseResolve(canvas);
};
image.src = url;
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 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 };
@ -190,7 +190,19 @@ export class UpdateDocumentRulers extends JsMessage {
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 = {
None: "none",
ZoomIn: "zoom-in",
ZoomOut: "zoom-out",
Grabbing: "grabbing",
@ -278,8 +290,8 @@ export class ImaginateGenerationParameters {
readonly negativePrompt!: string;
@BigIntTupleToNumberTuple
readonly resolution!: [number, number];
@BigIntTupleToVec2
readonly resolution!: XY;
readonly restoreFaces!: boolean;
@ -1008,6 +1020,7 @@ export const messageMakers: Record<string, MessageMaker> = {
UpdateDocumentModeLayout,
UpdateDocumentOverlays,
UpdateDocumentRulers,
UpdateEyedropperSamplingState,
UpdateDocumentScrollbars,
UpdateImageData,
UpdateInputHints,