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 = ''; /** * 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 = `
`; 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(".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("div.toolbar")!; this.icons = this.toolbar.querySelector(".icons")!; this.mover = this.modalDiv.querySelector("div.mover")!; this.settings = this.modalDiv.querySelector("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: '', label: l.undo }, { name: "redo", icon: '', label: l.redo }, EOImageEditorSeparator, { name: "zoomIn", icon: '', label: l.zoomIn }, { name: "zoomOut", icon: '', label: l.zoomOut }, EOImageEditorSeparator, { name: "rotateLeft", icon: '', label: l.rotateLeft }, { name: "rotateRight", icon: '', label: l.rotateRight }, EOImageEditorSeparator, { name: "text", icon: '', label: l.text }, { name: "image", icon: '', label: l.image }, { name: "crop", icon: cropPath, label: l.crop }, { name: "filter", icon: '', label: l.filter }, EOImageEditorSeparator, { name: "hcenter", icon: '', label: l.hcenter }, { name: "vcenter", icon: '', label: l.vcenter }, { name: "bringToFront", icon: '', label: l.bringToFront }, { name: "bringToBack", icon: '', label: l.bringToBack }, { name: "delete", icon: '', label: l.delete }, EOImageEditorSeparator, { name: "preview", icon: '', label: l.preview }, { name: "complete", icon: '', 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" ? '
' : `` ) .join("") + ``; this.icons.innerHTML = html; this.pngFormat = this.toolbar.querySelector( '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('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(".close-button"); if (closeDiv) { closeDiv.title = l.close; closeDiv.addEventListener("click", () => this.reset()); } }); } private createSVG(path: string, size: number = 24) { return `${path}`; } 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) => `` ) .join(""); this.showSettings(layout, fname, "flex"); this.settings .querySelectorAll("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 `${Reflect.get(l, n)}${ v == null ? "" : ` ` }`; }) .join(""); this.showSettings(layout, fname, "form"); const f = fabric.filters; this.settings .querySelectorAll("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( `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 = ``; this.showSettings(layout, "image", "flex"); const opacityInput = this.settings.querySelector( '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( 'input[name="shadowColor"]' ); let shadow: FabricType.Shadow | undefined; const color = shadowColorInput?.value; if (color) { const offsetX = this.settings.querySelector( 'input[name="shadowOffsetX"]' )?.valueAsNumber ?? 1; const offsetY = this.settings.querySelector( 'input[name="shadowOffsetY"]' )?.valueAsNumber ?? 1; const blur = this.settings.querySelector( 'input[name="shadowOffsetBlur"]' )?.valueAsNumber ?? 0; shadow = new fabric.Shadow({ color, offsetX, offsetY, blur }); } return { fontFamily: this.settings.querySelector( 'select[name="fontFamily"]' )?.value, fontWeight: this.settings.querySelector( 'input[name="fontWeight"]' )?.valueAsNumber, opacity: this.settings.querySelector( 'input[name="opacity"]' )?.valueAsNumber, padding: this.settings.querySelector( 'input[name="padding"]' )?.valueAsNumber, fill: this.settings .querySelector('input[name="fill"]') ?.value.trim(), backgroundColor: this.settings .querySelector('input[name="bgColor"]') ?.value.trim(), fontStyle: (this.settings.querySelector( 'input[name="italic"]' )?.checked ? "italic" : "normal") as any, underline: this.settings.querySelector( 'input[name="underline"]' )?.checked, linethrough: this.settings.querySelector( '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 ? ` ` : ""; const layout = ` ${shadowLayout}`; this.showSettings(layout, "text", "flex"); const fontFamilySelect = this.settings.querySelector( 'select[name="fontFamily"]' ); const fontWeightInput = this.settings.querySelector( 'input[name="fontWeight"]' ); const opacityInput = this.settings.querySelector( 'input[name="opacity"]' ); const paddingInput = this.settings.querySelector( 'input[name="padding"]' ); const italicInput = this.settings.querySelector( 'input[name="italic"]' ); const underlineInput = this.settings.querySelector( 'input[name="underline"]' ); const linethroughInput = this.settings.querySelector( 'input[name="linethrough"]' ); const shadowColorInput = this.settings.querySelector( '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( 'input[name="shadowOffsetX"]' ); const shadowOffsetYInput = this.settings.querySelector( 'input[name="shadowOffsetY"]' ); const shadowBlurInput = this.settings.querySelector( '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('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( '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 = `
${l.imageSize}
`; this.popup.show(html); const bgColorInput = this.popup.querySelector("#bgColor")!; this.palette.setupInput(bgColorInput); this.popup .querySelector('button[name="apply"]') ?.addEventListener("click", () => { const widthInput = this.popup.querySelector("#width")!; if (widthInput.value === "") { widthInput.focus(); return; } const heightInput = this.popup.querySelector("#height")!; if (heightInput.value === "") { heightInput.focus(); return; } if (bgColorInput.value === "") { bgColorInput.focus(); return; } this.popup.hide(); this.fc = new fabric.Canvas(this.canvas, { controlsAboveOverlay: true, width: widthInput.valueAsNumber, height: heightInput.valueAsNumber, backgroundColor: bgColorInput.value }); if (bgColorInput.value === "transparent") { if (this.pngFormat) this.pngFormat.checked = true; } this.setup(); this.toolbar.style.visibility = "visible"; }); } /** * Reset editor */ reset() { this.settings.innerHTML = ""; this.fc?.dispose(); this.fc = undefined; this.hidden = true; this.container.style.visibility = "hidden"; this.history?.clear(); this.history = undefined; } private setup() { const fc = this.fc!; this.container.style.visibility = "visible"; this.originalWidth = this.fc?.width; this.originalHeight = this.fc?.height; this.updateSize(); this.updateStatus(); if (this.redo && this.undo) { if (this.history == null) { this.history = new EOEditorHistory(); const updateStatus = () => { const h = this.history!; const status = h.getStatus(); const u = this.undo!; const r = this.redo!; u.disabled = !status[0]; r.disabled = !status[1]; const pState = h.states[h.index - 1]; const nState = h.states[h.index + 1]; u.title = u.disabled || pState == null ? "" : pState.title; r.title = r.disabled || nState == null ? "" : nState.title; }; this.history.on("navigate", (e) => { updateStatus(); const index = this.history!.index; const d = e.data.delta; // Only one step if (d > 0) { const state = this.history?.states[index - d + 1]; state?.action.call(this); } else { const state = this.history?.states[index - d]; state?.undo.call(this); } }); this.history.on("push", () => { updateStatus(); }); } else { this.history.clear(); } this.redo.disabled = true; this.undo.disabled = true; this.history.pushState({ title: "Start", action: () => {}, undo: () => {} }); } fc.on("selection:created", (e) => { this.activeObject = fc.getActiveObject(); this.updateStatus(); }); fc.on("selection:cleared", () => { this.activeObject = undefined; this.updateStatus(); this.showSettings(""); }); fc.on("selection:updated", () => { this.activeObject = fc.getActiveObject(); this.updateStatus(); }); let objectLastPos: [FabricType.FabricObject, number?, number?] | undefined; let movingIndicator: FabricType.IText | undefined; fc.on("object:moving", (e) => { if (objectLastPos == null && e.target) { objectLastPos = [e.target, e.target.left, e.target.top]; } if (this.activeObject) { const zoom = this.fc?.getZoom() ?? 1; if (movingIndicator == null) { movingIndicator = new fabric.IText("", { fontSize: 9 / zoom }); this.fc?.add(movingIndicator); } const { left = 0, top = 0, width = 0, scaleX = 1, height = 0, scaleY = 1 } = this.activeObject; const fcWidth = (this.fc?.width ?? 0) / zoom; const fcHeight = (this.fc?.height ?? 0) / zoom; const oWidth = width * scaleX; const oHeight = height * scaleY; if (Math.abs((fcWidth - oWidth) / 2 - left) < 1) { movingIndicator.set("fill", "#ff0000"); } else { movingIndicator.set("fill", "#000"); } if (Math.abs((fcHeight - oHeight) / 2 - top) < 1) { movingIndicator.set("backgroundColor", "#ffff00"); } else { movingIndicator.set("backgroundColor", undefined); } movingIndicator.text = `${(left * zoom).toExact(0)} x ${( top * zoom ).toExact(0)}`; movingIndicator.left = (left + oWidth / 2 + 4).toExact(0); movingIndicator.top = ( top <= 30 ? top + 8 + oHeight : top - 22 ).toExact(0); this.fc?.renderAll(); } }); let objectLastScale: | [FabricType.FabricObject, number?, number?] | undefined; fc.on("object:scaling", (e) => { if (objectLastScale == null && e.target) { objectLastScale = [e.target, e.target.scaleX, e.target.scaleY]; } }); let objectLastRotate: | [FabricType.FabricObject, FabricType.TDegree?] | undefined; fc.on("object:rotating", (e) => { if (objectLastRotate == null && e.target) { objectLastRotate = [e.target, e.target.angle]; } }); fc.on("mouse:up", (e) => { // Moving if (objectLastPos) { const [mObject, pLeft, pTop] = objectLastPos; const { left, top } = mObject; const moveState: EOEditorHistoryState = { title: "Move", action: () => { mObject.left = left; mObject.top = top; }, undo: () => { if (pLeft != null) mObject.left = pLeft; if (pTop != null) mObject.top = pTop; } }; this.history?.pushState(moveState); objectLastPos = undefined; } if (movingIndicator) { this.fc?.remove(movingIndicator); movingIndicator = undefined; } // Scaling if (objectLastScale) { const [sObject, sx, sy] = objectLastScale; const { scaleX, scaleY } = sObject; const scaleState: EOEditorHistoryState = { title: "Scale", action: () => { sObject.scaleX = scaleX; sObject.scaleY = scaleY; }, undo: () => { if (sx != null) sObject.scaleX = sx; if (sy != null) sObject.scaleY = sy; } }; this.history?.pushState(scaleState); objectLastScale = undefined; } // Rotating if (objectLastRotate) { const [sObject, sa] = objectLastRotate; const { angle } = sObject; const rotateState: EOEditorHistoryState = { title: "Rotate", action: () => { sObject.angle = angle; }, undo: () => { if (sa) sObject.angle = sa; } }; this.history?.pushState(rotateState); objectLastRotate = undefined; } // Text if (this.textInput) { const zoom = this.fc!.getZoom(); let left: number, top: number; if (e.viewportPoint) { left = (e.viewportPoint.x / zoom).toExact(0); top = (e.viewportPoint.y / zoom).toExact(0); } else { left = 0; top = 0; } const settings = this.getTextSettings(); const itext = new fabric.IText(this.labels!.inputHere, { left, top, ...settings }); let text: string | undefined = itext.text; itext.on("editing:entered", () => { text = itext.text; }); itext.on("editing:exited", () => { const oldText = text ?? ""; const newText = itext.text ?? ""; if (oldText !== newText) { const editedState: EOEditorHistoryState = { title: this.labels?.text!, action: () => (itext.text = newText), undo: () => (itext.text = oldText) }; this.history?.pushState(editedState); } }); const textState: EOEditorHistoryState = { title: this.labels?.text!, action: () => { fc.add(itext); fc.setActiveObject(itext); }, undo: () => { this.fc?.remove(itext); } }; textState.action(); itext.enterEditing(); itext.selectAll(); this.history?.pushState(textState); this.textInput = false; } }); fc.on("mouse:dblclick", () => { if ( this.activeObject?.type === "rect" && Reflect.get(this.activeObject, "name") === "crop" ) { this.doAction("crop"); } else if (this.activeObject == null) { const imageInput = this.icons.querySelector("input#imageFile"); imageInput?.click(); } }); } private updateStatus() { const a = this.activeObject; const o = a ?? this.image; if (o == null) { [ "rotateLeft", "rotateRight", "bringToBack", "bringToFront", "crop", "filter", "hcenter", "vcenter", "delete" ].forEach((n) => { const b = this.icons.querySelector( `button[name="${n}"]` ); if (b) b.disabled = true; }); } else { ["rotateLeft", "rotateRight"].forEach((n) => { const b = this.icons.querySelector( `button[name="${n}"]` ); if (b) b.disabled = false; }); ["hcenter", "vcenter", "bringToBack", "bringToFront", "delete"].forEach( (n) => { const b = this.icons.querySelector( `button[name="${n}"]` ); if (b) b.disabled = this.activeObject == null; } ); const isImage = o instanceof fabric.Image; const isText = a instanceof fabric.IText; if (a instanceof fabric.Image) { this.imageSettings(a); } else if (isText) { this.textSettings(a); } ["filter"].forEach((n) => { const b = this.icons.querySelector( `button[name="${n}"]` ); if (b) b.disabled = !isImage; }); } } private updateSize() { const w = this.fc?.getWidth(); const h = this.fc?.getHeight(); if (w == null || h == null) return; this.fcSize = [w, h]; this.modalDiv.querySelector( ".size-indicator" )!.innerHTML = `${NumberUtils.format(w.toExact(0))} x ${NumberUtils.format( h.toExact(0) )} px`; const c = this.container.getBoundingClientRect(); this.containerRect = c; const rect = this.mover.parentElement!.getBoundingClientRect(); this.rect = rect; const wr = c.width >= w ? 1 : c.width / w; const hr = c.height >= h ? 1 : c.height / h; const rw = rect.width * wr - 2; const rh = rect.height * hr - 2; this.mover.style.width = `${rw}px`; this.mover.style.height = `${rh}px`; } } customElements.get("eo-popup") || customElements.define("eo-popup", EOPopup); customElements.get("eo-palette") || customElements.define("eo-palette", EOPalette);