feat(moss): フキダシ・セリフの自動取込(レイアウトエディタ)
ページのオーバーレイと各コマのフキダシ(g.balloon-shape)から、セリフ入りのものを文字枠として一括取り込み。 枠はフキダシの輪郭、書字方向・フォントサイズ・形状(caption)を反映。再取込は取り込み済みの枠だけ入れ替える。 文字枠に「枠を文字の大きさに合わせる」の切替を追加(取り込んだ枠は既定でオフ)。 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
ab9516a20a
commit
ee45616bac
@@ -96,6 +96,11 @@ const LANGUAGES = {
|
||||
'moss.typeText': '文字',
|
||||
'moss.writingH': '横書き',
|
||||
'moss.itemWriting': '書字方向:',
|
||||
'moss.importBalloons': 'フキダシ・セリフを取り込む',
|
||||
'moss.noBalloons': 'このページにセリフ入りのフキダシがありません。レイアウトタブでフキダシにセリフを入れてください。',
|
||||
'moss.confirmFitCanvas': 'キャンバスをページの縦横比に合わせます。既存の枠も同じ比率で拡縮されます。続けますか?',
|
||||
'moss.balloonsImported': (a) => `フキダシ・セリフを${a}件取り込みました`,
|
||||
'moss.itemFit': '枠を文字の大きさに合わせる',
|
||||
'moss.seedRandom': 'ランダム',
|
||||
'moss.seedFixed': '固定',
|
||||
'moss.seedIncrement': '生成ごとに+1',
|
||||
@@ -1974,6 +1979,11 @@ const LANGUAGES = {
|
||||
'moss.typeText': 'Text',
|
||||
'moss.writingH': 'Horizontal',
|
||||
'moss.itemWriting': 'Writing:',
|
||||
'moss.importBalloons': 'Import balloons and lines',
|
||||
'moss.noBalloons': 'No balloons with text on this page. Add text to the balloons in the Layout tab first.',
|
||||
'moss.confirmFitCanvas': 'The canvas will be fitted to the page aspect ratio, and existing boxes are scaled by the same ratio. Continue?',
|
||||
'moss.balloonsImported': (a) => `Imported ${a} balloon(s) with text`,
|
||||
'moss.itemFit': 'Fit the box to the text size',
|
||||
'moss.seedRandom': 'Random',
|
||||
'moss.seedFixed': 'Fixed',
|
||||
'moss.seedIncrement': 'Increment per run',
|
||||
@@ -3823,6 +3833,11 @@ const LANGUAGES = {
|
||||
'moss.typeText': '文字',
|
||||
'moss.writingH': '横排',
|
||||
'moss.itemWriting': '书写方向:',
|
||||
'moss.importBalloons': '导入气泡和台词',
|
||||
'moss.noBalloons': '此页面没有带台词的气泡。请先在布局标签页中给气泡输入台词。',
|
||||
'moss.confirmFitCanvas': '画布将调整为页面的宽高比,已有的框也会按相同比例缩放。是否继续?',
|
||||
'moss.balloonsImported': (a) => `已导入 ${a} 个带台词的气泡`,
|
||||
'moss.itemFit': '使框适应文字大小',
|
||||
'moss.seedRandom': '随机',
|
||||
'moss.seedFixed': '固定',
|
||||
'moss.seedIncrement': '每次生成+1',
|
||||
|
||||
+113
-12
@@ -148,10 +148,12 @@ function onSizePresetChange() {
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
async function getActivePageSvgSize() {
|
||||
async function getActivePageRecord() {
|
||||
if (!state.activePage) return null;
|
||||
const rec = await dbGet('pages', state.activePage.name);
|
||||
if (!rec) return null;
|
||||
return (await dbGet('pages', state.activePage.name)) || null;
|
||||
}
|
||||
|
||||
function pageSvgSizeOf(rec) {
|
||||
let w = rec.width, h = rec.height;
|
||||
if (!(w > 0) || !(h > 0)) {
|
||||
const svg = new DOMParser().parseFromString(rec.svgContent || '', 'image/svg+xml').querySelector('svg');
|
||||
@@ -161,6 +163,11 @@ async function getActivePageSvgSize() {
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
async function getActivePageSvgSize() {
|
||||
const rec = await getActivePageRecord();
|
||||
return rec ? pageSvgSizeOf(rec) : null;
|
||||
}
|
||||
|
||||
// ─── レイアウトエディタ ───
|
||||
const selItem = () => S.items.find(i => i.id === S.selId) || null;
|
||||
const drawType = () => document.querySelector('#moss-draw-type .moss-type-btn.active')?.dataset.type || 'object';
|
||||
@@ -267,7 +274,7 @@ function textExtent(text, fs, vertical = false) {
|
||||
|
||||
// 文字が入っていれば、左上を保ったまま枠を文字の大きさにする(キャンバスからはみ出す分は位置を戻す)
|
||||
function autoSizeText(it) {
|
||||
if (it.type !== 'text' || !it.text) return;
|
||||
if (it.type !== 'text' || !it.text || it.fitBox === false) return; // fitBox=false: 枠はそのまま(取り込んだフキダシの輪郭など)
|
||||
if (!(it.fontSize > 0)) it.fontSize = defaultFontPx();
|
||||
const ext = textExtent(it.text, it.fontSize, it.vertical);
|
||||
it.w = Math.min(S.W, ext.w); it.h = Math.min(S.H, ext.h);
|
||||
@@ -286,22 +293,34 @@ function fitFontToBox(it) {
|
||||
|
||||
// 文字が未入力のときはcaptionを薄く表示する
|
||||
function applyLabel(label, it) {
|
||||
const resetLayout = () => {
|
||||
label.style.left = label.style.top = label.style.right = label.style.bottom = label.style.lineHeight = '';
|
||||
label.style.display = label.style.alignItems = label.style.justifyContent = label.style.textAlign = '';
|
||||
label.style.writingMode = label.style.textOrientation = '';
|
||||
};
|
||||
resetLayout();
|
||||
if (it.type === 'text' && it.text) {
|
||||
const fs = it.fontSize > 0 ? it.fontSize : defaultFontPx();
|
||||
label.textContent = it.text;
|
||||
label.style.fontSize = `${fs * S.scale}px`;
|
||||
label.style.lineHeight = String(TEXT_LINE_H);
|
||||
const pad = `${fs * TEXT_PAD * S.scale}px`;
|
||||
label.style.left = label.style.top = pad;
|
||||
// 縦書き: 右上から下へ、列は右→左。右側にも余白を取る
|
||||
label.style.right = it.vertical ? pad : '';
|
||||
label.style.writingMode = it.vertical ? 'vertical-rl' : '';
|
||||
label.style.textOrientation = it.vertical ? 'upright' : '';
|
||||
if (it.fitBox === false) {
|
||||
// 枠が文字より大きい(フキダシの輪郭など): 枠の中央に文字を置く
|
||||
label.style.left = label.style.top = label.style.right = label.style.bottom = '0';
|
||||
label.style.display = 'flex';
|
||||
label.style.alignItems = label.style.justifyContent = 'center';
|
||||
label.style.textAlign = 'center';
|
||||
} else {
|
||||
const pad = `${fs * TEXT_PAD * S.scale}px`;
|
||||
label.style.left = label.style.top = pad;
|
||||
// 縦書き: 右上から下へ、列は右→左。右側にも余白を取る
|
||||
label.style.right = it.vertical ? pad : '';
|
||||
}
|
||||
label.classList.add('moss-label-text');
|
||||
label.classList.remove('moss-label-dim');
|
||||
} else {
|
||||
label.style.left = label.style.top = label.style.right = label.style.lineHeight = '';
|
||||
label.style.writingMode = label.style.textOrientation = '';
|
||||
label.textContent = it.type === 'text' ? (it.caption || t('moss.textEmptyLabel')) : it.caption;
|
||||
label.style.fontSize = '';
|
||||
label.classList.remove('moss-label-text');
|
||||
@@ -375,9 +394,11 @@ function syncProps() {
|
||||
$('moss-item-text-field').style.display = it.type === 'text' ? '' : 'none';
|
||||
$('moss-item-font-field').style.display = it.type === 'text' ? '' : 'none';
|
||||
$('moss-item-writing-field').style.display = it.type === 'text' ? '' : 'none';
|
||||
$('moss-item-fit-field').style.display = it.type === 'text' ? '' : 'none';
|
||||
if (it.type === 'text') {
|
||||
$('moss-item-fontsize').value = it.fontSize > 0 ? it.fontSize : defaultFontPx();
|
||||
$('moss-item-writing').value = it.vertical ? '1' : '0';
|
||||
$('moss-item-fit').checked = it.fitBox !== false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,7 +504,7 @@ function initCanvasInteraction() {
|
||||
}
|
||||
const justDrew = drag.mode === 'draw' && S.items.includes(it);
|
||||
// 文字枠を手でリサイズしたら、その枠に収まる最大のフォントサイズに更新し、枠を文字の大きさに揃える
|
||||
if (it.type === 'text' && S.items.includes(it) && drag.mode !== 'move' && drag.mode !== 'draw') {
|
||||
if (it.type === 'text' && it.fitBox !== false && S.items.includes(it) && drag.mode !== 'move' && drag.mode !== 'draw') {
|
||||
it.fontSize = fitFontToBox(it);
|
||||
S.lastFontPx = it.fontSize;
|
||||
autoSizeText(it);
|
||||
@@ -553,6 +574,12 @@ function initItemProps() {
|
||||
if (el) { placeBox(el, it); applyLabel(el.querySelector('.moss-label'), it); }
|
||||
schedulePreview(); scheduleSave();
|
||||
});
|
||||
$('moss-item-fit').addEventListener('change', () => {
|
||||
const it = selItem(); if (!it || it.type !== 'text') return;
|
||||
it.fitBox = $('moss-item-fit').checked;
|
||||
if (it.fitBox) autoSizeText(it);
|
||||
renderCanvas(); schedulePreview(); scheduleSave();
|
||||
});
|
||||
$('moss-item-writing').addEventListener('change', () => setItemWriting(selItem(), $('moss-item-writing').value === '1'));
|
||||
// フォントサイズ(画像上のpx)。変更すると枠が文字の大きさに合わせて更新される
|
||||
$('moss-item-fontsize').addEventListener('input', () => {
|
||||
@@ -612,6 +639,79 @@ async function importPagePanels() {
|
||||
renderCanvas(); schedulePreview(); scheduleSave();
|
||||
}
|
||||
|
||||
// ─── フキダシ・セリフの取り込み ───
|
||||
// ページのフキダシ(g.balloon-shape。オーバーレイと各コマのSVGの両方)から、セリフ入りのものを取り出す。
|
||||
// 位置・大きさは data-cx/cy/rx/ry(ページのSVG座標)、セリフは data-bubble-text、フォントサイズは data-font-size-svg
|
||||
const BALLOON_PHRASE = {
|
||||
normal: 'Speech bubble.', rect: 'Rectangular speech bubble.', thought: 'Thought bubble.',
|
||||
bomb: 'Shouting burst-shaped bubble.', caption: 'Caption box.',
|
||||
cloudpuffy: 'Puffy cloud-shaped bubble.', cloudwavy: 'Wavy cloud-shaped bubble.',
|
||||
'textbox-rect': 'Text box.', 'textbox-rounded': 'Rounded text box.', 'textbox-oval': 'Oval text box.',
|
||||
};
|
||||
|
||||
function extractBalloons(rec) {
|
||||
const parser = new DOMParser();
|
||||
const sources = [rec.overlaySvgContent, ...(rec.panels || []).map(p => p.panelSvgContent)].filter(Boolean);
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const src of sources) {
|
||||
const doc = parser.parseFromString(src, 'image/svg+xml');
|
||||
for (const g of doc.querySelectorAll('g.balloon-shape')) {
|
||||
const text = (g.getAttribute('data-bubble-text') || '').trim();
|
||||
const num = (name) => parseFloat(g.getAttribute(name));
|
||||
const cx = num('data-cx'), cy = num('data-cy'), rx = num('data-rx'), ry = num('data-ry');
|
||||
if (!text || ![cx, cy, rx, ry].every(Number.isFinite) || rx <= 0 || ry <= 0) continue;
|
||||
const id = g.getAttribute('id') || `${cx},${cy}`;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push({
|
||||
id, text, cx, cy, rx, ry,
|
||||
shapeType: g.getAttribute('data-shape-type') || 'normal',
|
||||
vertical: g.getAttribute('data-bubble-text-vertical') === '1',
|
||||
fontSizeSvg: num('data-font-size-svg'),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 読み順(上から下、同じ段は右から左)
|
||||
const band = Math.max(1, ...out.map(b => b.ry)) * 1.2;
|
||||
out.sort((a, b) => (Math.abs(a.cy - b.cy) < band ? b.cx - a.cx : a.cy - b.cy));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function importBalloons() {
|
||||
const rec = await getActivePageRecord();
|
||||
if (!rec) { alert(t('layout.msgNoActivePage')); return; }
|
||||
const svg = pageSvgSizeOf(rec);
|
||||
const list = extractBalloons(rec);
|
||||
if (!list.length) { alert(t('moss.noBalloons')); return; }
|
||||
// キャンバスの縦横比がページと違えば、ページに合わせる(既存の枠も同じ比率で拡縮する)
|
||||
if (Math.abs((S.W / S.H) / (svg.w / svg.h) - 1) > 0.02) {
|
||||
if (S.items.length && !confirm(t('moss.confirmFitCanvas'))) return;
|
||||
const { w, h } = sizeForAspect(svg.w / svg.h, PAGE_TARGET_PIXELS);
|
||||
const kx = w / S.W, ky = h / S.H;
|
||||
for (const it of S.items) {
|
||||
it.x *= kx; it.w *= kx; it.y *= ky; it.h *= ky;
|
||||
if (it.fontSize > 0) it.fontSize = Math.min(FONT_MAX, Math.max(FONT_MIN, Math.round(it.fontSize * ky)));
|
||||
}
|
||||
setSize(w, h);
|
||||
}
|
||||
S.items = S.items.filter(i => i.src !== 'balloon'); // 再取込: 前回取り込んだ枠だけ入れ替える(手で足した枠は残す)
|
||||
if (S.selId && !S.items.some(i => i.id === S.selId)) S.selId = null;
|
||||
const sx = S.W / svg.w, sy = S.H / svg.h;
|
||||
for (const b of list) {
|
||||
const x = clamp((b.cx - b.rx) * sx, 0, S.W - MIN_BOX_PX), y = clamp((b.cy - b.ry) * sy, 0, S.H - MIN_BOX_PX);
|
||||
const it = addItem({
|
||||
type: 'text', src: 'balloon', text: b.text, vertical: b.vertical, fitBox: false, // 枠はフキダシの輪郭(文字の大きさに合わせない)
|
||||
caption: BALLOON_PHRASE[b.shapeType] || 'Speech bubble.',
|
||||
x, y, w: Math.min(b.rx * 2 * sx, S.W - x), h: Math.min(b.ry * 2 * sy, S.H - y),
|
||||
fontSize: b.fontSizeSvg > 0 ? Math.min(FONT_MAX, Math.max(FONT_MIN, Math.round(b.fontSizeSvg * sy))) : 0,
|
||||
});
|
||||
if (!(it.fontSize > 0)) it.fontSize = fitFontToBox(it);
|
||||
}
|
||||
renderCanvas(); schedulePreview(); scheduleSave();
|
||||
$('moss-progress-text').textContent = t('moss.balloonsImported', list.length);
|
||||
}
|
||||
|
||||
// ─── JSONプレビュー ───
|
||||
function schedulePreview() {
|
||||
clearTimeout(S.previewTimer);
|
||||
@@ -921,6 +1021,7 @@ async function initMossTabOnce() {
|
||||
$('moss-json-manual').addEventListener('change', onJsonManualToggle);
|
||||
$('moss-json-preview').addEventListener('input', scheduleSave);
|
||||
$('moss-layout-import-btn').addEventListener('click', importPagePanels);
|
||||
$('moss-layout-import-balloons-btn').addEventListener('click', importBalloons);
|
||||
$('moss-panel-reload-btn').addEventListener('click', reloadPanelList);
|
||||
$('moss-reload-models-btn').addEventListener('click', () => loadModelLists(loadSettings()));
|
||||
$('moss-generate-btn').addEventListener('click', onGenerate);
|
||||
@@ -940,4 +1041,4 @@ function initMossTab() {
|
||||
initMossTabOnce();
|
||||
}
|
||||
|
||||
export { initMossTab };
|
||||
export { initMossTab, extractBalloons };
|
||||
|
||||
@@ -2104,6 +2104,7 @@
|
||||
</div>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:6px; margin-top:8px;">
|
||||
<button id="moss-layout-import-btn" class="btn small secondary" data-i18n="moss.importPanels">現在のページのコマを取り込む</button>
|
||||
<button id="moss-layout-import-balloons-btn" class="btn small secondary" data-i18n="moss.importBalloons">フキダシ・セリフを取り込む</button>
|
||||
<button id="moss-layout-clear-btn" class="btn small danger" data-i18n="nb.clearAll">全クリア</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2195,6 +2196,11 @@
|
||||
<option value="1" data-i18n="moss.writingV">縦書き</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="comfyui-field" id="moss-item-fit-field">
|
||||
<label class="comfyui-checkbox-label" style="display:inline-flex; align-items:center; gap:4px; cursor:pointer;">
|
||||
<input type="checkbox" id="moss-item-fit" checked> <span data-i18n="moss.itemFit">枠を文字の大きさに合わせる</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="comfyui-field">
|
||||
<label class="comfyui-label" data-i18n="moss.itemCaption">説明(caption):</label>
|
||||
<textarea id="moss-item-caption" rows="3" class="comfyui-textarea" style="width:100%; background:#111; color:#fff; box-sizing:border-box;"></textarea>
|
||||
|
||||
Reference in New Issue
Block a user