0 directories, 46 files

util

Home / tinai / util
/**
 * Document-level horizontal swipe gestures for opening and closing the side
 * panels on touch devices. Behavior follows the current layout mode:
 * single-column (<= MOBILE) steps panel 1 -> 2 -> 3 on left swipes,
 * two-panel (<= TABLET) steps 3 -> 1 -> 2 on right swipes, and desktop
 * three-panel opens/closes 1 then 2. Swipes starting on form fields are
 * ignored. While an overlay is open, panel navigation is disabled: diagram,
 * table and dialog overlays swallow swipes entirely, and the code overlay
 * uses them to open/close its analysis column.
 */

import { is_code_overlay_open, swipe_code_overlay_analysis } from './code-overlay.js';

/** Minimum horizontal travel, in pixels, for a swipe to register. */
const SWIPE_MIN_DISTANCE = 60;

/** Swipe counts as horizontal only when travel exceeds this multiple of vertical travel. */
const HORIZONTAL_RATIO = 1.5;

/** Elements where swipes have their own meaning or would interfere. */
const EXCLUDED_SELECTOR = 'input, textarea, select, [contenteditable]';

/** Selector matching any open modal overlay. */
const OVERLAY_SELECTOR = '.div-structure-dialog-overlay.active, .div-diagram-overlay.active, .div-code-overlay.active, .div-table-overlay.active';

let app = null;
let tracking = false;
let start_x = 0;
let start_y = 0;

/**
 * Initializes the document-level swipe navigation gesture.
 * @param {App} app_instance - The main application instance.
 */
export function init_swipe_navigation(app_instance) {
	app = app_instance;
	document.addEventListener('touchstart', on_touch_start);
	document.addEventListener('touchend', on_touch_end);
	document.addEventListener('touchcancel', on_touch_cancel);
}

/**
 * Starts tracking a single-finger gesture unless it begins on an excluded element.
 * @param {TouchEvent} e - The touch event.
 */
function on_touch_start(e) {
	if (e.touches.length !== 1 || is_excluded(e.target)) {
		tracking = false;
		return;
	}
	tracking = true;
	start_x = e.touches[0].clientX;
	start_y = e.touches[0].clientY;
}

/**
 * Evaluates the finished gesture and triggers the matching action. Swipes are
 * ignored while a non-code overlay is open; while the code overlay is open,
 * swipes toggle its analysis column instead of the underlying panels.
 * @param {TouchEvent} e - The touch event.
 */
function on_touch_end(e) {
	if (!tracking) return;
	tracking = false;
	if (e.touches.length !== 0) return;
	const touch = e.changedTouches[0];
	if (!touch) return;
	const dx = touch.clientX - start_x;
	const dy = touch.clientY - start_y;
	if (Math.abs(dx) < SWIPE_MIN_DISTANCE || Math.abs(dx) < Math.abs(dy) * HORIZONTAL_RATIO) return;
	if (document.querySelector(OVERLAY_SELECTOR)) {
		if (is_code_overlay_open()) {
			swipe_code_overlay_analysis(dx < 0);
		}
		return;
	}
	if (dx < 0) {
		on_swipe_left();
	} else {
		on_swipe_right();
	}
}

/**
 * Cancels the tracked gesture.
 */
function on_touch_cancel() {
	tracking = false;
}

/**
 * Steps back one panel toward panel 1 in single-column mode (3 -> 2 -> 1,
 * skipping 2 when the index is disabled). Panel 1 is the end of the chain.
 * @returns {boolean} True if the view moved back a panel.
 */
export function step_panels_back_single_column() {
	if (get_layout_mode() !== 'single') {
		return false;
	}
	const panel = get_visible_panel();
	if (panel === 3) {
		set_panels(false, index_enabled());
		return true;
	}
	if (panel === 2) {
		set_panels(true, false);
		return true;
	}
	return false;
}

/**
 * Handles a left swipe according to the current layout mode.
 */
function on_swipe_left() {
	const panel = get_visible_panel();
	if (get_layout_mode() === 'single') {
		// 1 -> 2 -> 3 (skipping 2 when disabled)
		if (panel === 1) {
			set_panels(false, index_enabled());
		} else if (panel === 2) {
			set_panels(false, false);
		}
	} else if (get_layout_mode() === 'two') {
		// 2 -> 1 -> 3
		if (panel === 2) {
			set_panels(true, false);
		} else if (panel === 1) {
			set_panels(false, false);
		}
	} else {
		// close 2 first, then 1
		if (app.state_i_open) {
			set_panels(true, false);
		} else if (app.state_v_open) {
			set_panels(false, false);
		}
	}
}

/**
 * Handles a right swipe according to the current layout mode.
 */
function on_swipe_right() {
	const panel = get_visible_panel();
	if (get_layout_mode() === 'single') {
		// 3 -> 2 -> 1 (skipping 2 when disabled)
		step_panels_back_single_column();
	} else if (get_layout_mode() === 'two') {
		// 3 -> 1 -> 2
		if (panel === 3) {
			set_panels(true, false);
		} else if (panel === 1 && index_enabled()) {
			set_panels(false, true);
		}
	} else {
		// open 1 first, then 2
		if (!app.state_v_open) {
			set_panels(true, false);
		} else if (!app.state_i_open && index_enabled()) {
			set_panels(true, true);
		}
	}
}

/**
 * Determines the layout mode from the current window width.
 * @returns {string} 'single', 'two', or 'three'.
 */
function get_layout_mode() {
	if (window.innerWidth <= app.BREAKPOINT_MOBILE) return 'single';
	if (window.innerWidth <= app.BREAKPOINT_TABLET) return 'two';
	return 'three';
}

/**
 * Determines which panel is currently visible. In single-column mode a
 * conversation is shown unless exactly one side panel is open; the desktop
 * three-panel branch never uses this result.
 * @returns {number} 1 for the conversations list, 2 for the index, 3 for the conversation.
 */
function get_visible_panel() {
	if (app.state_v_open && !app.state_i_open) return 1;
	if (!app.state_v_open && app.state_i_open) return 2;
	return 3;
}

/**
 * Checks whether the conversation index panel is available.
 * @returns {boolean} True when panel 2 can be opened.
 */
function index_enabled() {
	return !!app.index_button_label;
}

/**
 * Sets both panel states and refreshes the layout.
 * @param {boolean} v_open - Whether panel 1 (conversations list) is open.
 * @param {boolean} i_open - Whether panel 2 (index) is open.
 */
function set_panels(v_open, i_open) {
	app.state_v_open = v_open;
	app.state_i_open = i_open;
	app.apply_panels_layout();
}

/**
 * Checks whether the gesture target handles its own horizontal interaction.
 * @param {EventTarget} target - The event target.
 * @returns {boolean} True if the gesture should be ignored.
 */
function is_excluded(target) {
	return !!(target instanceof Element && target.closest(EXCLUDED_SELECTOR));
}
🌐
swipe-navigation.js ×
Type: Web, text/x-java
6.16 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File