Tier Placeholder Tile

Create editable placeholder tiles and auto-insert/upload them into TierMaker templates

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Tier Placeholder Tile
// @namespace    http://tampermonkey.net/
// @version      2.1.4
// @description  Create editable placeholder tiles and auto-insert/upload them into TierMaker templates
// @author       Liminality Dreams
// @match        https://tiermaker.com/*
// @icon         https://i.pinimg.com/736x/ae/d8/49/aed849cca00ffd2a756b107ae91075f9.jpg
// @grant        none
// @license GNU 3.0
// ==/UserScript==

(function() {
    'use strict';

    function $(s, ctx=document){return ctx.querySelector(s)}
    function $all(s, ctx=document){return Array.from((ctx||document).querySelectorAll(s))}

    function ensureUi() {
        if (document.getElementById('tm-placeholder-ui')) return;
        const ui = document.createElement('div');
        ui.id = 'tm-placeholder-ui';
        ui.style.position = 'fixed';
        ui.style.right = '12px';
        ui.style.top = '12px';
        ui.style.zIndex = 2147483647;
        ui.style.width = '300px';
        ui.style.fontFamily = 'Inter, Arial, sans-serif';
        ui.style.boxShadow = '0 8px 24px rgba(0,0,0,0.4)';
        ui.style.borderRadius = '12px';
        ui.style.background = '#0f0f10';
        ui.style.color = '#eee';
        ui.style.padding = '8px';
        ui.style.backdropFilter = 'blur(4px)';
        ui.innerHTML = `
            <div id="tm-header" style="display:flex;align-items:center;gap:8px">
                <img id="tm-icon" src="https://i.pinimg.com/736x/ae/d8/49/aed849cca00ffd2a756b107ae91075f9.jpg" style="width:34px;height:34px;border-radius:6px;flex:0 0 34px;object-fit:cover" />
                <div style="flex:1">
                    <div style="font-weight:600">Tier Placeholder Tile</div>
                    <div style="font-size:11px;opacity:.7">Create, drag, upload</div>
                </div>
                <button id="tm-min" style="background:#111;border:1px solid #222;color:#ddd;padding:6px;border-radius:6px;cursor:pointer">—</button>
            </div>
            <div id="tm-body" style="margin-top:8px">
                <label style="font-size:12px;opacity:.8">Text</label>
                <input id="tm-text" type="text" maxlength="40" placeholder="placeholder text" style="width:100%;padding:6px;margin-top:4px;border-radius:8px;border:1px solid #222;background:#09090a;color:#eee"/>
                <div style="display:flex;gap:8px;margin-top:8px;align-items:center">
                    <div style="flex:1">
                        <label style="font-size:12px;opacity:.8">Background</label>
                        <input id="tm-color" type="color" value="#b76bff" style="width:100%;height:36px;border-radius:8px;border:0;padding:0;margin-top:4px"/>
                    </div>
                    <div style="width:84px">
                        <label style="font-size:12px;opacity:.8">Shape</label>
                        <select id="tm-shape" style="width:100%;padding:6px;margin-top:4px;border-radius:8px;background:#09090a;color:#eee;border:1px solid #222">
                            <option value="square">Square</option>
                            <option value="round">Round</option>
                        </select>
                    </div>
                </div>
                <div style="display:flex;gap:8px;margin-top:8px;align-items:center">
                    <div style="flex:1">
                        <label style="font-size:12px;opacity:.8">Size</label>
                        <select id="tm-size" style="width:100%;padding:6px;margin-top:4px;border-radius:8px;background:#09090a;color:#eee;border:1px solid #222">
                            <option value="256">256×256</option>
                            <option value="400" selected>400×400</option>
                            <option value="600">600×600</option>
                        </select>
                    </div>
                    <div style="width:84px">
                        <label style="font-size:12px;opacity:.8">Font</label>
                        <select id="tm-fontsize" style="width:100%;padding:6px;margin-top:4px;border-radius:8px;background:#09090a;color:#eee;border:1px solid #222">
                            <option value="18">18px</option>
                            <option value="22" selected>22px</option>
                            <option value="30">30px</option>
                        </select>
                    </div>
                </div>
                <div style="display:flex;gap:8px;margin-top:10px">
                    <button id="tm-create" style="flex:1;padding:8px;border-radius:8px;background:linear-gradient(180deg,#2a2a2a,#111);border:1px solid #333;color:#fff;cursor:pointer">Create Placeholder</button>
                    <button id="tm-insert" style="flex:1;padding:8px;border-radius:8px;background:#122;border:1px solid #233;color:#dff;cursor:pointer">Create + Insert</button>
                </div>
                <div id="tm-preview-wrap" style="margin-top:10px;display:flex;gap:8px;align-items:center">
                    <div id="tm-preview" style="width:64px;height:64px;border-radius:6px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.2);display:flex;align-items:center;justify-content:center;overflow:hidden;background:#b76bff;"></div>
                    <div style="flex:1;font-size:12px;opacity:.8">Drag the preview into the tier area or press Create + Insert to auto-add to the upload input.</div>
                </div>
                <div id="tm-thumbs" style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap"></div>
            </div>
        `;
        document.body.appendChild(ui);

        const header = $('#tm-header', ui);
        const body = $('#tm-body', ui);
        const minBtn = $('#tm-min', ui);
        let minimized = false;
        minBtn.addEventListener('click', () => {
            minimized = !minimized;
            body.style.display = minimized ? 'none' : 'block';
            minBtn.textContent = minimized ? '+' : '—';
            ui.style.width = minimized ? '48px' : '300px';
        });

        const inputs = {
            text: $('#tm-text', ui),
            color: $('#tm-color', ui),
            shape: $('#tm-shape', ui),
            size: $('#tm-size', ui),
            fontsize: $('#tm-fontsize', ui),
            preview: $('#tm-preview', ui),
            thumbs: $('#tm-thumbs', ui),
            create: $('#tm-create', ui),
            insert: $('#tm-insert', ui)
        };

        function renderPreview() {
            const size = parseInt(inputs.size.value,10);
            const canvas = document.createElement('canvas');
            canvas.width = 200;
            canvas.height = 200;
            const ctx = canvas.getContext('2d');
            const bg = inputs.color.value;
            ctx.fillStyle = bg;
            if (inputs.shape.value === 'round') {
                ctx.beginPath();
                ctx.arc(100,100,100,0,Math.PI*2);
                ctx.fill();
            } else {
                ctx.fillRect(0,0,200,200);
            }
            const txt = inputs.text.value || 'Placeholder';
            ctx.fillStyle = '#0b0b0b';
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.font = `${parseInt(inputs.fontsize.value,10)}px Arial`;
            wrapTextCenter(ctx, txt, 100, 100, 170, parseInt(inputs.fontsize.value,10)+6);
            inputs.preview.style.backgroundImage = `url(${canvas.toDataURL()})`;
            inputs.preview.style.backgroundSize = 'cover';
            inputs.preview.draggable = true;
            inputs.preview.dataset.dataurl = canvas.toDataURL();
        }

        function wrapTextCenter(ctx, text, x, y, maxWidth, lineHeight) {
            const words = text.split(' ');
            let line = '';
            const lines = [];
            for (let n = 0; n < words.length; n++) {
                const testLine = line + words[n] + ' ';
                const metrics = ctx.measureText(testLine);
                const testWidth = metrics.width;
                if (testWidth > maxWidth && n > 0) {
                    lines.push(line.trim());
                    line = words[n] + ' ';
                } else {
                    line = testLine;
                }
            }
            lines.push(line.trim());
            const startY = y - (lines.length-1) * (lineHeight/2);
            for (let i = 0; i < lines.length; i++) {
                ctx.fillText(lines[i], x, startY + i * lineHeight);
            }
        }

        inputs.text.addEventListener('input', renderPreview);
        inputs.color.addEventListener('input', renderPreview);
        inputs.shape.addEventListener('change', renderPreview);
        inputs.size.addEventListener('change', renderPreview);
        inputs.fontsize.addEventListener('change', renderPreview);

        async function createCanvasDataURL() {
            const size = parseInt(inputs.size.value,10);
            const canvas = document.createElement('canvas');
            canvas.width = size;
            canvas.height = size;
            const ctx = canvas.getContext('2d');
            const bg = inputs.color.value;
            ctx.fillStyle = bg;
            if (inputs.shape.value === 'round') {
                ctx.beginPath();
                ctx.arc(size/2, size/2, size/2, 0, Math.PI*2);
                ctx.fill();
            } else {
                ctx.fillRect(0,0,size,size);
            }
            const txt = inputs.text.value || 'Placeholder';
            ctx.fillStyle = '#000000';
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.font = `${parseInt(inputs.fontsize.value,10)}px Arial`;
            wrapTextCenter(ctx, txt, size/2, size/2, size - 24, parseInt(inputs.fontsize.value,10)+8);
            return canvas.toDataURL('image/png');
        }

        function makeThumb(dataUrl, filename) {
            const el = document.createElement('div');
            el.style.width = '64px';
            el.style.height = '64px';
            el.style.borderRadius = '8px';
            el.style.overflow = 'hidden';
            el.style.backgroundImage = `url(${dataUrl})`;
            el.style.backgroundSize = 'cover';
            el.style.cursor = 'grab';
            el.draggable = true;
            el.dataset.url = dataUrl;
            el.dataset.filename = filename;
            el.addEventListener('dragstart', (e) => {
                try {
                    e.dataTransfer.setData('text/uri-list', dataUrl);
                    e.dataTransfer.setData('text/plain', filename);
                } catch (err) {}
                if (e.dataTransfer.items && e.dataTransfer.items.add) {
                    fetch(dataUrl).then(r=>r.blob()).then(blob=>{
                        const file = new File([blob], filename, {type: 'image/png'});
                        const dt = new DataTransfer();
                        dt.items.add(file);
                        try {
                            e.dataTransfer.items.clear();
                        } catch(e){}
                        try {
                            e.dataTransfer.items.add(file);
                        } catch(e){}
                    });
                }
            });
            el.addEventListener('click', async () => {
                const dt = new DataTransfer();
                const blob = await (await fetch(dataUrl)).blob();
                dt.items.add(new File([blob], filename, {type:'image/png'}));
                autoInsertFiles(dt.files);
            });
            inputs.thumbs.appendChild(el);
        }

        async function autoInsertFiles(fileList) {
            const fileInput = document.querySelector('input[type=file]');
            if (fileInput) {
                const dt = new DataTransfer();
                if (fileList instanceof FileList) {
                    for (let f of fileList) dt.items.add(f);
                } else if (Array.isArray(fileList)) {
                    for (let f of fileList) dt.items.add(f);
                } else {
                    dt.items.add(fileList);
                }
                fileInput.files = dt.files;
                const ev = new Event('change', {bubbles:true});
                fileInput.dispatchEvent(ev);
                return true;
            } else {
                const imagesContainer = findImagesContainer();
                if (imagesContainer) {
                    for (let f of fileList) {
                        const url = URL.createObjectURL(f);
                        insertLocalThumbnail(imagesContainer, url, f.name);
                    }
                    return true;
                }
            }
            return false;
        }

        function insertLocalThumbnail(container, url, name) {
            const thumbWrap = document.createElement('div');
            thumbWrap.style.width = '80px';
            thumbWrap.style.height = '80px';
            thumbWrap.style.margin = '6px';
            thumbWrap.style.borderRadius = '8px';
            thumbWrap.style.overflow = 'hidden';
            thumbWrap.style.backgroundImage = `url(${url})`;
            thumbWrap.style.backgroundSize = 'cover';
            container.appendChild(thumbWrap);
        }

        function findImagesContainer() {
            const candidates = $all('div');
            for (let c of candidates) {
                if (/No images have been uploaded|Upload images to be used in your tier list/i.test(c.innerText)) continue;
                if (c.querySelector && c.querySelector('img')) return c;
            }
            const alt = document.querySelector('.uploaded-images, .images, .tiles, .tm-image-list');
            return alt;
        }

        async function createAndOptionallyInsert(doInsert=false) {
            const dataUrl = await createCanvasDataURL();
            const filename = `placeholder-${Date.now()}.png`;
            makeThumb(dataUrl, filename);
            const blob = await (await fetch(dataUrl)).blob();
            const file = new File([blob], filename, {type:'image/png'});
            const dt = new DataTransfer();
            dt.items.add(file);
            const success = await autoInsertFiles(dt.files);
            if (!success) {
                // fallback: create floating drag image
                const floatImg = document.createElement('img');
                floatImg.src = dataUrl;
                floatImg.style.position = 'fixed';
                floatImg.style.right = '12px';
                floatImg.style.top = '80px';
                floatImg.style.width = '120px';
                floatImg.style.height = '120px';
                floatImg.style.zIndex = 2147483646;
                floatImg.style.border = '2px solid rgba(255,255,255,0.06)';
                floatImg.style.borderRadius = '10px';
                document.body.appendChild(floatImg);
                setTimeout(()=>{ floatImg.remove(); }, 4000);
            }
        }

        inputs.create.addEventListener('click', ()=>createAndOptionallyInsert(false));
        inputs.insert.addEventListener('click', ()=>createAndOptionallyInsert(true));

        inputs.preview.addEventListener('dragstart', (e)=>{
            const dataUrl = e.target.dataset.dataurl;
            const filename = `placeholder-${Date.now()}.png`;
            try {
                e.dataTransfer.setData('text/uri-list', dataUrl);
                e.dataTransfer.setData('text/plain', filename);
            } catch(err){}
            fetch(dataUrl).then(r=>r.blob()).then(blob=>{
                const file = new File([blob], filename, {type:'image/png'});
                try {
                    e.dataTransfer.items.clear();
                } catch(e){}
                try {
                    e.dataTransfer.items.add(file);
                } catch(e){}
            });
        });

        renderPreview();
        setupTierDropTargets();
    }

    function setupTierDropTargets() {
        const observer = new MutationObserver(()=>attachDropToTargets());
        observer.observe(document.body, {childList:true, subtree:true});
        attachDropToTargets();
    }

    function attachDropToTargets() {
        const targets = $all('.tier.sort, .tier.sortable, .tier, .sort');
        targets.forEach(t => {
            if (t.dataset.tmPlaceholderAttached) return;
            t.dataset.tmPlaceholderAttached = '1';
            t.addEventListener('dragover', (e)=>{ e.preventDefault(); });
            t.addEventListener('drop', async (e)=>{
                e.preventDefault();
                const items = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length ? e.dataTransfer.files : null;
                if (items) {
                    const dt = new DataTransfer();
                    for (let f of items) dt.items.add(f);
                    autoInsertFilesToTier(t, dt.files);
                    return;
                }
                const uri = e.dataTransfer.getData('text/uri-list') || e.dataTransfer.getData('text/plain');
                if (uri && uri.startsWith('data:')) {
                    const blob = await (await fetch(uri)).blob();
                    const file = new File([blob], `placeholder-${Date.now()}.png`, {type:'image/png'});
                    const dt = new DataTransfer();
                    dt.items.add(file);
                    autoInsertFilesToTier(t, dt.files);
                }
            }, {passive:false});
        });
    }

    function autoInsertFilesToTier(tierEl, fileList) {
        const fileInput = document.querySelector('input[type=file]');
        if (fileInput) {
            const dt = new DataTransfer();
            for (let f of fileList) dt.items.add(f);
            fileInput.files = dt.files;
            fileInput.dispatchEvent(new Event('change', {bubbles:true}));
            return;
        }
        const img = document.createElement('img');
        img.style.width = '80px';
        img.style.height = '80px';
        img.style.objectFit = 'cover';
        img.style.margin = '4px';
        img.style.borderRadius = '8px';
        const reader = new FileReader();
        reader.onload = ()=> {
            img.src = reader.result;
            tierEl.appendChild(img);
        };
        reader.readAsDataURL(fileList[0]);
    }

    ensureUi();

    function autoInsertFiles(files) {
        const fileInput = document.querySelector('input[type=file]');
        if (fileInput) {
            const dt = new DataTransfer();
            for (let f of files) dt.items.add(f);
            fileInput.files = dt.files;
            fileInput.dispatchEvent(new Event('change',{bubbles:true}));
            return true;
        }
        const imagesContainer = document.querySelector('.uploaded-images, .images, .tm-image-list');
        if (imagesContainer) {
            for (let f of files) {
                const url = URL.createObjectURL(f);
                const d = document.createElement('div');
                d.style.width='80px';d.style.height='80px';d.style.margin='6px';d.style.borderRadius='8px';d.style.backgroundImage=`url(${url})`;d.style.backgroundSize='cover';
                imagesContainer.appendChild(d);
            }
            return true;
        }
        return false;
    }

})();