// 自前 Markdown エディタ（Notion風）。ProseMirror を土台にした TipTap で構築し、
// 見た目・挙動は 100% こちら側（デザイントークン・ツールバー無し）。フレームワーク非依存で
// textarea を渡すだけで使える（元は Relay/backlog-clone から切り出した独立パッケージ）。
//
// Markdown を知らない人でも使えるよう、操作はマウスでも完結する：
//   ・行頭の「＋」ボタン … 空の行に出る「＋」からブロック（見出し/リスト/TODO…）を挿入（/不要）
//   ・「/」スラッシュメニュー … 入力中に「/」でも同じブロック一覧
//   ・選択時のバブルメニュー … 太字/斜体/取り消し線/コード/リンクをボタンで
//   ・もちろん Markdown ショートカットも有効（## +空白→見出し2、- →箇条書き、[] →TODO 等）
//
// 公開契約：textarea._richEditor = { getMarkdown(), setMarkdown(v), focus(), destroy() }
import { Editor, Extension } from '@tiptap/core';
import { PluginKey } from '@tiptap/pm/state';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
import { FloatingMenu } from '@tiptap/extension-floating-menu';
import Image from '@tiptap/extension-image';
import { Table, TableRow, TableHeader, TableCell } from '@tiptap/extension-table';
import Suggestion from '@tiptap/suggestion';
import { Markdown } from 'tiptap-markdown';
import { restoreDraft, scheduleSave, flushSave, clearDraft, showRestoredChip } from './drafts';

// 画像アップローダ（editor.__reUpload）へファイルを渡し、返った URL を本文へ挿入する。
// __reUpload は mountTiptap で解決した `async (file) => ({ url, alt })`。
function insertUploadedImage(file, editor) {
    const up = editor.__reUpload;
    if (typeof up !== 'function') { window.alert('このエディタでは画像を追加できません。'); return; }
    Promise.resolve(up(file))
        .then((res) => {
            if (res && res.url) editor.chain().focus().setImage({ src: res.url, alt: res.alt || '' }).run();
            else throw new Error('upload failed');
        })
        .catch(() => window.alert('画像のアップロードに失敗しました。'));
}

// options.upload 未指定時のフォールバック：uploadUrl へ CSRF 付き POST する既定アップローダ（Laravel 想定）。
// サーバは { url, alt } を JSON で返す。CSRF は options.csrfToken か meta[name=csrf-token] から拾う。
function defaultUploader(url, csrfToken) {
    return (file) => {
        const body = new FormData();
        body.append('file', file, file.name || 'image.png');
        const csrf = csrfToken || document.querySelector('meta[name="csrf-token"]')?.content;
        return fetch(url, {
            method: 'POST',
            headers: { 'X-CSRF-TOKEN': csrf, Accept: 'application/json' },
            body,
            credentials: 'same-origin',
        }).then((r) => (r.ok ? r.json() : Promise.reject(new Error('upload failed'))));
    };
}

// 「画像」ブロック：ファイル選択ダイアログを出し、選ばれた画像を __reUpload で送る。
function pickAndUploadImage(editor) {
    if (typeof editor.__reUpload !== 'function') { window.alert('このエディタでは画像を追加できません。'); return; }
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'image/*';
    input.style.display = 'none';
    input.addEventListener('change', () => {
        const file = input.files && input.files[0];
        if (file) insertUploadedImage(file, editor);
        input.remove();
    });
    document.body.appendChild(input);
    input.click();
}

// 「関連ファイル」ブロック：既存ファイルを選んで本文に挿入する。ホストが options.onPickFile を
// 渡したときだけ有効（editor.__reFilePick）。onPickFile は「挿入関数 insert」を受け取るので、
// ホストは自前のピッカーで選んだファイルを insert({ name, isImage, previewUrl, downloadUrl }) で
// 本文へ差し込める。画像はプレビューURLでその場に表示、その他はダウンロードリンクとして挿入する。
function openFileLinkPicker(editor) {
    const pick = editor.__reFilePick;
    if (typeof pick !== 'function') { window.alert('このエディタではファイルを挿入できません。'); return; }
    const insert = (file) => {
        if (!file) return;
        if (file.isImage && file.previewUrl) {
            editor.chain().focus().setImage({ src: file.previewUrl, alt: file.name || '' }).run();
        } else {
            editor.chain().focus().insertContent({
                type: 'text',
                text: file.name || 'file',
                marks: [{ type: 'link', attrs: { href: file.downloadUrl || file.href || '#' } }],
            }).run();
        }
    };
    pick(insert);
}

// ── 画像：サイズ変更（ドラッグハンドル）＋ インライン化（表セルにも置ける）──────────
// 画像は Markdown 保存のまま。幅は標準 Markdown に無いので、画像タイトル枠へ "width=NNN" として
// 埋め込む（![alt](url "width=300")）。読み込み側（markdown-it→parseHTML）は title から width 属性へ
// 復元し、表示側（App\Support\Markdown）は title=width を style 幅へ変換する。
function serializeImage(state, node) {
    const src = (node.attrs.src || '').replace(/[()]/g, '\\$&');
    const alt = state.esc ? state.esc(node.attrs.alt || '') : (node.attrs.alt || '');
    const w = parseInt(node.attrs.width, 10);
    const title = w ? ` "width=${w}"` : '';
    state.write(`![${alt}](${src}${title})`);
}

// 画像ノードビュー：右下のハンドルをドラッグして幅を変える。確定時に setNodeMarkup で
// width 属性を保存し、onUpdate 経由で Markdown（"width=NNN"）へ反映する。
function buildImageNodeView(node, editor, getPos) {
    const dom = document.createElement('span');
    dom.className = 'pm-img';
    const img = document.createElement('img');
    img.src = node.attrs.src || '';
    img.alt = node.attrs.alt || '';
    if (node.attrs.width) img.style.width = node.attrs.width + 'px';
    dom.appendChild(img);

    // 右端の縦グリップ（左右にドラッグ＝幅変更）と、右下コーナー（斜め下にドラッグ＝拡大縮小）。
    // 高さは縦横比に追従（height:auto）なので、どちらも幅を動かせば縦も比例して変わる。
    const handle = document.createElement('span');
    handle.className = 'pm-img-resize';
    handle.setAttribute('contenteditable', 'false');
    handle.setAttribute('aria-label', '画像の幅を変更（左右にドラッグ）');
    handle.dataset.tip = '左右にドラッグで幅を変更';
    handle.innerHTML = '<span class="pm-img-grip"></span>';
    dom.appendChild(handle);

    const corner = document.createElement('span');
    corner.className = 'pm-img-corner';
    corner.setAttribute('contenteditable', 'false');
    corner.setAttribute('aria-label', '画像を拡大縮小（斜め下にドラッグ）');
    corner.dataset.tip = '斜め下にドラッグで拡大縮小';
    dom.appendChild(corner);

    // ドラッグ中だけ出る現在幅のバッジ（今どのくらいかを数値で見せる）。
    const badge = document.createElement('span');
    badge.className = 'pm-img-size';
    badge.setAttribute('contenteditable', 'false');
    dom.appendChild(badge);

    let curNode = node;
    // useDiagonal=true（右下コーナー）は横/縦の動きのうち大きいほうを幅の増減に使い、斜め操作を素直に。
    // 縦移動は縦横比で幅へ換算する。useDiagonal=false（右端グリップ）は横移動だけを使う。
    const startResize = (e, useDiagonal) => {
        if (!editor.isEditable) return;
        e.preventDefault();
        e.stopPropagation();
        const startX = e.clientX;
        const startY = e.clientY;
        const startW = img.offsetWidth;
        const startH = img.offsetHeight || 1;
        const ratio = startW / startH; // 幅/高さ。縦移動を幅へ換算するのに使う。
        const maxW = (dom.parentElement && dom.parentElement.clientWidth) || 2000;
        let w = startW;
        dom.classList.add('pm-img-resizing');
        const paint = () => { img.style.width = w + 'px'; badge.textContent = w + 'px'; };
        paint();
        const move = (ev) => {
            const dx = ev.clientX - startX;
            const delta = useDiagonal
                ? (Math.abs(ev.clientY - startY) > Math.abs(dx) ? (ev.clientY - startY) * ratio : dx)
                : dx;
            w = Math.max(40, Math.min(maxW, Math.round(startW + delta)));
            paint();
        };
        const up = () => {
            document.removeEventListener('pointermove', move);
            document.removeEventListener('pointerup', up);
            dom.classList.remove('pm-img-resizing');
            const pos = typeof getPos === 'function' ? getPos() : null;
            if (typeof pos === 'number') {
                editor.view.dispatch(editor.view.state.tr.setNodeMarkup(pos, undefined, { ...curNode.attrs, width: w }));
            }
        };
        document.addEventListener('pointermove', move);
        document.addEventListener('pointerup', up);
    };
    handle.addEventListener('pointerdown', (e) => startResize(e, false));
    corner.addEventListener('pointerdown', (e) => startResize(e, true));

    return {
        dom,
        update(updated) {
            if (updated.type.name !== node.type.name) return false;
            curNode = updated;
            if (img.getAttribute('src') !== updated.attrs.src) img.src = updated.attrs.src || '';
            img.alt = updated.attrs.alt || '';
            img.style.width = updated.attrs.width ? updated.attrs.width + 'px' : '';
            return true;
        },
        selectNode() { dom.classList.add('pm-img-selected'); },
        deselectNode() { dom.classList.remove('pm-img-selected'); },
        ignoreMutation() { return true; },
        stopEvent(e) { return e.target === handle || e.target === corner || (handle.contains(e.target) || corner.contains(e.target)); },
    };
}

// インライン画像（表セルにも置ける）＋ 幅属性 ＋ ドラッグリサイズ ＋ GFM 対応の直列化を持つ Image。
const RelayImage = Image.extend({
    addAttributes() {
        return {
            ...(this.parent?.() ?? {}),
            width: {
                default: null,
                parseHTML: (el) => {
                    const sw = el.style && el.style.width ? parseInt(el.style.width, 10) : null;
                    if (sw) return sw;
                    const aw = el.getAttribute('width');
                    if (aw) return parseInt(aw, 10) || null;
                    const m = (el.getAttribute('title') || '').match(/^width=(\d+)$/);
                    return m ? parseInt(m[1], 10) : null;
                },
                renderHTML: (attrs) => (attrs.width ? { style: `width:${attrs.width}px` } : {}),
            },
            // markdown-it は "width=NNN" を title に載せて来る。ツールチップに出さないよう捨てる。
            title: {
                default: null,
                parseHTML: (el) => {
                    const t = el.getAttribute('title');
                    return t && /^width=\d+$/.test(t) ? null : t;
                },
            },
        };
    },
    addStorage() {
        const parent = this.parent?.() ?? {};
        return { ...parent, markdown: { serialize: serializeImage, parse: {} } };
    },
    addNodeView() {
        return ({ node, editor, getPos }) => buildImageNodeView(node, editor, getPos);
    },
}).configure({ inline: true, allowBase64: false });

// ── テーブルを GFM のパイプ表として Markdown 出力する（保存形式は Markdown のまま） ──
// tiptap-markdown は table ノードの直列化を持たないので、ここで table ノードを丸ごと
// GFM（| 見出し | … | / | --- | … |）へ書き出す。読み込み側は markdown-it が GFM 表を
// HTML 化 → Table 拡張の parseHTML で復元するため、別途パーサ指定は不要。
function renderCellInline(state, cell) {
    // セル内の各ブロック（段落など）のインライン内容だけを 1 行に畳んで取り出す。
    // prosemirror-markdown の state.out（出力バッファ）を一時退避して横取りする。
    const saved = state.out;
    state.out = '';
    let first = true;
    cell.forEach((block) => {
        if (!first) state.out += ' ';
        first = false;
        if (block.isTextblock) state.renderInline(block);
        else state.out += block.textContent;
    });
    const md = state.out;
    state.out = saved;
    // セル内は改行不可・パイプはエスケープ（GFM 表の制約）。
    return md.replace(/\r?\n+/g, ' ').replace(/\|/g, '\\|').trim();
}

function serializeTable(state, node) {
    state.flushClose(); // 直前ブロックとの区切り（空行）を先に確定し、以降の横取りを汚さない
    const rows = [];
    node.forEach((rowNode) => {
        const cells = [];
        rowNode.forEach((cellNode) => cells.push(renderCellInline(state, cellNode)));
        rows.push(cells);
    });
    if (!rows.length) { state.closeBlock(node); return; }
    const cols = rows.reduce((m, r) => Math.max(m, r.length), 0);
    const pad = (r) => { const c = r.slice(); while (c.length < cols) c.push(''); return c; };
    const fmt = (r) => '| ' + pad(r).map((c) => (c === '' ? ' ' : c)).join(' | ') + ' |';
    const lines = [fmt(rows[0]), '| ' + Array(cols).fill('---').join(' | ') + ' |'];
    for (let i = 1; i < rows.length; i++) lines.push(fmt(rows[i]));
    lines.forEach((ln, i) => { if (i > 0) state.ensureNewLine(); state.write(ln); });
    state.closeBlock(node);
}

// table ノードに GFM 直列化を持たせた版。配下の行/セルは serializeTable が一括処理する
// ため個別の直列化は不要（再帰せず table 単位で書き出す）。
const RelayTable = Table.extend({
    addStorage() {
        const parent = this.parent?.() ?? {};
        return { ...parent, markdown: { serialize: serializeTable, parse: {} } };
    },
}).configure({ resizable: true });

// @メンション拡張。data-mention-users（[{name,avatar,initials}]）を候補に、選ぶと「@表示名 」を
// プレーンテキストで挿入する（サーバは本文の [@＠]名前 をテキスト一致で検出するため互換）。
function makeMentionExtension(users) {
    return Extension.create({
        name: 'relayMention',
        addProseMirrorPlugins() {
            return [
                Suggestion({
                    pluginKey: new PluginKey('relayMention'),
                    editor: this.editor,
                    char: '@',
                    startOfLine: false,
                    allowSpaces: false,
                    items: ({ query }) => {
                        const q = (query || '').toLowerCase();
                        return users.filter((u) => (u.name || '').toLowerCase().includes(q)).slice(0, 8);
                    },
                    command: ({ editor, range, props }) => {
                        editor.chain().focus().insertContentAt(range, '@' + props.name + ' ').run();
                    },
                    render: () => {
                        let el, items = [], active = 0, cmd, rect;
                        const paint = () => {
                            el.innerHTML = '';
                            if (!items.length) { const e = document.createElement('div'); e.className = 'mention-empty'; e.textContent = '該当なし'; el.appendChild(e); return; }
                            items.forEach((u, i) => {
                                const b = document.createElement('button');
                                b.type = 'button';
                                b.className = 'mention-item' + (i === active ? ' active' : '');
                                const ava = document.createElement(u.avatar ? 'img' : 'span');
                                ava.className = 'mention-ava' + (u.avatar ? '' : ' mention-ava-fallback');
                                if (u.avatar) ava.src = u.avatar; else ava.textContent = u.initials || '';
                                const at = document.createElement('span'); at.className = 'mention-at'; at.textContent = '@';
                                const nm = document.createElement('span'); nm.textContent = u.name;
                                b.append(ava, at, nm);
                                b.addEventListener('mousedown', (ev) => { ev.preventDefault(); cmd(u); });
                                b.addEventListener('mousemove', () => { active = i; paint(); });
                                el.appendChild(b);
                            });
                            // 矢印で選択が枠外（内部スクロール）へ出たら追従させる。
                            const act = el.querySelector('.mention-item.active');
                            if (act) act.scrollIntoView({ block: 'nearest' });
                        };
                        return {
                            onStart: (props) => {
                                items = props.items; active = 0; cmd = (u) => props.command(u); rect = props.clientRect;
                                el = document.createElement('div'); el.className = 'mention-pop'; document.body.appendChild(el);
                                paint(); placeMenu(el, rect());
                            },
                            onUpdate: (props) => {
                                items = props.items; active = 0; cmd = (u) => props.command(u); rect = props.clientRect;
                                paint(); placeMenu(el, rect());
                            },
                            onKeyDown: (props) => {
                                const e = props.event;
                                if (e.isComposing || e.keyCode === 229) return false;
                                if (['Escape', 'ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) e.stopPropagation();
                                if (e.key === 'Escape') return true;
                                if (e.key === 'ArrowDown') { active = (active + 1) % items.length; paint(); return true; }
                                if (e.key === 'ArrowUp') { active = (active - 1 + items.length) % items.length; paint(); return true; }
                                if (e.key === 'Enter') { if (items[active]) cmd(items[active]); return true; }
                                return false;
                            },
                            onExit: () => { if (el) el.remove(); el = null; },
                        };
                    },
                }),
            ];
        },
    });
}

// ── ブロック一覧（スラッシュ・＋ボタンで共用。run は chain を受け取って適用） ──
const BLOCK_ITEMS = [
    { title: '見出し1', hint: '# 大見出し', ico: 'H1', keys: ['h1', 'heading', 'midashi', 'みだし'], run: (c) => c.toggleHeading({ level: 1 }) },
    { title: '見出し2', hint: '## 見出し', ico: 'H2', keys: ['h2', 'heading', 'midashi', 'みだし'], run: (c) => c.toggleHeading({ level: 2 }) },
    { title: '見出し3', hint: '### 小見出し', ico: 'H3', keys: ['h3', 'heading', 'midashi', 'みだし'], run: (c) => c.toggleHeading({ level: 3 }) },
    { title: '箇条書き', hint: '- リスト', ico: '•', keys: ['list', 'bullet', 'ul', 'かじょう', 'りすと'], run: (c) => c.toggleBulletList() },
    { title: '番号付きリスト', hint: '1. リスト', ico: '1.', keys: ['ordered', 'number', 'ol', 'ばんごう'], run: (c) => c.toggleOrderedList() },
    { title: 'TODO', hint: '[] チェックリスト', ico: '☑', keys: ['todo', 'task', 'check', 'タスク', 'ちぇっく'], run: (c) => c.toggleTaskList() },
    { title: '引用', hint: '> 引用', ico: '❝', keys: ['quote', 'blockquote', 'いんよう'], run: (c) => c.toggleBlockquote() },
    { title: 'コード', hint: '``` コードブロック', ico: '</>', keys: ['code', 'pre', 'こーど'], run: (c) => c.toggleCodeBlock() },
    { title: 'テーブル', hint: '表（3×3）を挿入', ico: '⊞', keys: ['table', 'grid', 'てーぶる', 'ひょう', '表', ' table'], run: (c) => c.insertTable({ rows: 3, cols: 3, withHeaderRow: true }) },
    { title: '画像', hint: '画像をアップロード', ico: '🖼', keys: ['image', 'img', 'picture', 'photo', 'がぞう', 'いめーじ', 'しゃしん'], needsUpload: true, action: (editor) => pickAndUploadImage(editor) },
    { title: '関連ファイル', hint: '既存ファイルを挿入', ico: '🔗', keys: ['file', 'link', 'attach', 'かんれん', 'ふぁいる', 'りんく', 'てんぷ'], needsPicker: true, action: (editor) => openFileLinkPicker(editor) },
    { title: '区切り線', hint: '--- 区切り', ico: '—', keys: ['hr', 'divider', 'rule', 'くぎり'], run: (c) => c.setHorizontalRule() },
];

// バブルメニューの「種類を変える」ドロップダウン用（見出しレベル切替）。
// run は editor を受け取り自前で実行。見出し/テキストへは clearNodes() で正規化、
// リスト⇄リストは clearNodes せず直接トグル（一旦抜くと空項目が増えるため）。
const TURN_ITEMS = [
    { label: 'テキスト', ico: 'T', run: (ed) => ed.chain().focus().clearNodes().run(), match: (e) => e.isActive('paragraph') && !e.isActive('bulletList') && !e.isActive('orderedList') && !e.isActive('taskList') },
    { label: '見出し1', ico: 'H1', run: (ed) => ed.chain().focus().clearNodes().setHeading({ level: 1 }).run(), match: (e) => e.isActive('heading', { level: 1 }) },
    { label: '見出し2', ico: 'H2', run: (ed) => ed.chain().focus().clearNodes().setHeading({ level: 2 }).run(), match: (e) => e.isActive('heading', { level: 2 }) },
    { label: '見出し3', ico: 'H3', run: (ed) => ed.chain().focus().clearNodes().setHeading({ level: 3 }).run(), match: (e) => e.isActive('heading', { level: 3 }) },
    { label: '箇条書き', ico: '•', run: (ed) => (ed.isActive('orderedList') || ed.isActive('bulletList') ? ed.chain().focus() : ed.chain().focus().clearNodes()).toggleBulletList().run(), match: (e) => e.isActive('bulletList') },
    { label: '番号付きリスト', ico: '1.', run: (ed) => (ed.isActive('orderedList') || ed.isActive('bulletList') ? ed.chain().focus() : ed.chain().focus().clearNodes()).toggleOrderedList().run(), match: (e) => e.isActive('orderedList') },
];
function currentTurn(editor) {
    return TURN_ITEMS.find((it) => it.match(editor)) || TURN_ITEMS[0];
}
// バブルツールバーの状態（装飾のactive・種類ラベル）を現在の選択に同期。
function syncBubbleState(editor, bubbleEl) {
    (bubbleEl.__bubbleDefs || []).forEach((d) => {
        const btn = bubbleEl.querySelector(`[data-mark="${d.mark}"]`);
        if (btn) btn.classList.toggle('is-active', editor.isActive(d.mark));
    });
    const turnLabel = bubbleEl.querySelector('.bubble-turn-label');
    if (turnLabel) turnLabel.textContent = currentTurn(editor).label;
}

let _turnCleanup = null;
function closeTurnMenu() { if (_turnCleanup) { _turnCleanup(); _turnCleanup = null; } }
function openTurnMenu(editor, anchorRect) {
    closeTurnMenu();
    const el = document.createElement('div');
    el.className = 'bubble-dd pop-in';
    TURN_ITEMS.forEach((it) => {
        const b = document.createElement('button');
        b.type = 'button';
        b.className = 'bubble-dd-item' + (it.match(editor) ? ' is-current' : '');
        b.innerHTML = '<span class="bubble-dd-ico"></span><span class="bubble-dd-label"></span>';
        b.querySelector('.bubble-dd-ico').textContent = it.ico;
        b.querySelector('.bubble-dd-label').textContent = it.label;
        b.addEventListener('mousedown', (ev) => {
            ev.preventDefault();
            it.run(editor);
            const lbl = document.querySelector('.bubble-turn-label');
            if (lbl) lbl.textContent = it.label; // 選んだ種類をラベルへ即反映
            closeTurnMenu();
        });
        el.appendChild(b);
    });
    document.body.appendChild(el);
    el.style.left = Math.round(anchorRect.left) + 'px';
    el.style.top = Math.round(anchorRect.bottom + 6) + 'px';
    const onOut = (e) => { if (!el.contains(e.target)) closeTurnMenu(); };
    const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); closeTurnMenu(); } };
    setTimeout(() => { document.addEventListener('mousedown', onOut, true); document.addEventListener('keydown', onKey, true); }, 0);
    _turnCleanup = () => { document.removeEventListener('mousedown', onOut, true); document.removeEventListener('keydown', onKey, true); el.remove(); };
}

// アップロード関数を持たないエディタでは「画像」を、ファイル挿入コールバックを持たない
// エディタでは「関連ファイル」を隠す。
function availableBlocks(editor) {
    return BLOCK_ITEMS.filter((it) =>
        (!it.needsUpload || (editor && editor.__reUpload)) &&
        (!it.needsPicker || (editor && editor.__reFilePick))
    );
}

function filterBlocks(query, editor) {
    const items = availableBlocks(editor);
    const q = (query || '').toLowerCase().trim();
    if (!q) return items;
    return items.filter((it) => it.title.toLowerCase().includes(q) || it.keys.some((k) => k.includes(q)));
}

// メニューの中身を描画（スラッシュ・＋共用）。
function paintMenu(el, items, active, onPick, onHover) {
    el.innerHTML = '';
    if (!items.length) {
        const e = document.createElement('div');
        e.className = 'slash-empty';
        e.textContent = '該当なし';
        el.appendChild(e);
        return;
    }
    items.forEach((it, i) => {
        const b = document.createElement('button');
        b.type = 'button';
        b.className = 'slash-item' + (i === active ? ' is-active' : '');
        b.innerHTML = '<span class="slash-ico"></span><span class="slash-tx"><span class="slash-title"></span><span class="slash-hint"></span></span>';
        b.querySelector('.slash-ico').textContent = it.ico;
        b.querySelector('.slash-title').textContent = it.title;
        b.querySelector('.slash-hint').textContent = it.hint;
        b.addEventListener('mousedown', (ev) => { ev.preventDefault(); onPick(it); });
        if (onHover) b.addEventListener('mousemove', () => onHover(i));
        el.appendChild(b);
    });
    // 矢印で選択が枠外（max-height の内部スクロール）へ出たら、その項目まで追従させる。
    const act = el.querySelector('.slash-item.is-active');
    if (act) act.scrollIntoView({ block: 'nearest' });
}

// メニュー（スラッシュ/＋/メンション）を固定配置でアンカー近くに置く。下に入れば下、
// 入らなければ上、どちらにも収まらなければ広い側に出して高さを画面内に抑える。最後に
// ビューポート内へクランプして、必ず全体が見える＝「下までスクロールしないと出ない」を防ぐ。
function placeMenu(el, rect) {
    if (!rect) return;
    const m = 8;
    const vh = window.innerHeight;
    const vw = window.innerWidth;
    el.style.maxHeight = ''; // 直前の制限を解除して本来の高さ（CSSの上限まで）を測る
    let h = el.offsetHeight;
    const below = vh - rect.bottom - m; // アンカー下の余白
    const above = rect.top - m;         // アンカー上の余白
    let top;
    if (h + 6 <= below) {
        top = rect.bottom + 6;          // 下に収まる
    } else if (h + 6 <= above) {
        top = rect.top - 6 - h;         // 上に収まる
    } else {
        // どちらにも収まらない：広い側に出し、その余白に収まる高さへ抑える（内部スクロール）。
        const space = Math.max(below, above) - 6;
        h = Math.max(80, space);
        el.style.maxHeight = h + 'px';
        top = above > below ? rect.top - 6 - h : rect.bottom + 6;
    }
    top = Math.max(m, Math.min(top, vh - h - m));
    let left = Math.min(rect.left, vw - el.offsetWidth - m);
    left = Math.max(m, left);
    el.style.left = Math.round(left) + 'px';
    el.style.top = Math.round(top) + 'px';
}

// 「＋」ボタンから開くブロックメニュー（/ を打たずにブロック挿入）。
let _plusCleanup = null;
function closePlusMenu() { if (_plusCleanup) { _plusCleanup(); _plusCleanup = null; } }
function openPlusMenu(editor, anchorRect) {
    closePlusMenu();
    const items = availableBlocks(editor);
    let active = 0;
    const el = document.createElement('div');
    el.className = 'slash-menu pop-in';
    const pick = (it) => {
        closePlusMenu();
        if (it.action) { it.action(editor); return; }
        it.run(editor.chain().focus()).run();
    };
    const repaint = () => paintMenu(el, items, active, pick, (i) => { active = i; repaint(); });
    repaint();
    document.body.appendChild(el);
    placeMenu(el, anchorRect);

    const onKey = (e) => {
        if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closePlusMenu(); }
        else if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); active = (active + 1) % items.length; repaint(); }
        else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); active = (active - 1 + items.length) % items.length; repaint(); }
        else if (e.key === 'Enter') { if (e.isComposing) return; e.preventDefault(); e.stopPropagation(); pick(items[active]); }
    };
    const onOutside = (e) => { if (!el.contains(e.target)) closePlusMenu(); };
    document.addEventListener('keydown', onKey, true);
    setTimeout(() => document.addEventListener('mousedown', onOutside, true), 0);
    _plusCleanup = () => {
        document.removeEventListener('keydown', onKey, true);
        document.removeEventListener('mousedown', onOutside, true);
        el.remove();
    };
}

// 入力された URL を素直に補正：スキーム無しの素のドメイン（例 example.com）には https:// を補う。
// 既にスキーム有り・アンカー(#)・ルート相対(/)・mailto/tel はそのまま尊重する。
function normalizeUrl(raw) {
    const url = (raw || '').trim();
    if (!url) return '';
    if (/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(url)) return url;
    return /\./.test(url) ? 'https://' + url : url;
}

// リンク編集ポップオーバー。window.prompt（OSの小さなダイアログ＝大画面で本文から離れて
// 分かりにくい）の代わりに、選択箇所のすぐ近くにクレイ調の入力UIを出す。URL入力＋適用に
// 加え、既存リンクなら「開く」「リンクを外す」も用意する。
let _linkCleanup = null;
function closeLinkEditor() { if (_linkCleanup) { _linkCleanup(); _linkCleanup = null; } }
function openLinkEditor(editor, anchorRect) {
    closeLinkEditor();
    const prev = editor.getAttributes('link').href || '';
    const hasLink = editor.isActive('link');

    const el = document.createElement('div');
    el.className = 'link-editor pop-in';

    const row = document.createElement('div');
    row.className = 'link-editor-row';
    row.innerHTML = '<svg class="link-editor-ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>';
    const input = document.createElement('input');
    input.type = 'text';
    input.className = 'link-editor-input';
    input.placeholder = 'https://… を入力（または貼り付け）';
    input.value = prev;
    const applyBtn = document.createElement('button');
    applyBtn.type = 'button';
    applyBtn.className = 'link-editor-apply';
    applyBtn.textContent = hasLink ? '更新' : '適用';
    row.append(input, applyBtn);
    el.appendChild(row);

    const apply = () => {
        const url = normalizeUrl(input.value);
        const chain = editor.chain().focus().extendMarkRange('link');
        if (url === '') chain.unsetLink().run();
        else chain.setLink({ href: url }).run();
        closeLinkEditor();
    };
    applyBtn.addEventListener('mousedown', (e) => { e.preventDefault(); apply(); });
    input.addEventListener('keydown', (e) => {
        e.stopPropagation(); // エディタ側のショートカット（Esc=取消 等）へ伝播させない
        if (e.key === 'Enter') { e.preventDefault(); apply(); }
        else if (e.key === 'Escape') { e.preventDefault(); closeLinkEditor(); }
    });

    if (hasLink) {
        const actions = document.createElement('div');
        actions.className = 'link-editor-actions';
        const open = document.createElement('a');
        open.className = 'link-editor-act';
        open.href = prev; open.target = '_blank'; open.rel = 'noopener noreferrer';
        open.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg><span>開く</span>';
        const remove = document.createElement('button');
        remove.type = 'button';
        remove.className = 'link-editor-act link-editor-act--danger';
        remove.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg><span>リンクを外す</span>';
        remove.addEventListener('mousedown', (e) => {
            e.preventDefault();
            editor.chain().focus().extendMarkRange('link').unsetLink().run();
            closeLinkEditor();
        });
        actions.append(open, remove);
        el.appendChild(actions);
    }

    document.body.appendChild(el);
    placeMenu(el, anchorRect);
    setTimeout(() => { input.focus(); input.select(); }, 0);

    const onOut = (e) => { if (!el.contains(e.target)) closeLinkEditor(); };
    setTimeout(() => document.addEventListener('mousedown', onOut, true), 0);
    _linkCleanup = () => { document.removeEventListener('mousedown', onOut, true); el.remove(); };
}

// 「/」スラッシュコマンド拡張（@tiptap/suggestion）。
const SlashCommands = Extension.create({
    name: 'slashCommands',
    addProseMirrorPlugins() {
        return [
            Suggestion({
                pluginKey: new PluginKey('relaySlash'),
                editor: this.editor,
                char: '/',
                startOfLine: false,
                allowSpaces: false,
                items: ({ query, editor }) => filterBlocks(query, editor),
                command: ({ editor, range, props }) => {
                    if (props.action) { editor.chain().focus().deleteRange(range).run(); props.action(editor); return; }
                    props.run(editor.chain().focus().deleteRange(range)).run();
                },
                render: () => {
                    let el, items = [], active = 0, cmd, rect;
                    const repaint = () => paintMenu(el, items, active, (it) => cmd(it), (i) => { active = i; repaint(); });
                    return {
                        onStart: (props) => {
                            items = props.items; active = 0; cmd = (it) => props.command(it); rect = props.clientRect;
                            el = document.createElement('div');
                            el.className = 'slash-menu pop-in';
                            document.body.appendChild(el);
                            repaint(); placeMenu(el, rect());
                        },
                        onUpdate: (props) => {
                            items = props.items; active = 0; cmd = (it) => props.command(it); rect = props.clientRect;
                            repaint(); placeMenu(el, rect());
                        },
                        onKeyDown: (props) => {
                            const e = props.event;
                            if (e.isComposing || e.keyCode === 229) return false;
                            // 処理するキーは外側（説明エディタの Esc=取消 等）へ伝播させない。
                            if (['Escape', 'ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) e.stopPropagation();
                            if (e.key === 'Escape') return true;
                            if (e.key === 'ArrowDown') { active = (active + 1) % items.length; repaint(); return true; }
                            if (e.key === 'ArrowUp') { active = (active - 1 + items.length) % items.length; repaint(); return true; }
                            if (e.key === 'Enter') { if (items[active]) cmd(items[active]); return true; }
                            return false;
                        },
                        onExit: () => { if (el) el.remove(); el = null; },
                    };
                },
            }),
        ];
    },
});

function buildBubbleToolbar() {
    const bar = document.createElement('div');
    bar.className = 'bubble-menu';
    bar.style.visibility = 'hidden';

    // 先頭：ブロックの種類を変えるドロップダウン（テキスト/見出し1-3）
    const turn = document.createElement('button');
    turn.type = 'button';
    turn.className = 'bubble-turn';
    turn.setAttribute('aria-label', 'ブロックの種類を変える');
    turn.innerHTML = '<span class="bubble-turn-label">テキスト</span><svg class="bubble-turn-chev" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>';
    bar.appendChild(turn);
    const sep = document.createElement('span');
    sep.className = 'bubble-div';
    bar.appendChild(sep);

    const defs = [
        { mark: 'bold', label: '太字', html: '<b>B</b>' },
        { mark: 'italic', label: '斜体', html: '<i>i</i>' },
        { mark: 'strike', label: '取り消し線', html: '<s>S</s>' },
        { mark: 'code', label: 'コード', html: '&lt;&gt;' },
        { mark: 'link', label: 'リンク', isLink: true },
    ];
    bar.__bubbleDefs = defs;
    defs.forEach((d) => {
        const b = document.createElement('button');
        b.type = 'button';
        b.className = 'bubble-btn';
        b.dataset.mark = d.mark;
        b.title = d.label;
        b.setAttribute('aria-label', d.label);
        b.innerHTML = d.isLink
            ? '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>'
            : d.html;
        bar.appendChild(b);
    });
    return bar;
}

function buildPlusButton() {
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'floating-add';
    btn.style.visibility = 'hidden';
    btn.setAttribute('aria-label', 'ブロックを追加（見出し・リスト・TODOなど）');
    // native title は OS 任せでカーソル位置（＝左ガターの端）に出てしまう。中央寄せ＋画面端
    // クランプ済みのカスタムツールチップ（.re-tip / showTip）に乗せて、端に寄りすぎないようにする。
    btn.dataset.tip = 'ブロックを追加';
    btn.innerHTML = '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
    return btn;
}

// ── カスタムツールチップ（[data-tip] を持つ要素にホバーで表示）──────────────
// native title はラベルが分かりにくく出も遅いので、明確な日本語をクレイ調の小さな
// 吹き出しで見せる。document への委譲で一度だけ仕込む。
let _tipEl = null;
let _tipReady = false;
function showTip(target) {
    const text = target.getAttribute('data-tip');
    if (!text) return;
    if (!_tipEl) { _tipEl = document.createElement('div'); _tipEl.className = 're-tip'; document.body.appendChild(_tipEl); }
    _tipEl.textContent = text;
    _tipEl.style.visibility = 'hidden';
    _tipEl.style.display = 'block';
    const r = target.getBoundingClientRect();
    const tw = _tipEl.offsetWidth;
    const th = _tipEl.offsetHeight;
    let top = r.top - th - 7;
    if (top < 6) top = r.bottom + 7; // 上に入らなければ下へ
    let left = r.left + r.width / 2 - tw / 2;
    left = Math.max(6, Math.min(left, window.innerWidth - tw - 6));
    _tipEl.style.left = Math.round(left) + 'px';
    _tipEl.style.top = Math.round(top) + 'px';
    _tipEl.style.visibility = 'visible';
}
function hideTip() { if (_tipEl) _tipEl.style.display = 'none'; }
function initTips() {
    if (_tipReady) return;
    _tipReady = true;
    document.addEventListener('mouseover', (e) => { const t = e.target.closest && e.target.closest('[data-tip]'); if (t) showTip(t); });
    document.addEventListener('mouseout', (e) => { const t = e.target.closest && e.target.closest('[data-tip]'); if (t) hideTip(); });
    document.addEventListener('mousedown', hideTip, true); // クリックしたら隠す
    window.addEventListener('scroll', hideTip, true);
}

// テーブル操作のアイコン（24x24・線画。明るい字色のダーク背景に currentColor で描く）。
// ＋＝追加の向き（左/右/上/下）、×＝削除を一目で示す。正確な意味は data-tip で補う。
const T_ICONS = {
    addColumnBefore: '<rect x="11" y="4" width="10" height="16" rx="1.5"/><path d="M16 4v16"/><path d="M5 9v6M2 12h6"/>',
    addColumnAfter: '<rect x="3" y="4" width="10" height="16" rx="1.5"/><path d="M8 4v16"/><path d="M19 9v6M16 12h6"/>',
    deleteColumn: '<rect x="8" y="3" width="8" height="18" rx="1.5"/><path d="M10 10l4 4M14 10l-4 4"/>',
    addRowBefore: '<rect x="4" y="11" width="16" height="10" rx="1.5"/><path d="M4 16h16"/><path d="M9 5h6M12 2v6"/>',
    addRowAfter: '<rect x="4" y="3" width="16" height="10" rx="1.5"/><path d="M4 8h16"/><path d="M9 19h6M12 16v6"/>',
    deleteRow: '<rect x="3" y="8" width="18" height="8" rx="1.5"/><path d="M10 10l4 4M14 10l-4 4"/>',
    toggleHeaderRow: '<rect x="3" y="4" width="18" height="16" rx="1.5"/><path d="M3 9h18" stroke-width="2.6"/>',
    deleteTable: '<path d="M4 7h16"/><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/><path d="M10 11v6M14 11v6"/>',
    insertImage: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.6"/><path d="M21 15l-5-5L5 21"/>',
};
const tIcon = (name) => `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${T_ICONS[name]}</svg>`;

// テーブル内にカーソルがあるとき出る操作ツールバー（行/列の追加・削除、見出し行、表の削除）。
// data-cmd は TipTap Table 拡張のコマンド名。クリックで editor.chain()[cmd]() を実行する。
// 表示はアイコン、意味は data-tip のカスタムツールチップで明示する。
// canImage=true（画像アップロード可能なエディタ）のときだけ「セルに画像を挿入」ボタンを足す。
function buildTableToolbar(canImage) {
    const bar = document.createElement('div');
    bar.className = 'bubble-menu table-menu';
    bar.style.visibility = 'hidden';
    const groups = [
        [
            { cmd: 'addColumnBefore', label: '左に列を追加' },
            { cmd: 'addColumnAfter', label: '右に列を追加' },
            { cmd: 'deleteColumn', label: '列を削除', danger: true },
        ],
        [
            { cmd: 'addRowBefore', label: '上に行を追加' },
            { cmd: 'addRowAfter', label: '下に行を追加' },
            { cmd: 'deleteRow', label: '行を削除', danger: true },
        ],
        [
            { cmd: 'toggleHeaderRow', label: '見出し行の切り替え' },
            { cmd: 'deleteTable', label: 'テーブルを削除', danger: true },
        ],
    ];
    // 画像挿入は cmd ではなく action（pickAndUploadImage を直接呼ぶ）。先頭グループに置く。
    if (canImage) {
        groups.unshift([{ action: 'image', icon: 'insertImage', label: 'セルに画像を挿入' }]);
    }
    groups.forEach((g, gi) => {
        if (gi > 0) { const d = document.createElement('span'); d.className = 'bubble-div'; bar.appendChild(d); }
        g.forEach((b) => {
            const btn = document.createElement('button');
            btn.type = 'button';
            btn.className = 'bubble-btn' + (b.danger ? ' bubble-btn--danger' : '');
            if (b.cmd) btn.dataset.cmd = b.cmd;
            if (b.action) btn.dataset.action = b.action;
            btn.dataset.tip = b.label;            // カスタムツールチップ用
            btn.setAttribute('aria-label', b.label);
            btn.innerHTML = tIcon(b.icon || b.cmd);
            bar.appendChild(btn);
        });
    });
    return bar;
}

// エディタが内側スクロールコンテナ（課題モーダルの本文など）に入っているときは、
// そのコンテナのスクロールに「＋」やバブルメニューを追従させたい。TipTap/floating-ui は
// 仮想参照（カーソル座標）から DOM のスクロール親を辿れず、既定では window のスクロールしか
// 監視しないため、ここでスクロール親を求めて scrollTarget に渡す。見つからなければ window。
function scrollParent(el) {
    let node = el && el.parentElement;
    while (node && node !== document.body && node !== document.documentElement) {
        const oy = getComputedStyle(node).overflowY;
        if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') return node;
        node = node.parentElement;
    }
    return window;
}

// テーブル操作ツールバーを「表の上」にアンカー配置（セルに重ねない）。表内にカーソルがあり
// 編集フォーカス中のときだけ表示し、上に余白がなければ表の下へ。固定配置でビューポート内へ収める。
function positionTableToolbar(editor, tableEl) {
    if (!editor.isEditable || !editor.isFocused || !editor.isActive('table')) {
        tableEl.style.visibility = 'hidden';
        return;
    }
    let dom = null;
    try { dom = editor.view.domAtPos(editor.state.selection.from).node; } catch (e) { dom = null; }
    if (dom && dom.nodeType === 3) dom = dom.parentElement;
    const tableDom = dom && dom.closest ? dom.closest('table') : null;
    if (!tableDom) { tableEl.style.visibility = 'hidden'; return; }

    tableEl.style.position = 'fixed';
    tableEl.style.visibility = 'visible';
    const r = tableDom.getBoundingClientRect();
    const tw = tableEl.offsetWidth;
    const th = tableEl.offsetHeight;
    const gap = 8;
    const m = 8;
    let top = r.top - th - gap;            // まず表の上に
    if (top < m) top = r.bottom + gap;     // 上に入らなければ表の下に
    top = Math.max(m, Math.min(top, window.innerHeight - th - m));
    const left = Math.max(m, Math.min(r.left, window.innerWidth - tw - m));
    tableEl.style.top = Math.round(top) + 'px';
    tableEl.style.left = Math.round(left) + 'px';
}

// options で挙動を上書きできる（data-* 属性より優先）：
//   upload:     async (file) => ({ url, alt })   画像アップロード。未指定なら uploadUrl から既定アップローダを合成
//   uploadUrl:  string                           既定アップローダの送信先（CSRF付きPOST・Laravel想定）
//   csrfToken:  string                           既定アップローダのCSRFトークン（未指定は meta[name=csrf-token]）
//   mentions:   [{ name, avatar, initials }]     @メンション候補（空/未指定なら @ 無効）
//   onPickFile: (insert) => void                 「関連ファイル」ブロック。渡した時だけ表示
//   placeholder / minHeight / compact / draftKey / drafts:false(下書き無効)
export function mountTiptap(textarea, options = {}) {
    if (textarea.dataset.reReady) return textarea._richEditor ? textarea._richEditor._tiptap : undefined;
    textarea.dataset.reReady = '1';

    // options.draftKey を dataset へ寄せると drafts 側のキー導出がそのまま使える。
    if (options.draftKey) textarea.dataset.draftKey = options.draftKey;
    const draftsEnabled = options.drafts !== false && textarea.dataset.noDraft === undefined;

    // 付随機能を options（優先）→ data-* の順で解決する。
    const uploadUrl = options.uploadUrl || textarea.dataset.uploadUrl || '';
    const uploadFn = typeof options.upload === 'function'
        ? options.upload
        : (uploadUrl ? defaultUploader(uploadUrl, options.csrfToken) : null);
    const onPickFile = typeof options.onPickFile === 'function' ? options.onPickFile : null;
    let mentionUsers = Array.isArray(options.mentions) ? options.mentions : [];
    if (!mentionUsers.length && textarea.dataset.mentionUsers) {
        try { mentionUsers = JSON.parse(textarea.dataset.mentionUsers) || []; } catch (e) { mentionUsers = []; }
    }

    const host = document.createElement('div');
    host.className = 'pm-editor';
    const minHeight = options.minHeight || textarea.dataset.minHeight;
    if (minHeight) host.style.setProperty('--pm-min-h', minHeight);
    textarea.after(host);
    textarea.style.display = 'none';
    textarea.removeAttribute('required');

    // 下書き（リロード復元）：ベースライン＝読み込み時の値。未保存の差分が localStorage に
    // 残っていれば、それを初期内容として復元する（後で「復元しました」チップを出す）。
    const draftBaseline = textarea.value || '';
    let restoredDraft = null;
    if (draftsEnabled) { try { restoredDraft = restoreDraft(textarea, draftBaseline); } catch (e) { restoredDraft = null; } }
    const initialContent = restoredDraft != null ? restoredDraft : draftBaseline;

    const bubbleEl = buildBubbleToolbar();
    const tableEl = buildTableToolbar(!!uploadFn);
    const plusBtn = buildPlusButton();
    // コンパクト版（枠の狭い文脈）は「＋」ハンドルを小さくして、箱に収めるための左ガター
    // （＝本文インデント）を詰められるようにする。
    const compactHandle = !!options.compact || 'compact' in textarea.dataset;
    if (compactHandle) plusBtn.classList.add('floating-add--sm');
    document.body.appendChild(bubbleEl);
    document.body.appendChild(tableEl);
    document.body.appendChild(plusBtn);
    initTips(); // [data-tip] のカスタムツールチップ（テーブル操作の意味を明示）を一度だけ仕込む

    // 「＋」やバブルメニューを、ページ全体だけでなく内側スクロールコンテナ（モーダル本文など）の
    // スクロールにも追従させるための対象。マウント時点で host は最終位置に挿入済み。
    const scrollTarget = scrollParent(host);

    const extensions = [
        StarterKit.configure({
            heading: { levels: [1, 2, 3] },
            link: { openOnClick: false, autolink: true },
        }),
        // Cmd/Ctrl+Enter は「送信」用。HardBreak拡張の既定（Mod-Enter＝改行挿入）を
        // 高優先度で奪って無効化し、markdownに「\」(ハードブレーク)が紛れ込むのを防ぐ。
        // 実際の送信/保存は外側のフォーム or keydownハンドラ（bubbleで発火）に委ねる。
        // Shift+Enter の手動改行はそのまま残す。
        Extension.create({
            name: 'relayModEnterNoBreak',
            priority: 1000,
            addKeyboardShortcuts() {
                return { 'Mod-Enter': () => true, 'Ctrl-Enter': () => true };
            },
        }),
        TaskList,
        TaskItem.configure({ nested: true }),
        RelayTable,
        TableRow,
        TableHeader,
        TableCell,
        RelayImage,
        Placeholder.configure({
            placeholder: textarea.getAttribute('placeholder') || 'ここに入力…  行頭の「＋」か「/」で見出し・リスト・TODOなどを挿入',
        }),
        SlashCommands,
        FloatingMenu.configure({
            element: plusBtn,
            pluginKey: 'relayPlus',
            // 既定は60msのdebounce。スクロール中もカーソル行に滑らかに追従させるため0に。
            resizeDelay: 0,
            shouldShow: ({ editor, state }) => {
                if (!editor.isEditable) return false;
                const { $from, empty } = state.selection;
                // トップレベルの空の段落だけに出す（depth===1）。リスト/引用の中だと
                // インデントぶんカーソルが右にずれて「＋」位置が乱れるため除外する。
                return empty && $from.depth === 1 && $from.parent.type.name === 'paragraph' && $from.parent.content.size === 0;
            },
            // コンパクト版は offset をマイナスにしてカーソル寄りに置き、
            // ボタンを小さくしすぎずに本文の左ガター（インデント）を浅く保つ。
            // scrollTarget: 内側スクロールコンテナのスクロールでも「＋」を追従させる（既定 window）。
            // shift:false … 上端で「＋」をクランプ（押し留め）せず、カーソル行に沿ってそのまま潜らせる。
            //   モーダルのヘッダー/フッターは不透明＋上位 z-index にしてあるので、「＋」はその下へ
            //   潜って自然に隠れる（hide で消すのではなく重なりで解決）。
            options: { placement: 'left', offset: compactHandle ? -2 : 4, scrollTarget, shift: false },
        }),
        BubbleMenu.configure({
            element: bubbleEl,
            pluginKey: 'relayBubble',
            resizeDelay: 0,
            shouldShow: ({ editor, from, to }) => from !== to && !editor.isActive('codeBlock') && !editor.isActive('code'),
            options: { placement: 'top', offset: 8, scrollTarget },
        }),
        // テーブル操作ツールバーは BubbleMenu（＝選択＝セル位置に出る）だとセルへ重なるので、
        // 自前で「表の上」にアンカー配置する（positionTableToolbar）。ここでは登録しない。
        Markdown.configure({ html: false, tightLists: true, bulletListMarker: '-', transformPastedText: true, transformCopiedText: true }),
    ];
    if (mentionUsers.length) extensions.push(makeMentionExtension(mentionUsers));

    const imageDrop = (event) => {
        const files = Array.from((event.clipboardData || event.dataTransfer)?.files || []).filter((f) => f.type.startsWith('image/'));
        if (!files.length) return false;
        event.preventDefault();
        files.forEach((f) => insertUploadedImage(f, editor));
        return true;
    };

    const editor = new Editor({
        element: host,
        extensions,
        editorProps: uploadFn ? { handlePaste: (v, e) => imageDrop(e), handleDrop: (v, e) => imageDrop(e) } : {},
        content: initialContent,
        onUpdate({ editor }) {
            textarea.value = editor.storage.markdown.getMarkdown();
            if (draftsEnabled) { try { scheduleSave(textarea, textarea.value, draftBaseline); } catch (e) {} }
            syncBubbleState(editor, bubbleEl); positionTableToolbar(editor, tableEl);
        },
        onSelectionUpdate({ editor }) { syncBubbleState(editor, bubbleEl); positionTableToolbar(editor, tableEl); },
    });

    // 「画像」ブロック（スラッシュ/＋メニュー）からアップロード関数を引けるようにする。
    editor.__reUpload = uploadFn;
    // 「関連ファイル」ブロックからホストのピッカーを開けるようにする（onPickFile があるときだけ表示）。
    editor.__reFilePick = onPickFile;

    // 本文中のリンクをクリックしたら即リンクエディタを開く（選択して🔗を押す手間を省く）。
    // クリックでカーソルはリンク内に入る → isActive('link') が真になるのでそのまま編集UIを出す。
    host.addEventListener('click', (e) => {
        if (!editor.isEditable) return;
        const a = e.target.closest && e.target.closest('a');
        if (!a || !host.contains(a)) return;
        e.preventDefault();
        // クリック直後の選択確定を待ってから、リンクの位置にアンカーして開く。
        setTimeout(() => openLinkEditor(editor, a.getBoundingClientRect()), 0);
    });

    // テーブルツールバーを表の上に追従させる。スクロール（内側コンテナ含む＝capture）・リサイズ・
    // フォーカス変化で再配置。capture の scroll は非バブルでも子要素のスクロールを拾える。
    const repositionTableTb = () => positionTableToolbar(editor, tableEl);
    window.addEventListener('scroll', repositionTableTb, true);
    window.addEventListener('resize', repositionTableTb);
    editor.on('blur', repositionTableTb);
    editor.on('focus', repositionTableTb);
    // フォーカスを外れたら下書きを即保存（デバウンス待ちの取りこぼしを防ぐ）。
    if (draftsEnabled) editor.on('blur', () => { try { flushSave(textarea, editor.storage.markdown.getMarkdown(), draftBaseline); } catch (e) {} });

    // テーブル操作ツールバー：data-cmd は TipTap のチェーンコマンド、data-action="image" は
    // 現在のセルへ画像を挿入（ファイル選択→アップロード）。mousedown で選択（カーソル位置＝挿入先）を保つ。
    tableEl.querySelectorAll('.bubble-btn').forEach((btn) => {
        btn.addEventListener('mousedown', (e) => {
            e.preventDefault();
            if (btn.dataset.action === 'image') { pickAndUploadImage(editor); return; }
            const cmd = btn.dataset.cmd;
            const chain = editor.chain().focus();
            if (typeof chain[cmd] === 'function') chain[cmd]().run();
        });
    });

    // 「＋」ボタン → ブロックメニュー
    plusBtn.addEventListener('mousedown', (e) => {
        e.preventDefault();
        openPlusMenu(editor, plusBtn.getBoundingClientRect());
    });

    // バブルの「種類を変える」ドロップダウン
    const turnBtn = bubbleEl.querySelector('.bubble-turn');
    if (turnBtn) turnBtn.addEventListener('mousedown', (e) => { e.preventDefault(); openTurnMenu(editor, turnBtn.getBoundingClientRect()); });

    // バブルメニューのボタン挙動
    bubbleEl.querySelectorAll('.bubble-btn').forEach((btn) => {
        btn.addEventListener('mousedown', (e) => {
            e.preventDefault();
            const mark = btn.dataset.mark;
            if (mark === 'link') {
                openLinkEditor(editor, btn.getBoundingClientRect());
                return;
            }
            editor.chain().focus().toggleMark(mark).run();
        });
    });

    textarea.value = editor.storage.markdown.getMarkdown();

    textarea._richEditor = {
        getMarkdown: () => editor.storage.markdown.getMarkdown(),
        setMarkdown: (v) => editor.commands.setContent(v || ''),
        focus: () => editor.commands.focus('end'),
        destroy: () => { closePlusMenu(); closeTurnMenu(); closeLinkEditor(); window.removeEventListener('scroll', repositionTableTb, true); window.removeEventListener('resize', repositionTableTb); editor.destroy(); bubbleEl.remove(); tableEl.remove(); plusBtn.remove(); },
        _tiptap: editor,
    };

    const form = textarea.closest('form');
    if (form) form.addEventListener('submit', () => { textarea.value = editor.storage.markdown.getMarkdown(); });

    // 復元した場合だけ、エディタ上部に「下書きを復元しました・破棄」チップを出す。
    // 破棄でベースライン（サーバ値）へ戻し、下書きを消す。
    // ただし data-draft-scope フォーム内（課題の作成/編集・Wiki 等）では、フォーム先頭の
    // 1枚チップが本文もまとめて代表するので、エディタ個別のチップは出さない（二重表示の防止）。
    if (draftsEnabled && restoredDraft != null && !textarea.closest('[data-draft-scope]')) {
        try {
            showRestoredChip(host, () => {
                editor.commands.setContent(draftBaseline || '');
                textarea.value = draftBaseline || '';
                clearDraft(textarea);
            });
        } catch (e) {}
    }

    return editor;
}

// 公開API：セレクタ or textarea を受けてマウントする。返り値は TipTap の Editor インスタンス。
export function mountRichEditor(target, options = {}) {
    const el = typeof target === 'string' ? document.querySelector(target) : target;
    if (!el || el.tagName !== 'TEXTAREA') return undefined;
    return mountTiptap(el, options);
}

// [data-rich-editor] を持つ textarea をまとめてマウント（設定は data-* 属性から読む）。
// 既にマウント済み（data-re-ready）はスキップ。個別の失敗で全体を止めない。
export function initRichEditors(root = document) {
    (root || document).querySelectorAll('textarea[data-rich-editor]:not([data-re-ready])').forEach((ta) => {
        try { mountTiptap(ta); } catch (e) { /* ignore per-element failure */ }
    });
}
