From ddd692bcbd1fca471399b3ea53f68b78f059b8aa Mon Sep 17 00:00:00 2001 From: HF Date: Mon, 29 Jan 2024 23:50:07 +0100 Subject: [PATCH] :template editing, adding, export and import --- src/components/TemplateItem.jsx | 9 +- src/components/TemplateItemEdit.jsx | 136 +++++++++++++++++++++------- src/components/TemplateSettings.jsx | 132 +++++++++++++++++---------- src/components/windows/Settings.jsx | 8 +- src/core/utils.js | 22 +++++ src/store/actions/templates.js | 16 ++++ src/store/reducers/templates.js | 41 +++++++++ src/styles/default.css | 5 +- src/ui/templateLoader.js | 113 ++++++++++++++++++++--- src/utils/FileStorage.js | 31 ++++++- 10 files changed, 409 insertions(+), 104 deletions(-) diff --git a/src/components/TemplateItem.jsx b/src/components/TemplateItem.jsx index 7841012b..894b2c7d 100644 --- a/src/components/TemplateItem.jsx +++ b/src/components/TemplateItem.jsx @@ -14,7 +14,6 @@ const TemplateItem = ({ enabled, title, canvasId, x, y, width, height, imageId, startEditing, }) => { const imgRef = useRef(); - const [canvasTitle, setCanvasTitle] = useState('Earth'); const canvases = useSelector((state) => state.canvas.canvases); const dispatch = useDispatch(); @@ -27,19 +26,25 @@ const TemplateItem = ({ if (!previewImg) { return; } - imgRef.current.getContext('2d').drawImage(previewImg, 0, 0); + console.log('rerendering image', imageId, previewImg); + const bitmap = await createImageBitmap(previewImg); + imgRef.current.getContext('bitmaprenderer') + .transferFromImageBitmap(bitmap); + bitmap.close(); })(); }, [imageId]); return (
dispatch(changeTemplate(title, { enabled: !enabled }))} >
diff --git a/src/components/TemplateItemEdit.jsx b/src/components/TemplateItemEdit.jsx index 01702951..23e9ae06 100644 --- a/src/components/TemplateItemEdit.jsx +++ b/src/components/TemplateItemEdit.jsx @@ -5,44 +5,41 @@ import React, { useRef, useState, useEffect, useMemo, } from 'react'; -import { useDispatch, useSelector, shallowEqual } from 'react-redux'; +import { useSelector, shallowEqual } from 'react-redux'; import { t } from 'ttag'; import templateLoader from '../ui/templateLoader'; -import { changeTemplate } from '../store/actions/templates'; -import { selectCanvas, setViewCoordinates } from '../store/actions'; import { coordsFromUrl } from '../core/utils'; -const TemplateItem = ({ +const TemplateItemEdit = ({ title: initTitle, canvasId: initCanvasId, x: initX, y: initY, - width: initWidth, height: initHeight, - imageId: initImageId, + imageId, stopEditing, }) => { const [initCoords, initDimensions] = useMemo(() => [ (Number.isNaN(parseInt(initX, 10)) || Number.isNaN(parseInt(initY, 10))) ? null : [initX, initY], - (Number.isNaN(parseInt(initWidth, 10)) - || Number.isNaN(parseInt(initHeight, 10))) ? null : [initWidth, initHeight], - ], [initX, initY, initWidth, initHeight]); + ], [initX, initY]); const [coords, setCoords] = useState(initCoords); const [dimensions, setDimensions] = useState(initDimensions); - const [title, setTitle] = useState(initTitle); + const [title, setTitle] = useState(initTitle || ''); const [file, setFile] = useState(null); - const [imageId, setImageId] = useState(initImageId); + const [titleUnique, setTitleUnique] = useState(true); const imgRef = useRef(); + const fileRef = useRef(); const [ storeCanvasId, canvases, + templateList, ] = useSelector((state) => [ state.canvas.canvasId, state.canvas.canvases, + state.templates.list, ], shallowEqual); - const [selectedCanvas, selectCanvas] = useState(initCanvasId ?? storeCanvasId); - const dispatch = useDispatch(); + const [canvasId, selectCanvas] = useState(initCanvasId ?? storeCanvasId); useEffect(() => { (async () => { @@ -53,36 +50,78 @@ const TemplateItem = ({ if (!previewImg) { return; } + const bitmap = await createImageBitmap(previewImg); const canvas = imgRef.current; - canvas.width = previewImg.width; - canvas.height = previewImg.height; - canvas.getContext('2d').drawImage(previewImg, 0, 0); + const { width, height } = bitmap; + canvas.width = width; + canvas.height = height; + canvas.getContext('bitmaprenderer').transferFromImageBitmap(bitmap); + setDimensions([width, height]); + bitmap.close(); })(); }, [imageId]); - const canSubmit = (imgRef.current && file && coords && title && dimensions); + useEffect(() => { + if (!file || !imgRef.current) { + return; + } + (async () => { + const bitmap = await createImageBitmap(file); + const canvas = imgRef.current; + const { width, height } = bitmap; + canvas.width = width; + canvas.height = height; + canvas.getContext('bitmaprenderer').transferFromImageBitmap(bitmap); + setDimensions([width, height]); + bitmap.close(); + })(); + }, [file]); + + const canSubmit = (imgRef.current && (file || imageId) + && titleUnique && coords && title && dimensions); return (
- + +
+
fileRef.current?.click()} + >{t`Select File`}
+ { + setDimensions(null); + setFile(evt.target.files?.[0]); + }} /> -
{t`Select File`}

setTitle(evt.target.value)} + onChange={(evt) => { + const newTitle = evt.target.value; + setTitleUnique(!templateList.some((t) => t.title === newTitle)); + setTitle(evt.target.value); + }} placeholder={t`Template Name`} />

{t`Canvas`}:  { + templateLoader.importTemplates(evt.target.files?.[0]); + }} + /> +

+ ); }; diff --git a/src/components/windows/Settings.jsx b/src/components/windows/Settings.jsx index c0f82f23..6b36e794 100644 --- a/src/components/windows/Settings.jsx +++ b/src/components/windows/Settings.jsx @@ -67,6 +67,7 @@ const Settings = () => { isMuted, chatNotify, isHistoricalView, + templatesAvailable, ] = useSelector((state) => [ state.gui.showGrid, state.gui.showPixelNotify, @@ -79,6 +80,7 @@ const Settings = () => { state.gui.mute, state.gui.chatNotify, state.canvas.isHistoricalView, + state.templates.available, ], shallowEqual); const dispatch = useDispatch(); const audioAvailable = window.AudioContext || window.webkitAudioContext; @@ -193,11 +195,7 @@ const Settings = () => {
)}
-

{t`Templates`}

-

- {t`Tired of always spaming one single color? Want to create art instead, but you have to count pixels from some other image? Templates can help you with that! Templates can show as overlay and you can draw over them. One pixel on the template, should be one pixel on the canvas.`} -

- + {(templatesAvailable) && }
); }; diff --git a/src/core/utils.js b/src/core/utils.js index e357853a..35a5c3ca 100644 --- a/src/core/utils.js +++ b/src/core/utils.js @@ -688,3 +688,25 @@ export function parentExists() { return false; } } + +export function bufferToBase64(array) { + return new Promise((resolve) => { + const blob = new Blob([array]); + const reader = new FileReader(); + + reader.onload = (event) => { + const dataUrl = event.target.result; + const [_, base64] = dataUrl.split(','); + + resolve(base64); + }; + + reader.readAsDataURL(blob); + }); +} + +export async function base64ToBuffer(base64) { + const dataUrl = "data:application/octet-binary;base64," + base64; + const res = await fetch(dataUrl); + return res.arrayBuffer(); +} diff --git a/src/store/actions/templates.js b/src/store/actions/templates.js index 4eb6c510..ef7bc842 100644 --- a/src/store/actions/templates.js +++ b/src/store/actions/templates.js @@ -15,6 +15,12 @@ export function listTemplate(imageId, title, canvasId, x, y, width, height) { }; } +export function templatesReady(title) { + return { + type: 'TEMPLATES_READY', + }; +} + export function removeTemplate(title) { return { type: 's/REM_TEMPLATE', @@ -29,3 +35,13 @@ export function changeTemplate(title, props) { props, }; } + +export function updatedTemplateImage(imageId, width, height) { + console.log('update', width, height, 'store'); + return { + type: 's/UPD_TEMPLATE_IMG', + imageId, + width, + height, + }; +} diff --git a/src/store/reducers/templates.js b/src/store/reducers/templates.js index 710408c5..6771c1e1 100644 --- a/src/store/reducers/templates.js +++ b/src/store/reducers/templates.js @@ -71,6 +71,47 @@ export default function templates( }; } + case 's/REM_TEMPLATE': { + return { + ...state, + list: state.list.filter((t) => t.title !== action.title), + }; + } + + case 's/UPD_TEMPLATE_IMG': { + const { imageId, width, height } = action; + const { list } = state; + const index = list.findIndex((t) => t.imageId === imageId); + if (index === -1) { + return state; + } + return { + ...state, + list: [ + ...list.slice(0, index), + { + ...list[index], + imageId, + width, + height, + }, + ...list.slice(index + 1), + ], + }; + } + + case 'TEMPLATES_READY': + return { + ...state, + available: true, + }; + + case 'persist/REHYDRATE': + return { + ...state, + available: false, + }; + default: return state; } diff --git a/src/styles/default.css b/src/styles/default.css index 44bd2c9f..e82725c3 100644 --- a/src/styles/default.css +++ b/src/styles/default.css @@ -316,7 +316,6 @@ tr:nth-child(even) { display: flex; flex-direction: row; align-items: center; - cursor: pointer; } .tmpitm.disabled { @@ -356,9 +355,9 @@ tr:nth-child(even) { .centered-on-img { position: relative; - transform: translateY(-99px); + transform: translateY(-96px); line-height: 96px; - background: #e6e6e6d1; + background: #e6e6e6a6; white-space: nowrap; max-width: 96px; height: 96px; diff --git a/src/ui/templateLoader.js b/src/ui/templateLoader.js index 44295b61..94616760 100644 --- a/src/ui/templateLoader.js +++ b/src/ui/templateLoader.js @@ -6,7 +6,11 @@ import FileStorage from '../utils/FileStorage'; import { removeTemplate, listTemplate, + updatedTemplateImage, + changeTemplate, + templatesReady, } from '../store/actions/templates'; +import { bufferToBase64, base64ToBuffer } from '../core/utils'; import Template from './Template'; const STORE_NAME = 'templates'; @@ -20,13 +24,21 @@ class TemplateLoader { ready = false; async initialize(store) { - this.#store = store; - await this.#fileStorage.initialize(); - await this.syncDB(); - this.ready = true; + try { + this.#store = store; + await this.#fileStorage.initialize(); + this.ready = true; + this.#store.dispatch(templatesReady()); + await this.syncDB(); + } catch (err) { + console.warn(`Couldn't initialize Templates: ${err.message}`); + } } async getTemplate(id) { + if (!this.ready) { + return null; + } let template = this.#templates.get(id); if (template) { return template.image; @@ -88,6 +100,7 @@ class TemplateLoader { throw new Error('File does not exist in indexedDB'); } const { mimetype, buffer } = fileData; + console.log('mime', mimetype, 'buffer', buffer); const template = new Template(imageId); await template.fromBuffer(buffer, mimetype); this.#templates.set(imageId, template); @@ -105,16 +118,11 @@ class TemplateLoader { * @param element optional image or canvas element where file is already loaded, * can be used to avoid having to load it multiple times */ - async addFile(file, title, canvasId, x, y, element = null) { + async addFile(file, title, canvasId, x, y) { try { const imageId = await this.#fileStorage.saveFile(file); const template = new Template(imageId); - let dimensions; - if (element) { - dimensions = await template.fromImage(element); - } else { - dimensions = await template.fromFile(file); - } + const dimensions = await template.fromFile(file); this.#templates.set(imageId, template); this.#store.dispatch(listTemplate( imageId, title, canvasId, x, y, ...dimensions, @@ -124,6 +132,89 @@ class TemplateLoader { console.error(`Error saving template ${title}: ${err.message}`); } } + + async updateFile(imageId, file) { + try { + await this.#fileStorage.updateFile(imageId, file); + const template = new Template(imageId); + const dimensions = await template.fromFile(file); + this.#templates.set(imageId, template); + this.#store.dispatch(updatedTemplateImage(imageId, ...dimensions)); + } catch (err) { + // eslint-disable-next-line no-console + console.error(`Error updating file ${imageId}: ${err.message}`); + } + } + + changeTemplate(title, props) { + this.#store.dispatch(changeTemplate(title, props)); + } + + deleteTemplate(title) { + const { list } = this.#store.getState().templates; + const tData = list.find((z) => z.title === title) + if (!tData) { + return; + } + const { imageId } = tData; + this.#store.dispatch(removeTemplate(title)); + this.#fileStorage.deleteFile(imageId); + this.#templates.delete(imageId); + } + + async exportEnabledTemplates() { + const { list } = this.#store.getState().templates; + const tDataList = list.filter((z) => z.enabled); + const temps = await this.#fileStorage.loadFile( + tDataList.map((z) => z.imageId), + ); + const serilizableObj = []; + for (let i = 0; i < tDataList.length; i += 1) { + const { buffer, mimetype } = temps[i]; + console.log('mimetype', mimetype); + serilizableObj.push({ + ...tDataList[i], + // eslint-disable-next-line no-await-in-loop + buffer: await bufferToBase64(buffer), + mimetype, + }); + } + return serilizableObj; + } + + async importTemplates(file) { + const tDataList = JSON.parse(await file.text()); + const bufferList = await Promise.all( + tDataList.map((z) => base64ToBuffer(z.buffer)), + ); + const fileList = []; + for (let i = 0; i < tDataList.length; i += 1) { + const { mimetype } = tDataList[i]; + console.log('mimetype', mimetype, 'buffer', bufferList[i]); + fileList.push(new Blob([bufferList[i]], { type: mimetype })); + } + const { list } = this.#store.getState().templates; + const imageIdList = await this.#fileStorage.saveFile(fileList); + const idsToDelete = []; + for (let i = 0; i < tDataList.length; i += 1) { + const { x, y, width, height, canvasId, title } = tDataList[i]; + const imageId = imageIdList[i]; + const existing = list.find((z) => z.title === title); + if (existing) { + idsToDelete.push(existing.imageId); + this.changeTemplate(title, { + imageId, title, canvasId, x, y, width, height, + }); + } else { + this.#store.dispatch(listTemplate( + imageId, title, canvasId, x, y, width, height, + )); + } + } + if (idsToDelete.length) { + this.#fileStorage.deleteFile(idsToDelete); + } + } } const templateLoader = new TemplateLoader(); diff --git a/src/utils/FileStorage.js b/src/utils/FileStorage.js index 7477b2e1..a74a3e7b 100644 --- a/src/utils/FileStorage.js +++ b/src/utils/FileStorage.js @@ -49,7 +49,7 @@ class FileStorage { request.onerror = () => { console.error('Error on opening indexedDB:', request.error); - reject(); + reject(request.error); }; }); } @@ -74,7 +74,7 @@ class FileStorage { transaction.onabort = (event) => { event.stopPropagation(); console.log('Saving files to indexedDB aborted:', event, result); - reject(); + reject(event.target.error); }; const os = transaction.objectStore('files'); @@ -92,6 +92,29 @@ class FileStorage { }); } + async updateFile(id, file) { + const { db } = FileStorage; + if (!db) { + return null; + } + const buffer = await file.arrayBuffer(); + return new Promise((resolve, reject) => { + const transaction = db.transaction('files', 'readwrite'); + + transaction.onabort = (event) => { + event.stopPropagation(); + console.log('Saving files to indexedDB aborted:', event); + reject(event.target.error); + }; + + transaction.objectStore('files').put({ + type: this.type, + mimetpe: file.type, + buffer, + }, id).onsuccess = (event) => resolve(event.target.result); + }); + } + // eslint-disable-next-line class-methods-use-this loadFile(ids) { const { db } = FileStorage; @@ -111,7 +134,7 @@ class FileStorage { transaction.onabort = (event) => { event.stopPropagation(); console.log('Loading file from indexedDB aborted:', event.target.error); - reject(); + reject(event.target.error); }; const os = transaction.objectStore('files'); @@ -143,7 +166,7 @@ class FileStorage { transaction.onabort = (event) => { event.stopPropagation(); console.log('Saving files to indexedDB aborted:', event); - reject(); + reject(event.target.error); }; const os = transaction.objectStore('files');