tarinai/js/input_mode_manager.js
2026-06-21 22:29:00 +09:00

119 lines
3.9 KiB
JavaScript

"use strict";
(function (global) {
const MODES = Object.freeze(["auto", "camera", "tool", "family"]);
const STORAGE_KEY = "tarinai_mobile_input_mode_v1";
class InputModeManager {
constructor() {
this.storageKey = STORAGE_KEY;
this.currentMode = this.loadMode();
this.gestureState = {
active: false,
kind: "",
startedAt: 0,
pointerCount: 0,
};
this.classification = this.classify();
}
loadMode() {
try {
const mode = global.localStorage?.getItem(STORAGE_KEY) || "auto";
return MODES.includes(mode) ? mode : "auto";
} catch (_) {
return "auto";
}
}
setMode(mode = "auto") {
this.currentMode = MODES.includes(mode) ? mode : "auto";
try { global.localStorage?.setItem(STORAGE_KEY, this.currentMode); } catch (_) {}
this.emit("input:mode", this.snapshot());
return this.currentMode;
}
classify() {
const nav = global.navigator || {};
const ua = String(nav.userAgent || "");
const mm = typeof global.matchMedia === "function" ? global.matchMedia.bind(global) : null;
const width = Number(global.innerWidth || 9999);
const height = Number(global.innerHeight || 9999);
const shortSide = Math.min(width, height);
const longSide = Math.max(width, height);
const pointerCoarse = Boolean(mm?.("(pointer: coarse)")?.matches);
const hasHover = Boolean(mm?.("(hover: hover)")?.matches);
const noHover = Boolean(mm?.("(hover: none)")?.matches);
const isTouchDevice = Number(nav.maxTouchPoints || 0) > 0 || "ontouchstart" in global;
const isCompactViewport = shortSide <= 720 && longSide <= 1100;
const userAgentMobile = /Android|iPhone|iPod|iPad|Mobile|Silk|Tablet/i.test(ua);
const touchFirst = Boolean(pointerCoarse && noHover && !hasHover && isTouchDevice && (isCompactViewport || userAgentMobile));
this.classification = Object.freeze({
isTouchDevice,
isCompactViewport,
hasHover,
pointerCoarse,
userAgentMobile,
touchFirst,
width,
height,
});
return this.classification;
}
refresh() {
const previous = this.classification;
const next = this.classify();
if (JSON.stringify(previous) !== JSON.stringify(next)) this.emit("input:classification", this.snapshot());
return next;
}
beginGesture(kind = "", pointerCount = 1) {
this.gestureState = {
active: true,
kind: String(kind || ""),
startedAt: performance?.now?.() ?? Date.now(),
pointerCount: Math.max(0, Number(pointerCount) || 0),
};
return this.gestureState;
}
endGesture() {
this.gestureState = { ...this.gestureState, active: false, pointerCount: 0 };
return this.gestureState;
}
canPanCanvas() {
if (!this.classification?.touchFirst) return true;
return this.currentMode === "auto" || this.currentMode === "camera";
}
canPlaceTools() {
if (!this.classification?.touchFirst) return true;
return this.currentMode === "auto" || this.currentMode === "tool";
}
canFamilyTreeCaptureTouch() {
if (!this.classification?.touchFirst) return true;
return this.currentMode === "auto" || this.currentMode === "family";
}
snapshot() {
return Object.freeze({
...this.classification,
currentMode: this.currentMode,
gestureState: { ...this.gestureState },
canPanCanvas: this.canPanCanvas(),
canPlaceTools: this.canPlaceTools(),
canFamilyTreeCaptureTouch: this.canFamilyTreeCaptureTouch(),
});
}
emit(type, detail) {
global.TarinaiEvents?.emit?.(type, detail);
}
}
global.InputModeManager = InputModeManager;
global.TarinaiInputMode = global.TarinaiInputMode || new InputModeManager();
})(typeof window !== "undefined" ? window : globalThis);