Inital commit
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
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 { imageToLaserSVG } 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 options: TracerOptions = {};
|
||||
const svg = imageToLaserSVG(imageElement, {
|
||||
...options,
|
||||
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 options: TracerOptions = {
|
||||
...rendered.svg.options,
|
||||
scale,
|
||||
strokewidth: this.STROKE_WIDTH
|
||||
};
|
||||
|
||||
const prevOptions = cloneDeep(options);
|
||||
|
||||
const svg = imageToLaserSVG(rendered.image, options);
|
||||
|
||||
const dirty = !isEqual(prevOptions, options);
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user