Files
2026-08-26 09:55:12 -06:00

604 lines
23 KiB
JavaScript

// Image upload, drag/drop, paste, and graph-position media targeting.
import { api } from "../../../../scripts/api.js";
import { app } from "../../../../scripts/app.js";
import { LOCAL_MEDIA_MANAGER_DROP_TYPE } from "./constants.js";
import {
activeCanvasGraph,
addTimelineWindowListener,
errorText,
graphNodes,
isTargetNode,
makeId,
setTimelineStatus,
} from "./runtime.js";
import {
activeLayerIndex,
ensureSelectedKeyframe,
isEmptyLayer,
mirrorBaseLayer,
normalizeFileInfo,
normalizeKeyframes,
normalizeLayer,
preloadImage,
rejectLayerEditIfLocked,
selectedKeyframeIndex,
setSelectedKeyframe,
syncTimelineWidget,
uploadMode,
viewUrl,
} from "./model.js";
import { bounds, formatSlotSeconds } from "./timeline_ops.js";
export async function uploadSelectedImage(node, file, keyframeId, options = {}) {
if (rejectLayerEditIfLocked(node, "Image upload")) return false;
const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const index =
keyframeId == null
? selectedKeyframeIndex(node, keyframes)
: keyframes.findIndex((keyframe) => keyframe.id === keyframeId);
if (index == null || index < 0 || index >= keyframes.length) return;
const body = new FormData();
body.append("image", file);
body.append("type", "input");
if (options.pasted) {
body.append("subfolder", "pasted");
} else {
body.append("overwrite", "true");
}
const response = await api.fetchApi("/upload/image", { method: "POST", body });
if (!response.ok) throw new Error(`Image upload failed: ${response.status}`);
const payload = await response.json();
const currentKeyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node);
const currentIndex =
keyframeId == null
? selectedKeyframeIndex(node, currentKeyframes)
: currentKeyframes.findIndex((keyframe) => keyframe.id === keyframeId);
if (currentIndex == null || currentIndex < 0 || currentIndex >= currentKeyframes.length) return;
const uploaded = normalizeFileInfo(payload) || {
filename: payload.name || file.name,
subfolder: payload.subfolder || "",
type: payload.type || "input",
};
const keyframe = currentKeyframes[currentIndex];
const layers = Array.isArray(keyframe.layers) ? keyframe.layers : [];
const currentMode = uploadMode(node);
const layerIndex = activeLayerIndex(keyframe);
const activeEmptyIndex = layerIndex >= 0 && isEmptyLayer(layers[layerIndex]) ? layerIndex : -1;
if (activeEmptyIndex >= 0) {
const currentLayer = layers[activeEmptyIndex];
const layer = normalizeLayer({ ...currentLayer, image: uploaded, empty: false, fitMode: "crop", panX: 0, panY: 0, zoom: 1 });
layers[activeEmptyIndex] = layer;
keyframe.layers = layers;
keyframe.selectedLayerId = layer.id;
} else if (currentMode === "layer" && layers.length) {
const layer = normalizeLayer({ id: makeId(), image: uploaded, fitMode: "crop", panX: 0, panY: 0, zoom: 1 });
layers.push(layer);
keyframe.layers = layers;
keyframe.selectedLayerId = layer.id;
} else if (currentMode === "layer") {
const base = normalizeLayer({ id: makeId(), image: uploaded, fitMode: "crop", panX: 0, panY: 0, zoom: 1 });
keyframe.layers = [base];
keyframe.selectedLayerId = base.id;
} else if (layers.length) {
const replaceIndex = layerIndex >= 0 ? layerIndex : 0;
const nextLayer = normalizeLayer({
...layers[replaceIndex],
image: uploaded,
fitMode: "crop",
panX: 0,
panY: 0,
zoom: 1,
});
layers[replaceIndex] = nextLayer;
keyframe.layers = layers;
keyframe.selectedLayerId = nextLayer.id;
} else {
const base = normalizeLayer({ id: makeId(), image: uploaded, fitMode: "crop", panX: 0, panY: 0, zoom: 1 });
keyframe.layers = [base];
keyframe.selectedLayerId = base.id;
}
mirrorBaseLayer(keyframe);
currentKeyframes[currentIndex] = keyframe;
node.properties.etk_ltxv_timeline_keyframes = currentKeyframes;
setSelectedKeyframe(node, currentIndex, currentKeyframes);
preloadImage(node, currentKeyframes[currentIndex]);
syncTimelineWidget(node);
const action = options.pasted ? "Pasted" : "Uploaded";
const target = currentMode === "layer" ? "layer" : activeEmptyIndex >= 0 ? "empty layer" : "active layer";
setTimelineStatus(node, `${action} ${uploaded.filename || uploaded.name || "image"} to ${target} at ${formatSlotSeconds(node, keyframe.slot)}.`, "success");
node.setDirtyCanvas(true, true);
}
export function nodeFromGraphMouse(graphMouse) {
if (!graphMouse) return null;
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]
) {
return node;
}
}
return null;
}
export function eventToGraphPosition(event) {
const canvas = app.canvas;
const canvasElement = canvas ? canvas.canvas || canvas.canvas_element : null;
if (!canvas) return null;
if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) {
return canvas.graph_mouse ? [canvas.graph_mouse[0], canvas.graph_mouse[1]] : null;
}
let offset = null;
if (typeof canvas.convertEventToCanvasOffset === "function") {
offset = canvas.convertEventToCanvasOffset(event);
}
if (!offset) {
const rect = canvasElement ? canvasElement.getBoundingClientRect() : { left: 0, top: 0 };
offset = [event.clientX - rect.left, event.clientY - rect.top];
}
if (!Array.isArray(offset)) {
const offsetX = offset[0] != null ? offset[0] : offset.x;
const offsetY = offset[1] != null ? offset[1] : offset.y;
offset = [offsetX, offsetY];
}
if (!Number.isFinite(offset[0]) || !Number.isFinite(offset[1])) return null;
if (canvas.ds && typeof canvas.ds.convertOffsetToCanvas === "function") {
return canvas.ds.convertOffsetToCanvas(offset);
}
if (typeof canvas.convertOffsetToCanvas === "function") {
return canvas.convertOffsetToCanvas(offset);
}
if (canvas.ds && canvas.ds.offset && canvas.ds.scale) {
return [(offset[0] - canvas.ds.offset[0]) / canvas.ds.scale, (offset[1] - canvas.ds.offset[1]) / canvas.ds.scale];
}
return offset;
}
export function previewTargetFromGraphPosition(graphPos) {
if (!graphPos) return null;
for (const node of graphNodes(activeCanvasGraph())) {
if (!isTargetNode(node)) continue;
const localPos = [graphPos[0] - node.pos[0], graphPos[1] - node.pos[1]];
if (isPreviewPosition(node, localPos) || isTimelineImageInputPosition(node, graphPos)) {
return { node, localPos };
}
}
return null;
}
export function isPreviewPosition(node, localPos) {
if (!node || !localPos) return false;
const { padX, width, previewY, previewH } = bounds(node);
const [x, y] = localPos;
return x >= padX && x <= padX + width && y >= previewY && y <= previewY + previewH;
}
export function isTimelineImageInput(input) {
if (!input) return false;
const name = String(input.name || "").toLowerCase();
const type = String(input.type || "").toUpperCase();
return type === "IMAGE" || name === "image" || name === "images";
}
export function graphInputSlotPosition(node, input, index) {
if (typeof node.getInputPos === "function") return node.getInputPos(index);
if (typeof node.getConnectionPos === "function") return node.getConnectionPos(true, index);
if (Array.isArray(input.pos)) return [node.pos[0] + input.pos[0], node.pos[1] + input.pos[1]];
return [node.pos[0] + 10, node.pos[1] + (index + 0.7) * 20];
}
export function isTimelineImageInputPosition(node, graphPos) {
if (!node || !graphPos || !node.inputs) return false;
if (typeof node.getSlotInPosition === "function") {
const slot = node.getSlotInPosition(graphPos[0], graphPos[1]);
if (slot && isTimelineImageInput(slot.input)) return true;
}
if (typeof node.getInputOnPos === "function") {
const input = node.getInputOnPos(graphPos);
if (isTimelineImageInput(input)) return true;
}
for (let i = 0; i < node.inputs.length; i++) {
const input = node.inputs[i];
if (!isTimelineImageInput(input)) continue;
const pos = graphInputSlotPosition(node, input, i);
if (Math.abs(graphPos[0] - pos[0]) <= 18 && Math.abs(graphPos[1] - pos[1]) <= 14) return true;
}
return false;
}
export function previewTargetFromEvent(event) {
const graphPos = eventToGraphPosition(event);
const target = previewTargetFromGraphPosition(graphPos);
if (target) window.etkTimelineLastPreviewTarget = target;
return target;
}
export function currentPreviewTargetFromPointer() {
const canvas = app.canvas;
const graphMouse = canvas ? canvas.graph_mouse : null;
return previewTargetFromGraphPosition(graphMouse);
}
export function isSupportedImageFile(file) {
if (!file) return false;
if (file.type && /^image\/(png|jpe?g|webp)$/i.test(file.type)) return true;
return /\.(png|jpe?g|webp)$/i.test(file.name || "");
}
export function dragUriTypes(dataTransfer) {
if (!dataTransfer || !dataTransfer.types) return [];
const types = Array.from(dataTransfer.types);
return types.filter(
(type) =>
type === "text/uri-list" ||
type === "text/x-moz-url" ||
type === "text/plain" ||
type === LOCAL_MEDIA_MANAGER_DROP_TYPE
);
}
export function localMediaManagerUri(raw) {
if (!raw) return "";
try {
const payload = JSON.parse(raw);
const path = payload && (payload.path || payload.filepath || payload.filePath);
if (!path) return payload && payload.url ? String(payload.url) : "";
const base = typeof location !== "undefined" && location.href ? location.href : window.location && window.location.href;
const url = new URL("/local_image_gallery/view", base);
url.searchParams.set("filepath", path);
return url.toString();
} catch {
return String(raw || "").trim();
}
}
export function dragUriFromPayload(dataTransfer) {
const types = dragUriTypes(dataTransfer);
if (types.includes(LOCAL_MEDIA_MANAGER_DROP_TYPE)) {
const raw = dataTransfer.getData ? dataTransfer.getData(LOCAL_MEDIA_MANAGER_DROP_TYPE) : "";
const uri = localMediaManagerUri(raw);
if (uri) return uri;
}
for (const type of types) {
if (type === LOCAL_MEDIA_MANAGER_DROP_TYPE) continue;
const raw = dataTransfer.getData ? dataTransfer.getData(type) : "";
const uri = String(raw || "").split("\n").find((line) => line.trim() && !line.trim().startsWith("#"));
if (uri) return uri.trim();
}
return "";
}
export function normalizeDropUrl(url) {
if (!url) return null;
if (/\/local_image_gallery\/thumbnail$/i.test(url.pathname) && url.searchParams.has("filepath")) {
const viewUrl = new URL("/local_image_gallery/view", url);
viewUrl.searchParams.set("filepath", url.searchParams.get("filepath"));
return viewUrl;
}
return url;
}
export function isSupportedDropUrl(url) {
if (!url) return false;
if (/\/local_image_gallery\/view$/i.test(url.pathname)) return url.searchParams.has("filepath");
if (/\/view$/i.test(url.pathname)) return url.searchParams.has("filepath") || url.searchParams.has("filename");
if (url.searchParams.has("filename")) return true;
return /\.(png|jpe?g|webp)$/i.test(url.pathname);
}
export function sameOriginDropUrl(dataTransfer) {
const uri = dragUriFromPayload(dataTransfer);
if (!uri) return null;
try {
const base = typeof location !== "undefined" && location.href ? location.href : window.location && window.location.href;
const url = new URL(uri, base);
const origin = typeof location !== "undefined" && location.origin ? location.origin : window.location && window.location.origin;
if (origin && url.origin !== origin) return null;
const normalizedUrl = normalizeDropUrl(url);
if (!isSupportedDropUrl(normalizedUrl)) return null;
return normalizedUrl;
} catch {
return null;
}
}
export function fileNameFromDropUrl(url) {
const filepath = url.searchParams.get("filepath") || url.searchParams.get("filename") || url.pathname;
const cleanPath = String(filepath || "image.png").split(/[\\/]/).filter(Boolean).pop() || "image.png";
return /\.(png|jpe?g|webp)$/i.test(cleanPath) ? cleanPath : `${cleanPath}.png`;
}
export async function imageFileFromDropUrl(url) {
if (!url || typeof fetch !== "function") return null;
try {
const response = await fetch(url.toString());
if (!response || !response.ok) return null;
const blob = await response.blob();
const file = new File([blob], fileNameFromDropUrl(url), { type: blob.type || "image/png" });
return isSupportedImageFile(file) ? file : null;
} catch {
return null;
}
}
export function hasImageFilePayload(dataTransfer) {
if (!dataTransfer) return false;
if (dataTransfer.items && dataTransfer.items.length) {
const items = Array.from(dataTransfer.items);
if (items.some(
(item) =>
(item.kind === "file" && (!item.type || /^image\/(png|jpe?g|webp)$/i.test(item.type))) ||
(item.kind === "string" &&
(item.type === "text/uri-list" ||
item.type === "text/x-moz-url" ||
item.type === "text/plain" ||
item.type === LOCAL_MEDIA_MANAGER_DROP_TYPE))
)) {
return true;
}
}
if (dataTransfer.files && dataTransfer.files.length) return Array.from(dataTransfer.files).some(isSupportedImageFile);
if (dataTransfer.types && Array.from(dataTransfer.types).includes("Files")) return true;
return dragUriTypes(dataTransfer).length > 0;
}
export function imageFileFromPayload(payload) {
if (!payload) return null;
if (payload.files && payload.files.length) {
const file = Array.from(payload.files).find(isSupportedImageFile);
if (file) return file;
}
if (payload.items && payload.items.length) {
for (const item of Array.from(payload.items)) {
if (item.kind !== "file") continue;
const file = item.getAsFile ? item.getAsFile() : null;
if (isSupportedImageFile(file)) return file;
}
}
return null;
}
export function shouldPreferDropUrl(payload, dropUrl) {
if (!dropUrl) return false;
const types = payload && payload.types ? Array.from(payload.types) : [];
if (types.includes(LOCAL_MEDIA_MANAGER_DROP_TYPE)) return true;
return /\/local_image_gallery\/view$/i.test(dropUrl.pathname) && dropUrl.searchParams.has("filepath");
}
export async function imageFileFromDropPayload(payload, dropUrl = null) {
const resolvedDropUrl = dropUrl || sameOriginDropUrl(payload);
if (shouldPreferDropUrl(payload, resolvedDropUrl)) {
return (await imageFileFromDropUrl(resolvedDropUrl)) || imageFileFromPayload(payload);
}
return imageFileFromPayload(payload) || imageFileFromDropUrl(resolvedDropUrl);
}
export function setDropHoverNode(node) {
if (window.etkTimelineDropHoverNode === node) return;
if (window.etkTimelineDropHoverNode) {
window.etkTimelineDropHoverNode.etkTimelineDropHover = false;
window.etkTimelineDropHoverNode.setDirtyCanvas(true, true);
}
window.etkTimelineDropHoverNode = node || null;
if (node) {
node.etkTimelineDropHover = true;
node.setDirtyCanvas(true, true);
}
}
export function clearDropHoverNode() {
setDropHoverNode(null);
}
export function lastPreviewTarget() {
const target = window.etkTimelineLastPreviewTarget;
if (!target || !target.node || !target.node.graph) return null;
return target;
}
export function isEditableClipboardTarget(target) {
if (!target) return false;
const tagName = String(target.tagName || "").toUpperCase();
if (target.isContentEditable) return true;
if (tagName === "TEXTAREA" || tagName === "SELECT") return true;
if (tagName !== "INPUT") return false;
const inputType = String(target.type || "text").toLowerCase();
return !["button", "checkbox", "file", "hidden", "image", "radio", "range", "reset", "search", "submit"].includes(inputType);
}
export function hasSelectedDocumentText() {
if (typeof window.getSelection !== "function") return false;
const selection = window.getSelection();
return !!selection && String(selection.toString ? selection.toString() : "").trim().length > 0;
}
export function shouldIgnoreClipboardEvent(event) {
if (!event || event.defaultPrevented) return true;
if (event.shiftKey) return true;
return isEditableClipboardTarget(event.target) || hasSelectedDocumentText();
}
export function stopDropOrPasteEvent(event) {
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
}
export function stopKeyboardShortcutEvent(event) {
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
}
export async function uploadImageToNode(node, file, options = {}) {
if (!node || !file) return false;
if (rejectLayerEditIfLocked(node, "Image upload")) return false;
const keyframe = ensureSelectedKeyframe(node);
if (!keyframe) {
setTimelineStatus(node, "No free keyframe positions are available for this image.", "error");
return false;
}
await uploadSelectedImage(node, file, keyframe.id, options);
return true;
}
export async function uploadImageToPreviewTarget(target, file, options = {}) {
if (!target || !file) return false;
return uploadImageToNode(target.node, file, options);
}
export function installTimelinePasteFiles(node) {
if (node.etkTimelinePasteFilesInstalled) return;
const previousPasteFiles = typeof node.pasteFiles === "function" ? node.pasteFiles : null;
const installedPasteFiles = function (files) {
const file = Array.from(files || []).find(isSupportedImageFile);
if (!file) return previousPasteFiles ? previousPasteFiles.call(this, files) : false;
void uploadImageToNode(this || node, file, { pasted: true }).catch((error) => {
setTimelineStatus(this || node, `Pasted image upload failed: ${errorText(error)}`, "error");
console.error("[ETK timeline] Pasted image upload failed", error);
});
return true;
};
node.pasteFiles = installedPasteFiles;
node.etkTimelinePasteFilesInstalled = true;
const previousOnRemoved = node.onRemoved;
node.onRemoved = function () {
if (this.pasteFiles === installedPasteFiles) this.pasteFiles = previousPasteFiles || undefined;
if (previousOnRemoved) return previousOnRemoved.apply(this, arguments);
};
}
export function installTimelineDragDropHooks(node) {
if (node.etkTimelineDragDropInstalled) return;
const previousOnDragOver = typeof node.onDragOver === "function" ? node.onDragOver : null;
const previousOnDragDrop = typeof node.onDragDrop === "function" ? node.onDragDrop : null;
const installedOnDragOver = function (event) {
if (hasImageFilePayload(event && event.dataTransfer)) {
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
return true;
}
return previousOnDragOver ? previousOnDragOver.apply(this, arguments) : false;
};
const installedOnDragDrop = async function (event, claimEvent = true) {
const payload = event && event.dataTransfer;
const file = imageFileFromPayload(payload);
const dropUrl = sameOriginDropUrl(payload);
if (!file && !dropUrl) {
return previousOnDragDrop ? previousOnDragDrop.apply(this, arguments) : false;
}
if (claimEvent) stopDropOrPasteEvent(event);
try {
const resolvedFile = await imageFileFromDropPayload(payload, dropUrl);
if (!resolvedFile) return false;
await uploadImageToNode(this || node, resolvedFile);
return true;
} catch (error) {
setTimelineStatus(this || node, `Dropped image upload failed: ${errorText(error)}`, "error");
console.error("[ETK timeline] Dropped image upload failed", error);
return false;
}
};
node.onDragOver = installedOnDragOver;
node.onDragDrop = installedOnDragDrop;
node.etkTimelineDragDropInstalled = true;
const previousOnRemoved = node.onRemoved;
node.onRemoved = function () {
if (this.onDragOver === installedOnDragOver) this.onDragOver = previousOnDragOver || undefined;
if (this.onDragDrop === installedOnDragDrop) this.onDragDrop = previousOnDragDrop || undefined;
if (previousOnRemoved) return previousOnRemoved.apply(this, arguments);
};
}
export function installFileDropCapture() {
addTimelineWindowListener(
"fileDrop.mousemove",
"mousemove",
(event) => {
const target = previewTargetFromEvent(event);
if (target) window.etkTimelineLastPreviewTarget = target;
},
{ capture: true, passive: true }
);
addTimelineWindowListener(
"fileDrop.dragover",
"dragover",
(event) => {
const target = hasImageFilePayload(event.dataTransfer) ? previewTargetFromEvent(event) : null;
if (!target) {
clearDropHoverNode();
return;
}
setDropHoverNode(target.node);
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
stopDropOrPasteEvent(event);
},
{ capture: true, passive: false }
);
addTimelineWindowListener(
"fileDrop.drop",
"drop",
async (event) => {
const target = previewTargetFromEvent(event);
const file = target ? imageFileFromPayload(event.dataTransfer) : null;
const dropUrl = target ? sameOriginDropUrl(event.dataTransfer) : null;
clearDropHoverNode();
if (!target || (!file && !dropUrl)) return;
stopDropOrPasteEvent(event);
try {
const resolvedFile = await imageFileFromDropPayload(event.dataTransfer, dropUrl);
if (!resolvedFile) return;
await uploadImageToPreviewTarget(target, resolvedFile);
} catch (error) {
setTimelineStatus(target.node, `Dropped image upload failed: ${errorText(error)}`, "error");
console.error("[ETK timeline] Dropped image upload failed", error);
}
},
{ capture: true, passive: false }
);
addTimelineWindowListener("fileDrop.dragend", "dragend", clearDropHoverNode, true);
addTimelineWindowListener(
"fileDrop.dragleave",
"dragleave",
(event) => {
if (event.clientX <= 0 || event.clientY <= 0 || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight) {
clearDropHoverNode();
}
},
true
);
}
export function installClipboardPasteCapture() {
addTimelineWindowListener(
"clipboardPaste.paste",
"paste",
async (event) => {
if (shouldIgnoreClipboardEvent(event)) return;
const target = currentPreviewTargetFromPointer();
const file = target ? imageFileFromPayload(event.clipboardData) : null;
const dropUrl = target ? sameOriginDropUrl(event.clipboardData) : null;
if (!target || (!file && !dropUrl)) return;
stopDropOrPasteEvent(event);
try {
const resolvedFile = await imageFileFromDropPayload(event.clipboardData, dropUrl);
if (!resolvedFile) return;
await uploadImageToPreviewTarget(target, resolvedFile, { pasted: true });
} catch (error) {
setTimelineStatus(target.node, `Pasted image upload failed: ${errorText(error)}`, "error");
console.error("[ETK timeline] Pasted image upload failed", error);
}
},
{ capture: true, passive: false }
);
}