Skip to content

Feature Request: Automatic handling of images on editor paste #792

@theolivenbaum

Description

@theolivenbaum

It would be great if images could be paste in the editor and automatically stored to a file next to the markdown file, for example by storing it under an assets folder.

I've a prototype for this feature that uses a custom html file to observe "paste" events and replace any images with their base64-encoded version, and a helper C# project that monitors changes to the .md files, extract inline images and stores them in an assets folder next to the .md file. It would be great to have this kind of functionality out of the box.

Code for listening and modifying paste events on the editor:

<script>
(function() {
    const hookedEditors = new Set();

    const readSyncDataURL=function(file) {
        var url=URL.createObjectURL(file);
        var xhr=new XMLHttpRequest();
        xhr.open("GET",url,false); 
        xhr.overrideMimeType("text/plain; charset=x-user-defined");
        xhr.send();
        URL.revokeObjectURL(url);
        var returnText="";
        for (var i=0;i<xhr.responseText.length;i++){
        returnText+=String.fromCharCode(xhr.responseText.charCodeAt(i)&0xff);};
        return "data:"+file.type+";base64,"+btoa(returnText);
    }

    function setupImagePasteHook(editor) {
        if (hookedEditors.has(editor)) return;
        hookedEditors.add(editor);

        editor.addEventListener('paste', (event) => {
            const items = (event.clipboardData || event.originalEvent.clipboardData).items;

            for (const item of items) {
                if (item.type.indexOf('image') !== -1) {
                    event.preventDefault();
                    event.stopPropagation();
                    const base64Data = readSyncDataURL(item.getAsFile());
                    const markdownImage = `![Image Subtitle](${base64Data})`;
                    console.log(markdownImage);

                    var dT = null;
                    try{ dT = new DataTransfer();} catch(e){}
                    var evt = new ClipboardEvent('paste', {clipboardData: dT});
                    console.log('clipboardData available: ', !!evt.clipboardData);
                    evt.clipboardData.setData('text/plain', markdownImage);
                    event.target.dispatchEvent(evt);
                }
            }
        }, true);
    }

    function scanAndHook() {
        var retypeEditor = document.body.querySelector("#retype-editor-root");
        if (typeof retypeEditor === 'undefined' || !retypeEditor) return;
        
        setupImagePasteHook(retypeEditor);
    }

    const observer = new MutationObserver((mutations) => {
        let shouldCheck = false;
        for (const mutation of mutations) {
            if (mutation.addedNodes.length > 0) {
                shouldCheck = true;
                break;
            }
        }
        
        if (shouldCheck) {
            setTimeout(scanAndHook, 100);
        }
    });

    document.addEventListener('DOMContentLoaded', function() {
            observer.observe(document.body, {
                childList: true,
                subtree: true
            });
        
            scanAndHook();
    }, false);
    
    console.log("Clipboard Image to Base64 Text handler active.");
})();
</script>

Helper project that monitors for changes to the markdown files and extracts any images

using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Diagnostics;

var  base64ImageRegex = new Regex(@"!\[(?<subtitle>.*?)\]\(data:image\/(?<ext>png|jpg|jpeg|gif);base64,(?<data>.*?)\)", RegexOptions.Compiled);

string path = Directory.GetCurrentDirectory();

Console.WriteLine($"Image: Starting retype & watching for .md changes in: {path}");

var tcs = new TaskCompletionSource();
using var watcher = new FileSystemWatcher(path);


watcher.Filter = "*.md";
watcher.IncludeSubdirectories = true;
watcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName;

watcher.Changed += OnChanged;
watcher.Created += OnChanged;

watcher.EnableRaisingEvents = true;

var pi = new ProcessStartInfo()
{
    RedirectStandardOutput = true,
    RedirectStandardError  = true,
    UseShellExecute        = false,
    Arguments = "start --pro",
    FileName = "retype",
    WorkingDirectory = path,
    CreateNoWindow         = true,
    WindowStyle            = ProcessWindowStyle.Hidden
};

var process = new Process
{
    StartInfo           = pi,
    EnableRaisingEvents = true
};

process.ErrorDataReceived  += Retype_ErrorDataReceived;


process.OutputDataReceived += Retype_OutputDataReceived;


process.Exited             += Retype_Exited;

Console.CancelKeyPress += (_, __) =>
{
    try
    {
        process.Kill();
    }
    catch
    {
        //Ignore
    }
    tcs.TrySetResult();
    Environment.Exit(0);
};

void Retype_ErrorDataReceived(object sender, DataReceivedEventArgs e)
{
    Console.WriteLine($"Retype: Error: {e?.Data ?? ""}");
}
void Retype_OutputDataReceived(object sender, DataReceivedEventArgs e)
{
    Console.WriteLine($"Retype: {e?.Data ?? ""}");
}

void Retype_Exited(object sender, EventArgs e)
{
    tcs.TrySetResult();
}

try
{
    process.Start();
    process.BeginOutputReadLine();
    process.BeginErrorReadLine();
}
catch (Exception E)
{
    Console.WriteLine($"Retype: Error starting retype: {E.Message}");
    tcs.TrySetException(E);
}

await tcs.Task;

void OnChanged(object sender, FileSystemEventArgs e)
{
    // Small delay to ensure the writing process has finished and released the file lock
    Thread.Sleep(500);

    try
    {
        ProcessFile(e.FullPath);
    }
    catch (IOException ex)
    {
        Console.WriteLine($"Image: Could not access file {e.Name}: {ex.Message}");
    }
}

void ProcessFile(string filePath)
{
    string content = File.ReadAllText(filePath);

    var newContent = base64ImageRegex.Replace(content, match =>
    {

        string mdDir = Path.GetDirectoryName(filePath);
        string assetsDir = Path.Combine(mdDir, "assets");

        if (!Directory.Exists(assetsDir))
        {
            Directory.CreateDirectory(assetsDir);
            Console.WriteLine($"Image: Created assets directory: {assetsDir}");
        }

        string subtitle = match.Groups["subtitle"].Value;
        string extension = match.Groups["ext"].Value;
        string base64Data = match.Groups["data"].Value;

        try
        {
            byte[] imageBytes = Convert.FromBase64String(base64Data);

            // Create a short hash of the data to keep filenames unique but consistent
            string hash = GetShortHash(base64Data);
            string safeSubtitle = GetSafeFilename(subtitle);
            string fileName = $"{safeSubtitle}_{hash}.{extension}";
            string savePath = Path.Combine(assetsDir, fileName);

            if (!File.Exists(savePath))
            {
                File.WriteAllBytes(savePath, imageBytes);
                Console.WriteLine($"Image: Found image: {fileName} from {Path.GetFileName(filePath)}");
            }

            return $"![{subtitle}](/assets/{fileName})";
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Image: Error processing image '{subtitle}': {ex.Message}");
            return match.Value;
        }
    });

    if (newContent != content)
    {
        File.WriteAllText(filePath, newContent);
    }
}
string GetSafeFilename(string name)
{
    foreach (char c in Path.GetInvalidFileNameChars())
        name = name.Replace(c, '_');
    return string.IsNullOrWhiteSpace(name) ? "image" : name.Replace(" ", "_");
}

string GetShortHash(string input)
{
    using var sha1 = SHA1.Create();
    var hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(input));
    return BitConverter.ToString(hash).Replace("-", "").Substring(0, 8).ToLower();
}

The C# code above is responsible for starting and stoping retyped, so it's run as:

dotnet run --project .\_helpers\retype-helper.csproj --

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions