diff --git a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs index cbb7038b6d..e5019cbd9e 100644 --- a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs +++ b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs @@ -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(), diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index fc5c92e207..ef6c962dab 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -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, + }, UpdateImageData { #[serde(rename = "documentId")] document_id: u64, diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index 439990bfa6..883a46b397 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -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", } } } diff --git a/editor/src/messages/input_mapper/default_mapping.rs b/editor/src/messages/input_mapper/default_mapping.rs index 0770d6e842..a43766fe59 100644 --- a/editor/src/messages/input_mapper/default_mapping.rs +++ b/editor/src/messages/input_mapper/default_mapping.rs @@ -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), diff --git a/editor/src/messages/input_mapper/utility_types/input_mouse.rs b/editor/src/messages/input_mapper/utility_types/input_mouse.rs index 7cbf86c471..62d3003650 100644 --- a/editor/src/messages/input_mapper/utility_types/input_mouse.rs +++ b/editor/src/messages/input_mapper/utility_types/input_mouse.rs @@ -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)] diff --git a/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs b/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs index f7bc2d6628..608ef2114b 100644 --- a/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs +++ b/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs @@ -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(), diff --git a/editor/src/messages/tool/tool_message_handler.rs b/editor/src/messages/tool/tool_message_handler.rs index 4de6f8369f..44eaadfcb3 100644 --- a/editor/src/messages/tool/tool_message_handler.rs +++ b/editor/src/messages/tool/tool_message_handler.rs @@ -69,7 +69,7 @@ impl MessageHandler MessageHandler> 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, ) -> 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]); - - // 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()), - _ => {} - } - } - } - } + // Ready -> Sampling + (Ready, mouse_down) | (Ready, mouse_down) if mouse_down == LeftPointerDown || mouse_down == RightPointerDown => { + update_cursor_preview(responses, input, global_tool_data, None); + + 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) { - 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) { + 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, input: &InputPreprocessorMessageHandler, global_tool_data: &DocumentToolData, set_color_choice: Option) { + 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(), + ); +} diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index 03960a6553..4324d811b1 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -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> 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); diff --git a/editor/src/messages/tool/utility_types.rs b/editor/src/messages/tool/utility_types.rs index 84ad2bb9e7..179e9cfa8f 100644 --- a/editor/src/messages/tool/utility_types.rs +++ b/editor/src/messages/tool/utility_types.rs @@ -78,6 +78,8 @@ impl DocumentToolData { } .into(), ); + + responses.push_back(EyedropperToolMessage::PointerMove.into()); } } diff --git a/frontend/src/components/floating-menus/EyedropperPreview.vue b/frontend/src/components/floating-menus/EyedropperPreview.vue new file mode 100644 index 0000000000..05c8fae10a --- /dev/null +++ b/frontend/src/components/floating-menus/EyedropperPreview.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/frontend/src/components/floating-menus/FloatingMenu.vue b/frontend/src/components/floating-menus/FloatingMenu.vue index 385e1fd568..522389cd38 100644 --- a/frontend/src/components/floating-menus/FloatingMenu.vue +++ b/frontend/src/components/floating-menus/FloatingMenu.vue @@ -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; diff --git a/frontend/src/components/panels/Document.vue b/frontend/src/components/panels/Document.vue index 970f200a8f..0bd89d89a7 100644 --- a/frontend/src/components/panels/Document.vue +++ b/frontend/src/components/panels/Document.vue @@ -28,25 +28,25 @@ - -
- + + +
+ - +
@@ -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 = ``; + const artboards = this.artboardSvg; + const artwork = this.artworkSvg; + const svg = ` + ${outsideArtboards}${artboards}${artwork} + `.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(); }, - }, - mounted() { - this.panels.registerPanel("Document", this); + 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%"; - // 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")); + // 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`; + }, }, - 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, }, }); diff --git a/frontend/src/state-providers/panels.ts b/frontend/src/state-providers/panels.ts index 466a5acfaf..8cff6b8f91 100644 --- a/frontend/src/state-providers/panels.ts +++ b/frontend/src/state-providers/panels.ts @@ -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) => { diff --git a/frontend/src/utility-functions/imaginate.ts b/frontend/src/utility-functions/imaginate.ts index ae1924381d..f37bcf5e92 100644 --- a/frontend/src/utility-functions/imaginate.ts +++ b/frontend/src/utility-functions/imaginate.ts @@ -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, diff --git a/frontend/src/utility-functions/rasterization.ts b/frontend/src/utility-functions/rasterization.ts index 56392d568b..b776438dc7 100644 --- a/frontend/src/utility-functions/rasterization.ts +++ b/frontend/src/utility-functions/rasterization.ts @@ -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 { - let promiseResolve: (value: Blob | PromiseLike) => void | undefined; - let promiseReject: () => void | undefined; - const promise = new Promise((resolve, reject) => { +export async function rasterizeSVGCanvas(svg: string, width: number, height: number, backgroundColor?: string): Promise { + let promiseResolve: (value: HTMLCanvasElement | PromiseLike) => void | undefined; + const promise = new Promise((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); + promiseResolve(canvas); + }; + image.src = url; + + return promise; +} + +export async function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise { + let promiseResolve: (value: Blob | PromiseLike) => void | undefined; + let promiseReject: () => void | undefined; + const promise = new Promise((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); - }; - image.src = url; + }); return promise; } diff --git a/frontend/src/wasm-communication/messages.ts b/frontend/src/wasm-communication/messages.ts index 2b0790ccaa..fb79bf9d89 100644 --- a/frontend/src/wasm-communication/messages.ts +++ b/frontend/src/wasm-communication/messages.ts @@ -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 = { UpdateDocumentModeLayout, UpdateDocumentOverlays, UpdateDocumentRulers, + UpdateEyedropperSamplingState, UpdateDocumentScrollbars, UpdateImageData, UpdateInputHints,