#layout
  #control
    label(for="scaleRange") Scale
      input#scaleRange(
        type="range",
        disabled,
        min="0.1",
        max="2",
        step="0.1",
        value="1"
      )
    label(for="rotateRange") Rotate
      input#rotateRange(
        type="range",
        disabled,
        min="-360",
        max="360",
        step="1",
        value="0"
      )

    button#createButton(type="button") Create Table

  #content
    canvas#canvas
View Compiled
html,
body,
#layout {
  height: 100%;
}

body {
  background-color: #333;
  color: #fff;
}

#canvas {
  max-width: 100%;
  max-height: 100%;
}

#layout {
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: 1fr auto;
  grid-template-areas: "content" "control";
}

input[type="range"][disabled] {
  opacity: 0.1;
}

#content {
  grid-area: content;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  overflow: auto;
}

button {
  padding: 10px 20px;
}

#control {
  grid-area: control;
  padding: 10px 5px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  background-color: #1e1f26;

  label {
    display: inline-block;
    margin: 5px;

    input {
      display: block;
    }
  }
}
View Compiled
// PR https://github.com/enessefak/Canvas-table

"use strict";

(function ($) {
  $.fn.canvasTable = function (_options) {
    const options = $.extend(
      {
        width: 500,
        height: 500,
        templateAreas: [],
        images: [],
        gap: 1,
        fillStyle: "#fff",
        strokeStyle: "red",
        strokeDash: 0,
        strokeWidth: 3,
        showAlignmentLines: true,
        onSelected: () => null
      },
      _options
    );

    let areas = [];

    const $canvas = this;
    const ctx = $canvas[0].getContext("2d");

    const MODE = {
      TRANSLATE: "translate",
      CHANGE: "change",
      CHANGE_PREVIEW: "change_preview",
      SELECTION: "selection",
      MAGIC: "magic"
    };

    let startX;
    let startY;
    let activeMode;
    let editingIndex = options.images.length === 1 ? 0 : -1;
    let previewData = {};

    const gap = parseInt(options.gap, 10);
    const canvasWidth = parseInt(options.width, 10) - gap;
    const canvasHeight = parseInt(options.height, 10) - gap;

    ctx.canvas.width = canvasWidth + gap;
    ctx.canvas.height = canvasHeight + gap;

    const clear = () => {
      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
      ctx.fillStyle = options.fillStyle;
      ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    };

    const initArea = async (_image) => {
      const matchedRows = [];
      const matchedColumns = [];
      let area = {
        name: _image.area
      };

      const rowsLength = options.templateAreas.length;
      const heightUnit = canvasHeight / rowsLength;

      const columnsLength = options.templateAreas[0].split(" ").length;
      const widthUnit = canvasWidth / columnsLength;

      for (let rowIndex = 0; rowIndex < rowsLength; rowIndex++) {
        const columns = options.templateAreas[rowIndex].split(" ");
        const matchedArea = columns.find((column) => column === _image.area);
        if (!matchedArea) continue;

        if (!matchedRows.includes(rowIndex)) matchedRows.push(rowIndex);

        for (let columnIndex = 0; columnIndex < columnsLength; columnIndex++) {
          if (
            columns[columnIndex] === _image.area &&
            !matchedColumns.includes(columnIndex)
          ) {
            matchedColumns.push(columnIndex);
          }
        }
      }

      area.xPos = matchedColumns[0] * widthUnit + gap;
      area.yPos = matchedRows[0] * heightUnit + gap;

      area.width = matchedColumns.length * widthUnit - gap;
      area.height = matchedRows.length * heightUnit - gap;

      const image = new Image();
      image.crossOrigin = "anonymous";
      image.src = _image.source;

      return new Promise((resolve, reject) => {
        image.onerror = reject;

        image.onload = () => {
          image.width = area.width > image.width ? area.width : image.width;
          image.height =
            area.height > image.height ? area.height : image.height;

          const xDistance = image.width - area.width;
          const yDistance = image.height - area.height;
          image.xTranslate = 0;
          image.yTranslate = 0;

          image.scale = _image.scale || 1;
          image.rotate = _image.rotate || 0;

          image.xDistance = xDistance;
          image.yDistance = yDistance;

          image.xPos = area.xPos - xDistance / 2;
          image.yPos = area.yPos - yDistance / 2;

          image.xRelativePos = parseInt(
            image.xPos + image.xTranslate - area.xPos + gap
          );
          image.yRelativePos = parseInt(
            image.yPos + image.yTranslate - area.yPos + gap
          );

          resolve({
            image,
            ...area
          });
        };
      });
    };

    const drawArea = () => {
      clear();

      for (let i = 0; i < areas.length; i++) {
        const area = areas[i];
        const cx = area.xPos + area.image.xTranslate + 0.5 * area.width;
        const cy = area.yPos + area.image.yTranslate + 0.5 * area.height;

        ctx.save();
        ctx.beginPath();
        if (previewData.toAreaId === i) {
          ctx.rect(
            area.xPos + 5,
            area.yPos + 5,
            area.width - 10,
            area.height - 10
          );
        } else {
          ctx.rect(area.xPos, area.yPos, area.width, area.height);
        }
        ctx.translate(cx, cy);
        ctx.scale(area.image.scale, area.image.scale);
        ctx.rotate((Math.PI / 180) * area.image.rotate);
        ctx.translate(-cx, -cy);
        ctx.clip();
        ctx.drawImage(
          area.image,
          area.image.xPos + area.image.xTranslate,
          area.image.yPos + area.image.yTranslate,
          area.image.width,
          area.image.height
        );
        ctx.restore();

        if (i === editingIndex) {
          ctx.strokeStyle = options.strokeStyle;
          ctx.lineWidth = options.strokeWidth;
          ctx.setLineDash([options.strokeDash, options.strokeDash]);
          if (options.showAlignmentLines) {
            ctx.moveTo(area.xPos + area.width, area.yPos + area.height / 2);
            ctx.lineTo(area.xPos, area.yPos + area.height / 2);

            ctx.moveTo(area.xPos + area.width / 2, area.yPos);
            ctx.lineTo(area.xPos + area.width / 2, area.height + area.yPos);
          }
          ctx.stroke();
        }

        console.table({
          imageWidth: area.image.width,
          areaIndex: i,
          d: area.image.width * area.image.scale,
          s: area.image.scale,
          xRelativePos: area.image.xRelativePos,
          yRelativePos: area.image.yRelativePos
        });
      }

      if (activeMode === MODE.CHANGE_PREVIEW) {
        const isRectangle = previewData.image.width > previewData.image.height;
        ctx.globalAlpha = 0.8;
        ctx.drawImage(
          previewData.image,
          previewData.xPos,
          previewData.yPos,
          isRectangle ? 200 : 150,
          isRectangle ? 150 : 200
        );
        ctx.globalAlpha = 1;
      }
    };

    const getAreaIndexByPosition = (e) =>
      areas.findIndex(
        (area) =>
          e.offsetX >= area.xPos &&
          e.offsetX <= area.xPos + area.width &&
          e.offsetY >= area.yPos &&
          e.offsetY <= area.yPos + area.height
      );

    const changeImage = () => {
      const fromAreaId = previewData.fromAreaId;
      const toAreaId = previewData.toAreaId;
      previewData = {};

      activeMode = MODE.SELECTION;
      editingIndex = toAreaId;

      const fromArea = { ...areas[fromAreaId] };
      const toArea = { ...areas[toAreaId] };

      fromArea.image.xTranslate = 0;
      fromArea.image.yTranslate = 0;

      toArea.image.xTranslate = 0;
      toArea.image.yTranslate = 0;

      fromArea.image.xPos =
        toArea.xPos - (fromArea.image.width - toArea.width) / 2;
      fromArea.image.yPos =
        toArea.yPos - (fromArea.image.height - toArea.height) / 2;

      areas[fromAreaId] = {
        ...fromArea,
        image: toArea.image
      };

      toArea.image.xPos =
        fromArea.xPos - (toArea.image.width - fromArea.width) / 2;
      toArea.image.yPos =
        fromArea.yPos - (toArea.image.height - fromArea.height) / 2;

      areas[toAreaId] = {
        ...toArea,
        image: fromArea.image
      };

      drawArea();
    };

    const changePreview = (fromAreaId, toAreaId, e) => {
      activeMode = MODE.CHANGE_PREVIEW;
      const fromArea = areas[fromAreaId];

      if (fromArea) {
        previewData.image = fromArea.image;
        previewData.xPos = e.offsetX;
        previewData.yPos = e.offsetY;
        previewData.fromAreaId = fromAreaId;
        previewData.toAreaId = toAreaId;
        drawArea();
      }
    };

    const moveImage = (e) => {
      const editingArea = areas[editingIndex];
      const overOtherImageIndex = getAreaIndexByPosition(e);

      if (overOtherImageIndex > -1 && overOtherImageIndex !== editingIndex) {
        startX = -e.offsetX;
        startY = -e.offsetY;
        changePreview(editingIndex, overOtherImageIndex, e);
      } else {
        activeMode = MODE.TRANSLATE;
        previewData = {};
        $canvas.css("cursor", "move");
        editingArea.image.xTranslate = startX + e.offsetX;
        editingArea.image.yTranslate = startY + e.offsetY;

        editingArea.image.xRelativePos = parseInt(
          editingArea.image.xPos +
            editingArea.image.xTranslate -
            editingArea.xPos
        );

        editingArea.image.yRelativePos = parseInt(
          editingArea.image.yPos +
            editingArea.image.yTranslate -
            editingArea.yPos
        );

        drawArea();
      }
    };

    const selectMode = () => {
      activeMode = MODE.SELECTION;
      $canvas.css("cursor", "auto");
      drawArea();
      options.onSelected?.(areas[editingIndex]);
    };

    const handleMouseDown = (e) => {
      e.preventDefault();
      const clickedAreaIndex = getAreaIndexByPosition(e);

      if (activeMode === MODE.TRANSLATE || activeMode === MODE.CHANGE) {
        selectMode();
      } else if (clickedAreaIndex > -1) {
        options.onSelected?.(areas[clickedAreaIndex]);
        editingIndex = clickedAreaIndex;
        const editingArea = areas[editingIndex];
        startX = editingArea.image.xTranslate - e.offsetX;
        startY = editingArea.image.yTranslate - e.offsetY;
        activeMode = MODE.TRANSLATE;
      }
    };

    const handleMouseUp = (e) => {
      e.preventDefault();
      activeMode === MODE.CHANGE_PREVIEW ? changeImage() : selectMode();
    };

    const handleMouseMove = (e) => {
      if (editingIndex < 0 || !activeMode) return;
      e.preventDefault();
      if (
        activeMode === MODE.TRANSLATE ||
        activeMode === MODE.CHANGE ||
        activeMode === MODE.CHANGE_PREVIEW
      )
        moveImage(e);
    };

    $canvas.mousedown(handleMouseDown);
    $canvas.mousemove(handleMouseMove);
    $canvas.mouseup(handleMouseUp);

    const touchEvent = (e) => {
      const r = $canvas[0].getBoundingClientRect();
      const touch =
        e.originalEvent.touches[0] || e.originalEvent.changedTouches[0];
      e.offsetX = touch.clientX - r.left;
      e.offsetY = touch.clientY - r.top;

      return e;
    };

    $canvas.on("touchstart", (e) => handleMouseDown(touchEvent(e)));
    $canvas.on("touchmove", (e) => handleMouseMove(touchEvent(e)));
    $canvas.on("touchend", (e) => handleMouseUp(touchEvent(e)));

    const changeScale = (val) => {
      const editingArea = areas[editingIndex];
      if (editingArea) {
        editingArea.image.scale = Number(val);
        drawArea();
      }
      return $canvas;
    };

    const changeRotate = (val) => {
      const editingArea = areas[editingIndex];
      if (editingArea) {
        editingArea.image.rotate = Number(val);
        drawArea();
      }
      return $canvas;
    };

    const createPhoto = (type) => $canvas[0].toDataURL(type || "image/png");

    const setDefault = () => {
      editingIndex = -1;
      activeMode = null;
      $canvas.css("cursor", "auto");
      startX = 0;
      startY = 0;
      previewData = {};
      drawArea();
    };

    const init = async () => {
      const areasMapping = await Promise.all(options.images.map(initArea));
      areas.push(...areasMapping);
      drawArea();
      options.images.length === 1 && selectMode();
    };

    init();

    return {
      changeScale,
      changeRotate,
      createPhoto,
      setDefault
    };
  };
})(jQuery);

$(document).ready(function () {
  const $canvasTable = $("#canvas").canvasTable({
    width: 400,
    height: 600,
    gap: 10,
    strokeDash: 5,
    strokeWidth: 2,
    showAlignmentLines: true,
    // css grid template areas
    templateAreas: [
      "photo1 photo1 photo1",
      "photo2 photo3 photo4",
      "photo5 photo5 photo5"
    ],
    images: [
      {
        area: "photo1",
        source:
          "https://images.unsplash.com/photo-1589347528952-62cf2bd93f18?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=400&fit=max&ixid=eyJhcHBfaWQiOjE0NTg5fQ"
      },
      {
        area: "photo2",
        source:
          "https://images.unsplash.com/photo-1587928901212-e90704d83380?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=400&fit=max&ixid=eyJhcHBfaWQiOjE0NTg5fQ"
      },
      {
        area: "photo3",
        source:
          "https://images.unsplash.com/photo-1588231793676-7bf33c466184?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=400&fit=max&ixid=eyJhcHBfaWQiOjE0NTg5fQ"
      },
      {
        area: "photo4",
        source:
          "https://images.unsplash.com/photo-1586854146097-6d9fb7b031fc?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=400&fit=max&ixid=eyJhcHBfaWQiOjE0NTg5fQ"
      },
      {
        area: "photo5",
        source:
          "https://images.unsplash.com/photo-1587467238707-0f9b326d56bd?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=400&fit=max&ixid=eyJhcHBfaWQiOjE0NTg5fQ"
      }
    ],
    onSelected(selectedArea) {
      $("#scaleRange").attr("disabled", false);
      $("#rotateRange").attr("disabled", false);
      $("#scaleRange").val(selectedArea.image.scale);
      $("#rotateRange").val(selectedArea.image.rotate);
    }
  });

  $("#scaleRange").on("input", (event) => {
    $canvasTable.changeScale(event.target.value);
  });

  $("#rotateRange").on("input", (event) => {
    $canvasTable.changeRotate(event.target.value);
  });

  $("#createButton").click(() => {
    $canvasTable.setDefault();
    const canvasSource = $canvasTable.createPhoto();
    const canvasImage = new Image();
    canvasImage.src = canvasSource;
    canvasImage.onload = () => {
      const previewWindow = window.open(
        "",
        "_blank",
        `width=${canvasImage.width}px,height=${canvasImage.height}px`
      );
      previewWindow.document.title = "preview table";
      previewWindow.document.write(canvasImage.outerHTML);
    };
  });
});
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js