wa-dev-tools/public/svgeditor.html
2026-03-21 22:06:15 +03:00

2456 lines
160 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="ru" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SVG Редактор — images.wadevelop.ru</title>
<link href="/vendor/fonts.css" rel="stylesheet">
<script src="/vendor/opentype.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#060c18;--surface-800:rgba(9,16,32,0.95);--surface-700:#0d1828;--surface-600:#162040;--text-primary:#fff;--text-secondary:#c0c0c0;--text-muted:#666;--accent:#0054e6;--accent-dim:#0043b8;--accent-bg:rgba(0,84,230,0.08);--danger:#ff5252;--warn:#ffd600}
body{font-family:'Manrope',sans-serif;font-size:13px;background:var(--bg);color:var(--text-secondary);overflow:hidden;height:100vh;width:100vw;user-select:none}
input,select,button,textarea{font-family:inherit;font-size:inherit}
/* Sidebar */
.sidebar{position:fixed;top:0;left:0;width:56px;height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 0 12px;z-index:100;border-right:1px solid var(--surface-600);background:var(--surface-800);backdrop-filter:blur(12px)}
.sidebar-logo{margin-bottom:24px;color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;font-family:'JetBrains Mono',monospace}
.sidebar-nav{display:flex;flex-direction:column;gap:6px;flex:1;overflow-y:auto;scrollbar-width:none}
.sidebar-nav::-webkit-scrollbar{display:none}
.sidebar-link{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);transition:all .2s;text-decoration:none;position:relative;flex-shrink:0}
.sidebar-link:hover{color:var(--text-secondary);background:rgba(255,255,255,.04)}
.sidebar-link.active{color:var(--accent);background:var(--accent-bg)}
.sidebar-link.active::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;height:20px;border-radius:0 3px 3px 0;background:var(--accent)}
/* Main layout */
.editor-wrap{margin-left:56px;height:100vh;display:flex;flex-direction:column}
/* Toolbar */
.toolbar{height:44px;background:var(--surface-800);border-bottom:1px solid var(--surface-600);display:flex;align-items:center;padding:0 12px;gap:4px;flex-shrink:0;z-index:20}
.toolbar-group{display:flex;align-items:center;gap:2px}
.toolbar-sep{width:1px;height:24px;background:var(--surface-600);margin:0 6px}
.tb{width:34px;height:34px;border:none;border-radius:8px;background:transparent;color:var(--text-primary);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;position:relative}
.tb:hover{color:#fff;background:rgba(255,255,255,.08)}
.tb.active{color:var(--accent);background:var(--accent-bg)}
.tb:disabled{opacity:.3;cursor:default;pointer-events:none}
.tb svg{width:18px;height:18px}
.toolbar-right{margin-left:auto;display:flex;align-items:center;gap:4px}
/* Tool Options Bar */
.tool-options{height:36px;background:var(--surface-700);border-bottom:1px solid var(--surface-600);display:none;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;z-index:19}
.tool-options.visible{display:flex}
.tool-options label{font-size:11px;color:var(--text-muted);white-space:nowrap;font-family:'JetBrains Mono',monospace}
.tool-options input[type="range"]{width:80px;accent-color:var(--accent);height:4px;cursor:pointer}
.tool-options input[type="number"]{width:60px;background:var(--surface-600);border:1px solid transparent;color:var(--text-secondary);padding:3px 6px;border-radius:5px;text-align:center;font-family:'JetBrains Mono',monospace;font-size:11px}
.tool-options input[type="number"]:focus{border-color:var(--accent);outline:none}
.tool-options input[type="text"]{width:120px;background:var(--surface-600);border:1px solid transparent;color:var(--text-secondary);padding:3px 6px;border-radius:5px;font-family:'JetBrains Mono',monospace;font-size:11px}
.tool-options input[type="text"]:focus{border-color:var(--accent);outline:none}
.tool-options select{background:var(--surface-600);border:1px solid transparent;color:var(--text-secondary);padding:3px 8px;border-radius:5px;cursor:pointer;font-size:11px}
.tool-options select:focus{border-color:var(--accent);outline:none}
.tool-options .opt-sep{width:1px;height:20px;background:var(--surface-600)}
.opt-btn{padding:3px 10px;border:1px solid var(--surface-600);border-radius:5px;background:transparent;color:var(--text-secondary);cursor:pointer;font-size:11px;transition:all .15s}
.opt-btn:hover{border-color:var(--text-muted)}
.opt-btn.primary{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600}
.opt-btn.primary:hover{background:var(--accent-dim)}
/* Color swatch */
.color-swatch{width:28px;height:28px;border-radius:6px;overflow:hidden;border:2px solid var(--surface-600);cursor:pointer;position:relative}
.color-swatch input[type="color"]{position:absolute;inset:-4px;width:calc(100% + 8px);height:calc(100% + 8px);cursor:pointer;border:none;padding:0}
/* Viewport */
.viewport{flex:1;overflow:hidden;position:relative;background:#2c2c30}
#svgCanvas{background:#fff;box-shadow:0 2px 20px rgba(0,0,0,.4)}
.viewport svg{position:absolute;transform-origin:0 0;cursor:crosshair}
/* Grid dots */
.grid-dots{position:absolute;inset:0;pointer-events:none;z-index:1}
/* Selection box */
/* Tab Bar */
.tab-bar{height:30px;background:var(--surface-800);border-bottom:1px solid var(--surface-600);display:flex;align-items:stretch;overflow-x:auto;overflow-y:hidden;flex-shrink:0;scrollbar-width:none;z-index:18}
.tab-bar::-webkit-scrollbar{display:none}
.tab-item{display:flex;align-items:center;gap:5px;padding:0 8px 0 12px;border-right:1px solid var(--surface-600);white-space:nowrap;cursor:pointer;font-size:11px;font-family:'JetBrains Mono',monospace;color:var(--text-muted);flex-shrink:0;max-width:160px;transition:color .15s;position:relative}
.tab-item:hover{color:var(--text-secondary);background:rgba(255,255,255,.03)}
.tab-item.active{color:var(--text-primary);background:var(--surface-700);border-bottom:2px solid var(--accent)}
.tab-name{flex:1;overflow:hidden;text-overflow:ellipsis;min-width:40px}
.tab-close{background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:14px;line-height:1;padding:0 2px;border-radius:3px}
.tab-close:hover{color:var(--text-secondary);background:rgba(255,255,255,.08)}
.tab-add{width:30px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);cursor:pointer;border:none;background:none;font-size:16px;transition:color .15s}
.tab-add:hover{color:var(--text-secondary)}
/* Inline text editor */
.text-edit-overlay{position:absolute;z-index:15;display:none}
.text-edit-input{background:rgba(0,84,230,0.05);border:1.5px solid var(--accent);outline:none;padding:2px 4px;font-family:'Manrope',sans-serif;color:#000;resize:none;min-width:20px;white-space:pre;overflow:hidden;border-radius:2px;box-shadow:0 0 0 3px rgba(0,84,230,0.1)}
/* Context Menu */
.lp-layer{display:flex;align-items:center;gap:6px;padding:7px 10px;font-size:11px;font-family:'JetBrains Mono',monospace;color:var(--text-secondary);cursor:pointer;border-bottom:1px solid rgba(255,255,255,.04);transition:background .1s;font-weight:600}
.lp-layer:hover{background:rgba(255,255,255,.03)}
.lp-layer.active{background:var(--accent-bg);color:var(--accent);border-left:2px solid var(--accent)}
.lp-layer .lp-name{flex:1}
.lp-child{display:flex;align-items:center;gap:6px;padding:4px 10px 4px 24px;font-size:10px;font-family:'JetBrains Mono',monospace;color:var(--text-muted);cursor:pointer;border-bottom:1px solid rgba(255,255,255,.02);transition:background .1s}
.lp-child:hover{background:rgba(255,255,255,.03)}
.lp-child.selected{color:var(--accent);background:rgba(0,84,230,.05)}
.lp-add-layer{display:flex;align-items:center;justify-content:center;gap:4px;padding:8px;font-size:11px;font-family:'JetBrains Mono',monospace;color:var(--text-muted);cursor:pointer;border-top:1px solid var(--surface-600);transition:all .15s}
.lp-add-layer:hover{color:var(--text-secondary);background:rgba(255,255,255,.03)}
.lp-lock{opacity:.5;font-size:10px;cursor:pointer;padding:0 2px}.lp-lock:hover{opacity:1}.lp-lock.locked{opacity:1;color:var(--accent)}
.ctx-menu{position:absolute;z-index:50;background:var(--surface-700);border:1px solid var(--surface-600);border-radius:8px;padding:4px 0;min-width:180px;display:none;box-shadow:0 8px 24px rgba(0,0,0,.3)}
.ctx-menu.visible{display:block}
.ctx-item{display:flex;align-items:center;gap:8px;padding:6px 14px;font-size:12px;font-family:'JetBrains Mono',monospace;color:var(--text-secondary);cursor:pointer;transition:all .1s}
.ctx-item:hover{background:rgba(0,84,230,.1);color:var(--text-primary)}
.ctx-item.disabled{opacity:.3;pointer-events:none}
.ctx-sep{height:1px;background:var(--surface-600);margin:4px 0}
.ctx-shortcut{margin-left:auto;font-size:10px;color:var(--text-muted)}
@keyframes pulse-close{0%,100%{transform:scale(1)}50%{transform:scale(1.2)}}
.sh{position:absolute;width:8px;height:8px;background:#0054e6;border:1px solid #fff;border-radius:1px;pointer-events:auto;z-index:6}
.sel-box{position:absolute;border:1.5px solid var(--accent);background:rgba(0,84,230,0.05);pointer-events:none;z-index:5}
.sel-handle{position:absolute;width:8px;height:8px;background:var(--accent);border:1px solid #fff;border-radius:1px;z-index:6;cursor:pointer}
/* Properties Panel (slide-in from right) */
.props-panel{position:absolute;top:0;right:0;width:240px;height:100%;background:var(--surface-800);border-left:1px solid var(--surface-600);z-index:10;display:none;flex-direction:column;overflow-y:auto}
.props-panel.visible{display:flex}
.pp-header{padding:10px 14px;font-size:11px;font-weight:700;font-family:'JetBrains Mono',monospace;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid var(--surface-600);display:flex;justify-content:space-between;align-items:center}
.pp-close{background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:16px;line-height:1}
.pp-close:hover{color:var(--text-secondary)}
.pp-section{padding:10px 14px;border-bottom:1px solid var(--surface-600)}
.pp-section-title{font-size:10px;font-family:'JetBrains Mono',monospace;color:var(--text-muted);text-transform:uppercase;margin-bottom:8px;letter-spacing:.05em}
.pp-row{display:flex;align-items:center;gap:6px;margin-bottom:6px}
.pp-label{font-size:11px;font-family:'JetBrains Mono',monospace;color:var(--text-muted);width:24px;flex-shrink:0}
.pp-input{flex:1;background:var(--surface-600);border:1px solid transparent;color:var(--text-secondary);padding:4px 6px;border-radius:5px;font-family:'JetBrains Mono',monospace;font-size:11px;height:26px}
.pp-input:focus{border-color:var(--accent);outline:none}
.pp-color{width:26px;height:26px;border-radius:5px;border:1px solid var(--surface-600);cursor:pointer;padding:0;flex-shrink:0}
/* Layers Panel */
.layer-panel{position:absolute;top:0;right:0;width:220px;height:100%;background:var(--surface-800);border-left:1px solid var(--surface-600);z-index:10;display:none;flex-direction:column}
.layer-panel.visible{display:flex}
.lp-header{padding:8px 12px;font-size:11px;font-weight:700;font-family:'JetBrains Mono',monospace;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid var(--surface-600);display:flex;justify-content:space-between;align-items:center}
.lp-actions{display:flex;gap:2px}
.lp-act{width:24px;height:24px;border:none;border-radius:5px;background:transparent;color:var(--text-muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;transition:all .1s}
.lp-act:hover{color:var(--text-secondary);background:rgba(255,255,255,.05)}
.lp-list{flex:1;overflow-y:auto;scrollbar-width:thin}
.lp-item{display:flex;align-items:center;gap:6px;padding:6px 10px;font-size:11px;font-family:'JetBrains Mono',monospace;color:var(--text-secondary);cursor:pointer;border-bottom:1px solid rgba(255,255,255,.03);transition:background .1s}
.lp-item:hover{background:rgba(255,255,255,.03)}
.lp-item.selected{background:var(--accent-bg);color:var(--accent)}
.lp-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lp-btn{width:20px;height:20px;border:none;border-radius:4px;background:transparent;color:var(--text-muted);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .1s}
.lp-btn:hover{color:var(--text-secondary);background:rgba(255,255,255,.05)}
/* Status Bar */
.statusbar{height:28px;background:var(--surface-800);border-top:1px solid var(--surface-600);display:flex;align-items:center;padding:0 12px;gap:16px;flex-shrink:0;font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text-secondary)}
.statusbar span{white-space:nowrap}
.status-accent{color:var(--accent)}
/* AI Modal */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);backdrop-filter:blur(4px);z-index:200;display:none;align-items:center;justify-content:center}
.modal-overlay.visible{display:flex}
.modal{background:var(--surface-700);border:1px solid var(--surface-600);border-radius:12px;padding:24px;width:560px;max-width:90vw;max-height:85vh;overflow-y:auto}
.modal h3{font-size:16px;color:var(--text-primary);margin-bottom:16px;font-weight:600}
.modal-row{display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap}
.modal-row input,.modal-row select{background:var(--surface-600);border:1px solid transparent;color:var(--text-secondary);padding:6px 10px;border-radius:6px;font-size:12px}
.modal-row input:focus,.modal-row select:focus{border-color:var(--accent);outline:none}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
.modal-actions button{padding:6px 16px;border-radius:6px;border:1px solid var(--surface-600);background:transparent;color:var(--text-secondary);cursor:pointer;font-size:12px;font-weight:500;transition:all .15s}
.modal-actions button:hover{border-color:var(--text-muted)}
.modal-actions button.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
.modal-actions button.primary:hover{background:var(--accent-dim)}
.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(64px,1fr));gap:6px;max-height:280px;overflow-y:auto;padding:4px 0}
.icon-card{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 4px;border-radius:8px;border:1px solid transparent;cursor:pointer;transition:all .12s}
.icon-card:hover{border-color:var(--surface-600);background:rgba(255,255,255,.03)}
.icon-card.selected{border-color:var(--accent);background:var(--accent-bg)}
.icon-card span{font-size:9px;font-family:'JetBrains Mono',monospace;color:var(--text-muted);text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;width:100%}
/* Dropzone for SVG import */
.dropzone{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:10;cursor:pointer;transition:background .2s}
.dropzone:hover{background:rgba(0,84,230,0.03)}
.dropzone.drag-over{background:rgba(0,84,230,0.06);border:2px dashed var(--accent)}
.dropzone.hidden{display:none}
.dropzone-icon{width:64px;height:64px;border-radius:16px;background:var(--surface-700);border:2px dashed var(--surface-600);display:flex;align-items:center;justify-content:center;margin-bottom:16px;transition:all .3s}
.dropzone:hover .dropzone-icon{border-color:var(--accent);background:var(--accent-bg)}
.dropzone-icon svg{color:var(--text-muted);width:28px;height:28px}
.dropzone-text{font-size:15px;color:var(--text-secondary);margin-bottom:6px;font-weight:500}
.dropzone-hint{font-size:12px;color:var(--text-muted)}
</style>
</head>
<body>
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-logo">IW</div>
<div class="sidebar-nav">
<a href="/" class="sidebar-link" title="Главная"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg></a>
<a href="/compress" class="sidebar-link" title="Картинки"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg></a>
<a href="/md" class="sidebar-link" title="Markdown"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg></a>
<a href="/placeholder" class="sidebar-link" title="Placeholder"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714a2.25 2.25 0 00.659 1.591L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-2.47 2.47a2.25 2.25 0 01-1.59.659H9.06a2.25 2.25 0 01-1.591-.659L5 14.5m14 0V17a2.25 2.25 0 01-2.25 2.25H7.25A2.25 2.25 0 015 17v-2.5"/></svg></a>
<a href="/parser" class="sidebar-link" title="Parser"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.06a4.5 4.5 0 00-6.364-6.364L6.257 6.514a4.5 4.5 0 001.242 7.244"/></svg></a>
<a href="/password" class="sidebar-link" title="Password"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg></a>
<a href="/sanitizer" class="sidebar-link" title="Sanitizer"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg></a>
<a href="/converter" class="sidebar-link" title="Converter"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"/></svg></a>
<a href="/formatter" class="sidebar-link" title="Formatter"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/></svg></a>
<a href="/editor" class="sidebar-link" title="Editor"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"/></svg></a>
<a href="/httpclient" class="sidebar-link" title="HTTP Client"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"/></svg></a>
<a href="/redirects" class="sidebar-link" title="Redirect Analyzer"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg></a>
<a href="/svgeditor" class="sidebar-link active" title="SVG Редактор"><svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"/></svg></a>
</div>
</nav>
<!-- Editor -->
<div class="editor-wrap">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-group">
<button class="tb" id="btnNew" title="Новый холст (Ctrl+N)"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg></button>
<button class="tb" id="btnImport" title="Импорт SVG (Ctrl+O)"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"/></svg></button>
<button class="tb" id="btnExportSvg" title="Экспорт SVG (Ctrl+S)"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg></button>
<button class="tb" id="btnExportPng" title="Экспорт PNG"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg></button>
</div>
<div class="toolbar-sep"></div>
<!-- Drawing tools -->
<div class="toolbar-group" id="toolsGroup">
<button class="tb active" data-tool="select" title="Выделение (V)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672L13.684 16.6m0 0l-2.51 2.225.569-9.47 5.227 7.917-3.286-.672zM12 2.25V4.5m5.834.166l-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243l-1.59-1.59"/></svg></button>
<button class="tb" data-tool="rect" title="Прямоугольник (R)"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
<button class="tb" data-tool="circle" title="Эллипс (O)"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><ellipse cx="12" cy="12" rx="9" ry="9"/></svg></button>
<button class="tb" data-tool="line" title="Линия (L)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="5" y1="19" x2="19" y2="5" stroke-linecap="round"/></svg></button>
<button class="tb" data-tool="path" title="Перо (P)"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"/></svg></button>
<button class="tb" data-tool="text" title="Текст (T)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7 4h10M12 4v16m-4 0h8"/></svg></button>
</div>
<div class="toolbar-sep"></div>
<!-- Transform -->
<div class="toolbar-group">
<button class="tb" id="btnFront" title="На передний план"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 19.5v-15m0 0l-6.75 6.75M12 4.5l6.75 6.75"/></svg></button>
<button class="tb" id="btnBack" title="На задний план"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m0 0l6.75-6.75M12 19.5l-6.75-6.75"/></svg></button>
<button class="tb" id="btnDuplicate" title="Дублировать (Ctrl+D)"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75"/><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3h9.75a1.125 1.125 0 011.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125H8.25a1.125 1.125 0 01-1.125-1.125v-9.75c0-.621.504-1.125 1.125-1.125z"/></svg></button>
<button class="tb" id="btnDelete" title="Удалить (Del)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg></button>
<button class="tb" id="btnRasterize" title="Текст → контур (path)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg></button>
<button class="tb" id="btnOptimize" title="Оптимизировать SVG"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg></button>
</div>
<div class="toolbar-sep"></div>
<!-- Align -->
<div class="toolbar-group">
<button class="tb" id="btnAlignL" title="Выровнять по левому краю"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="4" y1="3" x2="4" y2="21"/><rect x="7" y="5" width="13" height="4" rx="1"/><rect x="7" y="13" width="8" height="4" rx="1"/></svg></button>
<button class="tb" id="btnAlignCX" title="Выровнять по центру (гориз.)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="12" y1="3" x2="12" y2="21" stroke-dasharray="2 2"/><rect x="5" y="5" width="14" height="4" rx="1"/><rect x="7" y="13" width="10" height="4" rx="1"/></svg></button>
<button class="tb" id="btnAlignR" title="Выровнять по правому краю"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="20" y1="3" x2="20" y2="21"/><rect x="4" y="5" width="13" height="4" rx="1"/><rect x="9" y="13" width="8" height="4" rx="1"/></svg></button>
<button class="tb" id="btnAlignT" title="Выровнять по верхнему краю"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="3" y1="4" x2="21" y2="4"/><rect x="5" y="7" width="4" height="13" rx="1"/><rect x="13" y="7" width="4" height="8" rx="1"/></svg></button>
<button class="tb" id="btnAlignCY" title="Выровнять по центру (верт.)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="3" y1="12" x2="21" y2="12" stroke-dasharray="2 2"/><rect x="5" y="5" width="4" height="14" rx="1"/><rect x="13" y="7" width="4" height="10" rx="1"/></svg></button>
<button class="tb" id="btnAlignB" title="Выровнять по нижнему краю"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="3" y1="20" x2="21" y2="20"/><rect x="5" y="4" width="4" height="13" rx="1"/><rect x="13" y="9" width="4" height="8" rx="1"/></svg></button>
</div>
<div class="toolbar-sep"></div>
<!-- Undo/Redo -->
<div class="toolbar-group">
<button class="tb" id="btnUndo" title="Отменить (Ctrl+Z)"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"/></svg></button>
<button class="tb" id="btnRedo" title="Повторить (Ctrl+Shift+Z)"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 000 12h3"/></svg></button>
</div>
<!-- Right side -->
<div class="toolbar-right">
<button class="tb" id="btnDevMode" title="SVG Код"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/></svg></button>
<button class="tb" id="btnAI" title="AI Генератор иконок"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z"/></svg></button>
<button class="tb" id="btnProps" title="Свойства"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"/></svg></button>
<button class="tb" id="btnLayers" title="Слои"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 2.25l9.75 5.25L12 12.75 2.25 7.5 12 2.25z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l9.75 5.25L21.75 12"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 16.5l9.75 5.25 9.75-5.25"/></svg></button>
<div class="toolbar-sep"></div>
<div class="color-swatch" id="fillSwatch" title="Заливка">
<input type="color" id="fillColor" value="#0054e6">
</div>
<div class="color-swatch" id="strokeSwatch" title="Обводка">
<input type="color" id="strokeColor" value="#333333">
</div>
<div class="toolbar-sep"></div>
<button class="tb" id="btnZoomOut" title="Zoom Out"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM13.5 10.5h-6"/></svg></button>
<span id="zoomLabel" style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-muted);min-width:40px;text-align:center">100%</span>
<button class="tb" id="btnZoomIn" title="Zoom In"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM10.5 7.5v6m3-3h-6"/></svg></button>
<button class="tb" id="btnFit" title="По размеру (Ctrl+0)"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m9 0h4.5v4.5m0 9v4.5h-4.5m-9 0H3.75v-4.5"/></svg></button>
</div>
</div>
<!-- Tool Options (context-sensitive) -->
<div class="tool-options" id="toolOptions"></div>
<!-- Tab Bar -->
<div class="tab-bar" id="tabBar"></div>
<!-- Viewport -->
<div class="viewport" id="viewport">
<svg id="svgCanvas" xmlns="http://www.w3.org/2000/svg" width="800" height="600" viewBox="0 0 800 600" ></svg>
<!-- Pen points preview -->
<div id="penPoints" style="display:none;position:absolute;pointer-events:none;z-index:4"></div>
<!-- Context Menu -->
<div class="ctx-menu" id="ctxMenu">
<div class="ctx-item" data-action="copy">Копировать<span class="ctx-shortcut">Ctrl+C</span></div>
<div class="ctx-item" data-action="cut">Вырезать<span class="ctx-shortcut">Ctrl+X</span></div>
<div class="ctx-item" data-action="paste">Вставить<span class="ctx-shortcut">Ctrl+V</span></div>
<div class="ctx-sep"></div>
<div class="ctx-item" data-action="duplicate">Дублировать<span class="ctx-shortcut">Ctrl+D</span></div>
<div class="ctx-item" data-action="delete">Удалить<span class="ctx-shortcut">Del</span></div>
<div class="ctx-sep"></div>
<div class="ctx-item" data-action="front">На передний план</div>
<div class="ctx-item" data-action="back">На задний план</div>
<div class="ctx-sep"></div>
<div class="ctx-item" data-action="group">Группировать<span class="ctx-shortcut">Ctrl+G</span></div>
<div class="ctx-item" data-action="ungroup">Разгруппировать<span class="ctx-shortcut">Ctrl+Shift+G</span></div>
<div class="ctx-sep"></div>
<div class="ctx-item" data-action="rasterize">Текст → контур</div>
</div>
<!-- Marquee selection -->
<div id="marquee" style="display:none;position:absolute;border:1.5px dashed #0054e6;background:rgba(0,84,230,0.08);pointer-events:none;z-index:4;border-radius:2px"></div>
<!-- Inline text editor -->
<div class="text-edit-overlay" id="textEditOverlay"><textarea class="text-edit-input" id="textEditInput"></textarea></div>
<!-- Selection handles -->
<div id="selHandles" style="display:none;position:absolute;pointer-events:none;z-index:5">
<div style="position:absolute;inset:-1px;border:1.5px solid #0054e6;pointer-events:none"></div>
<div class="sh" data-h="nw" style="top:-4px;left:-4px;cursor:nw-resize"></div>
<div class="sh" data-h="n" style="top:-4px;left:50%;margin-left:-4px;cursor:n-resize"></div>
<div class="sh" data-h="ne" style="top:-4px;right:-4px;cursor:ne-resize"></div>
<div class="sh" data-h="e" style="top:50%;margin-top:-4px;right:-4px;cursor:e-resize"></div>
<div class="sh" data-h="se" style="bottom:-4px;right:-4px;cursor:se-resize"></div>
<div class="sh" data-h="s" style="bottom:-4px;left:50%;margin-left:-4px;cursor:s-resize"></div>
<div class="sh" data-h="sw" style="bottom:-4px;left:-4px;cursor:sw-resize"></div>
<div class="sh" data-h="w" style="top:50%;margin-top:-4px;left:-4px;cursor:w-resize"></div>
</div>
<!-- Drop zone -->
<div class="dropzone hidden" id="dropzone">
<div class="dropzone-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"/></svg>
</div>
<div class="dropzone-text">Начните рисовать или импортируйте SVG</div>
<div class="dropzone-hint">Выберите инструмент / Ctrl+O / Drag & Drop</div>
</div>
<!-- Properties Panel -->
<div class="props-panel" id="propsPanel">
<div class="pp-header"><span>Свойства</span><button class="pp-close" id="propsClose">&times;</button></div>
<div id="propsBody"></div>
</div>
<!-- Layers Panel -->
<div class="layer-panel" id="layerPanel">
<div class="lp-header">
<span>Слои</span>
<div class="lp-actions">
<button class="lp-act" id="lpClear" title="Очистить">&times;</button>
</div>
</div>
<div class="lp-list" id="layerList"></div>
<div class="lp-add-layer" id="lpAddLayer">+ Новый слой</div>
</div>
</div>
<!-- Status Bar -->
<div class="statusbar">
<span id="stSize">800 × 600</span>
<span id="stZoom" class="status-accent">100%</span>
<span id="stCoords">0, 0</span>
<span id="stCount">0 элементов</span>
<span id="stStatus" style="margin-left:auto">Готово</span>
</div>
</div>
<!-- AI Modal -->
<div class="modal-overlay" id="aiModal">
<div class="modal">
<h3>AI Генератор иконок</h3>
<div class="modal-row">
<input id="aiSearch" type="text" style="flex:1;min-width:150px" placeholder="Поиск: home, star, user...">
<select id="aiStyle" style="width:100px"><option value="outline">Контур</option><option value="filled">Заливка</option><option value="duotone">Дуотон</option></select>
<select id="aiSize" style="width:70px"><option value="24">24</option><option value="32">32</option><option value="48" selected>48</option><option value="64">64</option><option value="128">128</option></select>
<input id="aiColor" type="color" value="#0054e6" style="width:34px;height:34px;border-radius:6px;border:1px solid var(--surface-600);cursor:pointer;padding:0">
</div>
<div class="icon-grid" id="aiGrid"></div>
<div class="modal-actions">
<button id="aiCancel">Отмена</button>
<button class="primary" id="aiAdd">Добавить на холст</button>
</div>
</div>
</div>
<!-- New Canvas Modal -->
<div class="modal-overlay" id="newModal">
<div class="modal" style="width:340px">
<h3>Новый холст</h3>
<div class="modal-row">
<label style="width:60px;font-size:12px;color:var(--text-muted)">Ширина</label>
<input id="newW" type="number" value="800" min="100" max="4000" style="flex:1">
<span style="font-size:11px;color:var(--text-muted)">px</span>
</div>
<div class="modal-row">
<label style="width:60px;font-size:12px;color:var(--text-muted)">Высота</label>
<input id="newH" type="number" value="600" min="100" max="4000" style="flex:1">
<span style="font-size:11px;color:var(--text-muted)">px</span>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px">
<button class="opt-btn" onclick="document.getElementById('newW').value=1920;document.getElementById('newH').value=1080">1920×1080</button>
<button class="opt-btn" onclick="document.getElementById('newW').value=1080;document.getElementById('newH').value=1080">1080×1080</button>
<button class="opt-btn" onclick="document.getElementById('newW').value=800;document.getElementById('newH').value=600">800×600</button>
<button class="opt-btn" onclick="document.getElementById('newW').value=512;document.getElementById('newH').value=512">512×512</button>
<button class="opt-btn" onclick="document.getElementById('newW').value=24;document.getElementById('newH').value=24">24×24</button>
<button class="opt-btn" onclick="document.getElementById('newW').value=48;document.getElementById('newH').value=48">48×48</button>
</div>
<div class="modal-actions">
<button id="newCancel">Отмена</button>
<button class="primary" id="newCreate">Создать</button>
</div>
</div>
</div>
<!-- Dev Mode Modal -->
<div class="modal-overlay" id="devModal">
<div class="modal" style="width:700px;max-height:90vh">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="margin:0">SVG Код</h3>
<div style="display:flex;gap:6px">
<button class="opt-btn" id="devCopy">Копировать</button>
<button class="opt-btn" id="devClose">&times;</button>
</div>
</div>
<textarea id="devCode" style="width:100%;height:60vh;background:var(--surface-600);border:1px solid var(--surface-600);color:var(--text-secondary);font-family:'JetBrains Mono',monospace;font-size:11px;padding:12px;border-radius:8px;resize:none;outline:none;tab-size:2" spellcheck="false"></textarea>
</div>
</div>
<input type="file" id="importFile" accept=".svg" style="display:none">
<script>
(function(){
const svg = document.getElementById('svgCanvas');
const viewport = document.getElementById('viewport');
const dropzone = document.getElementById('dropzone');
let currentTool = 'select';
let selectedEl = null;
let history = [], historyIdx = -1;
const MAX_HISTORY = 30;
let drawing = false, startX, startY, tempEl = null, pathPoints = [];
let zoom = 1, panX = 0, panY = 0;
let panning = false, panStartX, panStartY;
let fillCol = '#0054e6', strokeCol = '#333333', strokeW = 2;
// ── Position SVG ──
function positionCanvas() {
const vw = viewport.clientWidth, vh = viewport.clientHeight;
const cw = 800 * zoom, ch = 600 * zoom;
const x = Math.max(0, (vw - cw) / 2) + panX;
const y = Math.max(0, (vh - ch) / 2) + panY;
svg.style.left = x + 'px';
svg.style.top = y + 'px';
svg.style.width = cw + 'px';
svg.style.height = ch + 'px';
}
positionCanvas();
window.addEventListener('resize', () => { positionCanvas(); showHandles(); });
// ── Zoom ──
function setZoom(z) {
zoom = Math.max(0.1, Math.min(10, z));
positionCanvas();
const pct = Math.round(zoom * 100) + '%';
document.getElementById('zoomLabel').textContent = pct;
document.getElementById('stZoom').textContent = pct;
if (selectedEl) showHandles();
}
document.getElementById('btnZoomIn').addEventListener('click', () => setZoom(zoom * 1.25));
document.getElementById('btnZoomOut').addEventListener('click', () => setZoom(zoom / 1.25));
document.getElementById('btnFit').addEventListener('click', () => { panX = 0; panY = 0; setZoom(Math.min(viewport.clientWidth / 840, viewport.clientHeight / 640)); });
viewport.addEventListener('wheel', (e) => { e.preventDefault(); setZoom(zoom * (e.deltaY < 0 ? 1.08 : 0.92)); }, { passive: false });
// ── Pan (Space + drag) ──
document.addEventListener('keydown', (e) => {
if (e.code === 'Space' && !e.repeat && document.activeElement.tagName !== 'INPUT') {
e.preventDefault(); svg.style.cursor = 'grab';
}
});
document.addEventListener('keyup', (e) => {
if (e.code === 'Space') { svg.style.cursor = currentTool === 'select' ? 'default' : 'crosshair'; }
});
viewport.addEventListener('mousedown', (e) => {
// Only pan on middle-click, space+click, or click on empty viewport area
const onCanvas = svg.contains(e.target) || e.target === svg;
const spaceHeld = e.getModifierState && e.getModifierState('Space');
if (e.button === 1 || (e.button === 0 && spaceHeld) || (e.button === 0 && e.target === viewport)) {
panning = true; panStartX = e.clientX - panX; panStartY = e.clientY - panY;
svg.style.cursor = 'grabbing'; e.preventDefault();
}
});
viewport.addEventListener('mousemove', (e) => {
if (panning) { panX = e.clientX - panStartX; panY = e.clientY - panStartY; positionCanvas(); if (selectedEl) showHandles(); return; }
// Update coords
try {
const pos = getMousePos(e);
if (isFinite(pos.x) && isFinite(pos.y)) {
document.getElementById('stCoords').textContent = `${Math.round(pos.x)}, ${Math.round(pos.y)}`;
}
} catch(ex) {}
});
viewport.addEventListener('mouseup', () => {
if (panning) { panning = false; svg.style.cursor = currentTool === 'select' ? 'default' : 'crosshair'; }
});
// ── History ──
function pushHistory() {
historyIdx++;
history = history.slice(0, historyIdx);
history.push(svg.innerHTML);
if (history.length > MAX_HISTORY) { history.shift(); historyIdx--; }
updateLayers();
updateCount();
if (dropzone && svg.children.length > 0) dropzone.classList.add('hidden');
}
function undo() { if (historyIdx > 0) { historyIdx--; svg.innerHTML = history[historyIdx]; selectedEl = null; updateProps(); updateLayers(); updateCount(); } }
function redo() { if (historyIdx < history.length - 1) { historyIdx++; svg.innerHTML = history[historyIdx]; selectedEl = null; updateProps(); updateLayers(); updateCount(); } }
function updateCount() {
let count = 0;
svg.querySelectorAll('g[data-layer]').forEach(l => { count += l.children.length; });
document.getElementById('stCount').textContent = count + ' элементов';
}
// ── Layer System ──
let activeLayer = null;
function initLayers() {
if (!svg.querySelector('g[data-layer]')) {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('data-layer', 'true');
g.setAttribute('data-name', 'Слой 1');
// Move existing children into the layer
while (svg.firstChild) g.appendChild(svg.firstChild);
svg.appendChild(g);
}
activeLayer = svg.querySelector('g[data-layer]');
}
initLayers();
function isLayerLocked(layer) {
return layer && layer.getAttribute('data-locked') === '1';
}
function getActiveLayer() {
if (!activeLayer || !svg.contains(activeLayer)) {
activeLayer = svg.querySelector('g[data-layer]');
if (!activeLayer) {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('data-layer', 'true');
g.setAttribute('data-name', 'Слой 1');
svg.appendChild(g);
activeLayer = g;
}
}
return activeLayer;
}
function addLayer(name) {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('data-layer', 'true');
g.setAttribute('data-name', name || 'Слой ' + (svg.querySelectorAll('g[data-layer]').length + 1));
svg.appendChild(g);
activeLayer = g;
pushHistory();
updateLayers();
return g;
}
pushHistory();
// ── Tools ──
document.querySelectorAll('#toolsGroup .tb').forEach(btn => {
btn.addEventListener('click', () => {
currentTool = btn.dataset.tool;
document.querySelectorAll('#toolsGroup .tb').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
svg.style.cursor = currentTool === 'select' ? 'default' : 'crosshair';
pathPoints = []; if (typeof hidePenPoints === 'function') hidePenPoints();
updateToolOptions();
});
});
// ── Tool Options ──
let noFill = false, noStroke = false, fillOpacity = 1;
function updateToolOptions() {
const bar = document.getElementById('toolOptions');
if (currentTool === 'rect' || currentTool === 'circle' || currentTool === 'line' || currentTool === 'path') {
bar.innerHTML = `
<label>Заливка:</label>
<input type="color" id="optFill" value="${fillCol}" style="width:26px;height:26px;border-radius:5px;border:1px solid var(--surface-600);cursor:pointer;padding:0" ${noFill?'disabled':''}>
<button class="opt-btn ${noFill?'':'primary'}" id="optNoFill" style="font-size:10px;padding:2px 6px" title="Без заливки">${noFill?'✗':'✓'}</button>
<div class="opt-sep"></div>
<label>Непр.:</label>
<input type="range" id="optFillOp" min="0" max="1" step="0.05" value="${fillOpacity}" style="width:60px">
<span id="optFillOpVal" style="font-size:10px;color:var(--text-muted);min-width:28px">${Math.round(fillOpacity*100)}%</span>
<div class="opt-sep"></div>
<label>Обводка:</label>
<input type="color" id="optStroke" value="${strokeCol}" style="width:26px;height:26px;border-radius:5px;border:1px solid var(--surface-600);cursor:pointer;padding:0" ${noStroke?'disabled':''}>
<button class="opt-btn ${noStroke?'':'primary'}" id="optNoStroke" style="font-size:10px;padding:2px 6px" title="Без обводки">${noStroke?'✗':'✓'}</button>
<div class="opt-sep"></div>
<label>Толщина:</label>
<input type="number" id="optStrokeW" value="${strokeW}" min="0" max="50" step="0.5" ${noStroke?'disabled':''}>
`;
bar.classList.add('visible');
document.getElementById('optFill').addEventListener('input', (e) => { fillCol = e.target.value; document.getElementById('fillColor').value = fillCol; applyToSelected(); });
document.getElementById('optStroke').addEventListener('input', (e) => { strokeCol = e.target.value; document.getElementById('strokeColor').value = strokeCol; applyToSelected(); });
document.getElementById('optStrokeW').addEventListener('input', (e) => { strokeW = parseFloat(e.target.value) || 0; applyToSelected(); });
document.getElementById('optFillOp').addEventListener('input', (e) => {
fillOpacity = parseFloat(e.target.value);
document.getElementById('optFillOpVal').textContent = Math.round(fillOpacity*100)+'%';
applyToSelected();
});
document.getElementById('optNoFill').addEventListener('click', () => { noFill = !noFill; updateToolOptions(); applyToSelected(); });
document.getElementById('optNoStroke').addEventListener('click', () => { noStroke = !noStroke; updateToolOptions(); applyToSelected(); });
} else if (currentTool === 'text') {
bar.innerHTML = `
<label>Размер:</label><input type="number" id="optFontSize" value="20" min="6" max="400" style="width:50px">
<div class="opt-sep"></div>
<label>Шрифт:</label><select id="optFontFamily" style="width:120px">
<option value="Manrope, sans-serif">Manrope</option>
<option value="'JetBrains Mono', monospace">JetBrains Mono</option>
<option value="Arial, sans-serif">Arial</option>
<option value="Georgia, serif">Georgia</option>
<option value="'Times New Roman', serif">Times New Roman</option>
<option value="'Courier New', monospace">Courier New</option>
<option value="Verdana, sans-serif">Verdana</option>
<option value="Impact, sans-serif">Impact</option>
</select>
<div class="opt-sep"></div>
<label>Цвет:</label><input type="color" id="optTextColor" value="${fillCol}" style="width:26px;height:26px;border-radius:5px;border:1px solid var(--surface-600);cursor:pointer;padding:0">
<div class="opt-sep"></div>
<label>Межбукв.:</label><input type="number" id="optLetterSpacing" value="0" min="-5" max="20" step="0.5" style="width:50px">
<div class="opt-sep"></div>
<label>Жирный:</label><select id="optFontWeight" style="width:70px">
<option value="400">400</option>
<option value="500">500</option>
<option value="600">600</option>
<option value="700" selected>700</option>
<option value="800">800</option>
<option value="900">900</option>
</select>
`;
bar.classList.add('visible');
} else if (currentTool === 'select' && selectedEl) {
// Show selected element options
const tag = selectedEl.tagName;
if (tag === 'rect' || tag === 'ellipse' || tag === 'line' || tag === 'path') {
const elFill = selectedEl.getAttribute('fill') || 'none';
const elStroke = selectedEl.getAttribute('stroke') || 'none';
const elSW = selectedEl.getAttribute('stroke-width') || '2';
const elOp = selectedEl.getAttribute('fill-opacity') || '1';
const isNoFill = elFill === 'none';
const isNoStroke = elStroke === 'none';
bar.innerHTML = `
<label>Заливка:</label>
<input type="color" id="optFill" value="${isNoFill?'#000000':elFill}" style="width:26px;height:26px;border-radius:5px;border:1px solid var(--surface-600);cursor:pointer;padding:0" ${isNoFill?'disabled':''}>
<button class="opt-btn ${isNoFill?'':'primary'}" id="optNoFill" style="font-size:10px;padding:2px 6px">${isNoFill?'✗':'✓'}</button>
<div class="opt-sep"></div>
<label>Непр.:</label>
<input type="range" id="optFillOp" min="0" max="1" step="0.05" value="${elOp}" style="width:60px">
<span id="optFillOpVal" style="font-size:10px;color:var(--text-muted);min-width:28px">${Math.round(parseFloat(elOp)*100)}%</span>
<div class="opt-sep"></div>
<label>Обводка:</label>
<input type="color" id="optStroke" value="${isNoStroke?'#000000':elStroke}" style="width:26px;height:26px;border-radius:5px;border:1px solid var(--surface-600);cursor:pointer;padding:0" ${isNoStroke?'disabled':''}>
<button class="opt-btn ${isNoStroke?'':'primary'}" id="optNoStroke" style="font-size:10px;padding:2px 6px">${isNoStroke?'✗':'✓'}</button>
<div class="opt-sep"></div>
<label>Толщина:</label>
<input type="number" id="optStrokeW" value="${elSW}" min="0" max="50" step="0.5" ${isNoStroke?'disabled':''}>
`;
bar.classList.add('visible');
document.getElementById('optFill').addEventListener('input', (e) => { selectedEl.setAttribute('fill', e.target.value); });
document.getElementById('optStroke').addEventListener('input', (e) => { selectedEl.setAttribute('stroke', e.target.value); });
document.getElementById('optStrokeW').addEventListener('input', (e) => { selectedEl.setAttribute('stroke-width', e.target.value); });
document.getElementById('optFillOp').addEventListener('input', (e) => {
selectedEl.setAttribute('fill-opacity', e.target.value);
document.getElementById('optFillOpVal').textContent = Math.round(parseFloat(e.target.value)*100)+'%';
});
document.getElementById('optNoFill').addEventListener('click', () => {
const cur = selectedEl.getAttribute('fill');
if (cur === 'none') { selectedEl.setAttribute('fill', fillCol); } else { selectedEl.setAttribute('fill', 'none'); }
pushHistory(); updateToolOptions();
});
document.getElementById('optNoStroke').addEventListener('click', () => {
const cur = selectedEl.getAttribute('stroke');
if (cur === 'none') { selectedEl.setAttribute('stroke', strokeCol); selectedEl.setAttribute('stroke-width', strokeW); } else { selectedEl.setAttribute('stroke', 'none'); }
pushHistory(); updateToolOptions();
});
} else if (tag === 'text') {
const elFS = selectedEl.getAttribute('font-size') || '20';
const elFF = selectedEl.getAttribute('font-family') || 'Manrope, sans-serif';
const elFill = selectedEl.getAttribute('fill') || '#000';
const elLS = selectedEl.getAttribute('letter-spacing') || '0';
const elFW = selectedEl.getAttribute('font-weight') || '400';
bar.innerHTML = `
<label>Размер:</label><input type="number" id="optFontSize" value="${elFS}" min="6" max="400" style="width:50px">
<div class="opt-sep"></div>
<label>Шрифт:</label><select id="optFontFamily" style="width:120px">
<option value="Manrope, sans-serif" ${elFF.includes('Manrope')?'selected':''}>Manrope</option>
<option value="'JetBrains Mono', monospace" ${elFF.includes('JetBrains')?'selected':''}>JetBrains Mono</option>
<option value="Arial, sans-serif" ${elFF.includes('Arial')?'selected':''}>Arial</option>
<option value="Georgia, serif" ${elFF.includes('Georgia')?'selected':''}>Georgia</option>
<option value="'Times New Roman', serif" ${elFF.includes('Times')?'selected':''}>Times New Roman</option>
<option value="'Courier New', monospace" ${elFF.includes('Courier')?'selected':''}>Courier New</option>
<option value="Verdana, sans-serif" ${elFF.includes('Verdana')?'selected':''}>Verdana</option>
<option value="Impact, sans-serif" ${elFF.includes('Impact')?'selected':''}>Impact</option>
</select>
<div class="opt-sep"></div>
<label>Цвет:</label><input type="color" id="optTextColor" value="${elFill}" style="width:26px;height:26px;border-radius:5px;border:1px solid var(--surface-600);cursor:pointer;padding:0">
<div class="opt-sep"></div>
<label>Межбукв.:</label><input type="number" id="optLetterSpacing" value="${elLS}" min="-5" max="20" step="0.5" style="width:50px">
<div class="opt-sep"></div>
<label>Жирность:</label><select id="optFontWeight" style="width:70px">
<option value="400" ${elFW==='400'?'selected':''}>400</option>
<option value="500" ${elFW==='500'?'selected':''}>500</option>
<option value="600" ${elFW==='600'?'selected':''}>600</option>
<option value="700" ${elFW==='700'?'selected':''}>700</option>
<option value="800" ${elFW==='800'?'selected':''}>800</option>
<option value="900" ${elFW==='900'?'selected':''}>900</option>
</select>
`;
bar.classList.add('visible');
document.getElementById('optFontSize').addEventListener('input', (e) => { selectedEl.setAttribute('font-size', e.target.value); showHandles(); });
document.getElementById('optFontFamily').addEventListener('change', (e) => { selectedEl.setAttribute('font-family', e.target.value); showHandles(); });
document.getElementById('optTextColor').addEventListener('input', (e) => { selectedEl.setAttribute('fill', e.target.value); });
document.getElementById('optLetterSpacing').addEventListener('input', (e) => { selectedEl.setAttribute('letter-spacing', e.target.value); showHandles(); });
document.getElementById('optFontWeight').addEventListener('change', (e) => { selectedEl.setAttribute('font-weight', e.target.value); showHandles(); });
} else {
bar.innerHTML = '';
bar.classList.remove('visible');
}
} else {
bar.innerHTML = '';
bar.classList.remove('visible');
}
}
function applyToSelected() {
if (!selectedEl) return;
const tag = selectedEl.tagName;
if (tag === 'rect' || tag === 'ellipse' || tag === 'line' || tag === 'path') {
selectedEl.setAttribute('fill', noFill ? 'none' : fillCol);
selectedEl.setAttribute('fill-opacity', fillOpacity);
selectedEl.setAttribute('stroke', noStroke ? 'none' : strokeCol);
selectedEl.setAttribute('stroke-width', strokeW);
pushHistory();
}
}
// ── SVG Helpers ──
function svgEl(tag, attrs) {
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
return el;
}
function getMousePos(e) {
const rect = svg.getBoundingClientRect();
return { x: (e.clientX - rect.left) / zoom, y: (e.clientY - rect.top) / zoom };
}
// ── Drawing ──
// Pen tool point markers
const penPointsDiv = document.getElementById('penPoints');
function showPenPoints() {
if (pathPoints.length === 0) { hidePenPoints(); return; }
const svgRect = svg.getBoundingClientRect();
const vpRect = viewport.getBoundingClientRect();
const ox = svgRect.left - vpRect.left;
const oy = svgRect.top - vpRect.top;
let html = '';
pathPoints.forEach((p, i) => {
const isFirst = i === 0;
const closable = isFirst && pathPoints.length > 2;
const sz = closable ? 14 : 8;
const off = closable ? 7 : 4;
const style = `position:absolute;left:${ox + p.x * zoom - off}px;top:${oy + p.y * zoom - off}px;width:${sz}px;height:${sz}px;border-radius:${isFirst?'50%':'1px'};background:${isFirst?'#ff5252':'#0054e6'};border:${closable?'2':'1'}px solid #fff;z-index:5;${closable ? 'cursor:pointer;pointer-events:auto;box-shadow:0 0 8px rgba(255,82,82,.5);' : ''}transition:transform .15s;${closable ? 'animation:pulse-close 1s infinite;' : ''}`;
html += '<div style="' + style + '" ' + (isFirst ? 'title="Замкнуть контур"' : '') + '></div>';
});
penPointsDiv.style.display = 'block';
penPointsDiv.innerHTML = html;
}
function hidePenPoints() {
penPointsDiv.style.display = 'none';
penPointsDiv.innerHTML = '';
}
// Find nearest SVG element within tolerance (for thin lines etc)
function findNearestElement(px, py, tolerance) {
let best = null, bestDist = tolerance;
const svgR = svg.getBoundingClientRect();
// Search all elements in all layers
const allEls = [];
svg.querySelectorAll('g[data-layer]').forEach(layer => {
if (layer.getAttribute('visibility') === 'hidden') return;
Array.from(layer.children).forEach(el => allEls.push(el));
});
allEls.forEach(el => {
if (el.getAttribute('visibility') === 'hidden') return;
let dist = Infinity;
try {
// Use visual bounds (accounts for transforms)
const elR = el.getBoundingClientRect();
if (elR.width === 0 && elR.height === 0) return;
// Convert element visual bounds to SVG coords
const vx = (elR.left - svgR.left) / zoom;
const vy = (elR.top - svgR.top) / zoom;
const vw = elR.width / zoom;
const vh = elR.height / zoom;
if (px >= vx && px <= vx + vw && py >= vy && py <= vy + vh) {
dist = 0;
} else {
const cx = Math.max(vx, Math.min(px, vx + vw));
const cy = Math.max(vy, Math.min(py, vy + vh));
dist = Math.hypot(px - cx, py - cy);
}
} catch(ex) { return; }
if (dist < bestDist) { bestDist = dist; best = el; }
});
return best;
}
function pointToSegDist(px, py, x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(px - x1, py - y1);
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}
function distToBBox(px, py, bb) {
const cx = Math.max(bb.x, Math.min(px, bb.x + bb.width));
const cy = Math.max(bb.y, Math.min(py, bb.y + bb.height));
return Math.hypot(px - cx, py - cy);
}
svg.addEventListener('mousedown', (e) => {
if (e.button !== 0 || panning) return;
const pos = getMousePos(e);
startX = pos.x; startY = pos.y;
if (currentTool === 'select') {
// Hide handles to avoid hitting them
const hVis = selHandles.style.display;
const lhVis = lineHandles.style.display;
selHandles.style.display = 'none';
lineHandles.style.display = 'none';
let target = document.elementFromPoint(e.clientX, e.clientY);
selHandles.style.display = hVis;
lineHandles.style.display = lhVis;
// Walk up to direct child of layer (stop at layer <g>)
if (target && target !== svg && svg.contains(target)) {
while (target.parentElement && target.parentElement.getAttribute &&
!target.parentElement.getAttribute('data-layer') &&
target.parentElement !== svg) {
target = target.parentElement;
}
}
// If elementFromPoint missed but we already have a selected element,
// check if click is within its visual bounds → re-use it for drag
if ((!target || target === svg) && selectedEl) {
const selR = selectedEl.getBoundingClientRect();
if (e.clientX >= selR.left - 5 && e.clientX <= selR.right + 5 &&
e.clientY >= selR.top - 5 && e.clientY <= selR.bottom + 5) {
target = selectedEl;
}
}
if (target && target !== svg && svg.contains(target)) {
// Direct hit on element → select & start drag
selectElement(target);
drawing = true;
const tag = selectedEl.tagName;
let ox, oy;
if (tag === 'rect' || tag === 'text' || tag === 'image') {
ox = parseFloat(selectedEl.getAttribute('x') || 0);
oy = parseFloat(selectedEl.getAttribute('y') || 0);
} else if (tag === 'ellipse' || tag === 'circle') {
ox = parseFloat(selectedEl.getAttribute('cx') || 0);
oy = parseFloat(selectedEl.getAttribute('cy') || 0);
} else if (tag === 'line') {
ox = parseFloat(selectedEl.getAttribute('x1') || 0);
oy = parseFloat(selectedEl.getAttribute('y1') || 0);
} else {
ox = 0; oy = 0;
const t = selectedEl.getAttribute('transform') || '';
const m = t.match(/translate\(\s*([^,)\s]+)\s*,?\s*([^)\s]*)/);
if (m) { ox = parseFloat(m[1]) || 0; oy = parseFloat(m[2]) || 0; }
}
selectedEl._drag = { x: startX, y: startY, ox, oy };
if (selectedEl.tagName === 'line') {
selectedEl._drag.ox2 = parseFloat(selectedEl.getAttribute('x2'));
selectedEl._drag.oy2 = parseFloat(selectedEl.getAttribute('y2'));
}
} else {
// Empty space → start marquee (proximity check deferred to mouseup)
deselectAll();
drawing = true;
tempEl = null;
selectedEl = null;
const marquee = document.getElementById('marquee');
marquee._start = { x: e.clientX, y: e.clientY, sx: pos.x, sy: pos.y };
marquee.style.display = 'block';
const vpR = viewport.getBoundingClientRect();
marquee.style.left = (e.clientX - vpR.left) + 'px';
marquee.style.top = (e.clientY - vpR.top) + 'px';
marquee.style.width = '0px';
marquee.style.height = '0px';
}
return;
}
if (currentTool === 'text') {
const text = prompt('Введите текст:');
if (text) {
const fs = document.getElementById('optFontSize')?.value || 20;
const col = document.getElementById('optTextColor')?.value || fillCol;
const ff = document.getElementById('optFontFamily')?.value || 'Manrope, sans-serif';
const ls = document.getElementById('optLetterSpacing')?.value || 0;
const fw = document.getElementById('optFontWeight')?.value || 700;
const el = svgEl('text', { x: pos.x, y: pos.y, fill: col, 'font-size': fs, 'font-family': ff, 'font-weight': fw, 'letter-spacing': ls, 'data-name': 'Текст' });
el.textContent = text;
getActiveLayer().appendChild(el); pushHistory(); selectElement(el);
}
return;
}
if (currentTool === 'path') {
// Check if clicking near first point to close path
if (pathPoints.length > 2) {
const fp = pathPoints[0];
const dist = Math.hypot(pos.x - fp.x, pos.y - fp.y);
if (dist < 20) {
// Close the path
let d = `M${pathPoints[0].x},${pathPoints[0].y}`;
for (let i = 1; i < pathPoints.length; i++) d += ` L${pathPoints[i].x},${pathPoints[i].y}`;
d += ' Z';
tempEl.setAttribute('d', d);
tempEl.setAttribute('fill', noFill ? 'none' : fillCol);
tempEl.setAttribute('fill-opacity', fillOpacity);
pushHistory();
const drawnPath = tempEl; tempEl = null; pathPoints = [];
hidePenPoints();
currentTool = 'select';
document.querySelectorAll('#toolsGroup .tb').forEach(b => b.classList.remove('active'));
document.querySelector('[data-tool="select"]').classList.add('active');
svg.style.cursor = 'default';
updateToolOptions();
selectElement(drawnPath);
return;
}
}
pathPoints.push(pos);
if (pathPoints.length === 1) {
tempEl = svgEl('path', { d: `M${pos.x},${pos.y}`, fill: 'none', stroke: noStroke ? '#333' : strokeCol, 'stroke-width': strokeW, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'data-name': 'Путь' });
getActiveLayer().appendChild(tempEl);
} else {
let d = `M${pathPoints[0].x},${pathPoints[0].y}`;
for (let i = 1; i < pathPoints.length; i++) d += ` L${pathPoints[i].x},${pathPoints[i].y}`;
tempEl.setAttribute('d', d);
}
showPenPoints();
return;
}
drawing = true;
const f = (currentTool === 'line' || noFill) ? 'none' : fillCol;
const fOpacity = (currentTool === 'line') ? '1' : '0.15';
if (currentTool === 'rect') {
tempEl = svgEl('rect', { x: pos.x, y: pos.y, width: 0, height: 0, fill: f, 'fill-opacity': fOpacity, stroke: strokeCol, 'stroke-width': strokeW, rx: '0', 'data-name': 'Прямоугольник' });
} else if (currentTool === 'circle') {
tempEl = svgEl('ellipse', { cx: pos.x, cy: pos.y, rx: 0, ry: 0, fill: f, 'fill-opacity': fOpacity, stroke: strokeCol, 'stroke-width': strokeW, 'data-name': 'Эллипс' });
} else if (currentTool === 'line') {
tempEl = svgEl('line', { x1: pos.x, y1: pos.y, x2: pos.x, y2: pos.y, stroke: strokeCol, 'stroke-width': strokeW, 'stroke-linecap': 'round', 'data-name': 'Линия' });
}
if (tempEl) getActiveLayer().appendChild(tempEl);
});
document.addEventListener('mousemove', (e) => {
if (panning) return;
// Handle select+drag separately (tempEl is null during drag)
// Marquee selection drag
const marquee = document.getElementById('marquee');
if (drawing && currentTool === 'select' && !selectedEl && marquee._start) {
const ms = marquee._start;
const vpRect = viewport.getBoundingClientRect();
const cx = e.clientX, cy = e.clientY;
const left = Math.min(ms.x, cx) - vpRect.left;
const top = Math.min(ms.y, cy) - vpRect.top;
const w = Math.abs(cx - ms.x);
const h = Math.abs(cy - ms.y);
marquee.style.left = left + 'px';
marquee.style.top = top + 'px';
marquee.style.width = w + 'px';
marquee.style.height = h + 'px';
return;
}
if (drawing && currentTool === 'select' && selectedEl?._drag) {
const pos = getMousePos(e);
const d = selectedEl._drag, ddx = pos.x - d.x, ddy = pos.y - d.y, tag = selectedEl.tagName;
if (tag === 'rect' || tag === 'text' || tag === 'image') { selectedEl.setAttribute('x', d.ox + ddx); selectedEl.setAttribute('y', d.oy + ddy); }
else if (tag === 'ellipse' || tag === 'circle') { selectedEl.setAttribute('cx', d.ox + ddx); selectedEl.setAttribute('cy', d.oy + ddy); }
else if (tag === 'line') { selectedEl.setAttribute('x1', d.ox + ddx); selectedEl.setAttribute('y1', d.oy + ddy); selectedEl.setAttribute('x2', d.ox2 + ddx); selectedEl.setAttribute('y2', d.oy2 + ddy); }
else {
const origT = selectedEl.getAttribute('transform') || '';
const rest = origT.replace(/translate\([^)]*\)/, '').trim();
selectedEl.setAttribute('transform', `translate(${d.ox + ddx},${d.oy + ddy}) ${rest}`.trim());
}
showHandles();
return;
}
if (!drawing || !tempEl) return;
const pos = getMousePos(e);
const dx = pos.x - startX, dy = pos.y - startY;
const shift = e.shiftKey;
if (false && currentTool === 'select' && selectedEl?._drag) {
const d = selectedEl._drag, ddx = pos.x - d.x, ddy = pos.y - d.y, tag = selectedEl.tagName;
if (tag === 'rect' || tag === 'text' || tag === 'image') { selectedEl.setAttribute('x', d.ox + ddx); selectedEl.setAttribute('y', d.oy + ddy); }
else if (tag === 'ellipse' || tag === 'circle') { selectedEl.setAttribute('cx', d.ox + ddx); selectedEl.setAttribute('cy', d.oy + ddy); }
else if (tag === 'line') { selectedEl.setAttribute('x1', d.ox + ddx); selectedEl.setAttribute('y1', d.oy + ddy); selectedEl.setAttribute('x2', d.ox2 + ddx); selectedEl.setAttribute('y2', d.oy2 + ddy); }
else {
const origT = selectedEl.getAttribute('transform') || '';
const rest = origT.replace(/translate\([^)]*\)/, '').trim();
selectedEl.setAttribute('transform', `translate(${d.ox + ddx},${d.oy + ddy}) ${rest}`.trim());
}
showHandles();
return;
}
if (currentTool === 'rect') {
const w = shift ? Math.max(Math.abs(dx), Math.abs(dy)) : Math.abs(dx);
const h = shift ? w : Math.abs(dy);
tempEl.setAttribute('x', dx < 0 ? startX - w : startX); tempEl.setAttribute('y', dy < 0 ? startY - h : startY);
tempEl.setAttribute('width', w); tempEl.setAttribute('height', h);
} else if (currentTool === 'circle') {
const rx = shift ? Math.max(Math.abs(dx), Math.abs(dy)) / 2 : Math.abs(dx) / 2;
const ry = shift ? rx : Math.abs(dy) / 2;
tempEl.setAttribute('cx', startX + dx / 2); tempEl.setAttribute('cy', startY + dy / 2);
tempEl.setAttribute('rx', rx); tempEl.setAttribute('ry', ry);
} else if (currentTool === 'line') {
if (shift) {
const angle = Math.atan2(dy, dx), snap = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4), len = Math.sqrt(dx * dx + dy * dy);
tempEl.setAttribute('x2', startX + Math.cos(snap) * len); tempEl.setAttribute('y2', startY + Math.sin(snap) * len);
} else { tempEl.setAttribute('x2', pos.x); tempEl.setAttribute('y2', pos.y); }
}
});
document.addEventListener('mouseup', () => {
if (!drawing) return;
drawing = false;
// Marquee selection end
const mq = document.getElementById('marquee');
if (currentTool === 'select' && !selectedEl && mq._start) {
const ms = mq._start;
const pos2 = getMousePos({ clientX: mq._start.x, clientY: mq._start.y });
// Compute marquee rect in SVG coords
const mx1 = Math.min(ms.sx, getMousePos({ clientX: parseInt(mq.style.left) + viewport.getBoundingClientRect().left, clientY: 0 }).x);
const svgR = svg.getBoundingClientRect();
const vpR = viewport.getBoundingClientRect();
const mqLeft = parseFloat(mq.style.left);
const mqTop = parseFloat(mq.style.top);
const mqW = parseFloat(mq.style.width);
const mqH = parseFloat(mq.style.height);
// Convert marquee screen rect to SVG coords
const sx1 = (mqLeft + vpR.left - svgR.left) / zoom;
const sy1 = (mqTop + vpR.top - svgR.top) / zoom;
const sx2 = sx1 + mqW / zoom;
const sy2 = sy1 + mqH / zoom;
mq.style.display = 'none';
delete mq._start;
if (mqW > 5 || mqH > 5) {
// Find topmost element within marquee using visual bounds
let best = null;
Array.from(svg.children).forEach(el => {
if (el.getAttribute('visibility') === 'hidden') return;
try {
const elR = el.getBoundingClientRect();
const vpR2 = viewport.getBoundingClientRect();
const elL = elR.left - vpR2.left;
const elT = elR.top - vpR2.top;
const elR2 = elL + elR.width;
const elB = elT + elR.height;
// Marquee bounds in viewport coords
if (elL < mqLeft + mqW && elR2 > mqLeft && elT < mqTop + mqH && elB > mqTop) {
best = el; // last = topmost
}
} catch(e) {}
});
if (best) selectElement(best);
} else {
// Tiny marquee = click on empty space, try proximity for thin elements
const nearPos = getMousePos({ clientX: ms.x, clientY: ms.y });
const near = findNearestElement(nearPos.x, nearPos.y, 12);
if (near) selectElement(near);
}
return;
}
if (currentTool === 'select' && selectedEl) { delete selectedEl._drag; pushHistory(); updateProps(); showHandles(); return; }
if (tempEl && currentTool !== 'select') {
const w = parseFloat(tempEl.getAttribute('width') || tempEl.getAttribute('rx') || 0);
const h = parseFloat(tempEl.getAttribute('height') || tempEl.getAttribute('ry') || 0);
if (currentTool === 'line') {
const x1 = parseFloat(tempEl.getAttribute('x1')), y1 = parseFloat(tempEl.getAttribute('y1'));
const x2 = parseFloat(tempEl.getAttribute('x2')), y2 = parseFloat(tempEl.getAttribute('y2'));
if (Math.abs(x2 - x1) < 2 && Math.abs(y2 - y1) < 2) { tempEl.remove(); tempEl = null; return; }
} else if (w < 2 && h < 2) { tempEl.remove(); tempEl = null; return; }
tempEl.setAttribute('fill-opacity', noFill ? '0' : String(fillOpacity));
if (noStroke) tempEl.setAttribute('stroke', 'none');
pushHistory();
// Auto-switch to select and select the drawn element
const drawnEl = tempEl; tempEl = null;
currentTool = 'select';
document.querySelectorAll('#toolsGroup .tb').forEach(b => b.classList.remove('active'));
document.querySelector('[data-tool="select"]').classList.add('active');
svg.style.cursor = 'default';
updateToolOptions();
selectElement(drawnEl);
}
});
svg.addEventListener('dblclick', (e) => {
// Inline text edit on double-click
if (currentTool === 'select' && selectedEl && selectedEl.tagName === 'text') {
startTextEdit(selectedEl);
return;
}
if (currentTool === 'path' && pathPoints.length > 1) {
// Double-click finishes path (open, no close)
tempEl.setAttribute('fill', noFill ? 'none' : fillCol);
tempEl.setAttribute('fill-opacity', noFill ? '0' : String(fillOpacity));
pushHistory();
const drawnPath = tempEl; tempEl = null; pathPoints = [];
hidePenPoints();
currentTool = 'select';
document.querySelectorAll('#toolsGroup .tb').forEach(b => b.classList.remove('active'));
document.querySelector('[data-tool="select"]').classList.add('active');
svg.style.cursor = 'default';
updateToolOptions();
selectElement(drawnPath);
}
});
// ── Selection ──
const selHandles = document.getElementById('selHandles');
let resizing = false, resizeHandle = '', resizeStart = {};
function selectElement(el) {
deselectAll();
selectedEl = el;
// Defer showHandles to next frame so layout recalculates after toolOptions change
updateProps(); updateLayers(); updateToolOptions();
document.getElementById('propsPanel').classList.add('visible');
document.getElementById('layerPanel').classList.remove('visible');
requestAnimationFrame(() => showHandles());
}
function deselectAll() {
selectedEl = null;
selHandles.style.display = 'none';
lineHandles.style.display = 'none';
document.getElementById('propsPanel').classList.remove('visible');
const mqEl = document.getElementById('marquee');
if (mqEl) { mqEl.style.display = 'none'; delete mqEl._start; }
updateProps(); updateLayers();
}
// Line endpoint handles
const lineHandles = document.createElement('div');
lineHandles.id = 'lineHandles';
lineHandles.style.cssText = 'display:none;position:absolute;pointer-events:none;z-index:5';
lineHandles.innerHTML = '<div class="sh" data-lh="1" style="pointer-events:auto;cursor:move"></div><div class="sh" data-lh="2" style="pointer-events:auto;cursor:move"></div>';
viewport.appendChild(lineHandles);
// Line endpoint drag
lineHandles.querySelectorAll('.sh').forEach(h => {
h.addEventListener('mousedown', (e) => {
e.stopPropagation();
if (!selectedEl || selectedEl.tagName !== 'line') return;
resizing = true;
resizeHandle = h.dataset.lh;
resizeStart = { x: e.clientX, y: e.clientY,
x1: parseFloat(selectedEl.getAttribute('x1')), y1: parseFloat(selectedEl.getAttribute('y1')),
x2: parseFloat(selectedEl.getAttribute('x2')), y2: parseFloat(selectedEl.getAttribute('y2'))
};
document.addEventListener('mousemove', onLineHandleDrag);
document.addEventListener('mouseup', onLineHandleDragEnd);
});
});
function onLineHandleDrag(e) {
if (!resizing || !selectedEl) return;
const dx = (e.clientX - resizeStart.x) / zoom;
const dy = (e.clientY - resizeStart.y) / zoom;
if (resizeHandle === '1') {
selectedEl.setAttribute('x1', resizeStart.x1 + dx);
selectedEl.setAttribute('y1', resizeStart.y1 + dy);
} else {
selectedEl.setAttribute('x2', resizeStart.x2 + dx);
selectedEl.setAttribute('y2', resizeStart.y2 + dy);
}
showHandles();
}
function onLineHandleDragEnd() {
resizing = false;
document.removeEventListener('mousemove', onLineHandleDrag);
document.removeEventListener('mouseup', onLineHandleDragEnd);
pushHistory(); updateProps();
}
function getVisualBBox(el) {
// Compute visual bbox using CTM (avoids layout timing issues)
const bb = el.getBBox();
const ctm = el.getCTM();
if (!ctm) {
const svgRect = svg.getBoundingClientRect();
const vpRect = viewport.getBoundingClientRect();
return { left: svgRect.left - vpRect.left + bb.x * zoom, top: svgRect.top - vpRect.top + bb.y * zoom, width: bb.width * zoom, height: bb.height * zoom };
}
// Transform all 4 corners of bbox through CTM
const pts = [
{ x: bb.x, y: bb.y },
{ x: bb.x + bb.width, y: bb.y },
{ x: bb.x + bb.width, y: bb.y + bb.height },
{ x: bb.x, y: bb.y + bb.height }
];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const svgCtm = svg.getScreenCTM();
const vpRect = viewport.getBoundingClientRect();
pts.forEach(p => {
const pt = svg.createSVGPoint();
pt.x = p.x; pt.y = p.y;
// Transform through element CTM to screen, then to viewport-relative
const screen = pt.matrixTransform(el.getScreenCTM());
const vx = screen.x - vpRect.left;
const vy = screen.y - vpRect.top;
minX = Math.min(minX, vx); minY = Math.min(minY, vy);
maxX = Math.max(maxX, vx); maxY = Math.max(maxY, vy);
});
return { left: minX, top: minY, width: maxX - minX, height: maxY - minY };
}
function showHandles() {
if (!selectedEl) { selHandles.style.display = 'none'; lineHandles.style.display = 'none'; return; }
const vpRect = viewport.getBoundingClientRect();
const svgRect = svg.getBoundingClientRect();
const ox = svgRect.left - vpRect.left;
const oy = svgRect.top - vpRect.top;
if (selectedEl.tagName === 'line') {
selHandles.style.display = 'none';
lineHandles.style.display = 'block';
// For lines, account for any transform
const ctm = selectedEl.getCTM();
const svgCtm = svg.getScreenCTM();
let x1 = parseFloat(selectedEl.getAttribute('x1'));
let y1 = parseFloat(selectedEl.getAttribute('y1'));
let x2 = parseFloat(selectedEl.getAttribute('x2'));
let y2 = parseFloat(selectedEl.getAttribute('y2'));
if (ctm && svgCtm) {
const inv = svgCtm.inverse();
// Transform through element CTM then to screen, then to viewport
const pt1 = svg.createSVGPoint(); pt1.x = x1; pt1.y = y1;
const pt2 = svg.createSVGPoint(); pt2.x = x2; pt2.y = y2;
const s1 = pt1.matrixTransform(ctm).matrixTransform(inv);
const s2 = pt2.matrixTransform(ctm).matrixTransform(inv);
x1 = s1.x; y1 = s1.y; x2 = s2.x; y2 = s2.y;
}
lineHandles.children[0].style.left = (ox + x1 * zoom - 4) + 'px';
lineHandles.children[0].style.top = (oy + y1 * zoom - 4) + 'px';
lineHandles.children[1].style.left = (ox + x2 * zoom - 4) + 'px';
lineHandles.children[1].style.top = (oy + y2 * zoom - 4) + 'px';
return;
}
lineHandles.style.display = 'none';
// Use visual bounding rect (includes transforms)
const vbb = getVisualBBox(selectedEl);
selHandles.style.display = 'block';
selHandles.style.left = vbb.left + 'px';
selHandles.style.top = vbb.top + 'px';
selHandles.style.width = vbb.width + 'px';
selHandles.style.height = vbb.height + 'px';
}
// Resize handles
selHandles.querySelectorAll('.sh').forEach(h => {
h.addEventListener('mousedown', (e) => {
e.stopPropagation();
if (!selectedEl) return;
resizing = true;
resizeHandle = h.dataset.h;
const tag = selectedEl.tagName;
if (tag === 'g' || tag === 'path' || tag === 'text') {
// Use visual bounds for transformed elements
const vbb = getVisualBBox(selectedEl);
const bb = selectedEl.getBBox();
resizeStart = {
x: e.clientX, y: e.clientY,
bx: bb.x, by: bb.y, bw: bb.width, bh: bb.height,
vx: vbb.left, vy: vbb.top, vw: vbb.width, vh: vbb.height,
origTransform: selectedEl.getAttribute('transform') || '',
useVisual: true
};
} else {
const bb = selectedEl.getBBox();
resizeStart = { x: e.clientX, y: e.clientY, bx: bb.x, by: bb.y, bw: bb.width, bh: bb.height };
}
document.addEventListener('mousemove', onResize);
document.addEventListener('mouseup', onResizeEnd);
});
});
function onResize(e) {
if (!resizing || !selectedEl) return;
const dx = (e.clientX - resizeStart.x) / zoom;
const dy = (e.clientY - resizeStart.y) / zoom;
const s = resizeStart;
let nx = s.bx, ny = s.by, nw = s.bw, nh = s.bh;
const tag = selectedEl.tagName;
if (resizeHandle.includes('e')) nw = Math.max(2, s.bw + dx);
if (resizeHandle.includes('w')) { nx = s.bx + dx; nw = Math.max(2, s.bw - dx); }
if (resizeHandle.includes('s')) nh = Math.max(2, s.bh + dy);
if (resizeHandle.includes('n')) { ny = s.by + dy; nh = Math.max(2, s.bh - dy); }
// Shift = proportional
if (e.shiftKey && s.bw > 0 && s.bh > 0) {
const ratio = s.bw / s.bh;
if (resizeHandle === 'n' || resizeHandle === 's') { nw = nh * ratio; }
else if (resizeHandle === 'e' || resizeHandle === 'w') { nh = nw / ratio; }
else { const maxD = Math.max(Math.abs(nw - s.bw), Math.abs(nh - s.bh)); nw = s.bw + maxD * Math.sign(nw - s.bw || 1); nh = nw / ratio; }
}
if (tag === 'rect') {
selectedEl.setAttribute('x', nx); selectedEl.setAttribute('y', ny);
selectedEl.setAttribute('width', nw); selectedEl.setAttribute('height', nh);
} else if (tag === 'ellipse') {
selectedEl.setAttribute('cx', nx + nw / 2); selectedEl.setAttribute('cy', ny + nh / 2);
selectedEl.setAttribute('rx', nw / 2); selectedEl.setAttribute('ry', nh / 2);
} else if (tag === 'line') {
if (resizeHandle.includes('e') || resizeHandle.includes('s')) {
selectedEl.setAttribute('x2', parseFloat(selectedEl.getAttribute('x2')) + dx);
selectedEl.setAttribute('y2', parseFloat(selectedEl.getAttribute('y2')) + dy);
} else {
selectedEl.setAttribute('x1', parseFloat(selectedEl.getAttribute('x1')) + dx);
selectedEl.setAttribute('y1', parseFloat(selectedEl.getAttribute('y1')) + dy);
}
} else if (tag === 'text' || tag === 'g' || tag === 'path') {
if (!s.useVisual) return;
// Compute new visual size from handle drag (in screen pixels)
const dxPx = e.clientX - s.x;
const dyPx = e.clientY - s.y;
let newVW = s.vw, newVH = s.vh, newVX = s.vx, newVY = s.vy;
if (resizeHandle.includes('e')) newVW = Math.max(8, s.vw + dxPx);
if (resizeHandle.includes('w')) { newVX = s.vx + dxPx; newVW = Math.max(8, s.vw - dxPx); }
if (resizeHandle.includes('s')) newVH = Math.max(8, s.vh + dyPx);
if (resizeHandle.includes('n')) { newVY = s.vy + dyPx; newVH = Math.max(8, s.vh - dyPx); }
// Shift = proportional
if (e.shiftKey && s.vw > 0 && s.vh > 0) {
const ratio = s.vw / s.vh;
if (resizeHandle === 'n' || resizeHandle === 's') { newVW = newVH * ratio; }
else if (resizeHandle === 'e' || resizeHandle === 'w') { newVH = newVW / ratio; }
else { newVH = newVW / ratio; }
}
const scaleX = newVW / s.vw;
const scaleY = newVH / s.vh;
// Parse original transform to get existing translate and scale
const origT = s.origTransform;
const tm = origT.match(/translate\(\s*([^,)\s]+)\s*,?\s*([^)\s]*)\s*\)/);
const sm = origT.match(/scale\(\s*([^,)\s]+)\s*,?\s*([^)\s]*)\s*\)/);
const origTX = tm ? parseFloat(tm[1]) || 0 : 0;
const origTY = tm ? parseFloat(tm[2]) || 0 : 0;
const origSX = sm ? parseFloat(sm[1]) || 1 : 1;
const origSY = sm ? (parseFloat(sm[2]) || origSX) : origSX;
// New scale = original scale * ratio
const newSX = origSX * scaleX;
const newSY = origSY * scaleY;
// Adjust translate so the pinned corner stays in place
// The pinned corner is opposite to the drag handle
// For simplicity: compute offset needed
const vpR = document.getElementById('viewport').getBoundingClientRect();
const svgR = svg.getBoundingClientRect();
const pinnedVX = resizeHandle.includes('e') || resizeHandle.includes('s') ? s.vx : s.vx + s.vw;
const pinnedVY = resizeHandle.includes('e') || resizeHandle.includes('s') ? s.vy : s.vy + s.vh;
if (resizeHandle === 'n' || resizeHandle === 'w' || resizeHandle === 'nw') {
// Pin bottom-right
}
// Convert pinned visual point to SVG coords
const pinnedSvgX = (pinnedVX + vpR.left - svgR.left) / zoom;
const pinnedSvgY = (pinnedVY + vpR.top - svgR.top) / zoom;
// New translate: pin point - (local pin point * new scale)
// local pin point relative to bbox origin
let localPinX, localPinY;
if (resizeHandle.includes('w')) { localPinX = s.bx + s.bw; } else { localPinX = s.bx; }
if (resizeHandle.includes('n')) { localPinY = s.by + s.bh; } else { localPinY = s.by; }
const newTX = pinnedSvgX - localPinX * newSX;
const newTY = pinnedSvgY - localPinY * newSY;
selectedEl.setAttribute('transform', `translate(${newTX.toFixed(1)},${newTY.toFixed(1)}) scale(${newSX.toFixed(3)},${newSY.toFixed(3)})`);
showHandles();
return; // skip normal nx/ny logic
}
showHandles();
}
function onResizeEnd() {
if (resizing) {
resizing = false;
// For text: bake transform into font-size for cleaner SVG
if (selectedEl && selectedEl.tagName === 'text') {
const t = selectedEl.getAttribute('transform');
if (t) {
const bb = selectedEl.getBBox();
const m = svg.createSVGMatrix();
// Extract scale from transform via getBBox comparison
const ctm = selectedEl.getCTM();
const parentCtm = svg.getCTM();
if (ctm && parentCtm) {
const relScale = ctm.a / parentCtm.a;
const origFS = parseFloat(selectedEl.getAttribute('font-size') || 20);
const newFS = Math.round(origFS * relScale * 10) / 10;
// Get the transformed position
const pt = svg.createSVGPoint();
pt.x = parseFloat(selectedEl.getAttribute('x') || 0);
pt.y = parseFloat(selectedEl.getAttribute('y') || 0);
const tPt = pt.matrixTransform(ctm).matrixTransform(parentCtm.inverse());
selectedEl.setAttribute('font-size', Math.max(6, newFS));
selectedEl.setAttribute('x', Math.round(tPt.x * 10) / 10);
selectedEl.setAttribute('y', Math.round(tPt.y * 10) / 10);
selectedEl.removeAttribute('transform');
}
}
}
pushHistory(); updateProps();
}
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', onResizeEnd);
}
// ── Properties Panel ──
document.getElementById('btnProps').addEventListener('click', () => {
const p = document.getElementById('propsPanel');
p.classList.toggle('visible');
document.getElementById('layerPanel').classList.remove('visible');
});
document.getElementById('propsClose').addEventListener('click', () => document.getElementById('propsPanel').classList.remove('visible'));
function updateProps() {
const body = document.getElementById('propsBody');
if (!selectedEl) { body.innerHTML = '<div class="pp-section"><span style="font-size:11px;color:var(--text-muted);font-family:\'JetBrains Mono\',monospace">Выберите элемент</span></div>'; return; }
const tag = selectedEl.tagName;
let html = '<div class="pp-section"><div class="pp-section-title">Позиция</div>';
if (tag === 'rect' || tag === 'text') {
html += ppRow('X', 'ppX', selectedEl.getAttribute('x') || 0) + ppRow('Y', 'ppY', selectedEl.getAttribute('y') || 0);
} else if (tag === 'ellipse') {
html += ppRow('CX', 'ppX', selectedEl.getAttribute('cx') || 0) + ppRow('CY', 'ppY', selectedEl.getAttribute('cy') || 0);
} else if (tag === 'line') {
html += ppRow('X1', 'ppX', selectedEl.getAttribute('x1') || 0) + ppRow('Y1', 'ppY', selectedEl.getAttribute('y1') || 0);
}
html += '</div>';
if (tag === 'rect') { html += '<div class="pp-section"><div class="pp-section-title">Размер</div>' + ppRow('W', 'ppW', selectedEl.getAttribute('width') || 0) + ppRow('H', 'ppH', selectedEl.getAttribute('height') || 0) + '</div>'; }
else if (tag === 'ellipse') { html += '<div class="pp-section"><div class="pp-section-title">Размер</div>' + ppRow('RX', 'ppW', selectedEl.getAttribute('rx') || 0) + ppRow('RY', 'ppH', selectedEl.getAttribute('ry') || 0) + '</div>'; }
const fill = selectedEl.getAttribute('fill') || 'none';
const stroke = selectedEl.getAttribute('stroke') || 'none';
html += `<div class="pp-section"><div class="pp-section-title">Стиль</div>
<div class="pp-row"><span class="pp-label">Fill</span><input type="color" class="pp-color" id="ppFill" value="${fill === 'none' ? '#000000' : fill}"></div>
<div class="pp-row"><span class="pp-label">Str</span><input type="color" class="pp-color" id="ppStroke" value="${stroke === 'none' ? '#000000' : stroke}"></div>
${ppRow('SW', 'ppSW', selectedEl.getAttribute('stroke-width') || 1)}
${ppRow('Op', 'ppOp', selectedEl.getAttribute('opacity') || 1)}
</div>`;
if (tag === 'text') {
html += '<div class="pp-section"><div class="pp-section-title">Текст</div>';
html += ppRow('Sz', 'ppFS', selectedEl.getAttribute('font-size') || 16);
html += ppRow('LS', 'ppLS', selectedEl.getAttribute('letter-spacing') || 0);
html += ppRow('FW', 'ppFW', selectedEl.getAttribute('font-weight') || 400);
html += '</div>';
}
body.innerHTML = html;
body.querySelectorAll('.pp-input,.pp-color').forEach(input => input.addEventListener('change', () => applyProp(input)));
}
function ppRow(label, id, val) {
return `<div class="pp-row"><span class="pp-label">${label}</span><input class="pp-input" id="${id}" type="number" step="any" value="${parseFloat(val) || 0}"></div>`;
}
function applyProp(input) {
if (!selectedEl) return;
const id = input.id, val = input.value, tag = selectedEl.tagName;
if (id === 'ppX') selectedEl.setAttribute(tag === 'ellipse' ? 'cx' : tag === 'line' ? 'x1' : 'x', val);
else if (id === 'ppY') selectedEl.setAttribute(tag === 'ellipse' ? 'cy' : tag === 'line' ? 'y1' : 'y', val);
else if (id === 'ppW') selectedEl.setAttribute(tag === 'ellipse' ? 'rx' : 'width', val);
else if (id === 'ppH') selectedEl.setAttribute(tag === 'ellipse' ? 'ry' : 'height', val);
else if (id === 'ppFill') selectedEl.setAttribute('fill', val);
else if (id === 'ppStroke') selectedEl.setAttribute('stroke', val);
else if (id === 'ppSW') selectedEl.setAttribute('stroke-width', val);
else if (id === 'ppOp') selectedEl.setAttribute('opacity', val);
else if (id === 'ppFS') { selectedEl.setAttribute('font-size', val); showHandles(); }
else if (id === 'ppLS') { selectedEl.setAttribute('letter-spacing', val); showHandles(); }
else if (id === 'ppFW') { selectedEl.setAttribute('font-weight', val); showHandles(); }
pushHistory();
}
// ── Layers Panel ──
document.getElementById('btnLayers').addEventListener('click', () => {
const p = document.getElementById('layerPanel');
p.classList.toggle('visible');
document.getElementById('propsPanel').classList.remove('visible');
});
document.getElementById('lpClear').addEventListener('click', () => {
if (confirm('Очистить холст?')) {
svg.innerHTML = '';
activeLayer = null;
initLayers();
deselectAll();
pushHistory();
}
});
document.getElementById('lpAddLayer').addEventListener('click', () => {
addLayer();
document.getElementById('stStatus').textContent = 'Новый слой создан';
});
function updateLayers() {
const list = document.getElementById('layerList');
const layers = svg.querySelectorAll(':scope > g[data-layer]');
let html = '';
for (let li = layers.length - 1; li >= 0; li--) {
const layer = layers[li];
const layerName = layer.getAttribute('data-name') || 'Слой';
const isActive = layer === activeLayer;
const layerVis = layer.getAttribute('visibility') !== 'hidden';
html += '<div class="lp-layer ' + (isActive ? 'active' : '') + '" data-layer-idx="' + li + '">';
html += '<span class="lp-name">' + layerName + '</span>';
var layerLocked = layer.getAttribute('data-locked') === '1';
html += '<span class="lp-lock ' + (layerLocked ? 'locked' : '') + '" data-layer-lock="' + li + '" title="' + (layerLocked ? 'Разблокировать' : 'Заблокировать') + '">' + (layerLocked ? '🔒' : '🔓') + '</span>';
html += '<button class="lp-btn" data-layer-up="' + li + '" title="Выше" style="font-size:10px">↑</button>';
html += '<button class="lp-btn" data-layer-down="' + li + '" title="Ниже" style="font-size:10px">↓</button>';
html += '<button class="lp-btn" data-layer-vis="' + li + '" title="' + (layerVis ? 'Скрыть' : 'Показать') + '"><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">' + (layerVis ? '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/>' : '<line x1="1" y1="1" x2="23" y2="23"/>') + '</svg></button>';
html += '<button class="lp-btn" data-layer-del="' + li + '" title="Удалить слой"><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>';
html += '</div>';
// Children of this layer
const children = Array.from(layer.children);
for (let ci = children.length - 1; ci >= 0; ci--) {
const el = children[ci];
if (el.getAttribute('data-layer')) continue;
const name = el.getAttribute('data-name') || el.tagName;
const isSel = el === selectedEl;
const isGroup = el.tagName === 'g';
const icon = isGroup ? '📁' : '◇';
html += '<div class="lp-child ' + (isSel ? 'selected' : '') + '" data-layer-idx="' + li + '" data-child-idx="' + ci + '">';
html += '<span style="font-size:10px;flex-shrink:0">' + icon + '</span>';
html += '<span class="lp-name">' + name + (isGroup ? ' (' + el.children.length + ')' : '') + '</span>';
html += '<button class="lp-btn" data-child-del="' + li + '-' + ci + '"><svg width="10" height="10" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>';
html += '</div>';
}
}
list.innerHTML = html;
// Layer click → set active
list.querySelectorAll('.lp-layer').forEach(el => {
el.addEventListener('click', (e) => {
if (e.target.closest('.lp-btn')) return;
const idx = parseInt(el.dataset.layerIdx);
activeLayer = layers[idx];
updateLayers();
});
});
// Layer visibility
list.querySelectorAll('[data-layer-vis]').forEach(btn => {
btn.addEventListener('click', () => {
const layer = layers[parseInt(btn.dataset.layerVis)];
layer.setAttribute('visibility', layer.getAttribute('visibility') === 'hidden' ? 'visible' : 'hidden');
updateLayers();
});
});
// Layer delete
list.querySelectorAll('[data-layer-del]').forEach(btn => {
btn.addEventListener('click', () => {
if (layers.length <= 1) return; // keep at least one
const layer = layers[parseInt(btn.dataset.layerDel)];
layer.remove();
if (layer === activeLayer) activeLayer = svg.querySelector('g[data-layer]');
if (selectedEl && !svg.contains(selectedEl)) deselectAll();
pushHistory();
});
});
// Layer lock
list.querySelectorAll('[data-layer-lock]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const layer = layers[parseInt(btn.dataset.layerLock)];
const locked = layer.getAttribute('data-locked') === '1';
layer.setAttribute('data-locked', locked ? '0' : '1');
if (!locked) layer.style.pointerEvents = 'none';
else layer.style.pointerEvents = '';
updateLayers();
document.getElementById('stStatus').textContent = locked ? 'Слой разблокирован' : 'Слой заблокирован';
});
});
// Layer reorder
list.querySelectorAll('[data-layer-up]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.layerUp);
const layer = layers[idx];
if (layer.nextElementSibling) {
layer.parentElement.insertBefore(layer.nextElementSibling, layer);
pushHistory(); updateLayers();
}
});
});
list.querySelectorAll('[data-layer-down]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.layerDown);
const layer = layers[idx];
if (layer.previousElementSibling) {
layer.parentElement.insertBefore(layer, layer.previousElementSibling);
pushHistory(); updateLayers();
}
});
});
// Child click → select
list.querySelectorAll('.lp-child').forEach(el => {
el.addEventListener('click', (e) => {
if (e.target.closest('.lp-btn')) return;
const li2 = parseInt(el.dataset.layerIdx);
const ci2 = parseInt(el.dataset.childIdx);
const child = layers[li2].children[ci2];
if (child) selectElement(child);
});
});
// Child delete
list.querySelectorAll('[data-child-del]').forEach(btn => {
btn.addEventListener('click', () => {
const [li2, ci2] = btn.dataset.childDel.split('-').map(Number);
const child = layers[li2].children[ci2];
if (child) { if (child === selectedEl) deselectAll(); child.remove(); pushHistory(); }
});
});
}
// ── Toolbar Actions ──
document.getElementById('btnUndo').addEventListener('click', undo);
document.getElementById('btnRedo').addEventListener('click', redo);
document.getElementById('btnDelete').addEventListener('click', () => { if (selectedEl) { selectedEl.remove(); deselectAll(); pushHistory(); } });
document.getElementById('btnDuplicate').addEventListener('click', () => {
if (!selectedEl) return;
const clone = selectedEl.cloneNode(true);
const tag = clone.tagName;
if (tag === 'rect' || tag === 'text') { clone.setAttribute('x', parseFloat(clone.getAttribute('x') || 0) + 20); clone.setAttribute('y', parseFloat(clone.getAttribute('y') || 0) + 20); }
else if (tag === 'ellipse') { clone.setAttribute('cx', parseFloat(clone.getAttribute('cx') || 0) + 20); clone.setAttribute('cy', parseFloat(clone.getAttribute('cy') || 0) + 20); }
else if (tag === 'line') { ['x1','y1','x2','y2'].forEach(a => clone.setAttribute(a, parseFloat(clone.getAttribute(a) || 0) + 20)); }
getActiveLayer().appendChild(clone); pushHistory(); selectElement(clone);
});
document.getElementById('btnFront').addEventListener('click', () => { if (selectedEl) { svg.appendChild(selectedEl); pushHistory(); updateLayers(); } });
document.getElementById('btnBack').addEventListener('click', () => { if (selectedEl && svg.firstChild !== selectedEl) { svg.insertBefore(selectedEl, svg.firstChild); pushHistory(); updateLayers(); } });
// Color swatches
document.getElementById('fillColor').addEventListener('input', (e) => { fillCol = e.target.value; if (selectedEl) { selectedEl.setAttribute('fill', fillCol); pushHistory(); } });
document.getElementById('strokeColor').addEventListener('input', (e) => { strokeCol = e.target.value; if (selectedEl) { selectedEl.setAttribute('stroke', strokeCol); pushHistory(); } });
// Import
document.getElementById('btnImport').addEventListener('click', () => document.getElementById('importFile').click());
document.getElementById('importFile').addEventListener('change', (e) => {
const file = e.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const parser = new DOMParser();
const doc = parser.parseFromString(reader.result, 'image/svg+xml');
const imp = doc.querySelector('svg');
if (!imp) { alert('Не удалось разобрать SVG'); return; }
Array.from(imp.children).forEach(c => getActiveLayer().appendChild(document.importNode(c, true)));
pushHistory();
document.getElementById('stStatus').textContent = 'SVG импортирован';
};
reader.readAsText(file); e.target.value = '';
});
// Drag & drop
// dropzone click removed — start with blank canvas
viewport.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('drag-over'); });
viewport.addEventListener('dragleave', () => dropzone.classList.remove('drag-over'));
viewport.addEventListener('drop', (e) => {
e.preventDefault(); dropzone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.svg')) {
const reader = new FileReader();
reader.onload = () => {
const doc = new DOMParser().parseFromString(reader.result, 'image/svg+xml');
const imp = doc.querySelector('svg');
if (imp) { Array.from(imp.children).forEach(c => getActiveLayer().appendChild(document.importNode(c, true))); pushHistory(); }
};
reader.readAsText(file);
}
});
// Export SVG
document.getElementById('btnExportSvg').addEventListener('click', () => {
deselectAll();
// Strip positioning styles before export
const savedStyle = svg.getAttribute('style') || '';
svg.removeAttribute('style');
// Convert inline attributes to CSS classes
const clone = svg.cloneNode(true);
const styleMap = new Map();
let classIdx = 0;
function getStyleKey(el) {
const attrs = ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'opacity', 'font-size', 'font-family', 'font-weight', 'letter-spacing'];
const parts = [];
attrs.forEach(a => {
const v = el.getAttribute(a);
if (v !== null) parts.push(a + ':' + v);
});
return parts.join(';');
}
function processElement(el) {
if (el.nodeType !== 1) return;
const tag = el.tagName;
if (tag === 'g' || tag === 'svg') {
Array.from(el.children).forEach(processElement);
return;
}
const key = getStyleKey(el);
if (!key) return;
let className;
if (styleMap.has(key)) {
className = styleMap.get(key);
} else {
className = 'st' + classIdx++;
styleMap.set(key, className);
}
el.setAttribute('class', className);
// Remove inline style attributes that are now in CSS
['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'opacity', 'font-size', 'font-family', 'font-weight', 'letter-spacing'].forEach(a => {
el.removeAttribute(a);
});
}
Array.from(clone.children).forEach(processElement);
// Build <style> block
if (styleMap.size > 0) {
const styleEl = document.createElementNS('http://www.w3.org/2000/svg', 'style');
let css = '';
styleMap.forEach((cls, key) => {
css += '.' + cls + '{' + key + '}\n';
});
styleEl.textContent = css;
clone.insertBefore(styleEl, clone.firstChild);
}
// Remove data-* attributes from export
clone.querySelectorAll('[data-name]').forEach(el => el.removeAttribute('data-name'));
clone.querySelectorAll('[data-layer]').forEach(el => el.removeAttribute('data-layer'));
const data = new XMLSerializer().serializeToString(clone);
if (savedStyle) svg.setAttribute('style', savedStyle); else positionCanvas();
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([data], { type: 'image/svg+xml' }));
a.download = 'drawing.svg'; a.click(); URL.revokeObjectURL(a.href);
document.getElementById('stStatus').textContent = 'SVG экспортирован';
});
// Export PNG
document.getElementById('btnExportPng').addEventListener('click', () => {
deselectAll();
const savedStyle = svg.getAttribute('style') || '';
svg.removeAttribute('style');
const data = new XMLSerializer().serializeToString(svg);
if (savedStyle) svg.setAttribute('style', savedStyle); else positionCanvas();
const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600;
const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, 800, 600);
const img = new Image();
img.onload = () => { ctx.drawImage(img, 0, 0); const a = document.createElement('a'); a.href = canvas.toDataURL('image/png'); a.download = 'drawing.png'; a.click(); document.getElementById('stStatus').textContent = 'PNG экспортирован'; };
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(data)));
});
// Optimize
document.getElementById('btnOptimize').addEventListener('click', async () => {
deselectAll();
const savedStyle = svg.getAttribute('style') || '';
svg.removeAttribute('style');
const data = new XMLSerializer().serializeToString(svg);
if (savedStyle) svg.setAttribute('style', savedStyle); else positionCanvas();
document.getElementById('stStatus').textContent = 'Оптимизация...';
try {
const res = await fetch('/api/svg-optimize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ svg: data }) });
const result = await res.json();
if (result.svg) {
const doc = new DOMParser().parseFromString(result.svg, 'image/svg+xml');
const s = doc.querySelector('svg');
if (s) { svg.innerHTML = s.innerHTML; pushHistory(); }
document.getElementById('stStatus').textContent = `Оптимизировано: ${result.saved}%`;
}
} catch { document.getElementById('stStatus').textContent = 'Ошибка'; }
});
// ── Keyboard ──
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
const ctrl = e.ctrlKey || e.metaKey;
if (e.code === 'KeyV' && !ctrl) document.querySelector('[data-tool="select"]').click();
else if (e.code === 'KeyR' && !ctrl) document.querySelector('[data-tool="rect"]').click();
else if (e.code === 'KeyO' && !ctrl) document.querySelector('[data-tool="circle"]').click();
else if (e.code === 'KeyL' && !ctrl) document.querySelector('[data-tool="line"]').click();
else if (e.code === 'KeyP' && !ctrl) document.querySelector('[data-tool="path"]').click();
else if (e.code === 'KeyT' && !ctrl) document.querySelector('[data-tool="text"]').click();
else if ((e.code === 'Delete' || e.code === 'Backspace') && selectedEl) { selectedEl.remove(); deselectAll(); pushHistory(); }
else if (ctrl && e.code === 'KeyZ' && !e.shiftKey) { e.preventDefault(); undo(); }
else if (ctrl && e.code === 'KeyZ' && e.shiftKey) { e.preventDefault(); redo(); }
else if (ctrl && e.code === 'KeyC' && selectedEl) {
e.preventDefault();
window._svgClipboard = selectedEl.cloneNode(true);
document.getElementById('stStatus').textContent = 'Скопировано';
}
else if (ctrl && e.code === 'KeyV' && window._svgClipboard) {
e.preventDefault();
const clone = window._svgClipboard.cloneNode(true);
// Offset paste position
const tag = clone.tagName;
if (tag === 'rect' || tag === 'text' || tag === 'image') {
clone.setAttribute('x', parseFloat(clone.getAttribute('x') || 0) + 20);
clone.setAttribute('y', parseFloat(clone.getAttribute('y') || 0) + 20);
} else if (tag === 'ellipse' || tag === 'circle') {
clone.setAttribute('cx', parseFloat(clone.getAttribute('cx') || 0) + 20);
clone.setAttribute('cy', parseFloat(clone.getAttribute('cy') || 0) + 20);
} else if (tag === 'line') {
['x1','y1','x2','y2'].forEach(a => clone.setAttribute(a, parseFloat(clone.getAttribute(a) || 0) + 20));
} else {
const t = clone.getAttribute('transform') || '';
const m = t.match(/translate\(\s*([^,)\s]+)\s*,?\s*([^)\s]*)\s*\)/);
let ox = m ? parseFloat(m[1]) || 0 : 0;
let oy = m ? parseFloat(m[2]) || 0 : 0;
const rest = t.replace(/translate\([^)]*\)/, '').trim();
clone.setAttribute('transform', `translate(${ox + 20},${oy + 20}) ${rest}`.trim());
}
svg.appendChild(clone);
pushHistory();
selectElement(clone);
document.getElementById('stStatus').textContent = 'Вставлено';
}
else if (ctrl && e.code === 'KeyX' && selectedEl) {
e.preventDefault();
window._svgClipboard = selectedEl.cloneNode(true);
selectedEl.remove();
deselectAll();
pushHistory();
document.getElementById('stStatus').textContent = 'Вырезано';
}
else if (ctrl && e.code === 'KeyD') { e.preventDefault(); document.getElementById('btnDuplicate').click(); }
else if (ctrl && e.code === 'KeyS') { e.preventDefault(); document.getElementById('btnExportSvg').click(); }
else if (ctrl && e.code === 'KeyO') { e.preventDefault(); document.getElementById('btnImport').click(); }
else if (e.code === 'ArrowLeft' || e.code === 'ArrowRight' || e.code === 'ArrowUp' || e.code === 'ArrowDown') {
if (!selectedEl) return;
e.preventDefault();
const step = e.shiftKey ? 10 : 1;
const tag = selectedEl.tagName;
let dx = 0, dy = 0;
if (e.code === 'ArrowLeft') dx = -step;
if (e.code === 'ArrowRight') dx = step;
if (e.code === 'ArrowUp') dy = -step;
if (e.code === 'ArrowDown') dy = step;
if (tag === 'rect' || tag === 'text' || tag === 'image') {
selectedEl.setAttribute('x', parseFloat(selectedEl.getAttribute('x') || 0) + dx);
selectedEl.setAttribute('y', parseFloat(selectedEl.getAttribute('y') || 0) + dy);
} else if (tag === 'ellipse' || tag === 'circle') {
selectedEl.setAttribute('cx', parseFloat(selectedEl.getAttribute('cx') || 0) + dx);
selectedEl.setAttribute('cy', parseFloat(selectedEl.getAttribute('cy') || 0) + dy);
} else if (tag === 'line') {
selectedEl.setAttribute('x1', parseFloat(selectedEl.getAttribute('x1') || 0) + dx);
selectedEl.setAttribute('y1', parseFloat(selectedEl.getAttribute('y1') || 0) + dy);
selectedEl.setAttribute('x2', parseFloat(selectedEl.getAttribute('x2') || 0) + dx);
selectedEl.setAttribute('y2', parseFloat(selectedEl.getAttribute('y2') || 0) + dy);
} else {
const t = selectedEl.getAttribute('transform') || '';
const m = t.match(/translate\(([^,)]+),?([^)]*)/);
let ox = 0, oy = 0;
if (m) { ox = parseFloat(m[1]) || 0; oy = parseFloat(m[2]) || 0; }
// Preserve scale and other transforms, only update translate
const rest = t.replace(/translate\([^)]*\)/, '').trim();
selectedEl.setAttribute('transform', `translate(${ox + dx},${oy + dy}) ${rest}`.trim());
}
showHandles();
pushHistory();
}
else if (e.code === 'Escape') { deselectAll(); if (tempEl && currentTool === 'path') { tempEl.remove(); tempEl = null; } pathPoints = []; hidePenPoints(); }
});
// ── AI Modal ──
const ICONS = {
home:'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
user:'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
search:'M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z',
heart:'M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z',
star:'M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z',
settings:'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
mail:'M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75',
phone:'M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z',
lock:'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z',
bell:'M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0',
clock:'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z',
cloud:'M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z',
download:'M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3',
upload:'M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5',
trash:'M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0',
plus:'M12 4.5v15m7.5-7.5h-15', minus:'M19.5 12h-15',
check:'M4.5 12.75l6 6 9-13.5', close:'M6 18L18 6M6 6l12 12',
'arrow-left':'M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18', 'arrow-right':'M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3',
'arrow-up':'M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18', 'arrow-down':'M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3',
menu:'M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5',
globe:'M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418',
link:'M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.06a4.5 4.5 0 00-6.364-6.364L6.257 6.514a4.5 4.5 0 001.242 7.244',
eye:'M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
sun:'M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z',
moon:'M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z',
code:'M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5',
terminal:'M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z',
database:'M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125',
wifi:'M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z',
bookmark:'M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z',
share:'M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z',
cart:'M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 00-16.536-1.84M7.5 14.25L5.106 5.272M6 20.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm12.75 0a.75.75 0 11-1.5 0 .75.75 0 011.5 0z',
file:'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z',
folder:'M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z',
camera:'M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z',
};
const NAMES = {home:'дом',user:'юзер',search:'поиск',heart:'сердце',star:'звезда',settings:'настройки',mail:'почта',phone:'телефон',lock:'замок',bell:'колокол',clock:'часы',cloud:'облако',download:'скачать',upload:'загрузить',trash:'корзина',plus:'плюс',minus:'минус',check:'галочка',close:'закрыть','arrow-left':'←','arrow-right':'→','arrow-up':'↑','arrow-down':'↓',menu:'меню',globe:'глобус',link:'ссылка',eye:'глаз',sun:'солнце',moon:'луна',code:'код',terminal:'терминал',database:'БД',wifi:'wifi',bookmark:'закладка',share:'поделиться',cart:'корзина',file:'файл',folder:'папка',camera:'камера'};
let aiSelected = null;
document.getElementById('btnAI').addEventListener('click', () => { document.getElementById('aiModal').classList.add('visible'); renderAiGrid(); });
document.getElementById('aiCancel').addEventListener('click', () => document.getElementById('aiModal').classList.remove('visible'));
document.getElementById('aiModal').addEventListener('click', (e) => { if (e.target.id === 'aiModal') e.target.classList.remove('visible'); });
document.getElementById('aiSearch').addEventListener('input', renderAiGrid);
function renderAiGrid() {
const q = document.getElementById('aiSearch').value.toLowerCase().trim();
const color = document.getElementById('aiColor').value;
const style = document.getElementById('aiStyle').value;
const sz = Math.min(parseInt(document.getElementById('aiSize').value), 40);
let keys = Object.keys(ICONS);
if (q) keys = keys.filter(k => k.includes(q) || (NAMES[k] && NAMES[k].includes(q)));
const grid = document.getElementById('aiGrid');
grid.innerHTML = keys.map(k => {
const d = ICONS[k];
let s;
if (style === 'outline') s = `<svg width="${sz}" height="${sz}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
else if (style === 'filled') s = `<svg width="${sz}" height="${sz}" viewBox="0 0 24 24" fill="${color}" stroke="none"><path d="${d}"/></svg>`;
else s = `<svg width="${sz}" height="${sz}" viewBox="0 0 24 24" fill="${color}" fill-opacity="0.2" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
return `<div class="icon-card" data-icon="${k}">${s}<span>${NAMES[k]||k}</span></div>`;
}).join('');
grid.querySelectorAll('.icon-card').forEach(c => c.addEventListener('click', () => {
grid.querySelectorAll('.icon-card').forEach(x => x.classList.remove('selected'));
c.classList.add('selected'); aiSelected = c.dataset.icon;
}));
}
document.getElementById('aiAdd').addEventListener('click', () => {
if (!aiSelected || !ICONS[aiSelected]) return;
const color = document.getElementById('aiColor').value;
const style = document.getElementById('aiStyle').value;
const size = parseInt(document.getElementById('aiSize').value);
const g = svgEl('g', { 'data-name': NAMES[aiSelected] || aiSelected, transform: `translate(${400-size/2},${300-size/2}) scale(${size/24})` });
const p = svgEl('path', { d: ICONS[aiSelected] });
if (style === 'outline') { p.setAttribute('fill','none'); p.setAttribute('stroke',color); p.setAttribute('stroke-width','1.5'); p.setAttribute('stroke-linecap','round'); p.setAttribute('stroke-linejoin','round'); }
else if (style === 'filled') { p.setAttribute('fill',color); }
else { p.setAttribute('fill',color); p.setAttribute('fill-opacity','0.2'); p.setAttribute('stroke',color); p.setAttribute('stroke-width','1.5'); p.setAttribute('stroke-linecap','round'); p.setAttribute('stroke-linejoin','round'); }
g.appendChild(p); getActiveLayer().appendChild(g); pushHistory();
document.getElementById('aiModal').classList.remove('visible');
document.getElementById('stStatus').textContent = `Добавлено: ${NAMES[aiSelected]||aiSelected}`;
});
renderAiGrid();
// ── Text to Path via opentype.js ──
const fontCache = {};
function getFontUrl(ff, fw) {
const w = parseInt(fw) || 400;
if (ff.includes('JetBrains')) return '/vendor/fonts/jetbrains-' + w + '.ttf';
return '/vendor/fonts/manrope-' + w + '.ttf';
}
async function loadFont(url) {
if (fontCache[url]) return fontCache[url];
try {
const font = await opentype.load(url);
fontCache[url] = font;
return font;
} catch(e) {
console.error('Font load error:', e);
return null;
}
}
async function rasterizeText(textEl) {
if (!textEl || textEl.tagName !== 'text') return;
const text = textEl.textContent || '';
if (!text.trim()) return;
const fs = parseFloat(textEl.getAttribute('font-size') || 20);
const ff = textEl.getAttribute('font-family') || 'Manrope, sans-serif';
const fw = textEl.getAttribute('font-weight') || '400';
const fill = textEl.getAttribute('fill') || '#000';
const ls = parseFloat(textEl.getAttribute('letter-spacing') || 0);
const x = parseFloat(textEl.getAttribute('x') || 0);
const y = parseFloat(textEl.getAttribute('y') || 0);
document.getElementById('stStatus').textContent = 'Загрузка шрифта...';
const fontUrl = getFontUrl(ff, fw);
const font = await loadFont(fontUrl);
if (!font) {
document.getElementById('stStatus').textContent = 'Ошибка загрузки шрифта';
return;
}
// Generate path using opentype.js
const otPath = font.getPath(text, x, y, fs, { letterSpacing: ls / fs });
const pathD = otPath.toPathData(2);
if (!pathD) {
document.getElementById('stStatus').textContent = 'Не удалось создать контур';
return;
}
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathEl.setAttribute('d', pathD);
pathEl.setAttribute('fill', fill);
pathEl.setAttribute('fill-rule', 'nonzero');
pathEl.setAttribute('data-name', 'Контур: ' + text.substring(0, 20));
const parent = textEl.parentElement;
parent.insertBefore(pathEl, textEl);
textEl.remove();
if (selectedEl === textEl) selectedEl = pathEl;
pushHistory();
selectElement(pathEl);
document.getElementById('stStatus').textContent = 'Текст → контур';
}
document.getElementById('btnRasterize').addEventListener('click', async () => {
if (selectedEl && selectedEl.tagName === 'text') await rasterizeText(selectedEl);
else document.getElementById('stStatus').textContent = 'Выделите текстовый элемент';
});
// ── Alignment Tools ──
function alignSelected(mode) {
if (!selectedEl) return;
const canvasW = parseInt(svg.getAttribute('width'));
const canvasH = parseInt(svg.getAttribute('height'));
const bb = selectedEl.getBBox();
const tag = selectedEl.tagName;
const hasTransform = tag === 'g' || tag === 'path' || tag === 'text';
// Get current visual position
let curX, curY;
if (tag === 'rect' || tag === 'image') { curX = parseFloat(selectedEl.getAttribute('x')||0); curY = parseFloat(selectedEl.getAttribute('y')||0); }
else if (tag === 'ellipse' || tag === 'circle') { curX = parseFloat(selectedEl.getAttribute('cx')||0) - bb.width/2; curY = parseFloat(selectedEl.getAttribute('cy')||0) - bb.height/2; }
else if (tag === 'line') { curX = Math.min(parseFloat(selectedEl.getAttribute('x1')),parseFloat(selectedEl.getAttribute('x2'))); curY = Math.min(parseFloat(selectedEl.getAttribute('y1')),parseFloat(selectedEl.getAttribute('y2'))); }
else { curX = bb.x; curY = bb.y; }
let newX = curX, newY = curY;
if (mode === 'left') newX = 0;
else if (mode === 'centerX') newX = (canvasW - bb.width) / 2;
else if (mode === 'right') newX = canvasW - bb.width;
else if (mode === 'top') newY = 0;
else if (mode === 'centerY') newY = (canvasH - bb.height) / 2;
else if (mode === 'bottom') newY = canvasH - bb.height;
const dx = newX - curX, dy = newY - curY;
if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1) return;
if (tag === 'rect' || tag === 'image') {
selectedEl.setAttribute('x', parseFloat(selectedEl.getAttribute('x')||0) + dx);
selectedEl.setAttribute('y', parseFloat(selectedEl.getAttribute('y')||0) + dy);
} else if (tag === 'ellipse' || tag === 'circle') {
selectedEl.setAttribute('cx', parseFloat(selectedEl.getAttribute('cx')||0) + dx);
selectedEl.setAttribute('cy', parseFloat(selectedEl.getAttribute('cy')||0) + dy);
} else if (tag === 'line') {
selectedEl.setAttribute('x1', parseFloat(selectedEl.getAttribute('x1')||0) + dx);
selectedEl.setAttribute('y1', parseFloat(selectedEl.getAttribute('y1')||0) + dy);
selectedEl.setAttribute('x2', parseFloat(selectedEl.getAttribute('x2')||0) + dx);
selectedEl.setAttribute('y2', parseFloat(selectedEl.getAttribute('y2')||0) + dy);
} else {
const t = selectedEl.getAttribute('transform') || '';
const m = t.match(/translate\(\s*([^,)\s]+)\s*,?\s*([^)\s]*)\s*\)/);
let ox = m ? parseFloat(m[1])||0 : 0, oy = m ? parseFloat(m[2])||0 : 0;
const rest = t.replace(/translate\([^)]*\)/, '').trim();
selectedEl.setAttribute('transform', `translate(${ox+dx},${oy+dy}) ${rest}`.trim());
}
pushHistory(); showHandles(); updateProps();
}
document.getElementById('btnAlignL').addEventListener('click', () => alignSelected('left'));
document.getElementById('btnAlignCX').addEventListener('click', () => alignSelected('centerX'));
document.getElementById('btnAlignR').addEventListener('click', () => alignSelected('right'));
document.getElementById('btnAlignT').addEventListener('click', () => alignSelected('top'));
document.getElementById('btnAlignCY').addEventListener('click', () => alignSelected('centerY'));
document.getElementById('btnAlignB').addEventListener('click', () => alignSelected('bottom'));
// ── Dev Mode (SVG Code Viewer/Editor) ──
document.getElementById('btnDevMode').addEventListener('click', () => {
const savedStyle = svg.getAttribute('style') || '';
svg.removeAttribute('style');
let code = new XMLSerializer().serializeToString(svg);
if (savedStyle) svg.setAttribute('style', savedStyle); else positionCanvas();
// Pretty print
code = code.replace(/></g, '>\n<');
code = code.replace(/\n\n+/g, '\n');
document.getElementById('devCode').value = code;
document.getElementById('devModal').classList.add('visible');
});
document.getElementById('devClose').addEventListener('click', () => document.getElementById('devModal').classList.remove('visible'));
document.getElementById('devModal').addEventListener('click', (e) => { if (e.target.id === 'devModal') e.target.classList.remove('visible'); });
document.getElementById('devCopy').addEventListener('click', () => {
const ta = document.getElementById('devCode');
ta.select();
document.execCommand('copy');
document.getElementById('devCopy').textContent = 'Скопировано!';
setTimeout(() => document.getElementById('devCopy').textContent = 'Копировать', 1500);
});
// Apply edited SVG code back
document.getElementById('devCode').addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.code === 'Enter') {
e.preventDefault();
const code = document.getElementById('devCode').value;
try {
const doc = new DOMParser().parseFromString(code, 'image/svg+xml');
const newSvg = doc.querySelector('svg');
if (newSvg) {
svg.innerHTML = newSvg.innerHTML;
initLayers();
deselectAll();
pushHistory();
document.getElementById('devModal').classList.remove('visible');
document.getElementById('stStatus').textContent = 'SVG код применён';
}
} catch(err) { alert('Ошибка парсинга SVG'); }
}
e.stopPropagation();
});
// ── Context Menu ──
const ctxMenu = document.getElementById('ctxMenu');
viewport.addEventListener('contextmenu', (e) => {
e.preventDefault();
const vpR = viewport.getBoundingClientRect();
// Try to select element under cursor if nothing selected
if (!selectedEl) {
const hVis = selHandles.style.display;
const lhVis = lineHandles.style.display;
selHandles.style.display = 'none';
lineHandles.style.display = 'none';
let target = document.elementFromPoint(e.clientX, e.clientY);
selHandles.style.display = hVis;
lineHandles.style.display = lhVis;
if (target && target !== svg && svg.contains(target)) {
while (target.parentElement && target.parentElement !== svg) target = target.parentElement;
selectElement(target);
}
}
// Update menu items state
ctxMenu.querySelectorAll('.ctx-item').forEach(item => {
const a = item.dataset.action;
if (a === 'paste') item.classList.toggle('disabled', !window._svgClipboard);
else if (a === 'ungroup') item.classList.toggle('disabled', !selectedEl || selectedEl.tagName !== 'g' || !!selectedEl.getAttribute('data-layer'));
else if (a === 'rasterize') item.classList.toggle('disabled', !selectedEl || selectedEl.tagName !== 'text');
else if (a === 'group') item.classList.toggle('disabled', svg.children.length < 2);
else item.classList.toggle('disabled', !selectedEl);
});
ctxMenu.classList.add('visible');
const menuW = ctxMenu.offsetWidth;
const menuH = ctxMenu.offsetHeight;
let mx = e.clientX - vpR.left;
let my = e.clientY - vpR.top;
if (mx + menuW > vpR.width) mx = vpR.width - menuW - 4;
if (my + menuH > vpR.height) my = vpR.height - menuH - 4;
if (mx < 0) mx = 4;
if (my < 0) my = 4;
ctxMenu.style.left = mx + 'px';
ctxMenu.style.top = my + 'px';
});
document.addEventListener('click', () => ctxMenu.classList.remove('visible'));
document.addEventListener('keydown', (e) => { if (e.code === 'Escape') ctxMenu.classList.remove('visible'); });
ctxMenu.querySelectorAll('.ctx-item').forEach(item => {
item.addEventListener('click', async () => {
ctxMenu.classList.remove('visible');
const a = item.dataset.action;
if (a === 'copy' && selectedEl) {
window._svgClipboard = selectedEl.cloneNode(true);
document.getElementById('stStatus').textContent = 'Скопировано';
} else if (a === 'cut' && selectedEl) {
window._svgClipboard = selectedEl.cloneNode(true);
selectedEl.remove(); deselectAll(); pushHistory();
document.getElementById('stStatus').textContent = 'Вырезано';
} else if (a === 'paste' && window._svgClipboard) {
const clone = window._svgClipboard.cloneNode(true);
const tag = clone.tagName;
if (tag === 'rect' || tag === 'text') { clone.setAttribute('x', parseFloat(clone.getAttribute('x')||0)+20); clone.setAttribute('y', parseFloat(clone.getAttribute('y')||0)+20); }
else if (tag === 'ellipse') { clone.setAttribute('cx', parseFloat(clone.getAttribute('cx')||0)+20); clone.setAttribute('cy', parseFloat(clone.getAttribute('cy')||0)+20); }
else if (tag === 'line') { ['x1','y1','x2','y2'].forEach(a2 => clone.setAttribute(a2, parseFloat(clone.getAttribute(a2)||0)+20)); }
else { const t=clone.getAttribute('transform')||''; const m=t.match(/translate\(\s*([^,)\s]+)\s*,?\s*([^)\s]*)\s*\)/); let ox=m?parseFloat(m[1])||0:0,oy=m?parseFloat(m[2])||0:0; const rest=t.replace(/translate\([^)]*\)/,'').trim(); clone.setAttribute('transform',`translate(${ox+20},${oy+20}) ${rest}`.trim()); }
getActiveLayer().appendChild(clone); pushHistory(); selectElement(clone);
} else if (a === 'duplicate') {
document.getElementById('btnDuplicate').click();
} else if (a === 'delete' && selectedEl) {
selectedEl.remove(); deselectAll(); pushHistory();
} else if (a === 'front' && selectedEl) {
svg.appendChild(selectedEl); pushHistory(); updateLayers();
} else if (a === 'back' && selectedEl) {
if (svg.firstChild !== selectedEl) { svg.insertBefore(selectedEl, svg.firstChild); pushHistory(); updateLayers(); }
} else if (a === 'group') {
// Group all elements in active layer into a <g>
const layer = getActiveLayer();
if (layer.children.length > 0) {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('data-name', 'Группа');
while (layer.firstChild) g.appendChild(layer.firstChild);
layer.appendChild(g);
pushHistory(); selectElement(g);
document.getElementById('stStatus').textContent = 'Сгруппировано';
}
} else if (a === 'rasterize' && selectedEl && selectedEl.tagName === 'text') {
await rasterizeText(selectedEl);
} else if (a === 'ungroup' && selectedEl && selectedEl.tagName === 'g' && !selectedEl.getAttribute('data-layer')) {
const g = selectedEl;
const children = Array.from(g.children);
const parent = g.parentElement;
children.forEach(child => parent.insertBefore(child, g));
g.remove();
deselectAll(); pushHistory();
document.getElementById('stStatus').textContent = 'Разгруппировано';
}
});
});
// Ctrl+G / Ctrl+Shift+G shortcuts
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const ctrl = e.ctrlKey || e.metaKey;
if (ctrl && e.code === 'KeyG' && !e.shiftKey) {
e.preventDefault();
const layer = getActiveLayer();
if (layer.children.length > 0) {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('data-name', 'Группа');
while (layer.firstChild) g.appendChild(layer.firstChild);
layer.appendChild(g);
pushHistory(); selectElement(g);
document.getElementById('stStatus').textContent = 'Сгруппировано';
}
} else if (ctrl && e.code === 'KeyG' && e.shiftKey) {
e.preventDefault();
if (selectedEl && selectedEl.tagName === 'g' && !selectedEl.getAttribute('data-layer')) {
const g = selectedEl;
const parent = g.parentElement;
const children = Array.from(g.children);
children.forEach(child => parent.insertBefore(child, g));
g.remove(); deselectAll(); pushHistory();
document.getElementById('stStatus').textContent = 'Разгруппировано';
}
}
});
// ── Inline Text Editor ──
const textOverlay = document.getElementById('textEditOverlay');
const textInput = document.getElementById('textEditInput');
let editingText = null;
function startTextEdit(el) {
editingText = el;
const bb = el.getBBox();
const svgRect = svg.getBoundingClientRect();
const vpRect = viewport.getBoundingClientRect();
const fs = parseFloat(el.getAttribute('font-size') || 20);
textOverlay.style.display = 'block';
textOverlay.style.left = (svgRect.left - vpRect.left + bb.x * zoom) + 'px';
textOverlay.style.top = (svgRect.top - vpRect.top + bb.y * zoom) + 'px';
textInput.value = el.textContent;
textInput.style.fontSize = (fs * zoom) + 'px';
textInput.style.fontFamily = el.getAttribute('font-family') || 'Manrope, sans-serif';
textInput.style.color = el.getAttribute('fill') || '#000';
textInput.style.minWidth = (bb.width * zoom + 20) + 'px';
textInput.style.height = (bb.height * zoom + 10) + 'px';
selHandles.style.display = 'none';
textInput.focus();
textInput.select();
}
function finishTextEdit() {
if (!editingText) return;
const newText = textInput.value.trim();
if (newText && newText !== editingText.textContent) {
editingText.textContent = newText;
pushHistory();
}
textOverlay.style.display = 'none';
if (editingText) { selectElement(editingText); }
editingText = null;
}
textInput.addEventListener('blur', finishTextEdit);
textInput.addEventListener('keydown', (e) => {
if (e.code === 'Enter' && !e.shiftKey) { e.preventDefault(); finishTextEdit(); }
if (e.code === 'Escape') { textOverlay.style.display = 'none'; editingText = null; }
e.stopPropagation(); // don't trigger tool shortcuts
});
// ── Tabs ──
const tabBar = document.getElementById('tabBar');
let tabs = [{ name: 'Новый', svgContent: '', w: 800, h: 600 }];
let activeTab = 0;
function renderTabs() {
let html = '';
tabs.forEach((t, i) => {
html += '<div class="tab-item ' + (i === activeTab ? 'active' : '') + '" data-tab="' + i + '">';
html += '<span class="tab-name">' + t.name + '</span>';
if (tabs.length > 1) html += '<button class="tab-close" data-close="' + i + '">&times;</button>';
html += '</div>';
});
html += '<button class="tab-add" id="tabAdd" title="Новая вкладка (Ctrl+T)">+</button>';
tabBar.innerHTML = html;
tabBar.querySelectorAll('.tab-item').forEach(el => {
el.addEventListener('click', (e) => {
if (e.target.closest('.tab-close')) return;
switchTab(parseInt(el.dataset.tab));
});
});
tabBar.querySelectorAll('.tab-close').forEach(btn => {
btn.addEventListener('click', () => closeTab(parseInt(btn.dataset.close)));
});
document.getElementById('tabAdd').addEventListener('click', addTab);
}
function saveCurrentTab() {
tabs[activeTab].svgContent = svg.innerHTML;
tabs[activeTab].w = parseInt(svg.getAttribute('width'));
tabs[activeTab].h = parseInt(svg.getAttribute('height'));
tabs[activeTab].history = [...history];
tabs[activeTab].historyIdx = historyIdx;
}
function switchTab(idx) {
if (idx === activeTab) return;
saveCurrentTab();
deselectAll();
activeTab = idx;
const t = tabs[idx];
svg.innerHTML = t.svgContent;
svg.setAttribute('width', t.w); svg.setAttribute('height', t.h);
svg.setAttribute('viewBox', '0 0 ' + t.w + ' ' + t.h);
history = t.history || [t.svgContent];
historyIdx = t.historyIdx !== undefined ? t.historyIdx : 0;
document.getElementById('stSize').textContent = t.w + ' \u00d7 ' + t.h;
positionCanvas();
updateLayers(); updateCount();
renderTabs();
}
function addTab() {
saveCurrentTab();
deselectAll();
tabs.push({ name: 'Новый ' + (tabs.length + 1), svgContent: '', w: 800, h: 600, history: [''], historyIdx: 0 });
activeTab = tabs.length - 1;
svg.innerHTML = '';
svg.setAttribute('width', 800); svg.setAttribute('height', 600);
svg.setAttribute('viewBox', '0 0 800 600');
history = ['']; historyIdx = 0;
document.getElementById('stSize').textContent = '800 \u00d7 600';
positionCanvas();
updateLayers(); updateCount();
renderTabs();
}
function closeTab(idx) {
if (tabs.length <= 1) return;
tabs.splice(idx, 1);
if (activeTab >= tabs.length) activeTab = tabs.length - 1;
if (activeTab === idx || activeTab > idx) {
activeTab = Math.max(0, activeTab > idx ? activeTab - 1 : activeTab);
}
const t = tabs[activeTab];
svg.innerHTML = t.svgContent;
svg.setAttribute('width', t.w); svg.setAttribute('height', t.h);
svg.setAttribute('viewBox', '0 0 ' + t.w + ' ' + t.h);
history = t.history || [t.svgContent];
historyIdx = t.historyIdx !== undefined ? t.historyIdx : 0;
document.getElementById('stSize').textContent = t.w + ' \u00d7 ' + t.h;
positionCanvas();
updateLayers(); updateCount();
renderTabs();
}
// Ctrl+T for new tab
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.code === 'KeyT' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
e.preventDefault(); addTab();
}
});
renderTabs();
// ── New Canvas ──
document.getElementById('btnNew').addEventListener('click', () => document.getElementById('newModal').classList.add('visible'));
document.getElementById('newCancel').addEventListener('click', () => document.getElementById('newModal').classList.remove('visible'));
document.getElementById('newModal').addEventListener('click', (e) => { if (e.target.id === 'newModal') e.target.classList.remove('visible'); });
document.getElementById('newCreate').addEventListener('click', () => {
const w = parseInt(document.getElementById('newW').value) || 800;
const h = parseInt(document.getElementById('newH').value) || 600;
svg.setAttribute('width', w); svg.setAttribute('height', h);
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
svg.innerHTML = '';
deselectAll();
history = []; historyIdx = -1; pushHistory();
document.getElementById('stSize').textContent = `${w} \u00d7 ${h}`;
document.getElementById('newModal').classList.remove('visible');
document.getElementById('stStatus').textContent = `Холст ${w}×${h} создан`;
positionCanvas();
});
// Ctrl+N
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.code === 'KeyN') { e.preventDefault(); document.getElementById('btnNew').click(); }
});
})();
</script>
</body>
</html>