// 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 }); }