0 directories, 46 files

util

Home / tinai / util
/**
 * Overlay layer for viewing Mermaid diagrams enlarged. Supports drag-to-pan,
 * +/- zoom buttons, and pinch-zoom on touch devices.
 */

import { showOverlay, hideOverlay } from './overlay-utils.js';
import { addSafeEventListener, getEl } from './dom-utils.js';

const MIN_SCALE = 0.1;
const MAX_SCALE = 10;
const ZOOM_FACTOR = 1.25;
const FIT_PADDING = 20;

let div_overlay = null;
let div_viewport = null;
let div_stage = null;

let scale = 1;
let pan_x = 0;
let pan_y = 0;
let current_size = null;

const active_pointers = new Map();
let pinch_start = null;

/**
 * Checks whether the diagram overlay is currently open.
 * @returns {boolean} True if the overlay is active.
 */
export function is_diagram_overlay_open() {
	return !!div_overlay && div_overlay.classList.contains('active');
}

/**
 * Opens the overlay showing an enlarged clone of the given diagram SVG.
 * @param {SVGElement} svg - The rendered Mermaid SVG element.
 */
export function open_diagram_overlay(svg) {
	if (!svg) return;
	div_overlay = getEl('id-diagram-overlay');
	div_viewport = getEl('id-diagram-overlay-viewport');
	div_stage = getEl('id-diagram-overlay-stage');
	if (!div_overlay || !div_viewport || !div_stage) return;

	const clone = /** @type {SVGElement} */ (svg.cloneNode(true));
	const size = get_diagram_size(clone);
	div_stage.innerHTML = '';
	div_stage.style.width = size.width + 'px';
	div_stage.style.height = size.height + 'px';
	div_stage.appendChild(clone);

	current_size = size;
	showOverlay(div_overlay, 'flex');
	fit_to_view(size);
}

/**
 * Closes the diagram overlay if it is open.
 */
export function close_diagram_overlay() {
	if (!is_diagram_overlay_open()) return;
	active_pointers.clear();
	pinch_start = null;
	hideOverlay(div_overlay);
}

/**
 * Opens the overlay showing a message instead of a diagram, used when a
 * diagram cannot be rendered.
 * @param {string} message - The message to display.
 */
export function open_diagram_overlay_message(message) {
	div_overlay = getEl('id-diagram-overlay');
	div_viewport = getEl('id-diagram-overlay-viewport');
	div_stage = getEl('id-diagram-overlay-stage');
	if (!div_overlay || !div_viewport || !div_stage) return;

	div_stage.innerHTML = '';
	div_stage.style.width = '';
	div_stage.style.height = '';
	const div = document.createElement('div');
	div.className = 'div-diagram-overlay-message';
	div.textContent = message;
	div_stage.appendChild(div);

	current_size = null;
	showOverlay(div_overlay, 'flex');
}

/**
 * Binds all overlay interaction events. Call once during app initialization.
 */
export function init_diagram_overlay() {
	div_overlay = getEl('id-diagram-overlay');
	div_viewport = getEl('id-diagram-overlay-viewport');
	div_stage = getEl('id-diagram-overlay-stage');
	if (!div_overlay || !div_viewport || !div_stage) return;

	addSafeEventListener(getEl('btn-diagram-overlay-close'), 'click', close_diagram_overlay);
	addSafeEventListener(getEl('btn-diagram-overlay-zoom-in'), 'click', () => zoom_step(1));
	addSafeEventListener(getEl('btn-diagram-overlay-zoom-out'), 'click', () => zoom_step(-1));
	addSafeEventListener(getEl('btn-diagram-overlay-reset'), 'click', reset_view);

	addSafeEventListener(div_viewport, 'pointerdown', on_pointer_down);
	addSafeEventListener(div_viewport, 'pointermove', on_pointer_move);
	addSafeEventListener(div_viewport, 'pointerup', on_pointer_end);
	addSafeEventListener(div_viewport, 'pointercancel', on_pointer_end);
}

/**
 * Reads the natural size of a Mermaid SVG from its viewBox, falling back to
 * its rendered bounds.
 * @param {SVGElement} svg - The SVG element to measure.
 * @returns {{width: number, height: number}} Natural size in pixels.
 */
function get_diagram_size(svg) {
	const viewBox = svg?.getAttribute('viewBox');
	if (viewBox) {
		const parts = viewBox.split(/[\s,]+/).map(parseFloat);
		if (parts.length === 4 && parts[2] > 0 && parts[3] > 0) {
			return { width: parts[2], height: parts[3] };
		}
	}
	const rect = svg?.getBoundingClientRect();
	return {
		width: rect?.width || 600,
		height: rect?.height || 400
	};
}

/**
 * Applies the current pan/zoom transform to the stage element.
 */
function apply_transform() {
	if (!div_stage) return;
	div_stage.style.transform = 'translate(' + pan_x + 'px, ' + pan_y + 'px) scale(' + scale + ')';
}

/**
 * Scales and centers the diagram so it fully fits inside the viewport.
 * @param {{width: number, height: number}} size - Natural diagram size in pixels.
 */
function fit_to_view(size) {
	const rect = div_viewport.getBoundingClientRect();
	if (rect.width <= 0 || rect.height <= 0) return;
	scale = Math.min(
		(rect.width - FIT_PADDING * 2) / size.width,
		(rect.height - FIT_PADDING * 2) / size.height
	);
	scale = Math.min(MAX_SCALE, Math.max(scale, MIN_SCALE));
	pan_x = (rect.width - size.width * scale) / 2;
	pan_y = (rect.height - size.height * scale) / 2;
	apply_transform();
}

/**
 * Zooms in or out by one step, centered on the viewport.
 * @param {number} direction - 1 to zoom in, -1 to zoom out.
 */
function zoom_step(direction) {
	const rect = div_viewport.getBoundingClientRect();
	zoom_at(rect.width / 2, rect.height / 2, scale * Math.pow(ZOOM_FACTOR, direction));
}

/**
 * Restores the initial centered, fit-to-viewport zoom state.
 */
function reset_view() {
	if (!current_size) return;
	fit_to_view(current_size);
}

/**
 * Zooms to a new scale while keeping the given viewport point fixed.
 * @param {number} vx - X coordinate relative to the viewport.
 * @param {number} vy - Y coordinate relative to the viewport.
 * @param {number} next - The target scale.
 */
function zoom_at(vx, vy, next) {
	next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, next));
	const k = next / scale;
	pan_x = vx - (vx - pan_x) * k;
	pan_y = vy - (vy - pan_y) * k;
	scale = next;
	apply_transform();
}

/**
 * Starts tracking a pointer for panning or pinch-zooming.
 * @param {PointerEvent} e - The pointer event.
 */
function on_pointer_down(e) {
	if (!is_diagram_overlay_open()) return;
	div_viewport.setPointerCapture(e.pointerId);
	active_pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
	if (active_pointers.size === 2) {
		pinch_start = {
			dist: get_pointer_distance(),
			scale: scale,
			pan_x: pan_x,
			pan_y: pan_y
		};
	}
}

/**
 * Updates panning or pinch-zooming while pointers move.
 * @param {PointerEvent} e - The pointer event.
 */
function on_pointer_move(e) {
	const prev = active_pointers.get(e.pointerId);
	if (!prev || !is_diagram_overlay_open()) return;
	active_pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });

	if (active_pointers.size >= 2 && pinch_start && pinch_start.dist > 0) {
		const rect = div_viewport.getBoundingClientRect();
		const mid = get_pointer_midpoint();
		const dist = get_pointer_distance();
		const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, pinch_start.scale * dist / pinch_start.dist));
		const k = next / pinch_start.scale;
		const mx = mid.x - rect.left;
		const my = mid.y - rect.top;
		scale = next;
		pan_x = mx - (mx - pinch_start.pan_x) * k;
		pan_y = my - (my - pinch_start.pan_y) * k;
		apply_transform();
	} else if (active_pointers.size === 1) {
		pan_x += e.clientX - prev.x;
		pan_y += e.clientY - prev.y;
		apply_transform();
	}
}

/**
 * Stops tracking a pointer and resets pinch state when fewer than two remain.
 * @param {PointerEvent} e - The pointer event.
 */
function on_pointer_end(e) {
	active_pointers.delete(e.pointerId);
	if (active_pointers.size < 2) {
		pinch_start = null;
	}
}

/**
 * Computes the distance between the two tracked pointers.
 * @returns {number} Distance in pixels, or 0 if fewer than two pointers.
 */
function get_pointer_distance() {
	const points = [...active_pointers.values()];
	if (points.length < 2) return 0;
	return Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
}

/**
 * Computes the midpoint between the two tracked pointers.
 * @returns {{x: number, y: number}} Midpoint in client coordinates.
 */
function get_pointer_midpoint() {
	const points = [...active_pointers.values()];
	return {
		x: (points[0].x + points[1].x) / 2,
		y: (points[0].y + points[1].y) / 2
	};
}
🌐
diagram-overlay.js ×
Type: Web, text/x-java
7.93 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File