532 lines
15 KiB
JavaScript
532 lines
15 KiB
JavaScript
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;
|
||
} |