2024-10-12 21:48:16 -07:00

532 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as db from "../db.js";
import { parseGIF, decompressFrames } from "./dep/gifuct/index.js";
var mode = localStorage.mode ||= "emoji";
if (mode == "sticker") stickerMode();
function stickerMode() {
localStorage.mode = mode = "sticker";
modebtn.innerText = "Emoji Mode";
dynamicstyle.innerHTML = `.emojistuff {display: none}`;
}
function emojiMode() {
localStorage.mode = mode = "emoji";
modebtn.innerText = "Sticker Mode";
dynamicstyle.innerHTML = ``;
}
modebtn.onclick = function switchMode() {
if (mode == "emoji") {
stickerMode();
} else {
emojiMode();
}
loadToggleState();
}
const ANIMATION_STYLES = `default Aura Bats Bees Bounce Cloud Confetti Crying Dislike Fire Idea Lasers Like Magnet Mistletoe Money Noise Orbit Pizza Rain Rotate Shake Snow Snowball Spin Splash Stop ZZZ`.split(' ');
const animationStyleRegex = new RegExp(`(${ANIMATION_STYLES.join('|')})`, 'i');
function createAnimationStyleSelect() {
let select = document.createElement("select");
select.classList.add("emojistuff");
ANIMATION_STYLES.forEach(a => {
let option = document.createElement("option");
option.innerText = a;
if (a == "default") {
option.value = '';
} else {
option.value = a.toLowerCase();
}
select.appendChild(option);
});
return select;
}
db.getSetting("defaultAnimationStyle").then(defaultAnimationStyle => {
default_animation_style_select.value = defaultAnimationStyle || "aura";
});
default_animation_style_select.onchange = async function () {
await db.setSetting("defaultAnimationStyle", this.value);
var enabled = [...document.querySelectorAll("[data-state=enabled]")].filter(e => !e.querySelector("select").value);
if (enabled.length) {
default_animation_style_select.disabled = true;
await Promise.all(enabled.map(div => (async ()=>{
await div.disable();
await div.enable();
})()));
default_animation_style_select.disabled = false;
}
};
addbtn.onclick = async () => {
try {
var fileHandles = await showOpenFilePicker({
id: "add",
multiple: true,
types: [ { description: "Image", accept: { "image/*": [], } } ]
});
} catch (error) {
return;
}
if (!fileHandles.length) return;
var files = [];
for (let fileHandle of fileHandles) {
try {
files.push(await fileHandle.getFile());
} catch (error) {
displayError(error);
}
}
await addFiles(files);
};
document.body.onpaste = event => {
if (event.clipboardData.files.length) {
addFiles(event.clipboardData.files);
}
};
document.body.ondragover = event => event.preventDefault();
document.body.ondrop = event => {
event.preventDefault();
if (event.dataTransfer.files.length) {
addFiles(event.dataTransfer.files);
}
};
async function addFiles(files) {
var errors = [];
for (let file of files) {
try {
let e = {
internalId: randomId(),
date: new Date().toISOString()
};
if (file.type == "image/gif") {
let ss = await gifToSpritesheet(await file.arrayBuffer());
e.animated = true;
e.data = file;
e.data_spritesheet = ss.data;
e.fps = ss.fps;
e.framecount = ss.framecount;
} else {
let image = await loadImage(URL.createObjectURL(file));
let width = image.width;
let height = image.height;
const MIN = 64, MAX = 2048;
let aspectRatio = width / height;
if (height > width) {
height = Math.max(MIN, Math.min(MAX, height));
width = height * aspectRatio;
} else {
width = Math.max(MIN, Math.min(MAX, width));
height = width * (1/aspectRatio);
}
let largestDim = Math.max(width, height);
let offsetX = (largestDim - width) / 2;
let offsetY = (largestDim - height) / 2;
let canvas = document.createElement("canvas");
canvas.width = largestDim;
canvas.height = largestDim;
let ctx = canvas.getContext("2d");
ctx.drawImage(image, offsetX, offsetY, width, height);
let data = await new Promise(r => canvas.toBlob(r, "image/png"));
URL.revokeObjectURL(image.src);
e.animationStyle = file.name.match(animationStyleRegex)?.[1] || undefined;
e.data = data;
}
await db.storeImage(e);
createEmojiSquare(e);
errorDiv.innerText = "";
} catch(error) {
console.error(error);
errors.push(error.message);
}
}
if (errors.length) {
displayError(`Errors occured adding ${errors.length} files: ${errors.join(', ')}`);
}
loadToggleState();
}
var deleteMode = false;
deletebtn.onclick = () => {
if (deleteMode) {
deleteMode = false;
document.body.classList.remove("deletemode");
deletebtn.innerText = "Delete";
return;
}
deleteMode = true;
document.body.classList.add("deletemode");
deletebtn.innerText = "Stop Delete";
};
importbtn.onclick = async () => {
try {
try {
var [fileHandle] = await showOpenFilePicker({
id: "import",
types: [ { description: "ZIP or vrcem json", accept: { "application/zip": ".zip", "application/json": ".json" } } ]
});
} catch (error) {
return;
}
if (!fileHandle) return;
var file = await fileHandle.getFile();
if (file.name.toLowerCase().endsWith(".json")) {
let text = await file.text();
let store = JSON.parse(text);
var imgs = await Promise.all(store.emojis.map(async emoji => {
var blob = await fetch(store[`data-${emoji.internalId}`]).then(res => res.blob());
return new File([blob], `${emoji.animationStyle}.png`);
}));
} else {
var zip = await JSZip.loadAsync(file);
var imgs = zip.file(/^[^\/]*\.(?:png|gif)$/i);
imgs = await Promise.all(imgs.map(async img => {
var blob = await img.async("blob");
return new File([blob], img.name, {type: img.name.toLowerCase().endsWith('.gif') ? 'image/gif' : 'image.png'});
}));
}
await addFiles(imgs);
} catch(error) {
displayError(error);
}
};
exportbtn.onclick = async () => {
try {
var images = await db.getAllImages();
var zip = new JSZip();
for (let image of images) {
zip.file(`${image.internalId}.${image.animationStyle}.${image.animated?'gif':'png'}`, image.data);
}
var blob = await zip.generateAsync({type: "blob"});
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = `vrcem-${new Date().toISOString()}.zip`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
displayError(error);
}
};
clearbtn.onclick = async () => {
if (!confirm("Remove all images from manager? (This will not delete any on VRChat)")) return;
await db.deleteAllImages();
emojigrid.innerHTML = '';
};
(async function loadEmojis() {
errorDiv.innerText = "";
var images = await db.getAllImages();
if (!images.length) throw new Error("No emoji.");
for (let image of images) {
createEmojiSquare(image);
}
await loadToggleState();
})().catch(displayError);
async function refreshImages() {
console.debug("refreshImages");
var images = await db.getAllImages();
for (let image of images) {
if (!document.querySelector(`[data-internal-id="${image.internalId}"]`)) createEmojiSquare(image);
}
var ids = images.map(i => i.internalId);
for (let emojisquare of document.querySelectorAll(".emojisquare")) {
if (!ids.includes(emojisquare.dataset.internalId)) emojisquare.delete();
}
}
async function loadToggleState() {
console.debug("loadToggleState");
try {
var elements = document.querySelectorAll(".emojisquare");
if (elements.length === 0) return;
var currentFiles = await callContentScript("getFiles", mode);
var active = currentFiles?.map(e => e.id);
elements.forEach(e => {
e.dataset.state = active.includes(e.dataset["currentId_"+mode]) ? "enabled" : "disabled";
});
errorDiv.innerText = "";
} catch (error) {
displayError(error);
}
}
onfocus = () => refreshImages().then(loadToggleState);
function createEmojiSquare({internalId, data, data_spritesheet, animationStyle, currentId_emoji, currentId_sticker, animated, fps, framecount}) {
var div = document.createElement("div");
div.className = "emojisquare";
div.dataset.internalId = internalId;
div.blob = data;
if (currentId_emoji) div.dataset.currentId_emoji = currentId_emoji;
if (currentId_sticker) div.dataset.currentId_sticker = currentId_sticker;
if (animated) div.dataset.animated = "yes";
Object.assign(div, {
async enable() {
var lastState = this.dataset.state;
this.dataset.state = "pending";
select.disabled = true;
errorDiv.innerText = "";
try {
if (animated && mode == "sticker") throw new Error("Stickers can't be animated");
this.dataurl ||= await blobToDataURL(animated ? data_spritesheet : data);
var newEmoji = await callContentScript("createFile", {
url: this.dataurl,
tag: animated ? "emojianimated" : mode,
animationStyle: mode != "sticker" && (select.value || default_animation_style_select.value),
frames: framecount,
framesOverTime: fps
});
} catch (error) {
this.dataset.state = lastState;
select.disabled = false;
displayError(error);
return;
}
await db.updateImage(this.dataset.internalId, {["currentId_"+mode]: newEmoji.id});
this.dataset["currentId_"+mode] = newEmoji.id;
this.dataset.state = "enabled";
select.disabled = false;
},
async disable() {
var lastState = this.dataset.state;
this.dataset.state = "pending";
select.disabled = true;
errorDiv.innerText = "";
try {
await callContentScript("deleteFile", this.dataset["currentId_"+mode]);
await db.updateImage(this.dataset.internalId, {["currentId_"+mode]: null});
delete this.dataset["currentId_"+mode];
} catch (error) {
this.dataset.state = lastState;
select.disabled = false;
displayError(error);
return;
}
this.dataset.state = "disabled";
div.querySelector("select").disabled = false;
},
async toggle() {
if (this.dataset.state == "enabled") {
await this.disable();
} else if (this.dataset.state == "disabled") {
await this.enable();
}
},
async delete() {
await db.deleteImage(this.dataset.internalId);
if (this.dataset["currentId_emoji"]) callContentScript("deleteFile", this.dataset["currentId_emoji"]).catch(displayError);
if (this.dataset["currentId_sticker"]) callContentScript("deleteFile", this.dataset["currentId_sticker"]).catch(displayError);
this.remove();
}
});
div.onclick = function onEmojiClick() {
if (deleteMode) this.delete();
else this.toggle();
if (emojigrid.querySelector(".emojisquare:not([data-state])")) loadToggleState();
};
var imgdiv = document.createElement("div");
imgdiv.className = "imgcontainer";
var img = document.createElement("img");
img.src = URL.createObjectURL(data);
imgdiv.appendChild(img);
div.appendChild(imgdiv);
var select = createAnimationStyleSelect();
select.value = animationStyle || '';
select.onclick = event => event.stopPropagation();
select.onchange = async event => {
await db.updateImage(div.dataset.internalId, {animationStyle: event.target.value});
if (div.dataset.state == "enabled") {
await div.disable();
await div.enable();
}
};
div.appendChild(select);
emojigrid.appendChild(div);
return div;
}
function displayError(error) {
console.error(error);
error = error.message || String(error);
var html = error;
if (error.includes("VRChat tab not found."))
html += `<br>Please open <a href=\"https://vrchat.com/home\" target=\"_blank\">https://vrchat.com/home</a>`;
if (error.includes("Missing Credentials"))
html += `<br>Please log in to VRChat.com`;
if (error.includes("No emoji."))
html += ` Click "Add Emojis" to get started.`;
if (error.includes("Could not establish connection. Receiving end does not exist."))
html += `<br>Please reload your VRChat tab.`;
if (error.includes("You must upload a valid imageǃ"))
html += `<br> (NSFW images are not allowed)`;
errorDiv.innerHTML = html;
}
async function getVRchatTab() {
var tab = (await chrome.tabs.query({url:"https://vrchat.com/home*"}))?.[0];
if (!tab) throw new Error("VRChat tab not found.");
return tab;
}
async function callContentScript(method, ...args) {
var tab = await getVRchatTab();
var {error, response} = await chrome.tabs.sendMessage(tab.id, [method, ...args]);
if (error) throw error;
console.debug(response);
if (response.status == 429) {
throw new Error(`VRChat API ratelimited. Please wait ${response.retryAfter} seconds.`);
}
if (response.body.error) {
throw new Error(`VRChat API said: ${response.body.error.message}`);
}
return response.body;
};
function loadImage(src) {
return new Promise((resolve, reject) => {
var image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = src;
});
}
function blobToDataURL(blob) {
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.readAsDataURL(blob);
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
});
}
function randomId() {
let id = "";
const CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
for (var i = 0; i < 16; i++) {
id += CHARS[Math.floor(Math.random() * CHARS.length)];
}
return id;
}
async function gifToSpritesheet(arrayBuffer) {
var gif = parseGIF(arrayBuffer);
var frames = decompressFrames(gif, true);
frames = frames.slice(0, 64);
var gifCanvas = document.createElement("canvas");
//gifCanvas.width = frames[0].dims.width;
//gifCanvas.height = frames[0].dims.height;
var gifCanvasCtx = gifCanvas.getContext("2d");
var spritesheetCanvas = document.createElement("canvas");
spritesheetCanvas.width = 1024;
spritesheetCanvas.height = 1024;
var spritesheetCanvasCtx = spritesheetCanvas.getContext('2d');
if (frames.length <= 4) {
var tileSize = 512;
var columns = 2;
} else if (frames.length > 4 && frames.length <= 16) {
var tileSize = 256;
var columns = 4;
} else if (frames.length > 16 && frames.length <= 64) {
var tileSize = 128;
var columns = 8;
}
frames.forEach((frame, index) => {
gifCanvas.width = frame.dims.width;
gifCanvas.height = frame.dims.height;
let imageData = new ImageData(frame.patch, frame.dims.width, frame.dims.height);
gifCanvasCtx.putImageData(imageData, 0, 0);
let x = (index % columns) * tileSize;
let y = Math.floor(index / columns) * tileSize;
if (frame.dims.height > frame.dims.width) {
var fh = tileSize;
var fw = tileSize * (frame.dims.width/frame.dims.height);
x += (tileSize - fw) / 2;
} else {
var fw = tileSize;
var fh = tileSize * (frame.dims.height/frame.dims.width);
y += (tileSize - fh) / 2;
}
spritesheetCanvasCtx.drawImage(gifCanvas, x, y, fw, fh);
});
var frameDelays = frames.map(frame => frame.delay);
var frameDelay = modeOfNumbers(frameDelays);
var fps = 1000 / frameDelay;
var data = await new Promise(r => spritesheetCanvas.toBlob(r, "image/png"));
return {
data,
fps,
framecount: frames.length
}
}
function modeOfNumbers(numbers) {
var counters = {};
for (var number of numbers) {
counters[number] ||= 0;
counters[number]++;
}
counters = Object.entries(counters);
var max = counters.reduce((max, val) => val[1] > max ? val[1] : max, 0);
var mode = Number(counters.find(x => x[1] == max)[0]);
return mode;
}