Baker companion

Small tool with simple editing to create and download a collage with the images of a 4chan thread.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        Baker companion
// @namespace   Violentmonkey Scripts
// @match       https://boards.4channel.org/*/thread/*
// @match       https://boards.4chan.org/*/thread/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_listValues
// @grant GM_deleteValues
// @version     0.4.3
// @author      BakerCompanion
// @license     MIT
// @description Small tool with simple editing to create and download a collage with the images of a 4chan thread.
// ==/UserScript==

window.addEventListener('load', injectOven);

const MouseBtn = {
  Primary: 0,
  Secondary: 1,
  Aux: 2,
  Back: 3,
  Forward: 4,
};

const DEFAULT_CANVAS_SIZE = 4000;
/** @type {BCStore.config} */
const DEFAULT_CONFIG = {
  canvasWidth: DEFAULT_CANVAS_SIZE,
  canvasHeight: DEFAULT_CANVAS_SIZE,
  bgColor: '#000',
};
const IMAGE_EXTENSIONS = ['.png', '.jpg'];

const BOLDNESS = [400, 700, 900];
let ZOOM_LEVELS;
const FRAME_TIME = 16; //60 fps
const HOVER_DELAY = 200;

const MIN_IMAGE_SIZE = 50;
const IMG_GAP = 4;

const trashSet = new Set();

/** @type {BCStore.config} */
let bcConfig;

let thread;
let isOpen = false;
/** @type {CanvasSet} */
let imgSet;

let imageContainer;
let trashContainer;
let modalOverlay;
let zoomReading;

let offCanvas;
let offCtx;

/** @type {HTMLCanvasElement} */
let renderCanvas;
/** @type {CanvasRenderingContext2D} */
let renderCtx;
/** @type {HTMLCanvasElement} */
let mainCanvas;
/** @type {CanvasRenderingContext2D} */
let mainCtx;
/** @type {HTMLCanvasElement} */
let moveCanvas;
/** @type {CanvasRenderingContext2D} */
let moveCtx;

let resizeDebounce;

let extraImg = [];

class CanvasSet {
  images = new Set();
  texts = new Set();
  mask;
  maskLocked = false;

  add(ci) {
    if (ci !== this.mask) {
      return ci.text ? this.texts.add(ci) : this.images.add(ci);
    }
  }

  delete(ci) {
    if (ci === this.mask) {
      this.removeMask();
      return;
    }

    return ci.text ? this.texts.delete(ci) : this.images.delete(ci);
  }

  hide(ci) {
    ci.hidden = true;
  }

  unhide(ci, bringToTop = false) {
    ci.hidden = false;

    if (ci === this.mask) {
      return;
    }

    if (bringToTop) {
      this.delete(ci);
      this.add(ci);
    }
  }

  clear() {
    this.images.clear();
    this.texts.clear();
    this.removeMask();
  }

  /** @returns {CanvasImage[]} */
  values(options = { targetableOnly: false, clip: false }) {
    const items = [];

    if (!options.targetableOnly || !this.mask || this.maskLocked) {
      items.push(...this.images.values());
    }

    if (this.mask && (!options.targetableOnly || !this.maskLocked)) {
      items.push(this.mask);
    }

    const notHidden = [...items, ...this.texts.values()].filter((ci) => ci && !ci.hidden);

    if (options.clip) {
      return notHidden.filter(
        (img) =>
          img === this.mask ||
          (img.right >= 0 && img.trOrigin.x < mainCanvas.width && img.bottom >= 0 && img.trOrigin.y < mainCanvas.height)
      );
    } else {
      return notHidden;
    }
  }

  /** @param {(c: CanvasImage) => {}} fn */
  forEach(fn) {
    return this.values().forEach(fn);
  }

  setMask(ci) {
    if (this.mask) {
      if (this.mask === ci) {
        return;
      } else {
        this.removeMask(true);
      }
    }

    this.texts.delete(ci);
    this.mask = ci;
    ci.mask = true;
  }

  removeMask(keepLock = false) {
    this.maskLocked = keepLock && this.maskLocked;

    if (this.mask) {
      this.mask.mask = false;
      this.mask = null;
    }
  }

  toggleMaskLock(force) {
    this.maskLocked = force ?? !this.maskLocked;
  }

  /**
   * @param {DOMPoint | MouseEvent} coords
   * @returns {CanvasImage}
   */
  findtarget(coords) {
    const point = coords instanceof DOMPoint ? coords : new DOMPoint(coords.offsetX, coords.offsetY);

    return imgSet
      .values({ targetableOnly: true })
      .reverse()
      .find((img) => Space.intersects(point, img));
  }

  reload(fn) {
    // TODO
    let c = 0;
    this.images.forEach((i) => {
      const url = i.img.src;
      i.img.src = null;
      i.img.onload = () => {
        i.img.onload = null;

        c++;
        if (c === this.images.size) {
          fn();
        }
      };
      i.img.src = url;
    });
  }
}

class FrameState {
  static cursor = new DOMPoint();
  /** @type CanvasImage */
  static lastTarget;
  static lastFrame = 0;
  static doRender = false;
  /** @type CanvasImage */
  static draggingImg;
  static panning = false;

  static baseTransform = new DOMMatrix();

  static zoomLevel = 0;
  static zoomX = 0;
  static zoomY = 0;
  static zoomMatrix = new DOMMatrix();

  static reset() {
    this.cursor = new DOMPoint();
    this.lastTarget = undefined;
    this.lastFrame = 0;
    this.doRender = false;
    this.draggingImg = undefined;
    this.panning = false;

    this.baseTransform = new DOMMatrix();

    this.zoomLevel = 1;
    this.zoomX = 0;
    this.zoomY = 0;
    this.zoomMatrix = new DOMMatrix();
  }
}

function injectOven() {
  const ovenDoor = `[<a class="bc-door" href="#" title="Preheat">爐</a>]`;
  document.getElementById('navtopright').insertAdjacentHTML('afterbegin', ovenDoor);
  document.getElementById('navbotright').insertAdjacentHTML('afterbegin', ovenDoor);

  document.querySelectorAll('.bc-door').forEach((d) => d.addEventListener('click', openOven));

  bcConfig = GM_getValue('config', DEFAULT_CONFIG);
  validateConfig();
  imgSet = new CanvasSet();
}

/** @param {MouseEvent} me */
async function openOven(me) {
  if (me.button !== MouseBtn.Primary) {
    return;
  }

  me.preventDefault();

  if (isOpen) {
    return;
  }

  isOpen = true;

  const board = window.location.href.match(/boards.4chan.org\/(\w*)/)[1];
  thread = window.location.href.match(/thread\/(\d+)/)[1];

  await fetch(`https://a.4cdn.org/${board}/thread/${thread}.json`)
    .then((r) => r.json())
    .then((thread) => {
      const images = thread.posts
        .filter((p, i) => i > 0 && IMAGE_EXTENSIONS.includes(p.ext))
        .map((p) => `https://i.4cdn.org/${board}/${p.tim}${p.ext}`);

      preheat(images);
    });
}

/** @param {MouseEvent} me */
function closeOven(me) {
  if (me.button !== MouseBtn.Primary) {
    return;
  }

  me.stopPropagation();
  me.preventDefault();

  document.getElementById('bc-overlay').remove();
  document.body.style.overflow = null;
  isOpen = false;

  FrameState.reset();
  imgSet.clear();

  extraImg.forEach((i) => URL.revokeObjectURL(i));
  extraImg = [];

  document.body.removeEventListener('keydown', bodyKeyDown);
  document.body.removeEventListener('keyup', bodyKeyUp);
  window.removeEventListener('blur', stopPanning);
  window.removeEventListener('focus', stopPanning);
}

function preheat(images) {
  document.body.insertAdjacentHTML(
    'beforeend',
    `<div id="bc-overlay">
      <div id="bc-main-dialog" class="reply highlight">
        <div id="bc-header">
          <div>[<a id="bc-help" title="Halp">?</a>]</div>&nbsp;
          <div>[<a id="bc-close" title="Close">x</a>]</div>
        </div>

        <div id="bc-workspace">
          <div id="bc-toolbar">
            <div class="bc-relative">
              [<a id="bc-canvas-size" title="Canvas size" class="bc-tool">S</a>]

              <div id="bc-size-dialog" class="reply">
                <input id="bc-width-input" type="number" step="1" value="${bcConfig.canvasWidth}"/>x
                <input id="bc-height-input" type="number" step="1" value="${bcConfig.canvasHeight}"/>
                <div>[<a id="bc-set-size" title="Apply">Set</a>]</div>
                <div>[<a id="bc-close-size" title="Cancel">x</a>]</div>
              </div>
            </div>

            <div class="bc-relative">
              [<a id="bc-background-color" title="Background color" class="bc-tool">B</a>]
              <input id="bc-background-color-input" type="color" value="${bcConfig.bgColor}">
            </div>

            <div>[<a id="bc-add-text" title="Add text" class="bc-tool">T</a>]</div>

            <div>[<a id="bc-lock-mask" title="Lock mask" class="bc-tool">M</a>]</div>

            <div>[<a id="bc-add-img" title="Add image" class="bc-tool">I</a>]</div>

            <div class="bc-m-top-auto">[<a id="bc-reload" title="Reload" class="bc-tool">↺</a>]</div>
          </div>

          <div id="bc-canvas-col">
            <canvas id="bc-canvas-render" width="${bcConfig.canvasWidth}" height="${bcConfig.canvasHeight}"></canvas>
            <canvas id="bc-canvas" class="bc-stacked bc-transparent-bg"></canvas>
            <canvas id="bc-canvas-move" class="bc-stacked"></canvas>
          </div>

          <div id="bc-trash-container">
            <div>Removed images</div>
            <div id="bc-trash"></div>
          </div>
        </div>


        <div id="bc-footer">
          <div id="bc-zoom-controls">
            <div>[<a id="bc-zoom-out" title="Zoom out" class="bc-tool">&minus;</a>]</div>
            <div id="bc-zoom"></div>
            <div >[<a id="bc-zoom-in" title="Zoom in" class="bc-tool">&plus;</a>]</div>
          </div>

          <div>[<a id="bc-download" title="Bake">Download</a>]</div>
        </div>
      </div>

      <div id="bc-modal-overlay" hidden></div>
    </div>`
  );

  const globalStyle = document.createElement('style');
  globalStyle.textContent = `
  <style>
    .bc-bold {
      font-weight: bold;
    }

    .bc-center {
      text-align: center;
    }

    .bc-relative {
      position: relative;
    }

    .bc-m-top-auto {
      margin-top: auto;
    }

    #bc-overlay {
      position: fixed;
      top: 0;
      left: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      width: 100vw;
      height: 100vh;
    }

    #bc-overlay * {
      box-sizing: border-box;
      user-select: none
    }

    #bc-overlay a {
      cursor: pointer;
    }

    #bc-overlay .deadlink {
      text-decoration: none !important;
    }

    #bc-main-dialog {
      display: flex;
      flex-direction: column;
      gap: 10px;
      max-width: 160vh;
      max-height: 98vh;
      padding: 8px;

      margin: 1vh;
    }

    #bc-header {
      display: flex;
      justify-content: flex-end;
      margin-bottom: 3px;
    }

    #bc-workspace {
      display: flex;
      overflow: hidden;

      gap: 10px;
    }

    #bc-canvas-col {
      position: relative;
      display: flex;
      flex-direction: column;
      align-items: end;
      justify-content: center;
    }

    #bc-canvas-render {
      display: block;
      max-height: 88vh;
      max-width: 100%;
    }

    .bc-stacked {
      position: absolute;
      top: 0;
      left: 0;
    }

    #bc-canvas-render {
      visibility: hidden;
    }

    #bc-trash-container {
      display: flex;
      flex-direction: column;
      gap: 5px;
      min-width: 205px;
      max-width: 205px;
      max-height: 88vh;
    }

    #bc-trash {
      display: flex;
      flex-wrap: wrap;
      align-items: start;
      gap: 5px;
      overflow-y: auto;
      max-height: 100%;
    }

    #bc-trash .bc-trashed {
      position: relative;
      display: flex;
      align-items: center;
      justify-content: center;
      max-width: 100px;
      max-height: 100px;

      overflow: hidden;
    }

    #bc-trash .bc-trashed:hover::before {
      content: '↤';
      position: absolute;
      display: flex;
      align-items: center;
      justify-content: center;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      background: #00000055;

      font-size: 18px;
      text-shadow: 0 1px white, 1px 0 white, 0 -1px white, -1px 0 white,
                    1px 1px white, 1px -1px white, -1px 1px white, -1px -1px white;
      cursor: pointer;
    }

    .bc-trashed img {
      object-fit: contain;
      max-width: 100px;
      max-height: 100px;
    }

    .bc-trashed .bc-control-restore {
      display: flex;
      align-items: center;
      justify-content: center;
      opacity: 1;
      pointer-events: unset;
    }

    #bc-footer {
      display: flex;
    }

    #bc-toolbar {
      display: flex;
      flex-direction: column;
      width: 30px;
      align-items: center;
      gap: 5px;
    }

    a.bc-tool {
      font-family: serif;
      font-size: 16px;
      font-weight: bold;
    }

    #bc-background-color-input {
      position: absolute;
      border: none;
      padding: 0;
      margin: 0;
      width: 0;
      height: 0;
      outline: none;
    }

    #bc-modal-overlay {
      position: fixed;
      top: 0;
      left: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      width: 100%;
      height: 100%;

      visibility: hidden;
    }

    #bc-size-dialog {
      position: absolute;
      left: 26px;
      top: 2px;
      width: 200px;
      display: none;
      align-items: center;
      gap: 5px;

      z-index: 1000;
    }

    #bc-size-dialog.bc-visible {
      display: flex;
    }

    #bc-width-input, #bc-height-input {
      min-width: 1px;
    }

    .bc-transparent-bg {
      background: repeating-conic-gradient(#808080 0 25%, #0002 0 50%) 50% / 20px 20px
    }

    #bc-zoom-controls {
      display: flex;
      align-items: center;
      margin: 0 auto 0 40px;
    }


    #bc-zoom-controls div {
      display: flex;
      align-items: center;
      justify-content: center;
    }

    #bc-zoom {
      width: 70px;
    }
  </style>
  `;

  document.getElementById('bc-overlay').append(globalStyle);

  setup();
  loadImages(images);
}

function setup() {
  imageContainer = document.getElementById('bc-image-container');

  window.addEventListener('resize', resizeWindow);

  offCanvas = new OffscreenCanvas(bcConfig.canvasWidth, bcConfig.canvasHeight);
  offCtx = offCanvas.getContext('2d');

  renderCanvas = document.getElementById('bc-canvas-render');
  renderCtx = renderCanvas.getContext('2d');

  mainCanvas = document.getElementById('bc-canvas');
  mainCtx = mainCanvas.getContext('2d');
  moveCanvas = document.getElementById('bc-canvas-move');
  moveCtx = moveCanvas.getContext('2d');

  zoomReading = document.getElementById('bc-zoom');

  setCanvasSize();

  trashContainer = document.getElementById('bc-trash');

  document.body.style.overflow = 'hidden';
  document.getElementById('bc-help').addEventListener('click', openHelp);
  document.getElementById('bc-close').addEventListener('click', closeOven);
  document.getElementById('bc-download').addEventListener('click', downloadFile);

  moveCanvas.addEventListener('mousemove', canvasMove);
  moveCanvas.addEventListener('mouseover', startRendering);
  moveCanvas.addEventListener('mouseout', stopRendering);
  moveCanvas.addEventListener('click', canvasClick);
  moveCanvas.addEventListener('mousedown', startDrag);
  moveCanvas.addEventListener('wheel', canvasWheel);

  document.body.addEventListener('keydown', bodyKeyDown);
  document.body.addEventListener('keyup', bodyKeyUp);
  window.addEventListener('blur', stopPanning);
  window.addEventListener('focus', stopPanning);

  document.getElementById('bc-zoom-out').addEventListener('click', (me) => nextZoom(me, false));
  document.getElementById('bc-zoom-in').addEventListener('click', (me) => nextZoom(me, true));

  modalOverlay = document.getElementById('bc-modal-overlay');

  // ==== Tools ====
  document.getElementById('bc-canvas-size').addEventListener('click', toggleSizeInput);
  const widthInput = document.getElementById('bc-width-input');
  const heightInput = document.getElementById('bc-height-input');
  document
    .getElementById('bc-set-size')
    .addEventListener('click', () => changeSize(widthInput.value, heightInput.value));
  document.getElementById('bc-close-size').addEventListener('click', toggleSizeInput);

  document.getElementById('bc-add-text').addEventListener('click', openTextModal);

  const bgColorInput = document.getElementById('bc-background-color-input');
  bgColorInput.addEventListener('input', () => changeBackgroundColor(bgColorInput.value));
  document.getElementById('bc-background-color').addEventListener('click', () => bgColorInput.showPicker());

  document.getElementById('bc-lock-mask').addEventListener('click', () => toggleMaskLock());
  document.getElementById('bc-add-img').addEventListener('click', openImageModal);

  document.getElementById('bc-reload').addEventListener('click', () => imgSet.reload(reDraw));
}

function loadImages(images) {
  mainCtx.fillStyle = bcConfig.bgColor;
  mainCtx.fillRect(0, 0, mainCanvas.width, mainCanvas.height);

  const rows = Math.ceil(Math.sqrt(images.length));
  images.forEach((url, index) => {
    const img = new Image();
    img.onload = () => {
      const canvasImage = new CanvasImage(img);
      imgSet.add(canvasImage);
      positionImageInitial(canvasImage, index, rows);
      img.onload = null;
    };
    img.onerror = (e) => {
      img.crossOrigin = "anonymous";
      img.onError = null;
      img.src = url;
    }
    img.src = url;
  });
}

function resizeWindow() {
  clearTimeout(resizeDebounce);

  resizeDebounce = setTimeout(resizeCanvas, 100);
}

function resizeCanvas() {
  setCanvasSize();
  imgSet.forEach((img) => {
    img.setTransform(FrameState.baseTransform.multiply(FrameState.zoomMatrix));
    img.keepInBounds(renderCanvas, 250);
  });
  reDraw();
}

function setCanvasSize() {
  [mainCanvas, moveCanvas, offCanvas].forEach((c) => {
    c.width = renderCanvas.offsetWidth;
    c.height = renderCanvas.offsetHeight;

    if (c.style) {
      c.style.minWidth = `${mainCanvas.width}px`;
      c.style.minHeight = `${mainCanvas.height}px`;
    }
  });

  FrameState.baseTransform = new DOMMatrix().scale(
    mainCanvas.width / renderCanvas.width,
    mainCanvas.height / renderCanvas.height
  );

  if (FrameState.baseTransform.a < 1) {
    const step = 1 / FrameState.baseTransform.a / 3;
    ZOOM_LEVELS = [1].concat(
      Array(3)
        .fill(step)
        .map((s, i) => s * (i + 1))
    );
    ZOOM_LEVELS = ZOOM_LEVELS.concat(
      Array(5)
        .fill(ZOOM_LEVELS.at(-1))
        .map((z, i) => z * (i + 2))
    );
  } else {
    ZOOM_LEVELS = [1, 2, 3, 4];
  }

  updateZoomReading();
}

function positionImageInitial(canvasImage, index, rows, draw = true) {
  const availableSize = Math.min(renderCanvas.width, renderCanvas.height) / rows;

  canvasImage.ratio = canvasImage.img.naturalWidth / canvasImage.img.naturalHeight;

  const scale =
    canvasImage.ratio > 1
      ? (availableSize - IMG_GAP * 2) / canvasImage.img.naturalWidth
      : (availableSize - IMG_GAP * 2) / canvasImage.img.naturalHeight;

  canvasImage.resize(Math.max(Math.round(canvasImage.img.naturalWidth * scale), MIN_IMAGE_SIZE));
  canvasImage.translate(
    Math.round(availableSize) * (index % rows) + IMG_GAP,
    Math.round(availableSize) * Math.floor(index / rows) + IMG_GAP
  );
  canvasImage.setTransform(FrameState.baseTransform);

  if (draw) {
    drawImage(canvasImage);
  }
}

function startRendering() {
  FrameState.doRender = true;
  requestAnimationFrame(drawFrame);
}

function stopRendering() {
  clearCanvas(moveCtx);
  FrameState.doRender = false;
}

function drawFrame(timestamp) {
  if (!FrameState.doRender) {
    return;
  }

  if (timestamp - FrameState.lastFrame < FRAME_TIME) {
    requestAnimationFrame(drawFrame);
    return;
  }

  const onTarget = imgSet.findtarget(FrameState.cursor);
  if (FrameState.lastTarget !== onTarget) {
    if (FrameState.lastTarget) {
      FrameState.lastTarget.stopHover();
      moveCanvas.style.cursor = null;

      clearCanvas(moveCtx);
    }

    if (onTarget) {
      onTarget.hoveredSince = timestamp;
    }
  } else if (onTarget?.hoveredSince && !onTarget.hoverDrawn && timestamp - onTarget.hoveredSince > HOVER_DELAY) {
    onTarget.hoverDrawn = true;
    drawHover(onTarget);
  }

  moveCanvas.style.cursor = onTarget?.computeHoverState(FrameState.cursor)?.cursor ?? null;

  FrameState.lastTarget = onTarget;
  FrameState.lastFrame = timestamp;
  requestAnimationFrame(drawFrame);
}

/**
 * @param {CanvasText | CanvasImage} ci
 * @param {CanvasRenderingContext2D} ctx
 * @param {boolean} fullRes
 */
function drawImage(ci, ctx = mainCtx, fullRes = false) {
  ctx.save();

  const imgRect = ci.drawRect(fullRes);
  if (ci instanceof CanvasText) {
    if (ci.mask) {
      const drawBoundary = imgSet.maskLocked || FrameState.draggingImg === ci;

      offCtx.canvas.width = ctx.canvas.width;
      offCtx.canvas.height = ctx.canvas.height;
      offCtx.fillStyle = bcConfig.bgColor;
      offCtx.globalAlpha = drawBoundary ? 0.7 : 1;
      offCtx.fillRect(0, 0, offCanvas.width, offCanvas.height);
      offCtx.globalAlpha = 1;

      offCtx.font = ci.drawFont(fullRes);
      offCtx.textBaseline = 'top';

      applyRotation(ci, offCtx, fullRes);

      if (drawBoundary) {
        offCtx.lineWidth = 2;
        offCtx.setLineDash([6, 6]);
        offCtx.lineDashOffset = 0;
        offCtx.strokeStyle = '#000000cc';
        offCtx.strokeText(ci.text, imgRect.x, imgRect.y);

        offCtx.strokeStyle = '#ffffffcc';
        offCtx.lineDashOffset = 6;
        offCtx.strokeText(ci.text, imgRect.x, imgRect.y);
      }

      offCtx.globalCompositeOperation = 'destination-out';
      offCtx.fillStyle = '#000';
      offCtx.fillText(ci.text, imgRect.x, imgRect.y);

      ctx.drawImage(offCanvas, 0, 0);

      offCtx.reset();
    } else {
      ctx.font = ci.drawFont(fullRes);
      ctx.fillStyle = ci.color;
      ctx.textBaseline = 'top';

      applyRotation(ci, ctx, fullRes);

      if (ci.trStrokeSize) {
        ctx.lineWidth = ci.drawStroke(fullRes) * 2;
        ctx.strokeStyle = ci.strokeColor;
        ctx.miterLimit = ci.miter;
        ctx.lineJoin = ci.lineJoin;
        ctx.strokeText(ci.text, imgRect.x, imgRect.y);
      }

      ctx.fillText(ci.text, imgRect.x, imgRect.y);
    }
  } else {
    applyRotation(ci, ctx, fullRes);
    ctx.drawImage(ci.img, imgRect.x, imgRect.y, imgRect.width, imgRect.height);
  }

  ctx.restore();
}

function applyRotation(ci, ctx, fullRes = false) {
  const imgRect = ci.drawRect(fullRes);

  if (ci.rotation !== 0) {
    const xOffset = ci.rotation >= 180 ? imgRect.width : 0;
    const yOffset = ci.rotation <= 180 ? imgRect.height : 0;

    ctx.translate(imgRect.x, imgRect.y);
    ctx.rotate((Math.PI * ci.rotation) / 180);
    ctx.translate(-imgRect.x - xOffset, -imgRect.y - yOffset);
  }
}

function applyZoom(ctx) {
  if (FrameState.zoomLevel > 1) {
    ctx.setTransform(FrameState.zoomMatrix);
  }
  // TODO add zoom indicator
}

function reSampleImages(oldBounds, newBounds) {
  imgSet.forEach((ci) => ci.resample(oldBounds, newBounds));
}

/** @param {CanvasImage} ci */
function drawHover(ci) {
  clearCanvas(moveCtx);

  drawSelectionBox(ci);
  drawHoverButtons(ci);
  drawScaleGizmo(ci);
}

function drawSelectionBox(ci) {
  moveCtx.save();

  moveCtx.lineWidth = 1;
  moveCtx.setLineDash([6, 6]);
  moveCtx.lineDashOffset = 0;
  moveCtx.strokeStyle = '#000000cc';

  const pxlOffset = 0.5;
  const x = Math.floor(ci.x) + pxlOffset;
  const y = Math.floor(ci.y) + pxlOffset;
  const w = Math.floor(ci.x + ci.width) - Math.floor(ci.x);
  const h = Math.floor(ci.y + ci.height) - Math.floor(ci.y);
  moveCtx.strokeRect(x, y, w, h);

  moveCtx.lineDashOffset = 6;
  moveCtx.strokeStyle = '#ffffffcc';
  moveCtx.strokeRect(x, y, w, h);

  moveCtx.restore();
}

function drawHoverButtons(ci) {
  moveCtx.save();

  moveCtx.font = CanvasImage.FONT_STYLE;
  moveCtx.textBaseline = 'top';
  moveCtx.strokeStyle = 'black';
  moveCtx.miterLimit = 2;
  moveCtx.lineJoin = 'round';
  moveCtx.lineWidth = 4;
  moveCtx.fillStyle = 'white';

  const rotateBtnPos = ci.rotateBtnDrawPoint();
  moveCtx.strokeText('↻', rotateBtnPos.x, rotateBtnPos.y);
  moveCtx.fillText('↻', rotateBtnPos.x, rotateBtnPos.y);

  const trashBtnPos = ci.trashBtnDrawPoint();
  moveCtx.strokeText('×', trashBtnPos.x, trashBtnPos.y);
  moveCtx.fillText('×', trashBtnPos.x, trashBtnPos.y);

  moveCtx.restore();
}

function drawScaleGizmo(ci) {
  moveCtx.save();

  const gizmoSize = 8;
  moveCtx.strokeStyle = 'black';
  moveCtx.lineWidth = 6;
  moveCtx.lineJoin = 'miter';
  moveCtx.miterLimit = 10;
  moveCtx.translate(-moveCtx.lineWidth / 2, -moveCtx.lineWidth / 2);
  moveCtx.beginPath();
  moveCtx.moveTo(ci.x + ci.width - gizmoSize, ci.y + ci.height);
  moveCtx.lineTo(ci.x + ci.width, ci.y + ci.height);
  moveCtx.lineTo(ci.x + ci.width, ci.y + ci.height - gizmoSize);
  moveCtx.closePath();
  moveCtx.stroke();

  moveCtx.lineWidth = 2;
  moveCtx.strokeStyle = 'white';
  moveCtx.beginPath();
  moveCtx.moveTo(ci.x + ci.width - gizmoSize, ci.y + ci.height);
  moveCtx.lineTo(ci.x + ci.width, ci.y + ci.height);
  moveCtx.lineTo(ci.x + ci.width, ci.y + ci.height - gizmoSize);
  moveCtx.closePath();
  moveCtx.stroke();

  moveCtx.restore();
}

function clearCanvas(...args) {
  args.forEach((ctx) => ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height));
}

function reDraw() {
  clearCanvas(mainCtx);

  mainCtx.fillStyle = bcConfig.bgColor;
  mainCtx.save();
  mainCtx.setTransform(FrameState.baseTransform.multiply(FrameState.zoomMatrix));
  mainCtx.fillRect(0, 0, renderCanvas.width, renderCanvas.height);
  mainCtx.restore();

  imgSet.values({ clip: FrameState.zoomLevel !== 1 }).forEach((ci) => drawImage(ci));
}

/** @param {MouseEvent} me */
function canvasMove(me) {
  FrameState.cursor.x = me.offsetX;
  FrameState.cursor.y = me.offsetY;
}

/** @param {MouseEvent} me */
function canvasClick(me) {
  if (me.button !== MouseBtn.Primary) {
    return;
  }

  me.stopPropagation();
  me.preventDefault();

  if (FrameState.draggingImg || FrameState.panning) {
    return;
  }

  const target = imgSet.findtarget(me);
  if (!target) {
    return;
  }

  switch (target.hoverOn) {
    case 'rotate':
      target.rotate();
      drawHover(target);
      reDraw();
      break;
    case 'trash':
      trashImage(target);
      break;
  }
}

/** @param {CanvasImage} ci */
function trashImage(ci) {
  if (ci === imgSet.mask) {
    toggleMaskLock(false);
  }

  imgSet.delete(ci);
  reDraw();

  if (ci.text) {
    return;
  }

  trashSet.add(ci);

  const trashed = document.createElement('div');
  trashed.classList.add('bc-trashed');
  trashed.title = 'Restore';
  trashed.appendChild(ci.img);
  trashContainer.appendChild(trashed);

  trashed.addEventListener('click', (me) => restoreImage(me, trashed, ci));
}

/**
 * @param {MouseEvent} me
 * @param {HTMLElement} element
 * @param {CanvasImage} ci
 */
function restoreImage(me, element, ci) {
  me.stopPropagation();

  imgSet.add(ci);
  trashSet.delete(ci);

  element.remove();
  reDraw();
}

/** @param {MouseEvent} me */
function startDrag(me) {
  if (me.button !== MouseBtn.Primary) {
    return;
  }

  if (FrameState.panning) {
    me.stopPropagation();

    moveCanvas.style.cursor = 'grabbing';

    document.addEventListener('mousemove', pan);
    document.addEventListener('mouseup', panRelease);
    moveCanvas.removeEventListener('mouseover', startRendering);
    return;
  }

  const target = imgSet.findtarget(me);
  if (target && [null, 'scale'].includes(target.hoverOn)) {
    me.stopPropagation();

    stopRendering();
    FrameState.draggingImg = target;

    imgSet.hide(target);
    reDraw();
    clearCanvas(moveCtx);
    drawImage(target, moveCtx);
    drawSelectionBox(target);
    drawScaleGizmo(target);

    document.addEventListener('mousemove', dragImage);
    document.addEventListener('mouseup', stopDragImage);
    moveCanvas.removeEventListener('mouseover', startRendering);
  }
}

/** @param {MouseEvent} me */
function stopDragImage(me) {
  if (me.button !== MouseBtn.Primary) {
    return;
  }

  imgSet.unhide(FrameState.draggingImg, true);

  document.removeEventListener('mousemove', dragImage);
  document.removeEventListener('mouseup', stopDragImage);
  moveCanvas.addEventListener('mouseover', startRendering);

  FrameState.draggingImg.keepInBounds(renderCanvas);

  drawHover(FrameState.draggingImg);
  FrameState.draggingImg = null;
  reDraw();
  startRendering();
}

function dragImage(me) {
  me.stopPropagation();

  clearCanvas(moveCtx);

  if (FrameState.draggingImg.hoverOn === 'scale') {
    const dWidth = Math.abs(me.movementX) > Math.abs(me.movementY) ? me.movementX : me.movementY;

    if (FrameState.draggingImg.width + dWidth >= MIN_IMAGE_SIZE) {
      FrameState.draggingImg.resize(dWidth);
    }

    if (FrameState.draggingImg.text) {
      FrameState.draggingImg.saveBounds();
    }
  } else {
    FrameState.draggingImg.translate(me.movementX, me.movementY);
  }

  drawImage(FrameState.draggingImg, moveCtx);
  drawSelectionBox(FrameState.draggingImg);
  drawScaleGizmo(FrameState.draggingImg);
}

function canvasWheel(me) {
  me.stopPropagation();
  me.preventDefault();

  if (!me.ctrlKey) {
    return;
  }

  if (me.deltaY < 0) {
    nextZoom(me, true, me);
  } else {
    nextZoom(me, false, me);
  }
}

function changeZoom(newZoom, location) {
  if (FrameState.zoomLevel === newZoom) {
    return;
  }

  if (newZoom === 0) {
    FrameState.zoomMatrix = new DOMMatrix();
  } else {
    const tp = new DOMPointReadOnly(location.offsetX, location.offsetY).matrixTransform(
      FrameState.baseTransform.multiply(FrameState.zoomMatrix).inverse()
    );

    const zoomFactor = ZOOM_LEVELS[newZoom] / ZOOM_LEVELS[FrameState.zoomLevel];
    FrameState.zoomMatrix = FrameState.zoomMatrix
      .translate(tp.x, tp.y)
      .scale(zoomFactor, zoomFactor)
      .translate(-tp.x, -tp.y);
    clampPan();
  }

  FrameState.zoomLevel = newZoom;
  updateZoomReading();

  imgSet.forEach((img) => img.setTransform(FrameState.baseTransform.multiply(FrameState.zoomMatrix)));

  if (FrameState.lastTarget) {
    clearCanvas(moveCtx);
    drawHover(FrameState.lastTarget);
  }

  reDraw();
}

/** @param {MouseEvent} */
function nextZoom(me, zoomIn = true, at) {
  if (me && me.button !== MouseBtn.Primary) {
    return;
  }
  me?.stopPropagation();

  clearCanvas(moveCtx);
  const next = zoomIn
    ? Math.min(FrameState.zoomLevel + 1, ZOOM_LEVELS.length - 1)
    : Math.max(FrameState.zoomLevel - 1, 0);
  changeZoom(next, at ?? { offsetX: moveCanvas.width / 2, offsetY: moveCanvas.height / 2 });
}

function updateZoomReading() {
  zoomReading.textContent = (FrameState.baseTransform.multiply(FrameState.zoomMatrix).a * 100).toFixed(2) + '%';
}

/** @param {KeyboardEvent} */
function bodyKeyDown(ke) {
  switch (ke.key) {
    case ' ':
      ke.stopPropagation();

      FrameState.panning = true;
      stopRendering();
      clearCanvas(moveCtx);

      moveCanvas.style.cursor = 'grab';

      document.body.removeEventListener('keydown', bodyKeyDown);
      break;
  }
}

/** @param {KeyboardEvent} ke */
function bodyKeyUp(ke) {
  switch (ke.key) {
    case ' ':
      stopPanning(ke);
      break;
  }
}

/** @param {MouseEvent} me */
function pan(me) {
  if (me.button !== MouseBtn.Primary || FrameState.zoomLevel === 1) {
    return;
  }

  me.stopPropagation();

  const currentTransform = FrameState.baseTransform.multiply(FrameState.zoomMatrix);
  FrameState.zoomMatrix.translateSelf(me.movementX / currentTransform.a, me.movementY / currentTransform.d);
  clampPan();

  imgSet.forEach((img) => img.setTransform(FrameState.baseTransform.multiply(FrameState.zoomMatrix)));
  clearCanvas(mainCtx);
  reDraw();
}

function clampPan() {
  FrameState.zoomMatrix.e = Math.max(
    Math.min(0, FrameState.zoomMatrix.e),
    -renderCanvas.width * (FrameState.zoomMatrix.a - 1)
  );
  FrameState.zoomMatrix.f = Math.max(
    Math.min(0, FrameState.zoomMatrix.f),
    -renderCanvas.height * (FrameState.zoomMatrix.d - 1)
  );
}

/** @param {MouseEvent} me */
function panRelease(me) {
  if (me.button !== MouseBtn.Primary) {
    return;
  }

  me.stopPropagation();

  moveCanvas.style.cursor = 'grab';
  document.removeEventListener('mousemove', pan);
}

/** @param {KeyboardEvent} ev */
function stopPanning(ev) {
  if (ev instanceof KeyboardEvent) {
    ev.stopPropagation();
    ev.preventDefault();
  }

  if (!FrameState.panning) {
    return;
  }

  FrameState.panning = false;
  moveCanvas.style.cursor = null;

  startRendering();

  document.removeEventListener('mousemove', pan);
  document.removeEventListener('mouseup', panRelease);
  moveCanvas.addEventListener('mouseover', startRendering);
  document.body.addEventListener('keydown', bodyKeyDown);
}

function downloadFile() {
  stopRendering();

  renderCtx.fillStyle = bcConfig.bgColor;
  renderCtx.fillRect(0, 0, renderCanvas.width, renderCanvas.height);

  imgSet.forEach((ci) => drawImage(ci, renderCtx, true));

  renderCanvas.toBlob((blob) => {
    const link = document.createElement('a');
    link.href = URL.createObjectURL(blob);
    link.download = `${thread}_${Date.now()}.png`;
    link.click();
    URL.revokeObjectURL(link.href);

    renderCtx.reset();
  });
}

// ========== Toolbar ==========

/** @param {MouseInput} me */
function toggleSizeInput(me) {
  if (me && me.button !== MouseBtn.Primary) {
    return;
  }

  me?.stopPropagation();

  document.getElementById('bc-width-input').value = renderCanvas.width;
  document.getElementById('bc-height-input').value = renderCanvas.height;
  document.getElementById('bc-size-dialog').classList.toggle('bc-visible');
}

function changeSize(newWidth, newHeight) {
  if (Number.isNaN(Number.parseFloat(newWidth)) || Number.isNaN(Number.parseFloat(newHeight))) {
    return;
  }

  bcConfig.canvasWidth = renderCanvas.width = +newWidth;
  bcConfig.canvasHeight = renderCanvas.height = +newHeight;

  saveConfig();

  toggleSizeInput();
  resizeCanvas();
}

function changeBackgroundColor(color) {
  bcConfig.bgColor = color;

  saveConfig();

  reDraw();
}

function toggleMaskLock(force) {
  if (!imgSet.mask) {
    return;
  }

  imgSet.toggleMaskLock(force);
  reDraw();
  const btn = document.getElementById('bc-lock-mask');
  const isLocked = btn.classList.toggle('deadlink', force);

  if (isLocked) {
    btn.textContent = 'M';
    btn.title = 'Unlock mask';
  } else {
    btn.textContent = 'M';
    btn.title = 'Lock mask';
  }
}

/** @param {TextForm} form */
function addText(form) {
  if (!form.text) {
    return;
  }

  const ct = new CanvasText();
  const currentTransform = FrameState.baseTransform.multiply(FrameState.zoomMatrix);
  ct.translate(100, 100);
  ct.text = form.text;
  ct.fontSize = +form.size / currentTransform.a;
  ct.fontStyle = `${BOLDNESS[form.bold]} ${form.italic ? 'italic' : ''}`;
  ct.fontFamily = form.font || 'sans-serif';
  ct.strokeSize = form.stroke_size ? +form.stroke_size / currentTransform.a : null;
  ct.strokeColor = form.stroke_color;
  ct.lineJoin = form.line_join;
  ct.miterLimit = +form.miter;
  ct.color = form.color;
  ct.padding = 30;
  ct.setTransform(currentTransform);
  ct.saveBounds();

  if (form.mask) {
    imgSet.setMask(ct);
  } else {
    imgSet.add(ct);
  }

  closeModal();
  reDraw();
}

function createModal(title = '') {
  modalOverlay.insertAdjacentHTML(
    'beforeend',
    `
    <div id="bc-modal" class="reply">
      <div id="bc-modal-header">
        <div id="bc-modal-title">${title}</div>
        <div id="bc-close-modal" title="Close">[<a>x</a>]</div>
      </div>
    </div>
    <style>
      #bc-modal {
        display: flex;
        flex-direction: column;
        padding: 8px;

        gap: 8px;
      }

      #bc-modal-header, #bc-modal-footer {
        display: flex;
      }

      #bc-close-modal {
        margin-left: auto;
      }
    </style>
  `
  );
  modalOverlay.style.visibility = 'unset';

  modalOverlay.querySelector('#bc-close-modal a').addEventListener('click', closeModal);

  return modalOverlay.querySelector('#bc-modal');
}

/** @param {MouseEvent} me */
function openTextModal(me) {
  if (me.button !== MouseBtn.Primary) {
    return;
  }

  me.stopPropagation();

  const modal = createModal('Add text');
  modal.style.width = '620px';

  modal.insertAdjacentHTML(
    'beforeend',
    `
    <div class="bc-text-row">
      <input id="bc-text-input" type="text" placeholder="wip text" class="bc-input"/>

      <div id="bc-text-bold" title="Bold">
        [<a data-value="0">B</a>]
      </div>

      <div id="bc-text-italic" title="Italic">
        [<a><i style="font-family: serif"> I </i></a>]
      </div>
    </div>

    <div class="bc-text-row">
      <label>
        Font:
        <input id="bc-font-input" type="text" placeholder="sans-serif" class="bc-input"/>
      </label>

      <label>
        Size:
        <input id="bc-size-input" value="100" type="number" step="1" min="1" class="bc-input"/>
      </label>

      <label class="bc-text-color">
        Color:
        <input id="bc-color-input" value="#fff" type="color" class="bc-input">
      </label>

      <label class="bc-mask">
        Mask:
        <input id="bc-mask-input" type="checkbox" class="bc-input">
      </label>
    </div>

    <div class="bc-text-row">
      <label class="bc-text-stroke">
        Stroke:
        <input id="bc-stroke_size-input" type="number" value="0" step="1" class="bc-input">
        <input id="bc-stroke_color-input" value="#000" type="color" class="bc-input bc-hidden">
      </label>

      <label>
        Line cap:
        <select id="bc-line_join-input" value="miter" class="bc-input">
          <option>miter</option>
          <option>round</option>
          <option>bevel</option>
        </select>
      </label>

      <label>
        Miter limit:
        <input id="bc-miter-input" value="10" type="number" step="1" min="1" class="bc-input"/>
      </label>
    </div>

    <div id="bc-text-preview">
      <div>Preview</div>
      <canvas id="bc-text-preview-canvas" class="bc-transparent-bg"></canvas>
    </div>

    <div id="bc-modal-footer">
      <div id="bc-text-insert">[<a>Add</a>]</div>
    </div>

    <style>
      .bc-text-row {
        display: flex;
        align-items: center;
        gap: 8px;
      }

      .bc-text-row input[type="number"] {
        max-width: 10ch;
      }

      #bc-text-input {
        flex-grow: 1;
      }

      #bc-font-input {
        width: 130px;
      }

      #bc-text-insert {
        margin-left: auto;
      }

      #bc-text-bold {
        display: flex;
        align-items: center;
      }

      .bc-text-color {
        display: flex;
        align-items: center;
        gap: 5px;
      }

      .bc-text-stroke {
        display: flex;
        align-items: center;
        gap: 5px;
      }

      input[type="color"] {
        width: 32px;
        max-height: 20px;
        padding: 0;
      }

      #bc-text-preview {
        display: flex;
        flex-direction: column;
        align-items: flex-start;
        gap: 5px;

        overflow: hidden;
      }

      #bc-text-preview-canvas {
        max-height: 70vh;
      }

      #bc-text-preview:hover {
        overflow: visible;
      }
    </style>
  `
  );

  const boldBtn = modal.querySelector('#bc-text-bold a');
  const italicBtn = modal.querySelector('#bc-text-italic a');
  const preview = modal.querySelector('#bc-text-preview-canvas').getContext('2d');

  if (imgSet.mask) {
    modal.querySelector('.bc-mask').remove();
  }

  boldBtn.addEventListener('click', () => {
    boldBtn.dataset.value = (boldBtn.dataset.value + 1) % 3;
    boldBtn.style.fontWeight = BOLDNESS[boldBtn.dataset.value];

    if (boldBtn.dataset.value === 0) {
      boldBtn.classList.remove('deadlink');
    } else {
      boldBtn.classList.add('deadlink');
    }

    formValue.bold = boldBtn.dataset.value;
    updatePreview(preview, formValue);
  });

  italicBtn.addEventListener('click', () => {
    italicBtn.classList.toggle('deadlink');
    formValue.italic = italicBtn.classList.contains('deadlink');

    updatePreview(preview, formValue);
  });

  /** @type {TextForm} */
  const formValue = {
    italic: false,
    bold: boldBtn.dataset.value,
  };
  modal.querySelectorAll('.bc-input').forEach((i) => {
    formValue[i.id.slice(3, -6)] = i.type === 'checkbox' ? i.checked : i.value;

    i.addEventListener('input', () => {
      formValue[i.id.slice(3, -6)] = i.type === 'checkbox' ? i.checked : i.value;

      updatePreview(preview, formValue);
    });
  });

  modal.querySelector('#bc-text-insert a').addEventListener('click', () => addText(formValue));

  modal.querySelector('#bc-text-input').focus();
}

function updatePreview(ctx, form) {
  if (!form.text) {
    ctx.reset();
    return;
  }

  const font = `${BOLDNESS[form.bold]} ${form.italic ? 'italic ' : ''}${form.size}px ${form.font || 'sans-serif'}`;
  ctx.font = font;
  ctx.textBaseline = 'top';

  const bounds = ctx.measureText(form.text);
  const padding = 4 + form.stroke_size * 2;
  ctx.canvas.width = bounds.actualBoundingBoxRight - bounds.actualBoundingBoxLeft + padding * 2;
  ctx.canvas.height = bounds.actualBoundingBoxDescent - bounds.actualBoundingBoxAscent + padding * 2;

  ctx.font = font;
  ctx.textBaseline = 'top';

  if (+form.stroke_size) {
    ctx.lineWidth = form.stroke_size * 2;
    ctx.strokeStyle = form.stroke_color;
    ctx.miterLimit = form.miter;
    ctx.lineJoin = form.line_join;
    ctx.strokeText(form.text, padding, padding);
  }

  ctx.fillStyle = form.color;
  ctx.fillText(form.text, padding, padding);
}

/** @param {MouseEvent} me */
function closeModal(me) {
  if (me && me.button !== MouseBtn.Primary) {
    return;
  }

  me?.stopPropagation();

  modalOverlay.textContent = '';
  modalOverlay.style.visibility = null;
}

/** @param {MouseEvent} me */
function openImageModal(me) {
  if (me.button !== MouseBtn.Primary) {
    return;
  }

  me.stopPropagation();

  const modal = createModal('Add image');
  modal.style.width = '620px';

  modal.insertAdjacentHTML(
    'beforeend',
    `
    <div class="bc-img-div">
      <input id="bc-img-input" type="file" accept=".jpg,.png" class="bc-input"/>
      <img id="bc-ext-img-preview" />
    </div>

    <div id="bc-modal-footer">
      <div id="bc-img-insert">[<a>Add</a>]</div>
    </div>

    <style>
      #bc-img-input {
        margin-bottom: 5px;
      }

      #bc-ext-img-preview {
        display: block;
        max-height: 50vh;
        max-width: 100%;
      }

      #bc-img-insert {
        margin-left: auto;
      }
    </style>
  `
  );

  let objectUrl;
  const imgPreview = document.getElementById('bc-ext-img-preview');
  const fileInput = document.getElementById('bc-img-input');
  fileInput.addEventListener('input', () => {
    if (!fileInput.files[0]) {
      imgPreview.src = null;
    } else {
      objectUrl = URL.createObjectURL(fileInput.files[0]);
      imgPreview.onload = () => URL.revokeObjectURL(objectUrl);
      imgPreview.src = objectUrl;
    }
  });
  modal.querySelector('#bc-img-insert a').addEventListener('click', (me) => addImg(me, fileInput.files[0]));
}

function changeImgPreview(imgFile, previewElement) {}

function addImg(me, file) {
  if (me.button !== MouseBtn.Primary) {
    return;
  }

  me.stopPropagation();

  const img = new Image();
  img.onload = () => {
    const canvasImage = new CanvasImage(img);
    imgSet.add(canvasImage);
    positionImageInitial(canvasImage, 4, 3, false);
    canvasImage.setTransform(FrameState.baseTransform.multiply(FrameState.zoomMatrix));
    reDraw();
    img.onload = null;
  };
  img.src = URL.createObjectURL(file);
  img.crossOrigin = 'anonymous';
  extraImg.push(img.src);

  closeModal();
}

/** @param {MouseEvent} me */
function openHelp(me) {
  if (me.button !== MouseBtn.Primary) {
    return;
  }

  me.stopPropagation();

  const modal = createModal();
  modal.style.width = '800px';

  modal.insertAdjacentHTML(
    'beforeend',
    `
    <div class="post bc-help-header">
      <div class="bc-center postInfo">
        [<span class="subject">Baker companion</span>]
      </div>
    </div>

    <div class="bc-help">
      Loads all images in current thread, even new ones if you haven't refreshed the page, only loads ${IMAGE_EXTENSIONS.join(', ')} images.

      Images can be moved, scaled and rotated, last one touched renders on top.
      Text can be added and works in the same way, always renders on top of images.

      Images can be removed and later readded from the right column, removed text is deleted.

      <strong>Controls:</strong>
      <span><span class="bc-code">Control + Scroll</span> to zoom</span>
      <span>Hold <span class="bc-code">Space</span> and drag for panning</span>
      <span>Note: as the zoom goes closer than 1:1 the images are still rendered on screen with no pixelation when possible.</span>

      <strong>Toolbar:</strong>
      <span>[<a>S</a>] Canvas size: Changes the size of the canvas, may reposition some images, saved for next session. (default 4k x 4k)</span>
      <span>[<a>B</a>] Background color, saved for next session. (default black)</span>
      <span>[<a>T</a>] Add text: Adds custom text, if set as mask it will hide everything outside the text outline. There can only be one mask.</span>
      <span>[<a>M</a>] Lock/unlock mask: If a mask is present, locking it will make it unselectable and semi-transparent so the images can be moved underneath, unlock to move, scale or remove the mask.</span>
      <span>[<a>I</a>] Add image: to upload an image to the collage.</span>
      <span>[<a>↺</a>] Reload: If the canvas is empty after tab inactivity this forces the images to reload and <sup>might</sup> fix it.</span>
    </div>

    <div class="bc-help bc-center">Go <a href="https://greasyfork.org/en/scripts/572499-baker-companion/feedback" target="_blank">here</a> for feedback</div>

    <div class="bc-center bc-ligther">
      ~ This is a work in progress ~
    </div>

    <style>
      .bc-help-header {
        margin: -20px;
        pointer-events: none;
      }

      .bc-help {
        white-space: pre-line;
        padding: 0 20px;
      }

      .bc-help a:not([href]) {
        font-family: serif;
        font-weight: bold;
        pointer-events: none !important;
      }

      .bc-help span:not(.bc-code) {
        display: inline-block;
        margin-left: 1ch;
        margin-bottom: 4px;
      }

      .bc-ligther {
        opacity: .3;
      }

      .bc-code {
        font-family: 'courier';
        background-color: #fff3;
        border-radius: 4px;
        border: 1px solid gray;
      }
    </style>
  `
  );
}

class Space {
  static intersects(location, bounds) {
    return (
      (location.offsetX ?? location.x) >= bounds.x &&
      (location.offsetX ?? location.x) < bounds.x + bounds.width &&
      (location.offsetY ?? location.y) >= bounds.y &&
      (location.offsetY ?? location.y) < bounds.y + bounds.height
    );
  }
}

class CanvasImage {
  static BTN_PADDING = 5;
  static BTN_SIZE = 22;
  static FONT_STYLE = `18px sans-serif`;

  get x() {
    return this.trOrigin.x;
  }

  get y() {
    return this.trOrigin.y;
  }

  get right() {
    return this.x + this.width;
  }

  get bottom() {
    return this - y + this.height;
  }

  get width() {
    if (this.rotation % 180 === 0) {
      return this.trWidth;
    } else {
      return this.trHeight;
    }
  }

  get height() {
    if (this.rotation % 180 === 0) {
      return this.trHeight;
    } else {
      return this.trWidth;
    }
  }

  get right() {
    return this.x + this.width;
  }

  get bottom() {
    return this.y + this.height;
  }

  img;
  ratio;

  origin = new DOMPoint(0, 0);
  _width = 0;
  _height = 0;

  currentTransform = FrameState.zoomMatrix;
  trOrigin = this.origin;
  trWidth = 0;
  trHeight = 0;

  rotation = 0;

  hoveredSince;
  hoverDrawn;
  hoverOn; // hovered button if any
  hidden = false;

  constructor(i) {
    this.img = i;
  }

  translate(x, y) {
    const dX = x / this.currentTransform.a;
    const dY = y / this.currentTransform.d;
    this.origin.x += Math.sign(dX) * Math.ceil(Math.abs(dX));
    this.origin.y += Math.sign(dY) * Math.ceil(Math.abs(dY));
    this.trOrigin = this.origin.matrixTransform(this.currentTransform);
  }

  /** @param {{width: number, height: number}} bounds */
  keepInBounds(bounds, margin = 80) {
    const width = this.width / this.currentTransform.a;
    const height = this.height / this.currentTransform.a;

    if (this.origin.x + width < margin) {
      this.origin.x = 0;
    } else if (this.origin.x > bounds.width - margin) {
      this.origin.x = bounds.width - width;
    }

    if (this.origin.y + height < margin) {
      this.origin.y = 0;
    } else if (this.origin.y > bounds.height - margin) {
      this.origin.y = bounds.height - height;
    }

    this.trOrigin = this.origin.matrixTransform(this.currentTransform);
  }

  resize(dWidth) {
    this._width = Math.round(this._width + dWidth / this.currentTransform.a);
    this._height = Math.round(this._width / this.ratio);

    this.trWidth = Math.round(this._width * this.currentTransform.a);
    this.trHeight = Math.round(this._height * this.currentTransform.d);
  }

  rotate() {
    this.rotation = (this.rotation + 90) % 360;
  }

  drawRect(fullRes = false) {
    return {
      x: fullRes ? this.origin.x : this.trOrigin.x,
      y: fullRes ? this.origin.y : this.trOrigin.y,
      width: fullRes ? this._width : this.trWidth,
      height: fullRes ? this._height : this.trHeight,
    };
  }

  rotateBtnDrawPoint() {
    return new DOMPointReadOnly(this.x + CanvasImage.BTN_PADDING, this.y + CanvasImage.BTN_PADDING);
  }

  rotateBtnBounds() {
    return this.btnBounds(this.trOrigin);
  }

  trashBtnDrawPoint() {
    return new DOMPointReadOnly(
      this.x + this.width - CanvasImage.BTN_SIZE + CanvasImage.BTN_PADDING,
      this.y + CanvasImage.BTN_PADDING
    );
  }

  trashBtnBounds() {
    return this.btnBounds({
      x: this.x + this.width - CanvasImage.BTN_SIZE,
      y: this.y,
    });
  }

  scaleBtnBounds() {
    return this.btnBounds({
      x: this.x + this.width - CanvasImage.BTN_SIZE,
      y: this.y + this.height - CanvasImage.BTN_SIZE,
    });
  }

  btnBounds(position) {
    return {
      x: position.x,
      y: position.y,
      width: CanvasImage.BTN_SIZE,
      height: CanvasImage.BTN_SIZE,
    };
  }

  computeHoverState(point) {
    let cursor;
    if (Space.intersects(point, this.rotateBtnBounds())) {
      this.hoverOn = 'rotate';
      cursor = 'pointer';
    } else if (Space.intersects(point, this.trashBtnBounds())) {
      this.hoverOn = 'trash';
      cursor = 'pointer';
    } else if (Space.intersects(point, this.scaleBtnBounds())) {
      this.hoverOn = 'scale';
      cursor = 'nwse-resize';
    } else {
      this.hoverOn = null;
      cursor = 'move';
    }

    return { hoverOn: this.hoverOn, cursor: cursor };
  }

  stopHover() {
    this.hoveredSince = null;
    this.hoverDrawn = false;
    this.hoverOn = null;
  }

  /** @param {DOMMatrix} transform */
  setTransform(transform) {
    this.currentTransform = transform;
    this.trOrigin = this.origin.matrixTransform(transform);

    this.trWidth = this._width * transform.a;
    this.trHeight = this._height * transform.d;
  }
}

function saveConfig() {
  GM_setValue('config', bcConfig);
}

function validateConfig() {
  if (
    typeof bcConfig.canvasWidth !== 'number' ||
    typeof bcConfig.canvasHeight !== 'number' ||
    Number.isNaN(bcConfig.canvasWidth) ||
    Number.isNaN(bcConfig.canvasHeight) ||
    typeof bcConfig.bgColor !== 'string'
  ) {
    console.error('Saved config is corrupted, restoring to defaults.');

    bcConfig = DEFAULT_CONFIG;
    saveConfig();
    console.log(bcConfig);
  }
}

class CanvasText extends CanvasImage {
  text;
  fontStyle = '';
  fontSize = 10;
  fontFamily = 'sans-serif';
  color = '#000';
  strokeColor;
  strokeSize = 0;
  lineJoin;
  miterLimit = 10;
  bounds;
  mask = false;

  trFontSize = 10;
  trStrokeSize = 0;

  drawFont(fullRes = false) {
    return `${this.fontStyle ?? ''} ${fullRes ? this.fontSize : this.trFontSize}px ${this.fontFamily}`;
  }

  drawStroke(fullRes = false) {
    return fullRes ? this.strokeSize : this.trStrokeSize;
  }

  /** @param {DOMMatrix} transform */
  setTransform(transform) {
    this.trFontSize = this.fontSize * transform.a;
    this.trStrokeSize = this.strokeSize * transform.a;
    this.saveBounds();

    super.setTransform(transform);
  }

  saveBounds() {
    offCtx.save();
    offCtx.font = `${this.fontStyle ?? ''} ${this.fontSize}px ${this.fontFamily}`;
    offCtx.textBaseline = 'top';
    const newBounds = offCtx.measureText(this.text);
    offCtx.restore();

    this.bounds = newBounds;
    this._width = Math.ceil(this.bounds.actualBoundingBoxRight - this.bounds.actualBoundingBoxLeft);
    this._height = Math.ceil(this.bounds.actualBoundingBoxDescent - this.bounds.actualBoundingBoxAscent);
    this.ratio = this._width / this._height;
  }

  resize(dWidth) {
    const oldWidth = this._width;
    super.resize(dWidth);
    const scale = this._width / oldWidth;

    this.fontSize *= scale;
    this.trFontSize = this.fontSize * this.currentTransform.a;
    this.strokeSize *= scale;
    this.trStrokeSize = this.strokeSize * this.currentTransform.a;
  }
}

/**
 * @typedef {object} TextForm
 * @prop {string} text
 * @prop {string} size
 * @prop {number} bold
 * @prop {boolean} italic
 * @prop {string} font
 * @prop {string} stroke_size
 * @prop {string} stroke_color
 * @prop {string} line_join
 * @prop {string} miter
 * @prop {string} color
 */

/**
 * @typedef {object} BCStore
 * @prop {object} config
 * @prop {number} config.canvasWidth
 * @prop {number} config.canvasHeight
 * @prop {string} config.bgColor
 */