@etsoo/editor
Version:
ETSOO Free WYSIWYG HTML Editor
1,648 lines (1,452 loc) • 61.2 kB
text/typescript
import {
DomUtils,
ExtendUtils,
Keyboard,
NumberUtils,
Utils
} from "@etsoo/shared";
import {
EOEditorHistory,
EOEditorHistoryState
} from "../classes/EOEditorHistory";
import { ImageUtils } from "../ImageUtils";
import {
EOImageEditorGetLabels,
EOImageEditorLabelLanguage
} from "./EOImageEditorLabels";
import { EOPalette } from "./EOPalette";
import { EOPopup } from "./EOPopup";
import type * as FabricType from "fabric";
let fabric: typeof FabricType;
/**
* EOEditor Image Editor commands
*/
export interface EOImageEditorCommand {
/**
* Name
*/
name: string;
/**
* Icon
*/
icon: string;
/**
* Label
*/
label: string;
}
/**
* EOEditor Image Editor separator
*/
export const EOImageEditorSeparator: EOImageEditorCommand = {
name: "s",
icon: "",
label: ""
};
const embossMatrix = [1, 1, 1, 1, 0.7, -1, -1, -1, -1];
const sharpenMatrix = [0, -1, 0, -1, 5, -1, 0, -1, 0];
const cropPath =
'<path d="M7,17V1H5V5H1V7H5V17A2,2 0 0,0 7,19H17V23H19V19H23V17M17,15H19V7C19,5.89 18.1,5 17,5H9V7H17V15Z" />';
/**
* EOEditor Image Editor
* http://fabricjs.com/docs/fabric.Canvas.html
*/
export class EOImageEditor extends HTMLElement {
/**
* Canvas
*/
readonly canvas: HTMLCanvasElement;
/**
* Fabric canvas
*/
private fc?: FabricType.Canvas;
/**
* Main image
*/
private image?: FabricType.FabricImage;
/**
* Current active object
*/
private activeObject?: FabricType.FabricObject | null;
/**
* Complete callback
*/
private callback?: (data: string) => void;
/**
* Popup
*/
readonly popup: EOPopup;
/**
* Fonts
*/
readonly fonts = ["Arial", "Helvetica", "Simsun"];
/**
* Modal div
*/
readonly modalDiv: HTMLDivElement;
// Is small screen
private readonly xs: boolean;
// Color palette
private palette: EOPalette;
// Container
private readonly container: HTMLDivElement;
// Toolbar
private readonly toolbar: HTMLDivElement;
// Icons
private readonly icons: HTMLDivElement;
// PNG format
private pngFormat?: HTMLInputElement;
// Mover div
private readonly mover: HTMLDivElement;
// Settings div
private readonly settings: HTMLDivElement;
// History
private history?: EOEditorHistory;
// Redo/undo button
private redo?: HTMLButtonElement;
private undo?: HTMLButtonElement;
private fcSize?: [number, number];
private containerRect?: DOMRect;
private rect?: DOMRect;
private originalWidth?: number;
private originalHeight?: number;
private _labels?: EOImageEditorLabelLanguage;
/**
* Labels
*/
get labels() {
return this._labels;
}
/**
* Panel color
*/
get panelColor() {
return this.getAttribute("panelColor");
}
set panelColor(value: string | null) {
if (value) this.setAttribute("panelColor", value);
else this.removeAttribute("panelColor");
}
/**
* Language
*/
get language() {
return this.getAttribute("language");
}
set language(value: string | null) {
if (value) this.setAttribute("language", value);
else this.removeAttribute("language");
}
constructor() {
super();
this.hidden = true;
const xs = window.innerWidth < 480;
this.xs = xs;
const template = document.createElement("template");
template.innerHTML = `
<style id="style"></style>
<div class="modal">
<div class="close-button">${this.createSVG(
'<path d="M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2C6.47,2 2,6.47 2,12C2,17.53 6.47,22 12,22C17.53,22 22,17.53 22,12C22,6.47 17.53,2 12,2M14.59,8L12,10.59L9.41,8L8,9.41L10.59,12L8,14.59L9.41,16L12,13.41L14.59,16L16,14.59L13.41,12L16,9.41L14.59,8Z" />'
)}</div>
<div class="container"><canvas></canvas></div>
<div class="settings"></div>
<div class="toolbar">
<div class="icons"></div>
<div class="panels">
<div class="size-indicator">0 x 0</div>
<div class="move-panel"><div class="mover"></div></div>
</div>
</div>
<eo-popup></eo-popup>
<eo-palette></eo-palette>
</div>
<div class="wrapper"></div>
`;
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.appendChild(template.content);
this.loadStyles(shadowRoot.getElementById("style")!);
this.popup = shadowRoot.querySelector("eo-popup")!;
this.palette = shadowRoot.querySelector("eo-palette")!;
this.modalDiv = shadowRoot.querySelector("div.modal")!;
const clickHandler = (target: EventTarget | null) => {
if (this.activeObject == null) this.showSettings("");
else if (target instanceof Node && target.nodeName === "DIV") {
this.fc?.discardActiveObject();
this.fc?.renderAll();
}
};
this.container = this.modalDiv.querySelector<HTMLDivElement>(".container")!;
this.container.addEventListener("click", (e) => {
clickHandler(e.target);
});
this.container.addEventListener(
"touchstart",
(e) => {
clickHandler(e.target);
},
{ passive: true }
);
this.canvas = shadowRoot.querySelector("canvas")!;
this.toolbar = this.modalDiv.querySelector<HTMLDivElement>("div.toolbar")!;
this.icons = this.toolbar.querySelector<HTMLDivElement>(".icons")!;
this.mover = this.modalDiv.querySelector<HTMLDivElement>("div.mover")!;
this.settings =
this.modalDiv.querySelector<HTMLDivElement>("div.settings")!;
this.container.addEventListener("scroll", () => {
if (
this.fcSize == null ||
this.containerRect == null ||
this.rect == null
)
return;
const [w, h] = this.fcSize;
const top = this.container.scrollTop;
if (top === 0) {
this.mover.style.top = "0px";
} else {
const t = (this.rect.height * top) / h;
this.mover.style.top = `${t}px`;
}
const left = this.container.scrollLeft;
if (left === 0) {
this.mover.style.left = "0px";
} else {
const l = (this.rect.width * left) / w;
this.mover.style.left = `${l}px`;
}
});
const adjustMover = (clientX: number, clientY: number) => {
if (this.rect == null || this.fcSize == null) return;
// Event.offsetX will be the mover, not the mover container
const offsetX = clientX - this.rect.left;
const offsetY = clientY - this.rect.top;
const w = parseFloat(this.mover.style.width);
const h = parseFloat(this.mover.style.height);
const [fw, fh] = this.fcSize;
let nl: number;
if (offsetX + w / 2 >= this.rect.width) {
nl = this.rect.width - 2 - w;
} else {
nl = offsetX - w / 2;
if (nl < 0) nl = 0;
}
this.mover.style.left = `${nl}px`;
this.container.scrollLeft = (fw * nl) / this.rect.width;
let nt: number;
if (offsetY + h / 2 >= this.rect.height) {
nt = this.rect.height - 2 - h;
} else {
nt = offsetY - h / 2;
if (nt < 0) nt = 0;
}
this.mover.style.top = `${nt}px`;
this.container.scrollTop = (fh * nt) / this.rect.height;
};
const p = this.mover.parentElement!;
p.addEventListener("mousedown", (event) => {
this.preventEvent(event);
adjustMover(event.clientX, event.clientY);
});
p.addEventListener("mousemove", (event) => {
if (event.buttons !== 1) return;
this.preventEvent(event);
adjustMover(event.clientX, event.clientY);
});
const touchHandler = (event: TouchEvent) => {
this.preventEvent(event);
const x = event.touches.item(0)?.clientX;
const y = event.touches.item(0)?.clientY;
if (x == null || y == null) return;
adjustMover(x, y);
};
p.addEventListener("touchstart", touchHandler, { passive: true });
p.addEventListener("touchmove", touchHandler, { passive: true });
// document.fonts maybe not available in some browsers, like jsdom
document.fonts?.ready.then((value) => {
value.forEach((v) => {
if (!this.fonts.includes(v.family)) this.fonts.push(v.family);
});
});
}
private async loadStyles(style: HTMLElement) {
const styles = await import("./EOImageEditor.css");
style.innerHTML = styles.default;
this.style.setProperty("--height", `${this.xs ? 160 : 120}px`);
}
private preventEvent(event: Event) {
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
}
async connectedCallback() {
// Load the library dynamically
fabric = await import("fabric");
// https://github.com/fabricjs/fabric.js/issues/3319
// Change the padding logic to include background-color
fabric.Textbox.prototype.set({
_getNonTransformedDimensions() {
return new fabric.Point(this.width, this.height).scalarAdd(
this.padding
);
},
_calculateCurrentDimensions() {
// Controls dimensions
return this._getNonTransformedDimensions().transform(
this.getViewportTransform(),
true
);
}
});
this.hidden = true;
this.createCommands();
if (this.panelColor) {
this.style.setProperty("--color-panel", this.panelColor);
}
window.addEventListener("resize", this.onResize.bind(this));
window.addEventListener("keydown", this.onKeypress.bind(this));
}
disconnectedCallback() {
this.hidden = true;
window.removeEventListener("resize", this.onResize.bind(this));
window.removeEventListener("keydown", this.onKeypress.bind(this));
}
private onKeypress(event: KeyboardEvent) {
if (this.activeObject) {
const keys = Keyboard.Keys;
if (event.key === keys.Delete) {
this.preventEvent(event);
this.doAction("delete");
return;
} else {
const change: [number, number] = [0, 0];
if (event.key === keys.ArrowLeft) {
change[0] = -1;
} else if (event.key === keys.ArrowRight) {
change[0] = 1;
} else if (event.key === keys.ArrowUp) {
change[1] = -1;
} else if (event.key === keys.ArrowDown) {
change[1] = 1;
} else {
return;
}
this.preventEvent(event);
const left = this.activeObject.left ?? 0;
const top = this.activeObject.top ?? 0;
this.activeObject.left = left + change[0];
this.activeObject.top = top + change[1];
this.fc?.renderAll();
}
}
}
private onResize() {
this.updateSize();
}
private createCommands() {
const language = this.language ?? window.navigator.language;
EOImageEditorGetLabels(language).then((l) => {
this._labels = l;
this.palette.applyLabel = l.ok;
const commands: EOImageEditorCommand[] = [
{
name: "undo",
icon: '<path d="M12.5,8C9.85,8 7.45,9 5.6,10.6L2,7V16H11L7.38,12.38C8.77,11.22 10.54,10.5 12.5,10.5C16.04,10.5 19.05,12.81 20.1,16L22.47,15.22C21.08,11.03 17.15,8 12.5,8Z" />',
label: l.undo
},
{
name: "redo",
icon: '<path d="M18.4,10.6C16.55,9 14.15,8 11.5,8C6.85,8 2.92,11.03 1.54,15.22L3.9,16C4.95,12.81 7.95,10.5 11.5,10.5C13.45,10.5 15.23,11.22 16.62,12.38L13,16H22V7L18.4,10.6Z" />',
label: l.redo
},
EOImageEditorSeparator,
{
name: "zoomIn",
icon: '<path d="M15.5,14L20.5,19L19,20.5L14,15.5V14.71L13.73,14.43C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.43,13.73L14.71,14H15.5M9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14M12,10H10V12H9V10H7V9H9V7H10V9H12V10Z" />',
label: l.zoomIn
},
{
name: "zoomOut",
icon: '<path d="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5A6.5,6.5 0 0,0 9.5,3A6.5,6.5 0 0,0 3,9.5A6.5,6.5 0 0,0 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.5L20.5,19L15.5,14M9.5,14C7,14 5,12 5,9.5C5,7 7,5 9.5,5C12,5 14,7 14,9.5C14,12 12,14 9.5,14M7,9H12V10H7V9Z" />',
label: l.zoomOut
},
EOImageEditorSeparator,
{
name: "rotateLeft",
icon: '<path d="M13,4.07V1L8.45,5.55L13,10V6.09C15.84,6.57 18,9.03 18,12C18,14.97 15.84,17.43 13,17.91V19.93C16.95,19.44 20,16.08 20,12C20,7.92 16.95,4.56 13,4.07M7.1,18.32C8.26,19.22 9.61,19.76 11,19.93V17.9C10.13,17.75 9.29,17.41 8.54,16.87L7.1,18.32M6.09,13H4.07C4.24,14.39 4.79,15.73 5.69,16.89L7.1,15.47C6.58,14.72 6.23,13.88 6.09,13M7.11,8.53L5.7,7.11C4.8,8.27 4.24,9.61 4.07,11H6.09C6.23,10.13 6.58,9.28 7.11,8.53Z" />',
label: l.rotateLeft
},
{
name: "rotateRight",
icon: '<path d="M16.89,15.5L18.31,16.89C19.21,15.73 19.76,14.39 19.93,13H17.91C17.77,13.87 17.43,14.72 16.89,15.5M13,17.9V19.92C14.39,19.75 15.74,19.21 16.9,18.31L15.46,16.87C14.71,17.41 13.87,17.76 13,17.9M19.93,11C19.76,9.61 19.21,8.27 18.31,7.11L16.89,8.53C17.43,9.28 17.77,10.13 17.91,11M15.55,5.55L11,1V4.07C7.06,4.56 4,7.92 4,12C4,16.08 7.05,19.44 11,19.93V17.91C8.16,17.43 6,14.97 6,12C6,9.03 8.16,6.57 11,6.09V10L15.55,5.55Z" />',
label: l.rotateRight
},
EOImageEditorSeparator,
{
name: "text",
icon: '<path d="M18.5,4L19.66,8.35L18.7,8.61C18.25,7.74 17.79,6.87 17.26,6.43C16.73,6 16.11,6 15.5,6H13V16.5C13,17 13,17.5 13.33,17.75C13.67,18 14.33,18 15,18V19H9V18C9.67,18 10.33,18 10.67,17.75C11,17.5 11,17 11,16.5V6H8.5C7.89,6 7.27,6 6.74,6.43C6.21,6.87 5.75,7.74 5.3,8.61L4.34,8.35L5.5,4H18.5Z" />',
label: l.text
},
{
name: "image",
icon: '<path d="M19,19H5V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z" />',
label: l.image
},
{
name: "crop",
icon: cropPath,
label: l.crop
},
{
name: "filter",
icon: '<path d="M12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16M18.7,12.4C18.42,12.24 18.13,12.11 17.84,12C18.13,11.89 18.42,11.76 18.7,11.6C20.62,10.5 21.69,8.5 21.7,6.41C19.91,5.38 17.63,5.3 15.7,6.41C15.42,6.57 15.16,6.76 14.92,6.95C14.97,6.64 15,6.32 15,6C15,3.78 13.79,1.85 12,0.81C10.21,1.85 9,3.78 9,6C9,6.32 9.03,6.64 9.08,6.95C8.84,6.75 8.58,6.56 8.3,6.4C6.38,5.29 4.1,5.37 2.3,6.4C2.3,8.47 3.37,10.5 5.3,11.59C5.58,11.75 5.87,11.88 6.16,12C5.87,12.1 5.58,12.23 5.3,12.39C3.38,13.5 2.31,15.5 2.3,17.58C4.09,18.61 6.37,18.69 8.3,17.58C8.58,17.42 8.84,17.23 9.08,17.04C9.03,17.36 9,17.68 9,18C9,20.22 10.21,22.15 12,23.19C13.79,22.15 15,20.22 15,18C15,17.68 14.97,17.36 14.92,17.05C15.16,17.25 15.42,17.43 15.7,17.59C17.62,18.7 19.9,18.62 21.7,17.59C21.69,15.5 20.62,13.5 18.7,12.4Z" />',
label: l.filter
},
EOImageEditorSeparator,
{
name: "hcenter",
icon: '<path d="M19,16V13H23V11H19V8L15,12L19,16M5,8V11H1V13H5V16L9,12L5,8M11,20H13V4H11V20Z" />',
label: l.hcenter
},
{
name: "vcenter",
icon: '<path d="M8,19H11V23H13V19H16L12,15L8,19M16,5H13V1H11V5H8L12,9L16,5M4,11V13H20V11H4Z" />',
label: l.vcenter
},
{
name: "bringToFront",
icon: '<path d="M2,2H16V16H2V2M22,8V22H8V18H10V20H20V10H18V8H22Z" />',
label: l.bringToFront
},
{
name: "bringToBack",
icon: '<path d="M2,2H16V16H2V2M22,8V22H8V18H18V8H22M4,4V14H14V4H4Z" />',
label: l.bringToBack
},
{
name: "delete",
icon: '<path d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M7,6H17V19H7V6M9,8V17H11V8H9M13,8V17H15V8H13Z" />',
label: l.delete
},
EOImageEditorSeparator,
{
name: "preview",
icon: '<path d="M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17M12,4.5C7,4.5 2.73,7.61 1,12C2.73,16.39 7,19.5 12,19.5C17,19.5 21.27,16.39 23,12C21.27,7.61 17,4.5 12,4.5Z" />',
label: l.preview
},
{
name: "complete",
icon: '<path d="M0.41,13.41L6,19L7.41,17.58L1.83,12M22.24,5.58L11.66,16.17L7.5,12L6.07,13.41L11.66,19L23.66,7M18,7L16.59,5.58L10.24,11.93L11.66,13.34L18,7Z" />',
label: l.complete
}
];
// For small screens
if (this.xs) {
for (let i = commands.length - 1; i >= 0; i--) {
if (commands[i].name === "s") {
commands.splice(i, 1);
}
}
}
const html =
commands
.map((c) =>
c.name === "s"
? '<div class="separator"></div>'
: `<button name="${c.name}" title="${c.label}">${
c.name === "image"
? '<input id="imageFile" type="file" multiple accept="image/*" style="position: absolute; left: -2px; top: -2px; width: 40px; height: 32px; opacity: 0">'
: ""
}${this.createSVG(c.icon)}</button>`
)
.join("") +
`<label><input type="checkbox" name="pngFormat">PNG</label>`;
this.icons.innerHTML = html;
this.pngFormat = this.toolbar.querySelector<HTMLInputElement>(
'input[name="pngFormat"]'
)!;
this.icons.querySelectorAll("button").forEach((b) => {
b.addEventListener("click", () => {
this.doAction(b.name, b);
});
if (b.name === "redo") this.redo = b;
else if (b.name === "undo") this.undo = b;
});
const loadFile = (file: File) => {
if (!file.type.startsWith("image/")) return;
DomUtils.fileToDataURL(file).then((data) => {
fabric.FabricImage.fromURL(data).then((image) => {
const imageState: EOEditorHistoryState = {
title: l.image,
action: () => {
//image.lockUniScaling = true;
image.setControlsVisibility({
mt: false, // middle top disable
mb: false, // midle bottom
ml: false, // middle left
mr: false // middle right
});
this.fc?.add(image);
},
undo: () => this.fc?.remove(image)
};
imageState.action();
this.fc?.setActiveObject(image);
this.history?.pushState(imageState);
});
});
};
const fileInput =
this.icons.querySelector<HTMLInputElement>('input[type="file"]');
if (fileInput) {
fileInput.addEventListener("click", () => {
fileInput.value = "";
});
fileInput.addEventListener("change", () => {
const files = fileInput.files;
if (files == null || files.length === 0) return;
for (let file of files) {
loadFile(file);
}
});
}
const closeDiv =
this.modalDiv.querySelector<HTMLDivElement>(".close-button");
if (closeDiv) {
closeDiv.title = l.close;
closeDiv.addEventListener("click", () => this.reset());
}
});
}
private createSVG(path: string, size: number = 24) {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24">${path}</svg>`;
}
private clear() {
if (this.fc == null) return;
this.fc.remove(
...this.fc
.getObjects("rect")
.filter((r) => Reflect.get(r, "name") === "crop")
);
this.fc.remove(
...this.fc
.getObjects("i-text")
.filter((t) => t instanceof fabric.IText && t.text?.trim() === "")
);
this.fc.discardActiveObject();
}
private setCursor(cursor: string = "default") {
const f = this.fc;
if (f == null) return;
if (f.defaultCursor === cursor) return;
f.defaultCursor = cursor;
if (this.image) this.image.hoverCursor = cursor;
}
private _textInput: boolean = false;
private get textInput() {
return this._textInput;
}
private set textInput(value: boolean) {
this._textInput = value;
if (value) this.setCursor("text");
else this.setCursor();
}
private doAction(name: string, b?: HTMLButtonElement) {
const fc = this.fc;
if (fc == null) return;
const o = this.activeObject ?? this.image;
const l = this.labels!;
if (["image", "crop", "filter"].includes(name)) {
this.setCursor();
this.textInput = false;
}
switch (name) {
case "bringToBack":
if (o == null) return;
const bringToBackState: EOEditorHistoryState = {
title: l.bringToBack,
action: () => fc.sendObjectBackwards(o, true),
undo: () => fc.bringObjectForward(o, true)
};
bringToBackState.action();
this.history?.pushState(bringToBackState);
break;
case "bringToFront":
if (o == null) return;
const bringToFrontState: EOEditorHistoryState = {
title: l.bringToBack,
action: () => fc.bringObjectForward(o, true),
undo: () => fc.sendObjectBackwards(o, true)
};
bringToFrontState.action();
this.history?.pushState(bringToFrontState);
break;
case "complete":
this.clear();
const data = fc.toDataURL({
format: this.pngFormat?.checked ? "png" : "jpeg",
quality: 1,
multiplier: 1
});
if (data) {
if (this.callback) this.callback(data);
}
this.reset();
break;
case "crop":
if (o?.type === "rect" && Reflect.get(o, "name") === "crop") {
// Size
const { width, height, left = 0, top = 0 } = o;
if (width == null || height == null) return;
// Cache sizes
const sizes = [
fc.getWidth(),
fc.getHeight(),
this.originalWidth,
this.originalHeight
];
const cropState: EOEditorHistoryState = {
title: l.crop,
action: () => {
const zoom = fc.getZoom();
const scaleX = o.scaleX ?? 1;
const scaleY = o.scaleY ?? 1;
// Take the rect border into account
// Otherwise the saved image will have the mask borders
const nw = Math.floor(width * zoom * scaleX) - 1;
const nh = Math.floor(height * zoom * scaleY) - 1;
const nl = Math.ceil(left * zoom) + 1;
const nt = Math.ceil(top * zoom) + 1;
// Apply
// https://stackoverflow.com/questions/44437734/image-clipping-with-visible-overflow-in-fabricjs/44454016#44454016
fc.clipPath = o;
// Size
fc.setWidth(nw);
fc.setHeight(nh);
fc.absolutePan(new fabric.Point(nl, nt));
this.originalWidth = width * scaleX;
this.originalHeight = height * scaleY;
this.updateSize();
fc.remove(o);
},
undo: () => {
fc.clipPath = undefined;
// Size
fc.width = sizes[0]!;
fc.height = sizes[1]!;
fc.absolutePan(new fabric.Point(0, 0));
this.originalWidth = sizes[2];
this.originalHeight = sizes[3];
this.updateSize();
}
};
cropState.action();
this.history?.pushState(cropState);
} else {
this.cropSettings();
}
break;
case "delete":
const objs = fc.getActiveObjects();
if (objs) {
const deleteState: EOEditorHistoryState = {
title: `${l.delete}`,
action: () => fc.remove(...objs),
undo: () => fc.add(...objs)
};
deleteState.action();
this.history?.pushState(deleteState);
}
break;
case "filter":
if (o instanceof fabric.FabricImage) this.filterSettings(o);
break;
case "hcenter":
if (o) {
const hZoom = fc.getZoom() ?? 1;
if (hZoom === 1) {
fc.centerObjectH(o);
} else {
const hCenter =
(fc.width / hZoom - (o.width ?? 0) * (o.scaleX ?? 1)) / 2;
o.left = hCenter;
}
}
break;
case "preview":
this.clear();
const pData = fc.toDataURL({
format: this.pngFormat?.checked ? "png" : "jpeg",
quality: 1,
multiplier: 1
});
if (pData) {
const win = window.open();
if (win) {
const img = win.document.createElement("img");
img.src = pData;
win.document.body.appendChild(img);
win.document.title = this.labels!.preview;
}
}
break;
case "redo":
this.history?.forward();
break;
case "rotateLeft":
if (o) {
const rotateLeftState: EOEditorHistoryState = {
title: l.rotateLeft,
action: () => this.doRotate(o, -90),
undo: () => this.doRotate(o, 90)
};
rotateLeftState.action();
this.history?.pushState(rotateLeftState);
}
break;
case "rotateRight":
if (o) {
const rotateRightState: EOEditorHistoryState = {
title: l.rotateRight,
action: () => this.doRotate(o, 90),
undo: () => this.doRotate(o, -90)
};
rotateRightState.action();
this.history?.pushState(rotateRightState);
}
break;
case "text":
this.textInput = true;
this.textSettings();
break;
case "undo":
this.history?.back();
break;
case "vcenter":
if (o) {
const vZoom = fc.getZoom();
if (vZoom === 1) {
fc.centerObjectV(o);
} else {
const vCenter =
(fc.height / vZoom - (o.height ?? 0) * (o.scaleY ?? 1)) / 2;
o.top = vCenter;
}
}
break;
case "zoomIn":
const zi = (fc.getZoom() + 0.1).toExact();
if (zi > 10) return;
const zoomInState: EOEditorHistoryState = {
title: `${l.zoomIn}: ${zi}`,
action: () => {
fc.setZoom(zi);
this.updateZoomSize();
},
undo: () => {
fc.setZoom(zi - 0.1);
this.updateZoomSize();
}
};
zoomInState.action();
this.history?.pushState(zoomInState);
break;
case "zoomOut":
const zo = (fc.getZoom() - 0.1).toExact();
if (zo <= 0.1) return;
const zoomOutState: EOEditorHistoryState = {
title: `${l.zoomOut}: ${zo}`,
action: () => {
fc.setZoom(zo);
this.updateZoomSize();
},
undo: () => {
fc.setZoom(zo + 0.1);
this.updateZoomSize();
}
};
zoomOutState.action();
this.history?.pushState(zoomOutState);
break;
}
fc.renderAll();
}
private updateZoomSize() {
const fc = this.fc;
if (fc == null || this.originalWidth == null || this.originalHeight == null)
return;
const zoom = fc.getZoom();
fc.setWidth(this.originalWidth * zoom);
fc.setHeight(this.originalHeight * zoom);
this.updateSize();
}
private findFilter(item: any, name: string) {
const type: string = item.type;
if (type === "Convolute") {
const matrix = item.matrix;
if (name === "Emboss" && matrix === embossMatrix) return true;
if (name === "Sharpen" && matrix === sharpenMatrix) return true;
return false;
}
return type === name;
}
private cropSettings() {
const fname = "crop";
if (this.isSettingShowing(fname)) return;
const layout = ["*", "1:1", "2:1", "3:2", "4:3", "5:4", "7:5", "16:9"]
.map(
(r) =>
`<button class="vflex">${this.createSVG(
cropPath,
20
)}<span>${r}</span></button>`
)
.join("");
this.showSettings(layout, fname, "flex");
this.settings
.querySelectorAll<HTMLInputElement>("button")
.forEach((button) => {
button.addEventListener("click", () => {
if (this.fc == null) return;
// Size
const zoom = this.fc.getZoom();
if (this.fc.width == null || this.fc.height == null) return;
const width = (this.fc.width / zoom).toExact(0);
const height = (this.fc.height / zoom).toExact(0);
// Ratio
let rText = button.querySelector("span")?.innerText;
if (rText == null) return;
let rw: number, rh: number, rl: number, rt: number;
const custom = rText === "*";
const rItems = custom
? [1, 1]
: rText.split(":").map((i) => parseFloat(i));
const w = rItems[0];
const h = rItems[1];
if (w / h > width / height) {
// More height
rw = width;
rl = 0;
rh = ((rw * h) / w).toExact(0);
rt = (height - rh) / 2;
} else {
// More width
rh = height;
rt = 0;
rw = ((rh * w) / h).toExact(0);
rl = (width - rw) / 2;
}
if (this.fc.clipPath) {
rl += this.fc.clipPath.left ?? 0;
rt += this.fc.clipPath.top ?? 0;
}
// http://jsfiddle.net/a7mad24/aPLq5/
const rect = new fabric.Rect({
width: rw,
height: rh,
left: rl,
top: rt,
fill: "#fff",
opacity: 0.2,
//fill: 'transparent',
//stroke: '#ff0000',
//strokeDashArray: [5, 5],
name: "crop"
});
if (custom) {
//rect.lockUniScaling = false;
} else {
//rect.lockUniScaling = true;
rect.setControlsVisibility({
mt: false, // middle top disable
mb: false, // midle bottom
ml: false, // middle left
mr: false // middle right
});
}
this.fc.add(rect);
this.fc.bringObjectToFront(rect);
this.fc.setActiveObject(rect);
// Scroll to here
this.container.scrollTop = rt;
this.container.scrollLeft = rl;
this.showSettings("");
});
});
}
private filterSettings(o: FabricType.FabricImage) {
const fname = "filter";
if (this.isSettingShowing(fname)) return;
const filters = o.filters ?? [];
const fd: {
name: string;
value?: [number, number, number];
property?: string;
}[] = [
{ name: "Grayscale" },
{ name: "Invert" },
{ name: "Brownie" },
{ name: "Vintage" },
{ name: "Kodachrome" },
{ name: "Technicolor" },
{ name: "Polaroid" },
{ name: "Sharpen" },
{ name: "Emboss" },
{
name: "Brightness",
value: [-1, 1, 0.2]
},
{
name: "Saturation",
value: [0, 1, 0.1]
},
{
name: "Contrast",
value: [-1, 1, 0.2]
},
{
name: "Vibrance",
value: [-1, 1, 0.2]
},
{
name: "HueRotation",
value: [-1, 1, 0.2],
property: "rotation"
},
{
name: "Blur",
value: [0, 1, 0.1]
},
{
name: "Noise",
value: [0, 400, 20]
},
{
name: "Pixelate",
value: [1, 20, 1],
property: "blocksize"
}
];
const l = this.labels!;
const layout = fd
.map((f) => {
const filter = filters.find((item: any) =>
this.findFilter(item, f.name)
);
const v = f.value;
const n = Utils.formatInitial(f.name, false);
return `<label${
v == null ? ' class="span2"' : ""
}><input type="checkbox"${filter == null ? "" : " checked"} name="${
f.name
}"/>${Reflect.get(l, n)}</label>${
v == null
? ""
: ` <input type="range" data-property="${f.property ?? ""}" name="${
f.name
}-value" min="${v[0]}" max="${v[1]}" step="${v[2]}"${
filter == null
? " disabled"
: ` value="${Reflect.get(filter, f.property ?? n) ?? ""}"`
}/>`
}`;
})
.join("");
this.showSettings(layout, fname, "form");
const f = fabric.filters;
this.settings
.querySelectorAll<HTMLInputElement>("input")
.forEach((input) => {
input.addEventListener("input", () => {
let name = input.name;
let property: string | undefined;
let value: number | null = null;
let checked: boolean;
if (name.endsWith("-value")) {
name = name.substring(0, name.length - 6);
value = input.valueAsNumber;
property = input.dataset["property"];
checked = true;
} else {
checked = input.checked;
const valueInput = this.settings.querySelector<HTMLInputElement>(
`input[name="${name}-value"]`
);
if (valueInput) {
valueInput.disabled = !checked;
property = valueInput.dataset["property"];
value = valueInput.valueAsNumber;
}
}
const fi = filters.findIndex((item: any) =>
this.findFilter(item, name)
);
let filter = fi === -1 ? undefined : filters[fi];
if (checked) {
if (filter == null) {
if (name === "Emboss") {
filter = new f.Convolute({
matrix: embossMatrix
});
} else if (name === "Sharpen") {
filter = new f.Convolute({
matrix: sharpenMatrix
});
} else {
const fc = Reflect.get(f, name);
filter = new fc();
}
if (o.filters == null) o.filters = [filter!];
else o.filters.push(filter!);
}
if (value != null) {
const p = property ? property : Utils.formatInitial(name, false);
Reflect.set(filter!, p, value);
}
} else {
filters.splice(fi, 1);
}
o.applyFilters();
this.fc?.renderAll();
});
});
}
private imageSettings(o: FabricType.FabricImage) {
const layout = `<label>${this.labels?.opacity} <input type="range" name="opacity" value="${o.opacity}" min="0.1" max="1" step="0.1"/></label>`;
this.showSettings(layout, "image", "flex");
const opacityInput = this.settings.querySelector<HTMLInputElement>(
'input[name="opacity"]'
);
opacityInput?.addEventListener("input", () => {
o.opacity = opacityInput.valueAsNumber;
this.fc?.renderAll();
});
}
private getTextSettings() {
if (this.isSettingShowing("text", false)) {
const shadowColorInput = this.settings.querySelector<HTMLInputElement>(
'input[name="shadowColor"]'
);
let shadow: FabricType.Shadow | undefined;
const color = shadowColorInput?.value;
if (color) {
const offsetX =
this.settings.querySelector<HTMLInputElement>(
'input[name="shadowOffsetX"]'
)?.valueAsNumber ?? 1;
const offsetY =
this.settings.querySelector<HTMLInputElement>(
'input[name="shadowOffsetY"]'
)?.valueAsNumber ?? 1;
const blur =
this.settings.querySelector<HTMLInputElement>(
'input[name="shadowOffsetBlur"]'
)?.valueAsNumber ?? 0;
shadow = new fabric.Shadow({
color,
offsetX,
offsetY,
blur
});
}
return {
fontFamily: this.settings.querySelector<HTMLSelectElement>(
'select[name="fontFamily"]'
)?.value,
fontWeight: this.settings.querySelector<HTMLInputElement>(
'input[name="fontWeight"]'
)?.valueAsNumber,
opacity: this.settings.querySelector<HTMLInputElement>(
'input[name="opacity"]'
)?.valueAsNumber,
padding: this.settings.querySelector<HTMLInputElement>(
'input[name="padding"]'
)?.valueAsNumber,
fill: this.settings
.querySelector<HTMLInputElement>('input[name="fill"]')
?.value.trim(),
backgroundColor: this.settings
.querySelector<HTMLInputElement>('input[name="bgColor"]')
?.value.trim(),
fontStyle: (this.settings.querySelector<HTMLInputElement>(
'input[name="italic"]'
)?.checked
? "italic"
: "normal") as any,
underline: this.settings.querySelector<HTMLInputElement>(
'input[name="underline"]'
)?.checked,
linethrough: this.settings.querySelector<HTMLInputElement>(
'input[name="linethrough"]'
)?.checked,
shadow
};
}
return undefined;
}
private textSettings(o?: FabricType.IText) {
const l = this.labels!;
let shadow = o?.shadow
? typeof o.shadow === "string"
? new fabric.Shadow(o.shadow)
: o.shadow
: undefined;
const shadowLayout = o
? `<label><span>${
l.shadow
}:</span><input type="text" name="shadowColor" title="${
l.color
}" value="${shadow?.color ?? ""}"/>
</label><input type="range" name="shadowOffsetX" title="${
l.offsetX
}" value="${shadow?.offsetX ?? 1}" min="-10" max="10" step="1"/>
<input type="range" name="shadowOffsetY" title="${l.offsetY}" value="${
shadow?.offsetY ?? 1
}" min="-10" max="10" step="1"/>
<input type="range" name="shadowBlur" title="${l.blur}" value="${
shadow?.blur ?? 0
}" min="0" max="15" step="1"/>`
: "";
const layout = `<label><span>${
l.fontFamily
}:</span><select name="fontFamily">${this.fonts
.sort()
.map((f) => `<option>${f}</option>`)
.join("")}</select>
</label><label><span>${
l.fontWeight
}:</span><input type="range" name="fontWeight" value="${
o?.fontWeight ?? 100
}" min="100" max="1000" step="100"/></label>
<label><span>${
l.opacity
}:</span><input type="range" name="opacity" value="${
o?.opacity ?? 1
}" min="0.1" max="1" step="0.1"/></label>
<label><span>${
l.padding
}:</span><input type="number" name="padding" value="${
o?.padding ?? 0
}" min="0" max="100" step="1"/></label>
<label><span>${l.color}:</span><input type="text" name="fill" value="${
o?.fill ?? "#000"
}"/></label>
<label><span>${
l.bgColor
}:</span><input type="text" name="bgColor" value="${
o?.backgroundColor ?? ""
}"/></label>
<label><input type="checkbox" name="italic"${
o?.fontStyle === "italic" ? " checked" : ""
}/>${l.italic}</label>
<label><input type="checkbox" name="underline"${
o?.underline ? " checked" : ""
}/>${l.underline}</label>
<label><input type="checkbox" name="linethrough"${
o?.linethrough ? " checked" : ""
}/>${l.strikethrough}</label>${shadowLayout}`;
this.showSettings(layout, "text", "flex");
const fontFamilySelect = this.settings.querySelector<HTMLSelectElement>(
'select[name="fontFamily"]'
);
const fontWeightInput = this.settings.querySelector<HTMLInputElement>(
'input[name="fontWeight"]'
);
const opacityInput = this.settings.querySelector<HTMLInputElement>(
'input[name="opacity"]'
);
const paddingInput = this.settings.querySelector<HTMLInputElement>(
'input[name="padding"]'
);
const italicInput = this.settings.querySelector<HTMLInputElement>(
'input[name="italic"]'
);
const underlineInput = this.settings.querySelector<HTMLInputElement>(
'input[name="underline"]'
);
const linethroughInput = this.settings.querySelector<HTMLInputElement>(
'input[name="linethrough"]'
);
const shadowColorInput = this.settings.querySelector<HTMLInputElement>(
'input[name="shadowColor"]'
);
if (o) {
if (fontFamilySelect) {
if (o.fontFamily) fontFamilySelect.value = o.fontFamily;
fontFamilySelect.addEventListener("change", () => {
o.fontFamily = fontFamilySelect.value;
this.fc?.renderAll();
});
}
fontWeightInput?.addEventListener("input", () => {
o.fontWeight = fontWeightInput.valueAsNumber;
this.fc?.renderAll();
});
opacityInput?.addEventListener("input", () => {
o.opacity = opacityInput.valueAsNumber;
this.fc?.renderAll();
});
paddingInput?.addEventListener("input", () => {
o.padding = paddingInput.valueAsNumber;
this.fc?.renderAll();
});
italicInput?.addEventListener("change", () => {
o.fontStyle = italicInput.checked ? "italic" : "normal";
this.fc?.renderAll();
});
underlineInput?.addEventListener("change", () => {
//o.underline = underlineInput.checked;
o.set("underline", underlineInput.checked);
this.fc?.renderAll();
});
linethroughInput?.addEventListener("change", () => {
// o.linethrough = linethroughInput.checked;
o.set("linethrough", linethroughInput.checked);
this.fc?.renderAll();
});
if (shadowColorInput) {
this.palette.setupInput(shadowColorInput);
shadowColorInput.addEventListener("change", () => {
shadowColorInput.dispatchEvent(new Event("input"));
});
const shadowOffsetXInput =
this.settings.querySelector<HTMLInputElement>(
'input[name="shadowOffsetX"]'
);
const shadowOffsetYInput =
this.settings.querySelector<HTMLInputElement>(
'input[name="shadowOffsetY"]'
);
const shadowBlurInput = this.settings.querySelector<HTMLInputElement>(
'input[name="shadowBlur"]'
);
[
shadowColorInput,
shadowOffsetXInput,
shadowOffsetYInput,
shadowBlurInput
].forEach((input) => {
input?.addEventListener("input", () => {
const color = shadowColorInput.value.trim();
if (!color) return;
if (shadow == null) {
shadow = new fabric.Shadow({
color,
offsetX: shadowOffsetXInput?.valueAsNumber,
offsetY: shadowOffsetYInput?.valueAsNumber,
blur: shadowBlurInput?.valueAsNumber
});
o.shadow = shadow;
} else {
const name = input.name;
switch (name) {
case "shadowOffsetX":
shadow.offsetX = shadowOffsetXInput?.valueAsNumber ?? 0;
break;
case "shadowOffsetY":
shadow.offsetY = shadowOffsetYInput?.valueAsNumber ?? 0;
break;
case "shadowBlur":
shadow.blur = shadowBlurInput?.valueAsNumber ?? 0;
break;
default:
shadow.color = color;
break;
}
}
this.fc?.renderAll();
});
});
}
}
const fillInput =
this.settings.querySelector<HTMLInputElement>('input[name="fill"]');
if (fillInput) {
this.palette.setupInput(fillInput);
if (o) {
fillInput.addEventListener("change", () => {
o.set("fill", fillInput.value);
this.fc?.renderAll();
});
}
}
const bgColorInput = this.settings.querySelector<HTMLInputElement>(
'input[name="bgColor"]'
);
if (bgColorInput) {
this.palette.setupInput(bgColorInput);
if (o) {
bgColorInput.addEventListener("change", () => {
o.set("backgroundColor", bgColorInput.value);
this.fc?.renderAll();
});
}
}
}
private showSettings(html: string, name?: string, className?: string) {
this.settings.innerHTML = html;
this.settings.style.visibility = html === "" ? "hidden" : "visible";
this.settings.dataset["name"] = name;
this.settings.classList.forEach((c, _k, p) => {
if (c === "settings") return;
p.remove(c);
});
if (className) {
this.settings.classList.add(className);
}
}
private isSettingShowing(name: string, clear: boolean = true) {
if (this.settings.dataset["name"] === name) {
if (this.settings.hasChildNodes()) {
if (clear) this.showSettings("");
return true;
}
}
return false;
}
private doRotate(o: FabricType.Object, angle: number) {
let na = (o.angle ?? 0) + angle;
if (na >= 360) na -= 360;
else if (na < 0) na += 360;
o.rotate(na);
o.setCoords();
const i = this.image;
// Main image
if (o == i) {
const w = i.width ?? 0;
const h = i.height ?? 0;
if (na === 90 || na === 270) {
this.fc?.setDimensions({
width: h,
height: w
});
if (na === 90) {
this.image?.setPositionByOrigin(
new fabric.Point(h, 0),
"left",
"top"
);
} else {
this.image?.setPositionByOrigin(
new fabric.Point(0, w),
"left",
"top"
);
}
} else {
this.fc?.setDimensions({
width: w,
height: h
});
if (na === 0) {
this.image?.setPositionByOrigin(
new fabric.Point(0, 0),
"left",
"top"
);
} else {
this.image?.setPositionByOrigin(
new fabric.Point(w, h),
"left",
"top"
);
}
}
this.updateSize();
}
}
private setPngFormat(src: string) {
if (this.pngFormat) {
this.pngFormat.checked =
src.slice(-4).toLocaleLowerCase() === ".png" ||
src.substring(0, 15).toLocaleLowerCase().startsWith("data:image/png");
}
}
addImage(image: FabricType.FabricImage) {
const w = image.width;
const h = image.height;
if (w == null || h == null) return;
this.fc = new fabric.Canvas(this.canvas, {
controlsAboveOverlay: true,
width: w,
height: h
});
image.selectable = false;
image.hoverCursor = "default";
this.fc.add(image);
this.image = image;
ExtendUtils.waitFor(
() => {
this.setup();
},
() => {
if (this.container.offsetWidth > 0) {
const scrollbarWidth =
this.container.offsetWidth - this.container.clientWidth;
this.style.setProperty(
"--close-button-right",
scrollbarWidth > 0 ? `${scrollbarWidth + 8}px` : "8px"
);
return true;
} else {
return false;
}
}
);
}
/**
* Open editor
* http://fabricjs.com/fabric-filters
* @param img Image to edit
* @param callback Callback when doen
*/
open(img: HTMLImageElement | null, callback?: (data: string) => void) {
this.callback = callback;
this.fc?.clear();
if (img) {
// default fabric.textureSize is 2048 x 2048, 4096 x 4096 probably support
const maxSize = 2048;
fabric.config.textureSize = maxSize;
if (img.width > maxSize || img.height > maxSize) {
ImageUtils.resize(img, ImageUtils.calcMax(img, maxSize)).then(
(canvas) => {
this.setPngFormat(img.src);
const image = new fabric.FabricImage(canvas);
this.addImage(image);
}
);
} else {
const image = new fabric.FabricImage(img);
this.setPngFormat(img.src);
this.addImage(image);
}
} else {
this.toolbar.style.visibility = "hidden";
this.imageSize();
}
this.hidden = false;
}
private imageSize() {
const l = this.labels!;
const html = `<div class="grid">
<div class="grid-title">${l.imageSize}</div>
<label for="width">${l.width}</label>
<input type="number" id="width" value="800" min="10" max="2000"/>
<label for="height">${l.height}</label>
<input type="number" id="height" value="600" min="10" max="2000"/>
<label for="bgColor">${l.bgColor}</label>
<input type="text" id="bgColor" value="#ffffff"/>
<button class="full-width" name="apply">${l.ok}</button>
</div>`;
this.popup.show(html);
const bgColorInput =
this.popup.querySelector<HTMLInputElement>("#bgColor")!;
this.palette.setupInput(bgColorInput);
this.popup
.querySelector('button[name="apply"]')
?.addEventListener("click", () => {
const widthInput =
this.popup.querySelector<HTMLInputElement>("#width")!;
if (widthInput.value === "") {
widthInput.focus();
return;