Compare commits
47 Commits
6e7f3d4b4c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7da3622437 | |||
| db3a3e8851 | |||
| 57c782f5f3 | |||
| effb1a75d1 | |||
| 3e8fa8d7eb | |||
| b5f7208bc8 | |||
| b2054992b2 | |||
| 8b682f35bb | |||
| a74163b29a | |||
| 349c7bd551 | |||
| 779624a722 | |||
| a31550e205 | |||
| d8167a3a3d | |||
| 4ed59e5b66 | |||
| 0a6a6df29b | |||
| 2f76dd3df2 | |||
| 15198ca301 | |||
| f19ce27f2b | |||
| 9f020332cc | |||
| 5f1b0db47c | |||
| ebed204c9d | |||
| 9ec6b8d9cd | |||
| c5c47c111b | |||
| 321cc93cf5 | |||
| fcfe877437 | |||
| 672b7b3fd0 | |||
| cd660e6ff4 | |||
| 891fe17f48 | |||
| 031f9ead1f | |||
| 1ebd2708e3 | |||
| a2ca2065cd | |||
| d66b7300f8 | |||
| f520f8e460 | |||
| 4d9bb310e8 | |||
| 6adaa29ff1 | |||
| e0e6cb70c4 | |||
| b3d05bba21 | |||
| 2b3b0cecaf | |||
| 0122d67367 | |||
| 1022a0af34 | |||
| a8fc8881cf | |||
| c3e8dd4c1d | |||
| 715219e679 | |||
| f9945c3647 | |||
| f748d0b771 | |||
| d1de5350e1 | |||
| 826c7ccb73 |
Binary file not shown.
|
After Width: | Height: | Size: 878 KiB |
+12
-64
@@ -1,74 +1,22 @@
|
||||
import { getDb, getKnownEmojiIds, getKnownStickerIds, storeImage } from "./db.js";
|
||||
import { importVrcFile } from "./common.js";
|
||||
|
||||
getDb();
|
||||
|
||||
var functions = {
|
||||
async storeEmojis(emojis) {
|
||||
console.debug("storeEmojis", emojis);
|
||||
var emojiStore = (await chrome.storage.local.get(["emoji"])).emoji || [];
|
||||
|
||||
loop1: for (let emoji of emojis) {
|
||||
for (let existingEmoji of emojiStore) {
|
||||
if (existingEmoji.currentId == emoji.id) continue loop1;
|
||||
}
|
||||
console.log("store", emoji.id);
|
||||
var blob = await fetch(emoji.versions[1].file.url).then(res => res.blob());
|
||||
var dataString = await blobToDataURL(blob);
|
||||
var emojiDoc = {
|
||||
currentId: emoji.id,
|
||||
internalId: emoji.id,
|
||||
animationStyle: emoji.animationStyle
|
||||
};
|
||||
await chrome.storage.local.set({[`data-${emojiDoc.internalId}`]: dataString});
|
||||
emojiStore.push(emojiDoc);
|
||||
async storeFiles(files, type) {
|
||||
console.debug("storeFiles", files, type);
|
||||
var knownIds = type == "sticker" ? await getKnownStickerIds() : await getKnownEmojiIds();
|
||||
console.debug("knownIds", knownIds);
|
||||
for (let file of files) {
|
||||
if (knownIds.includes(file.id)) continue;
|
||||
await importVrcFile(file);
|
||||
}
|
||||
|
||||
chrome.storage.local.set({emoji: emojiStore});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
chrome.runtime.onMessage.addListener(function([method, ...args], sender, sendResponse) {
|
||||
console.debug(arguments);
|
||||
functions[method]?.apply(null, args).then(sendResponse).catch(error => sendResponse({error: error.toString()}));;
|
||||
functions[method].apply(null, args).then(sendResponse);
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function blobToDataURL(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
var reader = new FileReader();
|
||||
reader.readAsDataURL(blob);
|
||||
reader.onloadend = () => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function listEmoji() {
|
||||
return await fetch("https://vrchat.com/api/1/files?tag=emoji&n=100&offset=0").then(res => res.json());
|
||||
}
|
||||
|
||||
async function getEmojiFile(id) {
|
||||
return await fetch(`https://api.vrchat.cloud/api/1/file/${id}/1/file`);
|
||||
}
|
||||
|
||||
async function deleteEmoji(id) {
|
||||
return await fetch(`https://vrchat.com/api/1/file/${id}`, {method: "DELETE"});
|
||||
}
|
||||
|
||||
async function createEmoji(file, animationStyle) {
|
||||
var form = new FormData();
|
||||
form.append("tag", "emoji");
|
||||
form.append("animationStyle", animationStyle);
|
||||
form.append("maskTag", "square");
|
||||
form.append("file", file);
|
||||
return await fetch("https://vrchat.com/api/1/file/image", {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { storeImage } from "./db.js";
|
||||
|
||||
export async function importVrcFile(file) {
|
||||
console.log("store", file.id);
|
||||
let e = {
|
||||
date: file.versions[1].created_at,
|
||||
currentId_emoji: file.tags.includes("emoji") ? file.id : null,
|
||||
currentId_sticker: file.tags.includes("sticker") ? file.id : null,
|
||||
internalId: file.id,
|
||||
animationStyle: file.animationStyle,
|
||||
data: await fetch(file.versions[1].file.url).then(res => res.blob())
|
||||
};
|
||||
if (file.tags.includes("animated")) {
|
||||
e.animated = true;
|
||||
e.data_spritesheet = e.data; //todo, convert spritesheet back to gif???? or make spritesheet player??
|
||||
e.framecount = file.frames;
|
||||
e.fps = file.framesOverTime;
|
||||
}
|
||||
await storeImage(e);
|
||||
return e;
|
||||
}
|
||||
+38
-16
@@ -1,32 +1,54 @@
|
||||
fetch("https://vrchat.com/api/1/files?tag=emoji&n=100&offset=0").then(res => res.json()).then(data => {
|
||||
if (!data.error)
|
||||
chrome.runtime.sendMessage(["storeEmojis", data]);
|
||||
});
|
||||
|
||||
|
||||
var functions = {
|
||||
async getEmojis() {
|
||||
return await fetch("https://vrchat.com/api/1/files?tag=emoji&n=100&offset=0").then(res => res.json());
|
||||
async getFiles(tag = "emoji") {
|
||||
var res = await fetch(`https://vrchat.com/api/1/files?tag=${tag}&n=100&offset=0`);
|
||||
return {
|
||||
status: res.status,
|
||||
body: await res.json(),
|
||||
retryAfter: res.headers.get("Retry-After") || undefined
|
||||
};
|
||||
},
|
||||
async deleteEmoji(id) {
|
||||
return await fetch(`https://vrchat.com/api/1/file/${id}`, {method: "DELETE"}).then(res => res.json());
|
||||
async deleteFile(id) {
|
||||
var res = await fetch(`https://vrchat.com/api/1/file/${id}`, {method: "DELETE"});
|
||||
return {
|
||||
status: res.status,
|
||||
body: await res.json(),
|
||||
retryAfter: res.headers.get("Retry-After") || undefined
|
||||
};
|
||||
},
|
||||
async createEmoji(url, animationStyle) {
|
||||
async createFile({url, tag = "emoji", animationStyle, frames, framesOverTime}) {
|
||||
var blob = await fetch(url).then(res => res.blob());
|
||||
var form = new FormData();
|
||||
form.append("tag", "emoji");
|
||||
form.append("animationStyle", animationStyle);
|
||||
form.append("tag", tag);
|
||||
if (animationStyle) form.append("animationStyle", animationStyle);
|
||||
if (frames) form.append("frames", frames);
|
||||
if (framesOverTime) form.append("framesOverTime", framesOverTime);
|
||||
form.append("maskTag", "square");
|
||||
form.append("file", blob);
|
||||
return await fetch("https://vrchat.com/api/1/file/image", {
|
||||
var res = await fetch("https://vrchat.com/api/1/file/image", {
|
||||
method: "POST",
|
||||
body: form
|
||||
}).then(res => res.json());
|
||||
});
|
||||
return {
|
||||
status: res.status,
|
||||
body: await res.json(),
|
||||
retryAfter: res.headers.get("Retry-After") || undefined
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
functions.getFiles("emoji").then(data => {
|
||||
if (!data.error) chrome.runtime.sendMessage(["storeFiles", data.body, "emoji"]);
|
||||
else console.error(data.error);
|
||||
});
|
||||
functions.getFiles("sticker").then(data => {
|
||||
if (!data.error) chrome.runtime.sendMessage(["storeFiles", data.body, "sticker"]);
|
||||
else console.error(data.error);
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener(function([method, ...args], sender, sendResponse) {
|
||||
console.debug(arguments);
|
||||
functions[method]?.apply(null, args).then(sendResponse).catch(error => sendResponse({error: error.toString()}));
|
||||
functions[method].apply(null, args)
|
||||
.then(response => sendResponse({response}))
|
||||
.catch(error => sendResponse({error: error.toString()}));
|
||||
return true;
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
|
||||
var db;
|
||||
|
||||
export function getDb() {
|
||||
return db ||= initDb();
|
||||
}
|
||||
|
||||
function initDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
var request = indexedDB.open("database");
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = event => {
|
||||
console.debug("onsuccess");
|
||||
resolve(event.target.result);
|
||||
};
|
||||
request.onupgradeneeded = event => {
|
||||
console.debug("onupgradeneeded");
|
||||
//request.onsuccess = null;
|
||||
var db = event.target.result;
|
||||
var objectStore = db.createObjectStore("images", { keyPath: "internalId", autoIncrement: true });
|
||||
objectStore.createIndex("currentId_emoji", "currentId_emoji", {unique: true});
|
||||
objectStore.createIndex("currentId_sticker", "currentId_sticker", {unique: true});
|
||||
db.createObjectStore("settings");
|
||||
chrome.storage.local.get().then(async storage => {
|
||||
console.debug("storage", storage);
|
||||
if (storage.emojis) for (let emoji of storage.emojis) {
|
||||
try {
|
||||
emoji.data = await fetch(storage[`data-${emoji.internalId}`]).then(res => res.blob());
|
||||
await storeImage(emoji);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
if (storage.defaultAnimationStyle) await setSetting("defaultAnimationStyle", storage.defaultAnimationStyle);
|
||||
//resolve(db);
|
||||
console.debug("finished upgrade");
|
||||
});
|
||||
};
|
||||
request.onversionchange = () => {
|
||||
db = undefined; // idk
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export async function getKnownEmojiIds() {
|
||||
var db = await getDb();
|
||||
return await new Promise((resolve, reject) => {
|
||||
var request = db.transaction(["images"], "readonly").objectStore("images").index("currentId_emoji").getAllKeys();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getKnownStickerIds() {
|
||||
var db = await getDb();
|
||||
return await new Promise((resolve, reject) => {
|
||||
var request = db.transaction(["images"], "readonly").objectStore("images").index("currentId_sticker").getAllKeys();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllImages() {
|
||||
console.time("getAllImages");
|
||||
try {
|
||||
var db = await getDb();
|
||||
return await new Promise((resolve, reject) => {
|
||||
var request = db.transaction(["images"], "readonly").objectStore("images").getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
} finally {
|
||||
console.timeEnd("getAllImages");
|
||||
}
|
||||
}
|
||||
|
||||
export async function storeImage(entry) {
|
||||
var db = await getDb();
|
||||
return await new Promise((resolve, reject) => {
|
||||
var request = db.transaction(["images"], "readwrite").objectStore("images").add(entry);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getImage(internalId) {
|
||||
var db = await getDb();
|
||||
return await new Promise((resolve, reject) => {
|
||||
var request = db.transaction(["images"], "readonly").objectStore("images").get(internalId);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateImage(internalId, update) {
|
||||
var db = await getDb();
|
||||
return await new Promise((resolve, reject) => {
|
||||
var objectStore = db.transaction(["images"], "readwrite").objectStore("images");
|
||||
var request = objectStore.get(internalId);
|
||||
request.onsuccess = event => {
|
||||
var request = objectStore.put(Object.assign(event.target.result, update));
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteImage(internalId) {
|
||||
var db = await getDb();
|
||||
return await new Promise((resolve, reject) => {
|
||||
var request = db.transaction(["images"], "readwrite").objectStore("images").delete(internalId);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAllImages() {
|
||||
var db = await getDb();
|
||||
return await new Promise((resolve, reject) => {
|
||||
var request = db.transaction(["images"], "readwrite").objectStore("images").clear();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function setSetting(key, value) {
|
||||
var db = await getDb();
|
||||
return await new Promise((resolve, reject) => {
|
||||
var request = db.transaction(["settings"], "readwrite").objectStore("settings").put(value, key);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSetting(key) {
|
||||
var db = await getDb();
|
||||
return await new Promise((resolve, reject) => {
|
||||
var request = db.transaction(["settings"], "readonly").objectStore("settings").get(key);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Matt Way
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Deinterlace function from https://github.com/shachaf/jsgif
|
||||
*/
|
||||
|
||||
export const deinterlace = (pixels, width) => {
|
||||
const newPixels = new Array(pixels.length)
|
||||
const rows = pixels.length / width
|
||||
const cpRow = function(toRow, fromRow) {
|
||||
const fromPixels = pixels.slice(fromRow * width, (fromRow + 1) * width)
|
||||
newPixels.splice.apply(newPixels, [toRow * width, width].concat(fromPixels))
|
||||
}
|
||||
|
||||
// See appendix E.
|
||||
const offsets = [0, 4, 2, 1]
|
||||
const steps = [8, 8, 4, 2]
|
||||
|
||||
var fromRow = 0
|
||||
for (var pass = 0; pass < 4; pass++) {
|
||||
for (var toRow = offsets[pass]; toRow < rows; toRow += steps[pass]) {
|
||||
cpRow(toRow, fromRow)
|
||||
fromRow++
|
||||
}
|
||||
}
|
||||
|
||||
return newPixels
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import GIF from './js-binary-schema-parser/schemas/gif.js'
|
||||
import { parse } from './js-binary-schema-parser/index.js'
|
||||
import { buildStream } from './js-binary-schema-parser/parsers/uint8.js'
|
||||
import { deinterlace } from './deinterlace.js'
|
||||
import { lzw } from './lzw.js'
|
||||
|
||||
export const parseGIF = arrayBuffer => {
|
||||
const byteData = new Uint8Array(arrayBuffer)
|
||||
return parse(buildStream(byteData), GIF)
|
||||
}
|
||||
|
||||
const generatePatch = image => {
|
||||
const totalPixels = image.pixels.length
|
||||
const patchData = new Uint8ClampedArray(totalPixels * 4)
|
||||
for (var i = 0; i < totalPixels; i++) {
|
||||
const pos = i * 4
|
||||
const colorIndex = image.pixels[i]
|
||||
const color = image.colorTable[colorIndex] || [0, 0, 0]
|
||||
patchData[pos] = color[0]
|
||||
patchData[pos + 1] = color[1]
|
||||
patchData[pos + 2] = color[2]
|
||||
patchData[pos + 3] = colorIndex !== image.transparentIndex ? 255 : 0
|
||||
}
|
||||
|
||||
return patchData
|
||||
}
|
||||
|
||||
export const decompressFrame = (frame, gct, buildImagePatch) => {
|
||||
if (!frame.image) {
|
||||
console.warn('gif frame does not have associated image.')
|
||||
return
|
||||
}
|
||||
|
||||
const { image } = frame
|
||||
|
||||
// get the number of pixels
|
||||
const totalPixels = image.descriptor.width * image.descriptor.height
|
||||
// do lzw decompression
|
||||
var pixels = lzw(image.data.minCodeSize, image.data.blocks, totalPixels)
|
||||
|
||||
// deal with interlacing if necessary
|
||||
if (image.descriptor.lct.interlaced) {
|
||||
pixels = deinterlace(pixels, image.descriptor.width)
|
||||
}
|
||||
|
||||
const resultImage = {
|
||||
pixels: pixels,
|
||||
dims: {
|
||||
top: frame.image.descriptor.top,
|
||||
left: frame.image.descriptor.left,
|
||||
width: frame.image.descriptor.width,
|
||||
height: frame.image.descriptor.height
|
||||
}
|
||||
}
|
||||
|
||||
// color table
|
||||
if (image.descriptor.lct && image.descriptor.lct.exists) {
|
||||
resultImage.colorTable = image.lct
|
||||
} else {
|
||||
resultImage.colorTable = gct
|
||||
}
|
||||
|
||||
// add per frame relevant gce information
|
||||
if (frame.gce) {
|
||||
resultImage.delay = (frame.gce.delay || 10) * 10 // convert to ms
|
||||
resultImage.disposalType = frame.gce.extras.disposal
|
||||
// transparency
|
||||
if (frame.gce.extras.transparentColorGiven) {
|
||||
resultImage.transparentIndex = frame.gce.transparentColorIndex
|
||||
}
|
||||
}
|
||||
|
||||
// create canvas usable imagedata if desired
|
||||
if (buildImagePatch) {
|
||||
resultImage.patch = generatePatch(resultImage)
|
||||
}
|
||||
|
||||
return resultImage
|
||||
}
|
||||
|
||||
export const decompressFrames = (parsedGif, buildImagePatches) => {
|
||||
return parsedGif.frames
|
||||
.filter(f => f.image)
|
||||
.map(f => decompressFrame(f, parsedGif.gct, buildImagePatches))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export const parse = (stream, schema, result = {}, parent = result) => {
|
||||
if (Array.isArray(schema)) {
|
||||
schema.forEach(partSchema => parse(stream, partSchema, result, parent))
|
||||
} else if (typeof schema === 'function') {
|
||||
schema(stream, result, parent, parse)
|
||||
} else {
|
||||
const key = Object.keys(schema)[0]
|
||||
if (Array.isArray(schema[key])) {
|
||||
parent[key] = {}
|
||||
parse(stream, schema[key], result, parent[key])
|
||||
} else {
|
||||
parent[key] = schema[key](stream, result, parent, parse)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const conditional = (schema, conditionFunc) => (
|
||||
stream,
|
||||
result,
|
||||
parent,
|
||||
parse
|
||||
) => {
|
||||
if (conditionFunc(stream, result, parent)) {
|
||||
parse(stream, schema, result, parent)
|
||||
}
|
||||
}
|
||||
|
||||
export const loop = (schema, continueFunc) => (
|
||||
stream,
|
||||
result,
|
||||
parent,
|
||||
parse
|
||||
) => {
|
||||
const arr = []
|
||||
let lastStreamPos = stream.pos;
|
||||
while (continueFunc(stream, result, parent)) {
|
||||
const newParent = {}
|
||||
parse(stream, schema, result, newParent)
|
||||
// cases when whole file is parsed but no termination is there and stream position is not getting updated as well
|
||||
// it falls into infinite recursion, null check to avoid the same
|
||||
if(stream.pos === lastStreamPos) {
|
||||
break
|
||||
}
|
||||
lastStreamPos = stream.pos
|
||||
arr.push(newParent)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Default stream and parsers for Uint8TypedArray data type
|
||||
|
||||
export const buildStream = uint8Data => ({
|
||||
data: uint8Data,
|
||||
pos: 0
|
||||
})
|
||||
|
||||
export const readByte = () => stream => {
|
||||
return stream.data[stream.pos++]
|
||||
}
|
||||
|
||||
export const peekByte = (offset = 0) => stream => {
|
||||
return stream.data[stream.pos + offset]
|
||||
}
|
||||
|
||||
export const readBytes = length => stream => {
|
||||
return stream.data.subarray(stream.pos, (stream.pos += length))
|
||||
}
|
||||
|
||||
export const peekBytes = length => stream => {
|
||||
return stream.data.subarray(stream.pos, stream.pos + length)
|
||||
}
|
||||
|
||||
export const readString = length => stream => {
|
||||
return Array.from(readBytes(length)(stream))
|
||||
.map(value => String.fromCharCode(value))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export const readUnsigned = littleEndian => stream => {
|
||||
const bytes = readBytes(2)(stream)
|
||||
return littleEndian ? (bytes[1] << 8) + bytes[0] : (bytes[0] << 8) + bytes[1]
|
||||
}
|
||||
|
||||
export const readArray = (byteSize, totalOrFunc) => (
|
||||
stream,
|
||||
result,
|
||||
parent
|
||||
) => {
|
||||
const total =
|
||||
typeof totalOrFunc === 'function'
|
||||
? totalOrFunc(stream, result, parent)
|
||||
: totalOrFunc
|
||||
|
||||
const parser = readBytes(byteSize)
|
||||
const arr = new Array(total)
|
||||
for (var i = 0; i < total; i++) {
|
||||
arr[i] = parser(stream)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
const subBitsTotal = (bits, startIndex, length) => {
|
||||
var result = 0
|
||||
for (var i = 0; i < length; i++) {
|
||||
result += bits[startIndex + i] && 2 ** (length - i - 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const readBits = schema => stream => {
|
||||
const byte = readByte()(stream)
|
||||
// convert the byte to bit array
|
||||
const bits = new Array(8)
|
||||
for (var i = 0; i < 8; i++) {
|
||||
bits[7 - i] = !!(byte & (1 << i))
|
||||
}
|
||||
// convert the bit array to values based on the schema
|
||||
return Object.keys(schema).reduce((res, key) => {
|
||||
const def = schema[key]
|
||||
if (def.length) {
|
||||
res[key] = subBitsTotal(bits, def.index, def.length)
|
||||
} else {
|
||||
res[key] = bits[def.index]
|
||||
}
|
||||
return res
|
||||
}, {})
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { conditional, loop } from '../index.js'
|
||||
import {
|
||||
readByte,
|
||||
peekByte,
|
||||
readBytes,
|
||||
peekBytes,
|
||||
readString,
|
||||
readUnsigned,
|
||||
readArray,
|
||||
readBits,
|
||||
} from '../parsers/uint8.js'
|
||||
|
||||
// a set of 0x00 terminated subblocks
|
||||
var subBlocksSchema = {
|
||||
blocks: (stream) => {
|
||||
const terminator = 0x00
|
||||
const chunks = []
|
||||
const streamSize = stream.data.length
|
||||
var total = 0
|
||||
for (
|
||||
var size = readByte()(stream);
|
||||
size !== terminator;
|
||||
size = readByte()(stream)
|
||||
) {
|
||||
// size becomes undefined for some case when file is corrupted and terminator is not proper
|
||||
// null check to avoid recursion
|
||||
if(!size) break;
|
||||
// catch corrupted files with no terminator
|
||||
if (stream.pos + size >= streamSize) {
|
||||
const availableSize = streamSize - stream.pos
|
||||
chunks.push(readBytes(availableSize)(stream))
|
||||
total += availableSize
|
||||
break
|
||||
}
|
||||
chunks.push(readBytes(size)(stream))
|
||||
total += size
|
||||
}
|
||||
const result = new Uint8Array(total)
|
||||
var offset = 0
|
||||
for (var i = 0; i < chunks.length; i++) {
|
||||
result.set(chunks[i], offset)
|
||||
offset += chunks[i].length
|
||||
}
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
// global control extension
|
||||
const gceSchema = conditional(
|
||||
{
|
||||
gce: [
|
||||
{ codes: readBytes(2) },
|
||||
{ byteSize: readByte() },
|
||||
{
|
||||
extras: readBits({
|
||||
future: { index: 0, length: 3 },
|
||||
disposal: { index: 3, length: 3 },
|
||||
userInput: { index: 6 },
|
||||
transparentColorGiven: { index: 7 },
|
||||
}),
|
||||
},
|
||||
{ delay: readUnsigned(true) },
|
||||
{ transparentColorIndex: readByte() },
|
||||
{ terminator: readByte() },
|
||||
],
|
||||
},
|
||||
(stream) => {
|
||||
var codes = peekBytes(2)(stream)
|
||||
return codes[0] === 0x21 && codes[1] === 0xf9
|
||||
}
|
||||
)
|
||||
|
||||
// image pipeline block
|
||||
const imageSchema = conditional(
|
||||
{
|
||||
image: [
|
||||
{ code: readByte() },
|
||||
{
|
||||
descriptor: [
|
||||
{ left: readUnsigned(true) },
|
||||
{ top: readUnsigned(true) },
|
||||
{ width: readUnsigned(true) },
|
||||
{ height: readUnsigned(true) },
|
||||
{
|
||||
lct: readBits({
|
||||
exists: { index: 0 },
|
||||
interlaced: { index: 1 },
|
||||
sort: { index: 2 },
|
||||
future: { index: 3, length: 2 },
|
||||
size: { index: 5, length: 3 },
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
conditional(
|
||||
{
|
||||
lct: readArray(3, (stream, result, parent) => {
|
||||
return Math.pow(2, parent.descriptor.lct.size + 1)
|
||||
}),
|
||||
},
|
||||
(stream, result, parent) => {
|
||||
return parent.descriptor.lct.exists
|
||||
}
|
||||
),
|
||||
{ data: [{ minCodeSize: readByte() }, subBlocksSchema] },
|
||||
],
|
||||
},
|
||||
(stream) => {
|
||||
return peekByte()(stream) === 0x2c
|
||||
}
|
||||
)
|
||||
|
||||
// plain text block
|
||||
const textSchema = conditional(
|
||||
{
|
||||
text: [
|
||||
{ codes: readBytes(2) },
|
||||
{ blockSize: readByte() },
|
||||
{
|
||||
preData: (stream, result, parent) =>
|
||||
readBytes(parent.text.blockSize)(stream),
|
||||
},
|
||||
subBlocksSchema,
|
||||
],
|
||||
},
|
||||
(stream) => {
|
||||
var codes = peekBytes(2)(stream)
|
||||
return codes[0] === 0x21 && codes[1] === 0x01
|
||||
}
|
||||
)
|
||||
|
||||
// application block
|
||||
const applicationSchema = conditional(
|
||||
{
|
||||
application: [
|
||||
{ codes: readBytes(2) },
|
||||
{ blockSize: readByte() },
|
||||
{ id: (stream, result, parent) => readString(parent.blockSize)(stream) },
|
||||
subBlocksSchema,
|
||||
],
|
||||
},
|
||||
(stream) => {
|
||||
var codes = peekBytes(2)(stream)
|
||||
return codes[0] === 0x21 && codes[1] === 0xff
|
||||
}
|
||||
)
|
||||
|
||||
// comment block
|
||||
const commentSchema = conditional(
|
||||
{
|
||||
comment: [{ codes: readBytes(2) }, subBlocksSchema],
|
||||
},
|
||||
(stream) => {
|
||||
var codes = peekBytes(2)(stream)
|
||||
return codes[0] === 0x21 && codes[1] === 0xfe
|
||||
}
|
||||
)
|
||||
|
||||
const schema = [
|
||||
{ header: [{ signature: readString(3) }, { version: readString(3) }] },
|
||||
{
|
||||
lsd: [
|
||||
{ width: readUnsigned(true) },
|
||||
{ height: readUnsigned(true) },
|
||||
{
|
||||
gct: readBits({
|
||||
exists: { index: 0 },
|
||||
resolution: { index: 1, length: 3 },
|
||||
sort: { index: 4 },
|
||||
size: { index: 5, length: 3 },
|
||||
}),
|
||||
},
|
||||
{ backgroundColorIndex: readByte() },
|
||||
{ pixelAspectRatio: readByte() },
|
||||
],
|
||||
},
|
||||
conditional(
|
||||
{
|
||||
gct: readArray(3, (stream, result) =>
|
||||
Math.pow(2, result.lsd.gct.size + 1)
|
||||
),
|
||||
},
|
||||
(stream, result) => result.lsd.gct.exists
|
||||
),
|
||||
// content frames
|
||||
{
|
||||
frames: loop(
|
||||
[gceSchema, applicationSchema, commentSchema, imageSchema, textSchema],
|
||||
(stream) => {
|
||||
var nextCode = peekByte()(stream)
|
||||
// rather than check for a terminator, we should check for the existence
|
||||
// of an ext or image block to avoid infinite loops
|
||||
//var terminator = 0x3B;
|
||||
//return nextCode !== terminator;
|
||||
return nextCode === 0x21 || nextCode === 0x2c
|
||||
}
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export default schema
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* javascript port of java LZW decompression
|
||||
* Original java author url: https://gist.github.com/devunwired/4479231
|
||||
*/
|
||||
|
||||
export const lzw = (minCodeSize, data, pixelCount) => {
|
||||
const MAX_STACK_SIZE = 4096
|
||||
const nullCode = -1
|
||||
const npix = pixelCount
|
||||
var available,
|
||||
clear,
|
||||
code_mask,
|
||||
code_size,
|
||||
end_of_information,
|
||||
in_code,
|
||||
old_code,
|
||||
bits,
|
||||
code,
|
||||
i,
|
||||
datum,
|
||||
data_size,
|
||||
first,
|
||||
top,
|
||||
bi,
|
||||
pi
|
||||
|
||||
const dstPixels = new Array(pixelCount)
|
||||
const prefix = new Array(MAX_STACK_SIZE)
|
||||
const suffix = new Array(MAX_STACK_SIZE)
|
||||
const pixelStack = new Array(MAX_STACK_SIZE + 1)
|
||||
|
||||
// Initialize GIF data stream decoder.
|
||||
data_size = minCodeSize
|
||||
clear = 1 << data_size
|
||||
end_of_information = clear + 1
|
||||
available = clear + 2
|
||||
old_code = nullCode
|
||||
code_size = data_size + 1
|
||||
code_mask = (1 << code_size) - 1
|
||||
for (code = 0; code < clear; code++) {
|
||||
prefix[code] = 0
|
||||
suffix[code] = code
|
||||
}
|
||||
|
||||
// Decode GIF pixel stream.
|
||||
var datum, bits, count, first, top, pi, bi
|
||||
datum = bits = count = first = top = pi = bi = 0
|
||||
for (i = 0; i < npix; ) {
|
||||
if (top === 0) {
|
||||
if (bits < code_size) {
|
||||
// get the next byte
|
||||
datum += data[bi] << bits
|
||||
|
||||
bits += 8
|
||||
bi++
|
||||
continue
|
||||
}
|
||||
// Get the next code.
|
||||
code = datum & code_mask
|
||||
datum >>= code_size
|
||||
bits -= code_size
|
||||
// Interpret the code
|
||||
if (code > available || code == end_of_information) {
|
||||
break
|
||||
}
|
||||
if (code == clear) {
|
||||
// Reset decoder.
|
||||
code_size = data_size + 1
|
||||
code_mask = (1 << code_size) - 1
|
||||
available = clear + 2
|
||||
old_code = nullCode
|
||||
continue
|
||||
}
|
||||
if (old_code == nullCode) {
|
||||
pixelStack[top++] = suffix[code]
|
||||
old_code = code
|
||||
first = code
|
||||
continue
|
||||
}
|
||||
in_code = code
|
||||
if (code == available) {
|
||||
pixelStack[top++] = first
|
||||
code = old_code
|
||||
}
|
||||
while (code > clear) {
|
||||
pixelStack[top++] = suffix[code]
|
||||
code = prefix[code]
|
||||
}
|
||||
|
||||
first = suffix[code] & 0xff
|
||||
pixelStack[top++] = first
|
||||
|
||||
// add a new string to the table, but only if space is available
|
||||
// if not, just continue with current table until a clear code is found
|
||||
// (deferred clear code implementation as per GIF spec)
|
||||
if (available < MAX_STACK_SIZE) {
|
||||
prefix[available] = old_code
|
||||
suffix[available] = first
|
||||
available++
|
||||
if ((available & code_mask) === 0 && available < MAX_STACK_SIZE) {
|
||||
code_size++
|
||||
code_mask += available
|
||||
}
|
||||
}
|
||||
old_code = in_code
|
||||
}
|
||||
// Pop a pixel off the pixel stack.
|
||||
top--
|
||||
dstPixels[pi++] = pixelStack[top]
|
||||
i++
|
||||
}
|
||||
|
||||
for (i = pi; i < npix; i++) {
|
||||
dstPixels[i] = 0 // clear missing pixels
|
||||
}
|
||||
|
||||
return dstPixels
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
The MIT License
|
||||
Copyright (c) 2009-2016 Stuart Knightley, David Duponchel, Franz Buchinger, António Afonso
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
Vendored
+13
File diff suppressed because one or more lines are too long
@@ -1,23 +0,0 @@
|
||||
<!DOCTYPE html><html><head>
|
||||
<style>
|
||||
img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
</style>
|
||||
</head><body>
|
||||
|
||||
<h1>Manage emoji</h1>
|
||||
<p>Note: Changes made here do not affect VRChat. To commit animation style to VRChat you must toggle emoji off and back on again. If you a delete an enabled emoji, it will reappear.</p>
|
||||
<table id="table">
|
||||
<tr>
|
||||
<th>Emoji</th>
|
||||
<th>Animation Style</th>
|
||||
<th>Delete</th>
|
||||
</tr>
|
||||
</table>
|
||||
<h1>Add emoji</h1>
|
||||
<input type="file" id="fileinput" accept="image/*" multiple /><input type="submit" id="submit" />
|
||||
|
||||
<script src="edit.js" charset="UTF-8"></script>
|
||||
</body></html>
|
||||
@@ -1,114 +0,0 @@
|
||||
var animationStyles = `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('\n');
|
||||
|
||||
load();
|
||||
async function load() {
|
||||
var store = await chrome.storage.local.get();
|
||||
if (!store.emoji) throw new Error("No emoji.");
|
||||
for (let emoji of store.emoji) {
|
||||
await instantiateRow(store["data-" + emoji.internalId], emoji.internalId, emoji.animationStyle);
|
||||
}
|
||||
}
|
||||
|
||||
async function instantiateRow(url, internalId, animationStyle) {
|
||||
let row = table.insertRow();
|
||||
row.dataset.emojiId = internalId;
|
||||
let [c0, c1, c2] = [row.insertCell(), row.insertCell(), row.insertCell()];
|
||||
|
||||
let img = document.createElement("img");
|
||||
img.src = url;
|
||||
c0.appendChild(img);
|
||||
|
||||
let select = document.createElement("select");
|
||||
animationStyles.forEach(a => {
|
||||
let option = document.createElement("option");
|
||||
option.innerText = a;
|
||||
option.value = a.toLowerCase();
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.value = animationStyle;
|
||||
select.onchange = event => {
|
||||
setAnimationStyle(internalId, event.target.value);
|
||||
};
|
||||
c1.appendChild(select);
|
||||
|
||||
let button = document.createElement("button");
|
||||
button.innerText = "🗑️";
|
||||
button.onclick = () => {
|
||||
deleteEmoji(internalId);
|
||||
row.remove();
|
||||
};
|
||||
c2.appendChild(button);
|
||||
}
|
||||
|
||||
async function deleteEmoji(internalId) {
|
||||
if (!confirm("Delete emoji from storage. Are you sure?")) return;
|
||||
var {emoji} = await chrome.storage.local.get("emoji");
|
||||
emoji = emoji.filter(e => e.internalId != internalId);
|
||||
await chrome.storage.local.set({emoji});
|
||||
}
|
||||
|
||||
async function setAnimationStyle(internalId, animationStyle) {
|
||||
var {emoji} = await chrome.storage.local.get("emoji");
|
||||
var e = emoji.find(e => e.internalId == internalId);
|
||||
e.animationStyle = animationStyle;
|
||||
await chrome.storage.local.set({emoji});
|
||||
}
|
||||
|
||||
|
||||
submit.onclick = async () => {
|
||||
submit.disabled = true;
|
||||
var newEmoji = [];
|
||||
for (let file of fileinput.files) {
|
||||
let e = {
|
||||
internalId: Math.random().toString(),
|
||||
animationStyle: "aura",
|
||||
};
|
||||
let data = await fileToDataURL(file);
|
||||
//todo convert/resize
|
||||
await chrome.storage.local.set({["data-"+e.internalId]: data});
|
||||
newEmoji.push(e);
|
||||
instantiateRow(data, e.internalId, e.animationStyle);
|
||||
}
|
||||
var {emoji} = await chrome.storage.local.get("emoji");
|
||||
emoji = emoji.concat(newEmoji);
|
||||
await chrome.storage.local.set({emoji});
|
||||
submit.disabled = false;
|
||||
};
|
||||
|
||||
|
||||
function fileToDataURL(file) {
|
||||
return new Promise(function(resolve, reject){
|
||||
let reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
+86
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html><html><head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>VRChat Emoji and Sticker Manager</title>
|
||||
|
||||
<style>
|
||||
|
||||
body {
|
||||
margin: 0px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.deletemode {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
#emojigrid {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
align-content: flex-start;
|
||||
}
|
||||
.emojisquare {
|
||||
border: 1px solid gray;
|
||||
margin: 1px;
|
||||
padding: 4px;
|
||||
}
|
||||
.emojisquare .imgcontainer {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
}
|
||||
.emojisquare .imgcontainer img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
[data-state=enabled] {
|
||||
background-color:rgba(0, 255, 0, 0.5);
|
||||
}
|
||||
[data-state=disabled] {
|
||||
background-color:rgba(255, 0, 0, 0.5);
|
||||
}
|
||||
[data-state=pending] {
|
||||
background-color:rgba(255, 255, 0, 0.5);
|
||||
}
|
||||
|
||||
|
||||
#bottompanel {
|
||||
border: 1px solid gray;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
#bottompanel div {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
|
||||
#errorDiv {
|
||||
color: red;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
<style id="dynamicstyle"></style>
|
||||
</head><body>
|
||||
<div id="emojigrid"></div>
|
||||
<div id="bottompanel">
|
||||
<div id="errorDiv"></div>
|
||||
<div class="emojistuff">Default Animation Style: <select id="default_animation_style_select"><option value="aura">Aura</option><option value="bats">Bats</option><option value="bees">Bees</option><option value="bounce">Bounce</option><option value="cloud">Cloud</option><option value="confetti">Confetti</option><option value="crying">Crying</option><option value="dislike">Dislike</option><option value="fire">Fire</option><option value="idea">Idea</option><option value="lasers">Lasers</option><option value="like">Like</option><option value="magnet">Magnet</option><option value="mistletoe">Mistletoe</option><option value="money">Money</option><option value="noise">Noise</option><option value="orbit">Orbit</option><option value="pizza">Pizza</option><option value="rain">Rain</option><option value="rotate">Rotate</option><option value="shake">Shake</option><option value="snow">Snow</option><option value="snowball">Snowball</option><option value="spin">Spin</option><option value="splash">Splash</option><option value="stop">Stop</option><option value="zzz">ZZZ</option></select></div>
|
||||
<div><button id="modebtn">Sticker Mode</button></div>
|
||||
<div><button id="addbtn">Add</button></div>
|
||||
<div><button id="deletebtn">Delete</button></div>
|
||||
<div><button id="importbtn">Import</button></div>
|
||||
<div><button id="exportbtn">Export</button></div>
|
||||
<div><button id="clearbtn">Clear</button></div>
|
||||
</div>
|
||||
|
||||
<script src="dep/jszip.min.js"></script>
|
||||
<script type="module" src="manage.js"></script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,527 @@
|
||||
import * as db from "../db.js";
|
||||
import { parseGIF, decompressFrames } from "./dep/gifuct/index.js";
|
||||
import { importVrcFile } from "./common.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();
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
if (errors.length) {
|
||||
displayError(`Errors occured adding ${errors.length} files: ${errors.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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 = '';
|
||||
};
|
||||
|
||||
|
||||
refresh();
|
||||
onfocus = refresh;
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
var vrc_files = await callContentScript("getFiles", mode);
|
||||
var images = await db.getAllImages();
|
||||
if (!images.length) throw new Error("No emoji.");
|
||||
var currentIds = images.map(i => i["currentId_"+mode]);
|
||||
for (let f of vrc_files) {
|
||||
if (!currentIds.includes(f.id)) {
|
||||
let i = await importVrcFile(f);
|
||||
images.push(i);
|
||||
}
|
||||
}
|
||||
for (let image of images) {
|
||||
if (!document.querySelector(`[data-internal-id="${image.internalId}"]`)) createEmojiSquare(image);
|
||||
}
|
||||
var internalIds = images.map(i => i.internalId);
|
||||
var vrc_file_ids = vrc_files.map(e => e.id);
|
||||
for (let emojisquare of document.querySelectorAll(".emojisquare")) {
|
||||
if (!internalIds.includes(emojisquare.dataset.internalId)) emojisquare.delete();
|
||||
else {
|
||||
emojisquare.dataset.state = vrc_file_ids.includes(emojisquare.dataset["currentId_"+mode]) ? "enabled" : "disabled";
|
||||
}
|
||||
}
|
||||
errorDiv.innerText = "";
|
||||
} catch (error) {
|
||||
displayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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])")) refresh();
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
+10
-5
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "VRChat Emoji Manager",
|
||||
"version": "0.0.1",
|
||||
"name": "VRChat Emoji and Sticker Manager",
|
||||
"version": "1.2025.5.3",
|
||||
"description": "Store more than 9 emoji or stickers, toggle them on and off and change their animation styles.",
|
||||
"homepage_url": "https://gitea.moe/lamp/vrchat-emoji-manager",
|
||||
"icons": {
|
||||
"128": "icon128.png"
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"unlimitedStorage",
|
||||
"webRequest"
|
||||
"unlimitedStorage"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://vrchat.com/home*",
|
||||
@@ -26,7 +30,8 @@
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"options_page": "manage.html",
|
||||
"action": {
|
||||
"default_popup": "toggle.html"
|
||||
"default_popup": "popup.html"
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html><html><head>
|
||||
<style>
|
||||
body {
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
body div {
|
||||
margin: 2px;
|
||||
}
|
||||
</style>
|
||||
</head><body>
|
||||
<h2>VRChat Emoji & Sticker Manager</h2>
|
||||
<div><button id="btn1">Launch in new tab</button></div>
|
||||
<div><button id="btn2">Launch in pop up window</button></div>
|
||||
<script src="popup.js"></script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,12 @@
|
||||
btn1.onclick = () => open("manage.html");
|
||||
btn2.onclick = () => {
|
||||
chrome.windows.create({
|
||||
url: "manage.html",
|
||||
focused: true,
|
||||
type: "popup"
|
||||
});
|
||||
};
|
||||
|
||||
var width = Math.max(btn1.clientWidth, btn2.clientWidth);
|
||||
btn1.style.width = width + "px";
|
||||
btn2.style.width = width + "px";
|
||||
@@ -0,0 +1,27 @@
|
||||
# VRChat Emoji and Sticker Manager
|
||||
|
||||
VRChat Plus has a custom emoji and sticker feature but they are limited to 9 emojis or stickers and you cannot change the animation styles of the emojis. This Chrome extension allows you to have a much larger collection of emojis or stickers and conveniently toggle them on and off when needed.
|
||||
|
||||
Get it on the Chrome Web Store!!! https://chromewebstore.google.com/detail/vrchat-emoji-and-sticker/obmoelidfamikmdhgjeoacpmkfhohekb
|
||||
|
||||

|
||||
|
||||
## Manual install from source
|
||||
|
||||
1. If you have git, run `git clone https://gitea.moe/lamp/vrchat-emoji-manager.git`. If not, [download](https://gitea.moe/lamp/vrchat-emoji-manager/archive/main.zip) and extract the zip.
|
||||
2. Navigate to [chrome://extensions/](chrome://extensions/)
|
||||
3. Enable developer mode
|
||||
4. Click "Load unpacked"
|
||||
5. Select the cloned or unzipped folder
|
||||
6. Open https://vrchat.com/home, existing emojis should be imported
|
||||
7. Click extension icon to launch in tab or window
|
||||
|
||||
### Update
|
||||
|
||||
1. Export your emojis/stickers so you have a backup
|
||||
2. If you installed with git, run `git pull`. If not, download the zip again, and extract it to the SAME PATH
|
||||
3. Go to [chrome://extensions/](chrome://extensions/) and reload the extension
|
||||
|
||||
Note, do not move or rename the extension folder, otherwise you have to re-add the extension to Chrome, which will change the extension ID, and your emojis will be gone. It is also a good idea to keep an export file as a backup.
|
||||
|
||||
IF ANY ISSUES REPORT [HERE](https://gitea.moe/lamp/vrchat-emoji-manager/issues?state=open)
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
<!DOCTYPE html><html><head>
|
||||
<style>
|
||||
#emojigrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 269px;
|
||||
}
|
||||
.emojisquare {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 1px solid gray;
|
||||
margin: 1px;
|
||||
display: flex;
|
||||
}
|
||||
.emojisquare img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: auto;
|
||||
}
|
||||
.enabled {
|
||||
background-color:rgba(0, 255, 0, 0.5);
|
||||
}
|
||||
.disabled {
|
||||
background-color:rgba(255, 0, 0, 0.5);
|
||||
}
|
||||
.pending {
|
||||
background-color:rgba(255, 255, 0, 0.5);
|
||||
}
|
||||
#errorDiv {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
</head><body>
|
||||
|
||||
<div id="emojigrid"></div>
|
||||
<div id="errorDiv"></div>
|
||||
|
||||
<script src="toggle.js"></script>
|
||||
</body></html>
|
||||
@@ -1,104 +0,0 @@
|
||||
loadEmojis().catch(displayError);
|
||||
|
||||
async function loadEmojis() {
|
||||
errorDiv.innerText = "";
|
||||
var store = await chrome.storage.local.get();
|
||||
if (!store.emoji) throw new Error("No emoji.");
|
||||
for (let emoji of store.emoji) {
|
||||
let div = document.createElement("div");
|
||||
div.dataset.animationStyle = emoji.animationStyle;
|
||||
div.dataset.internalId = emoji.internalId;
|
||||
div.dataset.currentId = emoji.currentId;
|
||||
div.classList.add("emojisquare");
|
||||
div.onclick = toggleEmoji;
|
||||
|
||||
let img = document.createElement("img");
|
||||
img.src = store["data-" + emoji.internalId];
|
||||
div.appendChild(img);
|
||||
|
||||
emojigrid.appendChild(div);
|
||||
}
|
||||
await loadToggleState();
|
||||
}
|
||||
|
||||
async function loadToggleState() {
|
||||
var elements = document.querySelectorAll(".emojisquare");
|
||||
if (elements.length === 0) return;
|
||||
var currentEmojis = await callContentScript("getEmojis");
|
||||
var active = currentEmojis?.map(e => e.id);
|
||||
elements.forEach(e => {
|
||||
var yes = active.includes(e.dataset.currentId);
|
||||
e.classList.add(yes ? "enabled" : "disabled");
|
||||
e.classList.remove(yes ? "disabled" : "enabled");
|
||||
});
|
||||
}
|
||||
|
||||
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 += ` Log in to <a href=\"https://vrchat.com/home\" target=\"_blank\">https://vrchat.com/home</a> and then reload.`;
|
||||
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 response = await chrome.tabs.sendMessage(tab.id, [method, ...args]);
|
||||
console.debug(response);
|
||||
if (response.error) throw response.error;
|
||||
else return response;
|
||||
};
|
||||
|
||||
async function toggleEmoji(event) {
|
||||
if (this.classList.contains("pending")) return;
|
||||
this.classList.add("pending");
|
||||
errorDiv.innerText = "";
|
||||
|
||||
if (document.querySelectorAll(".enabled").length + document.querySelectorAll(".disabled").length < document.querySelectorAll(".emojisquare").length) {
|
||||
try {
|
||||
await loadToggleState();
|
||||
} catch (error) {
|
||||
displayError(error);
|
||||
this.classList.remove("pending");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.classList.contains("enabled")) {
|
||||
// disable
|
||||
callContentScript("deleteEmoji", this.dataset.currentId).then(() => {
|
||||
this.classList.remove("pending");
|
||||
this.classList.remove("enabled");
|
||||
this.classList.add("disabled");
|
||||
}).catch(error => {
|
||||
this.classList.remove("pending");
|
||||
displayError(error);
|
||||
});
|
||||
} else {
|
||||
// enable
|
||||
callContentScript("createEmoji", this.querySelector("img").src, this.dataset.animationStyle).then(newEmoji => {
|
||||
chrome.storage.local.get("emoji").then(({emoji}) => {
|
||||
emoji.find(e => e.internalId == this.dataset.internalId).currentId = newEmoji.id;
|
||||
chrome.storage.local.set({emoji});
|
||||
this.dataset.currentId = newEmoji.id;
|
||||
this.classList.remove("pending");
|
||||
this.classList.remove("disabled");
|
||||
this.classList.add("enabled");
|
||||
});
|
||||
}).catch(error => {
|
||||
this.classList.remove("pending");
|
||||
displayError(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user