- 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
1094 lines
44 KiB
HTML
1094 lines
44 KiB
HTML
<!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> |