SKRIBBL BREAD DRAWER

Image autodrawer for skribbl.io with color, quality controls, optional outlines, image URLs, and a Bert test image.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         SKRIBBL BREAD DRAWER
// @namespace    skribbl-bread-drawer
// @version      1.0.3
// @description  Image autodrawer for skribbl.io with color, quality controls, optional outlines, image URLs, and a Bert test image.
// @match        *://skribbl.io/*
// @match        *://*.skribbl.io/*
// @run-at       document-start
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  console.log("🍞 SKRIBBL BREAD DRAWER userscript starting");

  function boot() {
    if (!document.body) {
      setTimeout(boot, 100);
      return;
    }

    if (document.querySelector("#skribbl-bread-drawer")) {
      return;
    }

    // ============================================================
    // STATE
    // ============================================================

    let loadedImage = null;
    let drawingNow = false;
    let stopRequested = false;
    let overlay = null;
    let overlayCtx = null;

    const sleep = ms =>
      new Promise(resolve => setTimeout(resolve, ms));

    const BERT_TEST_IMAGE =
      "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcROVGR3OrBVzqQf_fLmP_v-Ev5hO-XYr20phYcbeTZMSA&s=10";

    const SETTINGS = {
      darkness: 170,
      minFeature: 2,
      simplify: 0.75,
      duplicateDistance: 3.5,
      whiteCutoff: 242,
      packetSize: 4
    };

    // ============================================================
    // SKRIBBL PALETTE
    // ============================================================

    const PALETTE = [
      {id:0,  rgb:[255,255,255], hex:"#ffffff"},
      {id:1,  rgb:[0,0,0],       hex:"#000000"},
      {id:2,  rgb:[193,193,193], hex:"#c1c1c1"},
      {id:3,  rgb:[80,80,80],    hex:"#505050"},
      {id:4,  rgb:[239,19,11],   hex:"#ef130b"},
      {id:5,  rgb:[116,11,7],    hex:"#740b07"},
      {id:6,  rgb:[255,113,0],   hex:"#ff7100"},
      {id:7,  rgb:[194,56,0],    hex:"#c23800"},
      {id:8,  rgb:[255,228,0],   hex:"#ffe400"},
      {id:9,  rgb:[232,162,0],   hex:"#e8a200"},
      {id:10, rgb:[0,204,0],     hex:"#00cc00"},
      {id:11, rgb:[0,85,16],     hex:"#005510"},
      {id:12, rgb:[0,255,145],   hex:"#00ff91"},
      {id:13, rgb:[0,120,93],    hex:"#00785d"},
      {id:14, rgb:[0,178,255],   hex:"#00b2ff"},
      {id:15, rgb:[0,86,158],    hex:"#00569e"},
      {id:16, rgb:[35,31,211],   hex:"#231fd3"},
      {id:17, rgb:[14,8,101],    hex:"#0e0865"},
      {id:18, rgb:[163,0,186],   hex:"#a300ba"},
      {id:19, rgb:[85,0,105],    hex:"#550069"},
      {id:20, rgb:[223,105,167], hex:"#df69a7"},
      {id:21, rgb:[135,53,84],   hex:"#873554"},
      {id:22, rgb:[255,172,142], hex:"#ffac8e"},
      {id:23, rgb:[204,119,77],  hex:"#cc774d"},
      {id:24, rgb:[160,82,45],   hex:"#a0522d"},
      {id:25, rgb:[99,48,13],    hex:"#63300d"}
    ];

    // ============================================================
    // UI
    // ============================================================

    const panel = document.createElement("div");
    panel.id = "skribbl-bread-drawer";

    Object.assign(panel.style, {
      position: "fixed",
      top: "10px",
      right: "10px",
      zIndex: "2147483647",
      width: "310px",
      padding: "12px",
      background: "#181818",
      color: "#fff",
      border: "1px solid #555",
      borderRadius: "12px",
      font: "13px Arial,sans-serif",
      boxShadow: "0 6px 28px rgba(0,0,0,.55)"
    });

    panel.innerHTML = `
      <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
        <div style="font-size:17px;font-weight:800">
          🍞 SKRIBBL BREAD DRAWER
        </div>

        <button id="sk-close" style="
          width:28px;height:28px;padding:0;
          background:#303030;color:white;
          border:1px solid #666;border-radius:6px;
          cursor:pointer;font-size:16px">
          ×
        </button>
      </div>

      <div id="sk-socket" style="
        padding:7px 9px;margin-bottom:8px;
        border-radius:6px;background:#492222;
        color:white;font-weight:700">
        🔴 draw one tiny line
      </div>

      <label for="sk-file" style="
        display:block;text-align:center;padding:8px;
        background:#353535;color:white;
        border:1px solid #666;border-radius:7px;
        cursor:pointer;font-weight:700">
        📁 CHOOSE IMAGE
      </label>

      <input
        id="sk-file"
        type="file"
        accept="image/*"
        style="display:none"
      >

      <button id="sk-bert-test" style="
        display:block;width:100%;margin-top:6px;padding:8px;
        background:#6847a8;color:white;
        border:1px solid #8b6dcc;border-radius:7px;
        cursor:pointer;font-weight:700">
        🧪 BERT TEST IMAGE
      </button>

      <div style="
        display:flex;
        gap:6px;
        margin-top:6px;
      ">
        <input
          id="sk-url"
          type="text"
          placeholder="Image URL..."
          style="
            flex:1;
            min-width:0;
            padding:7px;
            background:#262626;
            color:white;
            border:1px solid #555;
            border-radius:7px;
            outline:none;
            box-sizing:border-box;
          "
        >

        <button
          id="sk-load-url"
          style="
            padding:7px 10px;
            background:#355f8a;
            color:white;
            border:1px solid #5584b0;
            border-radius:7px;
            cursor:pointer;
            font-weight:700;
          "
        >
          LOAD
        </button>
      </div>

      <div id="sk-previewbox" style="
        display:none;height:85px;margin-top:7px;
        overflow:hidden;background:white;
        border-radius:7px;border:1px solid #444">
        <img
          id="sk-preview"
          style="width:100%;height:100%;object-fit:contain"
        >
      </div>

      <div style="margin-top:9px;padding-top:8px;border-top:1px solid #3d3d3d">

        <div style="display:flex;justify-content:space-between">
          <span>Picture quality</span>
          <b id="sk-qualityv">3</b>
        </div>

        <input
          id="sk-quality"
          type="range"
          min="1"
          max="5"
          value="3"
          style="width:100%"
        >

        <div style="display:flex;justify-content:space-between">
          <span>Color detail</span>
          <b id="sk-colorstepv">6</b>
        </div>

        <input
          id="sk-colorstep"
          type="range"
          min="3"
          max="12"
          value="6"
          style="width:100%"
        >

        <div style="display:flex;justify-content:space-between">
          <span>Color brush</span>
          <b id="sk-colorbrushv">8</b>
        </div>

        <input
          id="sk-colorbrush"
          type="range"
          min="3"
          max="14"
          value="8"
          style="width:100%"
        >

        <div style="display:flex;justify-content:space-between">
          <span>Outline brush</span>
          <b id="sk-outlinebrushv">4</b>
        </div>

        <input
          id="sk-outlinebrush"
          type="range"
          min="1"
          max="12"
          value="4"
          style="width:100%"
        >

        <div style="display:flex;justify-content:space-between">
          <span>Speed</span>
          <b id="sk-speedv">3</b>
        </div>

        <input
          id="sk-speed"
          type="range"
          min="1"
          max="5"
          value="3"
          style="width:100%"
        >

        <div style="display:flex;gap:18px;margin-top:6px">
          <label style="display:flex;align-items:center;gap:5px;cursor:pointer">
            <input
              id="sk-outline-enabled"
              type="checkbox"
              checked
            >
            Outline
          </label>

          <label style="display:flex;align-items:center;gap:5px;cursor:pointer">
            <input
              id="sk-local"
              type="checkbox"
              checked
            >
            Local preview
          </label>
        </div>
      </div>

      <div id="sk-status" style="
        margin-top:8px;padding:7px 9px;min-height:17px;
        background:#242424;color:#ccc;
        border-radius:6px;font-size:12px">
        waiting for image
      </div>

      <div style="
        display:grid;
        grid-template-columns:1fr 1fr;
        gap:7px;
        margin-top:8px
      ">
        <button id="sk-draw" style="
          padding:10px;background:#248b3b;color:white;
          border:1px solid #51ad64;border-radius:7px;
          font-weight:800;cursor:pointer;font-size:14px">
          ▶ DRAW
        </button>

        <button id="sk-stop" style="
          padding:10px;background:#a52a24;color:white;
          border:1px solid #d5554e;border-radius:7px;
          font-weight:800;cursor:pointer;font-size:14px">
          ■ STOP
        </button>
      </div>

      <button id="sk-clear" style="
        width:100%;margin-top:6px;padding:6px;
        background:#353535;color:#ddd;
        border:1px solid #555;border-radius:7px;
        cursor:pointer">
        Clear local preview
      </button>
    `;

    document.body.appendChild(panel);

    const $ = selector =>
      panel.querySelector(selector);

    function status(text) {
      $("#sk-status").textContent = text;
      console.log("[SKRIBBL BREAD DRAWER]", text);
    }

    [
      ["#sk-quality", "#sk-qualityv"],
      ["#sk-colorstep", "#sk-colorstepv"],
      ["#sk-colorbrush", "#sk-colorbrushv"],
      ["#sk-outlinebrush", "#sk-outlinebrushv"],
      ["#sk-speed", "#sk-speedv"]
    ].forEach(([input, output]) => {
      $(input).oninput = e => {
        $(output).textContent = e.target.value;
      };
    });

    // ============================================================
    // IMAGE LOADING
    // ============================================================

    function setImage(img) {
      loadedImage = img;

      $("#sk-preview").src = img.src;
      $("#sk-previewbox").style.display = "block";

      status(
        `${img.naturalWidth} × ${img.naturalHeight} loaded`
      );
    }

    $("#sk-file").onchange = e => {
      const file = e.target.files?.[0];

      if (!file) {
        return;
      }

      const img = new Image();

      img.onload = () => setImage(img);
      img.onerror = () => status("❌ couldn't load image");

      img.src = URL.createObjectURL(file);
    };

    $("#sk-bert-test").onclick = () => {
      status("loading Bert test image...");

      const img = new Image();

      img.crossOrigin = "anonymous";

      img.onload = () => {
        setImage(img);
        status("🧪 Bert test image loaded");
      };

      img.onerror = () => {
        status("❌ Bert test image failed to load");
      };

      img.src = BERT_TEST_IMAGE;
    };

    $("#sk-load-url").onclick = () => {
      const url =
        $("#sk-url").value.trim();

      if (!url) {
        status("❌ paste an image URL");
        return;
      }

      status("loading image URL...");

      const img = new Image();

      img.crossOrigin = "anonymous";

      img.onload = () => {
        setImage(img);

        status(
          `🌐 ${img.naturalWidth} × ${img.naturalHeight} URL image loaded`
        );
      };

      img.onerror = () => {
        status(
          "❌ URL image failed — try downloading/uploading it instead"
        );
      };

      img.src = url;
    };

    $("#sk-url").addEventListener("keydown", e => {
      if (e.key === "Enter") {
        $("#sk-load-url").click();
      }
    });

    // ============================================================
    // SOCKET CAPTURE
    // ============================================================

    if (!window.__breadTrueSend) {
      window.__breadTrueSend =
        WebSocket.prototype.send;
    }

    const rawSend =
      window.__breadTrueSend;

    WebSocket.prototype.send =
      function(data) {

        if (
          typeof data === "string" &&
          data.startsWith('42["data"') &&
          data.includes('"id":19')
        ) {
          window.__breadSocket =
            this;

          const socketBox =
            document.querySelector(
              "#skribbl-bread-drawer #sk-socket"
            );

          if (socketBox) {
            socketBox.textContent =
              "🟢 socket captured";

            socketBox.style.background =
              "#184529";
          }

          console.log(
            "🎯 Skribbl draw socket captured"
          );
        }

        return rawSend.apply(
          this,
          arguments
        );
      };

    function getSocket() {
      return (
        window.__breadSocket ||
        window.__skColorSocket ||
        window.__skBreadSocket
      );
    }

    // ============================================================
    // CANVAS
    // ============================================================

    function findCanvas() {
      return [
        ...document.querySelectorAll("canvas")
      ]
        .filter(canvas => {
          const r =
            canvas.getBoundingClientRect();

          return (
            r.width > 250 &&
            r.height > 150 &&
            r.bottom > 0 &&
            r.right > 0
          );
        })
        .sort((a, b) => {
          const A =
            a.getBoundingClientRect();

          const B =
            b.getBoundingClientRect();

          return (
            B.width * B.height -
            A.width * A.height
          );
        })[0] || null;
    }

    // ============================================================
    // OVERLAY
    // ============================================================

    function makeOverlay(canvas) {
      document
        .querySelector("#sk-bread-overlay")
        ?.remove();

      const rect =
        canvas.getBoundingClientRect();

      overlay =
        document.createElement("canvas");

      overlay.id =
        "sk-bread-overlay";

      overlay.width =
        canvas.width;

      overlay.height =
        canvas.height;

      Object.assign(
        overlay.style,
        {
          position: "fixed",
          left: rect.left + "px",
          top: rect.top + "px",
          width: rect.width + "px",
          height: rect.height + "px",
          pointerEvents: "none",
          zIndex: "2147483000"
        }
      );

      document.body.appendChild(
        overlay
      );

      overlayCtx =
        overlay.getContext("2d");

      overlayCtx.lineCap =
        "round";

      overlayCtx.lineJoin =
        "round";
    }

    function clearOverlay() {
      if (
        overlay &&
        overlayCtx
      ) {
        overlayCtx.clearRect(
          0,
          0,
          overlay.width,
          overlay.height
        );
      }
    }

    $("#sk-clear").onclick =
      clearOverlay;

    // ============================================================
    // COLOR MATCHING
    // ============================================================

    function nearestColor(
      r,
      g,
      b
    ) {
      let best =
        PALETTE[0];

      let bestDistance =
        Infinity;

      for (
        const p
        of PALETTE
      ) {
        const dr =
          r - p.rgb[0];

        const dg =
          g - p.rgb[1];

        const db =
          b - p.rgb[2];

        const distance =
          dr * dr * 0.30 +
          dg * dg * 0.59 +
          db * db * 0.11;

        if (
          distance <
          bestDistance
        ) {
          bestDistance =
            distance;

          best =
            p;
        }
      }

      return best;
    }

    // ============================================================
    // QUALITY
    // ============================================================

    function qualityScale() {
      const q =
        +$("#sk-quality").value;

      return ({
        1: 0.45,
        2: 0.60,
        3: 0.75,
        4: 0.88,
        5: 1.00
      })[q] || 0.75;
    }

    // ============================================================
    // SOURCE IMAGE
    // ============================================================

    function makeSourceCanvas(
      img,
      targetW,
      targetH
    ) {
      const scaleQ =
        qualityScale();

      const W =
        Math.max(
          100,
          Math.round(
            targetW * scaleQ
          )
        );

      const H =
        Math.max(
          100,
          Math.round(
            targetH * scaleQ
          )
        );

      const canvas =
        document.createElement("canvas");

      canvas.width =
        W;

      canvas.height =
        H;

      const ctx =
        canvas.getContext(
          "2d",
          {
            willReadFrequently: true
          }
        );

      ctx.fillStyle =
        "#ffffff";

      ctx.fillRect(
        0,
        0,
        W,
        H
      );

      const margin =
        Math.round(
          W * 0.05
        );

      const scale =
        Math.min(
          (W - margin * 2) /
            img.naturalWidth,

          (H - margin * 2) /
            img.naturalHeight
        );

      const iw =
        img.naturalWidth * scale;

      const ih =
        img.naturalHeight * scale;

      ctx.drawImage(
        img,
        (W - iw) / 2,
        (H - ih) / 2,
        iw,
        ih
      );

      canvas.__targetW =
        targetW;

      canvas.__targetH =
        targetH;

      return canvas;
    }

    // ============================================================
    // COLOR COMMANDS
    // ============================================================

    function makeColorCommands(
      source,
      step,
      brush
    ) {
      const W =
        source.width;

      const H =
        source.height;

      const targetW =
        source.__targetW || W;

      const targetH =
        source.__targetH || H;

      const sx =
        targetW / W;

      const sy =
        targetH / H;

      const ctx =
        source.getContext(
          "2d",
          {
            willReadFrequently: true
          }
        );

      const pixels =
        ctx.getImageData(
          0,
          0,
          W,
          H
        ).data;

      const commands = [];

      function pixelAt(
        x,
        y
      ) {
        const i =
          (y * W + x) * 4;

        return [
          pixels[i],
          pixels[i + 1],
          pixels[i + 2],
          pixels[i + 3]
        ];
      }

      for (
        let y =
          Math.floor(step / 2);

        y < H;

        y += step
      ) {
        let runColor = null;
        let runStart = null;
        let lastX = null;

        function finishRun() {
          if (
            runColor !== null &&
            runStart !== null &&
            lastX !== null
          ) {
            commands.push([
              0,
              runColor,
              brush,

              Math.round(
                runStart * sx
              ),

              Math.round(
                y * sy
              ),

              Math.round(
                Math.max(
                  runStart + 1,
                  lastX
                ) * sx
              ),

              Math.round(
                y * sy
              )
            ]);
          }

          runColor = null;
          runStart = null;
          lastX = null;
        }

        for (
          let x =
            Math.floor(step / 2);

          x < W;

          x += step
        ) {
          const [
            r,
            g,
            b,
            a
          ] =
            pixelAt(
              x,
              y
            );

          if (
            a < 40 ||
            (
              r >= SETTINGS.whiteCutoff &&
              g >= SETTINGS.whiteCutoff &&
              b >= SETTINGS.whiteCutoff
            )
          ) {
            finishRun();
            continue;
          }

          const color =
            nearestColor(
              r,
              g,
              b
            );

          if (
            color.id === 0 ||
            color.id === 1
          ) {
            finishRun();
            continue;
          }

          if (
            runColor === null
          ) {
            runColor =
              color.id;

            runStart =
              x;

            lastX =
              x;

            continue;
          }

          if (
            color.id ===
            runColor
          ) {
            lastX =
              x;

            continue;
          }

          finishRun();

          runColor =
            color.id;

          runStart =
            x;

          lastX =
            x;
        }

        finishRun();
      }

      return commands;
    }

    // ============================================================
    // OUTLINE MASK
    // ============================================================

    function buildMask(
      img,
      targetW,
      targetH
    ) {
      const quality =
        +$("#sk-quality").value;

      const qualityWidths = {
        1: 260,
        2: 360,
        3: 460,
        4: 560,
        5: 650
      };

      const W =
        qualityWidths[
          quality
        ] || 460;

      const H =
        Math.round(
          W * targetH / targetW
        );

      const canvas =
        document.createElement("canvas");

      canvas.width =
        W;

      canvas.height =
        H;

      const ctx =
        canvas.getContext(
          "2d",
          {
            willReadFrequently: true
          }
        );

      ctx.fillStyle =
        "white";

      ctx.fillRect(
        0,
        0,
        W,
        H
      );

      const margin =
        Math.round(
          W * 0.05
        );

      const scale =
        Math.min(
          (W - margin * 2) /
            img.naturalWidth,

          (H - margin * 2) /
            img.naturalHeight
        );

      const iw =
        img.naturalWidth * scale;

      const ih =
        img.naturalHeight * scale;

      ctx.drawImage(
        img,
        (W - iw) / 2,
        (H - ih) / 2,
        iw,
        ih
      );

      const data =
        ctx.getImageData(
          0,
          0,
          W,
          H
        ).data;

      const mask =
        new Uint8Array(
          W * H
        );

      for (
        let i = 0,
            p = 0;

        i < mask.length;

        i++,
        p += 4
      ) {
        const alpha =
          data[p + 3] / 255;

        let gray =
          data[p] * 0.299 +
          data[p + 1] * 0.587 +
          data[p + 2] * 0.114;

        gray =
          gray * alpha +
          255 * (1 - alpha);

        mask[i] =
          gray < SETTINGS.darkness
            ? 1
            : 0;
      }

      return {
        mask,
        W,
        H,
        sx: targetW / W,
        sy: targetH / H
      };
    }

    // ============================================================
    // BOUNDARY
    // ============================================================

    function getBoundary(
      mask,
      W,
      H
    ) {
      const edge =
        new Uint8Array(
          W * H
        );

      const I =
        (x, y) =>
          y * W + x;

      for (
        let y = 1;
        y < H - 1;
        y++
      ) {
        for (
          let x = 1;
          x < W - 1;
          x++
        ) {
          if (
            !mask[
              I(x, y)
            ]
          ) {
            continue;
          }

          if (
            !mask[I(x - 1, y)] ||
            !mask[I(x + 1, y)] ||
            !mask[I(x, y - 1)] ||
            !mask[I(x, y + 1)]
          ) {
            edge[
              I(x, y)
            ] = 1;
          }
        }
      }

      return edge;
    }

    // ============================================================
    // TRACE EDGES
    // ============================================================

    function traceEdges(
      edge,
      W,
      H
    ) {
      const I =
        (x, y) =>
          y * W + x;

      const dirs = [
        [-1,-1],
        [0,-1],
        [1,-1],
        [-1,0],
        [1,0],
        [-1,1],
        [0,1],
        [1,1]
      ];

      function on(x, y) {
        return (
          x >= 0 &&
          x < W &&
          y >= 0 &&
          y < H &&
          edge[I(x, y)]
        );
      }

      function edgeKey(a, b) {
        const A =
          `${a[0]},${a[1]}`;

        const B =
          `${b[0]},${b[1]}`;

        return (
          A < B
            ? `${A}|${B}`
            : `${B}|${A}`
        );
      }

      function neighbors(p) {
        const out = [];

        for (
          const [dx, dy]
          of dirs
        ) {
          const x =
            p[0] + dx;

          const y =
            p[1] + dy;

          if (
            on(x, y)
          ) {
            out.push(
              [x, y]
            );
          }
        }

        return out;
      }

      const used =
        new Set();

      function available(p) {
        return neighbors(p)
          .filter(
            n =>
              !used.has(
                edgeKey(
                  p,
                  n
                )
              )
          );
      }

      function score(
        prev,
        cur,
        next
      ) {
        const ax =
          cur[0] - prev[0];

        const ay =
          cur[1] - prev[1];

        const bx =
          next[0] - cur[0];

        const by =
          next[1] - cur[1];

        return (
          ax * bx +
          ay * by
        ) /
        (
          (
            Math.hypot(
              ax,
              ay
            ) || 1
          ) *
          (
            Math.hypot(
              bx,
              by
            ) || 1
          )
        );
      }

      function walk(
        start,
        first
      ) {
        const path = [
          start,
          first
        ];

        used.add(
          edgeKey(
            start,
            first
          )
        );

        let prev =
          start;

        let cur =
          first;

        let guard =
          0;

        while (
          guard++ < 30000
        ) {
          let options =
            available(cur);

          options =
            options.filter(
              p =>
                !(
                  p[0] === prev[0] &&
                  p[1] === prev[1]
                )
            );

          if (
            !options.length
          ) {
            break;
          }

          let best =
            options[0];

          let bestScore =
            -Infinity;

          for (
            const next
            of options
          ) {
            const s =
              score(
                prev,
                cur,
                next
              );

            if (
              s > bestScore
            ) {
              bestScore =
                s;

              best =
                next;
            }
          }

          used.add(
            edgeKey(
              cur,
              best
            )
          );

          path.push(
            best
          );

          prev =
            cur;

          cur =
            best;

          if (
            cur[0] === start[0] &&
            cur[1] === start[1]
          ) {
            break;
          }
        }

        return path;
      }

      const pixels = [];

      for (
        let y = 1;
        y < H - 1;
        y++
      ) {
        for (
          let x = 1;
          x < W - 1;
          x++
        ) {
          if (
            edge[
              I(x, y)
            ]
          ) {
            pixels.push(
              [x, y]
            );
          }
        }
      }

      const endpoints =
        pixels.filter(
          p =>
            neighbors(p).length === 1
        );

      const paths = [];

      for (
        const start
        of endpoints
      ) {
        let options;

        while (
          (
            options =
              available(start)
          ).length
        ) {
          const path =
            walk(
              start,
              options[0]
            );

          if (
            path.length > 1
          ) {
            paths.push(
              path
            );
          }
        }
      }

      for (
        const start
        of pixels
      ) {
        let options;
        let guard = 0;

        while (
          (
            options =
              available(start)
          ).length &&
          guard++ < 20
        ) {
          const path =
            walk(
              start,
              options[0]
            );

          if (
            path.length > 1
          ) {
            paths.push(
              path
            );
          }
        }
      }

      return paths;
    }

    // ============================================================
    // PATH HELPERS
    // ============================================================

    function pathLength(
      path
    ) {
      let total = 0;

      for (
        let i = 1;
        i < path.length;
        i++
      ) {
        total +=
          Math.hypot(
            path[i][0] -
              path[i - 1][0],

            path[i][1] -
              path[i - 1][1]
          );
      }

      return total;
    }

    function bbox(path) {
      let minX = Infinity;
      let minY = Infinity;
      let maxX = -Infinity;
      let maxY = -Infinity;

      for (
        const [x, y]
        of path
      ) {
        minX =
          Math.min(
            minX,
            x
          );

        minY =
          Math.min(
            minY,
            y
          );

        maxX =
          Math.max(
            maxX,
            x
          );

        maxY =
          Math.max(
            maxY,
            y
          );
      }

      return {
        minX,
        minY,
        maxX,
        maxY,
        w: maxX - minX,
        h: maxY - minY
      };
    }

    function lightSmooth(
      path
    ) {
      if (
        path.length < 5
      ) {
        return path;
      }

      const out = [
        path[0]
      ];

      for (
        let i = 1;
        i < path.length - 1;
        i++
      ) {
        const a =
          path[i - 1];

        const b =
          path[i];

        const c =
          path[i + 1];

        out.push([
          a[0] * 0.025 +
          b[0] * 0.95 +
          c[0] * 0.025,

          a[1] * 0.025 +
          b[1] * 0.95 +
          c[1] * 0.025
        ]);
      }

      out.push(
        path[
          path.length - 1
        ]
      );

      return out;
    }

    function resamplePath(
      path,
      count = 20
    ) {
      if (
        path.length < 2
      ) {
        return path;
      }

      const cumulative = [
        0
      ];

      for (
        let i = 1;
        i < path.length;
        i++
      ) {
        cumulative[i] =
          cumulative[i - 1] +
          Math.hypot(
            path[i][0] -
              path[i - 1][0],

            path[i][1] -
              path[i - 1][1]
          );
      }

      const total =
        cumulative[
          cumulative.length - 1
        ];

      if (
        total === 0
      ) {
        return Array.from(
          {
            length: count
          },
          () => [
            ...path[0]
          ]
        );
      }

      const result = [];
      let segment = 1;

      for (
        let k = 0;
        k < count;
        k++
      ) {
        const target =
          total *
          (
            k /
            (
              count - 1
            )
          );

        while (
          segment <
            cumulative.length - 1 &&
          cumulative[segment] <
            target
        ) {
          segment++;
        }

        const a =
          path[segment - 1];

        const b =
          path[segment];

        const before =
          cumulative[segment - 1];

        const after =
          cumulative[segment];

        const span =
          after - before;

        const t =
          span
            ? (
                target -
                before
              ) /
              span
            : 0;

        result.push([
          a[0] +
            (
              b[0] -
              a[0]
            ) *
            t,

          a[1] +
            (
              b[1] -
              a[1]
            ) *
            t
        ]);
      }

      return result;
    }

    // ============================================================
    // DUPLICATE SUPPRESSION
    // ============================================================

    function duplicateScore(
      a,
      b,
      maxDistance
    ) {
      const la =
        pathLength(a);

      const lb =
        pathLength(b);

      if (
        la < 35 ||
        lb < 35
      ) {
        return false;
      }

      const ratio =
        Math.min(
          la,
          lb
        ) /
        Math.max(
          la,
          lb
        );

      if (
        ratio < 0.68
      ) {
        return false;
      }

      const ba =
        bbox(a);

      const bb =
        bbox(b);

      const wr =
        Math.min(
          Math.max(ba.w, 1),
          Math.max(bb.w, 1)
        ) /
        Math.max(
          Math.max(ba.w, 1),
          Math.max(bb.w, 1)
        );

      const hr =
        Math.min(
          Math.max(ba.h, 1),
          Math.max(bb.h, 1)
        ) /
        Math.max(
          Math.max(ba.h, 1),
          Math.max(bb.h, 1)
        );

      if (
        Math.max(
          wr,
          hr
        ) < 0.65
      ) {
        return false;
      }

      const A =
        resamplePath(
          a,
          24
        );

      const B =
        resamplePath(
          b,
          24
        );

      function compare(
        P,
        Q
      ) {
        let total = 0;
        let close = 0;

        for (
          let i = 0;
          i < P.length;
          i++
        ) {
          const distance =
            Math.hypot(
              P[i][0] -
                Q[i][0],

              P[i][1] -
                Q[i][1]
            );

          total +=
            distance;

          if (
            distance <=
            maxDistance
          ) {
            close++;
          }
        }

        return {
          avg:
            total /
            P.length,

          fraction:
            close /
            P.length
        };
      }

      const forward =
        compare(
          A,
          B
        );

      const reverse =
        compare(
          A,
          [...B].reverse()
        );

      const best =
        forward.avg <
        reverse.avg
          ? forward
          : reverse;

      return (
        best.avg <=
          maxDistance * 1.05 &&
        best.fraction >=
          0.72
      );
    }

    function suppressDuplicates(
      paths
    ) {
      const ordered =
        [...paths]
          .sort(
            (a, b) =>
              pathLength(b) -
              pathLength(a)
          );

      const kept = [];

      for (
        const candidate
        of ordered
      ) {
        let duplicate =
          false;

        if (
          pathLength(
            candidate
          ) >= 35
        ) {
          for (
            const existing
            of kept
          ) {
            if (
              pathLength(
                existing
              ) < 35
            ) {
              continue;
            }

            if (
              duplicateScore(
                candidate,
                existing,
                SETTINGS.duplicateDistance
              )
            ) {
              duplicate =
                true;

              break;
            }
          }
        }

        if (
          !duplicate
        ) {
          kept.push(
            candidate
          );
        }
      }

      return kept;
    }

    // ============================================================
    // SIMPLIFY
    // ============================================================

    function simplify(
      points,
      epsilon
    ) {
      if (
        epsilon <= 0 ||
        points.length < 3
      ) {
        return points;
      }

      function pointDistance(
        p,
        a,
        b
      ) {
        const dx =
          b[0] - a[0];

        const dy =
          b[1] - a[1];

        if (
          !dx &&
          !dy
        ) {
          return Math.hypot(
            p[0] -
              a[0],

            p[1] -
              a[1]
          );
        }

        let t =
          (
            (
              p[0] - a[0]
            ) * dx +
            (
              p[1] - a[1]
            ) * dy
          ) /
          (
            dx * dx +
            dy * dy
          );

        t =
          Math.max(
            0,
            Math.min(
              1,
              t
            )
          );

        return Math.hypot(
          p[0] -
            (
              a[0] +
              t * dx
            ),

          p[1] -
            (
              a[1] +
              t * dy
            )
        );
      }

      function recurse(
        start,
        end
      ) {
        let maxDistance = 0;
        let index = -1;

        for (
          let i = start + 1;
          i < end;
          i++
        ) {
          const distance =
            pointDistance(
              points[i],
              points[start],
              points[end]
            );

          if (
            distance >
            maxDistance
          ) {
            maxDistance =
              distance;

            index =
              i;
          }
        }

        if (
          index !== -1 &&
          maxDistance >
            epsilon
        ) {
          const left =
            recurse(
              start,
              index
            );

          const right =
            recurse(
              index,
              end
            );

          return [
            ...left.slice(
              0,
              -1
            ),
            ...right
          ];
        }

        return [
          points[start],
          points[end]
        ];
      }

      return recurse(
        0,
        points.length - 1
      );
    }

    function scalePath(
      path,
      sx,
      sy
    ) {
      return path.map(
        ([x, y]) => [
          x * sx,
          y * sy
        ]
      );
    }

    // ============================================================
    // OUTLINE COMMANDS
    // ============================================================

    function makeOutlineCommands(
      paths,
      brush
    ) {
      const commands = [];

      for (
        const path
        of paths
      ) {
        for (
          let i = 1;
          i < path.length;
          i++
        ) {
          const a =
            path[i - 1];

          const b =
            path[i];

          const x1 =
            Math.round(
              a[0]
            );

          const y1 =
            Math.round(
              a[1]
            );

          const x2 =
            Math.round(
              b[0]
            );

          const y2 =
            Math.round(
              b[1]
            );

          if (
            x1 === x2 &&
            y1 === y2
          ) {
            continue;
          }

          commands.push([
            0,
            1,
            brush,
            x1,
            y1,
            x2,
            y2
          ]);
        }
      }

      return commands;
    }

    // ============================================================
    // NETWORK
    // ============================================================

    function sendBatch(
      socket,
      batch
    ) {
      const packet =
        "42" +
        JSON.stringify([
          "data",
          {
            id: 19,
            data: batch
          }
        ]);

      rawSend.call(
        socket,
        packet
      );
    }

    function mirrorCommand(
      command
    ) {
      if (
        !$("#sk-local").checked ||
        !overlayCtx
      ) {
        return;
      }

      const [
        ,
        colorId,
        brush,
        x1,
        y1,
        x2,
        y2
      ] =
        command;

      const color =
        PALETTE.find(
          item =>
            item.id ===
            colorId
        ) ||
        PALETTE[1];

      overlayCtx.strokeStyle =
        color.hex;

      overlayCtx.lineWidth =
        brush;

      overlayCtx.beginPath();

      overlayCtx.moveTo(
        x1,
        y1
      );

      overlayCtx.lineTo(
        x2,
        y2
      );

      overlayCtx.stroke();
    }

    function speedToDelay() {
      const speed =
        +$("#sk-speed").value;

      return ({
        1: 70,
        2: 50,
        3: 35,
        4: 24,
        5: 15
      })[speed] || 35;
    }

    async function transmit(
      commands,
      label
    ) {
      const socket =
        getSocket();

      if (
        !socket ||
        socket.readyState !==
          WebSocket.OPEN
      ) {
        throw new Error(
          "draw one tiny manual line first"
        );
      }

      const packetSize =
        SETTINGS.packetSize;

      const delay =
        speedToDelay();

      let sent = 0;

      for (
        let i = 0;
        i < commands.length;
        i += packetSize
      ) {
        if (
          stopRequested
        ) {
          return;
        }

        const batch =
          commands.slice(
            i,
            i + packetSize
          );

        sendBatch(
          socket,
          batch
        );

        batch.forEach(
          mirrorCommand
        );

        sent +=
          batch.length;

        status(
          `${label} ${sent}/${commands.length}`
        );

        await sleep(
          delay
        );
      }
    }

    // ============================================================
    // DRAW
    // ============================================================

    $("#sk-draw").onclick =
      async () => {
        try {
          if (
            drawingNow
          ) {
            return;
          }

          if (
            !loadedImage
          ) {
            status(
              "❌ choose an image first"
            );

            return;
          }

          const socket =
            getSocket();

          if (
            !socket ||
            socket.readyState !==
              WebSocket.OPEN
          ) {
            status(
              "❌ draw one tiny manual line first"
            );

            return;
          }

          const canvas =
            findCanvas();

          if (
            !canvas
          ) {
            status(
              "❌ couldn't find Skribbl canvas"
            );

            return;
          }

          drawingNow =
            true;

          stopRequested =
            false;

          if (
            $("#sk-local").checked
          ) {
            makeOverlay(
              canvas
            );
          }

          const rect =
            canvas.getBoundingClientRect();

          const W =
            canvas.width ||
            Math.round(
              rect.width
            );

          const H =
            canvas.height ||
            Math.round(
              rect.height
            );

          // ======================================================
          // COLORS
          // ======================================================

          status(
            "🎨 preparing colors"
          );

          const source =
            makeSourceCanvas(
              loadedImage,
              W,
              H
            );

          const colorCommands =
            makeColorCommands(
              source,
              +$("#sk-colorstep").value,
              +$("#sk-colorbrush").value
            );

          if (
            colorCommands.length >
            15000
          ) {
            throw new Error(
              "too many color commands"
            );
          }

          await transmit(
            colorCommands,
            "🎨"
          );

          if (
            stopRequested
          ) {
            drawingNow =
              false;

            status(
              "■ stopped"
            );

            return;
          }

          // ======================================================
          // OPTIONAL OUTLINE
          // ======================================================

          if (
            $("#sk-outline-enabled").checked
          ) {
            status(
              "🖤 tracing outline"
            );

            const prep =
              buildMask(
                loadedImage,
                W,
                H
              );

            const edge =
              getBoundary(
                prep.mask,
                prep.W,
                prep.H
              );

            let paths =
              traceEdges(
                edge,
                prep.W,
                prep.H
              );

            paths =
              paths.filter(
                path =>
                  pathLength(
                    path
                  ) >=
                  SETTINGS.minFeature
              );

            paths =
              paths.map(
                lightSmooth
              );

            paths =
              suppressDuplicates(
                paths
              );

            paths =
              paths.map(
                path =>
                  simplify(
                    path,
                    SETTINGS.simplify
                  )
              );

            paths =
              paths.filter(
                path =>
                  path.length >= 2
              );

            paths =
              paths.map(
                path =>
                  scalePath(
                    path,
                    prep.sx,
                    prep.sy
                  )
              );

            paths.sort(
              (a, b) =>
                pathLength(b) -
                pathLength(a)
            );

            const outlineCommands =
              makeOutlineCommands(
                paths,
                +$("#sk-outlinebrush").value
              );

            if (
              outlineCommands.length >
              12000
            ) {
              throw new Error(
                "outline command count suspiciously huge"
              );
            }

            await transmit(
              outlineCommands,
              "🖤"
            );
          }

          drawingNow =
            false;

          if (
            !stopRequested
          ) {
            status(
              "✅ drawing complete"
            );
          }

        } catch(error) {
          drawingNow =
            false;

          console.error(
            error
          );

          status(
            `❌ ${error.message || error}`
          );
        }
      };

    // ============================================================
    // STOP / CLOSE
    // ============================================================

    $("#sk-stop").onclick =
      () => {
        stopRequested =
          true;

        status(
          "■ stopping..."
        );
      };

    $("#sk-close").onclick =
      () => {
        stopRequested =
          true;

        overlay?.remove();

        panel.remove();
      };

    status(
      "choose image, paste URL, or use Bert test image"
    );

    console.log(
      "🍞 SKRIBBL BREAD DRAWER menu created"
    );
  }

  if (
    document.readyState ===
    "loading"
  ) {
    document.addEventListener(
      "DOMContentLoaded",
      boot,
      {
        once: true
      }
    );

    setTimeout(
      boot,
      1200
    );
  } else {
    boot();
  }
})();