/ src / scripts / image_editor.js
/*
 * Yioop image editor. Draws a picture kept with a wiki page on a canvas
 * the writer may change, and hands the changed picture back to the edit
 * form to be saved as the same file. A picture is loaded from the
 * address the edit view puts on the editor, drawn to fit the screen
 * while keeping its shape, and changed with the controls above it: the
 * width and height fields, the turn and mirror marks, and the drawing
 * tools. What the writer draws is kept in the picture's own pixels, so a
 * turn or a resize afterward carries the drawing with it.
 *
 * LICENSE
 *
 *  SeekQuarry/Yioop Open Source Pure PHP Search Engine, Crawler, and
 *  Indexer.
 *  Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful, but
 *  WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 *  General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program. If not, see
 *  <https://www.gnu.org/licenses/>.
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @copyright 2009 - 2026
 */
/*
 * The most of the screen a picture is allowed to fill when first drawn,
 * as a part of one. A picture wider or taller than this share is drawn
 * smaller so the controls above it and the page around it still show.
 */
const IMAGE_EDITOR_SCREEN_SHARE = 0.6;
/*
 * How far a quarter turn is, in degrees. The turn mark adds this to the
 * angle each time it is pressed.
 */
const IMAGE_EDITOR_QUARTER_TURN = 90;
/*
 * Holds the one editor on the page: the canvas and its context, the
 * picture's own pixels kept off screen at their true size, the tools
 * the controls choose, and where a drag began. One page edits one file,
 * so one set of these is enough.
 */
let image_editor = null;
/*
 * How many earlier states of the picture are kept so a writer may step
 * back through them. Deep enough for a run of drawings, shallow enough
 * that a large picture does not fill the browser's memory.
 */
const IMAGE_EDITOR_UNDO_DEPTH = 20;
/*
 * Sets up the image editor once the page has loaded, where the page
 * carries one. Reads the picture's address from the editor, loads it,
 * and draws it to fit once it arrives. Does nothing on a page with no
 * image editor, so it is safe to call on every wiki edit screen.
 */
function initImageEditor()
{
    let frame = elt("image-editor");
    if (!frame) {
        return;
    }
    let canvas = elt("image-editor-canvas");
    image_editor = {
        canvas: canvas,
        context: canvas.getContext("2d"),
        store: document.createElement("canvas"),
        drawing: false,
        start_x: 0,
        start_y: 0,
        earlier: [],
        typing_at: null,
        typed: ""
    };
    let picture = new Image();
    picture.onload = function () {
        image_editor.store.width = picture.naturalWidth;
        image_editor.store.height = picture.naturalHeight;
        image_editor.store.getContext("2d").drawImage(picture, 0, 0);
        fitImageEditor();
        drawImageEditor();
    };
    picture.src = frame.getAttribute("data-image-url");
    listenImageEditor();
}
/*
 * Sizes the canvas so the picture fits the screen while keeping its
 * shape. Reads the picture's true size from the off-screen store,
 * shrinks it to the allowed share of the window where it is larger, and
 * fills the width and height fields with the size shown. Never makes a
 * small picture larger, so a small picture stays its own size.
 */
function fitImageEditor()
{
    let store = image_editor.store;
    let most_wide = window.innerWidth * IMAGE_EDITOR_SCREEN_SHARE;
    let most_tall = window.innerHeight * IMAGE_EDITOR_SCREEN_SHARE;
    let scale = Math.min(1, most_wide / store.width,
        most_tall / store.height);
    image_editor.canvas.width = Math.round(store.width * scale);
    image_editor.canvas.height = Math.round(store.height * scale);
    elt("image-editor-width").value =
        image_editor.canvas.width;
    elt("image-editor-height").value =
        image_editor.canvas.height;
}
/*
 * Draws the picture from the off-screen store onto the canvas at the
 * canvas's size, so a resize, a turn, or a mirror shows. The store
 * holds the true pixels; the canvas holds what the writer sees. Called
 * after every change that alters the whole picture.
 */
function drawImageEditor()
{
    let context = image_editor.context;
    let canvas = image_editor.canvas;
    context.setTransform(1, 0, 0, 1, 0, 0);
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.drawImage(image_editor.store, 0, 0, canvas.width,
        canvas.height);
}
/*
 * Copies what is on the canvas back into the off-screen store at the
 * store's size, so a drawing the writer made is kept in the picture's
 * own pixels. Called after a doodle, a line, or a shape is drawn, so a
 * later turn or resize carries the drawing with it.
 */
function keepImageEditor()
{
    let store = image_editor.store;
    let keep = store.getContext("2d");
    keep.setTransform(1, 0, 0, 1, 0, 0);
    keep.clearRect(0, 0, store.width, store.height);
    keep.drawImage(image_editor.canvas, 0, 0, store.width, store.height);
}
/*
 * Puts the width and height a writer typed onto the canvas, keeping the
 * picture's pixels in the store at their true size. An empty or
 * not-a-number size is left as it was, so a half-typed number does not
 * blank the picture.
 *
 * @param Object field the width or height input the writer changed
 */
function sizeImageEditor()
{
    let wide = parseInt(
        elt("image-editor-width").value, 10);
    let tall = parseInt(
        elt("image-editor-height").value, 10);
    if (!wide || !tall || wide < 1 || tall < 1) {
        return;
    }
    let sized = document.createElement("canvas");
    sized.width = wide;
    sized.height = tall;
    sized.getContext("2d").drawImage(image_editor.store, 0, 0, wide,
        tall);
    image_editor.store = sized;
    image_editor.canvas.width = wide;
    image_editor.canvas.height = tall;
    drawImageEditor();
}
/*
 * Turns the picture a quarter turn to the right. The store's pixels are
 * redrawn onto a fresh store turned on its side, so the width and the
 * height swap, then the canvas is fitted and drawn again. Pressing the
 * mark four times brings the picture back to where it began.
 */
function rotateImageEditor()
{
    rememberImageEditor();
    let store = image_editor.store;
    let turned = document.createElement("canvas");
    turned.width = store.height;
    turned.height = store.width;
    let context = turned.getContext("2d");
    context.translate(turned.width / 2, turned.height / 2);
    context.rotate(IMAGE_EDITOR_QUARTER_TURN * Math.PI / 180);
    context.drawImage(store, -store.width / 2, -store.height / 2);
    image_editor.store = turned;
    fitImageEditor();
    drawImageEditor();
}
/*
 * Mirrors the picture left to right. The store's pixels are redrawn onto
 * a fresh store from the far side, so what was on the left is on the
 * right, then the canvas is drawn again. Pressing the mark twice brings
 * the picture back.
 */
function flipImageEditor(down)
{
    rememberImageEditor();
    let store = image_editor.store;
    let mirror = document.createElement("canvas");
    mirror.width = store.width;
    mirror.height = store.height;
    let context = mirror.getContext("2d");
    if (down) {
        context.translate(0, store.height);
        context.scale(1, -1);
    } else {
        context.translate(store.width, 0);
        context.scale(-1, 1);
    }
    context.drawImage(store, 0, 0);
    image_editor.store = mirror;
    drawImageEditor();
}
/*
 * Gives where the mouse is over the canvas in canvas pixels, since the
 * canvas may be shown at a size other than its pixel size. Reads the
 * canvas's place on screen and turns the mouse's page place into a place
 * on the canvas.
 *
 * @param Object event the mouse event the place is read from
 * @return Object the x and y of the mouse in canvas pixels
 */
function whereImageEditor(event)
{
    let canvas = image_editor.canvas;
    let box = canvas.getBoundingClientRect();
    return {
        x: (event.clientX - box.left) * (canvas.width / box.width),
        y: (event.clientY - box.top) * (canvas.height / box.height)
    };
}
/*
 * Starts a drawing where the mouse went down on the canvas. Keeps the
 * place the drag began and, for the doodle tool, moves the pen there so
 * the first move draws from the right spot.
 *
 * @param Object event the mouse-down event on the canvas
 */
function startImageDraw(event)
{
    let at = whereImageEditor(event);
    if (elt("image-editor-tool").value == "text") {
        keepImageEditor();
        rememberImageEditor();
        image_editor.typing_at = at;
        image_editor.typed = "";
        return;
    }
    rememberImageEditor();
    image_editor.drawing = true;
    image_editor.start_x = at.x;
    image_editor.start_y = at.y;
    let context = image_editor.context;
    context.strokeStyle =
        elt("image-editor-color").value;
    context.fillStyle = context.strokeStyle;
    context.lineWidth = parseInt(
        elt("image-editor-thickness").value, 10);
    context.lineCap = "round";
    if (elt("image-editor-tool").value == "doodle") {
        context.beginPath();
        context.moveTo(at.x, at.y);
    }
}
/*
 * Draws as the mouse moves with the button down. The doodle tool adds to
 * a free line under the pen. The line and shape tools draw from the
 * start to where the mouse is now over a fresh copy of the kept picture,
 * so the one being drawn follows the mouse without leaving a trail.
 *
 * @param Object event the mouse-move event on the canvas
 */
function moveImageDraw(event)
{
    if (!image_editor.drawing) {
        return;
    }
    let at = whereImageEditor(event);
    let context = image_editor.context;
    if (elt("image-editor-tool").value == "doodle") {
        context.lineTo(at.x, at.y);
        context.stroke();
        return;
    }
    drawImageEditor();
    context.beginPath();
    if (elt("image-editor-tool").value == "line") {
        context.moveTo(image_editor.start_x, image_editor.start_y);
        context.lineTo(at.x, at.y);
        context.stroke();
        return;
    }
    drawImageShape(context, at);
}
/*
 * Draws the chosen shape from where the drag began to where the mouse is
 * now. An oval is drawn inside the box the two corners make, and a
 * rectangle is drawn as that box. Called while a shape is being dragged
 * and once more when it is let go.
 *
 * @param Object context the canvas context the shape is drawn on
 * @param Object at the x and y of the mouse now, in canvas pixels
 */
function drawImageShape(context, at)
{
    let tool = elt("image-editor-tool").value;
    let left = Math.min(image_editor.start_x, at.x);
    let top = Math.min(image_editor.start_y, at.y);
    let wide = Math.abs(at.x - image_editor.start_x);
    let tall = Math.abs(at.y - image_editor.start_y);
    if (tool == "rectangle") {
        context.strokeRect(left, top, wide, tall);
        return;
    }
    if (tool == "rectangle_filled") {
        context.fillRect(left, top, wide, tall);
        return;
    }
    context.ellipse(left + wide / 2, top + tall / 2, wide / 2, tall / 2,
        0, 0, 2 * Math.PI);
    if (tool == "oval_filled") {
        context.fill();
        return;
    }
    context.stroke();
}
/*
 * Ends a drawing where the mouse comes up. Draws the line or shape one
 * last time to where the mouse ended, then copies the canvas into the
 * store so the drawing becomes part of the picture. A doodle is already
 * on the canvas, so it is only kept.
 *
 * @param Object event the mouse-up event on the canvas
 */
function endImageDraw(event)
{
    if (!image_editor.drawing) {
        return;
    }
    image_editor.drawing = false;
    if (elt("image-editor-tool").value != "doodle") {
        moveImageDrawEnd(event);
    }
    keepImageEditor();
}
/*
 * Draws the line or shape to where the mouse was let go, so the drawn
 * thing ends where the writer meant rather than where the last move
 * event landed. Reached only from endImageDraw for the line and shape
 * tools.
 *
 * @param Object event the mouse-up event on the canvas
 */
function moveImageDrawEnd(event)
{
    let at = whereImageEditor(event);
    let context = image_editor.context;
    drawImageEditor();
    context.beginPath();
    if (elt("image-editor-tool").value == "line") {
        context.moveTo(image_editor.start_x, image_editor.start_y);
        context.lineTo(at.x, at.y);
        context.stroke();
        return;
    }
    drawImageShape(context, at);
}
/*
 * Reads the picture off the canvas as a data address and puts it in the
 * form being sent, so a save writes the changed picture back as the
 * file. It runs as the form gathers its fields, and is given that
 * gathering by the event, so it sets the value both on the hidden field
 * and on the fields being sent. The width and height fields say how
 * large the saved picture is, so the kept pixels are drawn onto a canvas
 * of that size and read from there. A drawing on the canvas is kept into
 * the store first, so it is carried into the saved picture.
 *
 * @param Object event the form's formdata event, whose formData holds
 *     the fields being sent
 */
function packImageEditor(event)
{
    if (!image_editor) {
        return;
    }
    keepImageEditor();
    let wide = parseInt(
        elt("image-editor-width").value, 10);
    let tall = parseInt(
        elt("image-editor-height").value, 10);
    if (!wide || !tall || wide < 1 || tall < 1) {
        wide = image_editor.store.width;
        tall = image_editor.store.height;
    }
    let output = document.createElement("canvas");
    output.width = wide;
    output.height = tall;
    output.getContext("2d").drawImage(image_editor.store, 0, 0, wide,
        tall);
    let address = output.toDataURL(imageEditorKind());
    elt("image-editor-data").value = address;
    if (event && event.formData) {
        event.formData.set("image_editor_data", address);
    }
}
/*
 * Keeps a copy of the picture as it stands so a later undo can put it
 * back. Called before every change that alters the picture, and the
 * oldest copy is dropped once the stack is full.
 */
function rememberImageEditor()
{
    let kept = document.createElement("canvas");
    kept.width = image_editor.store.width;
    kept.height = image_editor.store.height;
    kept.getContext("2d").drawImage(image_editor.store, 0, 0);
    image_editor.earlier.push(kept);
    if (image_editor.earlier.length > IMAGE_EDITOR_UNDO_DEPTH) {
        image_editor.earlier.shift();
    }
}
/*
 * Puts the picture back as it stood before the last change, and sizes
 * the canvas and its fields to match, since a turn or a resize changes
 * how large the picture is. Does nothing where no earlier copy is kept.
 */
function undoImageEditor()
{
    if (!image_editor || image_editor.earlier.length < 1) {
        return;
    }
    image_editor.store = image_editor.earlier.pop();
    image_editor.canvas.width = image_editor.store.width;
    image_editor.canvas.height = image_editor.store.height;
    elt("image-editor-width").value = image_editor.store.width;
    elt("image-editor-height").value = image_editor.store.height;
    drawImageEditor();
}
/*
 * Draws the text a writer has typed so far at the place they clicked, in
 * the font, size and style the text panel names. The picture is redrawn
 * from what was kept first, so each letter typed replaces the run before
 * it rather than drawing over it.
 */
function drawImageText()
{
    let context = image_editor.context;
    drawImageEditor();
    let style = elt("image-editor-font-style").value;
    let weight = (style == "bold") ? "bold " : "";
    let slant = (style == "italic") ? "italic " : "";
    context.font = slant + weight + elt("image-editor-font-size").value +
        "px " + elt("image-editor-font").value;
    context.fillStyle = elt("image-editor-color").value;
    context.textBaseline = "top";
    context.fillText(image_editor.typed, image_editor.typing_at.x,
        image_editor.typing_at.y);
}
/*
 * Takes a key the writer pressed while text is being placed and adds it
 * to what is drawn. A letter is added, the rubbing-out key takes the
 * last letter away, and the enter or escape key finishes the text and
 * keeps it in the picture.
 *
 * @param Object event the key event to read
 */
function typeImageText(event)
{
    if (!image_editor || !image_editor.typing_at) {
        return;
    }
    if (event.key == "Enter" || event.key == "Escape") {
        keepImageEditor();
        image_editor.typing_at = null;
        image_editor.typed = "";
        return;
    }
    if (event.key == "Backspace") {
        image_editor.typed = image_editor.typed.slice(0, -1);
    } else if (event.key.length == 1) {
        image_editor.typed += event.key;
    } else {
        return;
    }
    event.preventDefault();
    drawImageText();
}
/*
 * Shows the panel naming the font, its size and its style while the text
 * tool is chosen, and hides it for every other tool, so the panel stands
 * only where it means something.
 */
function showImageTextSettings()
{
    let panel = elt("image-editor-text-settings");
    if (panel) {
        panel.style.display =
            (elt("image-editor-tool").value == "text") ? "" : "none";
    }
}
/*
 * Takes a key pressed anywhere on the editing screen. An undo steps the
 * picture back; any other key while text is being placed is added to
 * that text.
 *
 * @param Object event the key event to read
 */
function keyImageEditor(event)
{
    if (!image_editor) {
        return;
    }
    if ((event.ctrlKey || event.metaKey) && !event.shiftKey &&
        event.key.toLowerCase() == "z") {
        event.preventDefault();
        undoImageEditor();
        return;
    }
    typeImageText(event);
}
/*
 * Gives the kind of picture a save should write, worked out from the
 * name the file will be written under. A writer who saves a picture as
 * a name ending in jpg gets a jpg, whatever kind it was opened from.
 * An ending the canvas cannot write back gives a png.
 *
 * @return String the kind of picture, as a media type
 */
function imageEditorKind()
{
    let named = elt("save-as-name");
    let ending = "";
    if (named && named.value.indexOf(".") >= 0) {
        ending = named.value.split(".").pop().toLowerCase();
    }
    if (ending == "jpg" || ending == "jpeg") {
        return "image/jpeg";
    }
    if (ending == "webp") {
        return "image/webp";
    }
    return "image/png";
}
/*
 * Ties the controls and the canvas to what they do: the size fields
 * resize, the marks turn and mirror, and the mouse on the canvas draws.
 * The save marks are tied to packing the picture so it is read into the
 * form once, just as a save is pressed.
 */
function listenImageEditor()
{
    listen(elt("image-editor-width"), "change", sizeImageEditor);
    listen(elt("image-editor-height"), "change", sizeImageEditor);
    listen(elt("image-editor-rotate"), "click", rotateImageEditor);
    listen(elt("image-editor-flip-across"), "click", function () {
        flipImageEditor(false);
    });
    listen(elt("image-editor-flip-down"), "click", function () {
        flipImageEditor(true);
    });
    listen(elt("image-editor-tool"), "change", showImageTextSettings);
    let canvas = image_editor.canvas;
    listen(canvas, "mousedown", startImageDraw);
    listen(canvas, "mousemove", moveImageDraw);
    listen(canvas, "mouseup", endImageDraw);
    listen(canvas, "mouseleave", endImageDraw);
    listen(document, "keydown", keyImageEditor);
    showImageTextSettings();
    let form = elt("editpageForm");
    if (form) {
        listen(form, "formdata", packImageEditor);
    }
    let save_by_hand = elt("wiki-save-button");
    if (save_by_hand) {
        listen(save_by_hand, "mousedown", packImageEditor);
    }
    let save_as = elt("wiki-save-as-button");
    if (save_as) {
        listen(save_as, "mousedown", packImageEditor);
    }
}
listen(window, "load", initImageEditor);
X