Files
hermes-sites/espene-example.hidrago.click/index.html
T
jerome 486d395e6b Initial commit — site configs
- vault.hidrago.click: Vaultwarden (password manager)
- git.hidrago.click: Gitea (git service)
- All static sites (suma, weather, combien-jour, tokyo-trip, etc.)
- Nginx configs for all subdomains
- Systemd user service for Vaultwarden
2026-07-31 11:38:43 +01:00

1094 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Espène — dessin collaboratif</title>
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
touch-action: none;
-webkit-user-select: none; user-select: none;
}
body { display: flex; flex-direction: column; }
/* ── Header ──────────────────────────────────────── */
header {
padding: 6px 10px;
background: #16213e;
border-bottom: 1px solid #0f3460;
flex-shrink: 0;
}
.toolbar {
display: flex; align-items: center; gap: 5px; flex-wrap: wrap;
}
.toolbar-row {
display: flex; align-items: center; gap: 5px; flex-wrap: wrap;
}
.toolbar h1 {
font-size: 15px; font-weight: 600;
background: linear-gradient(135deg, #e94560, #0f3460);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
margin-right: 6px;
}
.toolbar button, .toolbar .btn {
padding: 4px 9px; border: 1px solid #333; border-radius: 5px;
background: #16213e; color: #ddd; font-size: 11px;
cursor: pointer; transition: all 0.12s;
white-space: nowrap;
}
.toolbar button:hover { background: #0f3460; border-color: #e94560; }
.toolbar button.active { background: #e94560; color: #fff; border-color: #e94560; }
.toolbar .sep { width: 1px; height: 20px; background: #333; }
.toolbar label { font-size: 11px; color: #888; display: flex; align-items: center; gap: 3px; }
.toolbar input[type="color"] { width: 26px; height: 26px; border: 2px solid #333; border-radius: 4px; background: none; cursor: pointer; padding: 0; }
.toolbar input[type="range"] { width: 55px; accent-color: #e94560; }
.toolbar .btn-session { border-color: #0f3460; background: #0b1a30; }
.toolbar .btn-session.connected { border-color: #4ade80; color: #4ade80; }
.toolbar .btn-penlock { border-color: #e94560; }
.toolbar .btn-penlock.unlocked { border-color: #666; color: #666; }
.toolbar .zoom-label { font-size: 11px; color: #888; min-width: 36px; text-align: center; }
.toolbar .page-label { font-size: 11px; color: #ddd; min-width: 65px; text-align: center; font-weight: 600; }
/* ── Canvas wrapper ──────────────────────────────── */
#canvas-wrapper {
flex: 1; position: relative; overflow: hidden;
background: #f0f0f0;
margin: 5px; border-radius: 8px; box-shadow: 0 0 20px rgba(0,0,0,0.4);
}
#transform-layer {
transform-origin: 0 0;
position: absolute; top: 0; left: 0;
}
canvas {
display: block;
background: #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
touch-action: none;
}
/* ── Pen overlay ─────────────────────────────────── */
#penOverlay {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
pointer-events: none; background: rgba(255,255,255,0.88);
z-index: 5; transition: opacity 0.3s;
}
/* ── Status bar ──────────────────────────────────── */
.status-bar {
padding: 3px 10px; background: #16213e; border-top: 1px solid #0f3460;
display: flex; justify-content: space-between; font-size: 10px; color: #666;
flex-shrink: 0;
}
.status-bar .dot {
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
margin-right: 3px; vertical-align: middle;
}
.dot-green { background: #4ade80; }
.dot-yellow { background: #facc15; }
.dot-red { background: #f87171; }
.dot-gray { background: #666; }
/* ── Recognition panel ───────────────────────────── */
#recog-panel {
position: absolute; bottom: 8px; left: 8px; right: 8px;
background: rgba(22,33,62,0.95);
border: 1px solid #0f3460; border-radius: 8px;
padding: 8px 12px; backdrop-filter: blur(8px);
display: none; z-index: 10; max-height: 120px; overflow-y: auto;
}
#recog-panel.visible { display: block; }
#recog-panel .recog-header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 3px;
}
#recog-panel .recog-header strong { font-size: 10px; color: #e94560; text-transform: uppercase; letter-spacing: 0.5px; }
#recog-panel .recog-header button { background: none; border: none; color: #888; cursor: pointer; font-size: 13px; padding: 0 4px; }
#recog-panel .recog-header button:hover { color: #e94560; }
#recog-result { font-size: 16px; color: #fff; word-break: break-word; min-height: 20px; line-height: 1.4; }
#recog-result .placeholder { color: #555; font-style: italic; font-size: 12px; }
#recog-status { font-size: 10px; color: #888; margin-top: 3px; }
#recog-result .confidence { font-size: 10px; color: #888; margin-left: 6px; }
/* ── Session Modal ───────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: none; align-items: center; justify-content: center;
z-index: 100; backdrop-filter: blur(4px);
}
.modal-overlay.visible { display: flex; }
.modal {
background: #16213e; border: 1px solid #0f3460; border-radius: 12px;
padding: 20px; width: 90%; max-width: 400px;
max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.modal h2 { font-size: 15px; margin-bottom: 14px; color: #e94560; }
.modal .field { margin-bottom: 10px; }
.modal .field label { display: block; font-size: 10px; color: #888; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.modal .field input { width: 100%; padding: 7px 9px; background: #0f1a2e; border: 1px solid #333; border-radius: 5px; color: #eee; font-size: 13px; outline: none; }
.modal .field input:focus { border-color: #e94560; }
.modal .actions { display: flex; gap: 6px; margin-top: 12px; }
.modal .actions button { flex: 1; padding: 7px 12px; border: 1px solid #333; border-radius: 5px; background: #0f3460; color: #eee; font-size: 12px; cursor: pointer; transition: background 0.15s; }
.modal .actions button:hover { background: #e94560; }
.modal .actions button.primary { background: #e94560; border-color: #e94560; }
.modal .actions button.primary:hover { background: #d63851; }
.modal .actions button.danger { border-color: #f87171; color: #f87171; }
.modal .actions button.danger:hover { background: #f87171; color: #fff; }
.modal .actions button:disabled { opacity: 0.4; cursor: not-allowed; }
.modal .session-list { margin-top: 12px; border-top: 1px solid #0f3460; padding-top: 10px; }
.modal .session-list h3 { font-size: 11px; color: #888; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
.modal .session-item { display: flex; align-items: center; justify-content: space-between; padding: 6px 9px; margin-bottom: 3px; background: #0f1a2e; border-radius: 5px; cursor: pointer; transition: background 0.15s; }
.modal .session-item:hover { background: #0f3460; }
.modal .session-item .sid { font-size: 13px; font-weight: 600; }
.modal .session-item .smeta { font-size: 10px; color: #888; }
.modal .session-item .sjoin { font-size: 10px; color: #4ade80; }
.modal .session-info { margin-bottom: 10px; padding: 8px; background: #0f1a2e; border-radius: 5px; text-align: center; }
.modal .session-info .sid { font-size: 18px; font-weight: 700; color: #e94560; letter-spacing: 2px; }
.modal .session-info .smeta { font-size: 11px; color: #888; margin-top: 3px; }
.modal .error { color: #f87171; font-size: 11px; margin-top: 6px; }
@media (max-width: 600px) {
header { padding: 4px 6px; }
.toolbar h1 { font-size: 13px; width: 100%; margin-bottom: 2px; }
.toolbar button, .toolbar .btn { padding: 3px 7px; font-size: 10px; }
.toolbar input[type="range"] { width: 40px; }
.toolbar .zoom-label { min-width: 28px; font-size: 10px; }
.toolbar .page-label { min-width: 50px; font-size: 10px; }
#canvas-wrapper { margin: 3px; border-radius: 6px; }
#recog-result { font-size: 14px; }
.modal { padding: 14px; }
}
</style>
</head>
<body>
<header>
<div class="toolbar">
<div class="toolbar-row">
<h1>✏️ Espène</h1>
<label><input type="color" id="colorPicker" value="#e94560"></label>
<label><span id="sizeLabel">3</span> <input type="range" id="sizeSlider" min="1" max="40" value="3"></label>
<div class="sep"></div>
<button id="btnEraser">Effacer</button>
<button id="btnClear">🗑️</button>
<button id="btnSave">💾</button>
<button id="btnUndo">↩️</button>
<div class="sep"></div>
<button id="btnRecognize">🔍</button>
<div class="sep"></div>
<button id="btnPenLock" class="btn-penlock" title="Stylet seulement">🔒</button>
<button id="btnSession" class="btn-session">👤</button>
</div>
<div class="toolbar-row" style="margin-top:4px">
<button id="btnPrevPage">◀</button>
<span class="page-label" id="pageLabel">Page 1/1</span>
<button id="btnNextPage">▶</button>
<button id="btnAddPage">+</button>
<div class="sep"></div>
<button id="btnZoomOut">➖</button>
<span class="zoom-label" id="zoomLabel">100%</span>
<button id="btnZoomIn">➕</button>
<button id="btnZoomReset">⊙</button>
</div>
</div>
</header>
<div id="canvas-wrapper">
<div id="transform-layer">
<canvas id="drawCanvas"></canvas>
</div>
<div id="penOverlay">
<div style="text-align:center;color:#888">
<div style="font-size:42px;margin-bottom:10px">🖊️</div>
<div style="font-size:16px;font-weight:600;color:#666">Utilisez un stylet</div>
<div style="font-size:13px;margin-top:3px">pour dessiner sur Espène</div>
</div>
</div>
<div id="recog-panel">
<div class="recog-header">
<strong>📝 Texte reconnu</strong>
<button id="recogClose">✕</button>
</div>
<div id="recog-result"><span class="placeholder">Appuyez sur 🔍</span></div>
<div id="recog-status"></div>
</div>
</div>
<div class="status-bar">
<span id="penStatus">🖊️ <span class="dot dot-gray"></span>Stylet —</span>
<span id="sessStatus"><span class="dot dot-gray"></span>Hors ligne</span>
<span id="coordDisplay">—</span>
</div>
<!-- ── Session Modal ──────────────────────────────────────────── -->
<div class="modal-overlay" id="sessionModal">
<div class="modal">
<h2 id="modalTitle">Sessions</h2>
<div id="sessionInfo" class="session-info" style="display:none">
<div class="sid" id="currentSessionDisplay">—</div>
<div class="smeta"><span id="clientCount">0</span> participant(s)</div>
</div>
<div class="field">
<label for="sessionNameInput">Nouvelle session</label>
<input id="sessionNameInput" placeholder="Nom (optionnel)" maxlength="30">
</div>
<div class="actions">
<button id="btnCreateSession" class="primary">✨ Créer</button>
<button id="btnJoinSession" disabled>🔗 Rejoindre</button>
</div>
<div class="field" style="margin-top:10px">
<label for="sessionJoinInput">Ou rejoindre par ID</label>
<input id="sessionJoinInput" placeholder="Ex: A3F9B2" maxlength="10" style="text-transform:uppercase">
</div>
<div class="session-list">
<h3>Actives <span id="refreshSessions" style="cursor:pointer;margin-left:6px;color:#e94560">↻</span></h3>
<div id="sessionListContainer"><div style="color:#555;font-size:11px;padding:6px">Aucune</div></div>
</div>
<div class="actions" style="margin-top:10px">
<button id="btnLeaveSession" class="danger" style="display:none">🚪 Quitter</button>
<button id="btnCloseModal">Fermer</button>
</div>
<div id="modalError" class="error"></div>
</div>
</div>
<script>
(function() {
// ── DOM refs ──────────────────────────────────────────────────
const canvas = document.getElementById('drawCanvas');
const wrapper = document.getElementById('canvas-wrapper');
const transformLayer = document.getElementById('transform-layer');
const ctx = canvas.getContext('2d');
const colorPicker = document.getElementById('colorPicker');
const sizeSlider = document.getElementById('sizeSlider');
const sizeLabel = document.getElementById('sizeLabel');
const btnEraser = document.getElementById('btnEraser');
const btnClear = document.getElementById('btnClear');
const btnSave = document.getElementById('btnSave');
const btnUndo = document.getElementById('btnUndo');
const btnRecognize = document.getElementById('btnRecognize');
const btnPenLock = document.getElementById('btnPenLock');
const btnSession = document.getElementById('btnSession');
const penStatus = document.getElementById('penStatus');
const sessStatus = document.getElementById('sessStatus');
const coordDisplay = document.getElementById('coordDisplay');
const recogPanel = document.getElementById('recog-panel');
const recogResult = document.getElementById('recog-result');
const recogStatus = document.getElementById('recog-status');
const recogClose = document.getElementById('recogClose');
const penOverlay = document.getElementById('penOverlay');
const pageLabel = document.getElementById('pageLabel');
const zoomLabel = document.getElementById('zoomLabel');
const btnPrevPage = document.getElementById('btnPrevPage');
const btnNextPage = document.getElementById('btnNextPage');
const btnAddPage = document.getElementById('btnAddPage');
const btnZoomOut = document.getElementById('btnZoomOut');
const btnZoomIn = document.getElementById('btnZoomIn');
const btnZoomReset = document.getElementById('btnZoomReset');
// Modal elements
const modalOverlay = document.getElementById('sessionModal');
const modalTitle = document.getElementById('modalTitle');
const sessionInfo = document.getElementById('sessionInfo');
const currentSessionDisplay = document.getElementById('currentSessionDisplay');
const clientCount = document.getElementById('clientCount');
const sessionNameInput = document.getElementById('sessionNameInput');
const sessionJoinInput = document.getElementById('sessionJoinInput');
const btnCreateSession = document.getElementById('btnCreateSession');
const btnJoinSession = document.getElementById('btnJoinSession');
const btnLeaveSession = document.getElementById('btnLeaveSession');
const btnCloseModal = document.getElementById('btnCloseModal');
const sessionListContainer = document.getElementById('sessionListContainer');
const refreshSessions = document.getElementById('refreshSessions');
const modalError = document.getElementById('modalError');
// ── State ─────────────────────────────────────────────────────
let isDrawing = false;
let isEraser = false;
let currentColor = '#e94560';
let currentSize = 3;
let isRecognizing = false;
// Pen-only mode
let penOnly = true;
let penDetected = false;
// Zoom & pan
let zoom = 1;
let panX = 0;
let panY = 0;
let isPanning = false;
let panStart = { x: 0, y: 0 };
let panStartOffset = { x: 0, y: 0 };
// Pages
let pages = []; // Array of {strokes: Stroke[]} — local mirror
let currentPage = 0; // 0-indexed
// Strokes for current page (local + remote)
let strokes = []; // Convenience alias for pages[currentPage].strokes
let pendingStroke = null;
let remoteStrokes = {};
// WebSocket
let ws = null;
let wsConnected = false;
let myClientId = null;
let currentSessionId = null;
let reconnectTimer = null;
// Offline buffer
let pendingSync = []; // Strokes drawn while offline, {points, color, size, isEraser, page}
let wasOffline = false; // Flag to trigger sync on reconnect
// ── Canvas sizing ─────────────────────────────────────────────
function resizeCanvas() {
const rect = wrapper.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = rect.width;
const h = rect.height;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
ctx.scale(dpr, dpr);
applyToolSettings();
updateTransform();
}
function updateTransform() {
transformLayer.style.transform = `scale(${zoom}) translate(${panX}px, ${panY}px)`;
}
function getCanvasPos(e) {
const rect = wrapper.getBoundingClientRect();
// CSS transform: scale(zoom) translate(panX, panY)
// Applied right-to-left: first translate, then scale
// screenX = (canvasX + panX) * zoom
// canvasX = screenX / zoom - panX
return {
x: (e.clientX - rect.left) / zoom - panX,
y: (e.clientY - rect.top) / zoom - panY,
};
}
function clearCanvasVisual() {
const rect = wrapper.getBoundingClientRect();
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, rect.width, rect.height);
}
function renderStroke(stroke) {
if (!stroke.points || stroke.points.length < 1) return;
ctx.save();
ctx.beginPath();
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
for (let i = 1; i < stroke.points.length; i++) {
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
}
ctx.strokeStyle = stroke.isEraser ? '#ffffff' : (stroke.color || '#e94560');
ctx.lineWidth = stroke.isEraser ? (stroke.size || 3) * 3 : (stroke.size || 3);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
ctx.restore();
}
function redrawAllStrokes() {
clearCanvasVisual();
for (const s of strokes) renderStroke(s);
for (const cid in remoteStrokes) renderStroke(remoteStrokes[cid]);
applyToolSettings();
}
function applyToolSettings() {
ctx.strokeStyle = isEraser ? '#ffffff' : currentColor;
ctx.lineWidth = isEraser ? currentSize * 3 : currentSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
// ── Pen-only mode ─────────────────────────────────────────────
function activatePen() {
penDetected = true;
penOverlay.style.display = 'none';
canvas.style.cursor = 'crosshair';
penStatus.innerHTML = '🖊️ <span class="dot dot-green"></span>Stylet ✓';
}
function deactivatePen() {
penDetected = false;
penOverlay.style.display = 'flex';
canvas.style.cursor = 'not-allowed';
penStatus.innerHTML = '🖊️ <span class="dot dot-gray"></span>Stylet —';
}
function togglePenLock() {
penOnly = !penOnly;
btnPenLock.textContent = penOnly ? '🔒' : '🔓';
btnPenLock.classList.toggle('unlocked', !penOnly);
btnPenLock.title = penOnly ? 'Stylet seulement' : 'Tous les appareils';
if (penOnly) {
// Re-check pen detection
if (!penDetected) {
penOverlay.style.display = 'flex';
canvas.style.cursor = 'not-allowed';
}
} else {
// Allow all input types
penOverlay.style.display = 'none';
canvas.style.cursor = 'crosshair';
penStatus.innerHTML = '🖊️ <span class="dot dot-yellow"></span>🔓 Libre';
}
}
// ── Drawing logic ─────────────────────────────────────────────
function shouldAcceptPointer(e) {
if (!penOnly) return true;
return e.pointerType === 'pen';
}
function startDraw(e) {
if (!shouldAcceptPointer(e)) return;
e.preventDefault();
const pos = getCanvasPos(e);
isDrawing = true;
pendingStroke = {
points: [{ x: pos.x, y: pos.y }],
color: currentColor,
size: currentSize,
isEraser,
};
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
ctx.lineTo(pos.x + 0.1, pos.y + 0.1);
ctx.stroke();
coordDisplay.textContent = `x:${Math.round(pos.x)} y:${Math.round(pos.y)}`;
sendDraw('start', pos.x, pos.y);
}
function draw(e) {
if (!shouldAcceptPointer(e)) return;
e.preventDefault();
if (!isDrawing || !pendingStroke) return;
const pos = getCanvasPos(e);
pendingStroke.points.push({ x: pos.x, y: pos.y });
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
coordDisplay.textContent = `x:${Math.round(pos.x)} y:${Math.round(pos.y)}`;
sendDraw('move', pos.x, pos.y);
}
function endDraw(e) {
if (!shouldAcceptPointer(e)) return;
e.preventDefault();
if (!isDrawing || !pendingStroke) return;
const pos = getCanvasPos(e);
pendingStroke.points.push({ x: pos.x, y: pos.y });
strokes.push(pendingStroke);
// Buffer for offline sync
if (!wsConnected || !currentSessionId) {
pendingSync.push({
points: pendingStroke.points.slice(),
color: pendingStroke.color,
size: pendingStroke.size,
isEraser: pendingStroke.isEraser,
page: currentPage,
});
}
pendingStroke = null;
isDrawing = false;
sendDraw('end', pos.x, pos.y);
}
// ── Pointer events ────────────────────────────────────────────
canvas.addEventListener('pointerdown', startDraw);
canvas.addEventListener('pointermove', draw);
canvas.addEventListener('pointerup', endDraw);
canvas.addEventListener('pointerleave', endDraw);
canvas.addEventListener('pointercancel', endDraw);
// Pen detection
canvas.addEventListener('pointerdown', function(e) {
if (penOnly && e.pointerType === 'pen') {
if (!penDetected) activatePen();
if (e.button === 2) toggleEraser();
}
});
canvas.addEventListener('pointermove', function(e) {
if (penOnly && e.pointerType === 'pen' && !penDetected) activatePen();
});
// ── Zoom & Pan ────────────────────────────────────────────────
function zoomAtPoint(newZoom, cx, cy) {
const oldZoom = zoom;
zoom = Math.max(0.2, Math.min(10, newZoom));
// Keep viewport point (cx, cy) stationary when zoom changes
// canvasX = cx / oldZoom - panX_old
// cx = (canvasX + panX_new) * newZoom
// panX_new = cx / newZoom - canvasX = cx/newZoom - cx/oldZoom + panX_old
panX = cx / zoom - cx / oldZoom + panX;
panY = cy / zoom - cy / oldZoom + panY;
updateTransform();
updateZoomLabel();
}
function setZoom(newZoom) {
const rect = wrapper.getBoundingClientRect();
zoomAtPoint(newZoom, rect.width / 2, rect.height / 2);
}
function updateZoomLabel() {
zoomLabel.textContent = Math.round(zoom * 100) + '%';
}
// Middle-click / alt+click to pan
canvas.addEventListener('mousedown', function(e) {
if (e.button === 1 || (e.button === 0 && e.altKey && zoom > 1)) {
e.preventDefault();
isPanning = true;
panStart = { x: e.clientX, y: e.clientY };
panStartOffset = { x: panX, y: panY };
canvas.style.cursor = 'grabbing';
}
});
canvas.addEventListener('mousemove', function(e) {
if (isPanning) {
panX = panStartOffset.x + (e.clientX - panStart.x) / zoom;
panY = panStartOffset.y + (e.clientY - panStart.y) / zoom;
updateTransform();
}
});
canvas.addEventListener('mouseup', function(e) {
if (isPanning) {
isPanning = false;
canvas.style.cursor = penOnly && !penDetected ? 'not-allowed' : 'crosshair';
}
});
// Scroll wheel zoom (Ctrl+scroll)
wrapper.addEventListener('wheel', function(e) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const rect = wrapper.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const delta = -e.deltaY * 0.001;
zoomAtPoint(zoom * (1 + delta), cx, cy);
}
}, { passive: false });
// Touch pinch-to-zoom
let lastTouchDist = 0;
let touchPanStart = { x: 0, y: 0 };
let touchPanOffset = { x: 0, y: 0 };
let isTouchPanning = false;
wrapper.addEventListener('touchstart', function(e) {
if (e.touches.length === 2) {
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
lastTouchDist = Math.sqrt(dx * dx + dy * dy);
touchPanOffset = { x: panX, y: panY };
isTouchPanning = false;
} else if (e.touches.length === 1 && zoom > 1) {
touchPanStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
touchPanOffset = { x: panX, y: panY };
isTouchPanning = true;
}
}, { passive: false });
wrapper.addEventListener('touchmove', function(e) {
if (e.touches.length === 2) {
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (lastTouchDist > 0) {
const rect = wrapper.getBoundingClientRect();
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
zoomAtPoint(zoom * (dist / lastTouchDist), cx, cy);
}
lastTouchDist = dist;
} else if (e.touches.length === 1 && isTouchPanning) {
panX = touchPanOffset.x + (e.touches[0].clientX - touchPanStart.x) / zoom;
panY = touchPanOffset.y + (e.touches[0].clientY - touchPanStart.y) / zoom;
updateTransform();
}
}, { passive: false });
wrapper.addEventListener('touchend', function(e) {
if (e.touches.length < 2) lastTouchDist = 0;
if (e.touches.length === 0) { isTouchPanning = false; }
});
// ── WebSocket ─────────────────────────────────────────────────
function sendDraw(action, x, y) {
if (!wsConnected || !currentSessionId) return;
ws.send(JSON.stringify({ type: 'draw', action, x, y, color: currentColor, size: currentSize, isEraser }));
}
function connectWebSocket() {
if (ws && (ws.readyState === 0 || ws.readyState === 1)) return;
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${location.host}/ws`;
try { ws = new WebSocket(url); } catch { return; }
ws.onopen = () => {
wsConnected = true;
wasOffline = true;
updateConnectionStatus(true, pendingSync.length > 0 ? `🔄 ${pendingSync.length} en attente` : 'Connecté');
if (currentSessionId) ws.send(JSON.stringify({ type: 'join', sessionId: currentSessionId }));
ws.send(JSON.stringify({ type: 'list_sessions' }));
};
ws.onclose = () => { wsConnected = false; updateConnectionStatus(false, `Hors-ligne${pendingSync.length > 0 ? ` (${pendingSync.length} en attente)` : ''}`); scheduleReconnect(); };
ws.onerror = () => { wsConnected = false; updateConnectionStatus(false, 'Erreur'); };
ws.onmessage = (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
switch (msg.type) {
case 'sessions':
renderSessionList(msg.sessions);
break;
case 'session_created':
joinSession(msg.sessionId);
break;
case 'session_joined':
myClientId = msg.clientId;
currentSessionId = msg.sessionId;
// Initialize pages from server
currentPage = msg.currentPage || 0;
pages = msg.pages.map(p => ({ name: p.name, strokes: [] }));
if (msg.strokes) {
strokes = msg.strokes;
if (!pages[currentPage]) pages[currentPage] = { name: 'Page 1', strokes: [] };
pages[currentPage].strokes = msg.strokes;
}
updateSessionUI(msg.sessionId, msg.name);
redrawAllStrokes();
updatePageLabel();
// Sync pending offline strokes
if (wasOffline && pendingSync.length > 0) {
syncPendingStrokes();
}
wasOffline = false;
break;
case 'draw': {
const cid = msg.clientId;
const page = msg.page !== undefined ? msg.page : currentPage;
if (page !== currentPage) break; // Only render if on same page
if (msg.action === 'start') {
remoteStrokes[cid] = {
points: [{ x: msg.x, y: msg.y }],
color: msg.color || '#e94560', size: msg.size || 3, isEraser: msg.isEraser || false,
};
ctx.save();
ctx.beginPath(); ctx.moveTo(msg.x, msg.y); ctx.lineTo(msg.x + 0.1, msg.y + 0.1);
ctx.strokeStyle = msg.isEraser ? '#ffffff' : (msg.color || '#e94560');
ctx.lineWidth = msg.isEraser ? (msg.size || 3) * 3 : (msg.size || 3);
ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke();
ctx.restore();
} else if (msg.action === 'move' && remoteStrokes[cid]) {
remoteStrokes[cid].points.push({ x: msg.x, y: msg.y });
ctx.save();
ctx.beginPath();
const pts = remoteStrokes[cid].points;
ctx.moveTo(pts[pts.length - 2].x, pts[pts.length - 2].y);
ctx.lineTo(msg.x, msg.y);
ctx.strokeStyle = remoteStrokes[cid].isEraser ? '#ffffff' : (remoteStrokes[cid].color || '#e94560');
ctx.lineWidth = remoteStrokes[cid].isEraser ? (remoteStrokes[cid].size || 3) * 3 : (remoteStrokes[cid].size || 3);
ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke();
ctx.restore();
} else if (msg.action === 'end' && remoteStrokes[cid]) {
remoteStrokes[cid].points.push({ x: msg.x, y: msg.y });
strokes.push(remoteStrokes[cid]);
delete remoteStrokes[cid];
// Draw last segment
const last = strokes[strokes.length - 1];
const pts = last.points;
if (pts.length >= 2) {
ctx.save();
ctx.beginPath(); ctx.moveTo(pts[pts.length - 2].x, pts[pts.length - 2].y); ctx.lineTo(msg.x, msg.y);
ctx.strokeStyle = last.isEraser ? '#ffffff' : (last.color || '#e94560');
ctx.lineWidth = last.isEraser ? (last.size || 3) * 3 : (last.size || 3);
ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke();
ctx.restore();
}
}
break;
}
case 'undo': {
if (msg.page !== undefined && msg.page !== currentPage) break;
if (strokes.length > 0) strokes.pop();
redrawAllStrokes();
break;
}
case 'clear': {
if (msg.page !== undefined && msg.page !== currentPage) break;
strokes = [];
clearCanvasVisual();
break;
}
case 'clients':
clientCount.textContent = msg.count;
break;
case 'batch_strokes': {
if (!msg.strokes) break;
for (const stroke of msg.strokes) {
const p = stroke.page !== undefined ? stroke.page : currentPage;
if (p !== currentPage) continue;
strokes.push(stroke);
renderStroke(stroke);
}
break;
}
case 'page_switched':
currentPage = msg.page;
strokes = msg.strokes || [];
if (pages[currentPage]) pages[currentPage].strokes = strokes;
updatePageLabel();
redrawAllStrokes();
break;
case 'page_added': {
const idx = msg.page;
pages.push({ name: msg.name || `Page ${idx + 1}`, strokes: [] });
updatePageLabel();
break;
}
case 'page_deleted': {
currentPage = msg.currentPage;
pages = pages.filter((_, i) => i !== msg.page);
strokes = msg.strokes || [];
if (pages[currentPage]) pages[currentPage].strokes = strokes;
updatePageLabel();
redrawAllStrokes();
break;
}
case 'error':
showModalError(msg.message);
break;
}
};
}
function scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connectWebSocket, 3000);
}
function updateConnectionStatus(connected, text) {
wsConnected = connected;
sessStatus.innerHTML = `<span class="dot ${connected ? 'dot-green' : 'dot-red'}"></span>${text}`;
btnSession.classList.toggle('connected', connected);
}
// Sync pending offline strokes to server
function syncPendingStrokes() {
if (!wsConnected || !currentSessionId || pendingSync.length === 0) return;
const count = pendingSync.length;
ws.send(JSON.stringify({ type: 'batch_strokes', strokes: pendingSync }));
// Append to local strokes (we don't receive our own broadcast)
for (const stroke of pendingSync) {
if (stroke.page === currentPage) {
strokes.push(stroke);
renderStroke(stroke);
}
}
pendingSync = [];
updateConnectionStatus(true, '✅ Synchronisé');
}
// ── Session management ────────────────────────────────────────
function joinSession(sessionId) {
if (!wsConnected) { showModalError('Non connecté'); return; }
pages = [{ name: 'Page 1', strokes: [] }];
currentPage = 0;
strokes = pages[0].strokes;
remoteStrokes = {};
clearCanvasVisual();
ws.send(JSON.stringify({ type: 'join', sessionId }));
}
function leaveSession() {
if (wsConnected && currentSessionId) ws.send(JSON.stringify({ type: 'leave' }));
currentSessionId = null; myClientId = null;
pages = [{ name: 'Page 1', strokes: [] }]; currentPage = 0; strokes = pages[0].strokes;
remoteStrokes = {}; clearCanvasVisual();
btnLeaveSession.style.display = 'none'; sessionInfo.style.display = 'none';
modalTitle.textContent = 'Sessions'; btnSession.textContent = '👤';
updatePageLabel();
refreshSessionList();
}
function updateSessionUI(sessionId, name) {
currentSessionDisplay.textContent = sessionId;
sessionInfo.style.display = 'block';
btnLeaveSession.style.display = 'inline-block';
modalTitle.textContent = `📋 ${name || sessionId}`;
btnSession.textContent = `📋 ${sessionId}`;
}
function showModalError(msg) {
modalError.textContent = msg; setTimeout(() => { modalError.textContent = ''; }, 4000);
}
function refreshSessionList() {
if (wsConnected) ws.send(JSON.stringify({ type: 'list_sessions' }));
}
function renderSessionList(sessions) {
if (!sessions || sessions.length === 0) {
sessionListContainer.innerHTML = '<div style="color:#555;font-size:11px;padding:6px">Aucune session active</div>';
return;
}
sessionListContainer.innerHTML = sessions.map(s => `
<div class="session-item" data-sid="${s.id}">
<div>
<div class="sid">${s.id}</div>
<div class="smeta">${s.name !== 'Session ' + s.id ? s.name : ''} ${s.pageCount}p</div>
</div>
<div><span class="smeta">${s.clientCount}</span> <span class="sjoin">➜</span></div>
</div>
`).join('');
sessionListContainer.querySelectorAll('.session-item').forEach(el => {
el.addEventListener('click', () => {
const sid = el.dataset.sid;
if (sid && sid !== currentSessionId) { joinSession(sid); modalOverlay.classList.remove('visible'); }
});
});
}
// ── Pages ─────────────────────────────────────────────────────
function updatePageLabel() {
pageLabel.textContent = `Page ${currentPage + 1}/${pages.length}`;
}
function switchPage(page) {
if (page < 0 || page >= pages.length) return;
currentPage = page;
strokes = pages[page].strokes;
updatePageLabel();
redrawAllStrokes();
if (wsConnected && currentSessionId) {
ws.send(JSON.stringify({ type: 'switch_page', page }));
}
}
function addPage() {
if (wsConnected && currentSessionId) {
ws.send(JSON.stringify({ type: 'add_page' }));
} else {
const idx = pages.length;
pages.push({ name: `Page ${idx + 1}`, strokes: [] });
updatePageLabel();
}
}
function deleteCurrentPage() {
if (pages.length <= 1) return;
if (wsConnected && currentSessionId) {
ws.send(JSON.stringify({ type: 'delete_page', page: currentPage }));
} else {
pages.splice(currentPage, 1);
currentPage = Math.min(currentPage, pages.length - 1);
strokes = pages[currentPage].strokes;
updatePageLabel();
redrawAllStrokes();
}
}
// ── Toggle eraser ─────────────────────────────────────────────
function toggleEraser() {
isEraser = !isEraser;
btnEraser.classList.toggle('active');
btnEraser.textContent = isEraser ? '✏️' : 'Effacer';
applyToolSettings();
}
// ── Handwriting recognition ───────────────────────────────────
async function recognizeHandwriting() {
if (isRecognizing) return;
if (typeof Tesseract === 'undefined') {
recogResult.innerHTML = '❌ Tesseract.js non chargé'; recogPanel.classList.add('visible'); return;
}
isRecognizing = true; btnRecognize.disabled = true; btnRecognize.textContent = '⏳';
recogPanel.classList.add('visible');
recogResult.innerHTML = '<span class="placeholder">Analyse en cours...</span>';
recogStatus.textContent = 'Initialisation...';
try {
const rect = wrapper.getBoundingClientRect();
const tempCanvas = document.createElement('canvas');
tempCanvas.width = rect.width; tempCanvas.height = rect.height;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(canvas, 0, 0, rect.width, rect.height);
recogStatus.textContent = 'Reconnaissance...';
const result = await Tesseract.recognize(tempCanvas, 'fra', {
logger: (m) => {
if (m.status === 'recognizing text') recogStatus.textContent = `Reconnaissance : ${Math.round(m.progress * 100)}%`;
else if (m.status === 'loading tesseract core') recogStatus.textContent = 'Chargement du moteur...';
else if (m.status === 'initializing tesseract') recogStatus.textContent = 'Initialisation...';
else if (m.status === 'loading language traineddata') recogStatus.textContent = 'Chargement français...';
}
});
const text = result.data.text.trim();
const confidence = Math.round(result.data.confidence);
recogResult.innerHTML = text ? `${text} <span class="confidence">(${confidence}%)</span>` : '<span class="placeholder">Aucun texte reconnu</span>';
recogStatus.textContent = text ? `✓ ${confidence}%` : '—';
} catch (err) {
recogResult.innerHTML = `❌ ${err.message || 'Erreur'}`; recogStatus.textContent = 'Erreur';
} finally {
isRecognizing = false; btnRecognize.disabled = false; btnRecognize.textContent = '🔍';
}
}
// ── Event listeners ───────────────────────────────────────────
// Toolbar
colorPicker.addEventListener('input', function(e) {
currentColor = e.target.value;
if (isEraser) toggleEraser();
applyToolSettings();
});
sizeSlider.addEventListener('input', function(e) {
currentSize = parseInt(e.target.value);
sizeLabel.textContent = currentSize;
applyToolSettings();
});
btnEraser.addEventListener('click', toggleEraser);
btnClear.addEventListener('click', function() {
if (!confirm('Tout effacer ?')) return;
strokes = []; clearCanvasVisual();
if (wsConnected && currentSessionId) ws.send(JSON.stringify({ type: 'clear' }));
});
btnUndo.addEventListener('click', function() {
if (strokes.length === 0) return;
strokes.pop(); redrawAllStrokes();
if (wsConnected && currentSessionId) ws.send(JSON.stringify({ type: 'undo' }));
});
btnSave.addEventListener('click', function() {
recogPanel.classList.remove('visible');
const link = document.createElement('a');
link.download = 'espene-' + new Date().toISOString().slice(0, 19).replace(/[:]/g, '-') + '.png';
link.href = canvas.toDataURL('image/png');
link.click();
});
btnRecognize.addEventListener('click', recognizeHandwriting);
recogClose.addEventListener('click', () => recogPanel.classList.remove('visible'));
// Pen lock toggle
btnPenLock.addEventListener('click', togglePenLock);
// Session modal
btnSession.addEventListener('click', () => { modalOverlay.classList.add('visible'); refreshSessionList(); });
btnCloseModal.addEventListener('click', () => modalOverlay.classList.remove('visible'));
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) modalOverlay.classList.remove('visible'); });
btnCreateSession.addEventListener('click', () => {
if (!wsConnected) { showModalError('Non connecté'); return; }
ws.send(JSON.stringify({ type: 'create_session', name: sessionNameInput.value.trim() || undefined }));
});
sessionJoinInput.addEventListener('input', () => { btnJoinSession.disabled = sessionJoinInput.value.toUpperCase().trim().length < 4; });
btnJoinSession.addEventListener('click', () => {
const sid = sessionJoinInput.value.toUpperCase().trim();
if (sid.length < 4) return; joinSession(sid); modalOverlay.classList.remove('visible');
});
sessionJoinInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !btnJoinSession.disabled) btnJoinSession.click(); });
btnLeaveSession.addEventListener('click', leaveSession);
refreshSessions.addEventListener('click', refreshSessionList);
// Pages
btnPrevPage.addEventListener('click', () => { if (currentPage > 0) switchPage(currentPage - 1); });
btnNextPage.addEventListener('click', () => { if (currentPage < pages.length - 1) switchPage(currentPage + 1); });
// Long-press on next to add page; single click to add
btnAddPage.addEventListener('click', addPage);
// Right-click on page label to delete current page
pageLabel.addEventListener('contextmenu', (e) => {
e.preventDefault();
if (pages.length > 1 && confirm(`Supprimer la page ${currentPage + 1} ?`)) deleteCurrentPage();
});
// Zoom
btnZoomIn.addEventListener('click', () => setZoom(zoom * 1.25));
btnZoomOut.addEventListener('click', () => setZoom(zoom / 1.25));
btnZoomReset.addEventListener('click', () => { zoom = 1; panX = 0; panY = 0; updateTransform(); updateZoomLabel(); });
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'z') { e.preventDefault(); btnUndo.click(); }
if (e.ctrlKey && e.key === 's') { e.preventDefault(); btnSave.click(); }
if (e.key === 'e') { toggleEraser(); }
if (e.key === 'r' && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); recognizeHandwriting(); }
if (e.key === 'ArrowLeft' && !e.ctrlKey) { btnPrevPage.click(); }
if (e.key === 'ArrowRight' && !e.ctrlKey) { btnNextPage.click(); }
if (e.key === '+' || e.key === '=') { btnZoomIn.click(); }
if (e.key === '-') { btnZoomOut.click(); }
if (e.key === '0' && !e.ctrlKey) { btnZoomReset.click(); }
});
// Prevent context menu on canvas
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// ── Init ──────────────────────────────────────────────────────
function init() {
canvas.style.cursor = 'not-allowed';
pages = [{ name: 'Page 1', strokes: [] }];
strokes = pages[0].strokes;
resizeCanvas();
clearCanvasVisual();
applyToolSettings();
connectWebSocket();
updatePageLabel();
updateZoomLabel();
}
init();
// Handle resize
let resizeTimer;
window.addEventListener('resize', function() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const dataUrl = canvas.toDataURL();
resizeCanvas();
const img = new Image();
img.onload = function() {
ctx.drawImage(img, 0, 0, wrapper.getBoundingClientRect().width, wrapper.getBoundingClientRect().height);
applyToolSettings();
};
img.src = dataUrl;
}, 200);
});
})();
</script>
</body>
</html>