2440 lines
153 KiB
HTML
2440 lines
153 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>SVG Editor — WA Dev Tools</title>
|
||
<link href="/vendor/tailwind.min.css" rel="stylesheet">
|
||
<script>window.WA_TOOL_ID = 'svgeditor';</script>
|
||
<script src="/shared.js?v=3"></script>
|
||
<link href="/vendor/fonts.css" rel="stylesheet">
|
||
<link href="/shared.css" rel="stylesheet">
|
||
<script src="/vendor/opentype.min.js"></script>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
input,select,button,textarea{font-family:inherit;font-size:inherit}
|
||
|
||
/* 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>
|
||
|
||
<!-- 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">×</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="Очистить">×</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">×</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 + '">×</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>
|
||
<!-- Yandex.Metrika counter -->
|
||
<script type="text/javascript">
|
||
(function(m,e,t,r,i,k,a){
|
||
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||
m[i].l=1*new Date();
|
||
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
||
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=108185982', 'ym');
|
||
ym(108185982, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
|
||
</script>
|
||
<noscript><div><img src="https://mc.yandex.ru/watch/108185982" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||
<!-- /Yandex.Metrika counter -->
|
||
</body>
|
||
</html>
|