362 lines
12 KiB
JavaScript
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 });
|
|
}
|