604 lines
23 KiB
JavaScript
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 }
|
|
);
|
|
}
|