Merge pull request #7 from ninjadev64/elgato

Implement support for Elgato devices
This commit is contained in:
ninjadev64 authored and GitHub committed 2024-02-22 20:48:23 +00:00
commit c3184071c7
16 files changed
+386 -145

No files matched your search

+52 -5
View File
@@ -106,6 +106,17 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "async-recursion"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.38",
]
[[package]]
name = "atk"
version = "0.15.1"
@@ -159,9 +170,9 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
version = "0.21.5"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "bitflags"
@@ -722,6 +733,18 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b"
[[package]]
name = "elgato-streamdeck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa5a8005cf7a867a16d968803de4b182bc2815448af1d23865fcc025ad45ea38"
dependencies = [
"async-recursion",
"hidapi",
"image",
"tokio",
]
[[package]]
name = "embed-resource"
version = "2.4.0"
@@ -1286,6 +1309,19 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hidapi"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b125253e27c9fd67beac20665348f4bfc5b488b5c8a1020610eeb7e6d205cde"
dependencies = [
"cc",
"cfg-if",
"libc",
"pkg-config",
"windows-sys 0.48.0",
]
[[package]]
name = "html5ever"
version = "0.25.2"
@@ -1418,6 +1454,7 @@ dependencies = [
"bytemuck",
"byteorder",
"color_quant",
"jpeg-decoder",
"num-rational",
"num-traits",
]
@@ -1517,6 +1554,12 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jpeg-decoder"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
[[package]]
name = "js-sys"
version = "0.3.65"
@@ -1990,7 +2033,11 @@ name = "opendeck"
version = "2.0.0"
dependencies = [
"anyhow",
"base64 0.21.7",
"elgato-streamdeck",
"futures-util",
"hidapi",
"image",
"lazy_static",
"log",
"os_info",
@@ -2227,7 +2274,7 @@ version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5699cc8a63d1aa2b1ee8e12b9ad70ac790d65788cd36101fa37f87ea46c4cef"
dependencies = [
"base64 0.21.5",
"base64 0.21.7",
"indexmap 2.1.0",
"line-wrap",
"quick-xml",
@@ -2752,7 +2799,7 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd236ccc1b7a29e7e2739f27c0b2dd199804abc4290e32f59f3b68d6405c23"
dependencies = [
"base64 0.21.5",
"base64 0.21.7",
"chrono",
"hex",
"indexmap 1.9.3",
@@ -3210,7 +3257,7 @@ version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b3475e55acec0b4a50fb96435f19631fb58cbcd31923e1a213de5c382536bbb"
dependencies = [
"base64 0.21.5",
"base64 0.21.7",
"brotli",
"ico",
"json-patch",
+5 -1
View File
@@ -14,13 +14,16 @@ tauri-build = { version = "1.5.0", features = [] }
[dependencies]
# Large, fundamental libraries
tauri = { version = "1.5.2", features = [ "shell-execute", "shell-open", "protocol-asset", "system-tray", "devtools" ] }
tauri = { version = "1.5.2", features = [ "shell-execute", "shell-open", "system-tray", "devtools" ] }
serde = { version = "1.0", features = [ "derive" ] }
serde_json = "1.0"
serialport = "4.2.2"
tokio = { version = "1.34.0", features = [ "full" ] }
tokio-tungstenite = "0.20.1"
tiny_http = "0.12.0"
elgato-streamdeck = { version = "0.5.0", default-features = false, features = [ "async" ] }
hidapi = { version = "2.5.0", default-features = false, features = [ "linux-static-hidraw", "macos-shared-device", "windows-native" ] }
image = { version = "0.24.6", default-features = false, features = [ "bmp", "jpeg" ] }
# Smaller utility libraries
futures-util = "0.3.29"
anyhow = "1.0.75"
@@ -31,6 +34,7 @@ serde_with = "3.4.0"
lazy_static = "1.4.0"
os_info = "3.7.0"
urlencoding = "2.1.3"
base64 = "0.21.7"
[features]
custom-protocol = [ "tauri/custom-protocol" ]
+72
View File
@@ -0,0 +1,72 @@
use crate::events::outbound::{keypad, encoder};
use std::collections::HashMap;
use elgato_streamdeck::{AsyncStreamDeck, DeviceStateUpdate, info};
use lazy_static::lazy_static;
use tokio::sync::Mutex;
use base64::Engine as _;
lazy_static! {
static ref ELGATO_DEVICES: Mutex<HashMap<String, AsyncStreamDeck>> = Mutex::new(HashMap::new());
}
pub async fn update_image(context: &crate::shared::ActionContext, url: &str) -> Result<(), anyhow::Error> {
if let Some(device) = ELGATO_DEVICES.lock().await.get(&context.device) {
let data = url.split_once(',').unwrap().1;
let bytes = base64::engine::general_purpose::STANDARD.decode(data)?;
device.set_button_image(context.position, image::load_from_memory(&bytes)?).await?;
}
Ok(())
}
pub async fn clear_image(context: &crate::shared::ActionContext) -> Result<(), anyhow::Error> {
if let Some(device) = ELGATO_DEVICES.lock().await.get(&context.device) {
device.clear_button_image(context.position).await?;
}
Ok(())
}
pub(super) async fn init(device: AsyncStreamDeck) {
let kind = device.kind();
let device_type = match kind.product_id() {
info::PID_STREAMDECK_ORIGINAL | info::PID_STREAMDECK_ORIGINAL_V2 | info::PID_STREAMDECK_MK2 => 0,
info::PID_STREAMDECK_MINI | info::PID_STREAMDECK_MINI_MK2 => 1,
info::PID_STREAMDECK_XL | info::PID_STREAMDECK_XL_V2 => 2,
info::PID_STREAMDECK_PEDAL => 5,
info::PID_STREAMDECK_PLUS => 7,
_ => 7
};
let device_id = format!("sd-{}", device.serial_number().await.unwrap());
super::DEVICES.lock().await.insert(device_id.clone(), super::DeviceInfo {
id: device_id.clone(),
name: device.product().await.unwrap(),
rows: kind.row_count(),
columns: kind.column_count(),
sliders: kind.encoder_count(),
r#type: device_type
});
let reader = device.get_reader();
ELGATO_DEVICES.lock().await.insert(device_id.clone(), device);
loop {
let updates = match reader.read(100.0).await {
Ok(updates) => updates,
Err(_) => continue
};
for update in updates {
match match update {
DeviceStateUpdate::ButtonDown(key) => keypad::key_down(&device_id, key).await,
DeviceStateUpdate::ButtonUp(key) => keypad::key_up(&device_id, key).await,
DeviceStateUpdate::EncoderTwist(dial, ticks) => encoder::dial_rotate(&device_id, dial, ticks.into()).await,
DeviceStateUpdate::EncoderDown(dial) => encoder::dial_press(&device_id, "dialDown", dial).await,
DeviceStateUpdate::EncoderUp(dial) => encoder::dial_press(&device_id, "dialUp", dial).await,
_ => Ok(())
} {
Ok(_) => (),
Err(error) => log::warn!("Failed to process device event {:?}: {}", update, error)
}
}
}
}
+17 -1
View File
@@ -1,4 +1,5 @@
mod prontokey;
pub mod elgato;
use std::collections::HashMap;
@@ -6,6 +7,7 @@ use lazy_static::lazy_static;
use tokio::sync::Mutex;
use serde::Serialize;
use log::warn;
/// Metadata of a device.
#[derive(Clone, Serialize)]
@@ -28,11 +30,25 @@ pub fn initialise_devices() {
for port in serialport::available_ports().unwrap_or_default() {
if let serialport::SerialPortType::UsbPort(info) = port.port_type {
if info.vid == 0x10c4 && info.pid == 0xea60 {
tokio::spawn(prontokey::ProntoKeyDevice::init(port.port_name));
tokio::spawn(prontokey::init(port.port_name));
}
}
}
// Iterate through detected Elgato devices and attempt to register them.
match elgato_streamdeck::new_hidapi() {
Ok(hid) => {
for (kind, serial) in elgato_streamdeck::asynchronous::list_devices_async(&hid) {
match elgato_streamdeck::AsyncStreamDeck::connect(&hid, kind, &serial) {
Ok(device) => { tokio::spawn(elgato::init(device)); },
Err(error) => warn!("Failed to connect to Elgato device: {}", error)
}
}
},
Err(error) => warn!("Failed to initialise hidapi: {}", error)
}
// Create a virtual device for testing without a physical device.
tokio::spawn(async move {
let mut devices = DEVICES.lock().await;
+91 -95
View File
@@ -8,102 +8,98 @@ use std::time::Duration;
use serde_json::Value;
use log::{warn, error};
pub struct ProntoKeyDevice {}
/// Attempt to open a serial connection with the device and handle incoming data.
pub async fn init(port: String) {
let mut initialised = false;
let mut device_id = "".to_owned();
let mut last_key: u8 = 0;
let mut last_sliders: Vec<i16> = vec![0; 2];
impl ProntoKeyDevice {
/// Attempt to open a serial connection with the device and handle incoming data.
pub async fn init(port: String) {
let mut initialised = false;
let mut device_id = "".to_owned();
let mut last_key: u8 = 0;
let mut last_sliders: Vec<i16> = vec![0; 2];
let mut port = match
serialport::new(port, 57600)
.timeout(Duration::from_millis(10))
.open()
{
Ok(p) => p,
Err(error) => {
error!("Failed to open serial port: {}", error);
panic!()
}
};
let _ = port.write("register".as_bytes());
let mut serial_buf: Vec<u8> = vec![0; 1024];
let mut holding_string = String::from("");
loop {
match port.read(serial_buf.as_mut_slice()) {
Ok(t) => {
match std::str::from_utf8(&serial_buf[..t]) {
Ok(data) => holding_string += data,
Err(_) => break
}
// Iterate through JSON objects received from device that are being held in the buffer.
while holding_string.contains('}') {
let index = holding_string.find('}').unwrap_or_default();
let chunk = holding_string[..=index].trim();
let j: Value = match serde_json::from_str(chunk) {
Ok(data) => data,
Err(_) => continue
};
holding_string = holding_string[(index + 1)..].to_owned();
// If the device is uninitialised, attempt to read its MAC address and initialise.
if !initialised {
if let Value::String(address) = &j["address"] {
initialised = true;
device_id = format!("pk-{}", address);
super::DEVICES.lock().await.insert(device_id.clone(), DeviceInfo {
id: device_id.clone(),
name: "ProntoKey".to_owned(),
rows: 3,
columns: 3,
sliders: 2,
r#type: 7
});
}
continue;
}
// Handle key presses and releases.
if let Value::Number(num) = &j["key"] {
match num.as_u64().unwrap_or_default() as u8 {
0 => {
let _ = outbound::keypad::key_up(&device_id, last_key - 1).await;
},
val => {
let _ = outbound::keypad::key_down(&device_id, val - 1).await;
last_key = val;
}
}
}
// Handle slider value changes.
if let Value::Number(val) = &j["slider0"] {
let val: i16 = match val.as_i64() {
Some(v) => v as i16,
_ => last_sliders[0]
};
let _ = outbound::encoder::dial_rotate(&device_id, 0, val - last_sliders[0]).await;
last_sliders[0] = val;
}
if let Value::Number(val) = &j["slider1"] {
let val: i16 = match val.as_i64() {
Some(v) => v as i16,
_ => last_sliders[1]
};
let _ = outbound::encoder::dial_rotate(&device_id, 1, val - last_sliders[1]).await;
last_sliders[1] = val;
}
}
},
Err(ref error) if error.kind() == std::io::ErrorKind::TimedOut => (),
Err(error) => warn!("Failed to decode serial message from ProntoKey device: {}", error)
}
thread::sleep(Duration::from_millis(10));
let mut port = match
serialport::new(port, 57600)
.timeout(Duration::from_millis(10))
.open()
{
Ok(p) => p,
Err(error) => {
error!("Failed to open serial port: {}", error);
panic!()
}
};
let _ = port.write("register".as_bytes());
let mut serial_buf: Vec<u8> = vec![0; 1024];
let mut holding_string = String::from("");
loop {
match port.read(serial_buf.as_mut_slice()) {
Ok(t) => {
match std::str::from_utf8(&serial_buf[..t]) {
Ok(data) => holding_string += data,
Err(_) => break
}
// Iterate through JSON objects received from device that are being held in the buffer.
while holding_string.contains('}') {
let index = holding_string.find('}').unwrap_or_default();
let chunk = holding_string[..=index].trim();
let j: Value = match serde_json::from_str(chunk) {
Ok(data) => data,
Err(_) => continue
};
holding_string = holding_string[(index + 1)..].to_owned();
// If the device is uninitialised, attempt to read its MAC address and initialise.
if !initialised {
if let Value::String(address) = &j["address"] {
initialised = true;
device_id = format!("pk-{}", address);
super::DEVICES.lock().await.insert(device_id.clone(), DeviceInfo {
id: device_id.clone(),
name: "ProntoKey".to_owned(),
rows: 3,
columns: 3,
sliders: 2,
r#type: 7
});
}
continue;
}
// Handle key presses and releases.
if let Value::Number(num) = &j["key"] {
match num.as_u64().unwrap_or_default() as u8 {
0 => {
let _ = outbound::keypad::key_up(&device_id, last_key - 1).await;
},
val => {
let _ = outbound::keypad::key_down(&device_id, val - 1).await;
last_key = val;
}
}
}
// Handle slider value changes.
if let Value::Number(val) = &j["slider0"] {
let val: i16 = match val.as_i64() {
Some(v) => v as i16,
_ => last_sliders[0]
};
let _ = outbound::encoder::dial_rotate(&device_id, 0, val - last_sliders[0]).await;
last_sliders[0] = val;
}
if let Value::Number(val) = &j["slider1"] {
let val: i16 = match val.as_i64() {
Some(v) => v as i16,
_ => last_sliders[1]
};
let _ = outbound::encoder::dial_rotate(&device_id, 1, val - last_sliders[1]).await;
last_sliders[1] = val;
}
}
},
Err(ref error) if error.kind() == std::io::ErrorKind::TimedOut => (),
Err(error) => warn!("Failed to decode serial message from ProntoKey device: {}", error)
}
thread::sleep(Duration::from_millis(10));
}
}
+9
View File
@@ -207,6 +207,15 @@ pub async fn switch_property_inspector(old: Option<ActionContext>, new: Option<A
}
}
#[tauri::command]
pub async fn update_image(context: ActionContext, image: String) {
if context.device.starts_with("sd-") {
if let Err(error) = crate::devices::elgato::update_image(&context, &image).await {
log::warn!("Failed to update device image at context {} with image {}: {}", context, image, error);
}
}
}
pub async fn update_state(app: &tauri::AppHandle, instance: &ActionInstance) -> Result<(), anyhow::Error> {
let window = app.get_window("main").unwrap();
window.emit("update_state", serde_json::to_string(instance).unwrap())?;
+38
View File
@@ -22,6 +22,22 @@ struct DialRotateEvent {
payload: DialRotatePayload
}
#[derive(Serialize)]
struct DialPressPayload {
controller: &'static str,
settings: serde_json::Value,
coordinates: Coordinates
}
#[derive(Serialize)]
struct DialPressEvent {
event: &'static str,
action: String,
context: ActionContext,
device: String,
payload: DialPressPayload
}
pub async fn dial_rotate(device: &str, index: u8, ticks: i16) -> Result<(), anyhow::Error> {
let instance = match get_instance(device, index, "Encoder").await? {
Some(instance) => instance,
@@ -44,3 +60,25 @@ pub async fn dial_rotate(device: &str, index: u8, ticks: i16) -> Result<(), anyh
}
}).await
}
pub async fn dial_press(device: &str, event: &'static str, index: u8) -> Result<(), anyhow::Error> {
let instance = match get_instance(device, index, "Encoder").await? {
Some(instance) => instance,
None => return Ok(())
};
send_to_plugin(&instance.action.plugin, &DialPressEvent {
event,
action: instance.action.uuid.clone(),
context: instance.context.clone(),
device: instance.context.device.clone(),
payload: DialPressPayload {
controller: "Encoder",
settings: instance.settings.clone(),
coordinates: Coordinates {
row: instance.context.position / 3,
column: instance.context.position % 3
}
}
}).await
}
@@ -32,5 +32,11 @@ pub async fn will_disappear(instance: &ActionInstance) -> Result<(), anyhow::Err
payload: GenericInstancePayload::new(instance)
}).await?;
if instance.context.device.starts_with("sd-") {
if let Err(error) = crate::devices::elgato::clear_image(&instance.context).await {
log::warn!("Failed to clear device image at context {}: {}", instance.context, error);
}
}
Ok(())
}
+2 -1
View File
@@ -30,7 +30,8 @@ async fn main() {
frontend::set_selected_profile,
frontend::delete_profile,
frontend::make_info,
frontend::switch_property_inspector
frontend::switch_property_inspector,
frontend::update_image
])
.plugin(tauri_plugin_log::Builder::default()
.targets([
+16 -6
View File
@@ -310,20 +310,30 @@ async fn init_browser_server(prefix: path::PathBuf) {
let content_type = mime(&extension);
if content_type.starts_with("text/") || content_type == "image/svg+xml" {
let response = tiny_http::Response::from_string(fs::read_to_string(url).unwrap_or_default());
let _ = request.respond(response.with_header(tiny_http::Header {
let mut response = tiny_http::Response::from_string(fs::read_to_string(url).unwrap_or_default());
response.add_header(tiny_http::Header {
field: "Access-Control-Allow-Origin".parse().unwrap(),
value: "*".parse().unwrap()
});
response.add_header(tiny_http::Header {
field: "Content-Type".parse().unwrap(),
value: content_type.parse().unwrap()
}));
});
let _ = request.respond(response);
} else {
let response = tiny_http::Response::from_file(match fs::File::open(url) {
let mut response = tiny_http::Response::from_file(match fs::File::open(url) {
Ok(file) => file,
Err(_) => continue
});
let _ = request.respond(response.with_header(tiny_http::Header {
response.add_header(tiny_http::Header {
field: "Access-Control-Allow-Origin".parse().unwrap(),
value: "*".parse().unwrap()
});
response.add_header(tiny_http::Header {
field: "Content-Type".parse().unwrap(),
value: content_type.parse().unwrap()
}));
});
let _ = request.respond(response);
}
}
} else {
+1 -7
View File
@@ -41,15 +41,9 @@
}
},
"security": {
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: https://appstore.elgato.com; frame-src http://localhost:57118; connect-src https://appstore.elgato.com;"
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http://localhost:57118; https://appstore.elgato.com; frame-src http://localhost:57118; connect-src https://appstore.elgato.com;"
},
"allowlist": {
"protocol": {
"asset": true,
"assetScope": [
"$APPCONFIG/plugins/**/*"
]
},
"shell": {
"open": true,
"execute": true
+7 -16
View File
@@ -2,16 +2,15 @@
import type { ActionInstance } from "$lib/ActionInstance";
import { inspectedInstance } from "$lib/propertyInspector";
import { getImage, renderImage } from "$lib/rendererHelper";
import { invoke } from "@tauri-apps/api";
import { listen } from "@tauri-apps/api/event";
import { convertFileSrc } from "@tauri-apps/api/tauri";
export let context: string;
export let instance: ActionInstance | null;
$: state = instance?.states[instance?.current_state];
let oldImage: string;
listen("update_state", ({ payload }: { payload: string }) => {
let i = JSON.parse(payload);
@@ -55,20 +54,12 @@
timeouts.push(setTimeout(() => showOk = 0, 2e3));
});
function getImage(image: string): string {
if (!image.startsWith("data:")) return convertFileSrc(image);
const svgxmlre = /^data:image\/svg\+xml,(.+)/;
const base64re = /^data:image\/(apng|avif|gif|jpeg|png|svg\+xml|webp|bmp|x-icon|tiff);base64,([A-Za-z0-9+/]+={0,2})?/;
if (svgxmlre.test(image)) {
image = "data:image/svg+xml;base64," + btoa(decodeURIComponent((svgxmlre.exec(image) as RegExpExecArray)[1].replace(/\;$/, "")));
}
if (base64re.test(image)) {
let exec = base64re.exec(image)!;
if (!exec[2]) return oldImage;
else image = exec[0];
}
let oldImage: string;
let image: string;
$: {
image = getImage(state?.image, oldImage);
oldImage = image;
return image;
if (state) renderImage(context, state);
}
</script>
@@ -81,7 +72,7 @@
{#if instance && state}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<img
src={getImage(state.image)}
src={image}
class="p-2 w-full rounded-xl"
alt={instance.action.tooltip}
on:click={clear} on:keyup={clear}
+1 -3
View File
@@ -1,8 +1,6 @@
<script lang="ts">
import type { Action } from "$lib/Action";
import { convertFileSrc } from "@tauri-apps/api/tauri";
export let action: Action;
function handleDragStart(event: DragEvent) {
@@ -12,7 +10,7 @@
<div class="flex flex-row items-center mt-2 mb-2 space-x-2">
<img
src={convertFileSrc(action.icon)}
src={"http://localhost:57118" + action.icon}
alt={action.tooltip}
class="w-12 h-12 rounded-sm"
draggable
+7 -9
View File
@@ -2,10 +2,10 @@
import type { ActionInstance } from "$lib/ActionInstance";
import { inspectedInstance } from "$lib/propertyInspector";
import { getImage } from "$lib/rendererHelper";
import { invoke } from "@tauri-apps/api";
import { listen } from "@tauri-apps/api/event";
import { convertFileSrc } from "@tauri-apps/api/tauri";
export let context: string;
export let instance: ActionInstance | null;
@@ -54,13 +54,11 @@
timeouts.push(setTimeout(() => showOk = 0, 2e3));
});
function getImage(image: string): string {
if (!image.startsWith("data:")) return convertFileSrc(image);
const svgxmlre = /^data:image\/svg\+xml,(.+)/;
if (svgxmlre.test(image)) {
image = "data:image/svg+xml;base64," + btoa(decodeURIComponent((svgxmlre.exec(image) as RegExpExecArray)[1].replace(/\;$/, "")));
}
return image;
let oldImage: string;
let image: string;
$: {
image = getImage(state?.image, oldImage);
oldImage = image;
}
</script>
@@ -73,7 +71,7 @@
{#if instance && state}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<img
src={getImage(state.image)}
src={image}
class="p-2 w-full rounded-xl"
alt={instance.action.tooltip}
on:click={clear} on:keyup={clear}
+1 -1
View File
@@ -4,7 +4,7 @@ export type ActionState = {
text: string,
show: boolean,
colour: string,
alignment: string,
alignment: "top" | "middle" | "bottom",
style: string,
size: string,
underline: boolean
+61
View File
@@ -0,0 +1,61 @@
import { invoke } from "@tauri-apps/api";
import type { ActionState } from "./ActionState";
export function getImage(image: string | undefined, fallback: string): string {
if (!image) return fallback;
if (!image.startsWith("data:")) return "http://localhost:57118" + image;
const svgxmlre = /^data:image\/svg\+xml,(.+)/;
const base64re = /^data:image\/(apng|avif|gif|jpeg|png|svg\+xml|webp|bmp|x-icon|tiff);base64,([A-Za-z0-9+/]+={0,2})?/;
if (svgxmlre.test(image)) {
image = "data:image/svg+xml;base64," + btoa(decodeURIComponent((svgxmlre.exec(image) as RegExpExecArray)[1].replace(/\;$/, "")));
}
if (base64re.test(image)) {
let exec = base64re.exec(image)!;
if (!exec[2]) return fallback;
else image = exec[0];
}
return image;
}
export async function renderImage(actionContext: string, state: ActionState) {
// Create canvas
let canvas = document.createElement("canvas");
canvas.width = 144;
canvas.height = 144;
let context = canvas.getContext("2d");
if (!context) return;
// Load image
let image = document.createElement("img");
image.crossOrigin = "anonymous";
image.src = getImage(state.image, undefined!);
if (image.src == undefined) return;
await new Promise((resolve) => {
image.onload = resolve;
});
// Draw image
context.drawImage(image, 0, 0, canvas.width, canvas.height);
// Draw text
if (state.show) {
context.textAlign = "center";
context.font = `${state.size}px serif`;
context.fillStyle = state.colour;
let x = canvas.width / 2;
let y = canvas.height / 2 + (parseInt(state.size) / 4);
switch (state.alignment) {
case "top": y = parseInt(state.size); break;
case "bottom": y = canvas.height - 5; break;
}
context.fillText(state.text, x, y);
if (state.underline) {
let width = context.measureText(state.text).width;
context.fillRect(x - (width / 2), y + 2, width, 3);
}
}
await invoke("update_image", { context: actionContext, image: canvas.toDataURL("image/jpeg") });
canvas.remove();
}