Files
Vectrace/src/handler/vectrace-handler.ts
T
2026-06-29 09:41:02 +02:00

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;
}
}