- Rename all user-facing and technical identifiers from OpenPets/Pet to FamiliarOS/Familiar. - Rename packages from @open-pets/* to @familiaros/*; rename install-pet/pet-format packages. - Rename plugin IDs and directories from openpets.* to familiaros.*. - Rename IPC namespace from openpets:* to familiaros:* and state filenames from openpets-* to familiaros-* with legacy migration. - Rename source files (pet-window, built-in-pet, default-pet-controller, etc.) to familiar equivalents. - Update locales (en, es-419, ja, ko, pt-BR, zh-Hans, zh-Hant) and tray/pet context menu strings. - Add Familiar naming feature: preference, settings input, tray menu display. - Update assets and packaging config; all desktop tests pass.
560 lines
22 KiB
JavaScript
560 lines
22 KiB
JavaScript
const { ipcRenderer } = require("electron");
|
|
|
|
const allowedMotionStates = new Set(["idle", "run-left", "run-right"]);
|
|
const allowedReactionStates = new Set(["idle", "running-right", "running-left", "waving", "jumping", "failed", "waiting", "running", "review"]);
|
|
let lastInteractiveHit = null;
|
|
let dragging = false;
|
|
let scaling = false;
|
|
let scaleStartY = 0;
|
|
let scaleStartValue = 0.56;
|
|
let scalePreviewTimer = null;
|
|
const dragThreshold = 4;
|
|
|
|
const dismissBubble = (event) => {
|
|
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
|
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) return;
|
|
|
|
const bubble = target.closest(".bubble");
|
|
if (!bubble) return;
|
|
|
|
const dismissToken = bubble.dataset.dismissToken;
|
|
if (!dismissToken) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
bubble.remove();
|
|
|
|
const newTarget = document.elementFromPoint(event.clientX, event.clientY);
|
|
const stillInteractive = Boolean(newTarget && newTarget.closest(".familiar-hitbox, .familiar-shell, .bubble")) || dragging || scaling;
|
|
reportInteractiveHit(stillInteractive, "bubble-dismiss", true);
|
|
|
|
ipcRenderer.send("familiaros:bubble-dismissed", dismissToken);
|
|
};
|
|
|
|
const requestPromptWindow = (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) return;
|
|
if (target.closest(".bubble")) return;
|
|
if (!target.closest(".familiar-hitbox, .familiar-shell")) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
ipcRenderer.send("familiaros:familiar-open-prompt");
|
|
};
|
|
|
|
ipcRenderer.on("familiaros:familiar-motion", (_event, state) => {
|
|
if (!allowedMotionStates.has(state)) {
|
|
return;
|
|
}
|
|
|
|
const apply = () => {
|
|
document.documentElement.dataset.motionState = state;
|
|
};
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", apply, { once: true });
|
|
} else {
|
|
apply();
|
|
}
|
|
});
|
|
|
|
ipcRenderer.on("familiaros:familiar-reaction-state", (_event, state) => {
|
|
if (!allowedReactionStates.has(state)) {
|
|
return;
|
|
}
|
|
|
|
const apply = () => {
|
|
document.documentElement.dataset.reactionState = state;
|
|
};
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", apply, { once: true });
|
|
} else {
|
|
apply();
|
|
}
|
|
});
|
|
|
|
ipcRenderer.on("familiaros:familiar-content-state", (_event, state) => {
|
|
if (!state || typeof state.bodyHtml !== "string" || state.bodyHtml.length > 64 * 1024 || !allowedReactionStates.has(state.reactionState)) {
|
|
return;
|
|
}
|
|
|
|
const apply = () => {
|
|
document.documentElement.dataset.reactionState = state.reactionState;
|
|
document.body.innerHTML = state.bodyHtml;
|
|
};
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", apply, { once: true });
|
|
} else {
|
|
apply();
|
|
}
|
|
});
|
|
|
|
const getInteractiveTarget = (event) => {
|
|
const target = document.elementFromPoint(event.clientX, event.clientY);
|
|
return target && target.closest(".familiar-hitbox, .familiar-shell, .bubble");
|
|
};
|
|
|
|
const reportInteractiveHit = (interactive, source, force = false) => {
|
|
if (!force && lastInteractiveHit === interactive) return;
|
|
lastInteractiveHit = interactive;
|
|
ipcRenderer.send("familiaros:familiar-hit-test", interactive, source);
|
|
};
|
|
|
|
const setInteractiveHit = (interactive, source = "mouse") => {
|
|
if (lastInteractiveHit === interactive) return;
|
|
reportInteractiveHit(interactive, source);
|
|
};
|
|
|
|
const updateInteractiveHit = (event) => {
|
|
setInteractiveHit(Boolean(getInteractiveTarget(event)) || dragging || scaling);
|
|
};
|
|
|
|
const getCurrentSpriteScale = () => {
|
|
const sprite = document.querySelector(".sprite, .installed-sprite");
|
|
if (!sprite) return 0.56;
|
|
const style = sprite.getAttribute("style") || "";
|
|
const match = style.match(/transform:\s*scale\(([\d.]+)\)/);
|
|
return match ? parseFloat(match[1]) : 0.56;
|
|
};
|
|
|
|
const applyScalePreview = (scale) => {
|
|
const clamped = Math.min(Math.max(scale, 0.16), 10);
|
|
const sprite = document.querySelector(".sprite, .installed-sprite");
|
|
if (sprite) sprite.style.transform = `scale(${clamped})`;
|
|
return clamped;
|
|
};
|
|
|
|
const sendScalePreview = (scale) => {
|
|
ipcRenderer.send("familiaros:familiar-scale-preview", { scale });
|
|
};
|
|
|
|
ipcRenderer.on("familiaros:familiar-probe-hit-test", (_event, point) => {
|
|
if (!point || typeof point.clientX !== "number" || typeof point.clientY !== "number" || !Number.isFinite(point.clientX) || !Number.isFinite(point.clientY)) return;
|
|
const clientX = point.clientX;
|
|
const clientY = point.clientY;
|
|
const target = document.elementFromPoint(clientX, clientY);
|
|
reportInteractiveHit(Boolean(target && target.closest(".familiar-hitbox, .familiar-shell, .bubble")) || dragging || scaling, typeof point.reason === "string" ? point.reason.slice(0, 80) : "probe", true);
|
|
});
|
|
|
|
// --- Plugin bubble interactions (actions, inline inputs) -------------------
|
|
|
|
const collectBubbleInputValues = (bubble) => {
|
|
const values = {};
|
|
for (const control of bubble.querySelectorAll(".bubble-input-control")) {
|
|
const id = control.dataset.inputId;
|
|
if (!id) continue;
|
|
values[id] = control.type === "number" ? Number(control.value) : String(control.value);
|
|
}
|
|
return values;
|
|
};
|
|
|
|
const handleBubbleInteraction = (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) return false;
|
|
const actionButton = target.closest("[data-bubble-action]");
|
|
if (actionButton) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
ipcRenderer.send("familiaros:bubble-action", actionButton.dataset.bubbleToken, actionButton.dataset.bubbleAction);
|
|
return true;
|
|
}
|
|
const submitButton = target.closest("[data-bubble-submit]");
|
|
if (submitButton) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const bubble = submitButton.closest(".bubble");
|
|
ipcRenderer.send("familiaros:bubble-submit", submitButton.dataset.bubbleSubmit, bubble ? collectBubbleInputValues(bubble) : {});
|
|
return true;
|
|
}
|
|
if (target.closest(".bubble-input-control")) return true;
|
|
return false;
|
|
};
|
|
|
|
// --- Familiar senses: clicks, hover, drops ---------------------------------------
|
|
|
|
let lastHoverSentAt = 0;
|
|
let suppressClickUntil = 0;
|
|
|
|
const sendPetEvent = (name, payload) => {
|
|
ipcRenderer.send("familiaros:familiar-event", name, payload || {});
|
|
};
|
|
|
|
const installPetSenses = () => {
|
|
let clickCount = 0;
|
|
let clickTimer = null;
|
|
let firstClickEvent = null;
|
|
|
|
const resetClickTimer = () => {
|
|
if (clickTimer) {
|
|
clearTimeout(clickTimer);
|
|
clickTimer = null;
|
|
}
|
|
};
|
|
|
|
const flushClicks = () => {
|
|
resetClickTimer();
|
|
if (clickCount === 1) {
|
|
sendPetEvent("familiar:clicked", {});
|
|
} else if (clickCount === 2 && firstClickEvent) {
|
|
sendPetEvent("familiar:doubleClicked", {});
|
|
requestPromptWindow(firstClickEvent);
|
|
}
|
|
clickCount = 0;
|
|
firstClickEvent = null;
|
|
};
|
|
|
|
document.addEventListener("click", (event) => {
|
|
if (event.button !== 0) return;
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) return;
|
|
if (!target.closest(".familiar-hitbox, .familiar-shell")) return;
|
|
if (Date.now() < suppressClickUntil) return;
|
|
|
|
clickCount += 1;
|
|
if (clickCount === 1) {
|
|
firstClickEvent = event;
|
|
}
|
|
resetClickTimer();
|
|
|
|
clickTimer = setTimeout(flushClicks, 200);
|
|
});
|
|
|
|
document.addEventListener("mouseover", (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Element) || !target.closest(".familiar-hitbox, .familiar-shell")) return;
|
|
const now = Date.now();
|
|
if (now - lastHoverSentAt < 2000) return;
|
|
lastHoverSentAt = now;
|
|
sendPetEvent("familiar:hover", {});
|
|
}, { passive: true });
|
|
|
|
const maxDropTextBytes = 256 * 1024;
|
|
const maxDropFileBytes = 5 * 1024 * 1024;
|
|
document.addEventListener("dragover", (event) => {
|
|
const target = event.target;
|
|
if (target instanceof Element && target.closest(".familiar-hitbox, .familiar-shell")) event.preventDefault();
|
|
});
|
|
document.addEventListener("drop", (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Element) || !target.closest(".familiar-hitbox, .familiar-shell")) return;
|
|
event.preventDefault();
|
|
const transfer = event.dataTransfer;
|
|
if (!transfer) return;
|
|
const files = [...(transfer.files || [])].slice(0, 4);
|
|
if (files.length > 0) {
|
|
Promise.all(files.map(async (file) => ({
|
|
name: String(file.name).slice(0, 200),
|
|
sizeBytes: file.size,
|
|
text: file.size <= maxDropFileBytes ? await file.text().catch(() => "") : "",
|
|
truncated: file.size > maxDropFileBytes,
|
|
}))).then((read) => sendPetEvent("familiar:drop", { kind: "files", droppedFiles: read })).catch(() => undefined);
|
|
return;
|
|
}
|
|
const text = String(transfer.getData("text/plain") || "").slice(0, maxDropTextBytes);
|
|
if (text) sendPetEvent("familiar:drop", { kind: "text", text });
|
|
});
|
|
};
|
|
|
|
// --- Plugin sprite/scale overrides ------------------------------------------
|
|
|
|
let spriteOverrideElement = null;
|
|
ipcRenderer.on("familiaros:familiar-sprite-override", (_event, override) => {
|
|
const shell = document.querySelector(".familiar-shell");
|
|
if (!shell) return;
|
|
const base = shell.querySelector(".sprite, .installed-card");
|
|
if (spriteOverrideElement) { spriteOverrideElement.remove(); spriteOverrideElement = null; }
|
|
if (!override || typeof override.fileUrl !== "string" || !override.fileUrl.startsWith("file://")) {
|
|
if (base) base.style.visibility = "";
|
|
return;
|
|
}
|
|
const probe = new Image();
|
|
probe.onload = () => {
|
|
const frame = probe.naturalHeight;
|
|
const frames = Math.max(1, Math.floor(probe.naturalWidth / Math.max(1, frame)));
|
|
const fps = Math.min(30, Math.max(1, Number(override.fps) || 8));
|
|
const el = document.createElement("div");
|
|
el.className = "plugin-sprite-override";
|
|
el.style.cssText = `position:absolute;left:50%;bottom:0;transform:translateX(-50%);width:${frame}px;height:${frame}px;background-image:url("${override.fileUrl.replace(/"/g, "%22")}");background-repeat:no-repeat;background-size:${probe.naturalWidth}px ${frame}px;animation:plugin-sprite-frames ${(frames / fps).toFixed(3)}s steps(${frames}) ${override.loop === false ? "1" : "infinite"};pointer-events:none;`;
|
|
let style = document.getElementById("plugin-sprite-override-style");
|
|
if (!style) {
|
|
style = document.createElement("style");
|
|
style.id = "plugin-sprite-override-style";
|
|
document.head.appendChild(style);
|
|
}
|
|
style.textContent = `@keyframes plugin-sprite-frames { from { background-position: 0 0; } to { background-position: -${frames * frame}px 0; } }`;
|
|
if (base) base.style.visibility = "hidden";
|
|
shell.appendChild(el);
|
|
spriteOverrideElement = el;
|
|
};
|
|
probe.src = override.fileUrl;
|
|
});
|
|
|
|
ipcRenderer.on("familiaros:familiar-scale-override", (_event, scale) => {
|
|
const value = Number(scale);
|
|
if (!Number.isFinite(value) || value < 0.25 || value > 3) return;
|
|
const sprite = document.querySelector(".sprite, .installed-sprite");
|
|
if (sprite) sprite.style.transform = `scale(${value})`;
|
|
});
|
|
|
|
ipcRenderer.on("familiaros:familiar-bubble-layout", (_event, layout) => {
|
|
if (!layout || typeof layout !== "object") return;
|
|
const root = document.documentElement;
|
|
if (typeof layout.bubbleMaxHeight === "number") root.style.setProperty("--bubble-max-height", `${layout.bubbleMaxHeight}px`);
|
|
if (typeof layout.bubbleMaxHeightLong === "number") root.style.setProperty("--bubble-max-height-long", `${layout.bubbleMaxHeightLong}px`);
|
|
if (typeof layout.bubbleMaxHeightVeryLong === "number") root.style.setProperty("--bubble-max-height-very-long", `${layout.bubbleMaxHeightVeryLong}px`);
|
|
document.querySelectorAll(".bubble").forEach((bubble) => {
|
|
bubble.classList.toggle("is-below", layout.bubbleBelow === true);
|
|
});
|
|
});
|
|
|
|
// --- Plugin audio (named WebAudio recipes + bundled data URLs) ---------------
|
|
|
|
let audioContext = null;
|
|
let activeAudioNodes = [];
|
|
let activeAudioElements = [];
|
|
|
|
const audioLog = (level, message, fields) => {
|
|
try {
|
|
const safeFields = fields && Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
|
|
const line = `[familiaros:familiar-audio] ${message}`;
|
|
if (level === "warn") console.warn(line, safeFields || {});
|
|
else console.debug(line, safeFields || {});
|
|
} catch { /* diagnostics must never affect playback */ }
|
|
};
|
|
|
|
const getAudioContext = () => {
|
|
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
return audioContext;
|
|
};
|
|
|
|
const namedSoundRecipes = {
|
|
chime: [{ freq: 880, type: "sine", start: 0, duration: 0.35 }, { freq: 1318.5, type: "sine", start: 0.12, duration: 0.4 }],
|
|
pop: [{ freq: 420, type: "square", start: 0, duration: 0.08 }],
|
|
nom: [{ freq: 220, type: "triangle", start: 0, duration: 0.1 }, { freq: 180, type: "triangle", start: 0.12, duration: 0.1 }],
|
|
alert: [{ freq: 660, type: "sawtooth", start: 0, duration: 0.18 }, { freq: 660, type: "sawtooth", start: 0.26, duration: 0.18 }],
|
|
"level-up": [{ freq: 523.25, type: "sine", start: 0, duration: 0.12 }, { freq: 659.25, type: "sine", start: 0.12, duration: 0.12 }, { freq: 783.99, type: "sine", start: 0.24, duration: 0.22 }],
|
|
tick: [{ freq: 1000, type: "square", start: 0, duration: 0.03 }],
|
|
success: [{ freq: 587.33, type: "sine", start: 0, duration: 0.14 }, { freq: 880, type: "sine", start: 0.14, duration: 0.24 }],
|
|
error: [{ freq: 311.13, type: "sine", start: 0, duration: 0.18 }, { freq: 233.08, type: "sine", start: 0.2, duration: 0.28 }],
|
|
};
|
|
|
|
ipcRenderer.on("familiaros:play-audio", (_event, payload) => {
|
|
try {
|
|
if (!payload) return;
|
|
const volume = Math.min(1, Math.max(0, Number(payload.volume) || 0.6));
|
|
audioLog("debug", "play requested", { kind: payload.kind, volume });
|
|
if (payload.kind === "named") {
|
|
const recipe = namedSoundRecipes[payload.name];
|
|
if (!recipe) { audioLog("warn", "named sound skipped", { name: payload.name, reason: "unknown-sound" }); return; }
|
|
const ctxAudio = getAudioContext();
|
|
if (ctxAudio.state === "suspended") void ctxAudio.resume().catch((error) => audioLog("warn", "audio context resume failed", { reason: error && error.message ? error.message : String(error) }));
|
|
const now = ctxAudio.currentTime;
|
|
for (const note of recipe) {
|
|
const osc = ctxAudio.createOscillator();
|
|
const gain = ctxAudio.createGain();
|
|
osc.type = note.type;
|
|
osc.frequency.value = note.freq;
|
|
gain.gain.setValueAtTime(0, now + note.start);
|
|
gain.gain.linearRampToValueAtTime(volume * 0.35, now + note.start + 0.015);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, now + note.start + note.duration);
|
|
osc.connect(gain).connect(ctxAudio.destination);
|
|
osc.start(now + note.start);
|
|
osc.stop(now + note.start + note.duration + 0.05);
|
|
activeAudioNodes.push(osc);
|
|
}
|
|
audioLog("debug", "named sound scheduled", { name: payload.name, notes: recipe.length, contextState: ctxAudio.state });
|
|
return;
|
|
}
|
|
if (payload.kind === "data" && typeof payload.dataUrl === "string" && payload.dataUrl.startsWith("data:audio/")) {
|
|
const element = new Audio(payload.dataUrl);
|
|
element.volume = volume;
|
|
activeAudioElements.push(element);
|
|
element.addEventListener("ended", () => { activeAudioElements = activeAudioElements.filter((entry) => entry !== element); audioLog("debug", "data sound ended", { remaining: activeAudioElements.length }); });
|
|
element.addEventListener("error", () => audioLog("warn", "data sound element error", { code: element.error ? element.error.code : undefined, message: element.error ? element.error.message : undefined }));
|
|
void element.play().then(() => audioLog("debug", "data sound playback started", { volume })).catch((error) => {
|
|
activeAudioElements = activeAudioElements.filter((entry) => entry !== element);
|
|
audioLog("warn", "data sound playback failed", { reason: error && error.message ? error.message : String(error), name: error && error.name ? error.name : undefined });
|
|
});
|
|
} else {
|
|
audioLog("warn", "play request ignored", { kind: payload.kind, reason: "invalid-payload" });
|
|
}
|
|
} catch (error) { audioLog("warn", "play request threw", { reason: error && error.message ? error.message : String(error) }); }
|
|
});
|
|
|
|
ipcRenderer.on("familiaros:stop-audio", () => {
|
|
audioLog("debug", "stop requested", { nodes: activeAudioNodes.length, elements: activeAudioElements.length });
|
|
for (const node of activeAudioNodes) { try { node.stop(); } catch { /* already stopped */ } }
|
|
activeAudioNodes = [];
|
|
for (const element of activeAudioElements) { try { element.pause(); } catch { /* noop */ } }
|
|
activeAudioElements = [];
|
|
});
|
|
|
|
// --- Plugin TTS ---------------------------------------------------------------
|
|
|
|
let currentTtsAudio = null;
|
|
|
|
ipcRenderer.on("familiaros:tts-speak", (_event, payload) => {
|
|
try {
|
|
stopCurrentTtsAudio();
|
|
if (!payload || typeof payload.text !== "string" || !window.speechSynthesis) return;
|
|
const utterance = new SpeechSynthesisUtterance(payload.text.slice(0, 500));
|
|
if (typeof payload.rate === "number" && payload.rate >= 0.5 && payload.rate <= 2) utterance.rate = payload.rate;
|
|
if (typeof payload.voice === "string" && payload.voice) {
|
|
const match = window.speechSynthesis.getVoices().find((voice) => voice.name === payload.voice || voice.lang === payload.voice);
|
|
if (match) utterance.voice = match;
|
|
}
|
|
window.speechSynthesis.speak(utterance);
|
|
} catch { /* tts is best-effort */ }
|
|
});
|
|
|
|
ipcRenderer.on("familiaros:tts-audio", (_event, payload) => {
|
|
try {
|
|
stopCurrentTtsAudio();
|
|
if (!payload || !payload.audio || !payload.mimeType) return;
|
|
const buffer = payload.audio instanceof Uint8Array ? payload.audio : new Uint8Array(payload.audio);
|
|
const blob = new Blob([buffer], { type: payload.mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
currentTtsAudio = new Audio(url);
|
|
currentTtsAudio.addEventListener("ended", () => {
|
|
URL.revokeObjectURL(url);
|
|
currentTtsAudio = null;
|
|
});
|
|
currentTtsAudio.play().catch(() => { /* audio is best-effort */ });
|
|
} catch { /* tts is best-effort */ }
|
|
});
|
|
|
|
ipcRenderer.on("familiaros:tts-stop", () => {
|
|
try {
|
|
stopCurrentTtsAudio();
|
|
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
|
} catch { /* noop */ }
|
|
});
|
|
|
|
function stopCurrentTtsAudio() {
|
|
if (currentTtsAudio) {
|
|
try { currentTtsAudio.pause(); } catch { /* noop */ }
|
|
try { currentTtsAudio.src = ""; } catch { /* noop */ }
|
|
currentTtsAudio = null;
|
|
}
|
|
}
|
|
|
|
const installMouseInterop = () => {
|
|
lastInteractiveHit = null;
|
|
dragging = false;
|
|
scaling = false;
|
|
|
|
document.addEventListener("click", (event) => {
|
|
if (handleBubbleInteraction(event)) return;
|
|
dismissBubble(event);
|
|
});
|
|
installPetSenses();
|
|
|
|
let dragStartPoint = null;
|
|
let dragCandidate = null;
|
|
|
|
document.addEventListener("mousemove", (event) => {
|
|
updateInteractiveHit(event);
|
|
if (dragging) {
|
|
ipcRenderer.send("familiaros:familiar-drag-move", { screenX: event.screenX, screenY: event.screenY });
|
|
} else if (dragCandidate) {
|
|
const dx = event.screenX - dragCandidate.screenX;
|
|
const dy = event.screenY - dragCandidate.screenY;
|
|
if (Math.hypot(dx, dy) > dragThreshold) {
|
|
dragging = true;
|
|
dragStartPoint = { screenX: dragCandidate.screenX, screenY: dragCandidate.screenY };
|
|
dragCandidate = null;
|
|
setInteractiveHit(true);
|
|
ipcRenderer.send("familiaros:familiar-drag-start", { screenX: event.screenX, screenY: event.screenY });
|
|
}
|
|
}
|
|
if (scaling) {
|
|
const newScale = scaleStartValue + (event.screenY - scaleStartY) * 0.003;
|
|
const clamped = applyScalePreview(newScale);
|
|
sendScalePreview(clamped);
|
|
}
|
|
}, { passive: true });
|
|
|
|
document.addEventListener("mousedown", (event) => {
|
|
const target = getInteractiveTarget(event);
|
|
setInteractiveHit(Boolean(target));
|
|
|
|
const scaleHandle = target && target.closest(".scale-handle");
|
|
if (scaleHandle) {
|
|
if (event.button !== 0) return;
|
|
event.preventDefault();
|
|
scaling = true;
|
|
scaleStartY = event.screenY;
|
|
scaleStartValue = getCurrentSpriteScale();
|
|
setInteractiveHit(true);
|
|
ipcRenderer.send("familiaros:familiar-scale-start");
|
|
return;
|
|
}
|
|
|
|
if (event.button !== 0 || !target?.closest(".familiar-hitbox, .familiar-shell")) return;
|
|
event.preventDefault();
|
|
dragCandidate = { screenX: event.screenX, screenY: event.screenY };
|
|
dragStartPoint = null;
|
|
});
|
|
|
|
document.addEventListener("mouseup", (event) => {
|
|
if (dragging) {
|
|
dragging = false;
|
|
if (dragStartPoint && Math.hypot(event.screenX - dragStartPoint.screenX, event.screenY - dragStartPoint.screenY) > dragThreshold) {
|
|
suppressClickUntil = Date.now() + 300;
|
|
}
|
|
dragStartPoint = null;
|
|
dragCandidate = null;
|
|
ipcRenderer.send("familiaros:familiar-drag-end");
|
|
} else if (dragCandidate) {
|
|
dragCandidate = null;
|
|
}
|
|
if (scaling) {
|
|
scaling = false;
|
|
const sprite = document.querySelector(".sprite, .installed-sprite");
|
|
const finalScale = sprite ? getCurrentSpriteScale() : scaleStartValue;
|
|
ipcRenderer.send("familiaros:familiar-scale-end", { scale: finalScale });
|
|
}
|
|
});
|
|
|
|
document.addEventListener("mouseleave", () => {
|
|
if (!dragging && !scaling) setInteractiveHit(false);
|
|
}, { passive: true });
|
|
|
|
const scaleNudgeStep = 0.02;
|
|
let scaleNudgeCommitTimer = null;
|
|
const commitScaleNudge = () => {
|
|
if (scaleNudgeCommitTimer) {
|
|
clearTimeout(scaleNudgeCommitTimer);
|
|
scaleNudgeCommitTimer = null;
|
|
}
|
|
const finalScale = getCurrentSpriteScale();
|
|
ipcRenderer.send("familiaros:familiar-scale-end", { scale: finalScale });
|
|
};
|
|
|
|
window.addEventListener("keydown", (event) => {
|
|
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
const target = event.target;
|
|
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
|
|
if (event.key !== "ArrowUp" && event.key !== "ArrowDown" && event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
|
event.preventDefault();
|
|
const current = getCurrentSpriteScale();
|
|
const delta = event.key === "ArrowUp" || event.key === "ArrowRight" ? scaleNudgeStep : -scaleNudgeStep;
|
|
const next = applyScalePreview(current + delta);
|
|
ipcRenderer.send("familiaros:familiar-scale-preview", { scale: next });
|
|
if (scaleNudgeCommitTimer) clearTimeout(scaleNudgeCommitTimer);
|
|
scaleNudgeCommitTimer = setTimeout(commitScaleNudge, 250);
|
|
});
|
|
|
|
setInteractiveHit(false, "ready");
|
|
ipcRenderer.send("familiaros:familiar-ready");
|
|
};
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", installMouseInterop, { once: true });
|
|
} else {
|
|
installMouseInterop();
|
|
}
|