510 lines
17 KiB
TypeScript
510 lines
17 KiB
TypeScript
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> | 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<CanvasImage[]>(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<string, string>();
|
|
|
|
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<void>(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<CanvasImageEx> {
|
|
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;
|
|
}
|
|
}
|