Draw on image

Here’s a wild one. Does anyone know how I can have a user annotate an image? Ideally they’d be able to draw a circle around an object.

So they’d see an image displayed → click it → annotate it → it saves to my database as the annotated version.

I suspect the answer is a hard no, but perhaps there’s someone out there that has an idea for me.

That is indeed a hard no with native components.

I don’t know if there are third party services that offer you anything like that, to be embedded in a web embed component. Even if there is, you would need to find a way to link the Glide image URL to that service, which is almost certainly tricky.

Hi, has anyone found a solution for this one?

I have tried to do it just now, but I haven’t found a way to actually save the annotated image.

Cool. Do you think sending it to a 3rd party app to render, like URLBox would work?

It has to be in consideration. Usually for components that save directly to Glide’s storage (say recorder), they must have a way to upload the file. We must be able to somehow do the same thing with custom code.

I’m guessing no new native solutions. @ThinhDinh can you share HTML?
Or anyone found a way?

It was my test with the Code Component, which is not working anymore.

1 Like

You can try using the URI in the JavaScript column. However, it’s true what @thinhdinh mentioned, that the issue lies with the storage. The image below is an example that I saved in the form of a data URI using the Glide API.

1 Like

With the AI Custom Component, you can also achieve the same result. Unfortunately, I couldn’t get the “save” feature to successfully upload/update in Glide. It might be achievable by using a third-party hosting service.

1 Like

Thanks for examples, but generally what i’m hearing is:
Open image in Glide = Fine
Edit image in Glide = Doable via JavaScript column / AI column
Save edited image = Problematic

No worries, answer to stakeholders is, that this is not at native Glide feature, and the else brilliant community support hasn’t found a workaround yet :slight_smile:

1 Like

In the first thread(2nd img), I demonstrated that images can be saved as a URI or reused to add new annotations.
Now I have succeeded and am currently trying to create a download button to download the URI as an image file. And this works without having to host it on a third party.

Recording2024-11-02195613-ezgif.com-video-to-webp-converter

6 Likes

WOW! - How does this work / can you share script? :slight_smile:

Here it is.
To save the annotated file, you need to add a basic column as an Image placeholder and a Row ID column. For the JavaScript column I provided to work, target p1 to the Image placeholder column and p2 to your Row ID column.
Additionally, you need to insert your own API data into the code at the end of the provided script (read the comments in the script).

Script
function generateAnnotatedImageURI(imageSource, rowID) {
  const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Annotated Image</title>
  <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<style>
:root {
    --text-color-light: #1f2937;
    --text-color-dark: #cccccc; 
}

body {
    color: var(--text-color-light);
    transition: background-color 0.5s, color 0.5s;
}

body.dark {
    color: var(--text-color-dark); 
}


label, .color-option {
    color: inherit;
    transition: color 0.5s; 
}

body.dark label, 
body.dark .color-option {
    color: var(--text-color-dark);
}

canvas { 
    border-radius: 0.5rem;
    display: block; 
    margin: 0 auto; 
    width: 100%; 
    height: auto; 
}

.color-option {
    border: 3px solid transparent;
    border-radius: 50%;
    width: 24px;
    height: 24px;
    cursor: pointer;
    display: inline-block;
}

.color-option.selected {
    border-color: white;
}
#save:hover {
    opacity: 0.9;
}
</style>

<body class="flex justify-center items-center min-h-screen m-0">
    <div class="relative text-center w-full">
        <div class="canvas-container" style="margin-bottom: 20px; width: 100%;">
            <canvas id="canvas" style="width: 100%; height: auto;"></canvas>
        </div>

        <div class="controls" style="display: grid; grid-template-columns: 1fr auto; gap: 10px; width: 100%;">
            <div class="control-content">
                <div class="flex flex-col md:flex-row items-start mt-2 gap-2">
                    <div class="flex items-center mb-2 md:mb-0">
                        <label class="mr-2">Size:</label>
                        <input type="range" id="sizeRange" min="1" max="50" value="5" class="w-32 h-2 bg-gray-300 rounded-lg appearance-none cursor-pointer" style="outline:none">
                        <span id="sizeValue" class="ml-2" style="font-size: 0.825rem; width: 20px; text-align: center; display: inline-block;">5</span> 
                    </div>
                    <div class="flex items-center mb-2 md:mt-0">
                        <label class="mr-2">Color:</label>
                        <div class="color-option cursor-pointer" data-color="black" style="background-color: #323232;"></div>
                        <div class="color-option cursor-pointer ml-1" data-color="red" style="background-color: red;"></div>
                        <div class="color-option cursor-pointer ml-1" data-color="blue" style="background-color: blue;"></div>
                        <div class="color-option cursor-pointer ml-1" data-color="green" style="background-color: green;"></div>
                        <div class="color-option cursor-pointer ml-1" data-color="yellow" style="background-color: yellow;"></div>
                    </div>
                </div>
                <div class="flex justify-start mb-2 mt-2 gap-2">
                    <button id="clear" class="border rounded px-3 py-1 bg-gray-200 hover:bg-gray-300 text-gray-800 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500">Clear</button>
                    <button id="save" class="border rounded px-3 py-1" style="background-color: orange; color: white;">Save</button>
                    <button id="download" class="border rounded px-3 py-1 bg-gray-200 hover:bg-gray-300" title="Download">
                        <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                            <path d="M3 12V17C3 19.2091 4.79086 21 7 21H17C19.2091 21 21 19.2091 21 17V12M7 11L12 16L17 11M12 3L12 15.5" stroke="black" stroke-width="1.5" vector-effect="non-scaling-stroke"></path>
                        </svg>
                    </button>
                </div>
            </div>
            <div class="flex gap-2 justify-self-end align-self-start items-start" style="position: relative; top: 0;">
                <button id="undo" class="icon-button p-2 bg-white rounded hover:opacity-70 text-gray-800 dark:text-white" title="Undo" disabled>
                    <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path>
                    </svg>
                </button>
                <button id="redo" class="icon-button p-2 bg-white rounded hover:opacity-70 text-gray-800 dark:text-white" title="Redo" disabled>
                    <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6"></path>
                    </svg>
                </button>
            </div>
        </div>
    </div>

<script>
    // Detect and apply theme
    const prefersDarkScheme = window.matchMedia("(prefers-color-scheme: dark)");
    if (prefersDarkScheme.matches) {
      document.body.classList.add("dark");
    }

    prefersDarkScheme.addEventListener("change", (e) => {
      if (e.matches) {
        document.body.classList.add("dark");
      } else {
        document.body.classList.remove("dark");
      }
    });

    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");

    const img = new Image();
    img.crossOrigin = "Anonymous"; 
    img.src = "${imageSource}";
    img.onload = () => {
      canvas.width = img.width;
      canvas.height = img.height;
      ctx.drawImage(img, 0, 0, img.width, img.height);
      saveState();
    };

    let color = "black";
    let size = document.getElementById("sizeRange").value;
    let isDrawing = false;
    let history = [];
    let historyStep = -1;

    document.getElementById("sizeRange").oninput = (e) => {
      size = e.target.value;
      document.getElementById("sizeValue").textContent = size;
    };

document.querySelectorAll(".color-option").forEach((colorOption) => {
  colorOption.onclick = () => {
    color = colorOption.getAttribute('data-color');
    document.querySelectorAll(".color-option").forEach(opt => opt.classList.remove('selected'));
    colorOption.classList.add('selected');
  };
});

document.querySelectorAll(".color-option")[0].classList.add('selected');


    document.getElementById("clear").onclick = () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.drawImage(img, 0, 0, img.width, img.height);
      saveState();
    };

    function getMousePos(canvas, evt) {
      const rect = canvas.getBoundingClientRect();
      const scaleX = canvas.width / rect.width;
      const scaleY = canvas.height / rect.height;
      return {
        x: (evt.clientX - rect.left) * scaleX,
        y: (evt.clientY - rect.top) * scaleY
      };
    }

    canvas.onmousedown = (e) => {
      isDrawing = true;
      ctx.beginPath();
      ctx.lineWidth = size;
      ctx.strokeStyle = color;
      ctx.lineJoin = "round";
      ctx.lineCap = "round";
      const pos = getMousePos(canvas, e);
      ctx.moveTo(pos.x, pos.y);
    };

    canvas.onmousemove = (e) => {
      if (isDrawing) {
        const pos = getMousePos(canvas, e);
        ctx.lineTo(pos.x, pos.y);
        ctx.stroke();
      }
    };

    canvas.onmouseup = () => {
      isDrawing = false;
      saveState();
    };

    function saveState() {
      if (historyStep < history.length - 1) {
        history = history.slice(0, historyStep + 1);
      }
      history.push(canvas.toDataURL());
      historyStep++;
      updateUndoRedo();
    }

    function updateUndoRedo() {
      document.getElementById("undo").disabled = historyStep <= 0;
      document.getElementById("redo").disabled = historyStep >= history.length - 1;
    }

    document.getElementById("undo").onclick = () => {
      if (historyStep > 0) {
        historyStep--;
        const imgData = new Image();
        imgData.src = history[historyStep];
        imgData.onload = () => {
          ctx.clearRect(0, 0, canvas.width, canvas.height);
          ctx.drawImage(imgData, 0, 0, imgData.width, imgData.height);
        };
      }
      updateUndoRedo();
    };

    document.getElementById("redo").onclick = () => {
      if (historyStep < history.length - 1) {
        historyStep++;
        const imgData = new Image();
        imgData.src = history[historyStep];
        imgData.onload = () => {
          ctx.clearRect(0, 0, canvas.width, canvas.height);
          ctx.drawImage(imgData, 0, 0, imgData.width, imgData.height);
        };
      }
      updateUndoRedo();
    };

    document.getElementById("save").onclick = () => {
      const dataURL = canvas.toDataURL("image/png");
      document.getElementById("save").innerText = "Saving...";
      document.getElementById("save").disabled = true;


      fetch('https://api.glideapp.io/api/function/mutateTables', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': 'Bearer [YOUR_API_KEY]' // Replace this with your API key
        },
        body: JSON.stringify({
          appID: "[YOUR_APP_ID]", // Replace this with your Glide app ID
          mutations: [
            {
              kind: "set-columns-in-row",
              tableName: "[YOUR_TABLE_NAME]", // Replace this with the table name in your Glide app
              columnValues: {
                "[COLUMN_NAME]": dataURL // Replace [COLUMN_NAME] with the name of the column where you want to store the image URL
              },
              rowID: "${rowID}"
            }
          ]
        })
      })
      .then(response => response.json())
      .then(data => {
        console.log('Success:', data);
        document.getElementById("save").innerText = "Save";
        document.getElementById("save").disabled = false;
      })
      .catch((error) => {
        console.error('Error:', error);
        alert("Error saving image. Please try again."); 
        document.getElementById("save").innerText = "Save";
        document.getElementById("save").disabled = false;
      });
    };

document.getElementById("download").onclick = () => {
      const link = document.createElement("a");
      link.href = canvas.toDataURL();
      link.download = "image.png";
      link.click();
    };
  </script>
</body>
</html>
  `;

return `data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`;
}

const imageURI = generateAnnotatedImageURI(p1, p2);
p1 = imageURI;
return imageURI; 

As an additional note, to make your web-embed component accept background transparency, use the following CSS with the class name color-scheme.

.color-scheme {
  color-scheme: auto;
}
4 Likes

You wizard! :smiley:

First of all, THANKS!.

I keep getting error when i save. I have quadruple-checked API_KEY, APP-ID, TABLE_NAME, COLUMN_NAME and rowID. Can you see what im doing wrong?

Script
  fetch('https://api.glideapp.io/api/function/mutateTables', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer XXXX'
    },
    body: JSON.stringify({
      appID: "YYYY",
      mutations: [
        {
          kind: "set-columns-in-row",
          tableName: "ZZZZ",
          columnValues: {
            "AAAAAA": dataURL
          },
          rowID: "BBBB"
        }
      ]
    })
  })

Also, can “${rowID}” be set to p2 directly? - or do i need to convert p2 to string first?

I have a few comments that are excessive. Try to retain the row ID by taking the value from p2. So the writing would be:

          rowID: "${rowID}" 

Hmm, still error?

Script
  fetch('https://api.glideapp.io/api/function/mutateTables', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer 11111111-XXXX-XXXX-XXXX-XXXXXXXXXXXX'
    },
    body: JSON.stringify({
      appID: "XXXXXXXXXXXXXXXXXXXX",
      mutations: [
        {
          kind: "set-columns-in-row",
          tableName: "native-table-ZZZZZZZZZZZZZZZZZZZZ",
          columnValues: {
            "AAAAA": dataURL
          },
          rowID: "${rowID}"
        }
      ]
    })
  })

Btw, Download works, so im guessing its something in the API. I have compared API to some of my other scripts, cant find diffence.

So you’re having save problems?

Yeah, I keep getting error when i save

I’ll try to provide the original script with the API I’ve manipulated so you can check where the error is.

Script
function generateAnnotatedImageURI(imageSource, rowID) {
  const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Annotated Image</title>
  <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<style>
:root {
    --text-color-light: #1f2937;
    --text-color-dark: #cccccc; 
}

body {
    color: var(--text-color-light);
    transition: background-color 0.5s, color 0.5s;
}

body.dark {
    color: var(--text-color-dark); 
}


label, .color-option {
    color: inherit;
    transition: color 0.5s; 
}

body.dark label, 
body.dark .color-option {
    color: var(--text-color-dark);
}

canvas { 
    border-radius: 0.5rem;
    display: block; 
    margin: 0 auto; 
    width: 100%; 
    height: auto; 
}

.color-option {
    border: 3px solid transparent;
    border-radius: 50%;
    width: 24px;
    height: 24px;
    cursor: pointer;
    display: inline-block;
}

.color-option.selected {
    border-color: white;
}
#save:hover {
    opacity: 0.9;
}
</style>

<body class="flex justify-center items-center min-h-screen m-0">
    <div class="relative text-center w-full">
        <div class="canvas-container" style="margin-bottom: 20px; width: 100%;">
            <canvas id="canvas" style="width: 100%; height: auto;"></canvas>
        </div>

        <div class="controls" style="display: grid; grid-template-columns: 1fr auto; gap: 10px; width: 100%;">
            <div class="control-content">
                <div class="flex flex-col md:flex-row items-start mt-2 gap-2">
                    <div class="flex items-center mb-2 md:mb-0">
                        <label class="mr-2">Size:</label>
                        <input type="range" id="sizeRange" min="1" max="50" value="5" class="w-32 h-2 bg-gray-300 rounded-lg appearance-none cursor-pointer" style="outline:none">
                        <span id="sizeValue" class="ml-2" style="font-size: 0.825rem; width: 20px; text-align: center; display: inline-block;">5</span> 
                    </div>
                    <div class="flex items-center mb-2 md:mt-0">
                        <label class="mr-2">Color:</label>
                        <div class="color-option cursor-pointer" data-color="black" style="background-color: #323232;"></div>
                        <div class="color-option cursor-pointer ml-1" data-color="red" style="background-color: red;"></div>
                        <div class="color-option cursor-pointer ml-1" data-color="blue" style="background-color: blue;"></div>
                        <div class="color-option cursor-pointer ml-1" data-color="green" style="background-color: green;"></div>
                        <div class="color-option cursor-pointer ml-1" data-color="yellow" style="background-color: yellow;"></div>
                    </div>
                </div>
                <div class="flex justify-start mt-2 mb-2 gap-2">
                    <button id="clear" class="border rounded px-3 py-1 bg-gray-200 hover:bg-gray-300 text-gray-800 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500">Clear</button>
                    <button id="save" class="border rounded px-3 py-1" style="background-color: orange; color: white;">Save</button>
                    <button id="download" class="border rounded px-3 py-1 bg-gray-200 hover:bg-gray-300" title="Download">
                        <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                            <path d="M3 12V17C3 19.2091 4.79086 21 7 21H17C19.2091 21 21 19.2091 21 17V12M7 11L12 16L17 11M12 3L12 15.5" stroke="black" stroke-width="1.5" vector-effect="non-scaling-stroke"></path>
                        </svg>
                    </button>
                </div>
            </div>
            <div class="flex gap-2 justify-self-end align-self-start items-start" style="position: relative; top: 0;">
                <button id="undo" class="icon-button p-2 bg-white rounded hover:opacity-70 text-gray-800 dark:text-white" title="Undo" disabled>
                    <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path>
                    </svg>
                </button>
                <button id="redo" class="icon-button p-2 bg-white rounded hover:opacity-70 text-gray-800 dark:text-white" title="Redo" disabled>
                    <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6"></path>
                    </svg>
                </button>
            </div>
        </div>
    </div>

<script>
    // Detect and apply theme
    const prefersDarkScheme = window.matchMedia("(prefers-color-scheme: dark)");
    if (prefersDarkScheme.matches) {
      document.body.classList.add("dark");
    }

    prefersDarkScheme.addEventListener("change", (e) => {
      if (e.matches) {
        document.body.classList.add("dark");
      } else {
        document.body.classList.remove("dark");
      }
    });

    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");

    const img = new Image();
    img.crossOrigin = "Anonymous"; 
    img.src = "${imageSource}";
    img.onload = () => {
      canvas.width = img.width;
      canvas.height = img.height;
      ctx.drawImage(img, 0, 0, img.width, img.height);
      saveState();
    };

    let color = "black";
    let size = document.getElementById("sizeRange").value;
    let isDrawing = false;
    let history = [];
    let historyStep = -1;

    document.getElementById("sizeRange").oninput = (e) => {
      size = e.target.value;
      document.getElementById("sizeValue").textContent = size;
    };

document.querySelectorAll(".color-option").forEach((colorOption) => {
  colorOption.onclick = () => {
    color = colorOption.getAttribute('data-color');
    document.querySelectorAll(".color-option").forEach(opt => opt.classList.remove('selected'));
    colorOption.classList.add('selected');
  };
});

document.querySelectorAll(".color-option")[0].classList.add('selected');


    document.getElementById("clear").onclick = () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.drawImage(img, 0, 0, img.width, img.height);
      saveState();
    };

    function getMousePos(canvas, evt) {
      const rect = canvas.getBoundingClientRect();
      const scaleX = canvas.width / rect.width;
      const scaleY = canvas.height / rect.height;
      return {
        x: (evt.clientX - rect.left) * scaleX,
        y: (evt.clientY - rect.top) * scaleY
      };
    }

    canvas.onmousedown = (e) => {
      isDrawing = true;
      ctx.beginPath();
      ctx.lineWidth = size;
      ctx.strokeStyle = color;
      ctx.lineJoin = "round";
      ctx.lineCap = "round";
      const pos = getMousePos(canvas, e);
      ctx.moveTo(pos.x, pos.y);
    };

    canvas.onmousemove = (e) => {
      if (isDrawing) {
        const pos = getMousePos(canvas, e);
        ctx.lineTo(pos.x, pos.y);
        ctx.stroke();
      }
    };

    canvas.onmouseup = () => {
      isDrawing = false;
      saveState();
    };

    function saveState() {
      if (historyStep < history.length - 1) {
        history = history.slice(0, historyStep + 1);
      }
      history.push(canvas.toDataURL());
      historyStep++;
      updateUndoRedo();
    }

    function updateUndoRedo() {
      document.getElementById("undo").disabled = historyStep <= 0;
      document.getElementById("redo").disabled = historyStep >= history.length - 1;
    }

    document.getElementById("undo").onclick = () => {
      if (historyStep > 0) {
        historyStep--;
        const imgData = new Image();
        imgData.src = history[historyStep];
        imgData.onload = () => {
          ctx.clearRect(0, 0, canvas.width, canvas.height);
          ctx.drawImage(imgData, 0, 0, imgData.width, imgData.height);
        };
      }
      updateUndoRedo();
    };

    document.getElementById("redo").onclick = () => {
      if (historyStep < history.length - 1) {
        historyStep++;
        const imgData = new Image();
        imgData.src = history[historyStep];
        imgData.onload = () => {
          ctx.clearRect(0, 0, canvas.width, canvas.height);
          ctx.drawImage(imgData, 0, 0, imgData.width, imgData.height);
        };
      }
      updateUndoRedo();
    };

    document.getElementById("save").onclick = () => {
      const dataURL = canvas.toDataURL("image/png");
      document.getElementById("save").innerText = "Saving...";
      document.getElementById("save").disabled = true;


      fetch('https://api.glideapp.io/api/function/mutateTables', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': 'Bearer d116a077-gce2-480a-bb88-78115353a629'
        },
        body: JSON.stringify({
          appID: "dKdkrSQrNQPy0SY4Ax6M",
          mutations: [
            {
              kind: "set-columns-in-row",
              tableName: "native-table-xedUieLHU43F8SR68jxa",
              columnValues: {
                "SXOYm": dataURL 
              },
              rowID: "${rowID}"
            }
          ]
        })
      })
      .then(response => response.json())
      .then(data => {
        console.log('Success:', data);
        document.getElementById("save").innerText = "Save";
        document.getElementById("save").disabled = false;
      })
      .catch((error) => {
        console.error('Error:', error);
        alert("Error saving image. Please try again.");
        document.getElementById("save").innerText = "Save";
        document.getElementById("save").disabled = false;
      });
    };

document.getElementById("download").onclick = () => {
      const link = document.createElement("a");
      link.href = canvas.toDataURL();
      link.download = "image.png";
      link.click();
    };
  </script>
</body>
</html>
  `;


  return `data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`;
}

const imageURI = generateAnnotatedImageURI(p1, p2);
p1 = imageURI;
return imageURI;