Files

6755 lines
346 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.
/**
* Image Tab
* Canvas-based image editor with object-based layer support.
* Ported from ComfyUI-Workflow-Studio's Image Edit tab, including the
* Mask Editor One compatible extensions (Color/Alpha/Text/Vector/Shape
* mask subtools, SAM3 segmentation, ABR brush picker). SAM3 and ABR
* brush features require backend routes (/mask_editor/*) that do not
* exist on comic-creator, so their UI is present but stays disabled.
*/
import { LayerManager, Layer } from "./image-tab/LayerManager.js";
import { DrawTool } from "./image-tab/DrawTool.js";
import { DrawPalette } from "./image-tab/DrawPalette.js";
import { TextTool, layoutVerticalText, drawVerticalCells } from "./image-tab/TextTool.js";
import { Text3DTool } from "./image-tab/Text3DTool.js";
import { SelectTool } from "./image-tab/SelectTool.js";
import { ShapeTool } from "./image-tab/ShapeTool.js";
import { FillTool } from "./image-tab/FillTool.js";
import { MaskTool } from "./image-tab/MaskTool.js";
import { MaskColorTool, MaskAlphaTool, MaskTextTool, MaskVectorTool, MaskShapeTool, MASK_TEXT_FONTS }
from "./image-tab/MaskEditorOneTools.js";
import { state, switchTab } from './main/01-state.js';
import { _fontMgrCatLabel, _fontMgrEnsureFontLoaded, _fontMgrGoogleList } from './main/19-font-manager.js';
import { getI2ISettingsState, saveI2ISettingsState, saveToEagle, sendI2IRunToWorkflowStudio, sendImageToWorkflowStudioI2I, sendInpaintToWorkflowStudio, sendOutpaintToWorkflowStudio } from './main/14-integrations.js';
import { pixiFxOpen } from './pixifx.js';
import { loadAssets } from './main/02-assets.js';
import { pushHistory, savePanelSvg } from './main/07-pages.js';
import { saveOverlaySvg } from './main/09b-balloon-shapes.js';
import { insertImage, selectPanel } from './main/08-panels-images.js';
import { getOcrTextItems } from './main/29-ocr-tab.js';
import { _scriptMangaGetSelectedDialogue } from './main/21a-script-manga.js';
import { openTextInsertModal } from './text-insert-modal.js';
import { fitFontSize } from './ocr-core.js';
import { resolveBackendError } from './i18n.js';
import { wevisoneStatus, pickWevisoneModel, runWevisone, parseWevisone } from './wevisone-client.js';
const TOOL_DEFS = [
{ id: "select", icon: "▲", label: "Select", ready: true },
{ id: "draw", icon: "✏", label: "Draw", ready: true },
{ id: "text", icon: "T", label: "Text", ready: true },
{ id: "text3d", icon: "◪", label: "3D Text", ready: true },
{ id: "shape", icon: "□", label: "Shape", ready: true },
{ id: "fill", icon: "🪣", label: "Fill", ready: true },
{ id: "mask", icon: "🎭", label: "Mask", ready: true },
{ id: "blur", icon: "≈", label: "Blur", ready: true },
{ id: "filter", icon: "★", label: "Filter", ready: true },
{ id: "bgremove", icon: "⬚", label: "BG Remove", ready: true },
{ id: "upscale", icon: "⤢", label: "Upscale", ready: true },
];
// Draw Tool: スポイト(Eyedropper)アイコン。ボタンとカスタムカーソルの両方で使い回す。
const EYEDROPPER_ICON_PATH = "M20.71 5.63l-2.34-2.34a1 1 0 0 0-1.41 0L14.7 5.55 12 8.24 3 17.24V21h3.76l9-9 2.69-2.69 2.26-2.26a1 1 0 0 0 0-1.41zM6.34 19H5v-1.34l8.06-8.06 1.41 1.41L6.34 19z";
const EYEDROPPER_ICON_SVG =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="${EYEDROPPER_ICON_PATH}"/></svg>`;
function _eyedropperCursorCss() {
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'>`
+ `<path d='${EYEDROPPER_ICON_PATH}' fill='%23ffffff' stroke='%23000000' stroke-width='1'/></svg>`;
return `url("data:image/svg+xml,${svg}") 2 22, crosshair`;
}
function _applyMosaicToRegion(ctx, x, y, w, h, size) {
if (w <= 0 || h <= 0 || size < 1) return;
const imgData = ctx.getImageData(x, y, w, h);
const d = imgData.data;
for (let py = 0; py < h; py += size) {
for (let px = 0; px < w; px += size) {
const i = (py * w + px) * 4;
const r = d[i], g = d[i + 1], b = d[i + 2], a = d[i + 3];
for (let by = py; by < Math.min(py + size, h); by++) {
for (let bx = px; bx < Math.min(px + size, w); bx++) {
const j = (by * w + bx) * 4;
d[j] = r; d[j + 1] = g; d[j + 2] = b; d[j + 3] = a;
}
}
}
}
ctx.putImageData(imgData, x, y);
}
const UNDO_LIMIT = 20;
// Crop(Selectツール): ハンドル一辺のサイズ・当たり判定半径(キャンバス座標系のピクセル数)
const CROP_HANDLE_SIZE = 8;
const CROP_HANDLE_HIT_R = 8;
// 調整レイヤーの定義(type → { label, defaultValue, min, max, step })
// 画像編集タブ(imgedit)の調整レイヤーシステムを移植したもの。
const ADJ_DEFS = {
brightness: { label: t("image.adjBrightness"), defaultValue: 0, min: -100, max: 100, step: 1 },
contrast: { label: t("image.adjContrast"), defaultValue: 0, min: -100, max: 100, step: 1 },
saturation: { label: t("image.adjSaturation"), defaultValue: 0, min: -100, max: 100, step: 1 },
hue: { label: t("image.adjHue"), defaultValue: 0, min: -180, max: 180, step: 1 },
blur: { label: t("image.adjBlur"), defaultValue: 0, min: 0, max: 30, step: 0.5 },
sharpen: { label: t("image.adjSharpen"), defaultValue: 0, min: 0, max: 100, step: 1 },
noise: { label: t("image.adjNoise"), defaultValue: 0, min: 0, max: 100, step: 1 },
sepia: { label: t("image.adjSepia"), defaultValue: 0, min: 0, max: 100, step: 1 },
grayscale: { label: t("image.adjGrayscale"), defaultValue: 0, min: 0, max: 100, step: 1 },
invert: { label: t("image.adjInvert"), defaultValue: 0, min: 0, max: 100, step: 1 },
temperature: { label: t("image.adjTemperature"), defaultValue: 0, min: -100, max: 100, step: 1 },
vignette: { label: t("image.adjVignette"), defaultValue: 0, min: 0, max: 100, step: 1 },
};
function fitToCanvas(imgW, imgH, canvasW, canvasH) {
const scale = Math.min(1, canvasW / imgW, canvasH / imgH);
return { w: Math.round(imgW * scale), h: Math.round(imgH * scale) };
}
// ── HSL変換ヘルパー(hue調整用) ──────────────
function _rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) { h = s = 0; }
else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h, s, l];
}
function _hslToRgb(h, s, l) {
let r, g, b;
if (s === 0) { r = g = b = l; }
else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
// ── 調整レイヤー フィルタ実装(imgeditタブから移植) ──
function _applyPixelAdj(ctx, w, h, type, value) {
const imgData = ctx.getImageData(0, 0, w, h);
const d = imgData.data;
const n = d.length;
for (let i = 0; i < n; i += 4) {
let r = d[i], g = d[i + 1], b = d[i + 2];
if (type === "brightness") {
const v = value * 2.55;
r = Math.min(255, Math.max(0, r + v));
g = Math.min(255, Math.max(0, g + v));
b = Math.min(255, Math.max(0, b + v));
} else if (type === "contrast") {
const f = (259 * (value + 255)) / (255 * (259 - value));
r = Math.min(255, Math.max(0, f * (r - 128) + 128));
g = Math.min(255, Math.max(0, f * (g - 128) + 128));
b = Math.min(255, Math.max(0, f * (b - 128) + 128));
} else if (type === "saturation") {
const gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const s = 1 + value / 100;
r = Math.min(255, Math.max(0, gray + (r - gray) * s));
g = Math.min(255, Math.max(0, gray + (g - gray) * s));
b = Math.min(255, Math.max(0, gray + (b - gray) * s));
} else if (type === "hue") {
const [h2, s2, l2] = _rgbToHsl(r, g, b);
const [nr, ng, nb] = _hslToRgb((h2 + value / 360 + 1) % 1, s2, l2);
r = nr; g = ng; b = nb;
} else if (type === "sepia") {
const t = value / 100;
const nr = Math.min(255, r * (1 - 0.607 * t) + g * 0.769 * t + b * 0.189 * t);
const ng = Math.min(255, r * 0.349 * t + g * (1 - 0.314 * t) + b * 0.168 * t);
const nb = Math.min(255, r * 0.272 * t + g * 0.534 * t + b * (1 - 0.869 * t));
r = nr; g = ng; b = nb;
} else if (type === "grayscale") {
const gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const t = value / 100;
r = r + (gray - r) * t;
g = g + (gray - g) * t;
b = b + (gray - b) * t;
} else if (type === "invert") {
const t = value / 100;
r = r + (255 - r - r) * t;
g = g + (255 - g - g) * t;
b = b + (255 - b - b) * t;
} else if (type === "temperature") {
const v = value * 1.5;
r = Math.min(255, Math.max(0, r + v));
b = Math.min(255, Math.max(0, b - v));
}
d[i] = r; d[i + 1] = g; d[i + 2] = b;
}
ctx.putImageData(imgData, 0, 0);
}
function _applyBlurFilter(ctx, w, h, radius) {
if (radius <= 0) return;
const tmp = document.createElement("canvas");
tmp.width = w; tmp.height = h;
const tc = tmp.getContext("2d");
tc.filter = `blur(${radius}px)`;
tc.drawImage(ctx.canvas, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.drawImage(tmp, 0, 0);
}
function _applySharpenFilter(ctx, w, h, amount) {
if (amount <= 0) return;
const blurCanvas = document.createElement("canvas");
blurCanvas.width = w; blurCanvas.height = h;
const bc = blurCanvas.getContext("2d");
bc.filter = "blur(1px)";
bc.drawImage(ctx.canvas, 0, 0);
const orig = ctx.getImageData(0, 0, w, h);
const blurred = bc.getImageData(0, 0, w, h);
const d = orig.data, bd = blurred.data;
const str = amount / 100 * 2;
for (let i = 0; i < d.length; i += 4) {
d[i] = Math.min(255, Math.max(0, d[i] + (d[i] - bd[i]) * str));
d[i + 1] = Math.min(255, Math.max(0, d[i + 1] + (d[i + 1] - bd[i + 1]) * str));
d[i + 2] = Math.min(255, Math.max(0, d[i + 2] + (d[i + 2] - bd[i + 2]) * str));
}
ctx.putImageData(orig, 0, 0);
}
function _applyNoiseFilter(ctx, w, h, amount) {
if (amount <= 0) return;
const imgData = ctx.getImageData(0, 0, w, h);
const d = imgData.data;
const str = amount * 1.5;
for (let i = 0; i < d.length; i += 4) {
const n = (Math.random() - 0.5) * str;
d[i] = Math.min(255, Math.max(0, d[i] + n));
d[i + 1] = Math.min(255, Math.max(0, d[i + 1] + n));
d[i + 2] = Math.min(255, Math.max(0, d[i + 2] + n));
}
ctx.putImageData(imgData, 0, 0);
}
function _applyVignette(ctx, w, h, amount) {
if (amount <= 0) return;
const gradient = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.3, w / 2, h / 2, Math.max(w, h) * 0.8);
gradient.addColorStop(0, "rgba(0,0,0,0)");
gradient.addColorStop(1, `rgba(0,0,0,${amount / 100 * 0.85})`);
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
ctx.restore();
}
// ── アップスケール(imgeditタブの実行パイプラインを移植) ──
function _procApplyDenoise(imageData, w, h) {
const src = imageData.data;
const out = new Uint8ClampedArray(src.length);
const radius = 2;
const sigma = 30;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let rSum = 0, gSum = 0, bSum = 0, wSum = 0;
const ci = (y * w + x) * 4;
const cr = src[ci], cg = src[ci + 1], cb = src[ci + 2];
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
const ni = (ny * w + nx) * 4;
const nr = src[ni], ng = src[ni + 1], nb = src[ni + 2];
const spatialW = Math.exp(-(dx * dx + dy * dy) / (2 * radius * radius));
const colorD = (nr - cr) ** 2 + (ng - cg) ** 2 + (nb - cb) ** 2;
const colorW = Math.exp(-colorD / (2 * sigma * sigma));
const w2 = spatialW * colorW;
rSum += nr * w2; gSum += ng * w2; bSum += nb * w2; wSum += w2;
}
}
const oi = (y * w + x) * 4;
out[oi] = rSum / wSum;
out[oi + 1] = gSum / wSum;
out[oi + 2] = bSum / wSum;
out[oi + 3] = src[oi + 3];
}
}
return new ImageData(out, w, h);
}
function _procApplySharpen(imageData, w, h, amount) {
const src = imageData.data;
const out = new Uint8ClampedArray(src.length);
const kernel = [-1, -1, -1, -1, 9, -1, -1, -1, -1];
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let r = 0, g = 0, b = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const nx = Math.max(0, Math.min(w - 1, x + kx));
const ny = Math.max(0, Math.min(h - 1, y + ky));
const ni = (ny * w + nx) * 4;
const k = kernel[(ky + 1) * 3 + (kx + 1)];
r += src[ni] * k;
g += src[ni + 1] * k;
b += src[ni + 2] * k;
}
}
const oi = (y * w + x) * 4;
const sr = src[oi], sg = src[oi + 1], sb = src[oi + 2];
out[oi] = Math.max(0, Math.min(255, sr + (r - sr) * amount));
out[oi + 1] = Math.max(0, Math.min(255, sg + (g - sg) * amount));
out[oi + 2] = Math.max(0, Math.min(255, sb + (b - sb) * amount));
out[oi + 3] = src[oi + 3];
}
}
return new ImageData(out, w, h);
}
function _procUpscale(dataUrl, scale, denoise, sharpenRatio) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const w = img.width;
const h = img.height;
const srcCanvas = document.createElement("canvas");
srcCanvas.width = w;
srcCanvas.height = h;
const srcCtx = srcCanvas.getContext("2d");
srcCtx.drawImage(img, 0, 0);
let srcData = srcCtx.getImageData(0, 0, w, h);
if (denoise) {
srcData = _procApplyDenoise(srcData, w, h);
srcCtx.putImageData(srcData, 0, 0);
}
const dstW = Math.round(w * scale);
const dstH = Math.round(h * scale);
const dstCanvas = document.createElement("canvas");
dstCanvas.width = dstW;
dstCanvas.height = dstH;
const dstCtx = dstCanvas.getContext("2d");
dstCtx.imageSmoothingEnabled = true;
dstCtx.imageSmoothingQuality = "high";
dstCtx.drawImage(srcCanvas, 0, 0, dstW, dstH);
if (sharpenRatio > 0) {
const sharpData = _procApplySharpen(dstCtx.getImageData(0, 0, dstW, dstH), dstW, dstH, sharpenRatio);
dstCtx.putImageData(sharpData, 0, 0);
}
resolve(dstCanvas.toDataURL("image/png"));
};
img.onerror = reject;
img.src = dataUrl;
});
}
class ImageTab {
constructor() {
this._layerMgr = null;
this._activeTool = "select";
this._drawTool = new DrawTool(null);
this._eyedropperActive = false; // Drawツールのスポイト(色抽出)モード
this._drawPalette = new DrawPalette({
onColorChange: hex => this._setDrawColor(hex),
toast: (msg, type) => this._toast(msg, type),
});
this._textTool = new TextTool(null);
// Textツールのフォント選択(レイアウトタブと同様のGoogle/システム/カテゴリ切替)
this._textFontSource = "google"; // 'google' | 'system' | 'favorites'
this._textFontFavCat = ""; // カテゴリ絞り込み(空=すべて)
this._systemFontsCache = null; // queryLocalFonts() の結果キャッシュ
this._selectTool = new SelectTool();
this._selectedLayerIds = new Set(); // レイヤーパネルでShift+クリックにより複数選択されたレイヤーID群
this._zoom = 1.0;
this._panOffset = { x: 0, y: 0 };
this._canvasW = 512;
this._canvasH = 512;
this._selectMargin = 200; // ie-canvas-select-overlayがキャンバス実サイズより拡張されている量(px)。_resizeCanvasElementsで再計算
this._baseName = "image";
this._undoStack = [];
this._redoStack = [];
this._panning = false;
this._panStart = null;
this._spaceDown = false;
this._compositeMode = false;
this._editingTextLayer = null;
this._editing3DTextLayer = null;
this._initialized = false;
this._shapeTool = new ShapeTool();
this._shapeSameLayer = true; // true: 描画済みのシェイプレイヤーに追記し続ける(デフォルト) / false: 描画のたびに新規レイヤー
this._shapeLayerId = null; // Same Layerモードで使い続けるレイヤーのID
this._mychainAssets = []; // My Curve用: assets/mychain フォルダの画像一覧
// Mask ツール
this._maskTool = null;
this._maskSubtool = "paint";
this._maskInverted = false;
this._maskOverlayColor = "#ff0000";
this._maskBlur = 0;
this._toastTimer = null;
// Mask Editor One 追加ツール
this._maskColorTool = null;
this._maskAlphaTool = null;
this._maskTextTool = null;
this._maskVectorTool = null;
this._maskShapeTool = null;
// Blur ツール
this._blurRectMode = null; // null | 'blur' | 'mosaic'
this._blurDragging = false;
this._blurDragStart = null;
this._blurDragCur = null;
// Crop(Selectツールのサブ機能): キャンバス全体をクロップ範囲にリサイズする
this._cropMode = false; // クロップ範囲編集中か
this._cropRect = null; // { x, y, w, h }(キャンバス/ページ座標系、ピクセル単位)
this._cropDragMode = null; // null | 'move' | 'n'|'s'|'e'|'w'|'ne'|'nw'|'se'|'sw'
this._cropDragStart = null; // { x, y, rect: {x,y,w,h} } ドラッグ開始時点のスナップショット
// Extend Canvas(Selectツールのサブ機能): キャンバスの四辺に余白を追加してサイズを拡張する
this._extendMode = false; // 拡張範囲編集中か
this._extendPad = { top: 0, right: 0, bottom: 0, left: 0 }; // 各辺に追加するピクセル数
this._extendLinked = false; // true: 四辺の値を連動させる(均等に追加)
this._extendColor = "transparent"; // 'transparent' | 'white' | 'black'
// G'MIC ツール状態
this._gmicState = {
lastResultJobId: null,
processing: false,
aborted: false,
};
// BiRefNet 利用可否(comic-creator側に対応ルートが無い場合は常にfalse)
this._birefnetAvailable = false;
// Mask Editor One SAM3 状態(comic-creator側に対応ルートが無い場合は常にfalse)
this._sam3Available = false;
this._sam3Results = []; // [{mask_b64, score, area}, ...]
this._sam3Prompt = "";
this._sam3MaxMasks = 9;
this._sam3Loading = false;
this._sam3Mode = "add"; // "add" | "erase"
this._sam3Selected = new Set(); // 選択中の結果インデックス
// テキスト指定の切り抜きエンジン: "sam3"(Mask Editor One)| "wevisone"(WeVisOne-VL / ComfyUI、ソフトな境界)
this._wevisAvailable = false;
this._wevisModel = "";
try { this._segEngine = localStorage.getItem("ccc_image_seg_engine") || "sam3"; } catch { this._segEngine = "sam3"; }
// ABR brush(comic-creator側に対応ルートが無い場合は常にfalse)
this._abrAvailable = false;
this._abrBrushTree = [];
// Inpaint(Mask サブツール。Workflow Studio 未導入なら _wfmAvailable が常にfalse)
this._wfmAvailable = false;
this._inpaintPositive = "";
this._inpaintNegative = "";
this._inpaintGrowMaskBy = 6;
this._inpaintDenoise = 1.0;
this._inpaintRunning = false;
// Outpaint(Mask サブツール。Inpaintと対称の構成。サイズ拡張+マスク作成をRun 1回で行う)
this._outpaintPositive = "";
this._outpaintNegative = "";
this._outpaintGrowMaskBy = 6;
this._outpaintDenoise = 1.0;
this._outpaintRunning = false;
this._outpaintPad = { top: 0, right: 0, bottom: 0, left: 0 };
this._outpaintLinked = false;
this._outpaintFillMode = "extend"; // 'extend' | 'transparent' | 'white' | 'black'
// Select→I2I(Select ツール専用の常時プロパティパネル。Inpaintと対称の構成)
this._selectI2IMode = "all"; // "all"(composite全体) | "layer"(選択中レイヤー単体)
this._selectI2IPositive = "";
this._selectI2INegative = "";
this._selectI2IDenoise = 1.0;
this._selectI2IRunning = false;
// 全レイヤーに掛かる全体不透明度(imgeditタブの調整レイヤーパネルから移植)
this._globalOpacity = 1.0;
// レイアウトタブから開いた場合の書き戻し先SVG <image> 要素(Upload/Newで開始した場合はnull)
this._sourceImageEl = null;
}
// ── 初期化 ────────────────────────────────────
init() {
if (this._initialized) return;
this._initialized = true;
this._setupToolButtons();
this._setupActionBar();
this._setupCanvasEvents();
this._setupLayerPanel();
this._setupKeyboard();
this._initBrushCursor();
this._checkBiRefNetAvailability();
this._checkSam3Availability();
this._checkAbrAvailability();
this._checkWfmAvailability();
this._loadMychainAssets();
}
/** My Curve用: assets/mychain フォルダの画像一覧を取得 */
async _loadMychainAssets() {
try {
const resp = await fetch("/ccc_assets/assets.json");
if (!resp.ok) return;
const data = await resp.json();
const folder = (data.folders || []).find(f => f.name === "mychain");
this._mychainAssets = folder?.assets ?? [];
} catch {
this._mychainAssets = [];
}
if (this._activeTool === "shape") this._renderToolOptions("shape");
}
// ── Textツール: フォントソース(Google/システム/カテゴリ) ──────────
/** フォント管理タブのカテゴリ一覧(localStorageを直接参照、main.js側の状態には依存しない) */
_getFavoriteCategories() {
let favs = {};
try { favs = JSON.parse(localStorage.getItem("fontmgr_favorites") || "{}"); } catch { favs = {}; }
return Object.keys(favs);
}
/** カテゴリ名の表示ラベル(予約カテゴリ「お気に入り」はmain.js側のi18nラベルへ変換、それ以外はそのまま) */
_fontCatLabel(cat) {
return _fontMgrCatLabel(cat);
}
/** カテゴリ(空文字なら全カテゴリ)に属するフォント一覧 */
_getFavoriteFontFamilies(cat) {
let favs = {};
try { favs = JSON.parse(localStorage.getItem("fontmgr_favorites") || "{}"); } catch { favs = {}; }
if (!cat) {
const all = new Set();
Object.values(favs).forEach(arr => (arr || []).forEach(f => all.add(f)));
return [...all].sort((a, b) => a.localeCompare(b));
}
return [...(favs[cat] || [])].sort((a, b) => a.localeCompare(b));
}
/** システムフォント一覧(Local Font Access API、結果はキャッシュ) */
async _getSystemFontFamiliesCached() {
if (this._systemFontsCache) return this._systemFontsCache;
if (!window.queryLocalFonts) { this._systemFontsCache = []; return []; }
try {
const fonts = await window.queryLocalFonts();
this._systemFontsCache = [...new Set(fonts.map(f => f.family))].sort((a, b) => a.localeCompare(b));
} catch {
this._systemFontsCache = [];
}
return this._systemFontsCache;
}
/** 現在の _textFontSource に応じたフォント一覧を返す */
async _getTextFontFamilies() {
if (this._textFontSource === "google") {
// main.js(レイアウトタブ)の _fontMgrGoogleList() を再利用
return _fontMgrGoogleList();
}
if (this._textFontSource === "favorites") {
return this._getFavoriteFontFamilies(this._textFontFavCat);
}
return await this._getSystemFontFamiliesCached();
}
/** #ie-text-font の options を現在のソース/カテゴリで再構築する */
async _populateTextFontSelect() {
const families = await this._getTextFontFamilies();
const sel = document.getElementById("ie-text-font");
if (!sel) return; // 取得中にツールが切り替わっていた場合
const selLayer = this.hasSelectedTextLayer() ? this._selectTool.getSelectedLayer() : null;
const current = selLayer ? selLayer.textProps.fontFamily : this._textTool.fontFamily;
if (families.length === 0) {
sel.innerHTML = `<option value="${current}">${t("image.fontListEmpty", current)}</option>`;
return;
}
const list = families.includes(current) ? families : [current, ...families];
sel.innerHTML = list.map(f => `<option value="${f}" ${f === current ? "selected" : ""}>${f}</option>`).join("");
}
async _checkBiRefNetAvailability() {
try {
const resp = await fetch("/mask_editor/birefnet/status");
if (!resp.ok) return;
const json = await resp.json();
this._birefnetAvailable = json.loaded === true || json.model_found === true;
} catch {
this._birefnetAvailable = false;
}
}
async _checkSam3Availability() {
try {
const resp = await fetch("/mask_editor/sam3/status");
if (!resp.ok) return;
const json = await resp.json();
this._sam3Available = json.loaded === true || json.ckpt_found === true;
} catch {
this._sam3Available = false;
} finally {
await this._checkWevisAvailability();
}
}
async _checkWevisAvailability() {
const st = await wevisoneStatus(location.origin);
this._wevisAvailable = st.available;
this._wevisModel = pickWevisoneModel(st.models);
// 片方しか無いときはそちらを使う
if (this._segEngine === "wevisone" && !this._wevisAvailable) this._segEngine = "sam3";
if (this._segEngine === "sam3" && !this._sam3Available && this._wevisAvailable) this._segEngine = "wevisone";
}
get _segAvailable() { return this._sam3Available || this._wevisAvailable; }
async _checkAbrAvailability() {
try {
const resp = await fetch("/mask_editor/brushes/list");
if (!resp.ok) return;
const json = await resp.json();
this._abrBrushTree = json.tree || [];
this._abrAvailable = this._abrBrushTree.length > 0;
} catch {
this._abrAvailable = false;
}
}
// Workflow Studio が /wfm にマウントされているか確認し、Maskサブツールバーの
// Inpaint ボタンの表示可否を切り替える(未導入環境ではボタン自体を出さない)
async _checkWfmAvailability() {
try {
const resp = await fetch("/wfm");
this._wfmAvailable = resp.ok;
} catch {
this._wfmAvailable = false;
}
if (this._activeTool === "mask") this._renderToolOptions("mask");
}
// ── トースト通知 ──────────────────────────────
_toast(message, type = "info", duration = 2500) {
const el = document.getElementById("ie-toast");
if (!el) return;
clearTimeout(this._toastTimer);
el.textContent = message;
el.className = "ie-toast" + (type === "success" || type === "error" ? ` ${type}` : "");
el.style.display = "block";
el.style.opacity = "1";
this._toastTimer = setTimeout(() => {
el.style.opacity = "0";
setTimeout(() => { el.style.display = "none"; }, 300);
}, duration);
}
// ── ブラシカーソル ────────────────────────────
_initBrushCursor() {
const el = document.createElement("div");
el.id = "ie-brush-cursor";
Object.assign(el.style, {
position: "fixed",
pointerEvents: "none",
border: "1.5px solid rgba(255,255,255,0.85)",
boxShadow: "0 0 0 1px rgba(0,0,0,0.6)",
borderRadius: "50%",
display: "none",
transform: "translate(-50%,-50%)",
zIndex: "99999",
});
document.body.appendChild(el);
this._brushCursorEl = el;
}
_updateBrushCursor(e) {
const el = this._brushCursorEl;
if (!el) return;
this._lastBrushCursorEvent = e; // キー操作でサイズを変えたときに再描画するため保持
const tool = this._activeTool;
const size = tool === "draw" ? this._drawTool?.brushSize
: (tool === "mask" && this._maskSubtool === "paint") ? this._maskTool?.brushSize : null;
if (size == null) { el.style.display = "none"; return; }
const refCanvas = document.getElementById("ie-canvas-draw");
if (!refCanvas) { el.style.display = "none"; return; }
const rect = refCanvas.getBoundingClientRect();
// Image タブ非表示時(width=0)はカーソルを消す
if (rect.width === 0 || rect.height === 0) { el.style.display = "none"; return; }
// キャンバス外は通常カーソルを表示
if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) {
el.style.display = "none";
return;
}
const scale = rect.width / refCanvas.width;
const px = Math.max(2, size * scale);
el.style.width = px + "px";
el.style.height = px + "px";
el.style.left = e.clientX + "px";
el.style.top = e.clientY + "px";
el.style.display = "block";
}
_hideBrushCursor() {
if (this._brushCursorEl) this._brushCursorEl.style.display = "none";
}
// ── Draw: スポイト(Eyedropper) ─────────────────
_setEyedropperActive(active) {
this._eyedropperActive = active;
const drawCanvas = document.getElementById("ie-canvas-draw");
const overlay = document.getElementById("ie-canvas-overlay");
const cursor = active ? _eyedropperCursorCss() : "";
if (drawCanvas) drawCanvas.style.cursor = cursor;
if (overlay) overlay.style.cursor = cursor;
if (active) this._hideBrushCursor();
document.getElementById("ie-draw-eyedropper")?.classList.toggle("ie-opt-active", active);
}
_pickColorAt(x, y) {
const canvas = document.getElementById("ie-canvas-draw");
const px = Math.floor(x), py = Math.floor(y);
if (!canvas || px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) {
this._setEyedropperActive(false);
return;
}
try {
const [r, g, b] = canvas.getContext("2d").getImageData(px, py, 1, 1).data;
const hex = "#" + [r, g, b].map(v => v.toString(16).padStart(2, "0")).join("");
this._setDrawColor(hex);
this._drawPalette.syncColor(hex);
} catch {
this._toast(window.t("image.msg.pixelColorFail"), "error");
}
this._setEyedropperActive(false);
if (this._activeTool === "draw") this._renderDrawProps();
}
_setDrawMode(mode) {
this._drawTool.mode = mode;
this._drawTool._stamp = null;
this._renderDrawProps();
}
_setDrawBrushSize(size) {
this._drawTool.brushSize = size;
this._drawTool._stamp = null;
const slider = document.getElementById("ie-draw-size");
if (slider) slider.value = size;
const label = document.getElementById("ie-draw-size-lbl");
if (label) label.textContent = size + "px";
if (this._lastBrushCursorEvent && !this._eyedropperActive) {
this._updateBrushCursor(this._lastBrushCursorEvent);
}
}
/** [ / ] キー: サイズが大きいほど刻みを粗くする */
_stepDrawBrushSize(direction) {
const size = this._drawTool.brushSize;
const base = direction < 0 ? size - 1 : size;
const step = base < 10 ? 1 : base < 50 ? 5 : 10;
this._setDrawBrushSize(Math.min(200, Math.max(1, size + direction * step)));
}
/** Drawツール中のBキー: ブラシ選択モーダルを開閉する */
_toggleDrawBrushPicker() {
if (this._abrAvailable) this._openAbrBrushPickerForDraw();
else this._toast(window.t("image.msg.noAbrBrushes"), "info");
}
_setDrawColor(hex) {
this._drawTool.color = hex;
this._drawTool._stamp = null;
this._drawTool._imgStamp = null;
const input = document.getElementById("ie-draw-color");
if (input) input.value = hex;
}
// ── ツールボタン ──────────────────────────────
_setupToolButtons() {
document.querySelectorAll(".ie-tool-btn[data-tool]").forEach(btn => {
btn.addEventListener("click", () => {
const def = TOOL_DEFS.find(d => d.id === btn.dataset.tool);
if (!def?.ready) { this._toast(window.t("image.msg.toolComingSoon", def?.label ?? btn.dataset.tool), "info"); return; }
this._setActiveTool(btn.dataset.tool);
});
});
}
_setActiveTool(toolId) {
this._hideBrushCursor();
if (this._eyedropperActive) this._setEyedropperActive(false);
if (this._activeTool === "draw") this._drawTool?.deactivate();
if (this._activeTool === "text") this._textTool?.deactivate();
if (this._activeTool === "text3d") this._text3dTool?.deactivate();
if (this._activeTool === "select") {
this._selectTool?.deactivate();
if (this._cropMode) this._exitCropMode();
if (this._extendMode) this._exitExtendMode();
}
if (this._activeTool === "shape") this._shapeTool?.deactivate();
if (this._activeTool === "fill") {
this._fillTool?.deactivate();
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) overlay.style.cursor = "";
}
if (this._activeTool === "mask") this._deactivateMaskSubtool();
if (this._activeTool === "filter") this._gmicAbort();
if (this._activeTool === "blur") {
this._blurRectMode = null;
this._blurDragging = false;
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) {
overlay.style.cursor = "";
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
}
// Draw/Mask/Fill/Select以外に切り替えたらプロパティペインを非表示
// (SelectはInpaintと同様、常時プロパティパネル(I2I)を表示する)
if (toolId !== "mask" && toolId !== "draw" && toolId !== "fill" && toolId !== "select") {
const pane = document.getElementById("ie-props-pane");
if (pane) pane.style.display = "none";
}
this._activeTool = toolId;
document.querySelectorAll(".ie-tool-btn").forEach(btn =>
btn.classList.toggle("active", btn.dataset.tool === toolId));
this._renderToolOptions(toolId);
this._activateCurrentTool();
}
_activateCurrentTool() {
const drawCanvas = document.getElementById("ie-canvas-draw");
const overlayCanvas = document.getElementById("ie-canvas-overlay");
if (!drawCanvas || !this._layerMgr) return;
if (this._activeTool === "draw" && this._drawTool) {
const activeLayer = this._layerMgr?.activeLayer;
if (activeLayer) this._drawTool.setCanvas(activeLayer.canvas);
this._drawTool.activate();
} else if (this._activeTool === "text" && this._textTool) {
this._textTool.setCanvas(drawCanvas);
this._textTool.activate();
// アクティブレイヤーがテキストならツール切替時点でも選択枠を出しておく
// (どのレイヤーがスタイルモーダル等の対象になるか一目で分かるように)
const activeLayer = this._layerMgr?.activeLayer;
if (activeLayer && activeLayer.type === "text") {
this._selectTool?.setLayer(activeLayer);
} else {
this._selectTool?.clearSelection();
}
} else if (this._activeTool === "text3d" && this._text3dTool) {
this._text3dTool.setCanvas(drawCanvas);
this._text3dTool.activate();
} else if (this._activeTool === "select" && this._selectTool) {
const selectOverlayCanvas = document.getElementById("ie-canvas-select-overlay");
this._selectTool.setCanvas(selectOverlayCanvas, this._selectMargin);
this._selectTool.activate();
} else if (this._activeTool === "shape" && this._shapeTool) {
this._shapeTool.setCanvas(overlayCanvas);
this._shapeTool.activate();
if (overlayCanvas) overlayCanvas.style.cursor = "crosshair";
} else if (this._activeTool === "fill" && this._fillTool) {
const activeLayer = this._layerMgr?.activeLayer;
if (activeLayer) this._fillTool.setCanvas(activeLayer.canvas);
this._fillTool.activate();
if (overlayCanvas) overlayCanvas.style.cursor = "crosshair";
} else if (this._activeTool === "mask") {
this._initMaskEditorOneTools();
this._activateMaskSubtool();
} else if (this._activeTool === "blur") {
if (overlayCanvas) overlayCanvas.style.cursor = this._blurRectMode ? "crosshair" : "default";
}
}
_renderToolOptions(toolId) {
const el = document.getElementById("ie-tool-options");
if (!el) return;
el.innerHTML = "";
if (toolId === "select" && this._selectTool) {
el.innerHTML = `
<div class="ie-opt-group">
<button class="it-btn it-btn-sm" id="ie-flip-h-btn" title="Flip Horizontal">↔ Flip H</button>
<button class="it-btn it-btn-sm" id="ie-flip-v-btn" title="Flip Vertical">↕ Flip V</button>
</div>
<div class="ie-opt-group" style="margin-left:8px;">
<label style="font-size:11px;color:var(--it-text-secondary);">Rotate</label>
<input type="number" id="ie-rotate-input" value="0" step="1" min="-360" max="360"
style="width:56px;" class="ie-opt-input" title="Rotation angle (degrees)">°
<button class="it-btn it-btn-sm" id="ie-rotate-apply-btn">Apply</button>
<button class="it-btn it-btn-sm" id="ie-rotate-reset-btn">Reset</button>
</div>
<div class="ie-opt-group" style="margin-left:8px;">
<button class="it-btn it-btn-sm" id="ie-select-style-btn" title="${t("image.selectStyleBtnTitle")}">${t("layout.styleBtn")}</button>
${this.hasSelected3DTextLayer() ? `<button class="it-btn it-btn-sm" id="ie-select-text3d-edit-btn" title="${t("image.text3dEditLayerBtnTitle")}">${t("image.text3dEditLayerBtn")}</button>` : ""}
</div>
<div style="width:1px;height:22px;background:var(--it-border);margin:0 6px;flex-shrink:0;"></div>
<div class="ie-opt-group">
<button class="it-btn it-btn-sm${this._cropMode ? " ie-opt-active" : ""}" id="ie-crop-toggle-btn" title="${t("image.cropBtnTitle")}">${t("image.cropBtn")}</button>
<button class="it-btn it-btn-sm${this._extendMode ? " ie-opt-active" : ""}" id="ie-extend-toggle-btn" title="${t("image.extendBtnTitle")}">${t("image.extendBtn")}</button>
</div>
`;
document.getElementById("ie-crop-toggle-btn")?.addEventListener("click", () => {
if (this._cropMode) { this._exitCropMode(); this._renderToolOptions("select"); }
else this._enterCropMode();
});
document.getElementById("ie-extend-toggle-btn")?.addEventListener("click", () => {
if (this._extendMode) { this._exitExtendMode(); this._renderToolOptions("select"); }
else this._enterExtendMode();
});
document.getElementById("ie-select-style-btn")?.addEventListener("click", () => {
if (!this.hasSelectedTextLayer()) {
this._toast(t("image.noTextLayerSelected"), "info");
return;
}
if (typeof window.openTextStyleModal !== "function") return;
const info = this.getSelectedTextStyleInfo();
window.openTextStyleModal({
fontFamily: info.fontFamily,
previewSize: info.fontSize,
initialStyle: info.style,
onApply: (style) => { this.applyFontStyleToSelection(style); },
});
});
document.getElementById("ie-select-text3d-edit-btn")?.addEventListener("click", () => {
const layer = this._selectTool.getSelectedLayer();
if (!layer || !layer.text3dProps) return;
this._editing3DTextLayer = layer;
this._setActiveTool("text3d");
const cx = Math.round(layer.x + layer.displayW / 2);
const cy = Math.round(layer.y + layer.displayH / 2);
this._text3dTool.open(cx, cy, layer.text3dProps);
});
document.getElementById("ie-flip-h-btn")?.addEventListener("click", () => {
this._selectTool.flipH();
this._updateCompositeView();
this._refreshLayerList();
});
document.getElementById("ie-flip-v-btn")?.addEventListener("click", () => {
this._selectTool.flipV();
this._updateCompositeView();
this._refreshLayerList();
});
document.getElementById("ie-rotate-apply-btn")?.addEventListener("click", () => {
const layer = this._selectTool.getSelectedLayer();
if (!layer) return;
const deg = parseFloat(document.getElementById("ie-rotate-input").value) || 0;
layer.rotation = deg;
this._selectTool.setLayer(layer);
this._updateCompositeView();
this._refreshLayerList();
});
document.getElementById("ie-rotate-reset-btn")?.addEventListener("click", () => {
const layer = this._selectTool.getSelectedLayer();
if (!layer) return;
layer.rotation = 0;
layer.flipX = false;
layer.flipY = false;
document.getElementById("ie-rotate-input").value = 0;
this._selectTool.setLayer(layer);
this._updateCompositeView();
this._refreshLayerList();
});
if (this._cropMode) this._renderCropProps();
else if (this._extendMode) this._renderExtendProps();
else this._renderSelectI2IProps();
} else if (toolId === "draw" && this._drawTool) {
el.innerHTML = "";
// 新規ドキュメントでDrawToolが作り直されても、選択中の色を引き継ぐ
this._setDrawColor(this._drawPalette.currentColor);
this._drawPalette.render(el);
this._renderDrawProps();
} else if (toolId === "text" && this._textTool) {
// 選択中のテキストレイヤーがあれば、オプションバーはそのレイヤーの現在値を表示する
// (無ければ従来通りツールのデフォルト値=次に新規作成するテキストの初期値)
const selLayerForOpts = this.hasSelectedTextLayer() ? this._selectTool.getSelectedLayer() : null;
const p = selLayerForOpts ? selLayerForOpts.textProps : this._textTool;
el.innerHTML = `
<div class="ie-opt-group">
<label>Color</label>
<input type="color" id="ie-text-color" value="${p.color}"
style="width:30px;height:24px;padding:0;border:1px solid var(--it-border);cursor:pointer;border-radius:3px;">
</div>
<div class="ie-opt-group">
<label>Size</label>
<input type="number" id="ie-text-size" value="${p.fontSize}"
min="6" max="500" style="width:56px;" class="ie-opt-input">
</div>
<div class="ie-opt-group" style="gap:2px;">
<label>Font</label>
<button class="it-btn it-btn-sm ${this._textFontSource==="google" ?"ie-opt-active":""}" id="ie-text-fsrc-google">Google</button>
<button class="it-btn it-btn-sm ${this._textFontSource==="system" ?"ie-opt-active":""}" id="ie-text-fsrc-system">System</button>
<button class="it-btn it-btn-sm ${this._textFontSource==="favorites" ?"ie-opt-active":""}" id="ie-text-fsrc-favorites">Cat</button>
<select id="ie-text-font-cat" class="ie-opt-select" style="width:80px;display:${this._textFontSource==="favorites"?"":"none"};">
<option value="">${t("layout.fontCatAll")}</option>
${this._getFavoriteCategories().map(c => `<option value="${c}" ${this._textFontFavCat===c?"selected":""}>${this._fontCatLabel(c)}</option>`).join("")}
</select>
<select id="ie-text-font" class="ie-opt-select" style="min-width:120px;">
<option value="${p.fontFamily}">${p.fontFamily}</option>
</select>
</div>
<div class="ie-opt-group" style="gap:4px;">
<input type="checkbox" id="ie-text-vertical" ${p.vertical ? "checked" : ""} style="cursor:pointer;">
<label for="ie-text-vertical" style="cursor:pointer;" title="${t("font.dirVertical")}">${t("layout.verticalLabel")}</label>
</div>
<div class="ie-opt-group">
<button class="it-btn it-btn-sm" id="ie-text-style-btn" title="${t("image.textStyleBtnTitle")}">${t("layout.styleBtn")}</button>
<button class="it-btn it-btn-sm" id="ie-text-insert-btn" title="${t("layout.textInsertTitle")}">Insert</button>
</div>
`;
document.getElementById("ie-text-insert-btn")?.addEventListener("click", () => this._openTextInsertModal());
document.getElementById("ie-text-color")?.addEventListener("input", e => {
this._textTool.color = e.target.value;
this._applyTextToolChangeToSelection({ color: e.target.value });
});
document.getElementById("ie-text-size")?.addEventListener("change", e => {
this._textTool.fontSize = parseInt(e.target.value) || 64;
this._applyTextToolChangeToSelection({ fontSize: this._textTool.fontSize });
});
["google", "system", "favorites"].forEach(src => {
document.getElementById(`ie-text-fsrc-${src}`)?.addEventListener("click", () => {
if (this._textFontSource === src) return;
this._textFontSource = src;
this._renderToolOptions("text");
});
});
document.getElementById("ie-text-font-cat")?.addEventListener("change", e => {
this._textFontFavCat = e.target.value;
this._populateTextFontSelect();
});
document.getElementById("ie-text-font")?.addEventListener("change", e => {
this._textTool.fontFamily = e.target.value;
this._applyTextToolChangeToSelection({ fontFamily: e.target.value });
});
this._populateTextFontSelect();
// 太字/斜体/文字寄せはオプションバーから廃止し、スタイルモーダル(boldEnabled/italicEnabled/align)で設定する
document.getElementById("ie-text-vertical")?.addEventListener("change", e => {
this._textTool.vertical = e.target.checked;
this._applyTextToolChangeToSelection({ vertical: e.target.checked });
});
document.getElementById("ie-text-style-btn")?.addEventListener("click", () => {
if (typeof window.openTextStyleModal !== "function") return;
const info = this.getSelectedTextStyleInfo();
window.openTextStyleModal({
fontFamily: info.fontFamily,
previewSize: info.fontSize,
initialStyle: info.style,
onApply: (style) => {
if (this.hasSelectedTextLayer()) this.applyFontStyleToSelection(style);
else this.insertFontStylePlaceholder(style);
},
});
});
} else if (toolId === "text3d" && this._text3dTool) {
const t3 = this._text3dTool;
const isSvg = t3.renderMode === "svg";
el.innerHTML = `
<div class="ie-opt-group" style="gap:2px;">
<button class="it-btn it-btn-sm ${!isSvg ? "ie-opt-active" : ""}" id="ie-text3d-mode-text">${t("layout.text3dModeText")}</button>
<button class="it-btn it-btn-sm ${isSvg ? "ie-opt-active" : ""}" id="ie-text3d-mode-svg">${t("layout.text3dModeSvg")}</button>
</div>
${!isSvg ? `` : `
<div class="ie-opt-group" style="gap:4px;">
<input type="file" id="ie-text3d-svg-file" accept=".svg,image/svg+xml" style="font-size:11px;max-width:120px;">
<span id="ie-text3d-svg-filename" style="font-size:11px;color:var(--text-secondary);">${t3.svgFileName || t("layout.text3dSvgFileNoneLabel")}</span>
</div>
<div class="ie-opt-group">
<label style="font-size:11px;">${t("layout.text3dSvgSizeLabel")}</label>
<input type="range" id="ie-text3d-svg-size" min="0.3" max="4.0" step="0.1" value="${t3.svgSize}" style="width:60px;">
</div>
`}
<div class="ie-opt-group">
<label style="font-size:11px;">${t("layout.text3dDepthLabel")}</label>
<input type="range" id="ie-text3d-depth" min="0.02" max="1.0" step="0.01" value="${t3.depth}" style="width:60px;">
</div>
<div class="ie-opt-group" style="gap:4px;">
<input type="checkbox" id="ie-text3d-bevel" ${t3.bevelEnabled ? "checked" : ""}>
<label for="ie-text3d-bevel">${t("layout.text3dBevelLabel")}</label>
</div>
<div class="ie-opt-group">
<button class="it-btn it-btn-sm" id="ie-text3d-reset-camera-btn" title="${t("layout.text3dResetCameraTitle")}">RC</button>
<button class="it-btn it-btn-sm" id="ie-text3d-settings-btn" title="${t("layout.text3dSettingsBtnTitle")}">${t("layout.text3dSettingsBtn")}</button>
</div>
<div class="ie-opt-group">
<button class="it-btn it-btn-sm it-btn-primary" id="ie-text3d-commit-btn">${t("image.text3dCommit")}</button>
<button class="it-btn it-btn-sm" id="ie-text3d-cancel-btn">${t("image.text3dCancel")}</button>
</div>
`;
document.getElementById("ie-text3d-mode-text")?.addEventListener("click", () => {
if (t3.renderMode === "text") return;
t3.setRenderMode("text");
this._renderToolOptions("text3d");
});
document.getElementById("ie-text3d-mode-svg")?.addEventListener("click", () => {
if (t3.renderMode === "svg") return;
t3.setRenderMode("svg");
this._renderToolOptions("text3d");
});
if (isSvg) {
document.getElementById("ie-text3d-svg-file")?.addEventListener("change", async e => {
const file = e.target.files?.[0];
if (!file) return;
let warning = "";
try { warning = await t3.setSvgFile(file); } catch (err) { console.error("[text3d] SVG読込失敗:", err); }
this._renderToolOptions("text3d");
if (warning) this._toast(warning, "info");
});
document.getElementById("ie-text3d-svg-size")?.addEventListener("input", e => t3.setSvgSize(parseFloat(e.target.value)));
}
document.getElementById("ie-text3d-depth")?.addEventListener("input", e => t3.setDepth(parseFloat(e.target.value)));
document.getElementById("ie-text3d-bevel")?.addEventListener("change", e => t3.setBevel(e.target.checked));
document.getElementById("ie-text3d-reset-camera-btn")?.addEventListener("click", () => t3.resetCamera());
document.getElementById("ie-text3d-settings-btn")?.addEventListener("click", () => t3.openSettingsModal());
document.getElementById("ie-text3d-commit-btn")?.addEventListener("click", () => {
if (!t3.isOpen) { this._toast(t("image.text3dClickCanvasFirst"), "info"); return; }
t3.commit();
});
document.getElementById("ie-text3d-cancel-btn")?.addEventListener("click", () => t3.cancel());
} else if (toolId === "shape" && this._shapeTool) {
const t = this._shapeTool;
const noFillKinds = ["line", "freeline", "chain", "rope", "original"];
const noStrokeUI = ["original"]; // stroke色設定自体が不要(画像そのまま描画のため)
const strokeAlways = ["line", "freeline", "chain", "rope"]; // strokeNoneの概念がない
const spacingKinds = ["chain", "rope", "original"];
const isLineKind = noFillKinds.includes(t.shape);
const showRounded = ["rect", "ellipse"].includes(t.shape);
const showSpacing = spacingKinds.includes(t.shape);
const showStrokeUI = !noStrokeUI.includes(t.shape);
const showImagePick = t.shape === "original";
el.innerHTML = `
<div class="ie-opt-group" id="ie-shape-samelayer-wrap" title="ON: draw into the current shape layer / OFF: create a new layer for each shape">
<label><input type="checkbox" id="ie-shape-samelayer" ${this._shapeSameLayer?"checked":""}> Same Layer</label>
</div>
<div class="ie-opt-group">
<label>Shape</label>
<select id="ie-shape-kind" class="ie-opt-select">
<option value="rect" ${t.shape==="rect" ?"selected":""}>Rect</option>
<option value="ellipse" ${t.shape==="ellipse" ?"selected":""}>Ellipse</option>
<option value="line" ${t.shape==="line" ?"selected":""}>Line</option>
<option value="freeline" ${t.shape==="freeline" ?"selected":""}>FreeLine</option>
<option value="chain" ${t.shape==="chain" ?"selected":""}>Chain</option>
<option value="rope" ${t.shape==="rope" ?"selected":""}>Rope</option>
<option value="original" ${t.shape==="original" ?"selected":""}>My Curve</option>
</select>
</div>
<div class="ie-opt-group" id="ie-shape-rounded-wrap" style="display:${showRounded?"":"none"};">
<label><input type="checkbox" id="ie-shape-rounded" ${t.rounded?"checked":""}> Rounded</label>
</div>
<div class="ie-opt-group" id="ie-shape-fill-wrap" style="display:${isLineKind?"none":""};">
<label>Fill</label>
<input type="checkbox" id="ie-shape-fill-none" ${t.fillNone?"checked":""}> <span style="font-size:11px;color:var(--it-text-secondary);">None</span>
<select id="ie-shape-fill-mode" class="ie-opt-select" ${t.fillNone?"disabled":""}>
<option value="solid" ${t.fillMode==="solid" ?"selected":""}>${window.t("font.fillModeSolid")}</option>
<option value="gradient" ${t.fillMode==="gradient" ?"selected":""}>${window.t("font.fillModeGradient")}</option>
<option value="texture" ${t.fillMode==="texture" ?"selected":""}>${window.t("font.fillModeTexture")}</option>
</select>
<input type="color" id="ie-shape-fill" value="${t.fillColor}" ${(t.fillNone||t.fillMode!=="solid")?"disabled":""}
style="width:28px;height:24px;padding:0;border:1px solid var(--it-border);cursor:pointer;border-radius:3px;margin-left:2px;display:${(!t.fillNone&&t.fillMode==="solid")?"":"none"};">
</div>
<div class="ie-opt-group" id="ie-shape-fill-gradient-panel" style="display:${(!t.fillNone&&!isLineKind&&t.fillMode==="gradient")?"":"none"};flex-wrap:wrap;">
<select id="ie-shape-grad-shape" class="ie-opt-select">
<option value="linear" ${t.fillGradient.shape==="linear"?"selected":""}>${window.t("font.gradShapeLinear")}</option>
<option value="radial" ${t.fillGradient.shape==="radial"?"selected":""}>${window.t("font.gradShapeRadial")}</option>
</select>
<label style="font-size:11px;">${window.t("font.gradAngle")}<input type="number" id="ie-shape-grad-angle" value="${t.fillGradient.angleDeg}" step="15" style="width:44px;" class="ie-opt-input"></label>
<canvas id="ie-shape-grad-ramp" width="120" height="24" style="display:block;width:120px;height:24px;border:1px solid var(--it-border);border-radius:3px;cursor:pointer;"></canvas>
<input type="color" id="ie-shape-grad-stop-color" value="#ffffff" style="width:26px;height:22px;padding:0;">
<button type="button" id="ie-shape-grad-stop-add" class="it-btn it-btn-sm" title="${window.t("font.gradStopAdd")}">+</button>
<button type="button" id="ie-shape-grad-stop-remove" class="it-btn it-btn-sm" title="${window.t("font.gradStopRemove")}">-</button>
</div>
<div class="ie-opt-group" id="ie-shape-fill-texture-panel" style="display:${(!t.fillNone&&!isLineKind&&t.fillMode==="texture")?"":"none"};">
<button type="button" id="ie-shape-tex-select-btn" class="it-btn it-btn-sm">${window.t("font.texSelectImage")}</button>
<img id="ie-shape-tex-thumb" alt="" style="width:24px;height:24px;object-fit:cover;border:1px solid var(--it-border);border-radius:3px;display:${t.fillTexture?.img?"":"none"};" ${t.fillTexture?.img?`src="${t.fillTexture.img.src}"`:""}>
<input type="file" id="ie-shape-tex-file" accept="image/*" style="display:none;">
<label style="font-size:11px;">${window.t("font.texScale")}<input type="number" id="ie-shape-tex-scale" min="1" max="1000" value="${t.fillTexture?.scale ?? 100}" style="width:50px;" class="ie-opt-input">%</label>
<label style="font-size:11px;">${window.t("font.texOffsetX")}<input type="number" id="ie-shape-tex-offset-x" value="${t.fillTexture?.offsetX ?? 0}" step="1" style="width:44px;" class="ie-opt-input"></label>
<label style="font-size:11px;">${window.t("font.texOffsetY")}<input type="number" id="ie-shape-tex-offset-y" value="${t.fillTexture?.offsetY ?? 0}" step="1" style="width:44px;" class="ie-opt-input"></label>
</div>
<div class="ie-opt-group" id="ie-shape-stroke-wrap" style="display:${showStrokeUI?"":"none"};">
<label>Stroke</label>
<div id="ie-shape-stroke-none-wrap" style="display:${strokeAlways.includes(t.shape)?"none":""};">
<input type="checkbox" id="ie-shape-stroke-none" ${t.strokeNone?"checked":""}> <span style="font-size:11px;color:var(--it-text-secondary);">None</span>
</div>
<input type="color" id="ie-shape-stroke" value="${t.strokeColor}" ${(!strokeAlways.includes(t.shape) && t.strokeNone)?"disabled":""}
style="width:28px;height:24px;padding:0;border:1px solid var(--it-border);cursor:pointer;border-radius:3px;margin-left:2px;">
<input type="number" id="ie-shape-stroke-width" value="${t.strokeWidth}" min="1" max="200" ${(!strokeAlways.includes(t.shape) && t.strokeNone)?"disabled":""}
style="width:44px;margin-left:2px;" class="ie-opt-input">
</div>
<div class="ie-opt-group" id="ie-shape-spacing-wrap" style="display:${showSpacing?"":"none"};">
<label>${window.t("image.spacingLabel")}</label>
<input type="range" id="ie-shape-spacing" min="1" max="100" value="${t.spacing}" style="width:70px;">
<span id="ie-shape-spacing-lbl">${t.spacing}</span>
</div>
<div class="ie-opt-group" id="ie-shape-image-wrap" style="display:${showImagePick?"":"none"};">
<select id="ie-shape-mychain-select" class="ie-opt-select">
<option value="">${window.t("image.mychainSelectOption")}</option>
${this._mychainAssets.map(a => `<option value="${a.path}">${a.name}</option>`).join("")}
</select>
<label class="it-btn it-btn-sm" style="cursor:pointer;">
${window.t("image.selectImageBtn")}
<input type="file" id="ie-shape-image-file" accept="image/*" style="display:none;">
</label>
<span id="ie-shape-image-name" style="font-size:11px;color:var(--it-text-secondary);max-width:90px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${t.originalImgName ?? window.t("layout.notSelected")}</span>
</div>
<div class="ie-opt-group">
<label>Opacity</label>
<input type="range" id="ie-shape-opacity" min="1" max="100" value="${Math.round(t.opacity*100)}" style="width:70px;">
<span id="ie-shape-opacity-lbl">${Math.round(t.opacity*100)}%</span>
</div>
<div class="ie-opt-group" style="margin-left:8px;">
<button class="it-btn it-btn-sm" id="ie-shape-undo-btn">↩ Undo</button>
</div>
`;
document.getElementById("ie-shape-samelayer")?.addEventListener("change", e => {
this._shapeSameLayer = e.target.checked;
});
document.getElementById("ie-shape-kind")?.addEventListener("change", e => {
t.shape = e.target.value;
this._renderToolOptions("shape");
});
document.getElementById("ie-shape-rounded")?.addEventListener("change", e => {
t.rounded = e.target.checked;
});
document.getElementById("ie-shape-fill-none")?.addEventListener("change", e => {
t.fillNone = e.target.checked;
this._renderToolOptions("shape");
});
document.getElementById("ie-shape-fill-mode")?.addEventListener("change", e => {
t.fillMode = e.target.value;
this._renderToolOptions("shape");
});
document.getElementById("ie-shape-fill")?.addEventListener("input", e => {
t.fillColor = e.target.value;
});
if (!t.fillNone && !isLineKind && t.fillMode === "gradient") {
document.getElementById("ie-shape-grad-shape")?.addEventListener("change", e => {
t.fillGradient.shape = e.target.value;
});
document.getElementById("ie-shape-grad-angle")?.addEventListener("input", e => {
t.fillGradient.angleDeg = parseFloat(e.target.value) || 0;
});
document.getElementById("ie-shape-grad-stop-color")?.addEventListener("input", e => {
const s = t.fillGradient.stops[t.selectedFillStopIdx];
if (s) { s.color = e.target.value; this._drawShapeGradRamp(); }
});
document.getElementById("ie-shape-grad-stop-add")?.addEventListener("click", () => {
t.addFillStop();
this._renderToolOptions("shape");
});
document.getElementById("ie-shape-grad-stop-remove")?.addEventListener("click", () => {
t.removeFillStop();
this._renderToolOptions("shape");
});
this._setupShapeGradRamp();
}
if (!t.fillNone && !isLineKind && t.fillMode === "texture") {
document.getElementById("ie-shape-tex-select-btn")?.addEventListener("click", () => {
document.getElementById("ie-shape-tex-file")?.click();
});
document.getElementById("ie-shape-tex-file")?.addEventListener("change", e => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
t.fillTexture = { img, scale: t.fillTexture?.scale ?? 100, offsetX: t.fillTexture?.offsetX ?? 0, offsetY: t.fillTexture?.offsetY ?? 0 };
this._renderToolOptions("shape");
};
img.src = reader.result;
};
reader.readAsDataURL(file);
e.target.value = "";
});
document.getElementById("ie-shape-tex-scale")?.addEventListener("input", e => {
if (t.fillTexture) t.fillTexture.scale = parseFloat(e.target.value) || 100;
});
document.getElementById("ie-shape-tex-offset-x")?.addEventListener("input", e => {
if (t.fillTexture) t.fillTexture.offsetX = parseFloat(e.target.value) || 0;
});
document.getElementById("ie-shape-tex-offset-y")?.addEventListener("input", e => {
if (t.fillTexture) t.fillTexture.offsetY = parseFloat(e.target.value) || 0;
});
}
document.getElementById("ie-shape-stroke-none")?.addEventListener("change", e => {
t.strokeNone = e.target.checked;
document.getElementById("ie-shape-stroke").disabled = e.target.checked;
document.getElementById("ie-shape-stroke-width").disabled = e.target.checked;
});
document.getElementById("ie-shape-stroke")?.addEventListener("input", e => {
t.strokeColor = e.target.value;
});
document.getElementById("ie-shape-stroke-width")?.addEventListener("input", e => {
t.strokeWidth = parseFloat(e.target.value) || 1;
});
document.getElementById("ie-shape-spacing")?.addEventListener("input", e => {
t.spacing = parseInt(e.target.value) || 1;
document.getElementById("ie-shape-spacing-lbl").textContent = e.target.value;
});
document.getElementById("ie-shape-mychain-select")?.addEventListener("change", e => {
const path = e.target.value;
if (!path) return;
const name = e.target.options[e.target.selectedIndex].textContent;
const img = new Image();
img.onload = () => {
t.setOriginalImage(img, name);
const nameEl = document.getElementById("ie-shape-image-name");
if (nameEl) nameEl.textContent = name;
};
img.onerror = () => this._toast(window.t("image.mychainLoadFailed"), "error");
img.src = path;
});
document.getElementById("ie-shape-image-file")?.addEventListener("change", e => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
const img = new Image();
img.onload = () => {
t.setOriginalImage(img, file.name);
const nameEl = document.getElementById("ie-shape-image-name");
if (nameEl) nameEl.textContent = file.name;
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
e.target.value = "";
});
document.getElementById("ie-shape-opacity")?.addEventListener("input", e => {
t.opacity = parseInt(e.target.value) / 100;
document.getElementById("ie-shape-opacity-lbl").textContent = e.target.value + "%";
});
document.getElementById("ie-shape-undo-btn")?.addEventListener("click", () => this._undo());
} else if (toolId === "fill" && this._fillTool) {
el.innerHTML = "";
this._renderFillProps();
} else if (toolId === "mask") {
const sub = this._maskSubtool ?? "paint";
const sam3Disabled = this._segAvailable ? "" : "disabled";
const sam3Title = this._segAvailable ? "SAM3 / WeVisOne Segment" : "SAM3 (Mask Editor One required)";
const useWevis = this._segEngine === "wevisone";
const sam3Ui = this._segAvailable && sub === "sam3" ? `
<div class="ie-opt-group">
<select id="ie-seg-engine" class="ie-opt-select" title="Segmentation engine" style="width:96px;">
<option value="sam3"${!useWevis ? " selected" : ""}${this._sam3Available ? "" : " disabled"}>SAM3</option>
<option value="wevisone"${useWevis ? " selected" : ""}${this._wevisAvailable ? "" : " disabled"}>WeVisOne</option>
</select>
</div>
<div class="ie-opt-group">
<input type="text" id="ie-sam3-prompt" class="ie-opt-input"
placeholder="${useWevis ? "e.g. the girl in a red coat" : "e.g. cat, person..."}"
value="${this._sam3Prompt}"
style="width:160px;font-size:11px;padding:2px 6px;border:1px solid var(--it-border);border-radius:3px;background:var(--it-surface);color:var(--it-text);">
</div>
<div class="ie-opt-group" style="${useWevis ? "display:none;" : ""}">
<label style="font-size:11px;color:var(--it-text-secondary);">Max</label>
<select id="ie-sam3-max" class="ie-opt-select" style="width:44px;">
${[3,6,9,12].map(n => `<option value="${n}"${n === this._sam3MaxMasks ? " selected" : ""}>${n}</option>`).join("")}
</select>
</div>
<div class="ie-opt-group">
<button class="it-btn it-btn-sm it-btn-primary" id="ie-sam3-run-btn" ${this._sam3Loading ? "disabled" : ""}>
${this._sam3Loading ? "Running..." : "Segment"}
</button>
</div>
<span id="ie-sam3-status" style="font-size:11px;color:var(--it-text-secondary);margin-left:4px;">
${this._sam3Results.length > 0 ? `${this._sam3Results.length} masks found` : ""}
</span>
` : "";
el.innerHTML = `
<div class="ie-opt-group" style="flex-wrap:nowrap;gap:2px;">
<button class="it-btn it-btn-sm${sub === "paint" ? " ie-opt-active" : ""}" id="ie-mask-paint-btn">Paint</button>
<button class="it-btn it-btn-sm${sub === "color" ? " ie-opt-active" : ""}" id="ie-mask-color-btn">Color</button>
<button class="it-btn it-btn-sm${sub === "alpha" ? " ie-opt-active" : ""}" id="ie-mask-alpha-btn">Alpha</button>
<button class="it-btn it-btn-sm${sub === "text" ? " ie-opt-active" : ""}" id="ie-mask-text-btn">Text</button>
<button class="it-btn it-btn-sm${sub === "vector" ? " ie-opt-active" : ""}" id="ie-mask-vector-btn">Vector</button>
<button class="it-btn it-btn-sm${sub === "shape" ? " ie-opt-active" : ""}" id="ie-mask-shape-btn">Shape</button>
<button class="it-btn it-btn-sm${sub === "sam3" ? " ie-opt-active" : ""}" id="ie-mask-sam3-btn"
${sam3Disabled} title="${sam3Title}">SAM3</button>
${this._wfmAvailable ? `<button class="it-btn it-btn-sm${sub === "inpaint" ? " ie-opt-active" : ""}" id="ie-mask-inpaint-btn" title="Inpaint via Workflow Studio">Inpaint</button>` : ""}
${this._wfmAvailable ? `<button class="it-btn it-btn-sm${sub === "outpaint" ? " ie-opt-active" : ""}" id="ie-mask-outpaint-btn" title="Outpaint via Workflow Studio">Outpaint</button>` : ""}
</div>
<div style="width:1px;height:22px;background:var(--it-border);margin:0 4px;flex-shrink:0;"></div>
${sam3Ui}
${sub !== "sam3" && sub !== "inpaint" && sub !== "outpaint" ? `
<div class="ie-opt-group">
<label style="font-size:11px;cursor:pointer;color:var(--it-text-secondary);">
<input type="checkbox" id="ie-mask-invert" ${this._maskInverted ? "checked" : ""}> Invert
</label>
</div>
<div class="ie-opt-group">
<label style="font-size:11px;color:var(--it-text-secondary);">Overlay</label>
<input type="color" id="ie-mask-overlay-color" value="${this._maskOverlayColor}"
style="width:28px;height:22px;padding:0;border:1px solid var(--it-border);cursor:pointer;border-radius:3px;">
</div>
<div class="ie-opt-group">
<label style="font-size:11px;color:var(--it-text-secondary);">Blur</label>
<input type="range" id="ie-mask-blur" min="0" max="50" value="${this._maskBlur}" style="width:70px;">
<span id="ie-mask-blur-val" style="font-size:11px;min-width:22px;">${this._maskBlur}</span>px
</div>` : ""}
`;
document.getElementById("ie-mask-paint-btn")?.addEventListener("click", () => {
this._switchMaskSubtool("paint");
});
document.getElementById("ie-mask-color-btn")?.addEventListener("click", () => {
this._switchMaskSubtool("color");
});
document.getElementById("ie-mask-alpha-btn")?.addEventListener("click", () => {
this._switchMaskSubtool("alpha");
});
document.getElementById("ie-mask-text-btn")?.addEventListener("click", () => {
this._switchMaskSubtool("text");
});
document.getElementById("ie-mask-vector-btn")?.addEventListener("click", () => {
this._switchMaskSubtool("vector");
});
document.getElementById("ie-mask-shape-btn")?.addEventListener("click", () => {
this._switchMaskSubtool("shape");
});
document.getElementById("ie-mask-sam3-btn")?.addEventListener("click", () => {
if (this._segAvailable) this._switchMaskSubtool("sam3");
});
document.getElementById("ie-seg-engine")?.addEventListener("change", e => {
this._segEngine = e.target.value;
try { localStorage.setItem("ccc_image_seg_engine", this._segEngine); } catch { /* ignore */ }
this._sam3Results = []; this._sam3Selected = new Set();
this._renderToolOptions("mask");
this._renderMaskProps("sam3");
});
document.getElementById("ie-mask-inpaint-btn")?.addEventListener("click", () => {
this._switchMaskSubtool("inpaint");
});
document.getElementById("ie-mask-outpaint-btn")?.addEventListener("click", () => {
this._switchMaskSubtool("outpaint");
});
document.getElementById("ie-sam3-prompt")?.addEventListener("input", e => {
this._sam3Prompt = e.target.value;
});
document.getElementById("ie-sam3-max")?.addEventListener("change", e => {
this._sam3MaxMasks = parseInt(e.target.value);
});
document.getElementById("ie-sam3-run-btn")?.addEventListener("click", () => this._runSam3Segment());
document.getElementById("ie-mask-invert")?.addEventListener("change", e => {
this._maskInverted = e.target.checked;
this._updateCompositeView();
});
document.getElementById("ie-mask-overlay-color")?.addEventListener("input", e => {
this._maskOverlayColor = e.target.value;
this._updateCompositeView();
});
document.getElementById("ie-mask-blur")?.addEventListener("input", e => {
this._maskBlur = parseInt(e.target.value);
document.getElementById("ie-mask-blur-val").textContent = e.target.value;
this._updateCompositeView();
});
this._renderMaskProps(sub);
} else if (toolId === "blur") {
const blurOn = this._blurRectMode === "blur";
const mosaicOn = this._blurRectMode === "mosaic";
el.innerHTML = `
<div class="ie-opt-group">
<span style="font-size:11px;color:var(--it-text-secondary);">Whole:</span>
</div>
<div class="ie-opt-group">
<label style="font-size:11px;">Blur</label>
<input type="range" id="ie-whole-blur" min="1" max="50" value="10" style="width:70px;">
<span id="ie-whole-blur-val" style="font-size:11px;min-width:22px;">10</span>px
<button class="it-btn it-btn-sm" id="ie-whole-blur-apply">Apply</button>
</div>
<div class="ie-opt-group">
<label style="font-size:11px;">Mosaic</label>
<input type="range" id="ie-whole-mosaic" min="5" max="100" value="20" style="width:70px;">
<span id="ie-whole-mosaic-val" style="font-size:11px;min-width:22px;">20</span>px
<button class="it-btn it-btn-sm" id="ie-whole-mosaic-apply">Apply</button>
</div>
<div style="width:1px;height:22px;background:var(--it-border);margin:0 6px;flex-shrink:0;"></div>
<div class="ie-opt-group">
<span style="font-size:11px;color:var(--it-text-secondary);">Rect:</span>
</div>
<div class="ie-opt-group">
<button class="it-btn it-btn-sm${blurOn ? " ie-opt-active" : ""}" id="ie-rect-blur-toggle">
Rect Blur: ${blurOn ? "ON" : "OFF"}
</button>
<input type="range" id="ie-rect-blur" min="1" max="50" value="10" style="width:70px;">
<span id="ie-rect-blur-val" style="font-size:11px;min-width:22px;">10</span>px
</div>
<div class="ie-opt-group">
<button class="it-btn it-btn-sm${mosaicOn ? " ie-opt-active" : ""}" id="ie-rect-mosaic-toggle">
Rect Mosaic: ${mosaicOn ? "ON" : "OFF"}
</button>
<input type="range" id="ie-rect-mosaic" min="5" max="50" value="15" style="width:70px;">
<span id="ie-rect-mosaic-val" style="font-size:11px;min-width:22px;">15</span>px
</div>
`;
document.getElementById("ie-whole-blur")?.addEventListener("input", e => {
document.getElementById("ie-whole-blur-val").textContent = e.target.value;
});
document.getElementById("ie-whole-mosaic")?.addEventListener("input", e => {
document.getElementById("ie-whole-mosaic-val").textContent = e.target.value;
});
document.getElementById("ie-whole-blur-apply")?.addEventListener("click", () => {
this._applyWholeBlur(parseInt(document.getElementById("ie-whole-blur").value));
});
document.getElementById("ie-whole-mosaic-apply")?.addEventListener("click", () => {
this._applyWholeMosaic(parseInt(document.getElementById("ie-whole-mosaic").value));
});
document.getElementById("ie-rect-blur-toggle")?.addEventListener("click", () => {
this._blurRectMode = this._blurRectMode === "blur" ? null : "blur";
this._renderToolOptions("blur");
const ov = document.getElementById("ie-canvas-overlay");
if (ov) ov.style.cursor = this._blurRectMode ? "crosshair" : "default";
});
document.getElementById("ie-rect-mosaic-toggle")?.addEventListener("click", () => {
this._blurRectMode = this._blurRectMode === "mosaic" ? null : "mosaic";
this._renderToolOptions("blur");
const ov = document.getElementById("ie-canvas-overlay");
if (ov) ov.style.cursor = this._blurRectMode ? "crosshair" : "default";
});
document.getElementById("ie-rect-blur")?.addEventListener("input", e => {
document.getElementById("ie-rect-blur-val").textContent = e.target.value;
});
document.getElementById("ie-rect-mosaic")?.addEventListener("input", e => {
document.getElementById("ie-rect-mosaic-val").textContent = e.target.value;
});
} else if (toolId === "bgremove") {
const birefnetDisabled = this._birefnetAvailable ? "" : "disabled";
const birefnetLabel = this._birefnetAvailable
? "BiRefNet (Mask Editor One)"
: "BiRefNet (Mask Editor One required)";
el.innerHTML = `
<div class="ie-opt-group">
<label>Model</label>
<select id="ie-bgremove-model" class="ie-opt-select">
<option value="imgly">Lightweight (@imgly)</option>
<option value="birefnet" ${birefnetDisabled}>${birefnetLabel}</option>
</select>
</div>
<div class="ie-opt-group" id="ie-bgremove-quality-wrap">
<label>Quality</label>
<select id="ie-bgremove-quality" class="ie-opt-select" title="Higher quality means a larger first-time download (small=42MB / medium=84MB / large=168MB)">
<option value="small">Low / light (42MB)</option>
<option value="medium" selected>Standard (84MB)</option>
<option value="large">High (168MB)</option>
</select>
</div>
<div class="ie-opt-group">
<label style="font-size:11px;cursor:pointer;">
<input type="checkbox" id="ie-bgremove-new-layer" checked> New Layer
</label>
</div>
<div class="ie-opt-group">
<button class="it-btn it-btn-sm" id="ie-bgremove-btn">Remove BG</button>
</div>
<span id="ie-bgremove-status" style="font-size:11px;color:var(--it-text-secondary);margin-left:4px;"></span>
`;
const bgModelSel = document.getElementById("ie-bgremove-model");
const bgQualityWrap = document.getElementById("ie-bgremove-quality-wrap");
const syncBgQualityVis = () => { bgQualityWrap.style.display = bgModelSel.value === "imgly" ? "" : "none"; };
bgModelSel?.addEventListener("change", syncBgQualityVis);
syncBgQualityVis();
document.getElementById("ie-bgremove-btn")?.addEventListener("click", () => this._applyBgRemove());
} else if (toolId === "filter") {
const openBtnDisabled = this._gmicState.processing ? "disabled" : "";
const applyBtnDisabled = (!this._gmicState.lastResultJobId || this._gmicState.processing) ? "disabled" : "";
const progressStyle = this._gmicState.processing ? "display:flex" : "display:none";
el.innerHTML = `
<div class="ie-opt-group">
<button class="it-btn it-btn-sm it-btn-primary" id="ie-gmic-open-btn" ${openBtnDisabled}>${t("layout.gmicOpenGui")}</button>
<button class="it-btn it-btn-sm" id="ie-gmic-apply-btn" ${applyBtnDisabled}>${t("image.gmicApplyResult")}</button>
</div>
<div class="ie-opt-group" id="ie-gmic-progress-area" style="${progressStyle}; align-items:center; gap:6px;">
<span id="ie-gmic-progress-lbl" style="font-size:11px; color:var(--it-text-secondary);">${t("image.gmicLaunching")}</span>
<button class="it-btn it-btn-sm" id="ie-gmic-abort-btn" style="background:#ea4335;color:#fff;">${t("layout.abortBtn")}</button>
</div>
`;
document.getElementById("ie-gmic-open-btn")?.addEventListener("click", () => this._gmicOpenGui());
document.getElementById("ie-gmic-apply-btn")?.addEventListener("click", () => this._gmicApplyResult());
document.getElementById("ie-gmic-abort-btn")?.addEventListener("click", () => this._gmicAbort());
} else if (toolId === "upscale") {
el.innerHTML = `
<div class="ie-opt-group">
<label style="font-size:11px;">${t("image.upscaleScaleLabel")}</label>
<input type="range" id="ie-upscale-scale" min="1" max="4" step="0.5" value="2" style="width:80px;">
<span id="ie-upscale-scale-val" style="font-size:11px;min-width:32px;">2.0×</span>
</div>
<div class="ie-opt-group">
<label style="font-size:11px;">${t("image.upscaleSharpenLabel")}</label>
<input type="range" id="ie-upscale-sharpen" min="0" max="100" value="30" style="width:70px;">
<span id="ie-upscale-sharpen-val" style="font-size:11px;min-width:28px;">30%</span>
</div>
<div class="ie-opt-group">
<label style="font-size:11px;cursor:pointer;">
<input type="checkbox" id="ie-upscale-denoise"> ${t("image.upscaleDenoiseLabel")}
</label>
</div>
<div class="ie-opt-group">
<button class="it-btn it-btn-sm it-btn-primary" id="ie-upscale-run-btn">${t("layout.runBtn")}</button>
</div>
<span id="ie-upscale-status" style="font-size:11px;color:var(--it-text-secondary);margin-left:4px;"></span>
`;
document.getElementById("ie-upscale-scale")?.addEventListener("input", e => {
document.getElementById("ie-upscale-scale-val").textContent = parseFloat(e.target.value).toFixed(1) + "×";
});
document.getElementById("ie-upscale-sharpen")?.addEventListener("input", e => {
document.getElementById("ie-upscale-sharpen-val").textContent = e.target.value + "%";
});
document.getElementById("ie-upscale-run-btn")?.addEventListener("click", () => this._applyUpscale());
} else {
const def = TOOL_DEFS.find(d => d.id === toolId);
const label = def?.label ?? toolId;
// ready:trueなツールでもこの分岐に来るのは、対応するツールインスタンス
// (this._drawTool等)が_initCanvases()未実行でまだ存在しないケース。
// 「coming soon」は未実装機能という誤解を招くため、キャンバス未作成時は
// 案内メッセージに差し替える(実装済み機能なのに「coming soon」のまま
// 見え続けるという報告への対応)。
const message = this._layerMgr ? `${label}: coming soon` : t("image.toolNeedsCanvas", label);
el.innerHTML = `<span style="font-size:12px;color:var(--it-text-secondary);">${message}</span>`;
}
}
// Select ツール専用の常時プロパティパネル(Mask/Inpaintの _renderMaskProps("inpaint") と対称構成)。
// All(composite全体)/ Layer(選択中レイヤー単体)を切り替えてI2Iを実行できる。
// I2I設定(デフォルトワークフロー)は設定タブから移設し、ここで読み書きする。
_renderSelectI2IProps() {
const pane = document.getElementById("ie-props-pane");
const body = document.getElementById("ie-props-body");
const title = document.getElementById("ie-props-title");
if (!pane || !body) return;
pane.style.display = "flex";
if (title) title.textContent = "I2I";
const modeAll = this._selectI2IMode === "all";
body.innerHTML = `
<div class="ie-props-row">
<label>Target</label>
<div style="display:flex;gap:4px;">
<button class="it-btn it-btn-sm${modeAll ? " ie-opt-active" : ""}" id="ie-sel-i2i-mode-all" style="flex:1;">All</button>
<button class="it-btn it-btn-sm${!modeAll ? " ie-opt-active" : ""}" id="ie-sel-i2i-mode-layer" style="flex:1;">Layer</button>
</div>
</div>
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
<label>Positive Prompt</label>
<textarea id="ie-sel-i2i-positive" rows="3" style="width:100%;box-sizing:border-box;resize:vertical;font-size:12px;">${this._selectI2IPositive}</textarea>
</div>
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
<label>Negative Prompt</label>
<textarea id="ie-sel-i2i-negative" rows="3" style="width:100%;box-sizing:border-box;resize:vertical;font-size:12px;">${this._selectI2INegative}</textarea>
</div>
<div class="ie-props-row">
<label>Denoise</label>
<input type="number" id="ie-sel-i2i-denoise" min="0" max="1" step="0.01" value="${this._selectI2IDenoise}" style="width:60px;">
</div>
<div class="ie-props-row">
<button class="it-btn it-btn-sm it-btn-primary" id="ie-sel-i2i-run-btn" style="flex:1;" ${this._selectI2IRunning ? "disabled" : ""}>
${this._selectI2IRunning ? "Running..." : "Run"}
</button>
</div>
<span id="ie-sel-i2i-status" style="font-size:11px;color:var(--it-text-secondary);"></span>
<div style="margin:6px 0 4px;border-top:1px solid var(--it-border);padding-top:6px;font-size:10px;color:var(--it-text-secondary);letter-spacing:0.05em;">
I2I SETTINGS
</div>
<div class="ie-props-row">
<label style="cursor:pointer;display:flex;align-items:center;gap:4px;">
<input type="checkbox" id="ie-i2i-default-wf-enabled"> Use default workflow
</label>
</div>
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
<label>Workflow file</label>
<input type="text" id="ie-i2i-default-wf-name" placeholder="cc_i2i_default.json" style="width:100%;box-sizing:border-box;">
</div>
<div class="ie-props-row">
<button class="it-btn it-btn-sm" id="ie-i2i-settings-save-btn">Save</button>
<span id="ie-i2i-settings-status" style="font-size:11px;color:var(--it-text-secondary);"></span>
</div>
`;
document.getElementById("ie-sel-i2i-mode-all")?.addEventListener("click", () => {
this._selectI2IMode = "all";
this._renderSelectI2IProps();
});
document.getElementById("ie-sel-i2i-mode-layer")?.addEventListener("click", () => {
this._selectI2IMode = "layer";
this._renderSelectI2IProps();
});
document.getElementById("ie-sel-i2i-positive")?.addEventListener("input", e => { this._selectI2IPositive = e.target.value; });
document.getElementById("ie-sel-i2i-negative")?.addEventListener("input", e => { this._selectI2INegative = e.target.value; });
document.getElementById("ie-sel-i2i-denoise")?.addEventListener("input", e => {
this._selectI2IDenoise = Math.max(0, Math.min(1, parseFloat(e.target.value)));
if (Number.isNaN(this._selectI2IDenoise)) this._selectI2IDenoise = 1.0;
});
document.getElementById("ie-sel-i2i-run-btn")?.addEventListener("click", () => this._runSelectI2I());
this._bindI2ISettingsPanel();
}
// I2I設定(デフォルトワークフロー使用有無・ファイル名)の読み込み・保存バインド。
// データ自体は static/js/main/14-integrations.js の _i2iSettings(レイアウトタブのI2I送信と共有)。
_bindI2ISettingsPanel() {
const enabledCb = document.getElementById("ie-i2i-default-wf-enabled");
const nameInput = document.getElementById("ie-i2i-default-wf-name");
const saveBtn = document.getElementById("ie-i2i-settings-save-btn");
const statusEl = document.getElementById("ie-i2i-settings-status");
if (!enabledCb) return;
const cur = getI2ISettingsState();
enabledCb.checked = cur.enabled;
if (nameInput) nameInput.value = cur.file;
saveBtn?.addEventListener("click", () => {
saveI2ISettingsState(enabledCb.checked, nameInput?.value);
if (statusEl) {
statusEl.textContent = "Saved";
setTimeout(() => { if (statusEl) statusEl.textContent = ""; }, 2000);
}
});
}
// Select→I2I実行本体。_runInpaint()と対称の構造(マスク不要・All/Layerモード分岐あり)。
async _runSelectI2I() {
if (this._selectI2IRunning) return;
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return; }
let sourceCanvas, targetLayer = null;
if (this._selectI2IMode === "layer") {
targetLayer = this._layerMgr.activeLayer;
if (!targetLayer) { this._toast(window.t("image.msg.noActiveLayer2"), "error"); return; }
sourceCanvas = targetLayer.canvas;
} else {
sourceCanvas = this._buildCompositeCanvas();
}
const runBtn = document.getElementById("ie-sel-i2i-run-btn");
const statusEl = document.getElementById("ie-sel-i2i-status");
const setStatus = (msg) => { if (statusEl) statusEl.textContent = msg; };
this._selectI2IRunning = true;
if (runBtn) { runBtn.disabled = true; runBtn.textContent = "Running..."; }
setStatus("Uploading...");
try {
const imageBlob = await new Promise(resolve => sourceCanvas.toBlob(resolve, "image/png"));
setStatus("Generating...");
const result = await sendI2IRunToWorkflowStudio(imageBlob, {
positive: this._selectI2IPositive,
negative: this._selectI2INegative,
denoise: this._selectI2IDenoise,
}, {
// Runは常にパネルに今表示されているチェックボックス/ファイル名の値を使う
// (「保存」ボタンは次回パネル表示時の初期値を保存するだけで、Run自体には影響しない)
enabled: document.getElementById("ie-i2i-default-wf-enabled")?.checked,
file: document.getElementById("ie-i2i-default-wf-name")?.value,
});
if (!result?.ok) throw new Error(result?.message || "I2I failed");
setStatus("Done");
if (this._selectI2IMode === "layer") {
await this._addI2IResultAsLayer(targetLayer, result.url);
} else {
await this._loadFromDataUrl(result.url, "I2I Result");
}
this._toast(window.t("image.msg.i2iDone"), "success");
} catch (err) {
setStatus("Error");
this._toast(window.t("image.msg.i2iFailed", err.message), "error");
} finally {
this._selectI2IRunning = false;
if (this._activeTool === "select") this._renderSelectI2IProps();
}
}
// Layerモードの結果反映: 元レイヤーは変更せず、同じ配置(displayW/displayH/x/y/rotation/flip)を
// 引き継いだ新規レイヤーとして結果画像を追加する(Allモードの_loadFromDataUrlと同様、常に新規レイヤー化)。
async _addI2IResultAsLayer(layer, dataUrl) {
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error("Failed to load result image"));
i.src = dataUrl;
});
this._saveUndo();
const newLayer = this._layerMgr.addLayer("image", `${layer.name} I2I`, {
contentW: img.width, contentH: img.height,
displayW: layer.displayW, displayH: layer.displayH,
x: layer.x, y: layer.y,
});
newLayer.rotation = layer.rotation;
newLayer.flipX = layer.flipX;
newLayer.flipY = layer.flipY;
newLayer.ctx.drawImage(img, 0, 0);
this._layerMgr.setActive(newLayer.id);
this._updateCompositeView();
this._refreshLayerList();
}
_renderMaskProps(sub) {
const pane = document.getElementById("ie-props-pane");
const body = document.getElementById("ie-props-body");
const title = document.getElementById("ie-props-title");
if (!pane || !body) return;
pane.style.display = "flex";
if (title) title.textContent = sub.charAt(0).toUpperCase() + sub.slice(1);
if (sub === "paint" && this._maskTool) {
const t = this._maskTool;
body.innerHTML = `
<div class="ie-props-row">
<label>Mode</label>
<div style="display:flex;gap:4px;">
<button class="it-btn it-btn-sm${t.mode === "paint" ? " ie-opt-active" : ""}" id="ie-mask-mode-add" style="flex:1;">Add</button>
<button class="it-btn it-btn-sm${t.mode === "erase" ? " ie-opt-active" : ""}" id="ie-mask-mode-erase" style="flex:1;">Erase</button>
</div>
</div>
<div class="ie-props-row">
<label>Size</label>
<input type="range" id="ie-mask-size" min="1" max="200" value="${t.brushSize}">
<span id="ie-mask-size-lbl">${t.brushSize}px</span>
</div>
<div class="ie-props-row">
<label>Hardness</label>
<input type="range" id="ie-mask-hard" min="0" max="100" value="${Math.round(t.hardness * 100)}"
${t.brushImage ? "disabled title='Hardness applies to circle brush only'" : ""}>
<span id="ie-mask-hard-lbl">${Math.round(t.hardness * 100)}%</span>
</div>
<div style="margin:6px 0 4px;border-top:1px solid var(--it-border);padding-top:6px;font-size:10px;color:${this._abrAvailable ? "var(--it-success)" : "var(--it-text-secondary)"};letter-spacing:0.05em;"
title="${this._abrAvailable ? "Mask Editor One: brushes available" : "Mask Editor One: no brushes imported yet"}">
MASK EDITOR ONE
</div>
<div class="ie-props-row">
<label>Brush</label>
<span style="font-size:11px;color:var(--it-text-secondary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
title="${t.brushName ?? "Circle"}">${t.brushName ?? "Circle"}</span>
<button class="it-btn it-btn-sm" id="ie-mask-select-brush" style="font-size:10px;padding:1px 6px;flex-shrink:0;" ${this._abrAvailable ? "" : "disabled"}>Select</button>
${t.brushImage ? `<button class="it-btn it-btn-sm" id="ie-mask-clear-brush" style="font-size:10px;padding:1px 5px;flex-shrink:0;">✕</button>` : ""}
</div>
${t.brushImage ? `
<div class="ie-props-row">
<label>Spacing</label>
<input type="range" id="ie-mask-spacing" min="5" max="100" value="${Math.round((t.spacing ?? 0.25) * 100)}">
<span id="ie-mask-spacing-lbl">${Math.round((t.spacing ?? 0.25) * 100)}%</span>
</div>
<div class="ie-props-row">
<label>Angle</label>
<input type="range" id="ie-mask-angle" min="0" max="359" value="${t.angle ?? 0}">
<span id="ie-mask-angle-lbl">${t.angle ?? 0}°</span>
</div>
<div class="ie-props-row">
<label>Sz Jitter</label>
<input type="range" id="ie-mask-szjitter" min="0" max="100" value="${Math.round((t.sizeJitterAmount ?? 0.5) * 100)}">
<span id="ie-mask-szjitter-lbl">${Math.round((t.sizeJitterAmount ?? 0.5) * 100)}%</span>
</div>
<div class="ie-props-row">
<label>Rot. Jitter</label>
<label style="cursor:pointer;font-size:11px;">
<input type="checkbox" id="ie-mask-rotjitter" ${t.rotationJitter ? "checked" : ""}> On
</label>
</div>` : ""}
`;
document.getElementById("ie-mask-mode-add")?.addEventListener("click", () => {
this._maskTool.mode = "paint";
this._maskTool._stamp = null;
this._renderMaskProps("paint");
});
document.getElementById("ie-mask-mode-erase")?.addEventListener("click", () => {
this._maskTool.mode = "erase";
this._maskTool._stamp = null;
this._renderMaskProps("paint");
});
document.getElementById("ie-mask-size")?.addEventListener("input", e => {
this._maskTool.brushSize = parseInt(e.target.value);
document.getElementById("ie-mask-size-lbl").textContent = e.target.value + "px";
this._maskTool._stamp = null;
});
document.getElementById("ie-mask-hard")?.addEventListener("input", e => {
this._maskTool.hardness = parseInt(e.target.value) / 100;
document.getElementById("ie-mask-hard-lbl").textContent = e.target.value + "%";
this._maskTool._stamp = null;
});
document.getElementById("ie-mask-select-brush")?.addEventListener("click", () => this._openAbrBrushPicker());
document.getElementById("ie-mask-clear-brush")?.addEventListener("click", () => {
this._maskTool?.clearImageBrush();
this._renderMaskProps("paint");
});
document.getElementById("ie-mask-spacing")?.addEventListener("input", e => {
this._maskTool.spacing = parseInt(e.target.value) / 100;
document.getElementById("ie-mask-spacing-lbl").textContent = e.target.value + "%";
});
document.getElementById("ie-mask-angle")?.addEventListener("input", e => {
this._maskTool.angle = parseInt(e.target.value);
document.getElementById("ie-mask-angle-lbl").textContent = e.target.value + "°";
});
document.getElementById("ie-mask-szjitter")?.addEventListener("input", e => {
this._maskTool.sizeJitterAmount = parseInt(e.target.value) / 100;
this._maskTool.sizeJitter = this._maskTool.sizeJitterAmount > 0;
document.getElementById("ie-mask-szjitter-lbl").textContent = e.target.value + "%";
});
document.getElementById("ie-mask-rotjitter")?.addEventListener("change", e => {
this._maskTool.rotationJitter = e.target.checked;
});
} else if (sub === "sam3") {
const modeAdd = this._sam3Mode === "add";
const selCount = this._sam3Selected.size;
const hasResult = this._sam3Results.length > 0;
body.innerHTML = `
<div class="ie-props-row">
<label>Mode</label>
<div style="display:flex;gap:4px;">
<button class="it-btn it-btn-sm${modeAdd ? " ie-opt-active" : ""}" id="ie-sam3-mode-add" style="flex:1;">Add</button>
<button class="it-btn it-btn-sm${!modeAdd ? " ie-opt-active" : ""}" id="ie-sam3-mode-erase" style="flex:1;">Erase</button>
</div>
</div>
${hasResult ? `
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div style="font-size:11px;color:var(--it-text-secondary);">Click to select / deselect:</div>
<div id="ie-sam3-results" style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;">
${this._sam3Results.map((r, i) => {
const sel = this._sam3Selected.has(i);
const borderColor = sel ? "var(--it-primary)" : "var(--it-border)";
const bg = sel ? "color-mix(in srgb,var(--it-primary) 15%,transparent)" : "transparent";
return `<div class="ie-sam3-thumb" data-idx="${i}"
style="cursor:pointer;border:2px solid ${borderColor};border-radius:4px;overflow:hidden;text-align:center;background:${bg};position:relative;">
<img src="${r.mask_b64}" style="width:100%;display:block;background:#000;">
<div style="font-size:10px;padding:2px 0;color:var(--it-text-secondary);">
${r.score == null ? (r.label || "") : `${Math.round(r.score * 100)}%`}
</div>
${sel ? '<div style="position:absolute;top:2px;right:3px;font-size:12px;line-height:1;color:var(--it-primary);">✓</div>' : ""}
</div>`;
}).join("")}
</div>
<button class="it-btn it-btn-sm it-btn-primary" id="ie-sam3-apply-btn"
${selCount === 0 ? "disabled" : ""} style="margin-top:4px;">
Apply Selected${selCount > 0 ? ` (${selCount})` : ""}
</button>
</div>` : `
<div style="font-size:11px;color:var(--it-text-secondary);padding:4px 0;">
${this._sam3Loading ? "Segmenting..." : "Enter a prompt and press Segment"}
</div>`}
`;
document.getElementById("ie-sam3-mode-add")?.addEventListener("click", () => {
this._sam3Mode = "add";
this._renderMaskProps("sam3");
});
document.getElementById("ie-sam3-mode-erase")?.addEventListener("click", () => {
this._sam3Mode = "erase";
this._renderMaskProps("sam3");
});
body.querySelectorAll(".ie-sam3-thumb").forEach(el => {
el.addEventListener("click", () => {
const idx = parseInt(el.dataset.idx);
if (this._sam3Selected.has(idx)) this._sam3Selected.delete(idx);
else this._sam3Selected.add(idx);
this._renderMaskProps("sam3");
});
});
document.getElementById("ie-sam3-apply-btn")?.addEventListener("click", () => {
this._applySelectedSam3Masks();
});
} else if (sub === "inpaint") {
body.innerHTML = `
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
<label>Positive Prompt</label>
<textarea id="ie-inpaint-positive" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this._inpaintPositive}</textarea>
</div>
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
<label>Negative Prompt</label>
<textarea id="ie-inpaint-negative" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this._inpaintNegative}</textarea>
</div>
<div class="ie-props-row">
<label>Grow Mask By</label>
<input type="number" id="ie-inpaint-grow-mask" min="0" max="200" step="1" value="${this._inpaintGrowMaskBy}" style="width:60px;">
</div>
<div class="ie-props-row">
<label>Denoise</label>
<input type="number" id="ie-inpaint-denoise" min="0" max="1" step="0.01" value="${this._inpaintDenoise}" style="width:60px;">
</div>
<div class="ie-props-row">
<button class="it-btn it-btn-sm it-btn-primary" id="ie-inpaint-run-btn" style="flex:1;" ${this._inpaintRunning ? "disabled" : ""}>
${this._inpaintRunning ? "Running..." : "Run"}
</button>
</div>
<span id="ie-inpaint-status" style="font-size:11px;color:var(--it-text-secondary);"></span>
`;
document.getElementById("ie-inpaint-positive")?.addEventListener("input", e => { this._inpaintPositive = e.target.value; });
document.getElementById("ie-inpaint-negative")?.addEventListener("input", e => { this._inpaintNegative = e.target.value; });
document.getElementById("ie-inpaint-grow-mask")?.addEventListener("input", e => {
this._inpaintGrowMaskBy = parseInt(e.target.value) || 0;
});
document.getElementById("ie-inpaint-denoise")?.addEventListener("input", e => {
this._inpaintDenoise = Math.max(0, Math.min(1, parseFloat(e.target.value)));
if (Number.isNaN(this._inpaintDenoise)) this._inpaintDenoise = 1.0;
});
document.getElementById("ie-inpaint-run-btn")?.addEventListener("click", () => this._runInpaint());
} else if (sub === "outpaint") {
const pad = this._outpaintPad;
const { newW, newH } = this._outpaintAdjustedPad(pad);
const fillOpts = [
{ id: "extend", label: t("image.extendColorExtend") },
{ id: "transparent", label: t("image.extendColorTransparent") },
{ id: "white", label: t("image.extendColorWhite") },
{ id: "black", label: t("image.extendColorBlack") },
];
body.innerHTML = `
<div class="ie-props-row">
<label style="cursor:pointer;display:flex;align-items:flex-start;gap:4px;white-space:normal;line-height:1.4;">
<input type="checkbox" id="ie-outpaint-linked" style="flex-shrink:0;margin-top:2px;" ${this._outpaintLinked ? "checked" : ""}>
<span>${t("image.extendLinkedTitle")}</span>
</label>
</div>
<div class="ie-props-row">
<label>${t("image.extendTopLabel")}</label>
<input type="number" id="ie-outpaint-top" value="${pad.top}" step="1" min="0" style="width:100%;box-sizing:border-box;">
</div>
<div class="ie-props-row">
<label>${t("image.extendRightLabel")}</label>
<input type="number" id="ie-outpaint-right" value="${pad.right}" step="1" min="0" style="width:100%;box-sizing:border-box;">
</div>
<div class="ie-props-row">
<label>${t("image.extendBottomLabel")}</label>
<input type="number" id="ie-outpaint-bottom" value="${pad.bottom}" step="1" min="0" style="width:100%;box-sizing:border-box;">
</div>
<div class="ie-props-row">
<label>${t("image.extendLeftLabel")}</label>
<input type="number" id="ie-outpaint-left" value="${pad.left}" step="1" min="0" style="width:100%;box-sizing:border-box;">
</div>
<div class="ie-props-row">
<label>${t("image.extendColorLabel")}</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;">
${fillOpts.map(o => `<button class="it-btn it-btn-sm${this._outpaintFillMode === o.id ? " ie-opt-active" : ""}" data-fill="${o.id}" style="padding:2px 4px;">${o.label}</button>`).join("")}
</div>
</div>
<div style="font-size:11px;color:var(--it-text-secondary);white-space:normal;line-height:1.4;">
${t("image.extendNewSizeLabelPrefix")}<br>${newW} × ${newH} px<br>${t("image.outpaintSizeAdjustNote")}
</div>
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
<label>Positive Prompt</label>
<textarea id="ie-outpaint-positive" rows="3" style="width:100%;box-sizing:border-box;resize:vertical;font-size:12px;">${this._outpaintPositive}</textarea>
</div>
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
<label>Negative Prompt</label>
<textarea id="ie-outpaint-negative" rows="3" style="width:100%;box-sizing:border-box;resize:vertical;font-size:12px;">${this._outpaintNegative}</textarea>
</div>
<div class="ie-props-row">
<label>Grow Mask By</label>
<input type="number" id="ie-outpaint-grow-mask" min="0" max="200" step="1" value="${this._outpaintGrowMaskBy}" style="width:60px;">
</div>
<div class="ie-props-row">
<label>Denoise</label>
<input type="number" id="ie-outpaint-denoise" min="0" max="1" step="0.01" value="${this._outpaintDenoise}" style="width:60px;">
</div>
<div class="ie-props-row">
<button class="it-btn it-btn-sm it-btn-primary" id="ie-outpaint-run-btn" style="flex:1;" ${this._outpaintRunning ? "disabled" : ""}>
${this._outpaintRunning ? "Running..." : "Run"}
</button>
</div>
<span id="ie-outpaint-status" style="font-size:11px;color:var(--it-text-secondary);"></span>
`;
const syncOutpaintFromInput = (side, value) => {
const v = Math.max(0, Math.round(parseFloat(value)) || 0);
this._outpaintPad = this._outpaintLinked
? { top: v, right: v, bottom: v, left: v }
: { ...this._outpaintPad, [side]: v };
this._renderMaskProps("outpaint");
};
document.getElementById("ie-outpaint-linked")?.addEventListener("change", (e) => {
this._outpaintLinked = e.target.checked;
if (this._outpaintLinked) {
const v = Math.max(this._outpaintPad.top, this._outpaintPad.right, this._outpaintPad.bottom, this._outpaintPad.left);
this._outpaintPad = { top: v, right: v, bottom: v, left: v };
}
this._renderMaskProps("outpaint");
});
document.getElementById("ie-outpaint-top")?.addEventListener("change", (e) => syncOutpaintFromInput("top", e.target.value));
document.getElementById("ie-outpaint-right")?.addEventListener("change", (e) => syncOutpaintFromInput("right", e.target.value));
document.getElementById("ie-outpaint-bottom")?.addEventListener("change", (e) => syncOutpaintFromInput("bottom", e.target.value));
document.getElementById("ie-outpaint-left")?.addEventListener("change", (e) => syncOutpaintFromInput("left", e.target.value));
body.querySelectorAll("[data-fill]").forEach(btn => {
btn.addEventListener("click", () => {
this._outpaintFillMode = btn.dataset.fill;
this._renderMaskProps("outpaint");
});
});
document.getElementById("ie-outpaint-positive")?.addEventListener("input", e => { this._outpaintPositive = e.target.value; });
document.getElementById("ie-outpaint-negative")?.addEventListener("input", e => { this._outpaintNegative = e.target.value; });
document.getElementById("ie-outpaint-grow-mask")?.addEventListener("input", e => {
this._outpaintGrowMaskBy = parseInt(e.target.value) || 0;
});
document.getElementById("ie-outpaint-denoise")?.addEventListener("input", e => {
this._outpaintDenoise = Math.max(0, Math.min(1, parseFloat(e.target.value)));
if (Number.isNaN(this._outpaintDenoise)) this._outpaintDenoise = 1.0;
});
document.getElementById("ie-outpaint-run-btn")?.addEventListener("click", () => this._runOutpaint());
} else if (sub === "color" && this._maskColorTool) {
const t = this._maskColorTool;
body.innerHTML = `
<div class="ie-props-row">
<label>Mode</label>
<div style="display:flex;gap:4px;">
<button class="it-btn it-btn-sm${t.mode === "add" ? " ie-opt-active" : ""}" id="ie-mc-add" style="flex:1;">Add</button>
<button class="it-btn it-btn-sm${t.mode === "subtract" ? " ie-opt-active" : ""}" id="ie-mc-sub" style="flex:1;">Sub</button>
</div>
</div>
<div class="ie-props-row">
<label>Tolerance</label>
<input type="range" id="ie-mc-tol" min="0" max="255" value="${t.tolerance}">
<span id="ie-mc-tol-lbl">${t.tolerance}</span>
</div>
<div class="ie-props-row">
<label>Feather %</label>
<input type="range" id="ie-mc-fea" min="0" max="100" value="${t.feather}">
<span id="ie-mc-fea-lbl">${t.feather}%</span>
</div>
<div style="font-size:10px;color:var(--it-text-secondary);margin-top:2px;">Click on canvas to select color</div>
`;
document.getElementById("ie-mc-add")?.addEventListener("click", () => { t.mode = "add"; this._renderMaskProps("color"); });
document.getElementById("ie-mc-sub")?.addEventListener("click", () => { t.mode = "subtract"; this._renderMaskProps("color"); });
document.getElementById("ie-mc-tol")?.addEventListener("input", e => {
t.tolerance = parseInt(e.target.value);
document.getElementById("ie-mc-tol-lbl").textContent = e.target.value;
});
document.getElementById("ie-mc-fea")?.addEventListener("input", e => {
t.feather = parseInt(e.target.value);
document.getElementById("ie-mc-fea-lbl").textContent = e.target.value + "%";
});
} else if (sub === "alpha" && this._maskAlphaTool) {
const t = this._maskAlphaTool;
body.innerHTML = `
<div class="ie-props-row">
<label>Threshold</label>
<input type="range" id="ie-ma-thr" min="0" max="255" value="${t.threshold}">
<span id="ie-ma-thr-lbl">${t.threshold}</span>
</div>
<div class="ie-props-row">
<label style="cursor:pointer;">
<input type="checkbox" id="ie-ma-inv" ${t.invert ? "checked" : ""}> Invert
</label>
</div>
<button class="it-btn it-btn-sm it-btn-primary" id="ie-ma-extract" style="margin-top:4px;">Extract Alpha</button>
<div style="font-size:10px;color:var(--it-text-secondary);margin-top:2px;">Extracts alpha from image layers</div>
`;
document.getElementById("ie-ma-thr")?.addEventListener("input", e => {
t.threshold = parseInt(e.target.value);
document.getElementById("ie-ma-thr-lbl").textContent = e.target.value;
});
document.getElementById("ie-ma-inv")?.addEventListener("change", e => { t.invert = e.target.checked; });
document.getElementById("ie-ma-extract")?.addEventListener("click", () => {
const activeLayer = this._layerMgr?.activeLayer;
if (!activeLayer || activeLayer.type !== "mask") { this._toast(window.t("image.msg.selectMaskFirst"), "info"); return; }
const bgCv = this._buildBgCanvas();
this._maskAlphaTool.setCanvas(activeLayer.canvas);
this._maskAlphaTool.setSourceImage(bgCv);
this._saveUndo();
this._maskAlphaTool.extract();
this._updateCompositeView();
this._refreshLayerList();
});
} else if (sub === "text" && this._maskTextTool) {
const t = this._maskTextTool;
body.innerHTML = `
<div class="ie-props-row">
<label>Mode</label>
<div style="display:flex;gap:4px;">
<button class="it-btn it-btn-sm${t.mode === "add" ? " ie-opt-active" : ""}" id="ie-mt-add" style="flex:1;">Add</button>
<button class="it-btn it-btn-sm${t.mode === "erase" ? " ie-opt-active" : ""}" id="ie-mt-erase" style="flex:1;">Erase</button>
</div>
</div>
<div class="ie-props-row">
<label>Font</label>
<select id="ie-mt-font" class="ie-opt-select" style="width:100%;font-size:11px;">
${MASK_TEXT_FONTS.map(f => `<option value="${f}"${t.fontFamily === f ? " selected" : ""}>${f}</option>`).join("")}
</select>
</div>
<div class="ie-props-row">
<label>Size</label>
<input type="number" id="ie-mt-size" value="${t.fontSize}" min="6" max="500" class="ie-opt-input" style="width:60px;">
</div>
<div class="ie-props-row" style="flex-direction:row;align-items:center;gap:6px;">
<button class="it-btn it-btn-sm${t.bold ? " ie-opt-active" : ""}" id="ie-mt-bold" style="min-width:30px;"><b>B</b></button>
<button class="it-btn it-btn-sm${t.italic ? " ie-opt-active" : ""}" id="ie-mt-italic" style="min-width:30px;"><i>I</i></button>
<select id="ie-mt-align" class="ie-opt-select" style="flex:1;font-size:11px;">
${["left","center","right"].map(a => `<option value="${a}"${t.align === a ? " selected" : ""}>${a.charAt(0).toUpperCase()+a.slice(1)}</option>`).join("")}
</select>
</div>
<div style="font-size:10px;color:var(--it-text-secondary);margin-top:2px;">Click on canvas to stamp text</div>
`;
document.getElementById("ie-mt-add")?.addEventListener("click", () => { t.mode = "add"; this._renderMaskProps("text"); });
document.getElementById("ie-mt-erase")?.addEventListener("click", () => { t.mode = "erase"; this._renderMaskProps("text"); });
document.getElementById("ie-mt-font")?.addEventListener("change", e => { t.fontFamily = e.target.value; });
document.getElementById("ie-mt-size")?.addEventListener("input", e => { t.fontSize = parseInt(e.target.value) || 64; });
document.getElementById("ie-mt-bold")?.addEventListener("click", () => { t.bold = !t.bold; this._renderMaskProps("text"); });
document.getElementById("ie-mt-italic")?.addEventListener("click", () => { t.italic = !t.italic; this._renderMaskProps("text"); });
document.getElementById("ie-mt-align")?.addEventListener("change", e => { t.align = e.target.value; });
} else if (sub === "vector" && this._maskVectorTool) {
const t = this._maskVectorTool;
const pts = t._points ?? [];
body.innerHTML = `
<div class="ie-props-row">
<label>Mode</label>
<div style="display:flex;gap:4px;">
<button class="it-btn it-btn-sm${t.mode === "add" ? " ie-opt-active" : ""}" id="ie-mv-add" style="flex:1;">Add</button>
<button class="it-btn it-btn-sm${t.mode === "erase" ? " ie-opt-active" : ""}" id="ie-mv-erase" style="flex:1;">Erase</button>
</div>
</div>
<div class="ie-props-row" style="flex-direction:row;align-items:center;justify-content:space-between;">
<span style="color:var(--it-text-secondary);">Points: ${pts.length}</span>
<button class="it-btn it-btn-sm" id="ie-mv-reset">Reset</button>
</div>
<div style="font-size:10px;color:var(--it-text-secondary);margin-top:2px;line-height:1.5;">
Click: add point<br>
Click 1st point: close<br>
Enter: close open path<br>
Backspace: remove last<br>
Esc: reset
</div>
`;
document.getElementById("ie-mv-add")?.addEventListener("click", () => { t.mode = "add"; this._renderMaskProps("vector"); });
document.getElementById("ie-mv-erase")?.addEventListener("click", () => { t.mode = "erase"; this._renderMaskProps("vector"); });
document.getElementById("ie-mv-reset")?.addEventListener("click", () => { t.reset(); this._renderMaskProps("vector"); });
} else if (sub === "shape" && this._maskShapeTool) {
const t = this._maskShapeTool;
body.innerHTML = `
<div class="ie-props-row">
<label>Mode</label>
<div style="display:flex;gap:4px;">
<button class="it-btn it-btn-sm${t.mode === "add" ? " ie-opt-active" : ""}" id="ie-ms-add" style="flex:1;">Add</button>
<button class="it-btn it-btn-sm${t.mode === "erase" ? " ie-opt-active" : ""}" id="ie-ms-erase" style="flex:1;">Erase</button>
</div>
</div>
<div class="ie-props-row">
<label>Shape</label>
<div style="display:flex;gap:4px;">
<button class="it-btn it-btn-sm${t.shape === "rect" ? " ie-opt-active" : ""}" id="ie-ms-rect" style="flex:1;">Rect</button>
<button class="it-btn it-btn-sm${t.shape === "ellipse" ? " ie-opt-active" : ""}" id="ie-ms-ellipse" style="flex:1;">Ellipse</button>
</div>
</div>
<div style="font-size:10px;color:var(--it-text-secondary);margin-top:2px;">Shift: square / circle</div>
`;
document.getElementById("ie-ms-add")?.addEventListener("click", () => { t.mode = "add"; this._renderMaskProps("shape"); });
document.getElementById("ie-ms-erase")?.addEventListener("click", () => { t.mode = "erase"; this._renderMaskProps("shape"); });
document.getElementById("ie-ms-rect")?.addEventListener("click", () => { t.shape = "rect"; this._renderMaskProps("shape"); });
document.getElementById("ie-ms-ellipse")?.addEventListener("click", () => { t.shape = "ellipse"; this._renderMaskProps("shape"); });
} else {
body.innerHTML = `<span style="font-size:11px;color:var(--it-text-secondary);">No options</span>`;
}
}
// 調整レイヤーのプロパティ(選択中に呼ばれる。ツールバーとは独立)
_renderAdjustProps(layer) {
const pane = document.getElementById("ie-props-pane");
const body = document.getElementById("ie-props-body");
const title = document.getElementById("ie-props-title");
const def = ADJ_DEFS[layer.adjType];
if (!pane || !body || !def) return;
pane.style.display = "flex";
if (title) title.textContent = def.label;
body.innerHTML = `
<div class="ie-props-row">
<label>${def.label}</label>
<input type="range" id="ie-adj-value" min="${def.min}" max="${def.max}" step="${def.step}" value="${layer.adjValue}">
<span id="ie-adj-value-lbl">${layer.adjValue}</span>
</div>
`;
document.getElementById("ie-adj-value")?.addEventListener("input", e => {
layer.adjValue = parseFloat(e.target.value);
document.getElementById("ie-adj-value-lbl").textContent = e.target.value;
this._updateCompositeView();
});
}
_renderDrawProps() {
const pane = document.getElementById("ie-props-pane");
const body = document.getElementById("ie-props-body");
const title = document.getElementById("ie-props-title");
if (!pane || !body) return;
pane.style.display = "flex";
if (title) title.textContent = "Draw";
const t = this._drawTool;
body.innerHTML = `
<div class="ie-props-row">
<label>Mode</label>
<div style="display:flex;gap:4px;">
<button class="it-btn it-btn-sm${t.mode === "draw" ? " ie-opt-active" : ""}" id="ie-draw-mode-draw" title="Draw (D)" style="flex:1;">Draw</button>
<button class="it-btn it-btn-sm${t.mode === "erase" ? " ie-opt-active" : ""}" id="ie-draw-mode-erase" title="Erase (E)" style="flex:1;">Erase</button>
</div>
</div>
<div class="ie-props-row">
<label>Color</label>
<input type="color" id="ie-draw-color" value="${t.color}"
style="width:36px;height:24px;padding:0;border:1px solid var(--it-border);cursor:pointer;border-radius:3px;flex-shrink:0;">
<button class="it-btn it-btn-sm${this._eyedropperActive ? " ie-opt-active" : ""}" id="ie-draw-eyedropper"
title="Eyedropper" style="flex-shrink:0;padding:2px 6px;display:flex;align-items:center;justify-content:center;">${EYEDROPPER_ICON_SVG}</button>
</div>
<div class="ie-props-row">
<label title="[ / ]">Size</label>
<input type="range" id="ie-draw-size" min="1" max="200" value="${t.brushSize}" title="[ / ]">
<span id="ie-draw-size-lbl">${t.brushSize}px</span>
</div>
<div class="ie-props-row">
<label>Hardness</label>
<input type="range" id="ie-draw-hard" min="0" max="100" value="${Math.round(t.hardness * 100)}">
<span id="ie-draw-hard-lbl">${Math.round(t.hardness * 100)}%</span>
</div>
<div class="ie-props-row">
<label>Opacity</label>
<input type="range" id="ie-draw-opacity" min="1" max="100" value="${Math.round(t.opacity * 100)}">
<span id="ie-draw-opacity-lbl">${Math.round(t.opacity * 100)}%</span>
</div>
<div style="margin:6px 0 4px;border-top:1px solid var(--it-border);padding-top:6px;font-size:10px;color:${this._abrAvailable ? "var(--it-success)" : "var(--it-text-secondary)"};letter-spacing:0.05em;"
title="${this._abrAvailable ? "Mask Editor One: brushes available" : "Mask Editor One: no brushes imported yet"}">
MASK EDITOR ONE (COLOR)
</div>
<div class="ie-props-row">
<label>Brush</label>
<span style="font-size:11px;color:var(--it-text-secondary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
title="${t.brushName ?? "Circle"}">${t.brushName ?? "Circle"}</span>
<button class="it-btn it-btn-sm" id="ie-draw-select-brush" title="B" style="font-size:10px;padding:1px 6px;flex-shrink:0;" ${this._abrAvailable ? "" : "disabled"}>Select</button>
${t.brushImage ? `<button class="it-btn it-btn-sm" id="ie-draw-clear-brush" style="font-size:10px;padding:1px 5px;flex-shrink:0;">✕</button>` : ""}
</div>
${t.brushImage ? `
<div class="ie-props-row">
<label>Spacing</label>
<input type="range" id="ie-draw-spacing" min="5" max="100" value="${Math.round((t.spacing ?? 0.25) * 100)}">
<span id="ie-draw-spacing-lbl">${Math.round((t.spacing ?? 0.25) * 100)}%</span>
</div>
<div class="ie-props-row">
<label>Angle</label>
<input type="range" id="ie-draw-angle" min="0" max="359" value="${t.angle ?? 0}">
<span id="ie-draw-angle-lbl">${t.angle ?? 0}°</span>
</div>
<div class="ie-props-row">
<label>Sz Jitter</label>
<input type="range" id="ie-draw-szjitter" min="0" max="100" value="${Math.round((t.sizeJitterAmount ?? 0.5) * 100)}">
<span id="ie-draw-szjitter-lbl">${Math.round((t.sizeJitterAmount ?? 0.5) * 100)}%</span>
</div>
<div class="ie-props-row">
<label>Rot. Jitter</label>
<label style="cursor:pointer;font-size:11px;">
<input type="checkbox" id="ie-draw-rotjitter" ${t.rotationJitter ? "checked" : ""}> On
</label>
</div>` : ""}
`;
document.getElementById("ie-draw-mode-draw")?.addEventListener("click", () => this._setDrawMode("draw"));
document.getElementById("ie-draw-mode-erase")?.addEventListener("click", () => this._setDrawMode("erase"));
document.getElementById("ie-draw-color")?.addEventListener("input", e => {
this._setDrawColor(e.target.value);
this._drawPalette.syncColor(e.target.value);
});
document.getElementById("ie-draw-eyedropper")?.addEventListener("click", () => {
this._setEyedropperActive(!this._eyedropperActive);
});
document.getElementById("ie-draw-size")?.addEventListener("input", e => {
this._setDrawBrushSize(parseInt(e.target.value));
});
document.getElementById("ie-draw-hard")?.addEventListener("input", e => {
this._drawTool.hardness = parseInt(e.target.value) / 100;
document.getElementById("ie-draw-hard-lbl").textContent = e.target.value + "%";
this._drawTool._stamp = null;
});
document.getElementById("ie-draw-opacity")?.addEventListener("input", e => {
this._drawTool.opacity = parseInt(e.target.value) / 100;
document.getElementById("ie-draw-opacity-lbl").textContent = e.target.value + "%";
});
document.getElementById("ie-draw-select-brush")?.addEventListener("click", () => this._openAbrBrushPickerForDraw());
document.getElementById("ie-draw-clear-brush")?.addEventListener("click", () => {
this._drawTool?.clearImageBrush();
this._renderDrawProps();
});
document.getElementById("ie-draw-spacing")?.addEventListener("input", e => {
this._drawTool.spacing = parseInt(e.target.value) / 100;
document.getElementById("ie-draw-spacing-lbl").textContent = e.target.value + "%";
});
document.getElementById("ie-draw-angle")?.addEventListener("input", e => {
this._drawTool.angle = parseInt(e.target.value);
document.getElementById("ie-draw-angle-lbl").textContent = e.target.value + "°";
});
document.getElementById("ie-draw-szjitter")?.addEventListener("input", e => {
this._drawTool.sizeJitterAmount = parseInt(e.target.value) / 100;
this._drawTool.sizeJitter = this._drawTool.sizeJitterAmount > 0;
document.getElementById("ie-draw-szjitter-lbl").textContent = e.target.value + "%";
});
document.getElementById("ie-draw-rotjitter")?.addEventListener("change", e => {
this._drawTool.rotationJitter = e.target.checked;
});
}
// ── Fill Tool (bucket fill) props ─────────────────────────────────────
_renderFillProps() {
const pane = document.getElementById("ie-props-pane");
const body = document.getElementById("ie-props-body");
const title = document.getElementById("ie-props-title");
if (!pane || !body) return;
pane.style.display = "flex";
if (title) title.textContent = "Fill";
const t = this._fillTool;
const isGradient = t.fillMode === "gradient";
body.innerHTML = `
<div class="ie-props-row">
<label>Mode</label>
<div style="display:flex;gap:4px;">
<button class="it-btn it-btn-sm${!isGradient ? " ie-opt-active" : ""}" id="ie-fill-mode-solid" style="flex:1;">Solid</button>
<button class="it-btn it-btn-sm${isGradient ? " ie-opt-active" : ""}" id="ie-fill-mode-gradient" style="flex:1;">Gradient</button>
</div>
</div>
${!isGradient ? `
<div class="ie-props-row">
<label>Color</label>
<input type="color" id="ie-fill-color" value="${t.color}"
style="width:36px;height:24px;padding:0;border:1px solid var(--it-border);cursor:pointer;border-radius:3px;flex-shrink:0;">
</div>` : `
<div class="ie-props-row">
<label>Shape</label>
<div style="display:flex;gap:4px;">
<button class="it-btn it-btn-sm${t.gradientShape === "linear" ? " ie-opt-active" : ""}" id="ie-fill-shape-linear" style="flex:1;">Linear</button>
<button class="it-btn it-btn-sm${t.gradientShape === "radial" ? " ie-opt-active" : ""}" id="ie-fill-shape-radial" style="flex:1;">Radial</button>
</div>
</div>
<div class="ie-props-row" style="align-items:flex-start;">
<label>Ramp</label>
<div style="display:flex;flex-direction:column;gap:10px;flex:1;min-width:0;">
<canvas id="ie-fill-ramp" width="130" height="24" style="display:block;width:130px;height:24px;border:1px solid var(--it-border);border-radius:3px;cursor:pointer;"></canvas>
<div style="display:flex;align-items:center;gap:4px;">
<input type="color" id="ie-fill-stop-color" value="${t.gradientStops[t.selectedStopIdx]?.color ?? "#ffffff"}"
style="width:28px;height:22px;padding:0;border:1px solid var(--it-border);cursor:pointer;border-radius:3px;">
<button class="it-btn it-btn-sm" id="ie-fill-stop-add" title="Add stop">+</button>
<button class="it-btn it-btn-sm" id="ie-fill-stop-remove" title="Remove stop">-</button>
</div>
</div>
</div>
<div class="ie-props-row" style="align-items:flex-start;">
<label>Direction</label>
<div style="display:flex;align-items:center;gap:6px;">
<canvas id="ie-fill-dir" width="70" height="70" style="width:70px;height:70px;border:1px solid var(--it-border);border-radius:3px;cursor:crosshair;"></canvas>
<span id="ie-fill-strength-lbl" style="font-size:11px;color:var(--it-text-secondary);">${t.gradientStrength.toFixed(1)}</span>
</div>
</div>`}
<div class="ie-props-row">
<label>Tolerance</label>
<input type="range" id="ie-fill-tolerance" min="0" max="255" value="${t.tolerance}">
<span id="ie-fill-tolerance-lbl">${t.tolerance}</span>
</div>
<div class="ie-props-row">
<label>Opacity</label>
<input type="range" id="ie-fill-opacity" min="1" max="100" value="${Math.round(t.opacity * 100)}">
<span id="ie-fill-opacity-lbl">${Math.round(t.opacity * 100)}%</span>
</div>
`;
document.getElementById("ie-fill-mode-solid")?.addEventListener("click", () => {
t.fillMode = "solid";
this._renderFillProps();
});
document.getElementById("ie-fill-mode-gradient")?.addEventListener("click", () => {
t.fillMode = "gradient";
this._renderFillProps();
});
document.getElementById("ie-fill-color")?.addEventListener("input", e => { t.color = e.target.value; });
document.getElementById("ie-fill-tolerance")?.addEventListener("input", e => {
t.tolerance = parseInt(e.target.value);
document.getElementById("ie-fill-tolerance-lbl").textContent = e.target.value;
});
document.getElementById("ie-fill-opacity")?.addEventListener("input", e => {
t.opacity = parseInt(e.target.value) / 100;
document.getElementById("ie-fill-opacity-lbl").textContent = e.target.value + "%";
});
if (isGradient) {
document.getElementById("ie-fill-shape-linear")?.addEventListener("click", () => {
t.gradientShape = "linear";
this._renderFillProps();
});
document.getElementById("ie-fill-shape-radial")?.addEventListener("click", () => {
t.gradientShape = "radial";
this._renderFillProps();
});
document.getElementById("ie-fill-stop-color")?.addEventListener("input", e => {
t.gradientStops[t.selectedStopIdx].color = e.target.value;
this._drawFillGradientRamp();
});
document.getElementById("ie-fill-stop-add")?.addEventListener("click", () => {
t.addStop();
this._renderFillProps();
});
document.getElementById("ie-fill-stop-remove")?.addEventListener("click", () => {
t.removeStop();
this._renderFillProps();
});
this._setupFillGradientRamp();
this._setupFillGradientDir();
}
}
/** カラーランプ(複数ストップの線形プレビュー)の描画とドラッグ操作。particle_widget.jsのカラーランプUIを移植。 */
_drawFillGradientRamp() {
const canvas = document.getElementById("ie-fill-ramp");
if (!canvas) return;
const ctx = canvas.getContext("2d");
const w = canvas.width, h = canvas.height;
const t = this._fillTool;
ctx.clearRect(0, 0, w, h);
const barH = h - 10; // 下部にハンドル用の余白を確保
const grad = ctx.createLinearGradient(0, 0, w, 0);
[...t.gradientStops].sort((a, b) => a.pos - b.pos).forEach(s => grad.addColorStop(s.pos, s.color));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, barH);
ctx.strokeStyle = "#666";
ctx.strokeRect(0.5, 0.5, w - 1, barH - 1);
t.gradientStops.forEach((s, i) => {
// ハンドル(三角形)がcanvas端で切れて見えなくならないようクランプする
const x = Math.max(5, Math.min(w - 5, s.pos * w));
ctx.beginPath();
ctx.moveTo(x, h);
ctx.lineTo(x - 5, barH);
ctx.lineTo(x + 5, barH);
ctx.closePath();
ctx.fillStyle = i === t.selectedStopIdx ? "#0077ff" : "#999";
ctx.fill();
});
}
_setupFillGradientRamp() {
const canvas = document.getElementById("ie-fill-ramp");
if (!canvas) return;
this._drawFillGradientRamp();
canvas.style.touchAction = "none";
const t = this._fillTool;
const hitTestStop = mx => {
let best = -1, bestDist = 9;
t.gradientStops.forEach((s, i) => {
const d = Math.abs(s.pos * canvas.width - mx);
if (d < bestDist) { bestDist = d; best = i; }
});
return best;
};
canvas.addEventListener("pointerdown", e => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
const idx = hitTestStop(mx);
if (idx < 0) return;
canvas.setPointerCapture(e.pointerId);
t.selectStop(idx);
// ドラッグ中onMove/onUpが参照し続けるcanvas要素が入れ替わらないよう、
// DOM全体を再構築する_renderFillProps()は使わず、選択状態の見た目だけを部分更新する
this._drawFillGradientRamp();
const colorInput = document.getElementById("ie-fill-stop-color");
if (colorInput) colorInput.value = t.gradientStops[idx].color;
const onMove = ev => {
const r = canvas.getBoundingClientRect();
const x = (ev.clientX - r.left) * (canvas.width / r.width);
t.gradientStops[t.selectedStopIdx].pos = Math.max(0, Math.min(1, x / canvas.width));
this._drawFillGradientRamp();
};
const onUp = () => {
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerup", onUp);
canvas.removeEventListener("pointercancel", onUp);
};
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointercancel", onUp);
});
}
/** シェイプツール(rect/ellipse)の塗りグラデーションランプ描画。_drawFillGradientRampと同型 */
_drawShapeGradRamp() {
const canvas = document.getElementById("ie-shape-grad-ramp");
if (!canvas) return;
const ctx = canvas.getContext("2d");
const w = canvas.width, h = canvas.height;
const t = this._shapeTool;
ctx.clearRect(0, 0, w, h);
const barH = h - 8;
const grad = ctx.createLinearGradient(0, 0, w, 0);
[...t.fillGradient.stops].sort((a, b) => a.pos - b.pos).forEach(s => grad.addColorStop(Math.max(0, Math.min(1, s.pos)), s.color));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, barH);
ctx.strokeStyle = "#666";
ctx.strokeRect(0.5, 0.5, w - 1, barH - 1);
t.fillGradient.stops.forEach((s, i) => {
const x = Math.max(5, Math.min(w - 5, s.pos * w));
ctx.beginPath();
ctx.moveTo(x, h);
ctx.lineTo(x - 5, barH);
ctx.lineTo(x + 5, barH);
ctx.closePath();
ctx.fillStyle = i === t.selectedFillStopIdx ? "#0077ff" : "#999";
ctx.fill();
});
}
_setupShapeGradRamp() {
const canvas = document.getElementById("ie-shape-grad-ramp");
if (!canvas) return;
this._drawShapeGradRamp();
canvas.style.touchAction = "none";
const t = this._shapeTool;
const hitTestStop = mx => {
let best = -1, bestDist = 9;
t.fillGradient.stops.forEach((s, i) => {
const d = Math.abs(s.pos * canvas.width - mx);
if (d < bestDist) { bestDist = d; best = i; }
});
return best;
};
canvas.addEventListener("pointerdown", e => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
const idx = hitTestStop(mx);
if (idx < 0) return;
canvas.setPointerCapture(e.pointerId);
t.selectedFillStopIdx = idx;
this._drawShapeGradRamp();
const colorInput = document.getElementById("ie-shape-grad-stop-color");
if (colorInput) colorInput.value = t.fillGradient.stops[idx].color;
const onMove = ev => {
const r = canvas.getBoundingClientRect();
const x = (ev.clientX - r.left) * (canvas.width / r.width);
t.fillGradient.stops[t.selectedFillStopIdx].pos = Math.max(0, Math.min(1, x / canvas.width));
this._drawShapeGradRamp();
};
const onUp = () => {
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerup", onUp);
canvas.removeEventListener("pointercancel", onUp);
};
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointercancel", onUp);
});
}
/** グラデーション方向(linear)/半径(radial)を指定するミニコンパスUI。particle_widget.jsの矢印方向ハンドルを移植。 */
_drawFillGradientDir() {
const canvas = document.getElementById("ie-fill-dir");
if (!canvas) return;
const ctx = canvas.getContext("2d");
const w = canvas.width, h = canvas.height, cx = w / 2, cy = h / 2;
const maxR = Math.min(w, h) / 2 - 6;
const t = this._fillTool;
const distFrac = (Math.max(0.2, Math.min(3.0, t.gradientStrength)) - 0.2) / 2.8;
const dist = 6 + distFrac * (maxR - 6);
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = "#888";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx - 4, cy); ctx.lineTo(cx + 4, cy);
ctx.moveTo(cx, cy - 4); ctx.lineTo(cx, cy + 4);
ctx.stroke();
ctx.setLineDash([3, 3]);
ctx.strokeStyle = "#0077ff";
if (t.gradientShape === "radial") {
ctx.beginPath();
ctx.arc(cx, cy, dist, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
this._fillDirHandlePos = { x: cx + dist, y: cy };
} else {
const rad = t.gradientAngleDeg * Math.PI / 180;
const ex = cx + Math.cos(rad) * dist, ey = cy + Math.sin(rad) * dist;
ctx.beginPath();
ctx.moveTo(cx, cy); ctx.lineTo(ex, ey);
ctx.stroke();
ctx.setLineDash([]);
this._fillDirHandlePos = { x: ex, y: ey };
}
ctx.beginPath();
ctx.arc(this._fillDirHandlePos.x, this._fillDirHandlePos.y, 4, 0, Math.PI * 2);
ctx.fillStyle = "#ffcc00";
ctx.fill();
}
_setupFillGradientDir() {
const canvas = document.getElementById("ie-fill-dir");
if (!canvas) return;
this._drawFillGradientDir();
canvas.style.touchAction = "none";
const t = this._fillTool;
const w = canvas.width, h = canvas.height, cx = w / 2, cy = h / 2;
const maxR = Math.min(w, h) / 2 - 6;
const applyDrag = (mx, my) => {
const dx = mx - cx, dy = my - cy;
const dist = Math.max(6, Math.min(maxR, Math.hypot(dx, dy)));
t.gradientStrength = 0.2 + ((dist - 6) / (maxR - 6)) * 2.8;
if (t.gradientShape === "linear") t.gradientAngleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
this._drawFillGradientDir();
const lbl = document.getElementById("ie-fill-strength-lbl");
if (lbl) lbl.textContent = t.gradientStrength.toFixed(1);
};
canvas.addEventListener("pointerdown", e => {
canvas.setPointerCapture(e.pointerId);
const rect = canvas.getBoundingClientRect();
applyDrag((e.clientX - rect.left) * (w / rect.width), (e.clientY - rect.top) * (h / rect.height));
const onMove = ev => {
const r = canvas.getBoundingClientRect();
applyDrag((ev.clientX - r.left) * (w / r.width), (ev.clientY - r.top) * (h / r.height));
};
const onUp = () => {
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerup", onUp);
canvas.removeEventListener("pointercancel", onUp);
};
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointercancel", onUp);
});
}
// ── Mask Editor One 追加ツール: 初期化・切り替え ──────────────────────────
_initMaskEditorOneTools() {
if (this._maskColorTool) return; // 初期化済み
const overlayCanvas = document.getElementById("ie-canvas-overlay");
const onChange = () => { this._updateCompositeView(); this._refreshLayerList(); };
this._maskColorTool = new MaskColorTool();
this._maskColorTool.onChange(onChange);
this._maskAlphaTool = new MaskAlphaTool();
this._maskAlphaTool.onChange(onChange);
this._maskTextTool = new MaskTextTool();
this._maskTextTool.onChange(onChange);
this._maskVectorTool = new MaskVectorTool();
this._maskVectorTool.setPreviewCanvas(overlayCanvas);
this._maskVectorTool.onBeforeCommit = () => this._saveUndo();
this._maskVectorTool.onChange(() => { onChange(); this._renderMaskProps("vector"); });
this._maskShapeTool = new MaskShapeTool();
this._maskShapeTool.setPreviewCanvas(overlayCanvas);
this._maskShapeTool.onChange(onChange);
}
_deactivateMaskSubtool() {
const sub = this._maskSubtool;
if (sub === "paint") this._maskTool?.deactivate();
if (sub === "color") this._maskColorTool?.deactivate();
if (sub === "alpha") this._maskAlphaTool?.deactivate();
if (sub === "text") this._maskTextTool?.deactivate();
if (sub === "vector") this._maskVectorTool?.deactivate();
if (sub === "shape") this._maskShapeTool?.deactivate();
}
_activateMaskSubtool() {
const sub = this._maskSubtool;
const activeLayer = this._layerMgr?.activeLayer;
const canvas = activeLayer?.type === "mask" ? activeLayer.canvas : null;
if (sub === "paint" && this._maskTool) {
if (canvas) this._maskTool.setCanvas(canvas);
this._maskTool.activate();
} else if (sub === "color" && this._maskColorTool) {
if (canvas) this._maskColorTool.setCanvas(canvas);
this._maskColorTool.activate();
} else if (sub === "alpha" && this._maskAlphaTool) {
if (canvas) this._maskAlphaTool.setCanvas(canvas);
this._maskAlphaTool.activate();
} else if (sub === "text" && this._maskTextTool) {
if (canvas) this._maskTextTool.setCanvas(canvas);
this._maskTextTool.activate();
} else if (sub === "vector" && this._maskVectorTool) {
if (canvas) this._maskVectorTool.setCanvas(canvas);
this._maskVectorTool.activate();
} else if (sub === "shape" && this._maskShapeTool) {
if (canvas) this._maskShapeTool.setCanvas(canvas);
this._maskShapeTool.activate();
}
}
_switchMaskSubtool(sub) {
this._deactivateMaskSubtool();
this._maskSubtool = sub;
this._renderToolOptions("mask");
this._activateMaskSubtool();
}
// マスクを除いた全表示レイヤーを合成した背景キャンバスを生成(ColorTool/AlphaTool 用)
_buildBgCanvas() {
const canvas = document.createElement("canvas");
canvas.width = this._canvasW;
canvas.height = this._canvasH;
if (!this._layerMgr) return canvas;
const ctx = canvas.getContext("2d");
const layers = this._layerMgr.layers;
for (let i = layers.length - 1; i >= 0; i--) {
const layer = layers[i];
if (!layer.visible || layer.type === "mask") continue;
ctx.save();
ctx.globalAlpha = layer.opacity;
ctx.globalCompositeOperation = layer.blendMode;
Layer.applyTransform(ctx, layer);
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
ctx.restore();
}
return canvas;
}
// Inpaint用: マスクレイヤー(透過背景+白ペイント)を黒背景に合成し、白=インペイント対象/
// 黒=維持の不透明なグレースケールPNGとして書き出す(Workflow Studioの同名メソッドと同じ規約)
_exportMaskCanvas(maskLayer) {
const w = maskLayer.canvas.width, h = maskLayer.canvas.height;
const canvas = document.createElement("canvas");
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, w, h);
ctx.drawImage(maskLayer.canvas, 0, 0);
return canvas;
}
// Maskツールの Inpaint サブツールから呼ばれるRunハンドラ。合成画像とマスクを
// Workflow Studio(iframe)に送信し、実行結果を新規レイヤーとして追加する
async _runInpaint() {
if (this._inpaintRunning) return;
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return; }
// 非表示のマスクレイヤーはInpaint対象から除外する(目玉アイコンOFF=対象外)
const maskLayer = (this._layerMgr.activeLayer?.type === "mask" && this._layerMgr.activeLayer.visible)
? this._layerMgr.activeLayer
: this._layerMgr.layers.find(l => l.type === "mask" && l.visible);
if (!maskLayer) { this._toast(window.t("image.msg.createMaskFirst"), "info"); return; }
const runBtn = document.getElementById("ie-inpaint-run-btn");
const statusEl = document.getElementById("ie-inpaint-status");
const setStatus = (msg) => { if (statusEl) statusEl.textContent = msg; };
this._inpaintRunning = true;
if (runBtn) { runBtn.disabled = true; runBtn.textContent = "Running..."; }
setStatus("Compositing...");
try {
const bgCanvas = this._buildCompositeCanvas();
const imageBlob = await new Promise((resolve) => bgCanvas.toBlob(resolve, "image/png"));
const maskCanvas = this._exportMaskCanvas(maskLayer);
const maskBlob = await new Promise((resolve) => maskCanvas.toBlob(resolve, "image/png"));
setStatus("Generating...");
const result = await sendInpaintToWorkflowStudio(imageBlob, maskBlob, {
positive: this._inpaintPositive,
negative: this._inpaintNegative,
growMaskBy: this._inpaintGrowMaskBy,
denoise: this._inpaintDenoise,
});
if (!result?.ok) throw new Error(result?.message || "Inpaint failed");
setStatus("Done");
await this._loadFromDataUrl(result.url, "Inpaint Result");
this._toast(window.t("image.msg.inpaintDone"), "success");
} catch (err) {
setStatus("Error");
this._toast(window.t("image.msg.inpaintFailed", err.message), "error");
} finally {
this._inpaintRunning = false;
if (this._activeTool === "mask" && this._maskSubtool === "inpaint") this._renderMaskProps("inpaint");
}
}
/**
* Outpaint用の拡張後サイズ・余白を計算する。VAEEncodeForInpaint→KSampler→VAEDecodeの
* 往復はVAEの8倍ダウンサンプリング+UNet内部の複数段ダウンサンプリングの関係で、
* pixel寸法が64の倍数でないと、エンコード時に切り詰められた(8や64の倍数に丸められた)
* サイズでデコードされて返ってくることがある。この場合、結果画像が拡張後キャンバスより
* わずかに小さくなり、_loadFromDataUrl()のアスペクト比保持センタリングにより
* 上下(または左右)に透過の隙間が生じる(実機で発覚。ユーザー報告により64/8倍数との
* 関連を調査し確認)。これを避けるため、実際に拡張する側の辺(ユーザーが0を指定した辺は
* 変更しない)へ必要な分だけ追加し、新キャンバスサイズを64の倍数に丸める。
*/
_outpaintAdjustedPad(pad) {
const origW = this._canvasW, origH = this._canvasH;
let { top, right, bottom, left } = pad;
const roundUp64 = (n) => Math.ceil(n / 64) * 64;
if (left > 0 || right > 0) {
const extraW = roundUp64(origW + left + right) - (origW + left + right);
if (extraW > 0) { if (right > 0) right += extraW; else left += extraW; }
}
if (top > 0 || bottom > 0) {
const extraH = roundUp64(origH + top + bottom) - (origH + top + bottom);
if (extraH > 0) { if (bottom > 0) bottom += extraH; else top += extraH; }
}
return { top, right, bottom, left, newW: origW + left + right, newH: origH + top + bottom };
}
// Maskツールの Outpaint サブツールから呼ばれるRunハンドラ。サイズ拡張(余白追加+
// 塗り方選択)とマスク作成(余白部分=白=生成対象、元画像部分=黒=維持)を1回のRunで
// 内部的に行い、拡張後の画像とマスクをInpaintと同じWorkflow Studio連携で送信する。
// 成功後にキャンバスサイズを拡大・既存レイヤーをシフトし、結果を新規レイヤーとして追加する
// (失敗時はキャンバスに何も変更を加えない)。
async _runOutpaint() {
if (this._outpaintRunning) return;
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return; }
const rawPad = this._outpaintPad;
if (rawPad.top === 0 && rawPad.right === 0 && rawPad.bottom === 0 && rawPad.left === 0) {
this._toast(window.t("image.msg.setPaddingFirst"), "info");
return;
}
const { top, right, bottom, left, newW, newH } = this._outpaintAdjustedPad(rawPad);
const runBtn = document.getElementById("ie-outpaint-run-btn");
const statusEl = document.getElementById("ie-outpaint-status");
const setStatus = (msg) => { if (statusEl) statusEl.textContent = msg; };
this._outpaintRunning = true;
if (runBtn) { runBtn.disabled = true; runBtn.textContent = "Running..."; }
setStatus("Compositing...");
try {
const origW = this._canvasW, origH = this._canvasH;
const srcComposite = this._buildCompositeCanvas();
const paddedCanvas = document.createElement("canvas");
paddedCanvas.width = newW; paddedCanvas.height = newH;
const pctx = paddedCanvas.getContext("2d");
if (this._outpaintFillMode === "extend") {
pctx.drawImage(this._edgeExtendFillCanvas(srcComposite, { top, right, bottom, left }), 0, 0);
} else {
if (this._outpaintFillMode !== "transparent") {
pctx.fillStyle = this._outpaintFillMode === "white" ? "#ffffff" : "#000000";
pctx.fillRect(0, 0, newW, newH);
}
pctx.drawImage(srcComposite, left, top);
}
const imageBlob = await new Promise((resolve) => paddedCanvas.toBlob(resolve, "image/png"));
// マスク: 白=生成対象(追加した余白部分)、黒=維持(元画像の範囲)
const maskCanvas = document.createElement("canvas");
maskCanvas.width = newW; maskCanvas.height = newH;
const mctx = maskCanvas.getContext("2d");
mctx.fillStyle = "#fff";
mctx.fillRect(0, 0, newW, newH);
mctx.fillStyle = "#000";
mctx.fillRect(left, top, origW, origH);
const maskBlob = await new Promise((resolve) => maskCanvas.toBlob(resolve, "image/png"));
setStatus("Generating...");
const result = await sendOutpaintToWorkflowStudio(imageBlob, maskBlob, {
positive: this._outpaintPositive,
negative: this._outpaintNegative,
growMaskBy: this._outpaintGrowMaskBy,
denoise: this._outpaintDenoise,
});
if (!result?.ok) throw new Error(result?.message || "Outpaint failed");
setStatus("Done");
this._saveUndo();
this._layerMgr.layers.forEach(layer => {
layer.x += left;
layer.y += top;
});
this._canvasW = newW;
this._canvasH = newH;
this._layerMgr.width = newW;
this._layerMgr.height = newH;
this._resizeCanvasElements(newW, newH);
await this._loadFromDataUrl(result.url, "Outpaint Result");
if (typeof this._fitToView === "function") this._fitToView();
this._toast(window.t("image.msg.outpaintDone"), "success");
} catch (err) {
setStatus("Error");
this._toast(window.t("image.msg.outpaintFailed", err.message), "error");
} finally {
this._outpaintRunning = false;
if (this._activeTool === "mask" && this._maskSubtool === "outpaint") this._renderMaskProps("outpaint");
}
}
_renderMaskLayerOverlay(ctx, maskLayer) {
const overlayColor = this._maskOverlayColor;
const blurPx = this._maskBlur;
const inverted = this._maskInverted;
const mw = maskLayer.canvas.width;
const mh = maskLayer.canvas.height;
const tmp = document.createElement("canvas");
tmp.width = mw;
tmp.height = mh;
const tc = tmp.getContext("2d");
tc.fillStyle = overlayColor;
tc.fillRect(0, 0, mw, mh);
tc.globalCompositeOperation = inverted ? "destination-out" : "destination-in";
tc.drawImage(maskLayer.canvas, 0, 0);
tc.globalCompositeOperation = "source-over";
ctx.save();
ctx.globalAlpha = 0.55 * maskLayer.opacity;
if (blurPx > 0) ctx.filter = `blur(${blurPx}px)`;
Layer.applyTransform(ctx, maskLayer);
ctx.drawImage(tmp, -mw / 2, -mh / 2);
ctx.restore();
if (blurPx > 0) ctx.filter = "none";
}
// ── アクションバー ─────────────────────────────
_setupActionBar() {
document.getElementById("ie-upload-input")?.addEventListener("change", e => {
const file = e.target.files?.[0];
if (file) this._loadFile(file);
e.target.value = "";
});
document.getElementById("ie-open-psd-input")?.addEventListener("change", e => {
const file = e.target.files?.[0];
if (file) this._loadPsdFile(file);
e.target.value = "";
});
document.getElementById("ie-new-btn")?.addEventListener("click", () => this._newCanvas());
document.getElementById("ie-draft-btn")?.addEventListener("click", () => this._newDraftFromActiveWork());
document.getElementById("ie-close-btn")?.addEventListener("click", () => this._closeDocument());
document.getElementById("ie-undo-btn")?.addEventListener("click", () => this._undo());
document.getElementById("ie-redo-btn")?.addEventListener("click", () => this._redo());
document.getElementById("ie-pixifx-btn")?.addEventListener("click", () => this._openPixiFx());
document.getElementById("ie-save-btn")?.addEventListener("click", () => this._savePng());
document.getElementById("ie-save-psd-btn")?.addEventListener("click", () => this._saveAsPsd());
document.getElementById("ie-save-project-btn")?.addEventListener("click", () => this._saveProject());
document.getElementById("ie-save-gallery-btn")?.addEventListener("click", () => this._saveToGallery());
document.getElementById("ie-save-eagle-btn")?.addEventListener("click", () => this._saveToEagle());
document.getElementById("ie-send-i2i-btn")?.addEventListener("click", () => this._sendToI2I());
document.getElementById("ie-send-linode-btn")?.addEventListener("click", () => this._sendToLoadImageNode());
document.getElementById("ie-save-layout-btn")?.addEventListener("click", () => this._saveToLayout());
document.getElementById("ie-zoom-fit")?.addEventListener("click", () => this._fitToView());
document.getElementById("ie-zoom-100")?.addEventListener("click", () => {
this._panOffset = { x: 0, y: 0 }; this._setZoom(1.0);
});
const tab = document.getElementById("image-tab");
if (tab) {
tab.addEventListener("dragover", e => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; });
tab.addEventListener("drop", e => {
e.preventDefault();
const file = e.dataTransfer.files?.[0];
if (!file) return;
if (file.name.toLowerCase().endsWith(".psd")) this._loadPsdFile(file);
else if (file.type.startsWith("image/")) this._loadFile(file);
});
}
}
// ── Canvas イベント ───────────────────────────
_setupCanvasEvents() {
const wrap = document.getElementById("ie-canvas-wrap");
if (!wrap) return;
wrap.addEventListener("wheel", e => {
e.preventDefault();
this._setZoom(this._zoom * (e.deltaY > 0 ? 0.9 : 1.1));
}, { passive: false });
wrap.addEventListener("pointerdown", e => {
if (e.button === 1 || (e.button === 0 && this._spaceDown)) {
e.preventDefault();
this._panning = true;
this._panStart = { x: e.clientX - this._panOffset.x, y: e.clientY - this._panOffset.y };
wrap.style.cursor = "grabbing";
return;
}
// Draw/Mask: allow starting a stroke from the canvas margin area
if (e.button !== 0) return;
const tool = this._activeTool;
if (tool !== "draw" && tool !== "mask") return;
const drawCanvas = document.getElementById("ie-canvas-draw");
const overlayCanvas = document.getElementById("ie-canvas-overlay");
if (e.target === drawCanvas || e.target === overlayCanvas) return;
if (!this._layerMgr || !drawCanvas) return;
this._onToolMouseDown(e, drawCanvas);
});
window.addEventListener("pointermove", e => {
if (this._panning) {
this._panOffset.x = e.clientX - this._panStart.x;
this._panOffset.y = e.clientY - this._panStart.y;
this._applyTransform();
}
const tool = this._activeTool;
if (this._eyedropperActive) {
this._hideBrushCursor();
} else if (tool === "draw" || tool === "mask") {
this._updateBrushCursor(e);
const isDrawing = tool === "draw"
? this._drawTool?._drawing
: this._maskTool?._drawing;
if (isDrawing) {
const refCanvas = document.getElementById("ie-canvas-draw");
if (refCanvas) this._onToolMouseMove(e, refCanvas);
}
}
});
window.addEventListener("pointerup", e => {
if (this._panning && (e.button === 1 || e.button === 0)) {
this._panning = false;
wrap.style.cursor = this._spaceDown ? "grab" : "";
}
if (e.button === 0) {
const tool = this._activeTool;
if (tool === "draw" && this._drawTool?._drawing) this._drawTool.onMouseUp();
if (tool === "mask" && this._maskTool?._drawing) this._maskTool.onMouseUp();
}
});
const drawCanvas = document.getElementById("ie-canvas-draw");
if (drawCanvas) {
drawCanvas.style.touchAction = "none";
drawCanvas.addEventListener("pointerdown", e => this._onToolMouseDown(e, drawCanvas));
drawCanvas.addEventListener("pointermove", e => this._onToolMouseMove(e, drawCanvas));
drawCanvas.addEventListener("pointerup", e => this._onToolMouseUp(e));
drawCanvas.addEventListener("pointerleave", () => this._onToolMouseLeave());
// Textツール時はoverlayがpointer-events:noneでdblclickがここに落ちるため、
// drawCanvas側にもテキスト再編集のダブルクリックを張る(Selectツール時はoverlay側が受ける)
drawCanvas.addEventListener("dblclick", e => this._onOverlayDblClick(e, drawCanvas));
}
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) {
overlay.style.touchAction = "none";
overlay.addEventListener("pointerdown", e => this._onToolMouseDown(e, overlay));
overlay.addEventListener("pointermove", e => this._onToolMouseMove(e, overlay));
overlay.addEventListener("pointerup", e => this._onToolMouseUp(e));
overlay.addEventListener("pointerleave", () => this._onToolMouseLeave());
overlay.addEventListener("dblclick", e => this._onOverlayDblClick(e, overlay));
}
// Selectツール専用の拡張オーバーレイ(キャンバス外にはみ出たオブジェクトの操作用)
const selectOverlay = document.getElementById("ie-canvas-select-overlay");
if (selectOverlay) {
selectOverlay.style.touchAction = "none";
selectOverlay.addEventListener("pointerdown", e => this._onToolMouseDown(e, selectOverlay));
selectOverlay.addEventListener("pointermove", e => this._onToolMouseMove(e, selectOverlay));
selectOverlay.addEventListener("pointerup", e => this._onToolMouseUp(e));
selectOverlay.addEventListener("pointerleave", () => this._onToolMouseLeave());
selectOverlay.addEventListener("dblclick", e => this._onOverlayDblClick(e, selectOverlay));
}
}
_onToolMouseDown(e, refCanvas) {
if (!this._layerMgr || e.button !== 0 || this._spaceDown) return;
const pos = DrawTool.getCanvasPos(refCanvas, e);
// ie-canvas-select-overlayはキャンバス実サイズより四方にmarginだけ拡張されているため、
// キャンバス座標系(レイヤーのx/yと同じ系)に変換する
if (refCanvas.id === "ie-canvas-select-overlay") {
pos.x -= this._selectMargin;
pos.y -= this._selectMargin;
}
// PointerEvent.pressure: pen -> actual tilt/pressure, mouse -> 0.5 while a button is held.
const pressure = e.pressure || 0.5;
if (this._eyedropperActive) {
this._pickColorAt(pos.x, pos.y);
return;
}
if (this._activeTool === "draw" && this._drawTool) {
const activeLayer = this._layerMgr.activeLayer;
if (!activeLayer) return;
this._saveUndo();
this._drawTool.setCanvas(activeLayer.canvas);
this._drawTool.onMouseDown(pos.x, pos.y, pressure);
this._updateCompositeView();
} else if (this._activeTool === "fill" && this._fillTool) {
const activeLayer = this._layerMgr.activeLayer;
if (!activeLayer) return;
this._saveUndo();
this._fillTool.setCanvas(activeLayer.canvas);
this._fillTool.onMouseDown(pos.x, pos.y);
this._updateCompositeView();
} else if (this._activeTool === "mask") {
const activeLayer = this._layerMgr.activeLayer;
if (!activeLayer || activeLayer.type !== "mask") {
this._toast(window.t("image.msg.selectMaskFirst"), "info");
return;
}
const sub = this._maskSubtool;
if (sub === "paint" && this._maskTool) {
this._saveUndo();
this._maskTool.setCanvas(activeLayer.canvas);
this._maskTool.onMouseDown(pos.x, pos.y, pressure);
this._updateCompositeView();
} else if (sub === "color" && this._maskColorTool) {
this._saveUndo();
this._maskColorTool.setCanvas(activeLayer.canvas);
this._maskColorTool.setBgCanvas(this._buildBgCanvas());
this._maskColorTool.onMouseDown(pos.x, pos.y);
this._updateCompositeView();
this._refreshLayerList();
} else if (sub === "text" && this._maskTextTool) {
this._maskTextTool.setCanvas(activeLayer.canvas);
this._maskTextTool.onMouseDown(pos.x, pos.y);
} else if (sub === "vector" && this._maskVectorTool) {
this._maskVectorTool.setCanvas(activeLayer.canvas);
this._maskVectorTool.onMouseDown(pos.x, pos.y);
this._renderMaskProps("vector");
} else if (sub === "shape" && this._maskShapeTool) {
this._saveUndo();
this._maskShapeTool.setCanvas(activeLayer.canvas);
this._maskShapeTool.onMouseDown(pos.x, pos.y);
}
} else if (this._activeTool === "shape" && this._shapeTool) {
this._shapeTool.onMouseDown(pos.x, pos.y);
} else if (this._activeTool === "text" && this._textTool) {
// 既存テキストレイヤーの上ではシングルクリックで新規入力を開かない
// (Selectツールと同じくダブルクリックで再編集を開く。_onOverlayDblClick参照)
// 入力オーバーレイ表示中は従来通りキャンバスクリックで閉じる(onMouseDown内で処理)
if (!this._textTool._overlay && this._findTextLayerAt(pos.x, pos.y)) return;
this._textTool.onMouseDown(pos.x, pos.y);
} else if (this._activeTool === "text3d" && this._text3dTool) {
if (!this._text3dTool.isOpen) this._text3dTool.open(pos.x, pos.y);
} else if (this._activeTool === "select" && this._selectTool) {
if (this._cropMode) { this._onCropMouseDown(pos); return; }
const result = this._selectTool.onMouseDown(pos.x, pos.y, this._layerMgr);
if (result === "select") {
const sel = this._selectTool.getSelectedLayer();
if (sel) { this._layerMgr.setActive(sel.id); this._refreshLayerList(); }
} else if (result && result !== null) {
this._saveUndo();
}
} else if (this._activeTool === "blur" && this._blurRectMode) {
this._blurDragging = true;
this._blurDragStart = { x: pos.x, y: pos.y };
this._blurDragCur = { x: pos.x, y: pos.y };
}
}
_onToolMouseMove(e, refCanvas) {
if (!this._layerMgr) return;
const pos = DrawTool.getCanvasPos(refCanvas, e);
if (refCanvas.id === "ie-canvas-select-overlay") {
pos.x -= this._selectMargin;
pos.y -= this._selectMargin;
}
const pressure = e.pressure || 0.5;
if (this._activeTool === "draw") {
this._drawTool?.onMouseMove(pos.x, pos.y, pressure);
if (this._drawTool?._drawing) this._updateCompositeView();
}
if (this._activeTool === "mask") {
const sub = this._maskSubtool;
if (sub === "paint") {
this._maskTool?.onMouseMove(pos.x, pos.y, pressure);
if (this._maskTool?._drawing) this._updateCompositeView();
} else if (sub === "vector") {
this._maskVectorTool?.onMouseMove(pos.x, pos.y);
} else if (sub === "shape") {
this._maskShapeTool?.onMouseMove(pos.x, pos.y, e);
}
}
if (this._activeTool === "shape") this._shapeTool?.onMouseMove(pos.x, pos.y);
if (this._activeTool === "select") {
if (this._cropMode) this._onCropMouseMove(pos);
else this._selectTool?.onMouseMove(pos.x, pos.y);
}
if (this._activeTool === "blur" && this._blurDragging) {
this._blurDragCur = pos;
this._drawBlurPreview();
}
}
_onToolMouseUp(e) {
if (!this._layerMgr || e.button !== 0) return;
if (this._activeTool === "draw") this._drawTool?.onMouseUp();
if (this._activeTool === "mask") {
const sub = this._maskSubtool;
if (sub === "paint") this._maskTool?.onMouseUp();
else if (sub === "shape") {
const activeLayer = this._layerMgr?.activeLayer;
if (activeLayer?.type === "mask") {
this._maskShapeTool?.onMouseUp();
this._updateCompositeView();
this._refreshLayerList();
}
}
}
if (this._activeTool === "shape") this._shapeTool?.onMouseUp();
if (this._activeTool === "select") {
if (this._cropMode) { this._cropDragMode = null; this._cropDragStart = null; }
else this._selectTool?.onMouseUp();
}
if (this._activeTool === "blur" && this._blurDragging) {
this._blurDragging = false;
this._applyRectEffect();
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
}
_onToolMouseLeave() {
// Draw/Mask: do NOT stop the stroke — window mousemove/mouseup continue tracking
this._hideBrushCursor();
if (this._activeTool === "shape") this._shapeTool?.onMouseLeave();
if (this._activeTool === "select") {
if (this._cropMode) { this._cropDragMode = null; this._cropDragStart = null; }
else this._selectTool?.onMouseLeave();
}
if (this._activeTool === "mask" && this._maskSubtool === "vector") this._maskVectorTool?.onMouseLeave();
if (this._activeTool === "blur" && this._blurDragging) {
this._blurDragging = false;
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
}
// ── Crop(Selectツールのサブ機能) ────────────────
// キャンバス(ページ)全体をオーバーレイのクロップ範囲にリサイズする。レイヤーのピクセルデータ自体は
// 触らず、_canvasW/_canvasH・LayerManager.width/height を縮小し、各レイヤーのx/yをクロップ原点分
// シフトするだけで実現する(レイヤーは元々ページ座標系に独立配置されているため、コンポジット時に
// キャンバス境界で自動的にクリップされる。詳細はLayerManager.composite参照)
_enterCropMode() {
if (!this._layerMgr) return;
if (this._activeTool !== "select") { this._setActiveTool("select"); }
if (this._extendMode) this._exitExtendMode();
const margin = Math.round(Math.min(this._canvasW, this._canvasH) * 0.1);
this._cropMode = true;
this._cropRect = this._clampCropRect({
x: margin, y: margin,
w: this._canvasW - margin * 2,
h: this._canvasH - margin * 2,
});
this._cropDragMode = null;
this._cropDragStart = null;
this._renderToolOptions("select");
}
_exitCropMode() {
this._cropMode = false;
this._cropRect = null;
this._cropDragMode = null;
this._cropDragStart = null;
const overlay = document.getElementById("ie-canvas-overlay");
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
/** クロップ範囲をキャンバス境界内・最小1pxにクランプする */
_clampCropRect(rect) {
const cw = this._canvasW, ch = this._canvasH;
const w = Math.max(1, Math.min(rect.w, cw));
const h = Math.max(1, Math.min(rect.h, ch));
const x = Math.max(0, Math.min(rect.x, cw - w));
const y = Math.max(0, Math.min(rect.y, ch - h));
return { x, y, w, h };
}
/** クロップ範囲のハンドル位置(四隅+四辺中点)をキャンバス座標で返す */
_cropGetHandlePositions() {
const { x, y, w, h } = this._cropRect;
return {
nw: { x, y }, n: { x: x + w / 2, y }, ne: { x: x + w, y },
w: { x, y: y + h / 2 }, e: { x: x + w, y: y + h / 2 },
sw: { x, y: y + h }, s: { x: x + w / 2, y: y + h }, se: { x: x + w, y: y + h },
};
}
/** (px,py)がどのハンドル/内部に当たるか。'move'|ハンドル名(n/s/e/w/ne/nw/se/sw)|null */
_cropHitHandle(px, py) {
if (!this._cropRect) return null;
const handles = this._cropGetHandlePositions();
for (const key of Object.keys(handles)) {
const p = handles[key];
if (Math.abs(px - p.x) <= CROP_HANDLE_HIT_R && Math.abs(py - p.y) <= CROP_HANDLE_HIT_R) return key;
}
const { x, y, w, h } = this._cropRect;
if (px >= x && px <= x + w && py >= y && py <= y + h) return "move";
return null;
}
_onCropMouseDown(pos) {
const mode = this._cropHitHandle(pos.x, pos.y);
if (!mode) return;
this._cropDragMode = mode;
this._cropDragStart = { x: pos.x, y: pos.y, rect: { ...this._cropRect } };
}
_onCropMouseMove(pos) {
if (!this._cropDragMode || !this._cropDragStart) return;
const dx = pos.x - this._cropDragStart.x;
const dy = pos.y - this._cropDragStart.y;
const r0 = this._cropDragStart.rect;
const mode = this._cropDragMode;
let x = r0.x, y = r0.y, w = r0.w, h = r0.h;
if (mode === "move") {
x = r0.x + dx;
y = r0.y + dy;
} else {
if (mode.includes("e")) w = r0.w + dx;
if (mode.includes("s")) h = r0.h + dy;
if (mode.includes("w")) { x = r0.x + dx; w = r0.w - dx; }
if (mode.includes("n")) { y = r0.y + dy; h = r0.h - dy; }
// 反対側のハンドルを越えて負サイズにならないよう、その辺を固定した最小1pxにする
if (w < 1) { if (mode.includes("w")) x = r0.x + r0.w - 1; w = 1; }
if (h < 1) { if (mode.includes("n")) y = r0.y + r0.h - 1; h = 1; }
}
this._cropRect = this._clampCropRect({ x, y, w, h });
this._cropSyncInputs();
this._drawCropOverlay();
}
/** クロップ範囲編集中のX/Y/W/H入力欄を現在のcropRectへ同期する(入力中の欄は上書きしない) */
_cropSyncInputs() {
if (!this._cropMode || !this._cropRect) return;
const map = { "ie-crop-x": "x", "ie-crop-y": "y", "ie-crop-w": "w", "ie-crop-h": "h" };
Object.keys(map).forEach(id => {
const inp = document.getElementById(id);
if (inp && document.activeElement !== inp) inp.value = Math.round(this._cropRect[map[id]]);
});
}
_drawCropOverlay() {
const overlay = document.getElementById("ie-canvas-overlay");
if (!overlay || !this._cropRect) return;
const ctx = overlay.getContext("2d");
ctx.clearRect(0, 0, overlay.width, overlay.height);
const { x, y, w, h } = this._cropRect;
// クロップ範囲外を半透明に暗くする
ctx.save();
ctx.fillStyle = "rgba(0,0,0,0.5)";
ctx.beginPath();
ctx.rect(0, 0, overlay.width, overlay.height);
ctx.rect(x, y, w, h);
ctx.fill("evenodd");
ctx.restore();
// クロップ範囲の枠線
ctx.save();
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 1;
ctx.setLineDash([6, 4]);
ctx.strokeRect(x + 0.5, y + 0.5, Math.max(0, w - 1), Math.max(0, h - 1));
ctx.restore();
// ハンドル(四隅+四辺中点)
ctx.save();
ctx.setLineDash([]);
ctx.fillStyle = "#ffffff";
ctx.strokeStyle = "#333333";
ctx.lineWidth = 1;
const r = CROP_HANDLE_SIZE / 2;
Object.values(this._cropGetHandlePositions()).forEach(p => {
ctx.fillRect(p.x - r, p.y - r, CROP_HANDLE_SIZE, CROP_HANDLE_SIZE);
ctx.strokeRect(p.x - r, p.y - r, CROP_HANDLE_SIZE, CROP_HANDLE_SIZE);
});
ctx.restore();
}
/** クロップを確定実行する: キャンバスサイズを縮小し、各レイヤーをクロップ原点分シフトする */
_applyCrop() {
if (!this._layerMgr || !this._cropRect) return;
const canvasW = this._canvasW, canvasH = this._canvasH;
const { x, y, w, h } = this._clampCropRect(this._cropRect);
if (x === 0 && y === 0 && w === canvasW && h === canvasH) {
// 変化なし
this._exitCropMode();
this._renderToolOptions("select");
return;
}
this._saveUndo();
this._layerMgr.layers.forEach(layer => {
layer.x -= x;
layer.y -= y;
});
this._canvasW = w;
this._canvasH = h;
this._layerMgr.width = w;
this._layerMgr.height = h;
this._resizeCanvasElements(w, h);
this._exitCropMode();
this._updateCompositeView();
this._refreshLayerList();
this._renderToolOptions("select");
if (typeof this._fitToView === "function") this._fitToView();
this._toast(window.t("image.msg.cropped", w, h), "success");
}
/** クロップ範囲編集中のX/Y/W/H入力欄とApply/Cancelをツールプロパティペインに表示する */
_renderCropProps() {
const pane = document.getElementById("ie-props-pane");
const body = document.getElementById("ie-props-body");
const title = document.getElementById("ie-props-title");
if (!pane || !body || !this._cropRect) return;
pane.style.display = "flex";
if (title) title.textContent = t("image.cropPaneTitle");
body.innerHTML = `
<div class="ie-props-row">
<label>X</label>
<input type="number" id="ie-crop-x" value="${Math.round(this._cropRect.x)}" step="1" style="width:100%;box-sizing:border-box;" title="${t("image.cropXLabel")}">
</div>
<div class="ie-props-row">
<label>Y</label>
<input type="number" id="ie-crop-y" value="${Math.round(this._cropRect.y)}" step="1" style="width:100%;box-sizing:border-box;" title="${t("image.cropYLabel")}">
</div>
<div class="ie-props-row">
<label>W</label>
<input type="number" id="ie-crop-w" value="${Math.round(this._cropRect.w)}" step="1" min="1" style="width:100%;box-sizing:border-box;" title="${t("image.cropWidthLabel")}">
</div>
<div class="ie-props-row">
<label>H</label>
<input type="number" id="ie-crop-h" value="${Math.round(this._cropRect.h)}" step="1" min="1" style="width:100%;box-sizing:border-box;" title="${t("image.cropHeightLabel")}">
</div>
<div class="ie-props-row" style="flex-direction:row;gap:6px;">
<button class="it-btn it-btn-sm it-btn-primary" id="ie-crop-apply-btn" style="flex:1;">${t("image.cropApply")}</button>
<button class="it-btn it-btn-sm" id="ie-crop-cancel-btn" style="flex:1;">${t("image.cropCancel")}</button>
</div>
`;
const applyCropInputs = () => {
const x = parseFloat(document.getElementById("ie-crop-x").value) || 0;
const y = parseFloat(document.getElementById("ie-crop-y").value) || 0;
const w = parseFloat(document.getElementById("ie-crop-w").value) || 1;
const h = parseFloat(document.getElementById("ie-crop-h").value) || 1;
this._cropRect = this._clampCropRect({ x, y, w, h });
this._cropSyncInputs();
this._drawCropOverlay();
};
["ie-crop-x", "ie-crop-y", "ie-crop-w", "ie-crop-h"].forEach(id => {
document.getElementById(id)?.addEventListener("change", applyCropInputs);
});
document.getElementById("ie-crop-apply-btn")?.addEventListener("click", () => this._applyCrop());
document.getElementById("ie-crop-cancel-btn")?.addEventListener("click", () => {
this._exitCropMode();
this._renderToolOptions("select");
});
this._drawCropOverlay();
}
// ── Extend Canvas(Selectツールのサブ機能) ────────────────
// キャンバスの四辺に余白を追加してサイズを拡張する。Cropの逆方向の操作で、レイヤーの
// ピクセルデータ自体は触らず、_canvasW/_canvasH・LayerManager.width/heightを拡大し、
// 各レイヤーのx/yを追加した上・左の余白分シフトするだけで実現する(Cropと同じ理屈で、
// レイヤーはページ座標系に独立配置されているため座標シフトだけで済む)。
// 透明以外の余白色を選んだ場合は、新キャンバス全体を塗りつぶす描画レイヤーを最背面に追加する
// (合成結果は透過背景のため、実際に書き出される画像に色を反映するにはレイヤーが必要)。
_enterExtendMode() {
if (!this._layerMgr) return;
if (this._activeTool !== "select") { this._setActiveTool("select"); }
if (this._cropMode) this._exitCropMode();
this._extendMode = true;
this._extendPad = { top: 0, right: 0, bottom: 0, left: 0 };
this._renderToolOptions("select");
}
_exitExtendMode() {
this._extendMode = false;
}
/** 拡張範囲編集中の上下左右の余白入力・色選択・Apply/Cancelをツールプロパティペインに表示する */
_renderExtendProps() {
const pane = document.getElementById("ie-props-pane");
const body = document.getElementById("ie-props-body");
const title = document.getElementById("ie-props-title");
if (!pane || !body) return;
pane.style.display = "flex";
if (title) title.textContent = t("image.extendPaneTitle");
const pad = this._extendPad;
const newW = this._canvasW + pad.left + pad.right;
const newH = this._canvasH + pad.top + pad.bottom;
const colorOpts = [
{ id: "transparent", label: t("image.extendColorTransparent") },
{ id: "white", label: t("image.extendColorWhite") },
{ id: "black", label: t("image.extendColorBlack") },
{ id: "extend", label: t("image.extendColorExtend") },
];
body.innerHTML = `
<div class="ie-props-row">
<label style="cursor:pointer;display:flex;align-items:flex-start;gap:4px;white-space:normal;line-height:1.4;">
<input type="checkbox" id="ie-extend-linked" style="flex-shrink:0;margin-top:2px;" ${this._extendLinked ? "checked" : ""}>
<span>${t("image.extendLinkedTitle")}</span>
</label>
</div>
<div class="ie-props-row">
<label>${t("image.extendTopLabel")}</label>
<input type="number" id="ie-extend-top" value="${pad.top}" step="1" min="0" style="width:100%;box-sizing:border-box;">
</div>
<div class="ie-props-row">
<label>${t("image.extendRightLabel")}</label>
<input type="number" id="ie-extend-right" value="${pad.right}" step="1" min="0" style="width:100%;box-sizing:border-box;">
</div>
<div class="ie-props-row">
<label>${t("image.extendBottomLabel")}</label>
<input type="number" id="ie-extend-bottom" value="${pad.bottom}" step="1" min="0" style="width:100%;box-sizing:border-box;">
</div>
<div class="ie-props-row">
<label>${t("image.extendLeftLabel")}</label>
<input type="number" id="ie-extend-left" value="${pad.left}" step="1" min="0" style="width:100%;box-sizing:border-box;">
</div>
<div class="ie-props-row">
<label>${t("image.extendColorLabel")}</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;">
${colorOpts.map(o => `<button class="it-btn it-btn-sm${this._extendColor === o.id ? " ie-opt-active" : ""}" data-color="${o.id}" style="padding:2px 4px;">${o.label}</button>`).join("")}
</div>
</div>
<div style="font-size:11px;color:var(--it-text-secondary);white-space:normal;line-height:1.4;">
${t("image.extendNewSizeLabelPrefix")}<br>${newW} × ${newH} px
</div>
<div class="ie-props-row" style="flex-direction:row;gap:6px;">
<button class="it-btn it-btn-sm it-btn-primary" id="ie-extend-apply-btn" style="flex:1;">${t("image.extendApply")}</button>
<button class="it-btn it-btn-sm" id="ie-extend-cancel-btn" style="flex:1;">${t("image.extendCancel")}</button>
</div>
`;
const syncFromInput = (side, value) => {
const v = Math.max(0, Math.round(parseFloat(value)) || 0);
this._extendPad = this._extendLinked
? { top: v, right: v, bottom: v, left: v }
: { ...this._extendPad, [side]: v };
this._renderExtendProps();
};
document.getElementById("ie-extend-linked")?.addEventListener("change", (e) => {
this._extendLinked = e.target.checked;
if (this._extendLinked) {
const v = Math.max(this._extendPad.top, this._extendPad.right, this._extendPad.bottom, this._extendPad.left);
this._extendPad = { top: v, right: v, bottom: v, left: v };
}
this._renderExtendProps();
});
document.getElementById("ie-extend-top")?.addEventListener("change", (e) => syncFromInput("top", e.target.value));
document.getElementById("ie-extend-right")?.addEventListener("change", (e) => syncFromInput("right", e.target.value));
document.getElementById("ie-extend-bottom")?.addEventListener("change", (e) => syncFromInput("bottom", e.target.value));
document.getElementById("ie-extend-left")?.addEventListener("change", (e) => syncFromInput("left", e.target.value));
body.querySelectorAll("[data-color]").forEach(btn => {
btn.addEventListener("click", () => {
this._extendColor = btn.dataset.color;
this._renderExtendProps();
});
});
document.getElementById("ie-extend-apply-btn")?.addEventListener("click", () => this._applyExtend());
document.getElementById("ie-extend-cancel-btn")?.addEventListener("click", () => {
this._exitExtendMode();
this._renderToolOptions("select");
});
}
/** 拡張を確定実行する: キャンバスサイズを拡大し、各レイヤーを追加した上・左の余白分シフトする */
_applyExtend() {
if (!this._layerMgr) return;
const { top, right, bottom, left } = this._extendPad;
if (top === 0 && right === 0 && bottom === 0 && left === 0) {
this._exitExtendMode();
this._renderToolOptions("select");
return;
}
this._saveUndo();
const newW = this._canvasW + left + right;
const newH = this._canvasH + top + bottom;
// "extend"(エッジ延伸)モードは、レイヤーをシフトする前の現在の合成結果を縁のピクセル延伸に使う
const srcComposite = this._extendColor === "extend" ? this._buildCompositeCanvas() : null;
this._layerMgr.layers.forEach(layer => {
layer.x += left;
layer.y += top;
});
if (this._extendColor !== "transparent") {
// 合成結果は透過背景のため、余白色を実際に書き出される画像へ反映するには
// 新キャンバス全体を塗りつぶす描画レイヤーを最背面に追加する必要がある
const fillLayer = this._layerMgr.addLayer("draw", "Background Fill", {
contentW: newW, contentH: newH, x: 0, y: 0,
});
if (this._extendColor === "extend") {
fillLayer.ctx.drawImage(this._edgeExtendFillCanvas(srcComposite, { top, right, bottom, left }), 0, 0);
} else {
fillLayer.ctx.fillStyle = this._extendColor === "white" ? "#ffffff" : "#000000";
fillLayer.ctx.fillRect(0, 0, newW, newH);
}
const idx = this._layerMgr.layers.indexOf(fillLayer);
if (idx !== -1) {
this._layerMgr.layers.splice(idx, 1);
this._layerMgr.layers.push(fillLayer); // 配列末尾=最背面
}
}
this._canvasW = newW;
this._canvasH = newH;
this._layerMgr.width = newW;
this._layerMgr.height = newH;
this._resizeCanvasElements(newW, newH);
this._exitExtendMode();
this._updateCompositeView();
this._refreshLayerList();
this._renderToolOptions("select");
if (typeof this._fitToView === "function") this._fitToView();
this._toast(window.t("image.msg.extended", newW, newH), "success");
}
/**
* 元画像(srcCanvas)の縁のピクセルを引き伸ばして四辺の余白を埋めた新キャンバスを返す。
* サイズ拡張の「エッジ延伸」モードと、Outpaintの下地画像作成の両方から使う共通ヘルパー。
* 単色塗りと違い、モデルに繋がりのヒントを与えられるため境界の継ぎ目が出にくい。
*/
_edgeExtendFillCanvas(srcCanvas, pad) {
const { top, right, bottom, left } = pad;
const w = srcCanvas.width, h = srcCanvas.height;
const newW = w + left + right, newH = h + top + bottom;
const canvas = document.createElement("canvas");
canvas.width = newW; canvas.height = newH;
const ctx = canvas.getContext("2d");
if (top > 0) ctx.drawImage(srcCanvas, 0, 0, w, 1, left, 0, w, top);
if (bottom > 0) ctx.drawImage(srcCanvas, 0, h - 1, w, 1, left, top + h, w, bottom);
if (left > 0) ctx.drawImage(srcCanvas, 0, 0, 1, h, 0, top, left, h);
if (right > 0) ctx.drawImage(srcCanvas, w - 1, 0, 1, h, left + w, top, right, h);
if (top > 0 && left > 0) ctx.drawImage(srcCanvas, 0, 0, 1, 1, 0, 0, left, top);
if (top > 0 && right > 0) ctx.drawImage(srcCanvas, w - 1, 0, 1, 1, left + w, 0, right, top);
if (bottom > 0 && left > 0) ctx.drawImage(srcCanvas, 0, h - 1, 1, 1, 0, top + h, left, bottom);
if (bottom > 0 && right > 0) ctx.drawImage(srcCanvas, w - 1, h - 1, 1, 1, left + w, top + h, right, bottom);
ctx.drawImage(srcCanvas, left, top);
return canvas;
}
// ── 画像ロード ────────────────────────────────
async _loadFile(file) {
const dataUrl = await new Promise(resolve => {
const reader = new FileReader();
reader.onload = e => resolve(e.target.result);
reader.readAsDataURL(file);
});
await this._loadFromDataUrl(dataUrl, file.name.replace(/\.[^.]+$/, ""));
}
async _loadFromDataUrl(dataUrl, baseName = "image") {
const img = await new Promise(resolve => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => resolve(null);
i.src = dataUrl;
});
if (!img) { this._toast(window.t("image.msg.loadImageFailed"), "error"); return; }
const hasLayers = this._layerMgr && this._layerMgr.layers.length > 0;
if (hasLayers) {
this._syncActiveLayerFromCanvas();
this._saveUndo();
const fit = fitToCanvas(img.width, img.height, this._canvasW, this._canvasH);
const newLayer = this._layerMgr.addLayer("image", baseName, {
contentW: img.width, contentH: img.height,
displayW: fit.w, displayH: fit.h,
x: Math.round((this._canvasW - fit.w) / 2),
y: Math.round((this._canvasH - fit.h) / 2),
});
newLayer.ctx.drawImage(img, 0, 0);
this._layerMgr.setActive(newLayer.id);
if (this._activeTool !== "select") this._setActiveTool("select");
this._selectTool?.setLayer(newLayer);
this._updateCompositeView();
this._refreshLayerList();
document.getElementById("ie-placeholder").style.display = "none";
this._toast(window.t("image.msg.imageAdded", img.width, img.height), "success");
return;
}
// 新規キャンバス(Layer 1に画像配置)
this._canvasW = img.width;
this._canvasH = img.height;
this._baseName = baseName;
this._initCanvases();
const layer1 = this._layerMgr.addLayer("image", "Layer 1", {
contentW: img.width, contentH: img.height,
displayW: img.width, displayH: img.height,
x: 0, y: 0,
});
layer1.ctx.drawImage(img, 0, 0);
layer1.locked = true; // 初期画像は誤操作防止のため自動ロック
this._undoStack = [];
this._redoStack = [];
this._setActiveTool("select");
this._selectTool?.setLayer(layer1);
this._refreshLayerList();
this._updateCompositeView();
this._fitToView();
document.getElementById("ie-placeholder").style.display = "none";
this._toast(window.t("image.msg.loaded", img.width, img.height), "success");
}
// PSD(Photoshop)ファイルをレイヤー構成ごと読み込む。ブラウザはPSDをネイティブデコードできないため
// サーバー側(psd-tools)でラスタライズしたレイヤー配列(dataURL)を受け取る。PSD自体のキャンバスサイズを
// そのまま使うため、既存の作業内容とはマージせず常に新規ドキュメントとして開く(loadProjectFromUrlと同様)。
async _loadPsdFile(file) {
const form = new FormData();
form.append("file", file);
let data;
try {
const r = await fetch("/api/ccc/psd/import-layers", { method: "POST", body: form });
data = await r.json();
if (data.status !== "ok") throw new Error(data.message || "unknown error");
} catch (err) {
this._toast(window.t("image.msg.openPsdFailed") + err.message, "error");
return;
}
this._canvasW = data.width;
this._canvasH = data.height;
this._baseName = file.name.replace(/\.[^.]+$/, "");
this._sourceImageEl = null; // PSD読み込みはレイアウトへの書き戻し対象外
this._initCanvases();
await this._layerMgr.fromJSON({
width: data.width, height: data.height,
layers: data.layers.map(l => ({
name: l.name, type: "image",
x: l.x, y: l.y, displayW: l.width, displayH: l.height,
opacity: l.opacity, visible: l.visible, blendMode: l.blendMode,
imageData: l.imageData,
})),
});
this._undoStack = [];
this._redoStack = [];
this._setActiveTool("select");
this._refreshLayerList();
this._updateCompositeView();
this._fitToView();
document.getElementById("ie-placeholder").style.display = "none";
this._toast(window.t("image.msg.psdLoaded", data.layers.length), "success");
}
/** 現在のドキュメントを閉じる(未保存確認あり)。空の状態なら何もしない */
_closeDocument() {
if (!this._layerMgr) return;
this._showCloseConfirmDialog();
}
_showCloseConfirmDialog() {
const overlay = document.createElement("div");
overlay.className = "ie-confirm-overlay";
overlay.innerHTML = `
<div class="ie-confirm-box">
<div class="ie-confirm-title">${t("image.closeConfirmTitle")}</div>
<div class="ie-confirm-body">${t("image.closeConfirmBody")}</div>
<div class="ie-confirm-btns">
<button class="it-btn it-btn-sm it-btn-primary" id="ie-close-save-btn">${t("image.closeSaveBtn")}</button>
<button class="it-btn it-btn-sm" id="ie-close-discard-btn">${t("image.closeDiscardBtn")}</button>
<button class="it-btn it-btn-sm" id="ie-close-cancel-btn">${t("common.cancel")}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelector("#ie-close-save-btn").addEventListener("click", async () => {
const saved = await this._saveProject();
close();
if (saved) this._resetToEmpty();
});
overlay.querySelector("#ie-close-discard-btn").addEventListener("click", () => {
close();
this._resetToEmpty();
});
overlay.querySelector("#ie-close-cancel-btn").addEventListener("click", close);
}
/** レイヤー・キャンバスを破棄し、プレースホルダー表示(未編集状態)に戻す */
_resetToEmpty() {
this._layerMgr = null;
this._undoStack = [];
this._redoStack = [];
this._sourceImageEl = null;
this._baseName = "image";
["ie-canvas-bg", "ie-canvas-draw", "ie-canvas-overlay"].forEach(id => {
const c = document.getElementById(id);
if (c) c.getContext("2d").clearRect(0, 0, c.width, c.height);
});
const layerListEl = document.getElementById("ie-layer-list");
if (layerListEl) layerListEl.innerHTML = "";
const placeholder = document.getElementById("ie-placeholder");
if (placeholder) placeholder.style.display = "";
this._toast(window.t("image.msg.docClosed"), "info");
}
_newCanvas() {
const current = `${this._canvasW || 512}x${this._canvasH || 512}`;
const input = prompt(window.t("image.msg.canvasSizePrompt"), current);
if (!input) return;
const m = input.match(/^(\d+)\s*[x×]\s*(\d+)$/i);
if (!m) { this._toast(window.t("image.msg.invalidSizeFormat"), "error"); return; }
const w = parseInt(m[1]), h = parseInt(m[2]);
if (w < 1 || h < 1 || w > 8192 || h > 8192) { this._toast(window.t("image.msg.sizeRange"), "error"); return; }
this._createNewCanvasWithSize(w, h, "new-canvas");
this._toast(window.t("image.msg.newCanvas", w, h), "success");
}
/**
* 作業中の作品(レイアウトタブの state.activeWork、単位は1/100mm)と同じ縦横比で、
* 下書き用の72dpi換算ピクセルサイズの新規キャンバスを作成する(サイズ入力プロンプトなし)。
* 下書きはラフスケッチ用途で高解像度が不要なため、印刷解像度ではなく画面表示相当のサイズにする。
*/
_newDraftFromActiveWork() {
const work = state.activeWork;
if (!work || !work.width || !work.height) {
this._toast(window.t("image.msg.noActiveWork"), "error");
return;
}
const DRAFT_DPI = 72;
const mmToPx = (v) => Math.max(1, Math.round((v / 100 / 25.4) * DRAFT_DPI));
const w = mmToPx(work.width);
const h = mmToPx(work.height);
this._createNewCanvasWithSize(w, h, "draft");
this._toast(window.t("image.msg.newDraftCanvas", w, h), "success");
}
/** 指定サイズで新規キャンバスを作成する共通処理(_newCanvas / _newDraftFromActiveWork から呼ばれる) */
_createNewCanvasWithSize(w, h, baseName) {
this._canvasW = w;
this._canvasH = h;
this._baseName = baseName;
this._initCanvases();
// 空のキャンバスのままだと描画先レイヤーが無いため、作成直後に描画用レイヤーを1枚追加しておく
const layer = this._layerMgr.addLayer("draw", "Layer 1");
layer.displayW = w; layer.displayH = h;
layer.x = 0; layer.y = 0;
this._undoStack = [];
this._redoStack = [];
this._setActiveTool("select");
this._refreshLayerList();
this._updateCompositeView();
this._fitToView();
document.getElementById("ie-placeholder").style.display = "none";
}
/** ie-canvas-draw/ie-canvas-overlay/ie-canvas-containerをw×hへリサイズする(LayerManagerには触れない)。
* _initCanvases(新規/再構築時)とクロップ実行・Undo/Redo(既存LayerManagerを維持したままの
* サイズ変更)の両方から共通で呼べるよう切り出してある */
_resizeCanvasElements(w, h) {
const drawCanvas = document.getElementById("ie-canvas-draw");
const overlayCanvas = document.getElementById("ie-canvas-overlay");
const bgCanvas = document.getElementById("ie-canvas-bg");
if (drawCanvas) { drawCanvas.width = w; drawCanvas.height = h; }
if (overlayCanvas) { overlayCanvas.width = w; overlayCanvas.height = h; }
if (bgCanvas) { bgCanvas.width = w; bgCanvas.height = h; }
this._drawTransparencyBg(bgCanvas);
const container = document.getElementById("ie-canvas-container");
if (container) {
container.style.width = w + "px";
container.style.height = h + "px";
}
// Selectツール専用の拡張オーバーレイ: キャンバス外にはみ出たオブジェクトの選択枠・ハンドルも
// 表示・操作できるよう、四方にmarginを持たせてキャンバス実サイズより大きく確保する
const margin = Math.max(150, Math.min(500, Math.round(Math.max(w, h) * 0.5)));
this._selectMargin = margin;
const selectOverlay = document.getElementById("ie-canvas-select-overlay");
if (selectOverlay) {
selectOverlay.width = w + margin * 2;
selectOverlay.height = h + margin * 2;
selectOverlay.style.left = `-${margin}px`;
selectOverlay.style.top = `-${margin}px`;
}
this._selectTool?.setCanvas(selectOverlay, margin);
}
// キャンバス範囲内(=実際に書き出される範囲)を示す透過チェッカーパターンをie-canvas-bgに描画する。
// ワークスペース背景(ie-canvas-wrap、暗いグレー系の市松)とは明確に異なる配色にすることで、
// 「ここまでがキャンバス」という境界を視覚的に判別しやすくする
_drawTransparencyBg(bgCanvas) {
const bg = bgCanvas ?? document.getElementById("ie-canvas-bg");
if (!bg) return;
const ctx = bg.getContext("2d");
const size = 16;
ctx.clearRect(0, 0, bg.width, bg.height);
for (let y = 0; y < bg.height; y += size) {
for (let x = 0; x < bg.width; x += size) {
ctx.fillStyle = ((x / size + y / size) % 2 === 0) ? "#e8e8e8" : "#c8c8c8";
ctx.fillRect(x, y, size, size);
}
}
}
_initCanvases() {
this._resizeCanvasElements(this._canvasW, this._canvasH);
const drawCanvas = document.getElementById("ie-canvas-draw");
const overlayCanvas = document.getElementById("ie-canvas-overlay");
this._layerMgr = new LayerManager(this._canvasW, this._canvasH);
this._layerMgr.on("change", () => this._refreshLayerList());
this._selectedLayerIds = new Set();
this._drawTool = new DrawTool(null);
this._drawTool.onChange(() => {
this._updateCompositeView();
this._refreshLayerList();
});
this._textTool = new TextTool(drawCanvas);
this._textTool.onChange((clickX, clickY) => {
const data = this._textTool.createLayerData(clickX, clickY);
const props = {
text: this._textTool.text,
fontFamily: this._textTool.fontFamily,
fontSize: this._textTool.fontSize,
bold: this._textTool.bold,
italic: this._textTool.italic,
align: this._textTool.align,
vertical: this._textTool.vertical,
color: this._textTool.color,
nativeW: data.width,
nativeH: data.height,
};
const label = (props.text.slice(0, 20).replace(/\n/g, " ").trim()) || "Text";
if (this._editingTextLayer) {
const layer = this._editingTextLayer;
this._editingTextLayer = null;
this._saveUndo();
layer.name = label;
// 既存のスタイル系プロパティ(線・袋文字・影・下線等)を保持したまま基本値を更新し、
// スタイル込みで再計測・再描画する(propsの完全置換+無スタイル描画だとスタイルが消える)
this._applyTextPropsToLayer(layer, { ...layer.textProps, ...props });
this._selectTool?.setLayer(layer);
this._updateCompositeView();
this._refreshLayerList();
return;
}
this._saveUndo();
const textLayer = this._layerMgr.addLayer("text", label, {
contentW: data.width, contentH: data.height,
displayW: data.width, displayH: data.height,
x: Math.round(data.x), y: Math.round(data.y),
});
textLayer.ctx.drawImage(data.canvas, 0, 0);
textLayer.textProps = props;
this._layerMgr.setActive(textLayer.id);
this._setActiveTool("select");
this._selectTool?.setLayer(textLayer);
this._updateCompositeView();
this._refreshLayerList();
});
this._shapeTool = new ShapeTool();
this._shapeTool.onChange(shapeObj => {
this._saveUndo();
// Same Layerモード: 直前にシェイプツールが使ったレイヤーがまだアクティブならそこに追記する
const activeLayer = this._layerMgr.activeLayer;
let layer = (this._shapeSameLayer && this._shapeLayerId && activeLayer?.id === this._shapeLayerId)
? activeLayer : null;
if (!layer) {
const layerName = `Shape ${this._layerMgr.layers.length + 1}`;
layer = this._layerMgr.addLayer("draw", layerName, {
contentW: this._canvasW, contentH: this._canvasH,
displayW: this._canvasW, displayH: this._canvasH,
x: 0, y: 0,
});
this._layerMgr.setActive(layer.id);
}
this._shapeLayerId = layer.id;
ShapeTool.drawShape(layer.ctx, shapeObj);
this._updateCompositeView();
this._refreshLayerList();
});
this._text3dTool = new Text3DTool(drawCanvas);
this._text3dTool.onCommit(({ dataUrl, contentW, contentH, displayW, displayH, x, y, props }) => {
const label = (props.text.slice(0, 20).replace(/\n/g, " ").trim()) || "3D Text";
const img = new Image();
img.onload = () => {
if (this._editing3DTextLayer) {
const layer = this._editing3DTextLayer;
this._editing3DTextLayer = null;
this._saveUndo();
layer.canvas.width = contentW;
layer.canvas.height = contentH;
layer.ctx = layer.canvas.getContext("2d");
layer.ctx.drawImage(img, 0, 0);
layer.x = x; layer.y = y;
layer.displayW = displayW; layer.displayH = displayH;
layer.name = label;
layer.text3dProps = props;
this._selectTool?.setLayer(layer);
} else {
this._saveUndo();
const layer = this._layerMgr.addLayer("image", label, {
contentW, contentH, displayW, displayH, x, y,
});
layer.ctx.drawImage(img, 0, 0);
layer.text3dProps = props;
this._layerMgr.setActive(layer.id);
this._selectTool?.setLayer(layer);
}
this._setActiveTool("select");
this._updateCompositeView();
this._refreshLayerList();
};
img.src = dataUrl;
});
this._text3dTool.onCancel(() => {
this._editing3DTextLayer = null;
this._setActiveTool("select");
});
this._fillTool = new FillTool();
this._selectTool = new SelectTool();
const selectOverlayCanvas = document.getElementById("ie-canvas-select-overlay");
this._selectTool.setCanvas(selectOverlayCanvas, this._selectMargin);
this._selectTool.onChange(eventType => {
if (eventType === "transformEnd") {
const sel = this._selectTool.getSelectedLayer();
if (sel?.type === "text" && sel.textProps) {
this._rerenderTextLayer(sel);
}
this._refreshLayerList();
}
this._updateCompositeView();
});
this._maskTool = new MaskTool(null);
this._maskTool.onChange(() => {
this._updateCompositeView();
this._refreshLayerList();
});
this._compositeMode = false;
}
// drawCanvas ← activeLayerのみ(描画前リセット、compositeMode = false)
_loadActiveLayerToCanvas() {
const layer = this._layerMgr?.activeLayer;
const drawCanvas = document.getElementById("ie-canvas-draw");
if (!layer || !drawCanvas) return;
const ctx = drawCanvas.getContext("2d");
ctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
ctx.drawImage(layer.canvas, 0, 0,
layer.displayW, layer.displayH,
layer.x, layer.y,
layer.displayW, layer.displayH);
this._compositeMode = false;
}
// drawCanvas → activeLayer に保存(compositeMode=true時はスキップ)
_syncActiveLayerFromCanvas() {
if (this._compositeMode) return;
const layer = this._layerMgr?.activeLayer;
const drawCanvas = document.getElementById("ie-canvas-draw");
if (!layer || !drawCanvas) return;
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
const scaleX = layer.canvas.width / layer.displayW;
const scaleY = layer.canvas.height / layer.displayH;
layer.ctx.save();
layer.ctx.scale(scaleX, scaleY);
layer.ctx.drawImage(drawCanvas, -layer.x * scaleX, -layer.y * scaleY);
layer.ctx.restore();
}
// maskApply=true のマスクレイヤー群を、直後の1レイヤー(ターゲット)にグルーピングする
// layers[0]=front … layers[n-1]=back が前提
_computeMaskGroups(layers) {
const maskGroupMap = new Map();
const skipIndices = new Set();
let i = 0;
while (i < layers.length) {
if (layers[i].type === "mask" && layers[i].maskApply) {
const frontIdx = i;
const masks = [];
while (i < layers.length && layers[i].type === "mask" && layers[i].maskApply) {
masks.push(layers[i]);
skipIndices.add(i);
i++;
}
if (i < layers.length) {
maskGroupMap.set(frontIdx, { masks, target: layers[i], targetIdx: i });
skipIndices.add(i);
} else {
maskGroupMap.set(frontIdx, { masks, target: null, targetIdx: -1 });
}
} else {
i++;
}
}
return { maskGroupMap, skipIndices };
}
// 全可視レイヤーを変換付きで合成する共通ロジック(プレビュー/保存の両方から使用)
// showOverlay=true のときも、実際にオーバーレイを描くのは「アクティブなマスクレイヤー」のみ
// (マスク以外のレイヤーを選択中は非表示 = 編集対象でないマスクの赤色ガイドは出さない)
_renderLayersComposite(ctx, canvas, showOverlay) {
const layers = this._layerMgr.layers;
const { maskGroupMap, skipIndices } = this._computeMaskGroups(layers);
const activeLayer = this._layerMgr.activeLayer;
for (let j = layers.length - 1; j >= 0; j--) {
if (skipIndices.has(j)) {
if (maskGroupMap.has(j)) {
const group = maskGroupMap.get(j);
const groupShowOverlay = showOverlay && group.masks.includes(activeLayer);
this._renderMaskGroup(ctx, canvas, group.masks, group.target, groupShowOverlay);
}
continue;
}
const layer = layers[j];
if (!layer.visible) continue;
if (layer.type === "mask") {
if (showOverlay && layer === activeLayer) this._renderMaskLayerOverlay(ctx, layer);
} else if (layer.type === "adjustment") {
this._applyAdjustmentLayer(ctx, layer);
} else {
ctx.save();
ctx.globalAlpha = layer.opacity;
ctx.globalCompositeOperation = layer.blendMode;
Layer.applyTransform(ctx, layer);
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
ctx.restore();
}
}
this._applyGlobalOpacity(ctx, canvas);
}
// 全可視レイヤーを変換付きで合成 → drawCanvas(compositeMode = true)
_updateCompositeView() {
const drawCanvas = document.getElementById("ie-canvas-draw");
if (!drawCanvas || !this._layerMgr) return;
const ctx = drawCanvas.getContext("2d");
ctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
this._renderLayersComposite(ctx, drawCanvas, true);
this._compositeMode = true;
}
// 全体不透明度(imgeditタブの調整レイヤーパネルから移植): 合成結果全体に一括で適用する
_applyGlobalOpacity(ctx, canvas) {
if (this._globalOpacity >= 1.0) return;
const tmp = document.createElement("canvas");
tmp.width = canvas.width; tmp.height = canvas.height;
tmp.getContext("2d").drawImage(canvas, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.globalAlpha = this._globalOpacity;
ctx.drawImage(tmp, 0, 0);
ctx.restore();
}
// 調整レイヤー: 自分より下(すでにctxに描画済みの部分)全体にフィルタを適用する
_applyAdjustmentLayer(ctx, layer) {
const def = ADJ_DEFS[layer.adjType];
if (!def || !layer.adjValue) return;
const w = this._canvasW, h = this._canvasH;
const alpha = layer.opacity ?? 1.0;
const applyAdj = () => {
switch (layer.adjType) {
case "brightness":
case "contrast":
case "saturation":
case "hue":
case "sepia":
case "grayscale":
case "invert":
case "temperature":
_applyPixelAdj(ctx, w, h, layer.adjType, layer.adjValue);
break;
case "blur": _applyBlurFilter(ctx, w, h, layer.adjValue); break;
case "sharpen": _applySharpenFilter(ctx, w, h, layer.adjValue); break;
case "noise": _applyNoiseFilter(ctx, w, h, layer.adjValue); break;
case "vignette": _applyVignette(ctx, w, h, layer.adjValue); break;
}
};
if (alpha >= 1.0) {
applyAdj();
} else {
const before = ctx.getImageData(0, 0, w, h);
applyAdj();
const after = ctx.getImageData(0, 0, w, h);
const bd = before.data, ad = after.data;
for (let i = 0; i < bd.length; i++) {
ad[i] = Math.round(bd[i] * (1 - alpha) + ad[i] * alpha);
}
ctx.putImageData(after, 0, 0);
}
}
// マスクレイヤー群(複数可、文書順=重ね順・後勝ち)から1枚の合成マスクcanvasを作る
_buildMaskCanvas(maskLayers, W, H) {
const maskCanvas = document.createElement("canvas");
maskCanvas.width = W;
maskCanvas.height = H;
const mc = maskCanvas.getContext("2d");
for (let k = maskLayers.length - 1; k >= 0; k--) {
const ml = maskLayers[k];
if (!ml.visible) continue;
mc.save();
mc.globalAlpha = ml.opacity;
mc.globalCompositeOperation = ml.operation === "subtract" ? "destination-out" : "lighten";
Layer.applyTransform(mc, ml);
mc.drawImage(ml.canvas, -ml.canvas.width / 2, -ml.canvas.height / 2);
mc.restore();
}
return maskCanvas;
}
_renderMaskGroup(ctx, drawCanvas, maskLayers, targetLayer, showOverlay = true) {
const W = drawCanvas.width;
const H = drawCanvas.height;
if (!targetLayer) {
if (showOverlay) {
for (const ml of maskLayers) {
if (ml.visible) this._renderMaskLayerOverlay(ctx, ml);
}
}
return;
}
const maskCanvas = this._buildMaskCanvas(maskLayers, W, H);
// 調整レイヤー: マスクの範囲だけに効果を適用する(非マスク部分は元の絵に戻す)
if (targetLayer.type === "adjustment") {
if (targetLayer.visible) {
const before = document.createElement("canvas");
before.width = W;
before.height = H;
before.getContext("2d").drawImage(ctx.canvas, 0, 0);
this._applyAdjustmentLayer(ctx, targetLayer);
ctx.save();
ctx.globalCompositeOperation = this._maskInverted ? "destination-out" : "destination-in";
ctx.drawImage(maskCanvas, 0, 0);
ctx.restore();
ctx.save();
ctx.globalCompositeOperation = "destination-over";
ctx.drawImage(before, 0, 0);
ctx.restore();
}
if (showOverlay) {
for (const ml of maskLayers) {
if (ml.visible) this._renderMaskLayerOverlay(ctx, ml);
}
}
return;
}
const tmp = document.createElement("canvas");
tmp.width = W;
tmp.height = H;
const tc = tmp.getContext("2d");
if (targetLayer.visible) {
tc.save();
tc.globalAlpha = targetLayer.opacity;
tc.globalCompositeOperation = targetLayer.blendMode;
Layer.applyTransform(tc, targetLayer);
tc.drawImage(targetLayer.canvas, -targetLayer.canvas.width / 2, -targetLayer.canvas.height / 2);
tc.restore();
}
tc.globalCompositeOperation = this._maskInverted ? "destination-out" : "destination-in";
tc.drawImage(maskCanvas, 0, 0);
ctx.drawImage(tmp, 0, 0);
if (showOverlay) {
for (const ml of maskLayers) {
if (ml.visible) this._renderMaskLayerOverlay(ctx, ml);
}
}
}
// ── ズーム・パン ──────────────────────────────
_fitToView() {
const wrap = document.getElementById("ie-canvas-wrap");
if (!wrap || !this._canvasW) return;
this._zoom = Math.min((wrap.clientWidth - 40) / this._canvasW, (wrap.clientHeight - 40) / this._canvasH, 2.0);
this._panOffset = { x: 0, y: 0 };
this._applyTransform();
}
_setZoom(z) {
this._zoom = Math.max(0.05, Math.min(10, z));
this._applyTransform();
}
_applyTransform() {
const container = document.getElementById("ie-canvas-container");
const wrap = document.getElementById("ie-canvas-wrap");
if (!container || !wrap) return;
const tx = this._panOffset.x + (wrap.clientWidth - this._canvasW * this._zoom) / 2;
const ty = this._panOffset.y + (wrap.clientHeight - this._canvasH * this._zoom) / 2;
container.style.transform = `translate(${tx}px,${ty}px) scale(${this._zoom})`;
const zl = document.getElementById("ie-zoom-label");
if (zl) zl.textContent = Math.round(this._zoom * 100) + "%";
}
// ── レイヤーパネル ────────────────────────────
_setupLayerPanel() {
document.getElementById("ie-add-layer-btn")?.addEventListener("click", () => {
if (!this._layerMgr) return;
this._syncActiveLayerFromCanvas();
this._saveUndo();
const layer = this._layerMgr.addLayer("draw", `Layer ${this._layerMgr.layers.length + 1}`);
layer.displayW = this._canvasW; layer.displayH = this._canvasH;
layer.x = 0; layer.y = 0;
this._loadActiveLayerToCanvas();
this._updateCompositeView();
this._activateCurrentTool();
});
document.getElementById("ie-add-mask-btn")?.addEventListener("click", () => {
if (!this._layerMgr) { this._toast(window.t("image.msg.openImageFirst"), "info"); return; }
this._syncActiveLayerFromCanvas();
this._saveUndo();
const maskCount = this._layerMgr.layers.filter(l => l.type === "mask").length + 1;
const layer = this._layerMgr.addLayer("mask", `Mask ${maskCount}`);
layer.displayW = this._canvasW;
layer.displayH = this._canvasH;
layer.x = 0;
layer.y = 0;
this._layerMgr.setActive(layer.id);
this._setActiveTool("mask");
this._loadActiveLayerToCanvas();
this._updateCompositeView();
this._refreshLayerList();
document.getElementById("ie-placeholder").style.display = "none";
this._toast(window.t("image.msg.maskLayerAdded"), "success");
});
document.getElementById("ie-adj-add-btn")?.addEventListener("click", () => {
if (!this._layerMgr) { this._toast(window.t("image.msg.openImageFirst"), "info"); return; }
const select = document.getElementById("ie-adj-type-select");
const adjType = select?.value || "brightness";
const def = ADJ_DEFS[adjType];
if (!def) return;
this._syncActiveLayerFromCanvas();
this._saveUndo();
const layer = this._layerMgr.addLayer("adjustment", def.label, {
adjType, adjValue: def.defaultValue,
displayW: this._canvasW, displayH: this._canvasH, x: 0, y: 0,
});
this._layerMgr.setActive(layer.id);
this._selectTool?.clearSelection();
this._renderAdjustProps(layer);
this._updateCompositeView();
this._refreshLayerList();
document.getElementById("ie-placeholder").style.display = "none";
this._toast(window.t("image.msg.layerAdded", def.label), "success");
});
document.getElementById("ie-del-layer-btn")?.addEventListener("click", () => {
if (!this._layerMgr || this._layerMgr.layers.length <= 1) return;
this._saveUndo();
const active = this._layerMgr.activeLayer;
if (active) this._layerMgr.deleteLayer(active.id);
if (this._selectTool?.getSelectedLayer()?.id === active?.id) {
this._selectTool.clearSelection();
}
this._updateCompositeView();
this._activateCurrentTool();
});
document.getElementById("ie-layer-up-btn")?.addEventListener("click", () => {
if (!this._layerMgr) return;
const active = this._layerMgr.activeLayer;
if (active) { this._layerMgr.moveUp(active.id); this._updateCompositeView(); }
});
document.getElementById("ie-layer-down-btn")?.addEventListener("click", () => {
if (!this._layerMgr) return;
const active = this._layerMgr.activeLayer;
if (active) { this._layerMgr.moveDown(active.id); this._updateCompositeView(); }
});
document.getElementById("ie-layer-opacity")?.addEventListener("input", e => {
if (!this._layerMgr) return;
const v = parseInt(e.target.value) / 100;
const active = this._layerMgr.activeLayer;
if (active) { this._layerMgr.setOpacity(active.id, v); this._updateCompositeView(); }
const lbl = document.getElementById("ie-layer-opacity-label");
if (lbl) lbl.textContent = e.target.value + "%";
});
document.getElementById("ie-global-opacity")?.addEventListener("input", e => {
this._globalOpacity = parseInt(e.target.value) / 100;
const lbl = document.getElementById("ie-global-opacity-label");
if (lbl) lbl.textContent = e.target.value + "%";
this._updateCompositeView();
});
document.getElementById("ie-flatten-btn")?.addEventListener("click", () => this._flattenLayers());
document.getElementById("ie-group-btn")?.addEventListener("click", () => this._groupSelectedLayers());
document.getElementById("ie-layer-duplicate-btn")?.addEventListener("click", () => {
if (!this._layerMgr) { this._toast(window.t("image.msg.openImageFirst"), "info"); return; }
const active = this._layerMgr.activeLayer;
if (!active) { this._toast(t("image.duplicateSelectFirst"), "info"); return; }
this._syncActiveLayerFromCanvas();
this._saveUndo();
const copy = this._layerMgr.duplicateLayer(active.id);
if (copy) {
this._updateCompositeView();
this._refreshLayerList();
this._toast(t("image.duplicateDone"), "success");
}
});
}
// 統合ボタン: レイヤーパネルで2枚以上選択中なら選択レイヤーのみ統合、それ以外は全レイヤーを1枚のimageレイヤーに統合する
// (imgeditタブの「統合」機能を移植。全統合は既存挙動のまま、選択統合を追加)
_flattenLayers() {
if (!this._layerMgr || this._layerMgr.layers.length === 0) return;
const selectedIds = [...this._selectedLayerIds];
if (selectedIds.length >= 2) {
this._mergeSelectedLayers(selectedIds);
return;
}
if (this._layerMgr.layers.length === 1 && this._layerMgr.layers[0].type === "image") {
this._toast(window.t("image.msg.nothingToFlatten"), "info");
return;
}
if (!confirm(t("image.flattenConfirm"))) return;
this._syncActiveLayerFromCanvas();
this._saveUndo();
const canvas = this._buildCompositeCanvas();
const w = this._canvasW, h = this._canvasH;
const flat = new Layer("Flattened", "image", w, h);
flat.displayW = w; flat.displayH = h;
flat.ctx.drawImage(canvas, 0, 0);
this._layerMgr.replaceAllWith(flat);
this._selectedLayerIds = new Set();
this._selectTool?.clearSelection();
this._activateCurrentTool();
this._updateCompositeView();
this._refreshLayerList();
this._toast(window.t("image.msg.flattened"), "success");
}
// 選択中の2枚以上のレイヤーのみを1枚に統合する(他のレイヤーはそのまま残す)
_mergeSelectedLayers(selectedIds) {
if (!confirm(t("image.mergeSelectedConfirm", selectedIds.length))) return;
this._syncActiveLayerFromCanvas();
this._saveUndo();
const merged = this._layerMgr.mergeLayers(selectedIds);
if (!merged) return;
this._selectedLayerIds = new Set([merged.id]);
this._selectTool?.clearSelection();
this._activateCurrentTool();
this._updateCompositeView();
this._refreshLayerList();
this._toast(t("image.mergeSelectedDone"), "success");
}
// レイヤーパネルで2枚以上(Shift+クリック)選択中のレイヤーを1つのグループ(フォルダ)にまとめる。
// ピクセル内容は一切変更せず、各レイヤーのgroupIdフィールドにグループIDを持たせるだけなので、
// 合成順(layers配列の並び)や統合(mergeLayers)には影響しない
_groupSelectedLayers() {
if (!this._layerMgr) return;
if (this._selectedLayerIds.size < 2) {
this._toast(t("image.groupSelectFirst"), "info");
return;
}
this._saveUndo();
const groupId = this._layerMgr.createGroup([...this._selectedLayerIds]);
if (!groupId) {
this._toast(t("image.groupSelectFirst"), "info");
return;
}
this._refreshLayerList();
this._toast(t("image.groupDone"), "success");
}
// アクティブレイヤーにPixiJSパーティクル/フィルタ効果を適用する(imgeditタブのPixiJS FX連携を移植)
_openPixiFx() {
if (!this._layerMgr) { this._toast(t("image.noImageLoaded"), "error"); return; }
const layer = this._layerMgr.activeLayer;
if (!layer) { this._toast(t("image.noActiveLayer"), "error"); return; }
this._syncActiveLayerFromCanvas();
const dataUrl = layer.canvas.toDataURL("image/png");
pixiFxOpen({
imageDataUrl: dataUrl,
onApply: async (resultDataUrl) => {
try {
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error("Failed to load result image"));
i.src = resultDataUrl;
});
this._saveUndo();
const active = this._layerMgr.activeLayer;
if (!active) return;
active.canvas.width = img.width;
active.canvas.height = img.height;
active.ctx = active.canvas.getContext("2d");
active.ctx.drawImage(img, 0, 0);
this._updateCompositeView();
this._refreshLayerList();
this._toast(t("image.pixifxApplied"), "success");
} catch (err) {
this._toast(t("image.pixifxApplyError", err.message), "error");
}
},
});
}
/** レイヤー1件分の行HTML(グループメンバーの場合はindented=trueでインデントを付ける) */
_renderLayerRowHtml(layer, isActive, isMultiSelected, indented) {
const typeIcon = layer.type === "image" ? "🖼"
: layer.type === "text" ? "T"
: layer.type === "mask" ? "⬚"
: layer.type === "adjustment" ? ""
: "✏";
const maskApplyBtn = layer.type === "mask"
? `<button class="ie-layer-vis-btn" data-id="${layer.id}" data-action="mask-apply"
title="${layer.maskApply ? "Disable clipping mask" : "Enable as clipping mask"}"
style="color:${layer.maskApply ? "var(--it-primary)" : "inherit"};font-size:11px;">✂</button>
<button class="ie-layer-vis-btn" data-id="${layer.id}" data-action="mask-op"
title="${layer.operation === "subtract" ? "Mode: Subtract (click to switch to Add)" : "Mode: Add (click to switch to Subtract)"}"
style="font-size:10px;font-weight:bold;min-width:16px;color:${layer.operation === "subtract" ? "#e2534a" : "#4db84d"};">${layer.operation === "subtract" ? "S" : "A"}</button>`
: "";
return `
<div class="ie-layer-item ${isActive ? "active" : ""} ${isMultiSelected ? "multi-selected" : ""}${indented ? " ie-layer-item-grouped" : ""}" data-id="${layer.id}" data-action="select" data-type="${layer.type}">
<button class="ie-layer-vis-btn" data-id="${layer.id}" data-action="vis"
title="${layer.visible ? "Hide" : "Show"}">${layer.visible ? "👁" : "🚫"}</button>
<button class="ie-layer-vis-btn" data-id="${layer.id}" data-action="lock"
title="${layer.locked ? "Unlock" : "Lock"}"
style="color:${layer.locked ? "#e2a04a" : "inherit"}">${layer.locked ? "🔒" : "🔓"}</button>
${maskApplyBtn}
<img class="ie-layer-thumb" src="${layer.getThumbnailDataURL()}" draggable="false">
<span class="ie-layer-type-icon" style="font-size:10px;opacity:0.7;flex-shrink:0;">${typeIcon}</span>
<span class="ie-layer-name">${layer.name}</span>
</div>
`;
}
/** グループ(フォルダ)のヘッダー行HTML */
_renderGroupHeaderHtml(group, members) {
const anyVisible = members.some(l => l.visible);
const allVisible = members.every(l => l.visible);
return `
<div class="ie-layer-group-header" data-group-id="${group.id}" data-action="group-toggle">
<button class="ie-layer-vis-btn" data-group-id="${group.id}" data-action="group-vis"
title="${allVisible ? "Hide group" : "Show group"}">${anyVisible ? "👁" : "🚫"}</button>
<span class="ie-layer-group-icon">${group.collapsed ? "▶" : "▼"}</span>
<span class="ie-layer-group-name">📁 ${group.name}</span>
<span class="ie-layer-group-count">${members.length}</span>
<button class="ie-layer-vis-btn" data-group-id="${group.id}" data-action="ungroup" title="${t("image.ungroupTitle")}">${t("image.ungroup")}</button>
</div>
`;
}
_refreshLayerList() {
const el = document.getElementById("ie-layer-list");
if (!el || !this._layerMgr) return;
// レイヤー削除等で選択済みIDが失効している場合に備えてクリーンアップ
const liveIds = new Set(this._layerMgr.layers.map(l => l.id));
for (const id of this._selectedLayerIds) if (!liveIds.has(id)) this._selectedLayerIds.delete(id);
const layers = this._layerMgr.layers;
const renderedGroups = new Set();
const rows = [];
layers.forEach((layer, i) => {
if (layer.groupId) {
if (renderedGroups.has(layer.groupId)) return; // このグループは最初のメンバー時にまとめて出力済み
renderedGroups.add(layer.groupId);
const group = this._layerMgr.groups.find(g => g.id === layer.groupId);
if (!group) { // データ不整合フォールバック(本来起こらない): 通常行として表示
rows.push(this._renderLayerRowHtml(layer, i === this._layerMgr.activeIndex, this._selectedLayerIds.has(layer.id), false));
return;
}
// 元のlayers配列の並び順を保ったまま、このグループの全メンバーをまとめて集める
// (配列上は他のレイヤーと入り混じっていても、表示上はヘッダー直下に並べる)
const members = layers.filter(l => l.groupId === layer.groupId);
rows.push(this._renderGroupHeaderHtml(group, members));
if (!group.collapsed) {
members.forEach(m => {
const mi = layers.indexOf(m);
rows.push(this._renderLayerRowHtml(m, mi === this._layerMgr.activeIndex, this._selectedLayerIds.has(m.id), true));
});
}
return;
}
rows.push(this._renderLayerRowHtml(layer, i === this._layerMgr.activeIndex, this._selectedLayerIds.has(layer.id), false));
});
el.innerHTML = rows.join("");
const flattenBtn = document.getElementById("ie-flatten-btn");
if (flattenBtn) {
const n = this._selectedLayerIds.size;
flattenBtn.title = n >= 2 ? t("image.flattenSelectedTitle", n) : t("image.flattenTitle");
}
const groupBtn = document.getElementById("ie-group-btn");
if (groupBtn) groupBtn.title = t(this._selectedLayerIds.size >= 2 ? "image.groupTitle" : "image.groupSelectFirst");
el.querySelectorAll("[data-action]").forEach(node => {
node.addEventListener("click", e => {
e.stopPropagation();
const id = node.dataset.id;
const action = node.dataset.action;
if (action === "group-toggle") {
this._layerMgr.toggleGroupCollapsed(node.dataset.groupId);
this._refreshLayerList();
} else if (action === "group-vis") {
const gid = node.dataset.groupId;
const allVisible = this._layerMgr.layers.filter(l => l.groupId === gid).every(l => l.visible);
this._layerMgr.setGroupVisible(gid, !allVisible);
this._updateCompositeView();
this._refreshLayerList();
} else if (action === "ungroup") {
this._layerMgr.ungroup(node.dataset.groupId);
this._refreshLayerList();
} else if (action === "vis") {
this._layerMgr.toggleVisible(id);
this._updateCompositeView();
} else if (action === "mask-apply") {
this._layerMgr.toggleMaskApply(id);
this._updateCompositeView();
this._refreshLayerList();
} else if (action === "mask-op") {
this._layerMgr.toggleOperation(id);
this._updateCompositeView();
this._refreshLayerList();
} else if (action === "lock") {
this._layerMgr.toggleLocked(id);
const sel = this._selectTool?.getSelectedLayer();
if (sel?.id === id) this._selectTool?.setLayer(sel);
this._refreshLayerList();
} else if (action === "select") {
if (e.shiftKey) {
// Shift+クリック: 複数選択のトグル。まだ何も複数選択されていなければ、
// 現在のアクティブレイヤーを起点として含めてからトグルする
if (this._selectedLayerIds.size === 0 && this._layerMgr.activeLayer) {
this._selectedLayerIds.add(this._layerMgr.activeLayer.id);
}
if (this._selectedLayerIds.has(id)) this._selectedLayerIds.delete(id);
else this._selectedLayerIds.add(id);
} else {
this._selectedLayerIds = new Set([id]);
}
this._syncActiveLayerFromCanvas();
this._layerMgr.setActive(id);
const layer = this._layerMgr.activeLayer;
if (layer?.type === "adjustment") {
this._selectTool?.clearSelection();
this._renderAdjustProps(layer);
this._updateCompositeView();
} else {
// 調整レイヤー以外を選んだ場合、プロパティ表示は現在選択中のツールのものに
// 切り替える(該当ツールにプロパティ表示が無ければ非表示のまま)
const pane = document.getElementById("ie-props-pane");
if (pane) pane.style.display = "none";
this._renderToolOptions(this._activeTool);
}
if (layer?.type === "mask") {
if (this._activeTool !== "mask") {
this._setActiveTool("mask");
} else {
this._maskTool?.setCanvas(layer.canvas);
this._maskTool?.activate();
}
if (this._maskTool) {
this._maskTool.mode = layer.operation === "subtract" ? "erase" : "paint";
this._renderToolOptions("mask");
}
this._updateCompositeView();
} else if (layer?.type === "adjustment") {
// 上でpropsペイン表示・再描画済み
} else if (this._activeTool === "draw" && layer) {
this._drawTool?.setCanvas(layer.canvas);
this._updateCompositeView();
} else if (layer) {
// select以外のツール(テキスト等)がアクティブな間も、レイヤーパネルでの選択操作を
// SelectTool内部の選択状態に同期しておく。これによりオーバーレイの枠表示で
// 現在の対象が分かるようになり、スタイルモーダル等が正しいレイヤーを参照できる
this._selectTool?.setLayer(layer);
this._updateCompositeView();
} else {
this._updateCompositeView();
}
this._refreshLayerList();
if (layer) {
const sl = document.getElementById("ie-layer-opacity");
const lb = document.getElementById("ie-layer-opacity-label");
if (sl) sl.value = Math.round(layer.opacity * 100);
if (lb) lb.textContent = Math.round(layer.opacity * 100) + "%";
}
}
});
});
}
// ── Undo / Redo ──────────────────────────────
_saveUndo() {
if (!this._layerMgr) return;
this._syncActiveLayerFromCanvas();
const state = JSON.stringify(this._layerMgr.toJSON());
this._undoStack.push(state);
if (this._undoStack.length > UNDO_LIMIT) this._undoStack.shift();
this._redoStack = [];
}
async _undo() {
if (!this._layerMgr || this._undoStack.length === 0) return;
this._syncActiveLayerFromCanvas();
this._redoStack.push(JSON.stringify(this._layerMgr.toJSON()));
await this._restoreState(this._undoStack.pop());
}
async _redo() {
if (!this._layerMgr || this._redoStack.length === 0) return;
this._syncActiveLayerFromCanvas();
this._undoStack.push(JSON.stringify(this._layerMgr.toJSON()));
await this._restoreState(this._redoStack.pop());
}
async _restoreState(jsonStr) {
const json = JSON.parse(jsonStr);
await this._layerMgr.fromJSON(json);
// クロップ等でキャンバスサイズが変わる操作もUndo/Redo対象のため、layerMgr.width/heightが
// 現在の_canvasW/_canvasH・実DOM canvasサイズと食い違っていたら合わせて復元する
if (this._layerMgr.width !== this._canvasW || this._layerMgr.height !== this._canvasH) {
this._canvasW = this._layerMgr.width;
this._canvasH = this._layerMgr.height;
this._resizeCanvasElements(this._canvasW, this._canvasH);
}
if (this._cropMode) this._exitCropMode();
if (this._extendMode) this._exitExtendMode();
this._selectTool?.clearSelection();
this._updateCompositeView();
this._activateCurrentTool();
this._refreshLayerList();
this._renderToolOptions(this._activeTool);
}
// ── 合成・保存 ─────────────────────────────────
_buildCompositeCanvas() {
const canvas = document.createElement("canvas");
canvas.width = this._canvasW;
canvas.height = this._canvasH;
if (!this._layerMgr) return canvas;
this._compositeForExport(canvas);
return canvas;
}
// 保存用合成: maskApply=true のクリッピングを適用、マスクオーバーレイは除外
_compositeForExport(target) {
const ctx = target.getContext("2d");
ctx.clearRect(0, 0, target.width, target.height);
this._renderLayersComposite(ctx, target, false);
}
_savePng() {
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return; }
const canvas = this._buildCompositeCanvas();
const a = document.createElement("a");
a.href = canvas.toDataURL("image/png");
a.download = (this._baseName || "image") + "-output.png";
a.click();
this._toast(window.t("image.msg.pngSaved"), "success");
}
// _renderLayersComposite と同じ走査順(背面→前面)でレイヤーを辿り、PSDの1レイヤーずつに対応する
// 独立したフルサイズcanvasの配列を作る(回転・反転・拡縮はベイクするが、opacity/blendModeは
// ピクセルに焼き込まずPSDレイヤー属性として別送りする=PSD側の合成で一度だけ適用される)。
// maskApply=trueのマスク群は対象レイヤーへ焼き込んで1枚にまとめる。調整レイヤーはPSDに独立した
// レイヤーとして表現できないため、直下の1枚へ効果を焼き込んでから除外する(本来は自分より下の
// 全レイヤーの累積結果に効くが、独立レイヤーへの分解では直下の1枚に対する近似に留める簡略化)。
_buildPsdExportLayers() {
const layers = this._layerMgr.layers;
const { maskGroupMap, skipIndices } = this._computeMaskGroups(layers);
const w = this._canvasW, h = this._canvasH;
const baked = []; // 背面→前面の順(PSD側で後から追加したレイヤーほど上に重なる)
const bakeLayer = (layer) => {
const canvas = document.createElement("canvas");
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.save();
Layer.applyTransform(ctx, layer);
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
ctx.restore();
return canvas;
};
for (let j = layers.length - 1; j >= 0; j--) {
if (skipIndices.has(j)) {
if (!maskGroupMap.has(j)) continue;
const group = maskGroupMap.get(j);
if (!group.target || !group.target.visible || group.target.type === "adjustment") continue;
const canvas = document.createElement("canvas");
canvas.width = w; canvas.height = h;
this._renderMaskGroup(canvas.getContext("2d"), canvas, group.masks, group.target, false);
baked.push({ canvas, name: group.target.name || "Layer", visible: true, opacity: 1, blendMode: "source-over" });
continue;
}
const layer = layers[j];
if (!layer.visible || layer.type === "mask") continue; // maskApply=falseの無効マスクは書き出し対象外
if (layer.type === "adjustment") {
const prev = baked[baked.length - 1]; // 直下(既に処理済み=より背面)の可視レイヤー
if (prev) this._applyAdjustmentLayer(prev.canvas.getContext("2d"), layer);
continue;
}
baked.push({ canvas: bakeLayer(layer), name: layer.name || "Layer", visible: layer.visible, opacity: layer.opacity, blendMode: layer.blendMode });
}
return baked.map(l => ({
name: l.name, visible: l.visible, opacity: l.opacity, blendMode: l.blendMode,
imageData: l.canvas.toDataURL("image/png"),
}));
}
// レイヤー編集状態を1枚のPSD(Photoshop)ファイルとして書き出す。
async _saveAsPsd() {
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return; }
this._syncActiveLayerFromCanvas();
const exportLayers = this._buildPsdExportLayers();
if (exportLayers.length === 0) { this._toast(window.t("image.msg.noLayersToExport"), "error"); return; }
try {
const r = await fetch("/api/ccc/psd/export-layers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ width: this._canvasW, height: this._canvasH, layers: exportLayers }),
});
if (!r.ok) {
const e = await r.json().catch(() => ({}));
throw new Error(e.message || r.statusText);
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = (this._baseName || "comic-creator-image") + ".psd";
a.click();
URL.revokeObjectURL(url);
this._toast(window.t("image.msg.psdSaved"), "success");
} catch (err) {
this._toast(window.t("image.msg.psdSaveFailed") + err.message, "error");
}
}
// レイヤー編集状態(LayerManager.toJSON、canvas内容込み)をサムネイルPNGとペアで
// アセットパネルの Image フォルダへ保存する。アセットパネルから再度読み込めば
// loadProjectFromUrl() でレイヤー構成ごと編集を再開できる。
/** @returns {Promise<boolean>} 保存できた(キャンセル・失敗ではない)か。Closeダイアログの「保存して閉じる」から呼び出す際の可否判定に使う */
async _saveProject() {
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return false; }
const defaultName = this._baseName || "image";
const filename = window.prompt(window.t("image.msg.saveProjectPrompt"), defaultName);
if (filename === null) return false; // キャンセル
const safeName = filename.trim() || defaultName;
const canvas = this._buildCompositeCanvas();
const thumbnail = canvas.toDataURL("image/png");
const project = JSON.stringify(this._layerMgr.toJSON());
try {
const r = await fetch("/api/ccc/save-image-project", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: safeName, thumbnail, project }),
});
const data = await r.json();
if (data.status !== "ok") throw new Error(data.message || "unknown error");
this._toast(window.t("image.msg.projectSaved", safeName), "success");
loadAssets(true);
return true;
} catch (err) {
this._toast(window.t("image.msg.saveProjectFailed") + err.message, "error");
return false;
}
}
// comic-creator既存のEagle連携(main.js の saveToEagle)を使って保存する。
// workflow studio側の「Save to Gallery」に相当(wfmギャラリーが無いためEagleに差し替え)。
async _saveToEagle() {
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return; }
const now = new Date();
const pad = n => String(n).padStart(2, "0");
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const defaultName = `cc-image-${ts}`;
const filename = window.prompt(window.t("image.msg.saveEaglePrompt"), defaultName);
if (filename === null) return; // キャンセル
const safeName = filename.trim() || defaultName;
const canvas = this._buildCompositeCanvas();
const dataUrl = canvas.toDataURL("image/png");
try {
const { ok, message } = await saveToEagle(dataUrl, safeName, ["comfyui-comic-creator", "image-tab"]);
if (ok) this._toast(window.t("image.msg.savedToEagle", safeName), "success");
else this._toast(message ? `Eagle save failed: ${message}` : "Eagle save failed", "error");
} catch (err) {
this._toast(window.t("image.msg.eagleSaveFailed") + err.message, "error");
}
}
// ComfyUIの標準アップロードAPI(/upload/image)に type=output, subfolder=cc を指定して保存する。
// フォルダが存在しなくてもComfyUI側(server.py image_upload)が自動作成する。
async _saveToGallery() {
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return; }
const canvas = this._buildCompositeCanvas();
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
const now = new Date();
const pad = n => String(n).padStart(2, "0");
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const file = new File([blob], `cc-image-${ts}.png`, { type: "image/png" });
const form = new FormData();
form.append("image", file);
form.append("type", "output");
form.append("subfolder", "cc");
form.append("overwrite", "true");
try {
const r = await fetch("/upload/image", { method: "POST", body: form });
const data = await r.json();
this._toast(window.t("image.msg.savedToGallery", data.subfolder, data.name), "success");
} catch (err) {
this._toast(window.t("image.msg.saveGalleryFailed") + err.message, "error");
}
}
// 合成結果をアップロードし、window.opener(Comic CreatorはComfyUIの「CC」ボタンから
// window.open()で開かれるポップアップのため、opener=ComfyUI本体)経由でComfyUIキャンバス上の
// 選択中ノード(LoadImage等のimageウィジェットを持つノード。無ければグラフ内の最初の該当ノード
// にフォールバック)へファイル名を書き込む。Workflow Studio Image Editタブの「Send to Workflow」
// ボタン(FileExport.sendToWorkflow)と同じブリッジ(web/comfyui/node_sets_menu.js の
// window.wfmSendImageToSelectedNode)を利用する。
async _sendToLoadImageNode() {
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return; }
if (!window.opener || typeof window.opener.wfmSendImageToSelectedNode !== "function") {
this._toast(window.t("image.msg.openFromComfyMenu"), "error");
return;
}
const canvas = this._buildCompositeCanvas();
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
const file = new File([blob], (this._baseName || "cc-image") + "-output.png", { type: "image/png" });
const form = new FormData();
form.append("image", file);
form.append("overwrite", "true");
try {
const r = await fetch("/upload/image", { method: "POST", body: form });
const data = await r.json();
const filename = data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
window.opener.wfmSendImageToSelectedNode(filename);
this._toast(window.t("image.msg.sentToWorkflow", filename), "success");
} catch (err) {
this._toast(window.t("image.msg.sendWorkflowFailed", err.message), "error");
}
}
// 合成結果をWorkflow StudioのGenerate UI Image入力スロットへ送信する(I2I連携・旧I2I機能)
async _sendToI2I() {
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return; }
const canvas = this._buildCompositeCanvas();
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
await sendImageToWorkflowStudioI2I(blob, this._baseName || "cc-image", "image");
}
/** 外部(アセットパネル等)から画像URLをロードするための公開API */
async loadFromUrl(url, name) {
try {
const r = await fetch(url);
const blob = await r.blob();
const dataUrl = await new Promise(resolve => {
const reader = new FileReader();
reader.onload = e => resolve(e.target.result);
reader.readAsDataURL(blob);
});
await this._loadFromDataUrl(dataUrl, name || url.split("/").pop().replace(/\.[^.]+$/, "") || "external-image");
} catch {
this._toast(window.t("image.msg.loadUrlFailed"), "error");
}
}
/** アセットパネルの Image プロジェクトアセット(.json、_saveProject()で保存)から
* レイヤー編集状態を復元して開く。常に新規キャンバスとして開始する(既存レイヤーとは混在させない)。 */
async loadProjectFromUrl(url, name) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
this._canvasW = data.width || 512;
this._canvasH = data.height || 512;
this._baseName = name || "image";
this._sourceImageEl = null; // プロジェクト読み込みはレイアウトへの書き戻し対象外
this._initCanvases();
await this._layerMgr.fromJSON(data);
this._undoStack = [];
this._redoStack = [];
this._setActiveTool("select");
this._refreshLayerList();
this._updateCompositeView();
this._fitToView();
document.getElementById("ie-placeholder").style.display = "none";
this._toast(window.t("image.msg.projectLoaded", this._baseName), "success");
} catch (err) {
this._toast(window.t("image.msg.loadProjectFailed") + err.message, "error");
}
}
// ── レイアウトタブ連携(imgeditタブの開く/保存フローを移植) ──
/** レイアウトタブの「画像タブで編集」ボタンから、選択中のSVG <image> 要素を開く */
async loadFromSvgElement(imgEl) {
const href = imgEl.getAttribute("href") || imgEl.getAttribute("xlink:href") || "";
if (!href.startsWith("data:")) {
this._toast(t("image.notBase64Error"), "error");
return;
}
// レイアウトから開く場合は常に単一画像の新規キャンバスとして開く(既存の作業中レイヤーとは混在させない)
this._layerMgr = null;
this._undoStack = [];
this._redoStack = [];
this._sourceImageEl = imgEl;
const name = imgEl.dataset?.name || imgEl.id || "layout-image";
await this._loadFromDataUrl(href, name);
}
/**
* レイアウトタブへ編集結果を送る(imgeditタブの「保存してレイアウトに戻る」に相当)。
* レイアウトタブの「画像タブで編集」から開いた画像の場合は元のSVG <image> 要素へ書き戻し、
* New/Uploadで作成した新規ドキュメントの場合はレイアウトの選択中コマ/オーバーレイに新規画像として挿入する。
*/
async _saveToLayout() {
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return; }
this._syncActiveLayerFromCanvas();
const canvas = this._buildCompositeCanvas();
const dataUrl = canvas.toDataURL("image/png");
if (this._sourceImageEl) {
try {
pushHistory();
this._sourceImageEl.setAttribute("href", dataUrl);
if (this._sourceImageEl.hasAttribute("xlink:href")) this._sourceImageEl.setAttribute("xlink:href", dataUrl);
const svgEl = this._sourceImageEl.closest("svg");
if (svgEl) {
const panelId = this._sourceImageEl.getAttribute("data-panel-id") ||
this._sourceImageEl.closest("[data-clip-panel]")?.getAttribute("data-clip-panel");
const isOverlay = svgEl.querySelector("g[data-overlay-layer]")?.contains(this._sourceImageEl) ?? false;
if (isOverlay) {
await saveOverlaySvg(svgEl);
} else if (panelId) {
await savePanelSvg(panelId, svgEl);
}
}
this._toast(window.t("image.msg.savedToLayout"), "success");
await switchTab("layout");
} catch (err) {
this._toast(window.t("image.msg.saveLayoutFailed") + err.message, "error");
}
return;
}
// 新規ドキュメント: レイアウトの選択中コマ/オーバーレイへ新規画像として挿入
try {
// 「下書き」ボタンで作成したキャンバス(作品と同じ縦横比の72dpi換算サイズ)は、
// 現在レイアウト側で何が選択されていても常に下書きレイヤーへページ全面サイズで挿入する。
// これにより、下書き=ページ全体のラフスケッチという対応関係が崩れず、
// 通常の40%センター配置の既定値による縮小表示を避けられる
let placement = null;
if (this._baseName === "draft") {
selectPanel("__draft__");
const work = state.activeWork;
if (work && work.width && work.height) {
placement = { x: 0, y: 0, width: work.width, height: work.height };
}
}
const inserted = await insertImage(dataUrl, canvas.width, canvas.height, {}, placement);
if (!inserted) return; // insertImage側でコマ未選択等のalertを表示済み
this._toast(window.t("image.msg.sentToLayout"), "success");
await switchTab("layout");
} catch (err) {
this._toast(window.t("image.msg.sendLayoutFailed") + err.message, "error");
}
}
// ── キーボードショートカット ──────────────────
_setupKeyboard() {
document.addEventListener("keydown", e => {
if (!document.getElementById("image-tab")?.classList.contains("active")) return;
if (e.key === " " && !e.target.closest("input, textarea, select")) {
e.preventDefault();
this._spaceDown = true;
const wrap = document.getElementById("ie-canvas-wrap");
if (wrap && !this._panning) wrap.style.cursor = "grab";
}
if (e.key === "Escape" && this._eyedropperActive) this._setEyedropperActive(false);
const brushPicker = document.getElementById("cc-abr-picker-overlay");
if (e.key === "Escape") brushPicker?.remove();
if (e.ctrlKey && e.key === "z") { e.preventDefault(); this._undo(); }
if (e.ctrlKey && e.key === "y") { e.preventDefault(); this._redo(); }
if (this._activeTool === "mask" && this._maskSubtool === "vector") {
this._maskVectorTool?.onKeyDown(e);
}
if (!e.ctrlKey && !e.target.closest("input, textarea, select")) {
if (e.key === "v") this._setActiveTool("select");
if (e.key === "b") {
if (this._activeTool === "draw") this._toggleDrawBrushPicker();
else this._setActiveTool("draw");
}
if (e.key === "t") this._setActiveTool("text");
if (e.key === "s") this._setActiveTool("shape");
if (e.key === "g") this._setActiveTool("fill");
if (this._activeTool === "draw" && !brushPicker && !e.altKey && !e.metaKey) {
if (e.key === "d") this._setDrawMode("draw");
if (e.key === "e") this._setDrawMode("erase");
if (e.key === "x") this._drawPalette.toggleWell();
if (e.key === "[") this._stepDrawBrushSize(-1);
if (e.key === "]") this._stepDrawBrushSize(1);
}
// Delete/Backspaceで選択オブジェクト削除
if ((e.key === "Delete" || e.key === "Backspace") && this._activeTool === "select") {
const layer = this._selectTool?.getSelectedLayer();
if (layer && this._layerMgr && this._layerMgr.layers.length > 1) {
this._saveUndo();
this._selectTool.clearSelection();
this._layerMgr.deleteLayer(layer.id);
this._updateCompositeView();
}
}
}
});
document.addEventListener("keyup", e => {
if (e.key === " ") {
this._spaceDown = false;
if (!this._panning) {
const wrap = document.getElementById("ie-canvas-wrap");
if (wrap) wrap.style.cursor = "";
}
}
});
}
// ── テキストオブジェクト再編集 ────────────────────
/** 座標上にある最前面の可視テキストレイヤーを返す(layers[0]が最前面) */
_findTextLayerAt(x, y) {
if (!this._layerMgr || !this._selectTool) return null;
for (const layer of this._layerMgr.layers) {
if (!layer.visible || layer.type !== "text" || !layer.textProps) continue;
if (this._selectTool._isPointInLayer(x, y, layer)) return layer;
}
return null;
}
/** canvasのダブルクリック: テキストレイヤー上なら再編集
* Selectツール=選択中レイヤーのみ対象(overlayCanvas経由)、
* Textツール=座標上のテキストレイヤーを対象(drawCanvas経由、Selectツールと同じ操作感) */
_onOverlayDblClick(e, refCanvas) {
if (!this._layerMgr) return;
const pos = DrawTool.getCanvasPos(refCanvas, e);
if (refCanvas.id === "ie-canvas-select-overlay") {
pos.x -= this._selectMargin;
pos.y -= this._selectMargin;
}
if (this._activeTool === "select") {
const layer = this._selectTool?.getSelectedLayer();
if (!layer || !this._selectTool._isPointInLayer(pos.x, pos.y, layer)) return;
if (layer.type === "text" && layer.textProps) {
this._openTextEditForLayer(layer, pos.x, pos.y);
}
} else if (this._activeTool === "text") {
const layer = this._findTextLayerAt(pos.x, pos.y);
if (layer) {
this._layerMgr.setActive(layer.id);
this._refreshLayerList();
this._openTextEditForLayer(layer, pos.x, pos.y);
}
}
}
/** textProps を TextTool にセットしてオーバーレイを開く */
_openTextEditForLayer(layer, canvasX, canvasY) {
const p = layer.textProps;
this._textTool.text = p.text;
this._textTool.fontFamily = p.fontFamily;
this._textTool.fontSize = p.fontSize;
this._textTool.bold = p.bold;
this._textTool.italic = p.italic;
this._textTool.align = p.align;
this._textTool.vertical = !!p.vertical;
this._textTool.color = p.color;
this._editingTextLayer = layer;
this._textTool.openAt(canvasX, canvasY);
}
/** 線/袋文字/影を考慮した追加余白(px)。フォントタブのスタイル/プリセット適用テキストのみ非ゼロになる。
* スタイル値は「フォントサイズ100pxあたりのpx」の相対値(v2)なのでfontSizeでスケールする */
_textExtraPad(p) {
const k = (p.fontSize || 100) / 100;
let outline = 0;
if (p.bukuroEnabled) outline = (p.strokeEnabled ? (p.strokeWidth || 0) / 2 : 0) + (p.bukuroWidth || 0);
else if (p.strokeEnabled) outline = (p.strokeWidth || 0) / 2;
let pad = outline;
if (p.shadowEnabled) {
pad = outline + (p.shadowBlur || 0) + Math.max(Math.abs(p.shadowDx || 0), Math.abs(p.shadowDy || 0));
}
return Math.ceil(pad * k);
}
/** textProps からバウンディングボックスサイズを計測する(TextTool.createLayerDataの計測部分+スタイル余白を考慮) */
_measureTextBox(p) {
const lines = p.text.split("\n");
const lineH = p.fontSize * (p.lineHeightMult || 1.2);
const font = [
p.italic ? "italic" : "",
p.bold ? "bold" : "",
`${p.fontSize}px`,
`"${p.fontFamily}", sans-serif`,
].filter(Boolean).join(" ");
const tmp = document.createElement("canvas");
const tmpCtx = tmp.getContext("2d");
tmpCtx.font = font;
const pad = 4 + this._textExtraPad(p);
if (p.vertical) {
const v = layoutVerticalText(tmpCtx, p.text, p.fontSize);
return {
tw: Math.max(1, Math.ceil(v.w + pad * 2)),
th: Math.max(1, Math.ceil(v.h + pad * 2)),
};
}
let maxW = 0;
for (const line of lines) {
const w = line.length > 0 ? tmpCtx.measureText(line).width : p.fontSize * 0.3;
if (w > maxW) maxW = w;
}
const tw = Math.max(1, Math.ceil(maxW + pad * 2));
// lineHeightMultは行同士の間隔(ベースライン間隔)であり、1行分のグリフ自体の高さ
// (降下部分等の実際の描画範囲)とは独立。lineHeightMultを1.2未満に下げても最終行の
// グリフが見切れないよう、最終行の実高さ分は常にfontSize*1.2(従来固定値)で確保し、
// それ以外の行間隔にのみlineHeightMultを適用する
const naturalLineH = p.fontSize * 1.2;
const th = Math.max(1, Math.ceil((lines.length - 1) * lineH + naturalLineH + pad * 2));
return { tw, th };
}
/**
* テキストレイヤーを displayW/H サイズで再描画する(拡大縮小後のぼやけ防止)。
* canvas.width = displayW にするため applyTransform のスケールは常に 1:1 になる。
* フォントタブのスタイル/プリセット由来の線・袋文字・影(textProps内)があればあわせて描画する。
*/
_rerenderTextLayer(layer) {
const p = layer.textProps;
if (!p) return;
const newW = Math.max(1, Math.round(layer.displayW));
const newH = Math.max(1, Math.round(layer.displayH));
const sx = newW / p.nativeW;
const sy = newH / p.nativeH;
layer.canvas.width = newW;
layer.canvas.height = newH;
layer.ctx = layer.canvas.getContext("2d");
const font = [
p.italic ? "italic" : "",
p.bold ? "bold" : "",
`${p.fontSize}px`,
`"${p.fontFamily}", sans-serif`,
].filter(Boolean).join(" ");
const lines = p.text.split("\n");
const lineH = p.fontSize * (p.lineHeightMult || 1.2);
const pad = 4 + this._textExtraPad(p);
const ctx = layer.ctx;
ctx.save();
ctx.scale(sx, sy);
ctx.font = font;
ctx.textBaseline = "top";
ctx.textAlign = p.align;
ctx.lineJoin = "round";
let drawX = pad;
if (p.align === "center") drawX = p.nativeW / 2;
else if (p.align === "right") drawX = p.nativeW - pad;
// 縦書き時は文字単位のセルレイアウトで各パスを描画する
const vLayout = p.vertical ? layoutVerticalText(ctx, p.text, p.fontSize) : null;
// 下線(スタイルのunderlineEnabled由来)の矩形を事前計算する。
// Canvas2Dにはtext-decorationが無いため自前描画で、SVG側(text-decoration+フィルタ)と
// 見た目を揃えるよう文字と同じパスに含めて描く=袋文字・線・影も下線に付く。
// 横書きは各行の下、縦書きは列(=行)の右側の傍線
const underlineRects = [];
if (p.underline) {
const thickness = Math.max(1, p.fontSize * 0.06);
if (vLayout) {
vLayout.cols.forEach(col => {
if (col.h > 0) underlineRects.push([pad + col.cx + p.fontSize * 0.5, pad, thickness, col.h]);
});
} else {
lines.forEach((line, i) => {
if (!line) return;
const w = ctx.measureText(line).width;
let x0 = drawX;
if (p.align === "center") x0 = drawX - w / 2;
else if (p.align === "right") x0 = drawX - w;
underlineRects.push([x0, pad + i * lineH + p.fontSize * 0.95, w, thickness]);
});
}
}
const drawPass = (method) => {
if (vLayout) drawVerticalCells(ctx, vLayout.cells, pad, pad, method);
else lines.forEach((line, i) => ctx[method](line, drawX, pad + i * lineH));
const rectMethod = method === "fillText" ? "fillRect" : "strokeRect";
underlineRects.forEach(r => ctx[rectMethod](...r));
};
// スタイル値は「フォントサイズ100pxあたりのpx」の相対値(v2)。
// 描画基準はレイアウトタブ(SVG)に合わせる:
// 線幅S → 中央基準ストローク(塗りが内側半分を覆うため外側に見えるのは S/2)
// 袋文字幅B → 線の外側にBの帯(feMorphology dilate radius=B 相当)
// 影 → 最背面のシルエット(袋文字含む)に1回だけ掛ける(feDropShadow相当)
const k = (p.fontSize || 100) / 100;
const setShadow = (on) => {
if (on && p.shadowEnabled) {
ctx.shadowColor = p.shadowColor;
ctx.shadowBlur = (p.shadowBlur || 0) * k;
ctx.shadowOffsetX = (p.shadowDx || 0) * k;
ctx.shadowOffsetY = (p.shadowDy || 0) * k;
} else {
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0;
}
};
let shadowDone = false;
// 袋文字(背面の太い縁取り)
if (p.bukuroEnabled) {
setShadow(true);
shadowDone = true;
ctx.strokeStyle = p.bukuroColor;
ctx.lineWidth = ((p.strokeEnabled ? p.strokeWidth || 0 : 0) + (p.bukuroWidth || 0) * 2) * k;
drawPass("strokeText");
}
// 線
if (p.strokeEnabled) {
setShadow(!shadowDone);
shadowDone = true;
ctx.strokeStyle = p.strokeColor;
ctx.lineWidth = (p.strokeWidth || 0) * k;
drawPass("strokeText");
}
// 塗り(fillEnabled=false は塗りなし。線・袋文字のみ描画される)
if (p.fillEnabled !== false) {
setShadow(!shadowDone);
ctx.fillStyle = this._textFillStyle(ctx, p, layer);
drawPass("fillText");
}
ctx.restore();
this._ensureLayerFontLoaded(layer);
}
/**
* テキストレイヤーの塗りfillStyleを生成する(単色/グラデーション/テクスチャ)。
* 座標系はctx.scale適用後のネイティブテキスト空間(0..nativeW/H)。
* テクスチャ画像が未ロードの場合は単色でフォールバックし、ロード完了後に再描画する
*/
_textFillStyle(ctx, p, layer) {
if (p.fillMode === "gradient" && p.fillGradient?.stops?.length) {
const g = p.fillGradient;
const W = p.nativeW, H = p.nativeH;
const cx = W / 2, cy = H / 2;
let grad;
if (g.shape === "radial") {
grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(1, Math.hypot(W, H) / 2));
} else {
// 角度方向へのバウンディングボックス投影幅でグラデーション区間を決める
const rad = ((g.angleDeg || 0) * Math.PI) / 180;
const dx = Math.cos(rad), dy = Math.sin(rad);
const half = (Math.abs(dx) * W + Math.abs(dy) * H) / 2;
grad = ctx.createLinearGradient(cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half);
}
[...g.stops].sort((a, b) => a.pos - b.pos).forEach(s => {
grad.addColorStop(Math.max(0, Math.min(1, s.pos)), s.color || "#000000");
});
return grad;
}
if (p.fillMode === "texture" && p.fillTexture?.dataUrl) {
const img = this._getTextureImage(p.fillTexture.dataUrl, layer);
if (img) {
const pattern = ctx.createPattern(img, "repeat");
// タイルサイズ = 画像実寸 × (scale/100) × (fontSize/100)(SVG側と同じv2相対値基準)
const k = (p.fontSize || 100) / 100;
const s = ((p.fillTexture.scale || 100) / 100) * k;
// テクスチャ位置(ユーザーが手動指定するタイルの位相オフセット)。SVG側の
// pattern x/y = offsetX*k と同じ意味になるよう、scale後の座標系でtranslateする
const offX = (p.fillTexture.offsetX || 0) * k;
const offY = (p.fillTexture.offsetY || 0) * k;
if (pattern && typeof pattern.setTransform === "function" && typeof DOMMatrix !== "undefined") {
pattern.setTransform(new DOMMatrix().translate(offX, offY).scale(s, s));
}
if (pattern) return pattern;
}
return p.color || "#000000";
}
return p.color || "#000000";
}
/** テクスチャ画像のキャッシュ取得。未ロードならロード開始し、完了時に対象レイヤーを再描画する */
_getTextureImage(dataUrl, layer) {
if (!this._texImageCache) this._texImageCache = new Map();
const entry = this._texImageCache.get(dataUrl);
if (entry) return entry.loaded ? entry.img : null;
const img = new Image();
const newEntry = { img, loaded: false };
this._texImageCache.set(dataUrl, newEntry);
img.onload = () => {
newEntry.loaded = true;
if (layer && layer.textProps) {
this._rerenderTextLayer(layer);
this._updateCompositeView();
}
};
img.src = dataUrl;
return null;
}
/** Google Fontsが未ダウンロードでフォールバック描画になっている場合、ロード完了を待って再描画する */
_ensureLayerFontLoaded(layer) {
if (!document.fonts) return;
const p = layer.textProps;
if (!p) return;
const family = p.fontFamily;
_fontMgrEnsureFontLoaded(family);
const fontSpec = `${p.italic ? "italic " : ""}${p.bold ? "bold " : ""}16px "${family}"`;
if (document.fonts.check(fontSpec)) return;
document.fonts.load(fontSpec).then(() => {
if (layer.textProps === p) {
this._rerenderTextLayer(layer);
this._updateCompositeView();
}
}).catch(() => {});
}
// ── フォントタブ「スタイル/プリセット」連携 ───────────
/** フォントタブのスタイルオブジェクト(main.js側 _fontMgrLoadStyles() の要素)をtextProps用の断片に変換 */
_fontStyleAttrsFromStyle(style) {
return {
color: style?.fill || "#000000",
fillEnabled: style?.fillEnabled !== false,
fillMode: style?.fillMode || "solid",
fillGradient: style?.fillGradient || null,
fillTexture: style?.fillTexture || null,
bold: !!style?.boldEnabled, italic: !!style?.italicEnabled, underline: !!style?.underlineEnabled, align: style?.align || "left",
valign: style?.valign || "top", lineHeightMult: style?.lineHeightMult || 1.2,
strokeEnabled: !!style?.strokeEnabled, strokeColor: style?.strokeColor || "#ffffff", strokeWidth: style?.strokeWidth || 0,
bukuroEnabled: !!style?.bukuroEnabled, bukuroColor: style?.bukuroColor || "#000000", bukuroWidth: style?.bukuroWidth || 0,
shadowEnabled: !!style?.shadowEnabled, shadowColor: style?.shadowColor || "#000000", shadowBlur: style?.shadowBlur || 0,
shadowDx: style?.shadowDx || 0, shadowDy: style?.shadowDy || 0,
};
}
/** 選択中レイヤーが(textPropsを持つ)テキストレイヤーかどうか */
hasSelectedTextLayer() {
const layer = this._selectTool?.getSelectedLayer();
return !!(layer && layer.type === "text" && layer.textProps);
}
/** 選択中レイヤーが(text3dPropsを持つ)3Dテキストレイヤーかどうか */
hasSelected3DTextLayer() {
const layer = this._selectTool?.getSelectedLayer();
return !!(layer && layer.type === "image" && layer.text3dProps);
}
/** テキストツールのオプションバー(Color/Size/Font/Bold/Italic/Align/縦書き)の変更を、選択中のテキストレイヤーがあればそこにも反映する */
_applyTextToolChangeToSelection(partialProps) {
const layer = this._selectTool?.getSelectedLayer();
if (!layer || layer.type !== "text" || !layer.textProps) return;
this._saveUndo();
const newProps = { ...layer.textProps, ...partialProps };
this._applyTextPropsToLayer(layer, newProps);
this._updateCompositeView();
this._refreshLayerList();
}
/** テキストスタイルモーダルの初期値用: 選択中テキストレイヤーの現在のフォント・サイズ・スタイルを返す(未選択ならツールのデフォルト値、styleはnull) */
getSelectedTextStyleInfo() {
const layer = this._selectTool?.getSelectedLayer();
if (layer && layer.type === "text" && layer.textProps) {
const p = layer.textProps;
return {
fontFamily: p.fontFamily,
fontSize: p.fontSize,
style: {
fill: p.color,
fillEnabled: p.fillEnabled !== false,
fillMode: p.fillMode || "solid",
fillGradient: p.fillGradient || null,
fillTexture: p.fillTexture || null,
boldEnabled: !!p.bold, italicEnabled: !!p.italic, underlineEnabled: !!p.underline, align: p.align || "left",
valign: p.valign || "top", lineHeightMult: p.lineHeightMult || 1.2,
strokeEnabled: !!p.strokeEnabled, strokeColor: p.strokeColor, strokeWidth: p.strokeWidth,
bukuroEnabled: !!p.bukuroEnabled, bukuroColor: p.bukuroColor, bukuroWidth: p.bukuroWidth,
shadowEnabled: !!p.shadowEnabled, shadowColor: p.shadowColor, shadowBlur: p.shadowBlur,
shadowDx: p.shadowDx, shadowDy: p.shadowDy,
},
};
}
return { fontFamily: this._textTool.fontFamily, fontSize: this._textTool.fontSize, style: null };
}
/**
* textProps を差し替えてレイヤーを再構築・再描画する。行数変化でレイヤー高さが変わる際、
* どこを基準に保つかは valign(テキストスタイルの「上下」設定)に従う:
* top=上端固定、bottom=下端固定、center(既定)=中心固定
*/
_applyTextPropsToLayer(layer, newProps) {
const oldProps = layer.textProps;
const scale = layer.displayW / oldProps.nativeW;
const { tw, th } = this._measureTextBox(newProps);
newProps.nativeW = tw;
newProps.nativeH = th;
const centerX = layer.x + layer.displayW / 2;
const topY = layer.y;
const bottomY = layer.y + layer.displayH;
const centerY = layer.y + layer.displayH / 2;
layer.textProps = newProps;
layer.displayW = Math.max(1, Math.round(tw * scale));
layer.displayH = Math.max(1, Math.round(th * scale));
layer.x = Math.round(centerX - layer.displayW / 2);
const valign = newProps.valign || "top";
layer.y = valign === "top" ? Math.round(topY)
: valign === "bottom" ? Math.round(bottomY - layer.displayH)
: Math.round(centerY - layer.displayH / 2);
this._rerenderTextLayer(layer);
}
/** フォントタブの「スタイル」を選択中のテキストレイヤーに適用する(フォント・サイズ・縦書きは変更しない) */
applyFontStyleToSelection(style) {
const layer = this._selectTool?.getSelectedLayer();
if (!layer || layer.type !== "text" || !layer.textProps) return false;
this._saveUndo();
const newProps = { ...layer.textProps, ...this._fontStyleAttrsFromStyle(style) };
this._applyTextPropsToLayer(layer, newProps);
this._updateCompositeView();
this._refreshLayerList();
this._toast(t("image.styleApplied"), "success");
return true;
}
/** フォントタブの「プリセット」を選択中のテキストレイヤーに適用する(フォント・サイズ・縦書きも変更) */
applyFontPresetToSelection(preset, styles) {
const layer = this._selectTool?.getSelectedLayer();
if (!layer || layer.type !== "text" || !layer.textProps) return false;
this._saveUndo();
const style = (styles || []).find(s => s.id === preset.styleId);
const newProps = {
...layer.textProps,
...this._fontStyleAttrsFromStyle(style),
fontFamily: preset.fontFamily,
fontSize: preset.fontSize,
vertical: !!preset.isVertical,
};
this._applyTextPropsToLayer(layer, newProps);
this._updateCompositeView();
this._refreshLayerList();
this._toast(t("image.presetApplied"), "success");
return true;
}
/** フォントタブの「スタイル/プリセット」をキャンバス中央にプレースホルダテキストとして新規レイヤー挿入する */
_insertStyledPlaceholderText(attrs, fontFamily, fontSize) {
if (!this._layerMgr) { this._toast(t("image.newOrLoadFirst"), "error"); return false; }
const props = {
text: t("textTool.defaultText"), fontFamily, fontSize,
bold: false, italic: false, align: "left", vertical: false,
...attrs,
};
const { tw, th } = this._measureTextBox(props);
props.nativeW = tw;
props.nativeH = th;
this._saveUndo();
const cx = this._canvasW / 2, cy = this._canvasH / 2;
const layer = this._layerMgr.addLayer("text", props.text, {
contentW: tw, contentH: th,
displayW: tw, displayH: th,
x: Math.round(cx - tw / 2), y: Math.round(cy - th / 2),
});
layer.textProps = props;
this._rerenderTextLayer(layer);
this._layerMgr.setActive(layer.id);
this._setActiveTool("select");
this._selectTool?.setLayer(layer);
this._updateCompositeView();
this._refreshLayerList();
this._toast(t("image.textInserted"), "success");
return true;
}
/** 「Insert」ボタン: スクリプトタブのセル / OCRタブの検出テキストをテキストレイヤーとして挿入するモーダルを開く */
_openTextInsertModal() {
if (!this._layerMgr) { this._toast(t("image.newOrLoadFirst"), "error"); return; }
openTextInsertModal({
ocrCount: getOcrTextItems().length, sizeUnit: "px", defaultSize: this._textTool.fontSize,
onScript: () => {
const text = _scriptMangaGetSelectedDialogue();
if (text == null) { this._toast(t("textTool.selectScriptCell"), "error"); return; }
if (!text.trim()) { this._toast(t("textTool.emptyCell"), "error"); return; }
this._insertTextEntries([{ text, fontSize: this._textTool.fontSize, vertical: this._textTool.vertical, cx: this._canvasW / 2, cy: this._canvasH / 2 }]);
},
onOcr: (o) => this._insertOcrTexts(o),
});
}
/** OCRタブの検出テキストを、キャンバス上の枠位置へテキストレイヤーとして挿入し「OCR」グループにまとめる */
_insertOcrTexts(o) {
const items = getOcrTextItems();
if (!items.length) { this._toast(t("insert.ocrNone"), "error"); return; }
const W = this._canvasW, H = this._canvasH;
const entries = items.map(it => {
const vertical = o.dir === "v" ? true : o.dir === "h" ? false : it.vertical;
const fontSize = o.sizeMode === "fixed"
? o.fixedSize
: fitFontSize(it.text, (it.x2 - it.x1) * W, (it.y2 - it.y1) * H, vertical);
return { text: it.text, fontSize: Math.max(6, Math.round(fontSize)), vertical, cx: (it.x1 + it.x2) / 2 * W, cy: (it.y1 + it.y2) / 2 * H };
});
this._insertTextEntries(entries, "OCR");
}
/**
* 中心座標(cx,cy)に各テキストレイヤーを作る(フォント・色はテキストツールの現在値)。
* groupName を渡すと、2件以上のときその名前のレイヤーグループにまとめる
*/
_insertTextEntries(entries, groupName) {
this._saveUndo();
const ids = [];
let last = null;
for (const e of entries) {
const props = {
text: e.text, fontFamily: this._textTool.fontFamily, fontSize: e.fontSize,
bold: false, italic: false, align: "left", vertical: !!e.vertical, color: this._textTool.color,
};
const { tw, th } = this._measureTextBox(props);
props.nativeW = tw;
props.nativeH = th;
const label = (e.text.slice(0, 20).replace(/\n/g, " ").trim()) || "Text";
const layer = this._layerMgr.addLayer("text", label, {
contentW: tw, contentH: th, displayW: tw, displayH: th,
x: Math.round(e.cx - tw / 2), y: Math.round(e.cy - th / 2),
});
layer.textProps = props;
this._rerenderTextLayer(layer);
ids.push(layer.id);
last = layer;
}
if (groupName && ids.length >= 2) this._layerMgr.createGroup(ids, groupName);
if (last) {
this._layerMgr.setActive(last.id);
this._setActiveTool("select");
this._selectTool?.setLayer(last);
}
this._updateCompositeView();
this._refreshLayerList();
this._toast(t("insert.done", entries.length), "success");
}
/** フォントタブの「スタイル」を新規プレースホルダテキストとして挿入する(フォント・サイズは現在のデフォルトのまま) */
insertFontStylePlaceholder(style) {
return this._insertStyledPlaceholderText(this._fontStyleAttrsFromStyle(style), this._textTool.fontFamily, this._textTool.fontSize);
}
/** フォントタブの「プリセット」を新規プレースホルダテキストとして挿入する(フォント・サイズ・縦書きもプリセットの値を使う) */
insertFontPresetPlaceholder(preset, styles) {
const style = (styles || []).find(s => s.id === preset.styleId);
const attrs = { ...this._fontStyleAttrsFromStyle(style), vertical: !!preset.isVertical };
return this._insertStyledPlaceholderText(attrs, preset.fontFamily, preset.fontSize);
}
// ── ぼかし / モザイク ─────────────────────────
_applyWholeBlur(amount) {
if (!this._layerMgr) return;
const layer = this._layerMgr.activeLayer;
if (!layer) { this._toast(window.t("image.msg.noActiveLayer2"), "error"); return; }
this._saveUndo();
const w = layer.canvas.width, h = layer.canvas.height;
const tmp = document.createElement("canvas");
tmp.width = w; tmp.height = h;
const tc = tmp.getContext("2d");
tc.filter = `blur(${amount}px)`;
tc.drawImage(layer.canvas, 0, 0);
layer.ctx.clearRect(0, 0, w, h);
layer.ctx.drawImage(tmp, 0, 0);
this._updateCompositeView();
this._refreshLayerList();
}
_applyWholeMosaic(size) {
if (!this._layerMgr) return;
const layer = this._layerMgr.activeLayer;
if (!layer) { this._toast(window.t("image.msg.noActiveLayer2"), "error"); return; }
this._saveUndo();
_applyMosaicToRegion(layer.ctx, 0, 0, layer.canvas.width, layer.canvas.height, size);
this._updateCompositeView();
this._refreshLayerList();
}
// canvas座標 (cx,cy) → layer.canvas 座標への逆変換
_canvasToLayerCoords(layer, cx, cy) {
const centerX = layer.x + layer.displayW / 2;
const centerY = layer.y + layer.displayH / 2;
const dx = cx - centerX, dy = cy - centerY;
const angle = -(layer.rotation || 0) * Math.PI / 180;
const rdx = dx * Math.cos(angle) - dy * Math.sin(angle);
const rdy = dx * Math.sin(angle) + dy * Math.cos(angle);
const scaleX = layer.displayW / layer.canvas.width;
const scaleY = layer.displayH / layer.canvas.height;
let lx = rdx / scaleX + layer.canvas.width / 2;
let ly = rdy / scaleY + layer.canvas.height / 2;
if (layer.flipX) lx = layer.canvas.width - lx;
if (layer.flipY) ly = layer.canvas.height - ly;
return { x: lx, y: ly };
}
_drawBlurPreview() {
const overlay = document.getElementById("ie-canvas-overlay");
if (!overlay) return;
const ctx = overlay.getContext("2d");
ctx.clearRect(0, 0, overlay.width, overlay.height);
const s = this._blurDragStart, c = this._blurDragCur;
if (!s || !c) return;
const x = Math.min(s.x, c.x), y = Math.min(s.y, c.y);
const w = Math.abs(c.x - s.x), h = Math.abs(c.y - s.y);
ctx.strokeStyle = this._blurRectMode === "blur" ? "#4af" : "#fa4";
ctx.lineWidth = 1 / this._zoom;
ctx.setLineDash([4 / this._zoom, 2 / this._zoom]);
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
}
_applyRectEffect() {
const layer = this._layerMgr?.activeLayer;
if (!layer || !this._blurDragStart || !this._blurDragCur) return;
const s = this._blurDragStart, c = this._blurDragCur;
if (Math.abs(c.x - s.x) < 3 || Math.abs(c.y - s.y) < 3) return;
const minX = Math.min(s.x, c.x), minY = Math.min(s.y, c.y);
const maxX = Math.max(s.x, c.x), maxY = Math.max(s.y, c.y);
const p1 = this._canvasToLayerCoords(layer, minX, minY);
const p2 = this._canvasToLayerCoords(layer, maxX, maxY);
const lx = Math.round(Math.max(0, Math.min(p1.x, p2.x)));
const ly = Math.round(Math.max(0, Math.min(p1.y, p2.y)));
const lw = Math.round(Math.min(layer.canvas.width - lx, Math.abs(p2.x - p1.x)));
const lh = Math.round(Math.min(layer.canvas.height - ly, Math.abs(p2.y - p1.y)));
if (lw <= 0 || lh <= 0) return;
this._saveUndo();
if (this._blurRectMode === "blur") {
const amount = parseInt(document.getElementById("ie-rect-blur")?.value ?? "10");
const tmp = document.createElement("canvas");
tmp.width = layer.canvas.width; tmp.height = layer.canvas.height;
const tc = tmp.getContext("2d");
tc.filter = `blur(${amount}px)`;
tc.drawImage(layer.canvas, 0, 0);
layer.ctx.drawImage(tmp, lx, ly, lw, lh, lx, ly, lw, lh);
} else {
const size = parseInt(document.getElementById("ie-rect-mosaic")?.value ?? "15");
_applyMosaicToRegion(layer.ctx, lx, ly, lw, lh, size);
}
this._updateCompositeView();
this._refreshLayerList();
}
// ── 背景除去 ─────────────────────────────────
// quality: "small"(軽量・低品質、isnet_quint8)/ "medium"(標準、isnet_fp16、既定)/ "large"(高品質、isnet)
async _bgRemoveImgly(dataUrl, onStatus, quality = "medium") {
if (!window._ccImageTabImglyRemoveBg) {
onStatus("Loading model...");
const mod = await import("https://esm.sh/@imgly/background-removal@1.5.7?bundle&target=es2022");
window._ccImageTabImglyRemoveBg = mod.removeBackground;
}
onStatus("Processing...");
const res = await fetch(dataUrl);
const blob = await res.blob();
const resultBlob = await window._ccImageTabImglyRemoveBg(blob, {
publicPath: "https://staticimgly.com/@imgly/background-removal-data/1.5.7/dist/",
model: quality,
});
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(resultBlob);
});
}
// BiRefNet背景除去(Mask Editor One互換ルート。comic-creator側にバックエンドが無い場合は
// _birefnetAvailable=false のためUI選択肢自体が無効化され、この経路には来ない)
async _bgRemoveBiRefNet(dataUrl, onStatus) {
const NODE_ID = "cc_image_tab_bgremove";
onStatus("Sending image...");
const storeResp = await fetch("/mask_editor/store_image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
});
if (!storeResp.ok) throw new Error("Failed to cache image for BiRefNet");
onStatus("Running BiRefNet...");
const resp = await fetch("/mask_editor/birefnet/remove_bg", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: NODE_ID }),
});
const json = await resp.json();
if (json.error) throw new Error(json.error);
onStatus("Compositing...");
return await this._applyMaskToImage(dataUrl, json.mask_b64);
}
// グレースケールマスク(白=前景)を元画像に destination-in で適用してRGBA PNGを返す
async _applyMaskToImage(imageB64, maskB64) {
const loadImage = src => new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Image load failed: " + src.slice(0, 40)));
img.src = src;
});
const [origImg, maskImg] = await Promise.all([loadImage(imageB64), loadImage(maskB64)]);
const w = origImg.naturalWidth;
const h = origImg.naturalHeight;
const maskCanvas = document.createElement("canvas");
maskCanvas.width = w;
maskCanvas.height = h;
const mc = maskCanvas.getContext("2d");
mc.drawImage(maskImg, 0, 0, w, h);
const maskData = mc.getImageData(0, 0, w, h);
const md = maskData.data;
for (let i = 0; i < md.length; i += 4) {
md[i + 3] = md[i]; // 輝度 → アルファ
md[i] = md[i + 1] = md[i + 2] = 255;
}
mc.putImageData(maskData, 0, 0);
const out = document.createElement("canvas");
out.width = w;
out.height = h;
const ctx = out.getContext("2d");
ctx.drawImage(origImg, 0, 0, w, h);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(maskCanvas, 0, 0, w, h);
return out.toDataURL("image/png");
}
async _applyBgRemove() {
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return; }
const layer = this._layerMgr.activeLayer;
if (!layer) { this._toast(window.t("image.msg.noActiveLayer2"), "error"); return; }
const model = document.getElementById("ie-bgremove-model")?.value ?? "imgly";
const quality = document.getElementById("ie-bgremove-quality")?.value ?? "medium";
const asNew = document.getElementById("ie-bgremove-new-layer")?.checked ?? true;
const statusEl = document.getElementById("ie-bgremove-status");
const btn = document.getElementById("ie-bgremove-btn");
const setStatus = msg => { if (statusEl) statusEl.textContent = msg; };
if (btn) btn.disabled = true;
setStatus("Starting...");
try {
const dataUrl = layer.canvas.toDataURL("image/png");
let resultDataUrl;
if (model === "imgly") {
resultDataUrl = await this._bgRemoveImgly(dataUrl, setStatus, quality);
} else {
resultDataUrl = await this._bgRemoveBiRefNet(dataUrl, setStatus);
}
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error("Result image load failed"));
i.src = resultDataUrl;
});
this._saveUndo();
if (asNew) {
const newL = this._layerMgr.addLayer("image", layer.name + " (no bg)", {
contentW: img.width, contentH: img.height,
displayW: layer.displayW, displayH: layer.displayH,
x: layer.x, y: layer.y,
});
newL.ctx.drawImage(img, 0, 0);
this._layerMgr.setActive(newL.id);
if (this._activeTool === "select") this._selectTool?.setLayer(newL);
} else {
layer.canvas.width = img.width;
layer.canvas.height = img.height;
layer.ctx = layer.canvas.getContext("2d");
layer.ctx.drawImage(img, 0, 0);
}
this._updateCompositeView();
this._refreshLayerList();
setStatus("Done!");
setTimeout(() => setStatus(""), 3000);
this._toast(window.t("image.msg.bgRemoved"), "success");
} catch (err) {
setStatus("Error: " + err.message);
this._toast(window.t("image.msg.bgRemoveFailed") + err.message, "error");
} finally {
if (btn) btn.disabled = false;
}
}
// ── アップスケール(imgeditタブの実行パイプラインを移植) ──
async _applyUpscale() {
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return; }
const layer = this._layerMgr.activeLayer;
if (!layer) { this._toast(window.t("image.msg.noActiveLayer2"), "error"); return; }
const scale = parseFloat(document.getElementById("ie-upscale-scale")?.value ?? "2");
const sharpen = parseInt(document.getElementById("ie-upscale-sharpen")?.value ?? "30", 10) / 100;
const denoise = document.getElementById("ie-upscale-denoise")?.checked ?? false;
const statusEl = document.getElementById("ie-upscale-status");
const btn = document.getElementById("ie-upscale-run-btn");
const setStatus = msg => { if (statusEl) statusEl.textContent = msg; };
if (btn) btn.disabled = true;
setStatus(t("layout.processing"));
try {
this._syncActiveLayerFromCanvas();
const dataUrl = layer.canvas.toDataURL("image/png");
if (denoise) setStatus(t("image.upscaleDenoising"));
setStatus(t("image.upscaleRunning"));
const resultDataUrl = await _procUpscale(dataUrl, scale, denoise, sharpen);
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error("Result image load failed"));
i.src = resultDataUrl;
});
this._saveUndo();
// ネイティブ解像度だけ上げる(displayW/Hは維持し、見た目のサイズは変えず画質のみ向上させる)
layer.canvas.width = img.width;
layer.canvas.height = img.height;
layer.ctx = layer.canvas.getContext("2d");
layer.ctx.drawImage(img, 0, 0);
this._updateCompositeView();
this._refreshLayerList();
setStatus(t("common.done"));
setTimeout(() => setStatus(""), 3000);
this._toast(window.t("image.msg.upscaled", img.width, img.height), "success");
} catch (err) {
setStatus(t("common.errorPrefix", err.message));
this._toast(window.t("image.msg.upscaleFailed") + err.message, "error");
} finally {
if (btn) btn.disabled = false;
}
}
// ── G'MIC連携(comic-creator既存ルート /api/ccc/local-gmic/* を使用) ──
async _gmicOpenGui() {
if (!this._layerMgr) { this._toast(window.t("image.msg.noImageLoaded2"), "error"); return; }
const layer = this._layerMgr.activeLayer;
if (!layer) { this._toast(window.t("image.msg.noActiveLayer2"), "error"); return; }
this._syncActiveLayerFromCanvas();
if (this._gmicState.processing) return;
const dataUrl = layer.canvas.toDataURL("image/png");
this._gmicState.processing = true;
const openBtn = document.getElementById("ie-gmic-open-btn");
const progressArea = document.getElementById("ie-gmic-progress-area");
const progressLbl = document.getElementById("ie-gmic-progress-lbl");
const applyBtn = document.getElementById("ie-gmic-apply-btn");
if (openBtn) openBtn.disabled = true;
if (applyBtn) applyBtn.disabled = true;
if (progressArea) progressArea.style.display = "flex";
if (progressLbl) progressLbl.textContent = t("image.gmicSendingImage");
try {
const res = await fetch("/api/ccc/local-gmic/open_in_gui_b64", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_b64: dataUrl })
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(resolveBackendError(err.error_code, err.error_params) || err.detail || `HTTP ${res.status}`);
}
const data = await res.json();
this._gmicState.lastResultJobId = data.job_id;
if (progressLbl) progressLbl.textContent = t("image.gmicEditingHint");
await this._gmicWaitForJob(data.job_id);
} catch (e) {
if (e.message !== "__aborted__") {
this._toast(window.t("image.msg.gmicError") + e.message, "error");
}
this._gmicState.processing = false;
this._gmicState.aborted = false;
if (openBtn) openBtn.disabled = false;
if (progressArea) progressArea.style.display = "none";
}
}
_gmicAbort() {
this._gmicState.aborted = true;
this._gmicState.processing = false;
const openBtn = document.getElementById("ie-gmic-open-btn");
const progressArea = document.getElementById("ie-gmic-progress-area");
if (progressArea) progressArea.style.display = "none";
if (openBtn) openBtn.disabled = false;
}
async _gmicWaitForJob(jobId) {
const progressLbl = document.getElementById("ie-gmic-progress-lbl");
const applyBtn = document.getElementById("ie-gmic-apply-btn");
const maxWait = 600, interval = 2000;
const start = Date.now();
this._gmicState.aborted = false;
while (true) {
if (this._gmicState.aborted) throw new Error("__aborted__");
if ((Date.now() - start) / 1000 > maxWait) throw new Error("Timeout");
await new Promise(r => setTimeout(r, interval));
if (this._gmicState.aborted) throw new Error("__aborted__");
try {
const res = await fetch(`/api/ccc/local-gmic/status/${jobId}`, { signal: AbortSignal.timeout(5000) });
if (res.status === 404) throw new Error("__aborted__");
if (!res.ok) continue;
const status = await res.json();
if (status.status === "completed") {
this._gmicState.lastResultJobId = jobId;
if (applyBtn) applyBtn.disabled = false;
if (progressLbl) progressLbl.textContent = t("image.gmicApplyHint");
this._gmicState.processing = false;
this._toast(window.t("image.msg.gmicFilterDone"), "success");
return;
}
if (status.status === "failed") {
if (progressLbl) progressLbl.textContent = resolveBackendError(status.error_code, status.error_params) || t("image.gmicCancelled");
throw new Error("__aborted__");
}
if (progressLbl) progressLbl.textContent = resolveBackendError(status.message_code, null) || t("image.gmicEditing");
} catch (e) {
if (e.message === "__aborted__" || e.message.includes("Timeout")) throw e;
}
}
}
async _gmicApplyResult() {
if (!this._gmicState.lastResultJobId) {
this._toast(window.t("image.msg.noGmicResult"), "error");
return;
}
const applyBtn = document.getElementById("ie-gmic-apply-btn");
const openBtn = document.getElementById("ie-gmic-open-btn");
const progressArea = document.getElementById("ie-gmic-progress-area");
if (applyBtn) applyBtn.disabled = true;
try {
const statusRes = await fetch(`/api/ccc/local-gmic/status/${this._gmicState.lastResultJobId}`);
if (!statusRes.ok) throw new Error(`HTTP ${statusRes.status}`);
const statusData = await statusRes.json();
if (!statusData.result_path) throw new Error("No result path found");
const b64res = await fetch("/api/ccc/local-gmic/result_b64", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result_path: statusData.result_path })
});
if (!b64res.ok) throw new Error(`HTTP ${b64res.status}`);
const { image_b64: dataUrl } = await b64res.json();
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error("Failed to load result image"));
i.src = dataUrl;
});
this._saveUndo();
const layer = this._layerMgr.activeLayer;
if (!layer) throw new Error("No active layer");
layer.canvas.width = img.width;
layer.canvas.height = img.height;
layer.ctx = layer.canvas.getContext("2d");
layer.ctx.drawImage(img, 0, 0);
this._updateCompositeView();
this._refreshLayerList();
this._toast(window.t("image.msg.gmicApplied"), "success");
// 設定タブの「G'MIC結果を自動保存」がONならEagleへも自動保存する(imgeditタブの挙動を移植)
if (this._isEagleAutoSaveGmicEnabled()) {
const gname = `gmic_image_tab_${Date.now()}.png`;
saveToEagle(layer.canvas.toDataURL("image/png"), gname, ["comfyui-comic-creator", "gmic"]);
}
this._gmicState.lastResultJobId = null;
this._gmicState.processing = false;
if (openBtn) openBtn.disabled = false;
if (progressArea) progressArea.style.display = "none";
this._renderToolOptions("filter");
} catch (err) {
this._toast(window.t("image.msg.gmicApplyFailed") + err.message, "error");
if (applyBtn) applyBtn.disabled = false;
}
}
// main.js側の設定(localStorage "eagle_settings")を直接読む。
// _eagleSettingsはconst宣言のためwindow経由でアクセスできないので、同じキーを直接参照する。
_isEagleAutoSaveGmicEnabled() {
try {
const settings = JSON.parse(localStorage.getItem("eagle_settings") || "{}");
return !!settings.autoSaveGmic;
} catch {
return false;
}
}
// ── SAM3 セグメンテーション(Mask Editor One)──────
async _runSam3Segment() {
if (!this._layerMgr || this._sam3Loading) return;
const prompt = this._sam3Prompt.trim();
if (!prompt) { this._toast(window.t("image.msg.enterPrompt"), "info"); return; }
let imageLayer = this._layerMgr.activeLayer;
if (!imageLayer || imageLayer.type === "mask") {
imageLayer = this._layerMgr.layers.find(l => l.type !== "mask" && l.visible);
}
if (!imageLayer) { this._toast(window.t("image.msg.noImageLayer"), "error"); return; }
const NODE_ID = "cc_image_tab_sam3";
this._sam3Loading = true;
this._sam3Results = [];
this._sam3Selected = new Set();
this._renderToolOptions("mask");
this._renderMaskProps("sam3");
try {
if (this._segEngine === "wevisone") {
this._sam3Results = await this._runWevisMatting(imageLayer, prompt);
if (this._sam3Results.length === 0) this._toast(window.t("image.msg.noMasksFound"), "info");
else this._toast(window.t("image.msg.masksFound", this._sam3Results.length), "success");
return;
}
const dataUrl = imageLayer.canvas.toDataURL("image/png");
await fetch("/mask_editor/store_image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
});
const resp = await fetch("/mask_editor/sam3/segment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: NODE_ID, prompt, max_masks: this._sam3MaxMasks }),
});
const json = await resp.json();
if (json.error) throw new Error(json.error);
this._sam3Results = json.masks || [];
if (this._sam3Results.length === 0) this._toast(window.t("image.msg.noMasksFound"), "info");
else this._toast(window.t("image.msg.masksFound", this._sam3Results.length), "success");
} catch (err) {
this._toast(window.t("image.msg.sam3Error") + err.message, "error");
} finally {
this._sam3Loading = false;
this._renderToolOptions("mask");
this._renderMaskProps("sam3");
}
}
/** WeVisOne-VL(ComfyUI)でテキスト指定のアルファマットを作り、SAM3 と同じ結果形式 [{mask_b64, score, label}] で返す */
async _runWevisMatting(imageLayer, prompt) {
if (!this._wevisModel) throw new Error("WeVisOne-VL not found");
const blob = await new Promise(resolve => imageLayer.canvas.toBlob(resolve, "image/png"));
const [res] = await runWevisone({
base: location.origin, image: blob, model: this._wevisModel,
jobs: [{ task: "matting", query: prompt, masks: true }],
});
const labels = parseWevisone(res.raw).map(b => b.label);
const toDataUrl = async (url) => {
const b = await (await fetch(url)).blob();
return new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = reject; r.readAsDataURL(b); });
};
const out = [];
for (let i = 0; i < res.maskUrls.length; i++) {
// ラベルはモデル出力(=プロンプトの反復)で、候補一覧の HTML テンプレートに入るためエスケープする
const label = `#${i + 1} ${labels[i] || ""}`.trim().slice(0, 24)
.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
out.push({ mask_b64: await toDataUrl(res.maskUrls[i]), score: null, label });
}
return out;
}
async _applySelectedSam3Masks() {
if (!this._layerMgr || this._sam3Selected.size === 0) return;
const indices = [...this._sam3Selected].sort((a, b) => a - b);
const masks = indices.map(i => this._sam3Results[i]).filter(Boolean);
if (masks.length === 0) return;
let maskLayer = this._layerMgr.activeLayer;
if (!maskLayer || maskLayer.type !== "mask") {
const ref = this._layerMgr.activeLayer;
maskLayer = this._layerMgr.addLayer("mask", "SAM3 Mask", {
contentW: ref?.canvas.width ?? this._layerMgr.width,
contentH: ref?.canvas.height ?? this._layerMgr.height,
displayW: ref?.displayW ?? this._layerMgr.width,
displayH: ref?.displayH ?? this._layerMgr.height,
x: ref?.x ?? 0, y: ref?.y ?? 0,
});
this._layerMgr.setActive(maskLayer.id);
}
this._saveUndo();
for (const r of masks) {
await this._applySam3Mask(maskLayer, r.mask_b64, this._sam3Mode);
}
this._updateCompositeView();
this._refreshLayerList();
this._toast(window.t("image.msg.sam3Applied", masks.length, this._sam3Mode), "success");
}
_applySam3Mask(maskLayer, maskB64, mode = "add") {
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
const W = maskLayer.canvas.width;
const H = maskLayer.canvas.height;
// グレースケール輝度 → アルファ白マスクに変換
const off = document.createElement("canvas");
off.width = W; off.height = H;
const mc = off.getContext("2d");
mc.drawImage(img, 0, 0, W, H);
const imgData = mc.getImageData(0, 0, W, H);
const d = imgData.data;
for (let i = 0; i < d.length; i += 4) {
const lum = d[i];
d[i] = d[i+1] = d[i+2] = 255;
d[i+3] = lum;
}
mc.putImageData(imgData, 0, 0);
maskLayer.ctx.save();
maskLayer.ctx.globalCompositeOperation = mode === "erase" ? "destination-out" : "source-over";
maskLayer.ctx.drawImage(off, 0, 0);
maskLayer.ctx.restore();
resolve();
};
img.onerror = () => resolve();
img.src = maskB64;
});
}
// ── ABR ブラシピッカー(Mask Editor One)──────────
// targetTool: MaskTool または DrawTool インスタンス。onApply: ブラシ選択後の再描画コールバック。
_openAbrBrushPickerFor(targetTool, onApply, titleSuffix, tintColor) {
const existing = document.getElementById("cc-abr-picker-overlay");
if (existing) { existing.remove(); return; }
const overlay = document.createElement("div");
overlay.id = "cc-abr-picker-overlay";
Object.assign(overlay.style, {
position: "fixed", inset: "0",
background: "rgba(0,0,0,0.55)",
display: "flex", alignItems: "center", justifyContent: "center",
zIndex: "99999",
});
const modal = document.createElement("div");
Object.assign(modal.style, {
background: "var(--it-surface, #2a2a2a)",
border: "1px solid var(--it-border, #444)",
borderRadius: "8px",
width: "640px", height: "460px",
display: "flex", flexDirection: "column",
overflow: "hidden",
boxShadow: "0 8px 32px rgba(0,0,0,0.6)",
color: "var(--it-text, #ddd)",
fontFamily: "sans-serif",
});
const header = document.createElement("div");
Object.assign(header.style, {
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "8px 12px",
borderBottom: "1px solid var(--it-border, #444)",
fontWeight: "bold", fontSize: "13px", flexShrink: "0",
});
const headerTitle = document.createElement("span");
headerTitle.textContent = `ABR Brush Library${titleSuffix ? " — " + titleSuffix : ""}`;
const closeBtn = document.createElement("button");
closeBtn.textContent = "×";
closeBtn.className = "it-btn it-btn-sm";
closeBtn.style.cssText = "font-size:16px;padding:0 6px;";
closeBtn.onclick = () => overlay.remove();
header.appendChild(headerTitle);
header.appendChild(closeBtn);
modal.appendChild(header);
const body = document.createElement("div");
Object.assign(body.style, { display: "flex", flex: "1", overflow: "hidden" });
const treeEl = document.createElement("div");
Object.assign(treeEl.style, {
width: "150px", flexShrink: "0",
borderRight: "1px solid var(--it-border, #444)",
overflowY: "auto", padding: "4px 0", fontSize: "12px",
});
const gridEl = document.createElement("div");
Object.assign(gridEl.style, {
flex: "1", overflowY: "auto",
display: "flex", flexWrap: "wrap",
alignContent: "flex-start", padding: "6px", gap: "4px",
});
body.appendChild(treeEl);
body.appendChild(gridEl);
modal.appendChild(body);
const footer = document.createElement("div");
Object.assign(footer.style, {
display: "flex", gap: "8px", padding: "8px 12px", flexShrink: "0",
borderTop: "1px solid var(--it-border, #444)",
});
const resetBtn = document.createElement("button");
resetBtn.className = "it-btn it-btn-sm";
resetBtn.textContent = "⬤ Round Brush (default)";
resetBtn.onclick = () => {
targetTool?.clearImageBrush();
onApply();
overlay.remove();
};
footer.appendChild(resetBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
overlay.addEventListener("mousedown", e => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
const tree = this._abrBrushTree;
let selectedFolder = null;
const expanded = new Set();
const collectFiles = (nodes) => {
const result = [];
for (const n of nodes) {
if (n.type === "file") result.push(n);
else if (n.type === "folder" && n.children) result.push(...collectFiles(n.children));
}
return result;
};
const findFolder = (nodes, path) => {
for (const n of nodes) {
if (n.type !== "folder") continue;
if (n.path === path) return n;
const found = findFolder(n.children || [], path);
if (found) return found;
}
return null;
};
let cr = 255, cg = 255, cb = 255;
if (tintColor) {
const hex = tintColor.replace("#", "");
cr = parseInt(hex.slice(0, 2), 16);
cg = parseInt(hex.slice(2, 4), 16);
cb = parseInt(hex.slice(4, 6), 16);
}
const makeBrushThumb = (node) => {
const item = document.createElement("div");
Object.assign(item.style, {
width: "72px", cursor: "pointer", textAlign: "center",
padding: "4px", borderRadius: "4px",
border: "1px solid var(--it-border, #444)",
background: "var(--it-bg, #1a1a1a)",
boxSizing: "border-box",
});
item.title = node.name;
const THUMB = 64;
const canvas = document.createElement("canvas");
canvas.width = THUMB; canvas.height = THUMB;
canvas.style.cssText = "display:block;border-radius:3px;";
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#1a1a2a";
ctx.fillRect(0, 0, THUMB, THUMB);
const img = new Image();
img.onload = () => {
const srcW = img.naturalWidth || img.width;
const srcH = img.naturalHeight || img.height;
const aspect = srcW / srcH || 1;
const WORK = 128;
const wW = aspect >= 1 ? WORK : Math.max(1, Math.round(WORK * aspect));
const wH = aspect >= 1 ? Math.max(1, Math.round(WORK / aspect)) : WORK;
const tmp = document.createElement("canvas");
tmp.width = wW; tmp.height = wH;
const stx = tmp.getContext("2d");
stx.drawImage(img, 0, 0, wW, wH);
const id = stx.getImageData(0, 0, wW, wH);
const d = id.data;
let hasAlpha = false;
for (let i = 3; i < d.length; i += 4) if (d[i] < 250) { hasAlpha = true; break; }
let invertLum = false;
if (!hasAlpha) {
const corners = [0, wW - 1, wW * (wH - 1), wW * wH - 1];
let bg = 0;
for (const ci of corners) { const ii = ci * 4; bg += (d[ii] * 0.299 + d[ii+1] * 0.587 + d[ii+2] * 0.114) / 255; }
invertLum = (bg / corners.length) > 0.5;
}
for (let i = 0; i < d.length; i += 4) {
const a = hasAlpha ? d[i+3] / 255 : (() => { const l = (d[i]*0.299 + d[i+1]*0.587 + d[i+2]*0.114)/255; return invertLum ? 1-l : l; })();
d[i] = cr; d[i+1] = cg; d[i+2] = cb; d[i+3] = Math.round(a * 255);
}
stx.putImageData(id, 0, 0);
const THRESHOLD = 15;
let x0 = wW, x1 = -1, y0 = wH, y1 = -1;
for (let y = 0; y < wH; y++) for (let x = 0; x < wW; x++) if (d[(y*wW+x)*4+3] > THRESHOLD) { if (x<x0)x0=x; if(x>x1)x1=x; if(y<y0)y0=y; if(y>y1)y1=y; }
if (x1 >= x0 && y1 >= y0) {
const cW = x1-x0+1, cH = y1-y0+1;
const s = Math.min((THUMB-4)/cW, (THUMB-4)/cH);
ctx.drawImage(tmp, x0, y0, cW, cH, Math.round((THUMB-Math.round(cW*s))/2), Math.round((THUMB-Math.round(cH*s))/2), Math.round(cW*s), Math.round(cH*s));
}
};
img.src = `/mask_editor/brushes/raw?path=${encodeURIComponent(node.path)}`;
item.appendChild(canvas);
const label = document.createElement("div");
label.textContent = node.name;
Object.assign(label.style, { fontSize: "10px", marginTop: "2px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--it-text-secondary, #999)" });
item.appendChild(label);
item.addEventListener("mouseenter", () => { item.style.background = "color-mix(in srgb, var(--it-primary, #4682e6) 20%, transparent)"; item.style.borderColor = "var(--it-primary, #4682e6)"; });
item.addEventListener("mouseleave", () => { item.style.background = "var(--it-bg, #1a1a1a)"; item.style.borderColor = "var(--it-border, #444)"; });
item.onclick = () => {
const loadImg = new Image();
loadImg.onload = () => {
targetTool?.setImageBrush(loadImg, node.name);
onApply();
overlay.remove();
};
loadImg.src = `/mask_editor/brushes/raw?path=${encodeURIComponent(node.path)}`;
};
return item;
};
const showFolder = (path) => {
const files = path === null ? collectFiles(tree) : collectFiles(findFolder(tree, path)?.children || []);
gridEl.innerHTML = "";
if (files.length === 0) {
const msg = document.createElement("span");
msg.style.cssText = "font-size:12px;color:var(--it-text-secondary,#999);padding:12px;";
msg.textContent = tree.length === 0 ? "No brushes installed" : "No brushes in this folder";
gridEl.appendChild(msg);
return;
}
for (const file of files) gridEl.appendChild(makeBrushThumb(file));
};
const renderTree = () => {
treeEl.innerHTML = "";
const allItem = document.createElement("div");
Object.assign(allItem.style, {
padding: "5px 10px", cursor: "pointer", fontSize: "12px",
background: selectedFolder === null ? "color-mix(in srgb, var(--it-primary, #4682e6) 25%, transparent)" : "",
});
allItem.textContent = "All Brushes";
allItem.onclick = () => { selectedFolder = null; renderTree(); showFolder(null); };
treeEl.appendChild(allItem);
const renderNodes = (nodes, depth) => {
for (const n of nodes) {
if (n.type !== "folder") continue;
const isExpanded = expanded.has(n.path);
const item = document.createElement("div");
Object.assign(item.style, {
padding: `5px 10px 5px ${10 + depth * 12}px`,
cursor: "pointer", fontSize: "12px", display: "flex", alignItems: "center", gap: "4px",
background: selectedFolder === n.path ? "color-mix(in srgb, var(--it-primary, #4682e6) 25%, transparent)" : "",
});
const arrow = document.createElement("span");
arrow.style.cssText = "font-size:9px;color:var(--it-text-secondary,#999);flex-shrink:0;";
arrow.textContent = isExpanded ? "▼" : "▶";
const nameSpan = document.createElement("span");
nameSpan.textContent = n.name;
nameSpan.style.overflow = "hidden";
nameSpan.style.textOverflow = "ellipsis";
nameSpan.style.whiteSpace = "nowrap";
item.appendChild(arrow);
item.appendChild(nameSpan);
item.onclick = () => {
if (isExpanded) expanded.delete(n.path); else expanded.add(n.path);
selectedFolder = n.path;
renderTree(); showFolder(n.path);
};
treeEl.appendChild(item);
if (isExpanded) renderNodes(n.children || [], depth + 1);
}
};
renderNodes(tree, 0);
};
renderTree();
showFolder(null);
}
_openAbrBrushPicker() {
this._openAbrBrushPickerFor(
this._maskTool,
() => { this._renderMaskProps("paint"); this._renderToolOptions("mask"); },
"MASK EDITOR ONE",
null,
);
}
_openAbrBrushPickerForDraw() {
this._openAbrBrushPickerFor(
this._drawTool,
() => this._renderDrawProps(),
"MASK EDITOR ONE (COLOR)",
this._drawTool?.color ?? "#000000",
);
}
}
export const imageTab = new ImageTab();
window._ccImageTab = imageTab;
function initImageTab() {
return imageTab.init();
}
export { initImageTab };