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