mirror of
https://github.com/litruv/picoGraph.git
synced 2026-07-24 02:36:04 +10:00
494 lines
13 KiB
JavaScript
494 lines
13 KiB
JavaScript
import { BlueprintWorkspace } from "./ui/BlueprintWorkspace.js";
|
|
import { NodeRegistry } from "./nodes/NodeRegistry.js";
|
|
import { LuaGenerator } from "./core/LuaGenerator.js";
|
|
import { EmbeddedPreview } from "./ui/EmbeddedPreview.js";
|
|
|
|
// Global error handlers
|
|
window.addEventListener('error', (event) => {
|
|
console.error('Global error:', event.error);
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|
console.error('Unhandled promise rejection:', event.reason);
|
|
});
|
|
|
|
/**
|
|
* @typedef {{ compileLua(source: string): Promise<{ filePath: string }> }} ElectronAPI
|
|
*/
|
|
|
|
/**
|
|
* @template T
|
|
* @param {T | null} element DOM element reference.
|
|
* @param {string} id Identifier used for error reporting.
|
|
* @returns {T}
|
|
*/
|
|
const requireElement = (element, id) => {
|
|
if (!element) {
|
|
throw new Error(`Missing required element: ${id}`);
|
|
}
|
|
return element;
|
|
};
|
|
|
|
const appBodyElement = /** @type {HTMLElement} */ (
|
|
requireElement(document.getElementById("appBody"), "appBody")
|
|
);
|
|
const workspaceElement = /** @type {HTMLElement} */ (
|
|
requireElement(document.getElementById("workspaceCanvas"), "workspaceCanvas")
|
|
);
|
|
const nodeLayer = /** @type {HTMLElement} */ (
|
|
requireElement(document.getElementById("nodeLayer"), "nodeLayer")
|
|
);
|
|
const connectionLayer = /** @type {SVGElement} */ (
|
|
requireElement(document.getElementById("connectionLayer"), "connectionLayer")
|
|
);
|
|
const eventList = /** @type {HTMLUListElement} */ (
|
|
requireElement(document.getElementById("eventList"), "eventList")
|
|
);
|
|
const variableList = /** @type {HTMLUListElement} */ (
|
|
requireElement(document.getElementById("variableList"), "variableList")
|
|
);
|
|
const addVariableButton = /** @type {HTMLButtonElement} */ (
|
|
requireElement(
|
|
document.getElementById("addVariableButton"),
|
|
"addVariableButton"
|
|
)
|
|
);
|
|
const paletteList = /** @type {HTMLElement} */ (
|
|
requireElement(document.getElementById("paletteList"), "paletteList")
|
|
);
|
|
const paletteSearch = /** @type {HTMLInputElement} */ (
|
|
requireElement(document.getElementById("paletteSearch"), "paletteSearch")
|
|
);
|
|
const paletteElement = /** @type {HTMLElement} */ (
|
|
requireElement(document.getElementById("palettePanel"), "palettePanel")
|
|
);
|
|
const inspectorContent = /** @type {HTMLElement} */ (
|
|
requireElement(
|
|
document.getElementById("inspectorContent"),
|
|
"inspectorContent"
|
|
)
|
|
);
|
|
const duplicateNodeButton = /** @type {HTMLButtonElement} */ (
|
|
requireElement(
|
|
document.getElementById("duplicateNodeButton"),
|
|
"duplicateNodeButton"
|
|
)
|
|
);
|
|
const deleteNodeButton = /** @type {HTMLButtonElement} */ (
|
|
requireElement(
|
|
document.getElementById("deleteNodeButton"),
|
|
"deleteNodeButton"
|
|
)
|
|
);
|
|
const compileButton = /** @type {HTMLButtonElement} */ (
|
|
requireElement(document.getElementById("compileButton"), "compileButton")
|
|
);
|
|
const previewButton = /** @type {HTMLButtonElement} */ (
|
|
requireElement(document.getElementById("previewButton"), "previewButton")
|
|
);
|
|
const exportLuaButton = /** @type {HTMLButtonElement} */ (
|
|
requireElement(document.getElementById("exportLuaButton"), "exportLuaButton")
|
|
);
|
|
const projectSettingsButton = /** @type {HTMLButtonElement} */ (
|
|
requireElement(
|
|
document.getElementById("projectSettingsButton"),
|
|
"projectSettingsButton"
|
|
)
|
|
);
|
|
const paletteToggleButton = /** @type {HTMLButtonElement} */ (
|
|
requireElement(
|
|
document.getElementById("paletteToggleButton"),
|
|
"paletteToggleButton"
|
|
)
|
|
);
|
|
const frameSelectionButton = /** @type {HTMLButtonElement} */ (
|
|
requireElement(
|
|
document.getElementById("frameSelectionButton"),
|
|
"frameSelectionButton"
|
|
)
|
|
);
|
|
const luaModal = /** @type {HTMLElement} */ (
|
|
requireElement(document.getElementById("luaModal"), "luaModal")
|
|
);
|
|
const luaModalCode = /** @type {HTMLElement} */ (
|
|
requireElement(document.getElementById("luaModalCode"), "luaModalCode")
|
|
);
|
|
const luaModalCodeContainer = /** @type {HTMLElement} */ (
|
|
requireElement(
|
|
luaModal.querySelector(".lua-modal__code"),
|
|
"luaModalCodeContainer"
|
|
)
|
|
);
|
|
const copyLuaButton = /** @type {HTMLButtonElement} */ (
|
|
requireElement(document.getElementById("copyLuaButton"), "copyLuaButton")
|
|
);
|
|
const closeLuaModalButton = /** @type {HTMLButtonElement} */ (
|
|
requireElement(
|
|
document.getElementById("closeLuaModalButton"),
|
|
"closeLuaModalButton"
|
|
)
|
|
);
|
|
|
|
const registry = new NodeRegistry();
|
|
const generator = new LuaGenerator(registry);
|
|
const workspace = new BlueprintWorkspace({
|
|
workspaceElement,
|
|
nodeLayer,
|
|
connectionLayer,
|
|
paletteList,
|
|
paletteSearch,
|
|
paletteElement,
|
|
inspectorContent,
|
|
duplicateNodeButton,
|
|
deleteNodeButton,
|
|
generator,
|
|
registry,
|
|
eventList,
|
|
variableList,
|
|
addVariableButton,
|
|
projectSettingsButton,
|
|
paletteToggleButton,
|
|
appBodyElement,
|
|
frameSelectionButton,
|
|
});
|
|
|
|
workspace.initialize();
|
|
|
|
// Initialize preview system
|
|
const embeddedPreview = new EmbeddedPreview({
|
|
getLuaCode: () => {
|
|
return workspace.exportLua();
|
|
},
|
|
});
|
|
|
|
/** @type {ElectronAPI | undefined} */
|
|
const electronAPI =
|
|
typeof window !== "undefined" && "electronAPI" in window
|
|
? /** @type {ElectronAPI} */ (window.electronAPI)
|
|
: undefined;
|
|
|
|
document.addEventListener("contextmenu", (event) => {
|
|
event.preventDefault();
|
|
});
|
|
|
|
const escapeHtml = (value) =>
|
|
value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
|
|
const luaTokenPattern =
|
|
/(--.*$)|("(?:\\.|[^"\\])*")|('(?!\[)(?:\\.|[^'\\])*')|(\b\d+(?:\.\d+)?\b)|\b(and|break|do|else|elseif|end|false|for|function|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b/gm;
|
|
|
|
const highlightLua = (source) => {
|
|
if (!source) {
|
|
return "";
|
|
}
|
|
|
|
let result = "";
|
|
let lastIndex = 0;
|
|
|
|
source.replace(
|
|
luaTokenPattern,
|
|
(
|
|
match,
|
|
comment,
|
|
doubleQuoted,
|
|
singleQuoted,
|
|
numberLiteral,
|
|
keyword,
|
|
offset
|
|
) => {
|
|
result += escapeHtml(source.slice(lastIndex, offset));
|
|
|
|
let tokenType = "";
|
|
if (comment) {
|
|
tokenType = "comment";
|
|
} else if (doubleQuoted || singleQuoted) {
|
|
tokenType = "string";
|
|
} else if (numberLiteral) {
|
|
tokenType = "number";
|
|
} else {
|
|
tokenType = "keyword";
|
|
}
|
|
|
|
result += `<span class="lua-token lua-token--${tokenType}">${escapeHtml(
|
|
match
|
|
)}</span>`;
|
|
lastIndex = offset + match.length;
|
|
return match;
|
|
}
|
|
);
|
|
|
|
result += escapeHtml(source.slice(lastIndex));
|
|
return result;
|
|
};
|
|
|
|
const focusableSelector =
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
let isLuaModalOpen = false;
|
|
let lastFocusedElement = /** @type {HTMLElement | null} */ (null);
|
|
let copyFeedbackTimeout = /** @type {number | null} */ (null);
|
|
let currentLuaSource = "";
|
|
let compileFeedbackTimeout = /** @type {number | null} */ (null);
|
|
|
|
const scheduleMicrotask = (callback) => {
|
|
if (typeof queueMicrotask === "function") {
|
|
queueMicrotask(callback);
|
|
return;
|
|
}
|
|
Promise.resolve().then(callback);
|
|
};
|
|
|
|
const getModalFocusableElements = () => {
|
|
return /** @type {Array<HTMLElement>} */ (
|
|
Array.from(luaModal.querySelectorAll(focusableSelector)).filter(
|
|
(element) =>
|
|
element instanceof HTMLElement && !element.hasAttribute("disabled")
|
|
)
|
|
);
|
|
};
|
|
|
|
const handleModalKeydown = (event) => {
|
|
if (!isLuaModalOpen) {
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeLuaModal();
|
|
return;
|
|
}
|
|
|
|
if (event.key !== "Tab") {
|
|
return;
|
|
}
|
|
|
|
const focusable = getModalFocusableElements();
|
|
if (!focusable.length) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
const currentIndex = focusable.indexOf(document.activeElement);
|
|
let nextIndex = currentIndex;
|
|
|
|
if (event.shiftKey) {
|
|
nextIndex = currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1;
|
|
} else {
|
|
nextIndex =
|
|
currentIndex === -1 || currentIndex === focusable.length - 1
|
|
? 0
|
|
: currentIndex + 1;
|
|
}
|
|
|
|
event.preventDefault();
|
|
focusable[nextIndex].focus();
|
|
};
|
|
|
|
const writeClipboard = async (text) => {
|
|
if (
|
|
navigator.clipboard &&
|
|
typeof navigator.clipboard.writeText === "function"
|
|
) {
|
|
await navigator.clipboard.writeText(text);
|
|
return;
|
|
}
|
|
|
|
const selection = document.getSelection();
|
|
const previousRange =
|
|
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
|
const fallback = document.createElement("textarea");
|
|
fallback.value = text;
|
|
fallback.setAttribute("readonly", "true");
|
|
fallback.style.position = "fixed";
|
|
fallback.style.opacity = "0";
|
|
document.body.appendChild(fallback);
|
|
fallback.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(fallback);
|
|
|
|
if (previousRange && selection) {
|
|
selection.removeAllRanges();
|
|
selection.addRange(previousRange);
|
|
}
|
|
};
|
|
|
|
const resetCopyFeedback = () => {
|
|
copyLuaButton.classList.remove("is-success");
|
|
copyLuaButton.setAttribute("aria-label", "Copy to clipboard");
|
|
copyLuaButton.setAttribute("title", "Copy to clipboard");
|
|
};
|
|
|
|
const openLuaModal = () => {
|
|
currentLuaSource = workspace.exportLua();
|
|
luaModalCode.innerHTML = highlightLua(currentLuaSource);
|
|
luaModalCodeContainer.scrollTop = 0;
|
|
if (copyFeedbackTimeout !== null) {
|
|
window.clearTimeout(copyFeedbackTimeout);
|
|
copyFeedbackTimeout = null;
|
|
}
|
|
resetCopyFeedback();
|
|
|
|
if (isLuaModalOpen) {
|
|
return;
|
|
}
|
|
|
|
lastFocusedElement =
|
|
document.activeElement instanceof HTMLElement
|
|
? document.activeElement
|
|
: null;
|
|
luaModal.removeAttribute("hidden");
|
|
luaModal.setAttribute("aria-hidden", "false");
|
|
document.body.classList.add("modal-open");
|
|
isLuaModalOpen = true;
|
|
document.addEventListener("keydown", handleModalKeydown);
|
|
|
|
scheduleMicrotask(() => {
|
|
const focusTarget = luaModalCodeContainer;
|
|
focusTarget.focus();
|
|
});
|
|
};
|
|
|
|
const closeLuaModal = () => {
|
|
if (!isLuaModalOpen) {
|
|
return;
|
|
}
|
|
|
|
isLuaModalOpen = false;
|
|
luaModal.setAttribute("aria-hidden", "true");
|
|
luaModal.setAttribute("hidden", "");
|
|
document.body.classList.remove("modal-open");
|
|
document.removeEventListener("keydown", handleModalKeydown);
|
|
|
|
if (copyFeedbackTimeout !== null) {
|
|
window.clearTimeout(copyFeedbackTimeout);
|
|
copyFeedbackTimeout = null;
|
|
}
|
|
resetCopyFeedback();
|
|
|
|
if (lastFocusedElement && document.contains(lastFocusedElement)) {
|
|
lastFocusedElement.focus();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Restores the compile button to its idle appearance.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
const resetCompileButton = () => {
|
|
if (compileFeedbackTimeout !== null) {
|
|
window.clearTimeout(compileFeedbackTimeout);
|
|
compileFeedbackTimeout = null;
|
|
}
|
|
|
|
compileButton.classList.remove("is-success", "is-error", "is-busy");
|
|
compileButton.disabled = false;
|
|
compileButton.textContent = "Compile";
|
|
};
|
|
|
|
/**
|
|
* Handles compile button clicks by requesting a Lua export from Electron.
|
|
*
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const handleCompileClick = async () => {
|
|
if (!electronAPI) {
|
|
return;
|
|
}
|
|
|
|
if (compileFeedbackTimeout !== null) {
|
|
window.clearTimeout(compileFeedbackTimeout);
|
|
compileFeedbackTimeout = null;
|
|
}
|
|
|
|
compileButton.classList.remove("is-success", "is-error");
|
|
compileButton.classList.add("is-busy");
|
|
compileButton.disabled = true;
|
|
compileButton.textContent = "Compiling...";
|
|
|
|
const source = workspace.exportLua();
|
|
|
|
try {
|
|
const result = await electronAPI.compileLua(source);
|
|
console.info("Lua compiled to", result.filePath);
|
|
compileButton.classList.remove("is-busy");
|
|
compileButton.classList.add("is-success");
|
|
compileButton.disabled = false;
|
|
compileButton.textContent = "Compiled!";
|
|
compileFeedbackTimeout = window.setTimeout(() => {
|
|
resetCompileButton();
|
|
}, 1800);
|
|
} catch (error) {
|
|
console.error("Failed to compile Lua", error);
|
|
compileButton.classList.remove("is-busy");
|
|
compileButton.classList.add("is-error");
|
|
compileButton.disabled = false;
|
|
compileButton.textContent = "Failed";
|
|
compileFeedbackTimeout = window.setTimeout(() => {
|
|
resetCompileButton();
|
|
}, 2200);
|
|
}
|
|
};
|
|
|
|
if (electronAPI && typeof electronAPI.compileLua === "function") {
|
|
compileButton.disabled = false;
|
|
compileButton.addEventListener("click", () => {
|
|
handleCompileClick().catch((error) => {
|
|
console.error("Unhandled compile error", error);
|
|
resetCompileButton();
|
|
});
|
|
});
|
|
} else {
|
|
compileButton.disabled = true;
|
|
compileButton.title = "Compile is available in the desktop app";
|
|
}
|
|
|
|
previewButton.addEventListener("click", () => {
|
|
embeddedPreview.open();
|
|
});
|
|
|
|
exportLuaButton.addEventListener("click", () => {
|
|
openLuaModal();
|
|
});
|
|
|
|
luaModal.addEventListener("click", (event) => {
|
|
const target =
|
|
event.target instanceof HTMLElement
|
|
? event.target.closest("[data-modal-close]")
|
|
: null;
|
|
if (target) {
|
|
event.preventDefault();
|
|
closeLuaModal();
|
|
}
|
|
});
|
|
|
|
copyLuaButton.addEventListener("click", async () => {
|
|
if (!currentLuaSource) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await writeClipboard(currentLuaSource);
|
|
copyLuaButton.classList.add("is-success");
|
|
copyLuaButton.setAttribute("aria-label", "Copied!");
|
|
copyLuaButton.setAttribute("title", "Copied!");
|
|
if (copyFeedbackTimeout !== null) {
|
|
window.clearTimeout(copyFeedbackTimeout);
|
|
}
|
|
copyFeedbackTimeout = window.setTimeout(() => {
|
|
resetCopyFeedback();
|
|
copyFeedbackTimeout = null;
|
|
}, 1500);
|
|
} catch (error) {
|
|
console.error("Copy failed", error);
|
|
}
|
|
});
|
|
|
|
closeLuaModalButton.addEventListener("click", () => {
|
|
closeLuaModal();
|
|
});
|