import localforage from "localforage"; import { BasicEventEmitter } from "../utils/eventEmitter"; import type { CanvasImage, CanvasImageEx, ProjectConfig, TracerOptions } from "../interface"; import { pushUnique, removeItem } from "../utils/collection"; import { cloneDeep, isEqual, last, throttle } from "lodash"; import { ImageEditor } from "./image-editor"; import { ToolHandler } from "./handler-tools"; import { GlobalSettingsHandler } from "./handler-global-settings"; import { Popup } from "./popup"; import { imageToOutlineSVG } from "../svg-tracer"; import { urlToImage, canvasToBlob, imageToCanvas } from "../utils/image"; import { loadFileAsDataUrl, makeFilenameSafe } from "../utils/generic"; import { v4 as uuidv4 } from "uuid"; import JSZip from "jszip"; import { downloadBlob } from "../utils/download"; declare const __APP_VERSION__: string; declare const __AUTHOR__: { name: string; email: string; url: string; }; export class VectraceHandler { public readonly VERSION = __APP_VERSION__; public readonly AUTHOR = __AUTHOR__; private readonly STORAGE_IMAGE_KEY = "images"; private readonly STORAGE_NAME_KEY = "NAME"; private _projectName = ""; private readonly STROKE_WIDTH = 2; public readonly popup = new Popup(); private store!: LocalForage; public readonly emitter = new BasicEventEmitter<{ update: [string, string[]]; add: [string]; remove: [string]; name: [string]; select: [string[], string | undefined | "*", string | undefined | "*"]; "array-length": [number]; ready: []; }>(); private _images: CanvasImage[] = []; private _renderedImages: CanvasImageEx[] = []; private _selected: string[] = []; private _ready = false; private _toolHandler = new ToolHandler(); private _globalSettingsHandler = new GlobalSettingsHandler(); private POOL_RATE = 250; private queue: { id: string, action: () => Promise | void }[] = []; private frame!: number; private destroyed = false; private tick = async () => { if (this.destroyed) return; const process = this.queue.shift(); if (process) { await Promise.resolve(process.action()).catch(console.log); } this.frame = window.setTimeout(this.tick, this.POOL_RATE); }; async init(name: string) { if (this.store) return; this._projectName = localStorage.getItem(this.STORAGE_NAME_KEY) || ""; if (!this._projectName) { await this.promptSetName(); } (window as any).c = this; this.store = localforage.createInstance({ name, }); const [images] = await Promise.all([ this.store.getItem(this.STORAGE_IMAGE_KEY), this._toolHandler.init(this.store), this._globalSettingsHandler.init(this.store), ]); this._images = images || []; this._renderedImages = await Promise.all(this._images.map((e) => this.renderImage(e))); this._ready = true; this.tick(); this.emitter.emit("ready"); } destroy() { clearTimeout(this.frame); this.destroyed = true; this.save(); } async promptSetName() { const fn = (message: string) => this._ready ? this.popup.prompt("Project name", message) : Promise.resolve(window.prompt(message)); this.setName(await fn("Enter your project name here") || ""); } async setName(name: string) { this._projectName = makeFilenameSafe(name); this.emitter.emit("name", this._projectName); localStorage.setItem(this.STORAGE_NAME_KEY, this._projectName); } async clear() { for (const image of [...this._images]) { this.deleteImage(image.id); } this._images = []; this._renderedImages = []; this._toolHandler.clear(); await Promise.all([ await this.store.setItem(this.STORAGE_IMAGE_KEY, this._images), await this._globalSettingsHandler.clear() ]); this._selected = []; localStorage.setItem(this.STORAGE_NAME_KEY, ""); this.emitter.emit("select", [], "*", "*"); } async exportProject() { const zip = new JSZip(); const images = zip.folder("images")!; for (const image of this._renderedImages) { images.file(image.id, image.buffer); } const data: ProjectConfig = { version: this.VERSION, name: this._projectName, settings: this._globalSettingsHandler.settings, tool: this._toolHandler.tool, images: this._images }; zip.file("config.json", new Blob([JSON.stringify(data)], { type: "application/json" })); const blob = await zip.generateAsync({ type: "blob" }); downloadBlob(blob, `${this.projectName}.ppd`); } importProject() { const input = document.createElement("input"); input.type = "file"; input.accept = ".ppd,application/octet-stream"; input.addEventListener("change", async () => { const file = [...(input.files || [])][0]; if (file) { const zip = await JSZip.loadAsync(file); const config = JSON.parse(await zip.files["config.json"].async("string")) as ProjectConfig; const map = new Map(); const entries = Object.keys(zip.files); for (let i = 0; i < entries.length; i++) { const name = entries[i]; if (!name.startsWith("images/")) continue; const entry = zip.files[name]; if (entry.dir) continue; const buffer = await entry.async("string"); map.set(last(name.split("/"))!, buffer); } const images: CanvasImage[] = []; for (const img of config.images) { if (map.has(img.id)) { images.push({ id: img.id, buffer: map.get(img.id)!, locked: img.locked, name: img.name, scale: img.scale, svg: img.svg, visible: img.visible, x: img.x, y: img.y }); } } if (await this.popup.confirm("Import", "Are you sure you want to import. Any unsaved changes will be discarded")) { await this.clear(); const renderedImages = await Promise.all(images.map((e) => this.renderImage(e))); this._images = images; this._renderedImages = renderedImages; for (const add of images) { this.emitter.emit("add", add.id); //await new Promise(e => requestAnimationFrame(() => e())); } this.emitter.emit("array-length", images.length); this._globalSettingsHandler.setSettings(config.settings); this._toolHandler.setTool(config.tool); this.setName(config.name); this.save(); } } else { this.popup.alert("Error", "File not selected"); } }); input.click(); } get projectName() { return this._projectName; } private async renderImage(image: CanvasImage): Promise { const imageBuffer = await urlToImage(image.buffer); const canvas = imageToCanvas(imageBuffer); const blob = await canvasToBlob(canvas); const url = URL.createObjectURL(blob); return { id: image.id, name: image.name, buffer: image.buffer, x: image.x, y: image.y, scale: image.scale, locked: image.locked, visible: image.visible, blob, url, canvas, svg: image.svg, image: imageBuffer, }; } move(id: string, x: number, y: number) { const { image, renderedImage } = this.getImagesEx(id); if (image.locked) return; renderedImage.x = image.x = Math.round(image.x + x); renderedImage.y = image.y = Math.round(image.y + y); this.emitter.emit("update", image.id, ["x", "y"]); this.save(); } async importFile(file: File) { const buffer = await loadFileAsDataUrl(file); const imageElement = await urlToImage(buffer); const { svg, options } = imageToOutlineSVG(imageElement, { scale: 1, strokewidth: this.STROKE_WIDTH }); const f = file.name.split("."); f.pop(); const fileName = f.join("."); const image: CanvasImage = { id: uuidv4(), buffer, x: 0, y: 0, locked: false, visible: true, name: fileName, svg: { dirty: false, data: svg, scale: 1, options }, scale: 1, }; const rendered = await this.renderImage(image); this._images.push(image); this._renderedImages.push(rendered); this.emitter.emit("array-length", this._images.length); this.emitter.emit("add", image.id); this.save(); } selectNext(x: number, y: number, reverse: boolean, add: boolean) { const potentials = this._renderedImages.filter((e) => { return e.visible && !e.locked && x >= e.x * this.globalSettingsHandler.settings.scale && x <= e.x * this.globalSettingsHandler.settings.scale + e.image.width * e.scale * this.globalSettingsHandler.settings.scale && y >= e.y * this.globalSettingsHandler.settings.scale && y <= e.y * this.globalSettingsHandler.settings.scale + e.image.height * e.scale * this.globalSettingsHandler.settings.scale; }); if (potentials.length === 0) { const copy = [...this._selected]; this._selected.length = 0; if (copy.length) { this.emitter.emit("select", this._selected, "*", "*"); } return; } let lastSelectedIndex = -1; for (let i = 0; i < potentials.length; i++) { if (this._selected.includes(potentials[i].id)) { lastSelectedIndex = i; } } if (add) { potentials.forEach((p) => { if (!this._selected.includes(p.id)) { this._selected.push(p.id); } }); } else { let nextIndex: number; if (lastSelectedIndex === -1) { nextIndex = reverse ? potentials.length - 1 : 0; } else { if (reverse) { nextIndex = (lastSelectedIndex - 1 + potentials.length) % potentials.length; } else { nextIndex = (lastSelectedIndex + 1) % potentials.length; } } this._selected = [potentials[nextIndex].id]; } this.emitter.emit("select", this._selected, "*", "*"); } get imagesData() { return [...this._images]; } get imagesRenders() { return [...this._renderedImages]; } private getImageById(id: string) { return this._renderedImages.find((e) => e.id === id) || null; } private getImageByIdEx(id: string) { const image = this.getImageById(id); if (image) { return image; } else { throw new Error("Image does not exist!"); } } private getImages(id: string) { const image = this._images.find((e) => e.id == id); const renderedImage = this._renderedImages.find((e) => e.id == id); if (image && renderedImage) { return { image, renderedImage }; } else { return null; } } private getImagesEx(id: string) { const obj = this.getImages(id); if (obj) { return obj; } else { throw new Error("Image does not exist!"); } } enqueueForRedrawSvg(id: string) { this.queue = this.queue.filter(e => e.id !== id); this.queue.push({ id, action: async () => { this.redrawSvg(id); } }); } redrawSvg(id: string) { const images = this.getImages(id); if (!images) return; const rendered = images.renderedImage; const base = images.image; const scale = rendered.scale; const oldOptions: TracerOptions = { ...rendered.svg.options, scale, strokewidth: this.STROKE_WIDTH }; const prevOptions = cloneDeep(oldOptions); const { svg, options } = imageToOutlineSVG(rendered.image, oldOptions); const dirty = !isEqual(prevOptions, oldOptions); rendered.svg.dirty = dirty; base.svg.dirty = dirty; base.svg.scale = scale; rendered.svg.scale = scale; if (!dirty) { rendered.svg.options = options; base.svg.options = options; } rendered.svg.data = svg; base.svg.data = svg; this.emitter.emit( "update", id, ["svg", "svgCanvas"], ); this.save(); } isSelected(id: string) { return this._selected.indexOf(id) !== -1; } setSelect(id: string, value: boolean) { if (this.isSelected(id)) { if (!value) { removeItem(this._selected, id); this.emitter.emit("select", this._selected, undefined, id); } } else { if (value) { if (this.getImageByIdEx(id).visible) { pushUnique(this._selected, id); this.emitter.emit("select", this._selected, id, undefined); } } } } get selected() { return this._selected; } deleteImage(id: string) { const images = this.getImages(id); if (images) { removeItem(this._images, images.image); removeItem(this._renderedImages, images.renderedImage); URL.revokeObjectURL(images.renderedImage.url); this.emitter.emit("remove", id); this.emitter.emit("array-length", this._images.length); this.save(); } } private save = throttle(async () => { await this.store.setItem(this.STORAGE_IMAGE_KEY, this._images); }, 1000); createImagesWithEmit() { return this._images.map((e) => this.createImageWithEmit(e.id)); } createImageWithEmit(id: string) { if (this.getImageById(id)) { return new ImageEditor( id, () => this.getImageByIdEx(id), async (data) => { const { image, renderedImage } = this.getImagesEx(id); if (image && renderedImage) { let emitUpdate = false; const entries = Object.entries(data); for (const [key, value] of entries) { if (image.locked && key !== "locked") { continue; } let v = value; if (key === "x" || key === "y") { v = Math.round(v as number); } (image as any)[key] = value as any; const diff = (renderedImage as any)[key] !== v; (renderedImage as any)[key] = v as any; if ((key === "svg") && diff) { this.enqueueForRedrawSvg(id); } if (diff) { emitUpdate = true; } } if (emitUpdate) { this.emitter.emit( "update", id, entries.map((e) => e[0]), ); } this.save(); } else { throw new Error("Image does not exist!"); } }, () => this.isSelected(id), () => { this.setSelect(id, !this.isSelected(id)); }, ); } else { throw new Error(`Image ${id} does not exist!`); } } get ready() { return this._ready; } get toolHandler() { return this._toolHandler; } get globalSettingsHandler() { return this._globalSettingsHandler; } }