/*
* 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);