569 lines
19 KiB
JavaScript
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); |