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||'';...