core/chain.js

import * as filters from '../filters/index.js';
import { loadImage } from './loader.js';
import { assertBrowserEnvironment, instanceOfGlobal } from './runtime.js';
import {
  getPixelData,
  putPixelData,
  canvasToBlob,
  resize,
  crop,
} from './canvas.js';

/**
 * @fileoverview LuminaJS Core - Chainable API
 * Provides a premium, fluent interface for image processing.
 */

export class Lumina {
  /**
   * @param {string|File|HTMLImageElement|HTMLCanvasElement|ImageData} source - The image source.
   */
  constructor(source) {
    /** @type {string|File|HTMLImageElement|HTMLCanvasElement|ImageData} */
    this.source = source;
    /** @type {Array<{fn: Function, args: any[]}>} */
    this.operations = [];
  }

  /**
   * Internal method to add an operation to the queue.
   * @param {Function} fn
   * @param {any[]} args
   * @private
   */
  _addOp(fn, ...args) {
    this.operations.push({ fn, args });
    return this;
  }

  // --- Filter Methods ---

  /** @returns {this} */
  grayscale() {
    return this._addOp(filters.grayscale);
  }

  /** @param {number} level - Brightness level. @returns {this} */
  brightness(level) {
    return this._addOp(filters.brightness, level);
  }

  /** @param {number} level - Contrast level. @returns {this} */
  contrast(level) {
    return this._addOp(filters.contrast, level);
  }

  /** @returns {this} */
  sepia() {
    return this._addOp(filters.sepia);
  }

  /** @param {any} [options] - ASCII options. @returns {this} */
  ascii(options = {}) {
    return this._addOp(filters.ascii, options);
  }

  /** @param {number} radius - Blur radius. @returns {this} */
  blur(radius) {
    return this._addOp(filters.blur, radius);
  }

  /** @param {number} radius - Gaussian blur radius. @returns {this} */
  gaussianBlur(radius) {
    return this._addOp(filters.gaussianBlur, radius);
  }

  /** @param {string} text @param {any} options @returns {this} */
  watermark(text, options) {
    return this._addOp(filters.watermark, text, options);
  }

  /** @param {any} options @returns {this} */
  backgroundBlur(options) {
    return this._addOp(filters.backgroundBlur, options);
  }

  /** @param {number[]} kernel @param {number} [divisor] @param {number} [offset] @returns {this} */
  applyConvolution(kernel, divisor, offset) {
    return this._addOp(filters.applyConvolution, kernel, divisor, offset);
  }

  /** @returns {this} */
  sharpen() {
    return this._addOp(filters.sharpen);
  }

  /** @returns {this} */
  emboss() {
    return this._addOp(filters.emboss);
  }

  /** @returns {this} */
  edgeDetection() {
    return this._addOp(filters.edgeDetection);
  }

  // --- Transformation Methods ---

  /**
   * Resizes the image in the chain.
   * @param {number} width
   * @param {number} height
   * @returns {this}
   */
  resize(width, height) {
    return this._addOp(
      (
        /** @type {ImageData} */ imageData,
        /** @type {number} */ w,
        /** @type {number} */ h,
      ) => {
        assertBrowserEnvironment('resize image in chain');
        const canvas = document.createElement('canvas');
        canvas.width = imageData.width;
        canvas.height = imageData.height;
        const ctx = canvas.getContext('2d');
        if (!ctx)
          throw new Error('LuminaJS [chain]: Failed to get canvas context.');
        ctx.putImageData(imageData, 0, 0);

        const resizedCanvas = resize(canvas, w, h);
        const resizedCtx = resizedCanvas.getContext('2d');
        if (!resizedCtx)
          throw new Error(
            'LuminaJS [chain]: Failed to get resized canvas context.',
          );
        return resizedCtx.getImageData(0, 0, w, h);
      },
      width,
      height,
    );
  }

  /**
   * Crops the image in the chain.
   * @param {number} x @param {number} y @param {number} width @param {number} height @returns {this}
   */
  crop(x, y, width, height) {
    return this._addOp(
      (
        /** @type {ImageData} */ imageData,
        /** @type {number} */ cx,
        /** @type {number} */ cy,
        /** @type {number} */ cw,
        /** @type {number} */ ch,
      ) => {
        assertBrowserEnvironment('crop image in chain');
        const canvas = document.createElement('canvas');
        canvas.width = imageData.width;
        canvas.height = imageData.height;
        const ctx = canvas.getContext('2d');
        if (!ctx)
          throw new Error('LuminaJS [chain]: Failed to get canvas context.');
        ctx.putImageData(imageData, 0, 0);

        const croppedCanvas = crop(canvas, cx, cy, cw, ch);
        const croppedCtx = croppedCanvas.getContext('2d');
        if (!croppedCtx)
          throw new Error(
            'LuminaJS [chain]: Failed to get cropped canvas context.',
          );
        return croppedCtx.getImageData(0, 0, cw, ch);
      },
      x,
      y,
      width,
      height,
    );
  }

  // --- Execution Methods ---

  /**
   * Resolves the source to ImageData.
   * @private
   * @returns {Promise<ImageData>}
   */
  async _resolveSource() {
    let currentSource = this.source;

    if (
      typeof currentSource === 'string' ||
      instanceOfGlobal(currentSource, 'File')
    ) {
      const loadableSource = /** @type {string|File} */ (currentSource);
      currentSource = await loadImage(loadableSource);
    }

    if (instanceOfGlobal(currentSource, 'HTMLImageElement')) {
      const imageSource = /** @type {HTMLImageElement} */ (currentSource);
      return getPixelData(imageSource).imageData;
    }

    if (instanceOfGlobal(currentSource, 'HTMLCanvasElement')) {
      const canvasSource = /** @type {HTMLCanvasElement} */ (currentSource);
      const ctx = canvasSource.getContext('2d');
      if (!ctx)
        throw new Error(
          'LuminaJS [chain]: Failed to get canvas context from source.',
        );
      return ctx.getImageData(0, 0, canvasSource.width, canvasSource.height);
    }

    if (instanceOfGlobal(currentSource, 'ImageData')) {
      return /** @type {ImageData} */ (currentSource);
    }

    throw new Error('LuminaJS [chain]: Unsupported source type.');
  }

  /**
   * Executes the chain and returns the final ImageData.
   * @returns {Promise<ImageData>}
   */
  async render() {
    let imageData = await this._resolveSource();

    for (const op of this.operations) {
      imageData = await op.fn(imageData, ...op.args);
    }

    return imageData;
  }

  /**
   * Executes the chain and draws the result to a canvas.
   * @param {HTMLCanvasElement} canvas
   * @returns {Promise<HTMLCanvasElement>}
   */
  async toCanvas(canvas) {
    const imageData = await this.render();
    canvas.width = imageData.width;
    canvas.height = imageData.height;
    putPixelData(canvas, imageData);
    return canvas;
  }

  /**
   * Executes the chain and returns a Blob.
   * @param {string} [mimeType='image/png']
   * @param {number} [quality=0.92]
   * @returns {Promise<Blob>}
   */
  async toBlob(mimeType = 'image/png', quality = 0.92) {
    const imageData = await this.render();
    assertBrowserEnvironment('export image as Blob');
    const canvas = document.createElement('canvas');
    canvas.width = imageData.width;
    canvas.height = imageData.height;
    putPixelData(canvas, imageData);
    return canvasToBlob(canvas, mimeType, quality);
  }

  /**
   * Executes the chain and returns a Data URL.
   * @param {string} [mimeType='image/png']
   * @param {number} [quality=0.92]
   * @returns {Promise<string>}
   */
  async toDataURL(mimeType = 'image/png', quality = 0.92) {
    const imageData = await this.render();
    assertBrowserEnvironment('export image as Data URL');
    const canvas = document.createElement('canvas');
    canvas.width = imageData.width;
    canvas.height = imageData.height;
    putPixelData(canvas, imageData);
    return canvas.toDataURL(mimeType, quality);
  }

  /**
   * Executes the chain and displays the result in an HTML element.
   * Supports <img> (via src) and <canvas> (via drawing).
   * @param {string|HTMLElement} elementOrId - The target element or its ID.
   * @returns {Promise<HTMLElement>}
   */
  async toHtmlElement(elementOrId) {
    assertBrowserEnvironment('render image into HTML element');

    const el =
      typeof elementOrId === 'string'
        ? document.getElementById(elementOrId)
        : elementOrId;
    if (!el) {
      throw new Error(
        `LuminaJS [chain]: Target element not found: "${elementOrId}"`,
      );
    }

    if (instanceOfGlobal(el, 'HTMLImageElement')) {
      const imageEl = /** @type {HTMLImageElement} */ (el);
      imageEl.src = await this.toDataURL();
    } else if (instanceOfGlobal(el, 'HTMLCanvasElement')) {
      const canvasEl = /** @type {HTMLCanvasElement} */ (el);
      await this.toCanvas(canvasEl);
    } else {
      throw new Error(
        'LuminaJS [chain]: toHtmlElement only supports <img> and <canvas> elements.',
      );
    }

    return el;
  }
}