Files
2025-08-22 01:28:18 +09:00

569 lines
19 KiB
JavaScript

import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { $el } from "../../scripts/ui.js";
import { debug, _name, _endpoint, api_get, api_post } from "./utils.js";
// ===============================================
// MovementManager - ノード・グループ移動拡張機能
// ===============================================
class MovementManager {
constructor() {
// 基本設定
this.enableArrowMove = true;
this.moveSteps = 100;
this.moveStepsShift = 200;
this.moveStepsCtrl = 10;
// Shift+ドラッグ制限の状態
this.shiftDragState = {
active: false,
startMouse: { x: 0, y: 0 },
nodeStartPositions: new Map(),
groupStartPositions: new Map(),
direction: null, // 'horizontal', 'vertical', null
threshold: 10
};
// イベントハンドラーをバインド
this.boundHandlers = {
keydown: this.handleKeyDown.bind(this),
shiftKeyUp: this.handleShiftKeyUp.bind(this),
pointerDown: this.handlePointerDown.bind(this),
pointerMove: this.handlePointerMove.bind(this),
pointerUp: this.handlePointerUp.bind(this)
};
this.isInitialized = false;
}
// ===============================================
// 初期化とクリーンアップ
// ===============================================
initialize() {
if (this.isInitialized) return;
this.setupEventListeners();
this.isInitialized = true;
}
cleanup() {
if (!this.isInitialized) return;
this.removeEventListeners();
this.resetShiftDrag();
this.isInitialized = false;
}
setupEventListeners() {
// キーボードイベント
document.addEventListener('keydown', this.boundHandlers.keydown, { capture: true, passive: false });
document.addEventListener('keyup', this.boundHandlers.shiftKeyUp, { capture: true });
// ポインターイベント(Shift+ドラッグ制限用)
app.canvasEl.addEventListener('pointerdown', this.boundHandlers.pointerDown, { capture: true });
app.canvasEl.addEventListener('pointermove', this.boundHandlers.pointerMove, { capture: true });
app.canvasEl.addEventListener('pointerup', this.boundHandlers.pointerUp, { capture: true });
}
removeEventListeners() {
document.removeEventListener('keydown', this.boundHandlers.keydown, { capture: true });
document.removeEventListener('keyup', this.boundHandlers.shiftKeyUp, { capture: true });
app.canvasEl.removeEventListener('pointerdown', this.boundHandlers.pointerDown, { capture: true });
app.canvasEl.removeEventListener('pointermove', this.boundHandlers.pointerMove, { capture: true });
app.canvasEl.removeEventListener('pointerup', this.boundHandlers.pointerUp, { capture: true });
}
// ===============================================
// ユーティリティ関数
// ===============================================
canNodeMove() {
const activeElement = document.activeElement;
// 入力フィールドにフォーカスがある場合は無効
const inputTags = ['INPUT', 'TEXTAREA'];
if (inputTags.includes(activeElement?.tagName) || activeElement?.isContentEditable) {
return false;
}
// 画像表示エリアでも、ノードまたはグループが選択されていれば移動を許可
const imageClasses = ['comfy-image', 'image-preview'];
const imageTags = ['CANVAS', 'IMG'];
if (imageClasses.some(cls => activeElement?.classList?.contains(cls)) ||
imageTags.includes(activeElement?.tagName)) {
const { nodes, groups } = this.getSelectedElements();
return nodes.length > 0 || groups.length > 0;
}
return true;
}
getSelectedNodes() {
const selectedNodes = app.canvas.selected_nodes;
return typeof selectedNodes === "object" ? Object.values(selectedNodes) : [];
}
getSelectedGroups() {
// ComfyUIのグループ選択状態を取得
const canvas = app.canvas;
const selectedGroups = [];
if (canvas.graph && canvas.graph._groups) {
canvas.graph._groups.forEach(group => {
if (group.selected || (canvas.selected_group === group)) {
selectedGroups.push(group);
}
});
}
return selectedGroups;
}
getSelectedElements() {
return {
nodes: this.getSelectedNodes(),
groups: this.getSelectedGroups()
};
}
// グループ内のノードを取得
getNodesInGroup(group) {
return group._nodes || group.nodes || [];
}
// グループ内の子グループを取得
getChildGroups(group) {
if (!group._children && !group.children) return [];
const children = group._children || group.children;
const childGroups = [];
children.forEach(child => {
if (child instanceof LGraphGroup || (child.constructor && child.constructor.name === 'LGraphGroup')) {
childGroups.push(child);
}
});
return childGroups;
}
// グループ内の全ノードを再帰的に取得(ネストグループも含む)
getAllNodesInGroupRecursive(group, visited = new Set()) {
if (visited.has(group)) return [];
visited.add(group);
// 直接的な子ノード
let allNodes = [...this.getNodesInGroup(group)];
// 子グループ内のノードも再帰的に取得
const childGroups = this.getChildGroups(group);
childGroups.forEach(childGroup => {
if (!visited.has(childGroup)) {
allNodes = allNodes.concat(this.getAllNodesInGroupRecursive(childGroup, visited));
}
});
return allNodes;
}
// ネストしたグループを再帰的に取得
getAllGroupsRecursive(group, visited = new Set()) {
if (visited.has(group)) return [];
visited.add(group);
const allGroups = [group];
// 子グループを直接取得
const childGroups = this.getChildGroups(group);
childGroups.forEach(childGroup => {
if (!visited.has(childGroup)) {
allGroups.push(...this.getAllGroupsRecursive(childGroup, visited));
}
});
return allGroups;
}
moveElements(dx, dy) {
const { nodes, groups } = this.getSelectedElements();
if (nodes.length === 0 && groups.length === 0) return false;
// 選択されたノード(直接選択)
const selectedNodes = new Set(nodes);
// 選択されたグループとその中のノードを処理
const processedGroups = new Set();
const groupNodes = new Set();
groups.forEach(group => {
const allGroups = this.getAllGroupsRecursive(group);
allGroups.forEach(g => {
if (!processedGroups.has(g)) {
// グループを移動
g.pos[0] += dx;
g.pos[1] += dy;
processedGroups.add(g);
// グループ内のノードも移動対象に追加
const nodesInGroup = this.getAllNodesInGroupRecursive(g);
nodesInGroup.forEach(node => groupNodes.add(node));
}
});
});
// 直接選択されたノードを移動
selectedNodes.forEach(node => {
node.pos[0] += dx;
node.pos[1] += dy;
});
// グループ内のノードを移動(直接選択されていないもののみ)
groupNodes.forEach(node => {
if (!selectedNodes.has(node)) {
node.pos[0] += dx;
node.pos[1] += dy;
}
});
// 選択オーバーレイも同期
this.updateSelectionOverlay(dx, dy);
// キャンバス更新
app.canvas.setDirty(true, true);
return true;
}
// 従来のmoveNodesメソッドを維持(後方互換性のため)
moveNodes(dx, dy) {
return this.moveElements(dx, dy);
}
// ===============================================
// キーボード移動機能
// ===============================================
handleKeyDown(e) {
const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
// 早期リターン条件をまとめて処理
if (!arrowKeys.includes(e.key) ||
!this.enableArrowMove ||
!this.canNodeMove()) {
return;
}
const { nodes, groups } = this.getSelectedElements();
if (nodes.length === 0 && groups.length === 0) {
return;
}
// イベント停止
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// 移動量の決定
let step = this.moveSteps;
if (e.shiftKey) step = this.moveStepsShift;
else if (e.ctrlKey || e.metaKey) step = this.moveStepsCtrl;
// 方向別の移動処理
const movements = {
'ArrowUp': [0, -step],
'ArrowDown': [0, step],
'ArrowLeft': [-step, 0],
'ArrowRight': [step, 0]
};
const [dx, dy] = movements[e.key];
this.moveElements(dx, dy);
}
handleShiftKeyUp(e) {
if (e.key === 'Shift' && this.shiftDragState.active) {
this.endShiftDrag();
}
}
// ===============================================
// Shift+ドラッグ制限機能
// ===============================================
handlePointerDown(e) {
const { nodes, groups } = this.getSelectedElements();
groups.forEach(group => group?.recomputeInsideNodes()); // 選択したグループの内部を再計算
if (e.shiftKey && (nodes.length > 0 || groups.length > 0)) {
this.startShiftDrag(e);
}
}
handlePointerMove(e) {
if (this.shiftDragState.active) {
this.processShiftDrag(e);
}
}
handlePointerUp(e) {
if (this.shiftDragState.active) {
this.endShiftDrag();
}
}
startShiftDrag(e) {
const state = this.shiftDragState;
const { nodes, groups } = this.getSelectedElements();
state.active = true;
state.direction = null;
state.startMouse = { x: e.clientX, y: e.clientY };
// 選択されたノード(直接選択)の開始位置を記録
state.nodeStartPositions.clear();
const selectedNodes = new Set(nodes);
nodes.forEach(node => {
state.nodeStartPositions.set(node, { x: node.pos[0], y: node.pos[1] });
});
// グループの開始位置を記録
state.groupStartPositions.clear();
const processedGroups = new Set();
const groupNodes = new Set();
groups.forEach(group => {
const allGroups = this.getAllGroupsRecursive(group);
allGroups.forEach(g => {
if (!processedGroups.has(g)) {
state.groupStartPositions.set(g, { x: g.pos[0], y: g.pos[1] });
processedGroups.add(g);
// グループ内のノードの開始位置も記録
const nodesInGroup = this.getAllNodesInGroupRecursive(g);
nodesInGroup.forEach(node => {
if (!selectedNodes.has(node) && !state.nodeStartPositions.has(node)) {
state.nodeStartPositions.set(node, { x: node.pos[0], y: node.pos[1] });
groupNodes.add(node);
}
});
}
});
});
}
processShiftDrag(e) {
const state = this.shiftDragState;
const deltaX = e.clientX - state.startMouse.x;
const deltaY = e.clientY - state.startMouse.y;
// 方向決定
if (!state.direction) {
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
if (absX > state.threshold || absY > state.threshold) {
state.direction = absX > absY ? 'horizontal' : 'vertical';
}
}
// 制約の適用
if (state.direction) {
this.applyMovementConstraint();
// グループ移動時は、グループ内のノードも制約を適用する必要がある
this.synchronizeGroupNodes();
}
}
applyMovementConstraint() {
const state = this.shiftDragState;
let constraintApplied = false;
// 全ノード(直接選択 + グループ内)の制約適用
state.nodeStartPositions.forEach((startPos, node) => {
if (state.direction === 'horizontal' && node.pos[1] !== startPos.y) {
node.pos[1] = startPos.y;
constraintApplied = true;
} else if (state.direction === 'vertical' && node.pos[0] !== startPos.x) {
node.pos[0] = startPos.x;
constraintApplied = true;
}
});
// グループの制約適用
state.groupStartPositions.forEach((startPos, group) => {
if (state.direction === 'horizontal' && group.pos[1] !== startPos.y) {
group.pos[1] = startPos.y;
constraintApplied = true;
} else if (state.direction === 'vertical' && group.pos[0] !== startPos.x) {
group.pos[0] = startPos.x;
constraintApplied = true;
}
});
if (constraintApplied) {
app.canvas.setDirty(true, true);
}
}
// グループ移動時にグループ内のノードを同期
synchronizeGroupNodes() {
const state = this.shiftDragState;
const { groups } = this.getSelectedElements();
let syncApplied = false;
groups.forEach(group => {
const allGroups = this.getAllGroupsRecursive(group);
allGroups.forEach(g => {
const groupStartPos = state.groupStartPositions.get(g);
if (!groupStartPos) return;
// グループの現在の移動量を計算
const groupDeltaX = g.pos[0] - groupStartPos.x;
const groupDeltaY = g.pos[1] - groupStartPos.y;
// グループ内のノードを同期
const nodesInGroup = this.getAllNodesInGroupRecursive(g);
nodesInGroup.forEach(node => {
const nodeStartPos = state.nodeStartPositions.get(node);
if (!nodeStartPos) return;
// グループの移動量に合わせてノードを移動
const expectedNodeX = nodeStartPos.x + groupDeltaX;
const expectedNodeY = nodeStartPos.y + groupDeltaY;
if (node.pos[0] !== expectedNodeX || node.pos[1] !== expectedNodeY) {
node.pos[0] = expectedNodeX;
node.pos[1] = expectedNodeY;
syncApplied = true;
}
});
});
});
if (syncApplied) {
app.canvas.setDirty(true, true);
}
}
endShiftDrag() {
this.resetShiftDrag();
}
resetShiftDrag() {
const state = this.shiftDragState;
state.active = false;
state.direction = null;
state.nodeStartPositions.clear();
state.groupStartPositions.clear();
}
// ===============================================
// 選択オーバーレイの同期
// ===============================================
updateSelectionOverlay(dx, dy) {
const container = app.canvasEl.parentElement;
const overlayElement = container?.querySelector('.selection-overlay-container');
if (!overlayElement) return;
// 現在位置の取得と計算
const style = overlayElement.style;
const currentLeft = parseFloat(style.left) || 0;
const currentTop = parseFloat(style.top) || 0;
// スケール考慮
const scale = app.canvas.ds?.scale || 1;
const newLeft = currentLeft + (dx * scale);
const newTop = currentTop + (dy * scale);
// 位置更新
style.left = `${newLeft}px`;
style.top = `${newTop}px`;
}
}
// ===============================================
// 設定項目の定義
// ===============================================
const enableArrowMoveSetting = {
name: "Enable Arrow Key Node Movement",
id: _name("EnableArrowMove"),
type: "boolean",
defaultValue: true,
onChange: (value) => {
if (movementManager) {
movementManager.enableArrowMove = value;
}
},
};
const moveStepsSetting = {
name: "Move Steps with ARROW key",
id: _name("moveSteps"),
type: "slider",
defaultValue: 100,
attrs: { min: 1, max: 400, step: 1 },
onChange: (value) => {
if (movementManager) {
movementManager.moveSteps = value;
}
},
};
const moveStepsShiftSetting = {
name: "Move Steps with SHIFT+ARROW key",
id: _name("moveStepsShift"),
type: "slider",
defaultValue: 200,
attrs: { min: 1, max: 400, step: 1 },
onChange: (value) => {
if (movementManager) {
movementManager.moveStepsShift = value;
}
},
};
const moveStepsCtrlSetting = {
name: "Move Steps with CTRL+ARROW key",
id: _name("moveStepsCtrl"),
type: "slider",
defaultValue: 10,
attrs: { min: 1, max: 400, step: 1 },
onChange: (value) => {
if (movementManager) {
movementManager.moveStepsCtrl = value;
}
},
};
// ===============================================
// グローバルインスタンスと拡張機能登録
// ===============================================
let movementManager = null;
const extension = {
name: _name("LinearMove"),
init: async function(app) {
movementManager = new MovementManager();
},
settings: [
enableArrowMoveSetting,
moveStepsSetting,
moveStepsShiftSetting,
moveStepsCtrlSetting
].slice().reverse(),
setup: async function(app) {
if (!movementManager) return;
// 設定を読み込み
movementManager.enableArrowMove = app.ui.settings.getSettingValue(enableArrowMoveSetting.id);
movementManager.moveSteps = app.ui.settings.getSettingValue(moveStepsSetting.id);
movementManager.moveStepsShift = app.ui.settings.getSettingValue(moveStepsShiftSetting.id);
movementManager.moveStepsCtrl = app.ui.settings.getSettingValue(moveStepsCtrlSetting.id);
// 機能を初期化
movementManager.initialize();
}
};
app.registerExtension(extension);