Files
da2el-ai-D2-nodes-ComfyUI/web/D2_FloatContainer.js
T
da2el-alandClaude Opus 4.8 7b45dd8529 feat: add actionbar docking to D2_QueueButton via drag and drop
Drag the queue button panel onto the top actionbar to dock it, and drag
the handle out (past a threshold) to float it again. Docking reuses
ComfyUI's ComfyButtonGroup / actionbar `.border-dashed` reveal hook, and
the mode is persisted in a cookie. The dock feature is opt-in via a new
D2_FloatContainer option so the shared PromptConvert / ProgressPreview
panels are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:28:59 +09:00

392 lines
13 KiB
JavaScript

/**
* ボタンを並べた小さいフロートウィンドウのクラス
*/
import { app } from "../../scripts/app.js";
import { setCookie, getCookie, loadCssFile } from "./modules/utils.js";
const CSS_CLASS_BUTTON_BASE =
'inline-flex items-center justify-center cursor-pointer touch-manipulation appearance-none border-none text-sm font-inter transition-colors h-8 rounded-lg px-4 font-light';
const CSS_CLSSS_BUTTON_PRIMARY = 'text-base-foreground bg-primary-background hover:bg-primary-background-hover';
const CSS_CLSSS_BUTTON_SECONDARY = 'text-secondary-foreground bg-secondary-background hover:bg-secondary-background-hover';
// dock から離脱する移動量しきい値(px)
const DOCK_BREAKAWAY_THRESHOLD = 6;
class D2_FloatContainer {
static CSS_FILEPATH = "/D2/assets/css/D2_FloatContainer.css?3";
container = undefined;
frame = undefined;
body = undefined;
cookieNameBase = "";
// ドック機能
enableDock = false;
mode = "float"; // "float" | "dock"
visible = true;
dockGroup = null; // アクションバーへ挿入する ComfyButtonGroup(ドック先&ドロップ枠を兼ねる)
/**
* @param {string} cookieNameBase cookie キーの接頭辞
* @param {number} default_left 初期位置 X
* @param {number} default_top 初期位置 Y
* @param {{enableDock?: boolean}} options enableDock: true でアクションバー収納を有効化
*/
constructor(cookieNameBase, default_left = 50, default_top = 50, options = {}) {
this.cookieNameBase = cookieNameBase;
this.enableDock = options.enableDock === true;
loadCssFile(D2_FloatContainer.CSS_FILEPATH);
this._createContainer();
// 初期位置
const pos = this._getPosition(default_left, default_top);
this.container.style.left = pos[0] + "px";
this.container.style.top = pos[1] + "px";
// ウィンドウリサイズ対策
window.addEventListener("resize", () => {
this.resizeSetting();
});
this.resizeSetting();
// ドック状態の復元(遅延)
if (this.enableDock) {
this._restoreDockState();
}
}
/**
* 内容物追加
* @param {HTMLElement} button
*/
addContent(button) {
this.body.appendChild(button);
}
/**
* 内容物全削除
*/
removeAllContent() {
this.body.innerHTML = "";
}
/**
* 表示切り替え
*/
changeVisible(bool) {
this.visible = bool;
this._applyVisible();
}
///////////////////////////////////////////////
// private method
///////////////////////////////////////////////
/**
* ベース作成
*/
_createContainer() {
this.container = document.createElement("div");
this.container.classList.add("p-panel", "d2-float-container");
document.querySelector("body").appendChild(this.container);
this.frame = document.createElement("div");
this.frame.classList.add("p-panel-content", "flex", "flex-nowrap", "items-center", "d2-float-container__frame");
this.container.appendChild(this.frame);
const dragHandle = document.createElement("span");
dragHandle.classList.add("drag-handle", "cursor-grab", "w-3", "mr-2", "d2-float-container__drag-handle");
this.frame.appendChild(dragHandle);
this.body = document.createElement("div");
this.body.classList.add("flex", "flex-nowrap", "items-center", "d2-float-container__body");
this.frame.appendChild(this.body);
// ドラッグ設定
this._dragSetting(dragHandle, this.container);
}
/**
* 現在の visible / mode に応じて表示状態を適用する
*/
_applyVisible() {
const bool = this.visible;
this.frame.style.display = bool ? "flex" : "none";
if (this.mode === "float") {
// float 時はラッパパネルごと表示/非表示
this.container.style.display = bool ? "block" : "none";
} else {
// dock 時は frame が container 外にあるため、空のラッパは常に隠す
this.container.style.display = "none";
}
}
/**
* 座標をcookieに記録
*/
_savePosition(x, y) {
const cookieName = `${this.cookieNameBase}_pos`;
const cookieValue = [x, y];
setCookie(cookieName, cookieValue);
}
/**
* 座標をcookieから取得
*/
_getPosition(default_left, default_top) {
const cookieName = `${this.cookieNameBase}_pos`;
const cookieValue = getCookie(cookieName);
return cookieValue !== undefined ? cookieValue : [default_left, default_top];
}
/**
* ドック状態をcookieに記録
*/
_saveMode() {
setCookie(`${this.cookieNameBase}_dock`, this.mode === "dock");
}
/**
* 保存されたドック状態を取得
*/
_getSavedDock() {
return getCookie(`${this.cookieNameBase}_dock`) === true;
}
///////////////////////////////////////////////
// dock 関連
///////////////////////////////////////////////
/**
* ドック先&ドロップ枠用の ComfyButtonGroup をアクションバーへ挿入(初回のみ)
* @returns {object|null}
*/
_createDockGroup() {
const ButtonGroup = window.comfyAPI?.buttonGroup?.ComfyButtonGroup;
const settingsGroup = app.menu?.settingsGroup;
if (!ButtonGroup || !settingsGroup?.element) return null;
const group = new ButtonGroup();
group.element.classList.add("d2-dock-group");
settingsGroup.element.before(group.element);
this.dockGroup = group;
return group;
}
/**
* ドック可能か(毎回評価)。利用不可なら false。
* 利用可能なら dockGroup を遅延生成する。
*/
_canDock() {
if (!this.enableDock) return false;
const ButtonGroup = window.comfyAPI?.buttonGroup?.ComfyButtonGroup;
const settingsGroup = app.menu?.settingsGroup;
if (!ButtonGroup || !settingsGroup?.element) return false;
if (!this.dockGroup) {
this._createDockGroup();
}
return !!this.dockGroup;
}
/**
* ドロップ枠を表示(border-dashed でアクションバーが開く)
*/
_showDropTarget() {
if (!this.dockGroup) return;
this.dockGroup.element.classList.add("border-dashed", "d2-dock-drop-target");
}
/**
* ドロップ枠を畳む
*/
_hideDropTarget() {
if (!this.dockGroup) return;
this.dockGroup.element.classList.remove("border-dashed", "d2-dock-drop-target", "d2-dock-drop-ready");
}
/**
* ドロップ可能状態のハイライト切り替え
*/
_highlightDropTarget(on) {
if (!this.dockGroup) return;
this.dockGroup.element.classList.toggle("d2-dock-drop-ready", !!on);
}
/**
* 表示中のドロップ枠の矩形を返す(未表示/サイズ0なら null)
*/
_getDropTargetRect() {
if (!this.dockGroup) return null;
const rect = this.dockGroup.element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
return rect;
}
/**
* float ⇔ dock の切り替え
* @param {boolean} bool true で dock、false で float
*/
setDock(bool) {
if (bool) {
if (!this._canDock()) return;
this.dockGroup.element.appendChild(this.frame);
this.dockGroup.element.classList.add("d2-dock-active");
this._hideDropTarget();
this.mode = "dock";
} else {
this.container.appendChild(this.frame);
if (this.dockGroup) {
this.dockGroup.element.classList.remove("d2-dock-active");
}
this.mode = "float";
// 位置を復元
const pos = this._getPosition(50, 50);
this.container.style.left = pos[0] + "px";
this.container.style.top = pos[1] + "px";
this.resizeSetting();
}
this._applyVisible();
this._saveMode();
}
/**
* 起動時のドック状態復元(app.menu の生成を待つ)
*/
_restoreDockState() {
if (!this._getSavedDock()) return;
let tries = 0;
const tryDock = () => {
if (this._canDock()) {
this.setDock(true);
return;
}
if (tries++ < 60) {
setTimeout(tryDock, 100);
}
};
tryDock();
}
/**
* ドラッグ設定
*/
_dragSetting(handle, container) {
let isDragging = false;
let startX, startY, initialLeft, initialTop;
let dropReady = false; // float ドラッグ中: カーソルが枠内
let brokeAway = false; // dock ドラッグ中: しきい値を超えて離脱済み
// マウスダウンイベントリスナー
handle.addEventListener("mousedown", (e) => {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
initialLeft = container.offsetLeft;
initialTop = container.offsetTop;
dropReady = false;
brokeAway = false;
// float 時はドロップ枠を表示
if (this.mode === "float" && this._canDock()) {
this._showDropTarget();
}
// テキスト選択を防止
e.preventDefault();
});
// マウスムーブイベントリスナー
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
// dock 中: しきい値を超えるまでは動かさない
if (this.mode === "dock") {
const dist = Math.abs(e.clientX - startX) + Math.abs(e.clientY - startY);
if (!brokeAway) {
if (dist < DOCK_BREAKAWAY_THRESHOLD) return;
brokeAway = true;
// float へ離脱し、カーソル位置へ移してドラッグ継続
this.setDock(false);
initialLeft = e.clientX - 10;
initialTop = e.clientY - 10;
startX = e.clientX;
startY = e.clientY;
}
}
// float 移動
const x = e.clientX - startX + initialLeft;
const y = e.clientY - startY + initialTop;
container.style.left = `${x}px`;
container.style.top = `${y}px`;
this._savePosition(x, y);
// ドロップ枠の当たり判定(float 時のみ)
if (this.mode === "float" && this._canDock()) {
const rect = this._getDropTargetRect();
dropReady = !!(rect &&
e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom);
this._highlightDropTarget(dropReady);
}
});
// マウスアップイベントリスナー
document.addEventListener("mouseup", () => {
if (!isDragging) return;
isDragging = false;
if (this.mode === "float" && this._canDock()) {
if (dropReady) {
this.setDock(true);
} else {
this._hideDropTarget();
}
}
dropReady = false;
brokeAway = false;
});
// マウスリーブイベントリスナー(ブラウザ外にマウスが出た場合)
document.addEventListener("mouseleave", () => {
if (!isDragging) return;
isDragging = false;
this._hideDropTarget();
dropReady = false;
brokeAway = false;
});
}
/**
* ウィンドウリサイズ対策
*/
resizeSetting() {
// dock 時は位置調整不要
if (this.mode === "dock") return;
const container = this.container;
const rect = container.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
// 右端の調整
if (rect.right > viewportWidth) {
const newLeft = Math.max(0, viewportWidth - rect.width);
container.style.left = `${newLeft}px`;
} else if (rect.left < 0) {
container.style.left = "0px";
}
// 下端の調整
if (rect.bottom > viewportHeight) {
const newTop = Math.max(0, viewportHeight - rect.height);
container.style.top = `${newTop}px`;
} else if (rect.top < 0) {
container.style.top = "0px";
}
}
}
export { D2_FloatContainer, CSS_CLASS_BUTTON_BASE, CSS_CLSSS_BUTTON_PRIMARY, CSS_CLSSS_BUTTON_SECONDARY }