Files
ComfyUI_ETK_LTXV_Timeline_E…/web/ltxv_timeline/interaction.js
T
2026-08-26 09:55:12 -06:00

362 lines
12 KiB
JavaScript

// Layer controls, pointer/keyboard interaction, zoom, and global input listeners.
import { app } from "../../../../scripts/app.js";
import {
CTRL_KEYBOARD_PAN_IMAGE_PX,
CTRL_WHEEL_ZOOM_STEP,
DEFAULT_TEXT_LAYER,
KEYBOARD_PAN_STEP,
SHIFT_WHEEL_ZOOM_STEP,
WHEEL_ZOOM_STEP,
} from "./constants.js";
import {
activeCanvasGraph,
addTimelineWindowListener,
graphNodes,
isTargetNode,
makeId,
setTimelineStatus,
} from "./runtime.js";
import {
activeLayer,
activeLayerIndex,
ensureSelectedKeyframe,
isEmptyLayer,
mirrorBaseLayer,
normalizeKeyframes,
normalizeLayer,
normalizePan,
normalizeZoom,
rejectLayerEditIfLocked,
selectedKeyframe,
selectedKeyframeIndex,
setSelectedKeyframe,
syncTimelineWidget,
updateActiveLayer,
viewUrl,
} from "./model.js";
import { syncTextLayerEditor } from "./editors.js";
import { setupNode } from "./node_setup.js";
import {
deleteKeyframeAtIndex,
duplicateKeyframeAtIndex,
findKeyframeAt,
formatSlotSeconds,
previewImageBox,
} from "./timeline_ops.js";
import { controlContentBounds, previewPlacement } from "./rendering.js";
import { nodeFromGraphMouse, stopKeyboardShortcutEvent } from "./media.js";
export function modeButtonBounds(node, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
return { x: content.x + 192, y: content.y + 4, w: 68, h: 22 };
}
export function lockLayerButtonBounds(node, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
return { x: content.x + 268, y: content.y + 4, w: 56, h: 22 };
}
export function imageButtonBounds(node, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
return { x: content.x + 136, y: content.y + 4, w: 50, h: 22 };
}
export function textLayerButtonBounds(node, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
return { x: content.x + 332, y: content.y + 4, w: 50, h: 22 };
}
export function uploadModeButtonBounds(node, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
return { x: content.x + 50, y: content.y + 4, w: 78, h: 22 };
}
export function deleteLayerButtonBounds(node, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
return { x: content.x, y: content.y + 4, w: 42, h: 22 };
}
export function keyframeStrengthSliderBounds(node, kind, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
const sliderW = Math.min(520, Math.max(220, content.w));
const x = content.x;
const y = content.y + (kind === "latent" ? 0 : 25);
return {
kind,
label: kind === "latent" ? "latent strength" : "prompt strength",
min: 0,
max: kind === "latent" ? 1 : 4,
color: kind === "latent" ? "#42c06e" : "#7d6ccf",
x,
y,
w: sliderW,
h: 19,
trackX: x + 104,
trackY: y + 8,
trackW: Math.max(50, sliderW - 116),
trackH: 4,
};
}
export function keyframeStrengthSliderAt(node, x, y) {
for (const kind of ["latent", "prompt"]) {
const slider = keyframeStrengthSliderBounds(node, kind);
if (x >= slider.x && x <= slider.x + slider.w && y >= slider.y && y <= slider.y + slider.h) {
return slider;
}
}
return null;
}
export function layerFilterSliderBounds(node, kind, areaY = null, areaH = null) {
const content = controlContentBounds(node, areaY, areaH);
const sliderW = Math.min(520, Math.max(220, content.w));
const x = content.x;
const y = content.y + (kind === "brightness" ? 0 : 25);
return {
kind,
label: kind,
min: 0,
max: 2,
color: kind === "brightness" ? "#f0c45c" : "#5fb4f0",
x,
y,
w: sliderW,
h: 19,
trackX: x + 86,
trackY: y + 8,
trackW: Math.max(50, sliderW - 98),
trackH: 4,
};
}
export function layerFilterSliderAt(node, x, y) {
for (const kind of ["brightness", "contrast"]) {
const slider = layerFilterSliderBounds(node, kind);
if (x >= slider.x && x <= slider.x + slider.w && y >= slider.y && y <= slider.y + slider.h) {
return slider;
}
}
return null;
}
export function addTextLayer(node, keyframeId = null) {
if (rejectLayerEditIfLocked(node, "Text layer add")) return false;
const keyframe = keyframeId == null ? ensureSelectedKeyframe(node) : selectedKeyframe(node);
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const index = keyframeId == null
? selectedKeyframeIndex(node, keyframes)
: keyframes.findIndex((candidate) => candidate.id === keyframeId);
if (!keyframe || index < 0) return false;
const target = keyframes[index];
const layers = Array.isArray(target.layers) ? target.layers.slice() : [];
const activeIndex = activeLayerIndex(target);
const textLayer = normalizeLayer({
id: makeId(),
type: "text",
text: DEFAULT_TEXT_LAYER.text,
fontFamily: DEFAULT_TEXT_LAYER.fontFamily,
fontSize: DEFAULT_TEXT_LAYER.fontSize,
bold: DEFAULT_TEXT_LAYER.bold,
italic: DEFAULT_TEXT_LAYER.italic,
align: DEFAULT_TEXT_LAYER.align,
color: DEFAULT_TEXT_LAYER.color,
outlineColor: DEFAULT_TEXT_LAYER.outlineColor,
outlineWidth: DEFAULT_TEXT_LAYER.outlineWidth,
shadowColor: DEFAULT_TEXT_LAYER.shadowColor,
shadowBlur: DEFAULT_TEXT_LAYER.shadowBlur,
shadowOffsetX: DEFAULT_TEXT_LAYER.shadowOffsetX,
shadowOffsetY: DEFAULT_TEXT_LAYER.shadowOffsetY,
fitMode: "pad",
panX: 0,
panY: 0,
zoom: 1,
});
if (activeIndex >= 0 && isEmptyLayer(layers[activeIndex])) {
textLayer.id = layers[activeIndex].id || textLayer.id;
layers[activeIndex] = textLayer;
} else {
layers.push(textLayer);
}
target.layers = layers;
target.selectedLayerId = textLayer.id;
mirrorBaseLayer(target);
keyframes[index] = target;
node.properties.etk_ltxv_timeline_keyframes = keyframes;
setSelectedKeyframe(node, index, keyframes);
syncTimelineWidget(node);
syncTextLayerEditor(node);
setTimelineStatus(node, `Added text layer at ${formatSlotSeconds(node, target.slot)}.`, "success");
node.setDirtyCanvas(true, true);
return true;
}
export function releaseDrag(node) {
if (!node || (!node.etkTimelineDragging && !node.etkPreviewPanning && !node.etkSliderDragging)) return false;
node.etkTimelineDragging = false;
node.etkTimelineDraggingId = null;
node.etkPreviewPanning = null;
node.etkSliderDragging = null;
if (window.etkTimelineActiveDragNode === node) window.etkTimelineActiveDragNode = null;
syncTimelineWidget(node);
node.setDirtyCanvas(true, true);
return true;
}
export function wheelZoomStep(event) {
if (event && (event.ctrlKey || event.metaKey)) return CTRL_WHEEL_ZOOM_STEP;
if (event && event.shiftKey) return SHIFT_WHEEL_ZOOM_STEP;
return WHEEL_ZOOM_STEP;
}
export function zoomPreviewAt(node, event, pos) {
setupNode(node);
const selected = selectedKeyframe(node);
const layer = selected ? activeLayer(selected) : null;
if (!selected || !layer || !pos) return false;
const { boxX, boxY, boxW, boxH } = previewImageBox(node);
const [x, y] = pos;
if (x < boxX || x > boxX + boxW || y < boxY || y > boxY + boxH) return false;
if (rejectLayerEditIfLocked(node, "Layer zoom")) return true;
const delta = event && Number.isFinite(event.deltaY) ? event.deltaY : 0;
const step = wheelZoomStep(event);
const factor = delta < 0 ? 1 + step : 1 - step;
updateActiveLayer(node, selected.id, (current) => ({
...current,
zoom: normalizeZoom(normalizeZoom(current.zoom) * factor),
}));
return true;
}
export function isNodeSelected(node) {
const canvas = app.canvas;
if (!node || !canvas) return false;
if (canvas.selected_node === node) return true;
const selected = canvas.selected_nodes || canvas.selectedNodes;
if (Array.isArray(selected)) return selected.includes(node);
if (selected && typeof selected === "object") {
if (node.id != null && (selected[node.id] === node || selected[node.id] === true)) return true;
if (Object.values(selected).includes(node)) return true;
}
return Boolean(node.selected || node.is_selected || (node.flags && node.flags.selected));
}
export function panPreviewWithKeyboard(node, event, pos) {
setupNode(node);
const key = String(event && event.key ? event.key : "");
const directions = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
};
const direction = directions[key];
if (!direction) return false;
const selected = selectedKeyframe(node);
const layer = selected ? activeLayer(selected) : null;
if (!selected || !layer || isEmptyLayer(layer) || !pos) return false;
const { boxX, boxY, boxW, boxH } = previewImageBox(node);
const [x, y] = pos;
if (x < boxX || x > boxX + boxW || y < boxY || y > boxY + boxH) return false;
if (rejectLayerEditIfLocked(node, "Layer pan")) return true;
let deltaX = direction[0] * KEYBOARD_PAN_STEP;
let deltaY = direction[1] * KEYBOARD_PAN_STEP;
if (event && event.ctrlKey) {
const url = viewUrl(layer.image);
const image = url && node.etkTimelineImageCache ? node.etkTimelineImageCache.get(url) : null;
if (!image || !image.complete || image.naturalWidth <= 0 || image.naturalHeight <= 0) return false;
const placement = previewPlacement(
image,
boxW,
boxH,
layer.fitMode,
layer.panX,
layer.panY,
layer.zoom
);
const sourcePixelW = placement.drawW / image.naturalWidth;
const sourcePixelH = placement.drawH / image.naturalHeight;
deltaX = direction[0] ? direction[0] * CTRL_KEYBOARD_PAN_IMAGE_PX * sourcePixelW / (placement.panRangeX / 2) : 0;
deltaY = direction[1] ? direction[1] * CTRL_KEYBOARD_PAN_IMAGE_PX * sourcePixelH / (placement.panRangeY / 2) : 0;
}
updateActiveLayer(node, selected.id, (current) => ({
...current,
panX: normalizePan(current.panX) + deltaX,
panY: normalizePan(current.panY) + deltaY,
}));
return true;
}
export function installWheelCapture() {
addTimelineWindowListener(
"wheelCapture.wheel",
"wheel",
(event) => {
const canvas = app.canvas;
const graphMouse = canvas ? canvas.graph_mouse : null;
const node = nodeFromGraphMouse(graphMouse);
if (!node) return;
const localPos = [graphMouse[0] - node.pos[0], graphMouse[1] - node.pos[1]];
if (!zoomPreviewAt(node, event, localPos)) return;
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
},
{ capture: true, passive: false }
);
}
export function installPointerRelease() {
const releaseActiveDrag = () => {
releaseDrag(window.etkTimelineActiveDragNode);
};
addTimelineWindowListener("pointerRelease.mouseup", "mouseup", releaseActiveDrag, true);
addTimelineWindowListener("pointerRelease.pointerup", "pointerup", releaseActiveDrag, true);
addTimelineWindowListener("pointerRelease.pointercancel", "pointercancel", releaseActiveDrag, true);
addTimelineWindowListener("pointerRelease.blur", "blur", releaseActiveDrag, true);
}
export function installKeyboardShortcuts() {
addTimelineWindowListener("keyboardShortcuts.keydown", "keydown", (event) => {
const rawKey = String(event.key || "");
const key = rawKey.toLowerCase();
const arrowKey = rawKey === "ArrowLeft" || rawKey === "ArrowRight" || rawKey === "ArrowUp" || rawKey === "ArrowDown";
if (key !== "d" && key !== "x" && !arrowKey) return;
if (event.target && ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName)) return;
const canvas = app.canvas;
const graphMouse = canvas ? canvas.graph_mouse : null;
if (!canvas || !graphMouse) return;
if (arrowKey) {
const node = nodeFromGraphMouse(graphMouse);
const localPos = node ? [graphMouse[0] - node.pos[0], graphMouse[1] - node.pos[1]] : null;
if (node && isNodeSelected(node) && panPreviewWithKeyboard(node, event, localPos)) {
stopKeyboardShortcutEvent(event);
}
return;
}
for (const node of graphNodes(activeCanvasGraph())) {
if (!isTargetNode(node)) continue;
if (
graphMouse[0] < node.pos[0] ||
graphMouse[0] > node.pos[0] + node.size[0] ||
graphMouse[1] < node.pos[1] ||
graphMouse[1] > node.pos[1] + node.size[1]
) {
continue;
}
const index = findKeyframeAt(node, [graphMouse[0] - node.pos[0], graphMouse[1] - node.pos[1]]);
if (index >= 0) {
const handled = key === "d" ? duplicateKeyframeAtIndex(node, index) : deleteKeyframeAtIndex(node, index);
if (handled) {
stopKeyboardShortcutEvent(event);
return;
}
}
}
}, { capture: true, passive: false });
}