JSFiddle - React, Tailwind, and code Playground
HTML
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Детективная доска</title>
</head>
<body>
<div class="toolbar" role="toolbar" aria-label="Инструменты">
<div class="group">
<button id="addNote">Добавить заметку</button>
<label><input id="addImageInput" type="file" accept="image/*">Загрузка изображения</label>
<button id="linkMode" title="Создать связь (клик по двум карточкам)">Связать</button>
<span class="group">Цвет нитки <input id="wireColor" type="color" value="#c1272d"></span>
</div>
<div class="group">
<button id="save" class="ghost" title="Сохранить в LocalStorage">Сохранить</button>
<button id="load" class="ghost">Загрузить</button>
<button id="clear" class="ghost">Очистить</button>
<button id="export" class="ghost" title="Экспорт PNG (скриншот)">Экспорт PNG</button>
</div>
</div>
<div class="board-wrap">
<div id="board">
<svg id="wires"></svg>
</div>
</div>
<div id="hint" class="hint" style="display:none">Режим связи: выберите два узла</div>
</body>
</html>
CSS
:root{
--bg:#b88962; /* цвет пробковой доски */
--card:#fff9e8; /* цвет карточек */
--text:#2b2b2b;
--accent:#c1272d; /* цвет ниток */
--pin:#1f5fbf; /* цвет кнопок */
--shadow:0 6px 18px rgba(0,0,0,.15);
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Inter,Arial,sans-serif;background:var(--bg)}
/* Топ-панель */
.toolbar{
position:fixed;inset:12px 12px auto 12px;display:flex;gap:8px;flex-wrap:wrap;z-index:10;
background:#fff; padding:8px; border-radius:14px; box-shadow:var(--shadow);
}
.toolbar button,.toolbar label{
border:0; outline:0; background:#111; color:#fff; padding:10px 12px; border-radius:10px; cursor:pointer; font-weight:600;
}
.toolbar button[data-active="true"]{background:var(--accent)}
.toolbar input[type="color"]{width:36px;height:36px;padding:0;border:0;background:transparent;cursor:pointer}
.toolbar .group{display:flex;gap:8px;align-items:center}
.toolbar .ghost{background:#f0f0f0;color:#111}
/* Область доски */
.board-wrap{position:fixed; inset:0}
#board{position:absolute; inset:0; overflow:hidden;}
/* Лёгкая текстура пробки */
#board::before{
content:""; position:absolute; inset:0; pointer-events:none; opacity:.12;
background-image: radial-gradient(#000 1px, transparent 1px);
background-size: 12px 12px;
}
/* SVG-слой для ниток */
#wires{position:absolute; inset:0; pointer-events:none}
/* Узлы */
.node{position:absolute; min-width:140px; max-width:340px; min-height:80px; padding:8px; border-radius:14px; background:var(--card); color:var(--text); box-shadow:var(--shadow); user-select:none}
.node.selected{outline:3px solid #4f46e5}
.node .handle{cursor:grab; display:flex; align-items:center; gap:8px; font-weight:700;...
JavaScript
(() => {
const board = document.getElementById('board');
const wiresSvg = document.getElementById('wires');
const addNoteBtn = document.getElementById('addNote');
const addImageInput = document.getElementById('addImageInput');
const linkBtn = document.getElementById('linkMode');
const colorInput = document.getElementById('wireColor');
const saveBtn = document.getElementById('save');
const loadBtn = document.getElementById('load');
const clearBtn = document.getElementById('clear');
const exportBtn = document.getElementById('export');
const hint = document.getElementById('hint');
let nodes = []; // {id,x,y,width,height,type:'note'|'image',text?,src?}
let edges = []; // {from,to,color}
let selected = null; // id выбранного узла
let linkMode = false, linkFirst = null;
let nextId = 1;
// ===== helpers =====
const uid = () => String(nextId++);
const qs = (el,s) => el.querySelector(s);
function nodeById(id){return nodes.find(n=>n.id===id)}
function createNodeEl(n){
const el = document.createElement('div');
el.className = 'node';
el.style.left = n.x + 'px';
el.style.top = n.y + 'px';
el.style.width = (n.width||220) + 'px';
el.dataset.id = n.id;
el.innerHTML = `
<div class="handle"><span class="pin"></span>Карточка #${n.id}</div>
<div class="content" ${n.type==='note'? 'contenteditable="true"':''}></div>
<div class="actions">
<button class="ghost" data-act="link">Связать</button>
<button data-act="del">Удалить</button>
</div>`;
const content = qs(el,'.content');
if(n.type==='note') content.textContent = n.text||'Двойной клик чтобы редактировать';
if(n.type==='image') {
const img = document.createElement('img');
img.src = n.src; content.appendChild(img);
const cap = document.createElement('div');
cap.contentEditable = true; cap.className='caption'; cap.textContent = n.text||'';...