Files
ketle-manandClaude Sonnet 5.5 fe98db57b9 v1.50.0: コマの変形・削除、テキストの縦長/横長/平行四辺形変形、バックアップを設定タブへ移動
- コマの変形・削除(レイアウトタブ「変形」サブタブ)
  - 選択中のコマを破壊的に変形(コマ間の幅・外側の幅は無視)
  - 頂点ドラッグ移動、頂点削除(四角形→三角形。三角形は不可)、
    辺ドラッグによる平行移動(辺の向きを保ち、両端は隣の辺の延長線上を滑る)
  - 外周ロック(既定ON)でレイアウト全体の外周上の頂点を保護。
    コマ一括ロック中のコマは変形・削除不可
  - レイヤーパネルのコマ行の✕ボタン/「選択コマを削除」でコマ削除
    (中の画像・フキダシ・サブコマも削除、Undo可)
- テキストの変形: 辺中央の青いハンドルで縦長/横長、Shift+ドラッグで
  せん断して平行四辺形。「変形リセット」ボタンで元に戻せる
- 一括バックアップ/復元をページタブから設定タブへ移動。
  バックアップ対象に T2I/Inpaint/Outpaint設定・PixiJSプリセット・
  Autoタブのデータ・分割/変形の操作設定などを追加
- ヘルプ(日英中)・README(3言語)・DEVLOGを更新

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:09:54 +09:00

957 lines
52 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// マスクレイヤー機能 分割ファイル (2/2): renderLayerPanel本体+不透明度/コンテナ取得ヘルパー
// 元 04-mask-layers.js(分割前)の行 830-1553 に相当
// type="module" として読み込まれる(ESモジュール化 G1)。
// 04a-mask-core.js とは相互import(循環)。両ファイルとも、循環先のシンボル参照は
// すべて関数内部(呼び出し時点で評価)に閉じており、モジュールのトップレベルでは参照していないため安全。
// 主なトップレベル定義: _layerOpacityGetSelected,_layerOpacitySync,getActiveContainer,getPanelLayerSvg,renderLayerPanel
// 未ESM化の外部依存(非moduleのグローバル関数はwindowプロパティとして自動的に見えるため、
// 呼び出し箇所は書き換えていない):
// state(01-state.js), _escHtml(21-script-tab.js),
// clearImageHandles/renderImageHandles/selectPanel/savePanelSvg代わりに使用箇所あり(08-panels-images.js),
// clearTextHandles/renderTextHandles/syncFontFamilyUI(09d-balloon-tools.js),
// clearDrawShapeHandles/_drawShapeSyncProps/_layerDrawSelectShape(17c-layer-draw-handles.js),
// clearGroupHandles/renderGroupHandles(06a-polygon-geometry.js),
// updateBalloonUI/_h2CleanupBalloonChainBeforeDelete/_h2RefreshChainAfterDelete/saveOverlaySvg/selectOverlay(09b-balloon-shapes.js),
// clearHandles/renderHandles(09c-balloon-handles.js), pushHistory/savePanelSvg(07-pages.js),
// saveGroupAsAsset/ungroupLayer(05-groups-move.js),
// toggleSubPanelFrameMode/deleteSubPanel/renderSubPanelHandles/_isSubPanelFrameMode(24-sub-panels.js),
// selectDraft(08-panels-images.js)
// ============================================================
import { t } from '../i18n.js';
import {
syncPanelSelectionToObject, togglePanelBorderVisibility, togglePanelLock,
_isPanelBorderHidden, _isPanelLocked, updateDuplicatePanelSelect,
} from './03-layers-panel.js';
import {
_maskState, _maskIsObjectTarget, _maskTypeLabel, _maskOpenEditorFor, _maskReassignObject,
_maskToggleLayerVisible, _maskDeleteLayer, _maskLayerList, _maskTargetGroup,
_maskEnsureElId, _maskAddLayerAndEdit,
} from './04a-mask-core.js';
import { state } from './01-state.js';
import { clearImageHandles, renderImageHandles, selectDraft, selectPanel } from './08-panels-images.js';
import { clearTextHandles, renderTextHandles, syncFontFamilyUI } from './09d-balloon-tools.js';
import { _drawShapeSyncProps, _layerDrawSelectShape, clearDrawShapeHandles } from './17c-layer-draw-handles.js';
import { clearGroupHandles, renderGroupHandles } from './06a-polygon-geometry.js';
import { _h2CleanupBalloonChainBeforeDelete, _h2RefreshChainAfterDelete, saveOverlaySvg, selectOverlay, updateBalloonUI } from './09b-balloon-shapes.js';
import { clearHandles, renderHandles } from './09c-balloon-handles.js';
import { pushHistory, savePanelSvg } from './07-pages.js';
import { saveGroupAsAsset, ungroupLayer } from './05-groups-move.js';
import { _isSubPanelFrameMode, deleteSubPanel, renderSubPanelHandles, toggleSubPanelFrameMode } from './24-sub-panels.js';
import { deletePanel } from './24b-panel-deform.js';
// ── Alt+クリックで「同じ種類のオブジェクトをページ全体で一括表示/非表示切替」 ──
// ページ(=getPanelLayerSvg()が返す統合SVG。全コマ+オーバーレイ+下書きを含む)内から、
// kindに応じた対象要素を集める。textは、フキダシ内蔵のセリフ用<text>(balloon-shape配下)を
// 誤って含めないよう、.balloon-shapeの子孫であるものを除外する(レイヤーパネル本体の描画ループ
// でもテキストツールの<text>はg.data-clip-panel等の直接の子だけを対象にしており、
// フキダシ内蔵の<text>はそもそも別行として表示されない=同じ判定基準に揃える)。
function _rlpCollectSameTypeElements(kind, panelSvg) {
if (!panelSvg) return [];
switch (kind) {
case 'shape': return Array.from(panelSvg.querySelectorAll('.balloon-shape'));
case 'image': return Array.from(panelSvg.querySelectorAll('.inserted-image'));
case 'draw': return Array.from(panelSvg.querySelectorAll('.draw-shape'));
case 'text': return Array.from(panelSvg.querySelectorAll('text')).filter(el => !el.closest('.balloon-shape'));
default: return [];
}
}
// 対象要素は複数のコマ/オーバーレイ/下書きレイヤーにまたがることがあるため、実際に変更が
// 生じた範囲(panelId)だけを重複なく集め、それぞれ個別に保存する。savePanelSvgは
// 'panel-0'/'__overlay__'→オーバーレイ、'__draft__'→下書きへの振り分けを内部で行う。
async function _rlpBulkToggleSameType(kind, newHidden) {
const panelSvg = getPanelLayerSvg();
const elements = _rlpCollectSameTypeElements(kind, panelSvg);
if (!elements.length) return;
elements.forEach(el => { el.style.display = newHidden ? 'none' : ''; });
const panelIds = new Set();
elements.forEach(el => {
if (el.closest('g[data-draft-layer]')) { panelIds.add('__draft__'); return; }
panelIds.add(el.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') || 'panel-0');
});
for (const panelId of panelIds) {
await savePanelSvg(panelId, panelSvg);
}
}
function renderLayerPanel() {
const listEl = document.getElementById('layer-list');
if (!listEl) return;
const container = getActiveContainer();
if (!container || !state.activePage) {
listEl.innerHTML = `<p class="empty-message" style="font-size:12px;">${t('layer.noObjects')}</p>`;
return;
}
// 統合 panel-layer SVG から全オブジェクトを取得
const panelSvg = getPanelLayerSvg(container);
listEl.innerHTML = '';
// 現在処理中のコマが一括ロックされているか(コマループ内で更新、オーバーレイ処理時は false)
let _rlpPanelLocked = false;
// ── ヘルパー:チェックボックス生成 ──
const makeCheckbox = (svgEl) => {
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'layer-check';
cb.checked = state.checkedLayerEls.has(svgEl);
cb.addEventListener('change', (e) => {
e.stopPropagation();
if (cb.checked) {
state.checkedLayerEls.add(svgEl);
} else {
state.checkedLayerEls.delete(svgEl);
}
});
return cb;
};
// ── ヘルパー:フキダシアイテム生成 ──
const makeShapeItem = (shape, objIdx, indent, inGroup = false) => {
const isHidden = shape.style.display === 'none';
const panelLocked = _rlpPanelLocked;
const individualLocked = shape.dataset.locked === 'true';
const isLocked = individualLocked || panelLocked;
const isActive = shape.id === state.selectedShapeId;
const shapeTypeIcons = { rect: '▭', caption: '▬', 'textbox-rect': '▭', 'textbox-rounded': '▢', 'textbox-oval': '○', cloudpuffy: '☁', cloudwavy: '☁' };
const typeLabel = shapeTypeIcons[shape.dataset.shapeType] || '○';
if (!shape.dataset.name) {
shape.dataset.name = t('layer.balloonName', objIdx + 1);
}
const name = shape.dataset.name;
const item = document.createElement('div');
item.className = 'layer-item layer-item-shape' + (isActive ? ' active' : '') + (isHidden ? ' hidden-obj' : '') + (isLocked ? ' locked-obj' : '');
item.style.paddingLeft = `${6 + indent * 14}px`;
item.innerHTML = `
<span class="layer-item-icon">${typeLabel}</span>
<span class="layer-item-name">${_escHtml(name)}</span>
<div class="layer-item-btns">
<button class="layer-item-btn lock-btn" ${panelLocked ? 'disabled' : ''} title="${panelLocked ? t('layer.panelLockedTitle') : (individualLocked ? t('layer.unlockTitle') : t('layer.lockTitle'))}">${isLocked ? '🔒' : '🔓'}</button>
<button class="layer-item-btn vis-btn" title="${(isHidden ? t('layer.showTitle') : t('layer.hideTitle'))}${t('layer.visAltHint')}">${isHidden ? '🚫' : '👁'}</button>
<button class="layer-item-btn delete-btn" title="${t('common.delete')}">✕</button>
</div>
`;
if (!inGroup) item.prepend(makeCheckbox(shape));
item.addEventListener('click', (e) => {
if (e.target.closest('.layer-item-btn') || e.target.classList.contains('layer-check')) return;
if (isLocked) return;
state.selectedShapeId = shape.id;
state.selectedImageEl = null;
state.selectedImageId = null;
state.selectedTextEl = null;
state.selectedGroupId = null;
state.selectedDrawId = null;
state.selectedDrawEl = null;
clearImageHandles();
const _sv = getPanelLayerSvg();
if (_sv) { clearTextHandles(_sv); clearDrawShapeHandles(_sv); }
clearGroupHandles();
state.balloon.isEditMode = true;
updateBalloonUI();
renderHandles(shape);
syncPanelSelectionToObject(shape);
renderLayerPanel();
});
item.querySelector('.lock-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (panelLocked) return;
if (individualLocked) {
delete shape.dataset.locked;
} else {
shape.dataset.locked = 'true';
// ロック時は選択解除
if (state.selectedShapeId === shape.id) { state.selectedShapeId = null; clearHandles(); }
}
// 実際のDOM上の親コマを最優先で使う(レイヤーパネルは全コマのオブジェクトを一覧表示するため、
// state.selectedPanelId(キャンバス上で最後に選択したコマ)と対象shapeの所属コマが
// 一致しないケースがあり、それに頼ると無関係なコマへ誤保存されてしまう)
const panelId = shape.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') || 'panel-0';
const curSvg = getPanelLayerSvg();
if (curSvg) await savePanelSvg(panelId, curSvg);
renderLayerPanel();
});
item.querySelector('.vis-btn').addEventListener('click', async (e) => {
e.stopPropagation();
const newHidden = !isHidden;
if (e.altKey) {
await _rlpBulkToggleSameType('shape', newHidden);
} else {
shape.style.display = newHidden ? 'none' : '';
const panelId = shape.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') || 'panel-0';
const curSvg = getPanelLayerSvg();
if (curSvg) await savePanelSvg(panelId, curSvg);
}
renderLayerPanel();
});
item.querySelector('.delete-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (isLocked) return;
pushHistory();
if (state.selectedShapeId === shape.id) { state.selectedShapeId = null; clearHandles(); }
state.checkedLayerEls.delete(shape);
const panelId = shape.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') || 'panel-0';
// 延長フキダシの道連れ削除・ネック/共有リングの後始末は09b-balloon-shapes.jsの
// 共通ヘルパーに集約(Delete/Backspaceキー経由の05-groups-move.jsと共有)。
// これを経由しないと、延長・ネック・共有リングが消し忘れられて保存後もゴミが残る
const linkedToId = _h2CleanupBalloonChainBeforeDelete(shape);
shape.remove();
_h2RefreshChainAfterDelete(linkedToId);
const curSvg = getPanelLayerSvg();
if (curSvg) await savePanelSvg(panelId, curSvg);
renderLayerPanel();
});
return item;
};
// ── ヘルパー:画像アイテム生成 ──
const makeImageItem = (img, objIdx, indent, inGroup = false) => {
const isHidden = img.style.display === 'none' || img.getAttribute('visibility') === 'hidden';
const panelLocked = _rlpPanelLocked;
const individualLocked = img.dataset.locked === 'true';
const isLocked = individualLocked || panelLocked;
const isActive = img.id === state.selectedImageId;
const src = img.getAttribute('href') || img.getAttribute('xlink:href') || '';
const isVector = src.startsWith('data:image/svg');
const isPaintObject = img.dataset.cccPaintObject === '1';
const isVideoObj = !!img.dataset.videoSrc;
const icon = isVideoObj ? '🎬' : (isPaintObject ? '🖌' : (isVector ? '⬡' : '🖼'));
if (!img.dataset.name) {
img.dataset.name = isVideoObj
? t('layer.videoName', objIdx + 1)
: (isPaintObject
? t('layer.paintName', objIdx + 1)
: (isVector ? `SVG ${objIdx + 1}` : t('layer.imageName', objIdx + 1)));
}
const name = img.dataset.name;
// 下書きレイヤー内の画像はマスク機能非対応(下書きは画像の配置・移動のみをサポート)
const isDraftImg = !!img.closest('g[data-draft-layer]');
const item = document.createElement('div');
item.className = 'layer-item layer-item-shape' + (isActive ? ' active' : '') + (isHidden ? ' hidden-obj' : '') + (isLocked ? ' locked-obj' : '');
item.style.paddingLeft = `${6 + indent * 14}px`;
item.innerHTML = `
<span class="layer-item-icon">${icon}</span>
<span class="layer-item-name">${_escHtml(name)}</span>
<div class="layer-item-btns">
${isDraftImg ? '' : `<button class="layer-item-btn addmask-btn" title="${t('layer.addMaskTitle')}">🎭</button>`}
<button class="layer-item-btn lock-btn" ${panelLocked ? 'disabled' : ''} title="${panelLocked ? t('layer.panelLockedTitle') : (individualLocked ? t('layer.unlockTitle') : t('layer.lockTitle'))}">${isLocked ? '🔒' : '🔓'}</button>
<button class="layer-item-btn vis-btn" title="${(isHidden ? t('layer.showTitle') : t('layer.hideTitle'))}${t('layer.visAltHint')}">${isHidden ? '🚫' : '👁'}</button>
<button class="layer-item-btn delete-btn" title="${t('common.delete')}">✕</button>
</div>
`;
if (!inGroup) item.prepend(makeCheckbox(img));
item.querySelector('.addmask-btn')?.addEventListener('click', async (e) => {
e.stopPropagation();
_maskEnsureElId(img);
document.querySelector('.subtab-btn[data-subtab="mask"]')?.click();
const type = document.getElementById('mask-new-type')?.value === 'show' ? 'show' : 'hide';
await _maskAddLayerAndEdit(img.id, type);
});
item.addEventListener('click', (e) => {
if (e.target.closest('.layer-item-btn') || e.target.classList.contains('layer-check')) return;
if (isLocked) return;
const activeContainer = getActiveContainer();
state.selectedImageId = img.id;
state.selectedImageEl = img;
state.selectedShapeId = null;
state.selectedTextEl = null;
state.selectedGroupId = null;
state.selectedDrawId = null;
state.selectedDrawEl = null;
state.balloon.isEditMode = false;
updateBalloonUI();
clearHandles();
clearImageHandles();
const _sv2 = getPanelLayerSvg();
if (_sv2) { clearTextHandles(_sv2); clearDrawShapeHandles(_sv2); }
clearGroupHandles();
if (activeContainer) {
activeContainer.querySelectorAll('.inserted-image').forEach(i => i.classList.remove('selected'));
const panelSvgEl = getPanelLayerSvg(activeContainer);
if (panelSvgEl) renderImageHandles(img, panelSvgEl);
}
img.classList.add('selected');
syncPanelSelectionToObject(img);
renderLayerPanel();
if (typeof window._ccVideoOnObjectSelected === 'function') window._ccVideoOnObjectSelected(img);
});
item.querySelector('.lock-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (panelLocked) return;
// 実際のDOM上の親コマを最優先で使う(data-panel-id属性とのズレによる誤保存を防ぐ)
const panelId = img.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') ||
img.getAttribute('data-panel-id') || state.selectedPanelId || 'panel-0';
if (individualLocked) {
delete img.dataset.locked;
} else {
img.dataset.locked = 'true';
// ロック時は選択解除
if (state.selectedImageId === img.id) {
state.selectedImageId = null; state.selectedImageEl = null;
clearImageHandles();
}
}
const curSvg = getPanelLayerSvg();
if (curSvg) await savePanelSvg(panelId, curSvg);
renderLayerPanel();
});
item.querySelector('.vis-btn').addEventListener('click', async (e) => {
e.stopPropagation();
const newHidden = !isHidden;
if (e.altKey) {
await _rlpBulkToggleSameType('image', newHidden);
} else {
img.style.display = newHidden ? 'none' : '';
const curSvg = getPanelLayerSvg();
const visPanelId = img.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') ||
img.getAttribute('data-panel-id') || state.selectedPanelId || 'panel-0';
if (curSvg) await savePanelSvg(visPanelId, curSvg);
}
renderLayerPanel();
});
item.querySelector('.delete-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (isLocked) return;
pushHistory();
// 実際のDOM上の親コマを最優先で使う(data-panel-id属性とのズレにより誤ったコマへ
// 保存され、削除が本来のコマに反映されない=再描画時に復活する不具合を防ぐ)
const panelId = img.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') ||
img.getAttribute('data-panel-id') || state.selectedPanelId || 'panel-0';
if (state.selectedImageId === img.id) { state.selectedImageId = null; state.selectedImageEl = null; }
state.checkedLayerEls.delete(img);
img.remove();
const curSvg = getPanelLayerSvg();
if (curSvg) await savePanelSvg(panelId, curSvg);
renderLayerPanel();
});
return item;
};
// ── ヘルパー:テキストアイテム生成 ──
const makeTextItem = (textEl, objIdx, indent, inGroup = false) => {
const isHidden = textEl.style.display === 'none';
const panelLocked = _rlpPanelLocked;
const individualLocked = textEl.dataset.locked === 'true';
const isLocked = individualLocked || panelLocked;
const isActive = state.selectedTextEl === textEl;
const contentLabel = textEl.textContent.trim().slice(0, 10);
if (!contentLabel && !textEl.dataset.name) {
textEl.dataset.name = t('layer.textName', objIdx + 1);
}
const label = contentLabel || textEl.dataset.name;
const item = document.createElement('div');
item.className = 'layer-item layer-item-shape' + (isActive ? ' active' : '') + (isHidden ? ' hidden-obj' : '') + (isLocked ? ' locked-obj' : '');
item.style.paddingLeft = `${6 + indent * 14}px`;
item.innerHTML = `
<span class="layer-item-icon">✍</span>
<span class="layer-item-name">${_escHtml(label)}</span>
<div class="layer-item-btns">
<button class="layer-item-btn lock-btn" ${panelLocked ? 'disabled' : ''} title="${panelLocked ? t('layer.panelLockedTitle') : (individualLocked ? t('layer.unlockTitle') : t('layer.lockTitle'))}">${isLocked ? '🔒' : '🔓'}</button>
<button class="layer-item-btn vis-btn" title="${(isHidden ? t('layer.showTitle') : t('layer.hideTitle'))}${t('layer.visAltHint')}">${isHidden ? '🚫' : '👁'}</button>
<button class="layer-item-btn delete-btn" title="${t('common.delete')}">✕</button>
</div>
`;
if (!inGroup) item.prepend(makeCheckbox(textEl));
item.addEventListener('click', (e) => {
if (e.target.closest('.layer-item-btn') || e.target.classList.contains('layer-check')) return;
if (isLocked) return;
state.selectedTextEl = textEl;
state.selectedShapeId = null;
state.selectedImageEl = null;
state.selectedImageId = null;
state.selectedGroupId = null;
state.selectedDrawId = null;
state.selectedDrawEl = null;
state.balloon.isEditMode = false;
updateBalloonUI();
clearHandles();
clearImageHandles();
if (panelSvg) clearDrawShapeHandles(panelSvg);
clearGroupHandles();
syncFontFamilyUI(textEl);
if (panelSvg) renderTextHandles(textEl, panelSvg);
syncPanelSelectionToObject(textEl);
renderLayerPanel();
});
item.querySelector('.lock-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (panelLocked) return;
if (individualLocked) {
delete textEl.dataset.locked;
} else {
textEl.dataset.locked = 'true';
// ロック時は選択解除
if (state.selectedTextEl === textEl) {
state.selectedTextEl = null;
if (panelSvg) clearTextHandles(panelSvg);
}
}
// 実際のDOM上の親コマを最優先で使う(理由はmakeShapeItemのコメント参照)
const panelId = textEl.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') || 'panel-0';
const curSvg = getPanelLayerSvg();
if (curSvg) await savePanelSvg(panelId, curSvg);
renderLayerPanel();
});
item.querySelector('.vis-btn').addEventListener('click', async (e) => {
e.stopPropagation();
const newHidden = !isHidden;
if (e.altKey) {
await _rlpBulkToggleSameType('text', newHidden);
} else {
textEl.style.display = newHidden ? 'none' : '';
const panelId = textEl.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') || 'panel-0';
const curSvg = getPanelLayerSvg();
if (curSvg) await savePanelSvg(panelId, curSvg);
}
renderLayerPanel();
});
item.querySelector('.delete-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (isLocked) return;
pushHistory();
if (state.selectedTextEl === textEl) {
state.selectedTextEl = null;
if (panelSvg) clearTextHandles(panelSvg);
}
state.checkedLayerEls.delete(textEl);
const panelId = textEl.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') || 'panel-0';
textEl.remove();
const curSvg = getPanelLayerSvg();
if (curSvg) await savePanelSvg(panelId, curSvg);
renderLayerPanel();
});
return item;
};
// ── ヘルパー:描画図形アイテム生成 ──
const makeDrawShapeItem = (el, objIdx, indent, inGroup = false) => {
const isHidden = el.style.display === 'none';
const panelLocked = _rlpPanelLocked;
const individualLocked = el.dataset.locked === 'true';
const isLocked = individualLocked || panelLocked;
const isActive = el.id === state.selectedDrawId;
const kind = el.getAttribute('data-shape-kind') || el.tagName.toLowerCase();
const iconMap = { rect: '▬', ellipse: '⬭', line: '╱', polygon: '⬠' };
const icon = iconMap[kind] || '◼';
if (!el.dataset.name) {
el.dataset.name = t('layer.shapeName', objIdx + 1);
}
const name = el.dataset.name;
const item = document.createElement('div');
item.className = 'layer-item layer-item-shape' + (isActive ? ' active' : '') + (isHidden ? ' hidden-obj' : '') + (isLocked ? ' locked-obj' : '');
item.style.paddingLeft = `${6 + indent * 14}px`;
item.innerHTML = `
<span class="layer-item-icon">${icon}</span>
<span class="layer-item-name">${_escHtml(name)}</span>
<div class="layer-item-btns">
<button class="layer-item-btn addmask-btn" title="${t('layer.addMaskTitle')}">🎭</button>
<button class="layer-item-btn lock-btn" ${panelLocked ? 'disabled' : ''} title="${panelLocked ? t('layer.panelLockedTitle') : (individualLocked ? t('layer.unlockTitle') : t('layer.lockTitle'))}">${isLocked ? '🔒' : '🔓'}</button>
<button class="layer-item-btn vis-btn" title="${(isHidden ? t('layer.showTitle') : t('layer.hideTitle'))}${t('layer.visAltHint')}">${isHidden ? '🚫' : '👁'}</button>
<button class="layer-item-btn delete-btn" title="${t('common.delete')}">✕</button>
</div>
`;
if (!inGroup) item.prepend(makeCheckbox(el));
item.querySelector('.addmask-btn').addEventListener('click', async (e) => {
e.stopPropagation();
_maskEnsureElId(el);
document.querySelector('.subtab-btn[data-subtab="mask"]')?.click();
const type = document.getElementById('mask-new-type')?.value === 'show' ? 'show' : 'hide';
await _maskAddLayerAndEdit(el.id, type);
});
item.addEventListener('click', (e) => {
if (e.target.closest('.layer-item-btn') || e.target.classList.contains('layer-check')) return;
if (isLocked) return;
const svgEl = getPanelLayerSvg();
if (svgEl) _layerDrawSelectShape(el, svgEl);
renderLayerPanel();
});
item.querySelector('.lock-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (panelLocked) return;
if (individualLocked) { delete el.dataset.locked; }
else {
el.dataset.locked = 'true';
if (state.selectedDrawId === el.id) { state.selectedDrawId = null; state.selectedDrawEl = null; clearDrawShapeHandles(); _drawShapeSyncProps(null); }
}
// 実際のDOM上の親コマを最優先で使う(理由はmakeShapeItemのコメント参照)
const clipPanelId = el.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel');
const curSvg = getPanelLayerSvg();
if (curSvg) {
if (!clipPanelId) await saveOverlaySvg(curSvg);
else await savePanelSvg(clipPanelId, curSvg);
}
renderLayerPanel();
});
item.querySelector('.vis-btn').addEventListener('click', async (e) => {
e.stopPropagation();
const newHidden = !isHidden;
if (e.altKey) {
await _rlpBulkToggleSameType('draw', newHidden);
} else {
el.style.display = newHidden ? 'none' : '';
const clipPanelId = el.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel');
const curSvg = getPanelLayerSvg();
if (curSvg) {
if (!clipPanelId) await saveOverlaySvg(curSvg);
else await savePanelSvg(clipPanelId, curSvg);
}
}
renderLayerPanel();
});
item.querySelector('.delete-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (isLocked) return;
if (state.selectedDrawId === el.id) { state.selectedDrawId = null; state.selectedDrawEl = null; clearDrawShapeHandles(); _drawShapeSyncProps(null); }
state.checkedLayerEls.delete(el);
const clipPanelId = el.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel');
el.remove();
const curSvg = getPanelLayerSvg();
if (curSvg) {
if (!clipPanelId) await saveOverlaySvg(curSvg);
else await savePanelSvg(clipPanelId, curSvg);
}
renderLayerPanel();
});
return item;
};
// ── ヘルパー:グループアイテム生成 ──
const makeGroupItem = (groupEl, objIdx, indent) => {
const panelLocked = _rlpPanelLocked;
const isActive = groupEl.id === state.selectedGroupId;
if (!groupEl.dataset.groupName) {
groupEl.dataset.groupName = t('layer.groupNameDefault', objIdx + 1);
}
const name = groupEl.dataset.groupName;
const item = document.createElement('div');
item.className = 'layer-item layer-item-group' + (isActive ? ' active' : '') + (panelLocked ? ' locked-obj' : '');
item.style.paddingLeft = `${6 + indent * 14}px`;
item.innerHTML = `
<span class="layer-item-icon">[G]</span>
<span class="layer-item-name">${_escHtml(name)}</span>
<div class="layer-item-btns">
<button class="layer-item-btn save-asset-btn" title="${t('layer.saveAssetTitle')}">📦</button>
<button class="layer-item-btn ungroup-btn" title="${t('layer.ungroupTitle')}">G-</button>
<button class="layer-item-btn delete-btn" title="${t('layer.deleteGroupTitle')}">✕</button>
</div>
`;
item.addEventListener('click', (e) => {
if (e.target.closest('.layer-item-btn')) return;
if (panelLocked) return;
// グループ選択
state.selectedGroupId = groupEl.id;
state.selectedShapeId = null;
state.selectedImageEl = null;
state.selectedImageId = null;
state.selectedTextEl = null;
state.selectedDrawId = null;
state.selectedDrawEl = null;
clearHandles();
clearImageHandles();
const curSvgEl = getPanelLayerSvg();
if (curSvgEl) {
clearTextHandles(curSvgEl);
clearDrawShapeHandles(curSvgEl);
renderGroupHandles(groupEl, curSvgEl);
}
syncPanelSelectionToObject(groupEl);
renderLayerPanel();
});
item.querySelector('.save-asset-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (panelLocked) return;
await saveGroupAsAsset(groupEl, name);
});
item.querySelector('.ungroup-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (panelLocked) return;
await ungroupLayer(groupEl);
});
item.querySelector('.delete-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (panelLocked) return;
pushHistory();
if (state.selectedGroupId === groupEl.id) {
state.selectedGroupId = null;
clearGroupHandles();
}
groupEl.remove();
const curSvg = getPanelLayerSvg();
const panelId = groupEl.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') ||
(groupEl.closest('g[data-overlay-layer]') ? '__overlay__' : state.selectedPanelId || 'panel-0');
if (curSvg) await savePanelSvg(panelId, curSvg);
renderLayerPanel();
});
// グループ内子要素を表示(インデント+1)
const childObjs = Array.from(groupEl.children).filter(el =>
el.classList.contains('balloon-shape') ||
el.classList.contains('inserted-image') ||
el.tagName.toLowerCase() === 'text'
);
const fragment = document.createDocumentFragment();
fragment.appendChild(item);
[...childObjs].reverse().forEach((el) => {
const idx = childObjs.indexOf(el);
if (el.classList.contains('balloon-shape')) fragment.appendChild(makeShapeItem(el, idx, indent + 1, true));
else if (el.classList.contains('inserted-image')) fragment.appendChild(makeImageItem(el, idx, indent + 1, true));
else if (el.tagName.toLowerCase() === 'text') fragment.appendChild(makeTextItem(el, idx, indent + 1, true));
});
return fragment;
};
// ── ヘルパー:マスクレイヤーアイテム生成 ──
const makeMaskLayerItem = (target, ml, indent) => {
const isActive = _maskState.editing && _maskState.target === target && _maskState.layerId === ml.id;
const isObj = _maskIsObjectTarget(target);
const item = document.createElement('div');
item.className = 'layer-item layer-item-shape' + (isActive ? ' active' : '') + (ml.visible ? '' : ' hidden-obj');
item.style.paddingLeft = `${6 + indent * 14}px`;
item.innerHTML = `
<span class="layer-item-icon">🎭</span>
<span class="layer-item-name">${_escHtml(ml.name)}(${_maskTypeLabel(ml.type)})${isActive ? ' ✎' : ''}</span>
<div class="layer-item-btns">
${isObj ? `
<button class="layer-item-btn mask-up-btn" title="${t('layer.maskUpTitle')}">↑</button>
<button class="layer-item-btn mask-down-btn" title="${t('layer.maskDownTitle')}">↓</button>` : ''}
<button class="layer-item-btn vis-btn" title="${ml.visible ? t('layer.maskDisableTitle') : t('layer.maskEnableTitle')}">${ml.visible ? '👁' : '🚫'}</button>
<button class="layer-item-btn delete-btn" title="${t('layer.maskDeleteTitle')}">✕</button>
</div>
`;
item.addEventListener('click', async (e) => {
if (e.target.closest('.layer-item-btn')) return;
await _maskOpenEditorFor(target, ml.id);
});
item.querySelector('.mask-up-btn')?.addEventListener('click', async (e) => {
e.stopPropagation();
await _maskReassignObject(target, +1);
});
item.querySelector('.mask-down-btn')?.addEventListener('click', async (e) => {
e.stopPropagation();
await _maskReassignObject(target, -1);
});
item.querySelector('.vis-btn').addEventListener('click', async (e) => {
e.stopPropagation();
await _maskToggleLayerVisible(target, ml.id);
});
item.querySelector('.delete-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(t('layer.confirmDeleteMask', ml.name, _maskTypeLabel(ml.type)))) return;
await _maskDeleteLayer(target, ml.id);
});
return item;
};
const panels = state.activePage.panels || [];
// サブコマは配列末尾に追加される(作成順)ため、そのまま並べると実際の親コマとは
// 無関係に「配列で直前にあるコマの子」のように見えてしまう(例: コマ1のサブコマなのに
// コマ2の直後に表示され、コマ2配下に見える)。表示順だけ「親コマの直後にその子サブコマ」
// という並びに組み替える(データ自体・番号計算はpanels/panels.indexOfを使い元のまま)
const _rlpChildrenOf = new Map();
panels.forEach(p => {
if (!p.parentPanelId) return;
if (!_rlpChildrenOf.has(p.parentPanelId)) _rlpChildrenOf.set(p.parentPanelId, []);
_rlpChildrenOf.get(p.parentPanelId).push(p);
});
const orderedPanels = [];
const _rlpAppendWithChildren = (p) => {
orderedPanels.push(p);
(_rlpChildrenOf.get(p.id) || []).forEach(_rlpAppendWithChildren);
};
panels.filter(p => !p.parentPanelId).forEach(_rlpAppendWithChildren);
// ── 各コマノード ── サブコマの親は実コマだけでなく'__overlay__'(オーバーレイ)の場合もあるため、
// オーバーレイ配下のサブコマをオーバーレイ行の直後に表示するために関数として切り出し、
// 通常のコマループとオーバーレイセクションの両方から呼べるようにしてある
const renderPanelNode = (panel) => {
const isSubPanel = !!panel.parentPanelId;
const num = (panel.number !== undefined) ? panel.number : panels.indexOf(panel) + 1;
const panelItem = document.createElement('div');
const isPanelActive = !state.selectedOverlay && state.selectedPanelId === panel.id;
const isBorderHidden = !isSubPanel && _isPanelBorderHidden(panel.id);
const isPanelLocked = _isPanelLocked(panel.id);
_rlpPanelLocked = isPanelLocked; // 以降このコマ配下のオブジェクト生成(makeShapeItem等)で参照される
const panelMaskLayers = panelSvg ? _maskLayerList(panelSvg, panel.id) : [];
const panelMaskOff = panelSvg ? !!_maskTargetGroup(panelSvg, panel.id)?.hasAttribute('data-ccc-mask-off') : false;
panelItem.className = 'layer-item layer-item-panel' + (isPanelActive ? ' active' : '') + (isPanelLocked ? ' locked-obj' : '');
panelItem.style.paddingLeft = isSubPanel ? '20px' : '6px';
let nameLabel;
if (isSubPanel && panel.parentPanelId === '__overlay__') {
nameLabel = t('subpanel.optionLabelOverlay');
} else if (isSubPanel) {
const parent = panels.find(p => p.id === panel.parentPanelId);
const parentNum = parent ? ((parent.number !== undefined) ? parent.number : panels.indexOf(parent) + 1) : '?';
nameLabel = t('subpanel.optionLabel', parentNum);
} else {
nameLabel = t('common.panelName', num);
}
const isSubFrameMode = isSubPanel && _isSubPanelFrameMode(panel.id);
panelItem.innerHTML = `
${isSubPanel
? `<input type="checkbox" class="layer-check subpanel-frame-mode-check" ${isSubFrameMode ? 'checked' : ''} title="${t('subpanel.frameModeCheckTitle')}" />`
: `<span class="layer-item-icon">🔲</span>`}
<span class="layer-item-name">${_escHtml(nameLabel)}</span>
<div class="layer-item-btns">
<button class="layer-item-btn panel-lock-btn" title="${isPanelLocked ? t('layer.panelUnlockAllTitle') : t('layer.panelLockAllTitle')}">${isPanelLocked ? '🔒' : '🔓'}</button>
<button class="layer-item-btn mask-btn" title="${t('layer.panelMaskTitle')}"
style="${panelMaskLayers.length ? (panelMaskOff ? 'opacity:0.45;' : 'color:#7ab8ff;') : 'opacity:0.6;'}">🎭</button>
${isSubPanel
? `<button class="layer-item-btn subpanel-delete-btn" title="${t('subpanel.deleteTitle')}">✕</button>`
: `<button class="layer-item-btn border-toggle-btn" title="${isBorderHidden ? t('layer.borderShowTitle') : t('layer.borderHideTitle')}">${isBorderHidden ? '−' : '□'}</button>
<button class="layer-item-btn panel-delete-btn" title="${t('deform.layerDeleteTitle')}">✕</button>`}
</div>
`;
if (isSubPanel) {
panelItem.querySelector('.subpanel-frame-mode-check').addEventListener('click', async (e) => {
e.stopPropagation();
await toggleSubPanelFrameMode(panel.id);
});
}
panelItem.addEventListener('click', (e) => {
if (e.target.closest('.layer-item-btn') || e.target.classList.contains('subpanel-frame-mode-check')) return;
selectPanel(panel.id);
if (isSubPanel) {
const sv = getPanelLayerSvg();
if (sv) renderSubPanelHandles(panel, sv);
}
});
panelItem.querySelector('.panel-lock-btn').addEventListener('click', async (e) => {
e.stopPropagation();
await togglePanelLock(panel.id);
});
if (isSubPanel) {
panelItem.querySelector('.subpanel-delete-btn').addEventListener('click', async (e) => {
e.stopPropagation();
await deleteSubPanel(panel.id);
});
} else {
panelItem.querySelector('.border-toggle-btn').addEventListener('click', async (e) => {
e.stopPropagation();
await togglePanelBorderVisibility(panel.id);
});
panelItem.querySelector('.panel-delete-btn').addEventListener('click', async (e) => {
e.stopPropagation();
await deletePanel(panel.id);
});
}
panelItem.querySelector('.mask-btn').addEventListener('click', async (e) => {
e.stopPropagation();
selectPanel(panel.id);
document.querySelector('.subtab-btn[data-subtab="mask"]')?.click();
const type = document.getElementById('mask-new-type')?.value === 'show' ? 'show' : 'hide';
await _maskAddLayerAndEdit(panel.id, type);
});
listEl.appendChild(panelItem);
// マスクレイヤー行(最前面が上)
[...panelMaskLayers].reverse().forEach(ml => listEl.appendChild(makeMaskLayerItem(panel.id, ml, 2)));
// panel-layer の g[data-clip-panel] からコンテンツを収集(DOM順でソートして表示)
if (panelSvg) {
const group = panelSvg.querySelector(`g[data-clip-panel="${panel.id}"]`);
if (group) {
const allObjs = Array.from(group.children).filter(el =>
el.classList.contains('balloon-shape') ||
el.classList.contains('inserted-image') ||
el.classList.contains('draw-shape') ||
el.tagName.toLowerCase() === 'text' ||
el.hasAttribute('data-group-id')
);
[...allObjs].reverse().forEach((el) => {
const idx = allObjs.indexOf(el);
// このオブジェクトに付いたレイヤーマスク行(対象の直上に表示)
if (el.id) {
[..._maskLayerList(panelSvg, el.id)].reverse().forEach(ml =>
listEl.appendChild(makeMaskLayerItem(el.id, ml, 3)));
}
if (el.hasAttribute('data-group-id')) listEl.appendChild(makeGroupItem(el, idx, 2));
else if (el.classList.contains('balloon-shape')) listEl.appendChild(makeShapeItem(el, idx, 2));
else if (el.classList.contains('inserted-image')) listEl.appendChild(makeImageItem(el, idx, 2));
else if (el.classList.contains('draw-shape')) listEl.appendChild(makeDrawShapeItem(el, idx, 2));
else if (el.tagName.toLowerCase() === 'text') listEl.appendChild(makeTextItem(el, idx, 2));
});
}
}
};
orderedPanels.forEach(renderPanelNode);
// ── オーバーレイレイヤー(最下段・ページ全面) ── コマの一括ロックは対象外
_rlpPanelLocked = false;
const overlayItem = document.createElement('div');
const isOverlayActive = state.selectedOverlay;
const ovMaskLayers = panelSvg ? _maskLayerList(panelSvg, '__overlay__') : [];
const ovMaskOff = panelSvg ? !!_maskTargetGroup(panelSvg, '__overlay__')?.hasAttribute('data-ccc-mask-off') : false;
overlayItem.className = 'layer-item layer-item-panel' + (isOverlayActive ? ' active' : '');
overlayItem.style.paddingLeft = '6px';
overlayItem.innerHTML = `
<span class="layer-item-icon">⬜</span><span class="layer-item-name">${t('common.overlayFull')}</span>
<div class="layer-item-btns">
<button class="layer-item-btn mask-btn" title="${t('layer.overlayMaskTitle')}"
style="${ovMaskLayers.length ? (ovMaskOff ? 'opacity:0.45;' : 'color:#7ab8ff;') : 'opacity:0.6;'}">🎭</button>
</div>
`;
overlayItem.addEventListener('click', (e) => {
if (e.target.closest('.layer-item-btn')) return;
selectOverlay();
});
overlayItem.querySelector('.mask-btn').addEventListener('click', async (e) => {
e.stopPropagation();
selectOverlay();
document.querySelector('.subtab-btn[data-subtab="mask"]')?.click();
const type = document.getElementById('mask-new-type')?.value === 'show' ? 'show' : 'hide';
await _maskAddLayerAndEdit('__overlay__', type);
});
listEl.appendChild(overlayItem);
// オーバーレイのマスクレイヤー行(最前面が上)
[...ovMaskLayers].reverse().forEach(ml => listEl.appendChild(makeMaskLayerItem('__overlay__', ml, 2)));
// オーバーレイを親コマとするサブコマ(移動/複製でオーバーレイへ付け替えられたもの)を、
// 実コマ配下のサブコマと同じ見た目でオーバーレイ行の直後に表示する
panels.filter(p => p.parentPanelId === '__overlay__').forEach(renderPanelNode);
// renderPanelNode内でサブコマ自身のロック状態に応じて_rlpPanelLockedが書き換わるため、
// 以降のオーバーレイ本体のオブジェクト一覧(コマの一括ロックの対象外)向けにリセットする
_rlpPanelLocked = false;
// オーバーレイg要素内のオブジェクトを表示(DOM順でソートして表示)
if (panelSvg) {
const overlayG = panelSvg.querySelector('g[data-overlay-layer]');
if (overlayG) {
const allObjs = Array.from(overlayG.children).filter(el =>
el.classList.contains('balloon-shape') ||
el.classList.contains('inserted-image') ||
el.classList.contains('draw-shape') ||
el.tagName.toLowerCase() === 'text' ||
el.hasAttribute('data-group-id')
);
[...allObjs].reverse().forEach((el) => {
const idx = allObjs.indexOf(el);
// このオブジェクトに付いたレイヤーマスク行(対象の直上に表示)
if (el.id) {
[..._maskLayerList(panelSvg, el.id)].reverse().forEach(ml =>
listEl.appendChild(makeMaskLayerItem(el.id, ml, 3)));
}
if (el.hasAttribute('data-group-id')) listEl.appendChild(makeGroupItem(el, idx, 2));
else if (el.classList.contains('balloon-shape')) listEl.appendChild(makeShapeItem(el, idx, 2));
else if (el.classList.contains('inserted-image')) listEl.appendChild(makeImageItem(el, idx, 2));
else if (el.classList.contains('draw-shape')) listEl.appendChild(makeDrawShapeItem(el, idx, 2));
else if (el.tagName.toLowerCase() === 'text') listEl.appendChild(makeTextItem(el, idx, 2));
});
}
}
// ── 下書きレイヤー(最前面・画像のみ・出力対象外) ──
// 行クリックで編集モードに入っている間だけプレビュー上でクリック操作できる(それ以外は常にクリック無視)
_rlpPanelLocked = false;
const draftItem = document.createElement('div');
const isDraftActive = state.selectedDraft;
draftItem.className = 'layer-item layer-item-panel' + (isDraftActive ? ' active' : '');
draftItem.style.paddingLeft = '6px';
draftItem.title = t('layer.draftRowTitle');
draftItem.innerHTML = `
<span class="layer-item-icon">📝</span><span class="layer-item-name">${t('common.draftFull')}${isDraftActive ? ' ✎' : ''}</span>
`;
draftItem.addEventListener('click', () => selectDraft());
listEl.appendChild(draftItem);
// 下書きg要素内の画像を表示(DOM順でソート、最前面が上)。下書きは画像のみサポート
if (panelSvg) {
const draftG = panelSvg.querySelector('g[data-draft-layer]');
if (draftG) {
const draftImgs = Array.from(draftG.children).filter(el => el.classList.contains('inserted-image'));
[...draftImgs].reverse().forEach((el) => {
listEl.appendChild(makeImageItem(el, draftImgs.indexOf(el), 2));
});
}
}
updateDuplicatePanelSelect();
_layerOpacitySync();
// 「画像タブで編集」ボタン: 画像選択中のみ有効
const openImageTabBtn = document.getElementById('layer-draw-open-imgedit');
if (openImageTabBtn) {
const hasImage = !!state.selectedImageEl;
openImageTabBtn.disabled = !hasImage;
openImageTabBtn.title = hasImage
? t('layer.openInImageTab', state.selectedImageEl.dataset.name || state.selectedImageEl.id)
: t('layer.noImageSelected');
}
// 「OC」ボタン(選択オブジェクトを中央へ移動): 何らかのオブジェクト選択中のみ有効
const ocBtn = document.getElementById('object-center-btn');
if (ocBtn) ocBtn.disabled = !_layerOpacityGetSelected();
// 「図形をPNG変換」ボタン: 図形レイヤー選択中のみ有効
const shapeToPngBtn = document.getElementById('layer-draw-shape-to-png');
if (shapeToPngBtn) shapeToPngBtn.disabled = !state.selectedDrawEl;
}
// ── 選択中オブジェクトの不透明度スライダー同期 ──
function _layerOpacitySync() {
const row = document.getElementById('layer-opacity-row');
if (!row) return;
const el = _layerOpacityGetSelected();
if (!el) {
row.style.display = 'none';
return;
}
const rawOp = el.getAttribute('opacity') || el.style.opacity || '1';
const opVal = Math.round(Math.min(1, Math.max(0, parseFloat(rawOp) || 1)) * 100);
row.style.display = 'flex';
const slider = document.getElementById('layer-opacity-slider');
const label = document.getElementById('layer-opacity-value');
slider.value = opVal;
label.textContent = opVal + '%';
}
// 選択中オブジェクトSVG要素を返す(画像/フキダシ/テキスト/draw-shape/グループ)
function _layerOpacityGetSelected() {
if (state.selectedImageEl) return state.selectedImageEl;
if (state.selectedDrawEl) return state.selectedDrawEl;
if (state.selectedTextEl) return state.selectedTextEl;
if (state.selectedShapeId) {
const svgEl = getPanelLayerSvg();
return svgEl ? svgEl.querySelector(`#${CSS.escape(state.selectedShapeId)}`) : null;
}
if (state.selectedGroupId) {
const svgEl = getPanelLayerSvg();
return svgEl ? svgEl.querySelector(`[data-group-id="${state.selectedGroupId}"]`) : null;
}
return null;
}
function getActiveContainer() {
return document.getElementById('layout-preview');
}
// アクティブなコンテナから統合SVGを取得するヘルパー
function getPanelLayerSvg(container) {
if (!container) container = getActiveContainer();
return container?.querySelector('#image-layer svg') || null;
}
export { _layerOpacityGetSelected, _layerOpacitySync, getActiveContainer, getPanelLayerSvg, renderLayerPanel };
// まだESM化されていない main/以下の classic <script> から呼べるようにするブリッジ
// (ESモジュール化移行中の一時措置。全分割ファイルのESM化が完了したら、
// 各呼び出し元をimport文に置き換えてこのブロックごと削除する)。
window._layerOpacityGetSelected = _layerOpacityGetSelected;
window._layerOpacitySync = _layerOpacitySync;