R42-33G ぼかし選択枠と写真拡大を追加
R42-33H ぼかし範囲をドラッグ選択に変更
58行目: 58行目:
     status.setAttribute('role', 'status');
     status.setAttribute('role', 'status');
     const preview = document.createElement('img');
     const preview = document.createElement('img');
     preview.alt = '公開前の写真。ぼかす場所をクリックしてください';
     preview.alt = '公開前の写真。ぼかす範囲をドラッグしてください';
     preview.style.cssText = 'display:none;max-width:100%;max-height:600px;cursor:crosshair';
     preview.style.cssText = 'display:none;max-width:100%;max-height:600px;cursor:crosshair;touch-action:none;user-select:none';
     preview.draggable = false;
     preview.draggable = false;
     const viewport = document.createElement('div');
     const viewport = document.createElement('div');
106行目: 106行目:
     let previewUrl = '';
     let previewUrl = '';
     let busy = false;
     let busy = false;
    let drag = null;
     copy.disabled = true;
     copy.disabled = true;
     save.disabled = true;
     save.disabled = true;
148行目: 149行目:
         marks.replaceChildren();
         marks.replaceChildren();
         if (!state) return;
         if (!state) return;
         state.regions.forEach(function (region, index) {
         state.regions.concat(drag && drag.region ? [drag.region] : []).forEach(function (region, index) {
             const mark = document.createElement('span');
             const mark = document.createElement('span');
             mark.style.cssText = 'position:absolute;box-sizing:border-box;border:3px solid #e33;background:rgba(255,255,0,.25)';
             mark.style.cssText = 'position:absolute;box-sizing:border-box;border:3px solid #e33;background:rgba(255,255,0,.25)';
171行目: 172行目:
     async function prepare() {
     async function prepare() {
         state = null;
         state = null;
        drag = null;
         confirmed.checked = false;
         confirmed.checked = false;
         useGps.checked = false;
         useGps.checked = false;
207行目: 209行目:
         }
         }
     }
     }
     preview.addEventListener('click', function (e) {
     function imagePoint(e) {
        if (!state || state.uploaded || busy) return;
         const rect = preview.getBoundingClientRect();
         const rect = preview.getBoundingClientRect();
         const x = (e.clientX - rect.left) / rect.width;
         return {
        const y = (e.clientY - rect.top) / rect.height;
            x: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
         state.regions.push({
            y: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
             x: Math.max(0, x - 0.075), y: Math.max(0, y - 0.075),
        };
             width: Math.min(0.15, 1 - Math.max(0, x - 0.075)),
    }
             height: Math.min(0.15, 1 - Math.max(0, y - 0.075))
    preview.addEventListener('pointerdown', function (e) {
         });
         if (!state || state.uploaded || busy || (e.pointerType === 'mouse' && e.button !== 0)) return;
        e.preventDefault();
        const start = imagePoint(e);
        drag = { id: e.pointerId, start: start, region: null };
        preview.setPointerCapture(e.pointerId);
        status.textContent = 'ぼかしたい範囲をドラッグして、指を離してください。';
    });
    preview.addEventListener('pointermove', function (e) {
        if (!drag || drag.id !== e.pointerId) return;
        const end = imagePoint(e);
        drag.region = {
             x: Math.min(drag.start.x, end.x),
            y: Math.min(drag.start.y, end.y),
            width: Math.abs(drag.start.x - end.x),
             height: Math.abs(drag.start.y - end.y)
        };
        renderMarks();
    });
    preview.addEventListener('pointerup', function (e) {
        if (!drag || drag.id !== e.pointerId) return;
        const end = imagePoint(e);
        const region = {
            x: Math.min(drag.start.x, end.x),
             y: Math.min(drag.start.y, end.y),
            width: Math.abs(drag.start.x - end.x),
            height: Math.abs(drag.start.y - end.y)
        };
        drag = null;
        const rect = preview.getBoundingClientRect();
        if (region.width * rect.width < 5 || region.height * rect.height < 5) {
            status.textContent = '範囲が小さすぎます。写真の上をドラッグしてください。';
            renderMarks();
            return;
         }
        state.regions.push(region);
         confirmed.checked = false;
         confirmed.checked = false;
         status.textContent = 'ぼかす場所を指定しました。ぼかしを適用してください。';
         status.textContent = '赤枠を確認し、「選択した箇所をぼかす」を押してください。';
         renderMarks();
         renderMarks();
         controls();
         controls();
     });
     });
    preview.addEventListener('pointercancel', function () { drag = null; renderMarks(); });
     preview.addEventListener('dragstart', function (e) { e.preventDefault(); });
     preview.addEventListener('dragstart', function (e) { e.preventDefault(); });
     async function applyMosaic() {
     async function applyMosaic() {

2026年9月27日 (日) 02:55時点における版

(function () {
    'use strict';
    if (mw.config.get('wgPageName').startsWith('出店写真整理/')) {
        const actions = document.getElementById('stall-photo-draft-actions');
        if (!actions) return;
        const api = new mw.Api();
        ['done', 'discarded'].forEach(function (status) {
            const button = document.createElement('button');
            button.type = 'button';
            button.textContent = status === 'done' ? '出店情報への登録完了' : 'この整理待ちを取り消す';
            button.addEventListener('click', async function () {
                if (!confirm('整理待ちの状態を変更します。公開済みの画像は削除されません。続けますか?')) return;
                button.disabled = true;
                try {
                    const page = await api.get({
                        action: 'query', prop: 'revisions', rvprop: 'timestamp|content',
                        rvslots: 'main', titles: mw.config.get('wgPageName'), formatversion: 2
                    });
                    const rev = page.query.pages[0].revisions[0];
                    const content = rev.slots.main.content;
                    if (!/\|status=pending\n/.test(content)) throw new Error('整理待ちではないか、内容が変更されています');
                    await api.postWithToken('csrf', {
                        action: 'edit', title: mw.config.get('wgPageName'),
                        basetimestamp: rev.timestamp,
                        text: content.replace('|status=pending\n', '|status=' + status + '\n'),
                        summary: '屋台写真の整理状態を更新'
                    });
                    location.reload();
                } catch (e) {
                    button.disabled = false;
                    alert('更新できませんでした:' + (e.message || String(e)));
                }
            });
            actions.append(button, document.createTextNode(' '));
        });
        return;
    }
    if (mw.config.get('wgPageName') !== '屋台写真一括登録') return;
    const root = document.getElementById('stall-photo-batch');
    const safety = window.matsuriBatchPhotoSafety;
    if (!root || !safety) return;
    const api = new mw.Api();
    const params = new URLSearchParams(location.search);
    const festivalInput = document.createElement('input');
    festivalInput.type = 'number';
    festivalInput.min = '1';
    festivalInput.value = params.get('festival_id') || '';
    const yearInput = document.createElement('input');
    yearInput.type = 'number';
    yearInput.min = '2000';
    yearInput.max = '2100';
    yearInput.value = String(new Date().getFullYear());
    const picker = document.createElement('input');
    picker.type = 'file';
    picker.accept = 'image/jpeg,image/png,image/webp';
    picker.multiple = true;
    const status = document.createElement('p');
    status.setAttribute('role', 'status');
    const preview = document.createElement('img');
    preview.alt = '公開前の写真。ぼかす範囲をドラッグしてください';
    preview.style.cssText = 'display:none;max-width:100%;max-height:600px;cursor:crosshair;touch-action:none;user-select:none';
    preview.draggable = false;
    const viewport = document.createElement('div');
    viewport.style.cssText = 'max-width:100%;max-height:75vh;overflow:auto';
    const previewFrame = document.createElement('div');
    previewFrame.style.cssText = 'position:relative;display:inline-block;line-height:0';
    const marks = document.createElement('div');
    marks.style.cssText = 'position:absolute;inset:0;pointer-events:none';
    previewFrame.append(preview, marks);
    viewport.append(previewFrame);
    const zoom = button('写真を拡大', function () {
        const enlarged = preview.dataset.enlarged !== 'true';
        preview.dataset.enlarged = String(enlarged);
        preview.style.maxWidth = enlarged ? 'none' : '100%';
        preview.style.maxHeight = enlarged ? 'none' : '600px';
        preview.style.width = enlarged ? '900px' : 'auto';
        zoom.textContent = enlarged ? '写真を縮小' : '写真を拡大';
    });
    const gps = document.createElement('label');
    const useGps = document.createElement('input');
    useGps.type = 'checkbox';
    gps.append(useGps, document.createTextNode(' 写真のGPSをこの出店候補の位置に使う'));
    const confirm = document.createElement('label');
    const confirmed = document.createElement('input');
    confirmed.type = 'checkbox';
    confirm.append(confirmed, document.createTextNode(' 写真を確認し、写り込んだ人・個人情報を必要に応じてぼかしました。画像が公開されることを理解しました'));
    const blur = button('選択した箇所をぼかす', applyMosaic);
    const clear = button('ぼかし指定を解除', function () {
        if (state) { state.regions = []; confirmed.checked = false; showPreview(state.base.blob); }
    });
    const save = button('確認してアップロード・整理待ちに保存', upload);
    const skip = button('この写真を飛ばす', next);
    const copy = button('同じ写真から別の出店候補を作る', duplicate);
    const links = document.createElement('ul');
    root.append(
        label('祭りページID', festivalInput),
        label('開催年', yearInput),
        label('写真を複数選択(1枚ずつ確認します)', picker),
        status, zoom, viewport, gps, document.createElement('br'),
        confirm, document.createElement('br'), blur, clear, save, skip,
        document.createElement('br'), copy, links
    );
    let queue = [];
    let index = 0;
    let state = null;
    let previewUrl = '';
    let busy = false;
    let drag = null;
    copy.disabled = true;
    save.disabled = true;
    blur.disabled = true;
    clear.disabled = true;

    function label(title, control) {
        const el = document.createElement('label');
        el.textContent = title + ':';
        el.append(control, document.createElement('br'));
        return el;
    }
    function button(title, fn) {
        const el = document.createElement('button');
        el.type = 'button';
        el.textContent = title;
        el.addEventListener('click', function () {
            Promise.resolve().then(fn).catch(function (e) {
                status.textContent = '失敗しました。再試行できます:' + (e.message || String(e));
                busy = false;
                controls();
            });
        });
        return el;
    }
    function controls() {
        picker.disabled = busy;
        save.disabled = busy || !state || (state.uploaded && !state.needsDraft);
        skip.disabled = busy || !state;
        blur.disabled = busy || !state || !!state.uploaded || !state.regions.length;
        clear.disabled = busy || !state || !!state.uploaded || !state.regions.length;
        copy.disabled = busy || !state || !state.uploaded || state.needsDraft;
    }
    function showPreview(blob) {
        if (previewUrl) URL.revokeObjectURL(previewUrl);
        previewUrl = URL.createObjectURL(blob);
        preview.src = previewUrl;
        preview.style.display = 'block';
        renderMarks();
    }
    function renderMarks() {
        marks.replaceChildren();
        if (!state) return;
        state.regions.concat(drag && drag.region ? [drag.region] : []).forEach(function (region, index) {
            const mark = document.createElement('span');
            mark.style.cssText = 'position:absolute;box-sizing:border-box;border:3px solid #e33;background:rgba(255,255,0,.25)';
            mark.style.left = (region.x * 100) + '%';
            mark.style.top = (region.y * 100) + '%';
            mark.style.width = (region.width * 100) + '%';
            mark.style.height = (region.height * 100) + '%';
            mark.setAttribute('aria-label', 'ぼかし指定 ' + (index + 1));
            marks.append(mark);
        });
    }
    function fail(message) { throw new Error(message); }
    picker.addEventListener('change', function () {
        if (busy) return;
        queue = Array.from(picker.files).slice(0, 30);
        if (picker.files.length > 30) {
            status.textContent = '一度に選べるのは30枚までです。';
        }
        index = 0;
        prepare().catch(function (e) { status.textContent = e.message; busy = false; controls(); });
    });
    async function prepare() {
        state = null;
        drag = null;
        confirmed.checked = false;
        useGps.checked = false;
        if (!queue[index]) {
            status.textContent = '選択した写真の確認が終わりました。';
            preview.style.display = 'none';
            controls();
            return;
        }
        busy = true;
        controls();
        status.textContent = (index + 1) + '/' + queue.length + '枚目をブラウザ内で処理中…';
        try {
            const prepared = await safety.prepare(queue[index]);
            state = {
                base: prepared.safe,
                blob: prepared.safe.blob,
                gps: prepared.gps,
                regions: prepared.faces.regions.slice(),
                uploaded: '',
                count: 0
            };
            showPreview(state.blob);
            gps.style.display = state.gps ? 'block' : 'none';
            status.textContent = state.gps
                ? 'GPS候補があります。位置を使う場合だけチェックしてください。'
                : 'GPS候補はありません。位置は後から編集できます。';
            if (!prepared.faces.supported || prepared.faces.failed) {
                status.textContent += ' 顔の自動検出は利用できません。必ず目視で確認してください。';
            } else if (state.regions.length) {
                status.textContent += ' 顔候補を検出しました。「選択した箇所をぼかす」を押してください。';
            }
        } finally {
            busy = false;
            controls();
        }
    }
    function imagePoint(e) {
        const rect = preview.getBoundingClientRect();
        return {
            x: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
            y: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
        };
    }
    preview.addEventListener('pointerdown', function (e) {
        if (!state || state.uploaded || busy || (e.pointerType === 'mouse' && e.button !== 0)) return;
        e.preventDefault();
        const start = imagePoint(e);
        drag = { id: e.pointerId, start: start, region: null };
        preview.setPointerCapture(e.pointerId);
        status.textContent = 'ぼかしたい範囲をドラッグして、指を離してください。';
    });
    preview.addEventListener('pointermove', function (e) {
        if (!drag || drag.id !== e.pointerId) return;
        const end = imagePoint(e);
        drag.region = {
            x: Math.min(drag.start.x, end.x),
            y: Math.min(drag.start.y, end.y),
            width: Math.abs(drag.start.x - end.x),
            height: Math.abs(drag.start.y - end.y)
        };
        renderMarks();
    });
    preview.addEventListener('pointerup', function (e) {
        if (!drag || drag.id !== e.pointerId) return;
        const end = imagePoint(e);
        const region = {
            x: Math.min(drag.start.x, end.x),
            y: Math.min(drag.start.y, end.y),
            width: Math.abs(drag.start.x - end.x),
            height: Math.abs(drag.start.y - end.y)
        };
        drag = null;
        const rect = preview.getBoundingClientRect();
        if (region.width * rect.width < 5 || region.height * rect.height < 5) {
            status.textContent = '範囲が小さすぎます。写真の上をドラッグしてください。';
            renderMarks();
            return;
        }
        state.regions.push(region);
        confirmed.checked = false;
        status.textContent = '赤枠を確認し、「選択した箇所をぼかす」を押してください。';
        renderMarks();
        controls();
    });
    preview.addEventListener('pointercancel', function () { drag = null; renderMarks(); });
    preview.addEventListener('dragstart', function (e) { e.preventDefault(); });
    async function applyMosaic() {
        if (!state || !state.regions.length) return;
        busy = true;
        controls();
        try {
            const result = await safety.mosaic(
                state.base.blob, state.base.width, state.base.height,
                state.base.quality, state.regions
            );
            if (await safety.hasExif(result.blob)) fail('EXIF除去の検証に失敗しました');
            state.blob = result.blob;
            showPreview(state.blob);
            confirmed.checked = false;
            status.textContent = 'ぼかし後の画像を表示しました。もう一度確認してください。';
        } finally { busy = false; controls(); }
    }
    async function verifyFestival() {
        const id = Number(festivalInput.value);
        const year = Number(yearInput.value);
        if (!Number.isSafeInteger(id) || id < 1) fail('祭りページIDを指定してください');
        if (!Number.isInteger(year) || year < 2000 || year > 2100) fail('開催年を確認してください');
        const data = await api.get({
            action: 'cargoquery', tables: 'Festivals',
            fields: 'festival_id,name', where: 'festival_id=' + id, limit: 1
        });
        if (!data.cargoquery || !data.cargoquery[0] ||
            Number(data.cargoquery[0].title.festival_id) !== id) {
            fail('登録済みの祭りを選択してください');
        }
        return { id: id, year: year, name: data.cargoquery[0].title.name };
    }
    function title() { return '出店写真整理/' + crypto.randomUUID(); }
    async function createDraft(fileName, festival, coordinates) {
        const page = title();
        const text = '{{StallPhotoDraft\n|festival_id=' + festival.id +
            '\n|year=' + festival.year + '\n|file=' + fileName +
            '\n|latitude=' + (coordinates ? coordinates.latitude.toFixed(8) : '') +
            '\n|longitude=' + (coordinates ? coordinates.longitude.toFixed(8) : '') +
            '\n|status=pending\n}}';
        await api.postWithToken('csrf', {
            action: 'edit', title: page, text: text, createonly: 1,
            summary: '屋台写真を整理待ちに追加'
        });
        const li = document.createElement('li');
        const link = document.createElement('a');
        link.href = mw.util.getUrl(page);
        link.textContent = festival.name + ':写真の整理待ち ' + (state.count + 1);
        li.append(link);
        links.append(li);
        state.count++;
        return page;
    }
    async function upload() {
        if (!state || busy) return;
        if (state.uploaded && state.needsDraft) {
            busy = true;
            controls();
            try {
                await createDraft(state.uploaded, state.festival, state.coordinates);
                sessionStorage.removeItem('matsuriBatchUnlinkedFile');
                state.needsDraft = false;
                status.textContent = '整理待ちに保存しました。';
            } finally { busy = false; controls(); }
            return;
        }
        if (state.uploaded) return;
        if (!confirmed.checked) fail('写真の公開・写り込み確認が必要です');
        if (state.regions.length && state.blob === state.base.blob) {
            fail('指定されたぼかしを適用してください');
        }
        busy = true;
        controls();
        try {
            const festival = await verifyFestival();
            if (await safety.hasExif(state.blob)) fail('EXIF除去を確認できません');
            const filename = 'Matsuri_stall_batch_' + crypto.randomUUID() + '.jpg';
            const existing = await api.get({
                action: 'query', titles: 'File:' + filename, formatversion: 2
            });
            if (!existing.query.pages[0].missing) fail('ファイル名が重複しました');
            status.textContent = '安全処理済み写真をアップロード中…';
            const result = await api.upload(state.blob, {
                filename: filename,
                comment: '屋台写真の整理待ち。撮影情報を除去し、公開前に確認済み'
            });
            if (!result.upload || result.upload.result !== 'Success') {
                fail('アップロードに成功していません');
            }
            state.uploaded = result.upload.filename || filename;
            state.festival = festival;
            state.coordinates = useGps.checked ? state.gps : null;
            state.needsDraft = true;
            sessionStorage.setItem('matsuriBatchUnlinkedFile', state.uploaded);
            await createDraft(state.uploaded, festival, state.coordinates);
            sessionStorage.removeItem('matsuriBatchUnlinkedFile');
            state.needsDraft = false;
            status.textContent = '整理待ちに保存しました。別の屋台が写っている場合は同じ写真から複製できます。';
        } finally { busy = false; controls(); }
    }
    async function duplicate() {
        if (!state || !state.uploaded || busy) return;
        busy = true;
        controls();
        try {
            await createDraft(state.uploaded, state.festival, state.coordinates);
            status.textContent = '同じ写真を参照する整理待ち記録を追加しました。';
        } finally { busy = false; controls(); }
    }
    function next() {
        if (busy) return;
        index++;
        prepare().catch(function (e) { status.textContent = e.message; busy = false; controls(); });
    }
    const dangling = sessionStorage.getItem('matsuriBatchUnlinkedFile');
    if (dangling) status.textContent = '前回、画像の公開後に整理待ちへの保存が中断した可能性があります。ファイル名:' + dangling + '。重複アップロードせず管理者に確認してください。';
}());