From 970b296df1147615ae9c4766c639faa125e21906 Mon Sep 17 00:00:00 2001 From: LDRoff Date: Wed, 1 Oct 2025 00:12:10 +0300 Subject: [PATCH 1/8] Reposition drawing sub-toolbar above main toolbar --- css/style.css | 101 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/css/style.css b/css/style.css index 234bf80..9abbc1a 100644 --- a/css/style.css +++ b/css/style.css @@ -1,5 +1,5 @@ /* --- START OF FILE style.css --- */ -body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: #f0f2f5; font-family: sans-serif; } +body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: #f0f2f5; font-family: sans-serif; } canvas { display: block; position: absolute; top: 0; left: 0; } #backgroundCanvas { z-index: 1; background-color: #ffffff; } #drawingBoard { z-index: 2; background-color: transparent; touch-action: none; } @@ -18,8 +18,8 @@ canvas { display: block; position: absolute; top: 0; left: 0; } .zoom-controls button.active { background: rgba(135, 206, 250, 0.5); } .toolbar-wrapper { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; display: flex; align-items: flex-end; } -.toolbar-content-slider { display: flex; flex-direction: column; align-items: center; gap: 10px; transition: transform 0.3s ease, opacity 0.3s ease; } -.toolbar-container { position: relative; padding-top: 10px; } +.toolbar-content-slider { display: flex; flex-direction: column; align-items: center; gap: 10px; transition: transform 0.3s ease, opacity 0.3s ease; } +.toolbar-container { position: relative; padding-top: 10px; display: flex; flex-direction: column; align-items: center; } .toolbar-drag-handle { position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 50px; height: 6px; background-color: rgba(0, 0, 0, 0.2); border-radius: 3px; cursor: move; } .toolbar { display: flex; align-items: center; gap: 10px; padding: 10px; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.2); box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); } .toolbar button { background: transparent; border: none; padding: 8px; border-radius: 12px; cursor: pointer; transition: background 0.2s ease-in-out, opacity 0.2s ease-in-out; display: flex; align-items: center; justify-content: center; } @@ -27,24 +27,77 @@ canvas { display: block; position: absolute; top: 0; left: 0; } .toolbar button:hover { background: rgba(255, 255, 255, 0.3); } .toolbar button.active { background: rgba(135, 206, 250, 0.5); } .toolbar button:disabled { cursor: not-allowed; opacity: 0.4; } -.toolbar button:disabled:hover { background: transparent; } -.toolbar-separator { width: 1px; height: 24px; background-color: rgba(0, 0, 0, 0.15); margin: 0 2px; } -.tool-container { position: relative; } -.tool-options { display: none; position: absolute; bottom: calc(100% + 25px); left: 50%; transform: translateX(-50%); background: rgba(255, 255, 255, 0.9); backdrop-filter: blur(200px); border-radius: 8px; padding: 5px; box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37); min-width: 180px; z-index: 1001; } +.toolbar button:disabled:hover { background: transparent; } +.toolbar-separator { width: 1px; height: 24px; background-color: rgba(0, 0, 0, 0.15); margin: 0 2px; } +.tool-container { position: relative; } +.tool-options { display: none; position: absolute; bottom: calc(100% + 25px); left: 50%; transform: translateX(-50%); background: rgba(255, 255, 255, 0.9); backdrop-filter: blur(200px); border-radius: 8px; padding: 5px; box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37); min-width: 180px; z-index: 1001; } .tool-options a { display: flex; align-items: center; gap: 8px; padding: 8px 12px; color: #333; text-decoration: none; border-radius: 6px; white-space: nowrap; } .tool-options a svg { width: 20px; height: 20px; stroke: #333; } .tool-options a:hover { background-color: rgba(0, 0, 0, 0.2); } .tool-container.active .tool-options { display: block; } -.sub-toolbar-container { position: relative; height: 50px; } -.sub-toolbar { display: flex; align-items: center; justify-content: space-between; width: auto; padding: 8px 15px; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(12px); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.3); box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); transition: opacity 0.2s ease, transform 0.2s ease; box-sizing: border-box; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); } -.sub-toolbar.hidden { opacity: 0; pointer-events: none; transform: translateX(-50%) translateY(10px); } -.color-palette { display: flex; align-items: center; gap: 5px; } -.color-dot { width: 12px; height: 12px; border-radius: 50%; cursor: pointer; border: 1px solid transparent; transition: transform 0.15s ease; box-sizing: border-box; } +.sub-toolbar-container { width: 100%; display: flex; justify-content: center; } +.sub-toolbar { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 10px 18px; gap: 20px; background: rgba(255, 255, 255, 0.18); backdrop-filter: blur(14px); border-radius: 18px; border: 1px solid rgba(255, 255, 255, 0.35); box-shadow: 0 10px 36px 0 rgba(31, 38, 135, 0.35); box-sizing: border-box; margin-bottom: 12px; flex-wrap: nowrap; } +.sub-toolbar.hidden { display: none; } +.color-palette { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: rgba(255, 255, 255, 0.55); border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.4); box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.4); flex: 0 1 auto; } +.color-dot { width: 16px; height: 16px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; transition: transform 0.15s ease, border-color 0.15s ease; box-sizing: border-box; } .color-dot[data-color="#FFFFFF"] { border-color: #ccc; } .color-dot:hover { transform: scale(1.15); } -.color-dot.active { border-color: #007AFF; transform: scale(1.15); } -.size-editor { display: flex; align-items: center; gap: 10px; } -input[type="range"] { width: 120px; } +.color-dot.active { border-color: #007AFF; transform: scale(1.1); } +.size-editor { display: flex; align-items: center; gap: 12px; padding: 6px 12px; background: rgba(255, 255, 255, 0.55); border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.4); box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.35); flex: 1 1 200px; justify-content: flex-end; } +.size-editor::before { content: "Размер"; font-size: 13px; font-weight: 500; color: #333; text-transform: uppercase; letter-spacing: 0.04em; } +input[type="range"] { width: 100%; max-width: 220px; accent-color: #007AFF; } + +@media (max-width: 1024px) { + .toolbar-wrapper { bottom: 16px; } + .toolbar { gap: 8px; } +} + +@media (max-width: 768px) { + .logo-container { top: 12px; left: 12px; } + #logo { width: 44px; height: 44px; } + .settings-menu { min-width: 160px; padding: 12px; } + + .zoom-controls { top: 12px; bottom: auto; right: 12px; flex-direction: row; align-items: center; gap: 6px; padding: 6px; } + .zoom-controls button { width: 32px; height: 32px; border-radius: 10px; } + .zoom-controls button svg { width: 18px; height: 18px; } + + .toolbar-wrapper { width: calc(100% - 32px); left: 50%; transform: translateX(-50%); } + .toolbar-container { width: 100%; } + .toolbar-content-slider { width: 100%; } + .toolbar { flex-wrap: nowrap; justify-content: center; padding: 6px 8px; gap: 4px; } + .toolbar button { padding: 5px; } + .toolbar button svg { width: 18px; height: 18px; } + .toolbar-separator { display: none; } + + .sub-toolbar { width: 100%; padding: 8px 12px; gap: 14px; margin-bottom: 10px; } + .color-palette { gap: 6px; padding: 6px 8px; } + .size-editor { gap: 10px; padding: 6px 10px; } + .size-editor::before { margin-right: 4px; font-size: 12px; } + input[type="range"] { max-width: 160px; } + + .floating-toolbar { flex-wrap: wrap; gap: 4px; } + .floating-toolbar .toolbar-select { font-size: 13px; } + .floating-toolbar .toolbar-font-size { width: 40px; } +} + +@media (max-width: 480px) { + body, html { font-size: 14px; } + + .zoom-controls { top: 10px; right: 10px; } + .zoom-controls button { width: 28px; height: 28px; border-radius: 8px; } + .zoom-controls button svg { width: 16px; height: 16px; } + + .toolbar { gap: 4px; } + .toolbar button { padding: 4px; border-radius: 10px; } + .toolbar button svg { width: 16px; height: 16px; } + + .sub-toolbar { padding: 8px 10px; gap: 10px; margin-bottom: 8px; } + .color-palette, + .size-editor { padding: 6px 8px; } + .color-dot { width: 14px; height: 14px; } + .size-editor::before { font-size: 11px; } + input[type="range"] { max-width: 130px; } +} .toggle-toolbar { width: 28px; height: 28px; padding: 4px; border-radius: 50%; border: 1px solid rgba(0, 0, 0, 0.08); background: #f0f2f5; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); cursor: pointer; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; margin-bottom: 15px; margin-right: 10px; z-index: 1; } .toggle-toolbar:hover { transform: scale(1.1); } @@ -285,11 +338,6 @@ body.dark-theme .modal-main::-webkit-scrollbar-thumb:hover { top: 10px; left: 10px; } - .zoom-controls { - top: 10px; - right: 10px; - } - .modal-panel li { display: block; text-align: left; @@ -299,6 +347,13 @@ body.dark-theme .modal-main::-webkit-scrollbar-thumb:hover { text-align: left; margin-bottom: 8px; } -} -/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */ -/* --- END OF FILE style.css --- */ \ No newline at end of file +} +/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */ +@media (max-width: 800px) and (min-width: 769px) { + .zoom-controls { + top: 10px; + right: 10px; + bottom: auto; + } +} +/* --- END OF FILE style.css --- */ From 0a34bab15b9be8678b28ddbfaf7a7b0e6f1a6275 Mon Sep 17 00:00:00 2001 From: LDRoff Date: Wed, 1 Oct 2025 00:39:27 +0300 Subject: [PATCH 2/8] Improve toolbar UX and add touch gestures --- css/style.css | 49 +++++++++----- js/canvas.js | 180 ++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 185 insertions(+), 44 deletions(-) diff --git a/css/style.css b/css/style.css index 9abbc1a..0cfa5d4 100644 --- a/css/style.css +++ b/css/style.css @@ -17,8 +17,8 @@ canvas { display: block; position: absolute; top: 0; left: 0; } .zoom-controls button:hover { background: rgba(255, 255, 255, 0.3); } .zoom-controls button.active { background: rgba(135, 206, 250, 0.5); } -.toolbar-wrapper { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; display: flex; align-items: flex-end; } -.toolbar-content-slider { display: flex; flex-direction: column; align-items: center; gap: 10px; transition: transform 0.3s ease, opacity 0.3s ease; } +.toolbar-wrapper { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; display: flex; align-items: flex-end; padding-top: 52px; } +.toolbar-content-slider { display: flex; flex-direction: column; align-items: center; gap: 10px; transition: transform 0.3s ease, opacity 0.3s ease; position: relative; } .toolbar-container { position: relative; padding-top: 10px; display: flex; flex-direction: column; align-items: center; } .toolbar-drag-handle { position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 50px; height: 6px; background-color: rgba(0, 0, 0, 0.2); border-radius: 3px; cursor: move; } .toolbar { display: flex; align-items: center; gap: 10px; padding: 10px; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.2); box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); } @@ -36,7 +36,7 @@ canvas { display: block; position: absolute; top: 0; left: 0; } .tool-options a:hover { background-color: rgba(0, 0, 0, 0.2); } .tool-container.active .tool-options { display: block; } .sub-toolbar-container { width: 100%; display: flex; justify-content: center; } -.sub-toolbar { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 10px 18px; gap: 20px; background: rgba(255, 255, 255, 0.18); backdrop-filter: blur(14px); border-radius: 18px; border: 1px solid rgba(255, 255, 255, 0.35); box-shadow: 0 10px 36px 0 rgba(31, 38, 135, 0.35); box-sizing: border-box; margin-bottom: 12px; flex-wrap: nowrap; } +.sub-toolbar { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 10px 18px; gap: 20px; background: rgba(255, 255, 255, 0.18); backdrop-filter: blur(14px); border-radius: 18px; border: 1px solid rgba(255, 255, 255, 0.35); box-shadow: 0 10px 36px 0 rgba(31, 38, 135, 0.35); box-sizing: border-box; margin-bottom: 12px; flex-wrap: nowrap; min-width: 0; } .sub-toolbar.hidden { display: none; } .color-palette { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: rgba(255, 255, 255, 0.55); border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.4); box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.4); flex: 0 1 auto; } .color-dot { width: 16px; height: 16px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; transition: transform 0.15s ease, border-color 0.15s ease; box-sizing: border-box; } @@ -44,7 +44,8 @@ canvas { display: block; position: absolute; top: 0; left: 0; } .color-dot:hover { transform: scale(1.15); } .color-dot.active { border-color: #007AFF; transform: scale(1.1); } .size-editor { display: flex; align-items: center; gap: 12px; padding: 6px 12px; background: rgba(255, 255, 255, 0.55); border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.4); box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.35); flex: 1 1 200px; justify-content: flex-end; } -.size-editor::before { content: "Размер"; font-size: 13px; font-weight: 500; color: #333; text-transform: uppercase; letter-spacing: 0.04em; } +.size-editor::before { content: ""; width: 18px; height: 18px; border-radius: 50%; border: 2px solid rgba(51, 51, 51, 0.8); box-shadow: inset 0 0 0 4px rgba(51, 51, 51, 0.15); display: inline-flex; flex-shrink: 0; pointer-events: none; } +.size-editor input[type="range"] { min-width: 0; } input[type="range"] { width: 100%; max-width: 220px; accent-color: #007AFF; } @media (max-width: 1024px) { @@ -61,7 +62,7 @@ input[type="range"] { width: 100%; max-width: 220px; accent-color: #007AFF; } .zoom-controls button { width: 32px; height: 32px; border-radius: 10px; } .zoom-controls button svg { width: 18px; height: 18px; } - .toolbar-wrapper { width: calc(100% - 32px); left: 50%; transform: translateX(-50%); } + .toolbar-wrapper { width: calc(100% - 32px); left: 50%; transform: translateX(-50%); padding-top: 48px; } .toolbar-container { width: 100%; } .toolbar-content-slider { width: 100%; } .toolbar { flex-wrap: nowrap; justify-content: center; padding: 6px 8px; gap: 4px; } @@ -72,9 +73,11 @@ input[type="range"] { width: 100%; max-width: 220px; accent-color: #007AFF; } .sub-toolbar { width: 100%; padding: 8px 12px; gap: 14px; margin-bottom: 10px; } .color-palette { gap: 6px; padding: 6px 8px; } .size-editor { gap: 10px; padding: 6px 10px; } - .size-editor::before { margin-right: 4px; font-size: 12px; } + .size-editor::before { margin-right: 0; width: 16px; height: 16px; border-width: 1.6px; } input[type="range"] { max-width: 160px; } + .toggle-toolbar { top: 4px; right: 12px; } + .floating-toolbar { flex-wrap: wrap; gap: 4px; } .floating-toolbar .toolbar-select { font-size: 13px; } .floating-toolbar .toolbar-font-size { width: 40px; } @@ -95,14 +98,25 @@ input[type="range"] { width: 100%; max-width: 220px; accent-color: #007AFF; } .color-palette, .size-editor { padding: 6px 8px; } .color-dot { width: 14px; height: 14px; } - .size-editor::before { font-size: 11px; } + .size-editor::before { width: 14px; height: 14px; border-width: 1.4px; } input[type="range"] { max-width: 130px; } + + .toolbar-wrapper { padding-top: 44px; } + .toggle-toolbar { top: 2px; right: 8px; width: 30px; height: 30px; padding: 5px; } +} + +@media (max-width: 600px) { + .modal-photoshop { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; width: 100%; max-width: 420px; height: auto; max-height: calc(100vh - 40px); } + .modal-sidebar { grid-row: auto; grid-column: 1; display: flex; gap: 6px; padding: 10px 12px; border-right: none; border-bottom: 1px solid rgba(255, 255, 255, 0.2); border-radius: 16px 16px 0 0; overflow-x: auto; } + .sidebar-button { flex: 1 0 auto; text-align: center; } + .modal-main { padding: 16px; } + .modal-footer { grid-column: 1; grid-row: 3; padding: 16px; } } -.toggle-toolbar { width: 28px; height: 28px; padding: 4px; border-radius: 50%; border: 1px solid rgba(0, 0, 0, 0.08); background: #f0f2f5; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); cursor: pointer; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; margin-bottom: 15px; margin-right: 10px; z-index: 1; } +.toggle-toolbar { width: 32px; height: 32px; padding: 6px; border-radius: 50%; border: 1px solid rgba(0, 0, 0, 0.08); background: #f0f2f5; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); cursor: pointer; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; position: absolute; top: 10px; right: 16px; z-index: 1; } .toggle-toolbar:hover { transform: scale(1.1); } .toggle-toolbar svg { width: 20px; height: 20px; transition: transform 0.3s ease; } -.toolbar-wrapper.collapsed .toolbar-content-slider { transform: translateX(calc(-100% - 10px)); opacity: 0; pointer-events: none; } +.toolbar-wrapper.collapsed .toolbar-content-slider { transform: translateX(calc(-100% - 20px)); opacity: 0; pointer-events: none; } .toolbar-wrapper.collapsed .toggle-toolbar svg { transform: rotate(180deg); } .floating-toolbar { position: absolute; display: none; align-items: center; gap: 5px; padding: 5px; background: white; border-radius: 12px; box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.15); z-index: 1001; transition: opacity 0.1s ease-in-out; } @@ -122,9 +136,9 @@ input[type="range"] { width: 100%; max-width: 220px; accent-color: #007AFF; } } .color-picker-wrapper.active .floating-palette { visibility: visible; opacity: 1; } -.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.3); display: flex; justify-content: center; align-items: center; z-index: 2000; } -.modal-overlay.hidden { display: none; } -.modal-photoshop { display: grid; grid-template-columns: 160px 1fr; grid-template-rows: 1fr auto; width: 550px; height: 350px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(15px); -webkit-backdrop-filter: blur(15px); border: 1px solid rgba(255, 255, 255, 0.2); box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); color: #333; border-radius: 16px; font-size: 13px; } +.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.3); display: flex; justify-content: center; align-items: center; z-index: 2000; padding: 20px; box-sizing: border-box; } +.modal-overlay.hidden { display: none; } +.modal-photoshop { display: grid; grid-template-columns: 160px 1fr; grid-template-rows: 1fr auto; width: min(550px, 90vw); height: min(350px, 80vh); max-height: calc(100vh - 40px); background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(15px); -webkit-backdrop-filter: blur(15px); border: 1px solid rgba(255, 255, 255, 0.2); box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); color: #333; border-radius: 16px; font-size: 13px; overflow: hidden; } .modal-sidebar { grid-row: 1 / 3; border-right: 1px solid rgba(255, 255, 255, 0.2); padding: 10px 0; border-radius: 16px 0 0 16px; } .sidebar-button { display: block; width: 100%; background: none; border: none; color: #333; padding: 8px 15px; text-align: left; cursor: pointer; font-size: 13px; border-radius: 0; } .sidebar-button:hover { background-color: rgba(255, 255, 255, 0.2); } @@ -188,10 +202,11 @@ body.dark-theme .toggle-toolbar svg, body.dark-theme .zoom-controls button svg { stroke: #f0f2f5; } body.dark-theme .settings-menu a, body.dark-theme .tool-options a { color: #f0f2f5; } -body.dark-theme .settings-menu, -body.dark-theme .toolbar, -body.dark-theme .sub-toolbar { background: rgba(44, 62, 80, 0.5); border: 1px solid rgba(255, 255, 255, 0.1); } -body.dark-theme .tool-options { background: rgba(44, 62, 80, 0.9); } +body.dark-theme .settings-menu, +body.dark-theme .toolbar, +body.dark-theme .sub-toolbar { background: rgba(44, 62, 80, 0.5); border: 1px solid rgba(255, 255, 255, 0.1); } +body.dark-theme .size-editor::before { border-color: rgba(240, 242, 245, 0.85); box-shadow: inset 0 0 0 4px rgba(240, 242, 245, 0.18); } +body.dark-theme .tool-options { background: rgba(44, 62, 80, 0.9); } body.dark-theme .settings-menu a:hover, body.dark-theme .tool-options a:hover, body.dark-theme .toolbar button:hover, @@ -202,7 +217,7 @@ body.dark-theme .zoom-controls button.active { background: rgba(0, 122, 255, 0.7 body.dark-theme .toolbar-separator { background-color: rgba(255, 255, 255, 0.15); } body.dark-theme .toggle-toolbar { background: #2c3e50; border-color: rgba(255, 255, 255, 0.1); } body.dark-theme .modal-photoshop { background: rgba(44, 62, 80, 0.5); border: 1px solid rgba(255, 255, 255, 0.1); color: #f0f2f5; } -body.dark-theme .modal-sidebar { border-right-color: rgba(255, 255, 255, 0.1); } +body.dark-theme .modal-sidebar { border-right-color: rgba(255, 255, 255, 0.1); border-bottom-color: rgba(255, 255, 255, 0.1); } body.dark-theme .modal-main h3 { border-bottom-color: rgba(255, 255, 255, 0.1); } body.dark-theme .sidebar-button { color: #f0f2f5; } body.dark-theme .sidebar-button.active { background-color: rgba(255, 255, 255, 0.2); } diff --git a/js/canvas.js b/js/canvas.js index 5a83a9f..8b095c9 100644 --- a/js/canvas.js +++ b/js/canvas.js @@ -35,15 +35,22 @@ export function initializeCanvas(canvas, ctx, redrawCallback, saveState, updateT shapeWasJustRecognized: false, eraserTrailNodes: [], eraserAnimationId: null, - lastEraserPos: { x: 0, y: 0 }, - isEditingText: false, - }; - - // --- НАЧАЛО ИЗМЕНЕНИЙ: Добавляем функцию обновления редактора в состояние холста --- + lastEraserPos: { x: 0, y: 0 }, + isEditingText: false, + activePointers: new Map(), + isPinching: false, + initialPinchDistance: 0, + initialPinchZoom: 1, + initialPinchWorld: { x: 0, y: 0 }, + }; + + // --- НАЧАЛО ИЗМЕНЕНИЙ: Добавляем функцию обновления редактора в состояние холста --- state.updateTextEditorStyle = textTool.updateEditorStyle; // --- КОНЕЦ ИЗМЕНЕНИЙ --- - state.updateFloatingToolbar = () => { + const clamp = (value, min, max) => Math.min(Math.max(value, min), max); + + state.updateFloatingToolbar = () => { const toolbar = document.getElementById('floating-text-toolbar'); const isVisible = state.isEditingText || (state.selectedLayers.length === 1 && state.selectedLayers[0].type === 'text'); @@ -99,8 +106,73 @@ export function initializeCanvas(canvas, ctx, redrawCallback, saveState, updateT } }; - const NUM_TRAIL_NODES = 15; - const EASING_FACTOR = 0.2; + const NUM_TRAIL_NODES = 15; + const EASING_FACTOR = 0.2; + + function updateTouchPointer(e) { + if (e.pointerType !== 'touch') return; + const rect = canvas.getBoundingClientRect(); + state.activePointers.set(e.pointerId, { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } + + function removeTouchPointer(e) { + if (e.pointerType !== 'touch') return; + state.activePointers.delete(e.pointerId); + } + + function beginPinchGesture() { + if (state.activePointers.size < 2) return false; + const pointers = Array.from(state.activePointers.values()).slice(0, 2); + const [first, second] = pointers; + const distance = Math.hypot(second.x - first.x, second.y - first.y) || 1; + + if (state.isDrawing || state.currentAction.startsWith('drawing')) { + state.isDrawing = false; + state.currentAction = 'none'; + state.tempLayer = null; + } + + if (state.eraserAnimationId) { + cancelAnimationFrame(state.eraserAnimationId); + state.eraserAnimationId = null; + } + + state.isPinching = true; + state.initialPinchDistance = distance; + state.initialPinchZoom = state.zoom; + const center = { + x: (first.x + second.x) / 2, + y: (first.y + second.y) / 2, + }; + state.initialPinchWorld = { + x: (center.x - state.panX) / state.zoom, + y: (center.y - state.panY) / state.zoom, + }; + return true; + } + + function updatePinchGesture() { + if (!state.isPinching || state.activePointers.size < 2) return; + const pointers = Array.from(state.activePointers.values()).slice(0, 2); + const [first, second] = pointers; + const distance = Math.hypot(second.x - first.x, second.y - first.y) || 1; + const scale = distance / (state.initialPinchDistance || distance); + const newZoom = clamp(state.initialPinchZoom * scale, 0.1, 10); + const center = { + x: (first.x + second.x) / 2, + y: (first.y + second.y) / 2, + }; + + state.zoom = newZoom; + state.panX = center.x - state.initialPinchWorld.x * newZoom; + state.panY = center.y - state.initialPinchWorld.y * newZoom; + + redrawCallback(); + state.updateFloatingToolbar(); + } function animateEraserTrail() { state.eraserAnimationId = requestAnimationFrame(animateEraserTrail); @@ -194,11 +266,23 @@ export function initializeCanvas(canvas, ctx, redrawCallback, saveState, updateT function handleTripleClick(pos) { const layer = hitTest.getLayerAtPosition(pos, state.layers); if (layer) { state.isDrawing = false; state.selectedLayers = [layer]; const selectButton = document.querySelector('button[data-tool="select"]'); if (selectButton && state.activeTool !== 'select') { selectButton.click(); } else { redrawCallback(); } updateToolbarCallback(); return true; } return false; } - function startDrawing(e) { - if (state.isEditingText) return; - const pos = getMousePos(e); - - // --- НАЧАЛО ИЗМЕНЕНИЙ: Логика для быстрого редактирования текста --- + function startDrawing(e) { + if (state.isEditingText) return; + if (e.pointerType === 'touch') { + updateTouchPointer(e); + if (state.activePointers.size >= 2) { + if (!state.isPinching) { + beginPinchGesture(); + } + updatePinchGesture(); + return; + } else { + state.isPinching = false; + } + } + const pos = getMousePos(e); + + // --- НАЧАЛО ИЗМЕНЕНИЙ: Логика для быстрого редактирования текста --- if (state.activeTool === 'text') { const clickedLayer = hitTest.getLayerAtPosition(pos, state.layers); if (clickedLayer && clickedLayer.type === 'text') { @@ -438,11 +522,31 @@ export function initializeCanvas(canvas, ctx, redrawCallback, saveState, updateT } } - function draw(e) { - if (state.isEditingText) return; - - if (state.isPanning) { const dx = e.clientX - state.panStartPos.x; const dy = e.clientY - state.panStartPos.y; state.panX += dx; state.panY += dy; state.panStartPos = { x: e.clientX, y: e.clientY }; redrawCallback(); state.updateFloatingToolbar(); return; } - const pos = getMousePos(e); + function draw(e) { + if (state.isEditingText) return; + + if (e.pointerType === 'touch') { + updateTouchPointer(e); + if (state.activePointers.size >= 2) { + if (!state.isPinching) { + beginPinchGesture(); + } + updatePinchGesture(); + return; + } + + if (state.isPinching) { + updatePinchGesture(); + return; + } + } + + if (state.isPinching) { + return; + } + + if (state.isPanning) { const dx = e.clientX - state.panStartPos.x; const dy = e.clientY - state.panStartPos.y; state.panX += dx; state.panY += dy; state.panStartPos = { x: e.clientX, y: e.clientY }; redrawCallback(); state.updateFloatingToolbar(); return; } + const pos = getMousePos(e); if (!state.isDrawing && state.currentAction === 'none') { const layerAtPos = hitTest.getLayerAtPosition(pos, state.layers); @@ -517,11 +621,27 @@ export function initializeCanvas(canvas, ctx, redrawCallback, saveState, updateT } } - function stopDrawing(e) { - if (state.isEditingText) return; - - clearTimeout(state.shapeRecognitionTimer); - if (state.eraserAnimationId) { + function stopDrawing(e) { + if (state.isEditingText) return; + + if (e.pointerType === 'touch') { + removeTouchPointer(e); + if (state.isPinching) { + if (state.activePointers.size < 2) { + state.isPinching = false; + state.initialPinchDistance = 0; + if (state.activePointers.size === 0) { + state.initialPinchWorld = { x: 0, y: 0 }; + } + } + redrawCallback(); + state.updateFloatingToolbar(); + return; + } + } + + clearTimeout(state.shapeRecognitionTimer); + if (state.eraserAnimationId) { cancelAnimationFrame(state.eraserAnimationId); state.eraserAnimationId = null; redrawCallback(); @@ -770,10 +890,16 @@ export function initializeCanvas(canvas, ctx, redrawCallback, saveState, updateT } } - canvas.addEventListener('pointerdown', startDrawing); - canvas.addEventListener('pointermove', draw); - canvas.addEventListener('pointerup', stopDrawing); - canvas.addEventListener('pointerleave', (e) => { if (state.isDrawing || state.currentAction !== 'none' || state.isPanning) { stopDrawing(e); } state.isPanning = false; }); + canvas.addEventListener('pointerdown', startDrawing); + canvas.addEventListener('pointermove', draw); + canvas.addEventListener('pointerup', stopDrawing); + canvas.addEventListener('pointerleave', (e) => { + if (state.isDrawing || state.currentAction !== 'none' || state.isPanning || state.isPinching || (e.pointerType === 'touch' && state.activePointers.size > 0)) { + stopDrawing(e); + } + state.isPanning = false; + }); + canvas.addEventListener('pointercancel', stopDrawing); canvas.addEventListener('wheel', (e) => { e.preventDefault(); From 9da48bbd0c7aa7fb3a75c8f8afeea74c2369e152 Mon Sep 17 00:00:00 2001 From: LDRoff Date: Wed, 1 Oct 2025 17:26:13 +0300 Subject: [PATCH 3/8] Restyle toolbar UI with glassmorphism --- css/style.css | 312 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 189 insertions(+), 123 deletions(-) diff --git a/css/style.css b/css/style.css index 0cfa5d4..ef17d7f 100644 --- a/css/style.css +++ b/css/style.css @@ -1,52 +1,124 @@ /* --- START OF FILE style.css --- */ -body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: #f0f2f5; font-family: sans-serif; } +:root { + --app-background: radial-gradient(circle at 20% 20%, #fefefe 0%, #d0dcff 28%, #c1e4ff 45%, #f6d1ff 65%, #c6d9ff 85%, #d3ddff 100%); + --app-background-overlay: radial-gradient(90% 140% at 80% 10%, rgba(255, 255, 255, 0.7) 0%, rgba(255, 255, 255, 0.05) 45%, rgba(255, 255, 255, 0) 70%), radial-gradient(120% 160% at -10% 80%, rgba(126, 197, 255, 0.35) 0%, rgba(249, 173, 255, 0.25) 35%, rgba(104, 117, 255, 0) 70%); + --glass-surface: radial-gradient(circle at 0% 0%, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.08) 45%, rgba(255, 255, 255, 0.04) 65%), linear-gradient(135deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.1)); + --glass-border: rgba(255, 255, 255, 0.55); + --glass-border-strong: rgba(255, 255, 255, 0.7); + --glass-shadow: 0 25px 65px rgba(26, 43, 92, 0.25); + --glass-glow: 0 0 40px rgba(120, 162, 255, 0.35); + --glass-highlight: linear-gradient(125deg, rgba(255, 255, 255, 0.75) 0%, rgba(255, 255, 255, 0.35) 12%, rgba(255, 255, 255, 0) 45%); + --glass-inner-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2); + --glass-gradient-mask: radial-gradient(120% 100% at 50% -20%, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0)); + --accent-color: #007AFF; +} + +body.dark-theme { + --app-background: radial-gradient(circle at 15% 20%, #1b2337 0%, #1a2d46 30%, #111b2a 70%, #0a101a 100%); + --app-background-overlay: radial-gradient(110% 140% at 90% -10%, rgba(137, 200, 255, 0.18), rgba(137, 200, 255, 0)), radial-gradient(160% 140% at -15% 90%, rgba(126, 59, 255, 0.3), rgba(126, 59, 255, 0)); + --glass-surface: radial-gradient(circle at 0% 10%, rgba(74, 92, 128, 0.5), rgba(34, 48, 76, 0.45) 45%, rgba(18, 26, 39, 0.35) 70%), linear-gradient(135deg, rgba(91, 110, 150, 0.35), rgba(24, 34, 52, 0.5)); + --glass-border: rgba(255, 255, 255, 0.12); + --glass-border-strong: rgba(255, 255, 255, 0.25); + --glass-shadow: 0 30px 70px rgba(0, 0, 0, 0.5); + --glass-glow: 0 0 32px rgba(54, 94, 255, 0.35); + --glass-highlight: linear-gradient(130deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0.12) 15%, rgba(255, 255, 255, 0) 45%); + --glass-inner-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + --glass-gradient-mask: radial-gradient(120% 90% at 50% -10%, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0)); +} + +body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: var(--app-background); font-family: sans-serif; position: relative; color: #1c2333; } + +body::before, +body::after { + content: ""; + position: fixed; + inset: -20% -10% auto; + height: 120vh; + background: var(--app-background-overlay); + filter: blur(40px) saturate(150%); + opacity: 0.9; + pointer-events: none; + z-index: 0; +} + +body::after { + inset: auto -5% -20%; + height: 100vh; + transform: translateY(10%); + opacity: 0.65; +} + +body.dark-theme, +body.dark-theme html { + color: #f0f4ff; +} canvas { display: block; position: absolute; top: 0; left: 0; } #backgroundCanvas { z-index: 1; background-color: #ffffff; } #drawingBoard { z-index: 2; background-color: transparent; touch-action: none; } .logo-container { position: absolute; top: 20px; left: 20px; z-index: 1001; } -#logo { width: 50px; height: 50px; border-radius: 50%; cursor: pointer; background-color: rgba(255, 255, 255, 0.2); box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37); transition: transform 0.2s; display: flex; align-items: center; justify-content: center; } -#logo:hover { transform: scale(1.1); } -#logo svg { width: 28px; height: 28px; stroke: #333; } -.settings-menu { display: none; position: absolute; top: 65px; left: 0; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 12px; padding: 15px; box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); min-width: 180px; } -.settings-menu a { display: block; padding: 8px 12px; color: #333; text-decoration: none; border-radius: 8px; } -.settings-menu a:hover { background-color: rgba(0, 0, 0, 0.2); } +#logo { width: 50px; height: 50px; border-radius: 50%; cursor: pointer; background: var(--glass-surface); box-shadow: var(--glass-shadow), var(--glass-glow); border: 1px solid var(--glass-border-strong); backdrop-filter: blur(24px) saturate(160%); -webkit-backdrop-filter: blur(24px) saturate(160%); transition: transform 0.2s, box-shadow 0.2s ease; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; } +#logo::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); mix-blend-mode: screen; opacity: 0.8; pointer-events: none; } +#logo::after { content: ""; position: absolute; inset: 0; background: var(--glass-gradient-mask); opacity: 0.6; pointer-events: none; } +#logo:hover { transform: scale(1.08); box-shadow: 0 20px 50px rgba(124, 144, 255, 0.35); } +#logo svg { width: 28px; height: 28px; stroke: #333; position: relative; z-index: 1; } +.settings-menu { display: none; position: absolute; top: 65px; left: 0; background: var(--glass-surface); backdrop-filter: blur(28px) saturate(165%); -webkit-backdrop-filter: blur(28px) saturate(165%); border: 1px solid var(--glass-border); border-radius: 16px; padding: 18px; box-shadow: var(--glass-shadow); min-width: 200px; overflow: hidden; } +.settings-menu::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.settings-menu::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)); mix-blend-mode: soft-light; pointer-events: none; } +.settings-menu a { display: block; padding: 10px 14px; color: rgba(35, 44, 70, 0.85); text-decoration: none; border-radius: 10px; transition: transform 0.2s ease, background 0.2s ease; position: relative; z-index: 1; } +.settings-menu a:hover { background-color: rgba(255, 255, 255, 0.35); transform: translateX(3px); } -.zoom-controls { position: absolute; top: 20px; right: 20px; z-index: 1000; display: flex; flex-direction: column; gap: 8px; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.2); box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); padding: 8px; } -.zoom-controls button { background: transparent; border: none; width: 40px; height: 40px; border-radius: 12px; cursor: pointer; transition: background 0.2s ease-in-out; display: flex; align-items: center; justify-content: center; } -.zoom-controls button svg { width: 24px; height: 24px; stroke: #333; pointer-events: none; } -.zoom-controls button:hover { background: rgba(255, 255, 255, 0.3); } -.zoom-controls button.active { background: rgba(135, 206, 250, 0.5); } +.zoom-controls { position: absolute; top: 20px; right: 20px; z-index: 1000; display: flex; flex-direction: column; gap: 8px; background: var(--glass-surface); backdrop-filter: blur(28px) saturate(165%); -webkit-backdrop-filter: blur(28px) saturate(165%); border-radius: 18px; border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); padding: 10px; overflow: hidden; } +.zoom-controls::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.zoom-controls::after { content: ""; position: absolute; inset: 0; background: radial-gradient(90% 140% at 100% 0%, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0)); pointer-events: none; mix-blend-mode: screen; } +.zoom-controls button { background: linear-gradient(135deg, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0.15)); border: 1px solid rgba(255, 255, 255, 0.45); width: 42px; height: 42px; border-radius: 14px; cursor: pointer; transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; display: flex; align-items: center; justify-content: center; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 8px 18px rgba(30, 45, 90, 0.2); } +.zoom-controls button svg { width: 24px; height: 24px; stroke: rgba(28, 35, 51, 0.85); pointer-events: none; } +.zoom-controls button:hover { transform: translateY(-2px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 15px 30px rgba(30, 45, 90, 0.28); } +.zoom-controls button:active { transform: translateY(0); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 10px 18px rgba(30, 45, 90, 0.25); } +.zoom-controls button.active { background: linear-gradient(135deg, rgba(0, 122, 255, 0.9), rgba(102, 172, 255, 0.6)); border-color: rgba(255, 255, 255, 0.7); color: white; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55), 0 18px 35px rgba(0, 122, 255, 0.35); } -.toolbar-wrapper { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; display: flex; align-items: flex-end; padding-top: 52px; } +.toolbar-wrapper { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; display: flex; align-items: flex-end; padding-top: 52px; pointer-events: none; } .toolbar-content-slider { display: flex; flex-direction: column; align-items: center; gap: 10px; transition: transform 0.3s ease, opacity 0.3s ease; position: relative; } -.toolbar-container { position: relative; padding-top: 10px; display: flex; flex-direction: column; align-items: center; } -.toolbar-drag-handle { position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 50px; height: 6px; background-color: rgba(0, 0, 0, 0.2); border-radius: 3px; cursor: move; } -.toolbar { display: flex; align-items: center; gap: 10px; padding: 10px; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.2); box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); } -.toolbar button { background: transparent; border: none; padding: 8px; border-radius: 12px; cursor: pointer; transition: background 0.2s ease-in-out, opacity 0.2s ease-in-out; display: flex; align-items: center; justify-content: center; } -.toolbar button svg { width: 24px; height: 24px; stroke: #333; pointer-events: none; } -.toolbar button:hover { background: rgba(255, 255, 255, 0.3); } -.toolbar button.active { background: rgba(135, 206, 250, 0.5); } +.toolbar-container { position: relative; padding-top: 10px; display: flex; flex-direction: column; align-items: center; pointer-events: auto; } +.toolbar-drag-handle { position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 50px; height: 6px; background: linear-gradient(90deg, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.2)); border-radius: 999px; cursor: move; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 6px 14px rgba(35, 52, 94, 0.15); } +.toolbar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: var(--glass-surface); backdrop-filter: blur(28px) saturate(165%); -webkit-backdrop-filter: blur(28px) saturate(165%); border-radius: 20px; border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); position: relative; overflow: hidden; } +.toolbar::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.toolbar::after { content: ""; position: absolute; inset: 0; background: radial-gradient(140% 120% at 120% -20%, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0)); pointer-events: none; } +.toolbar button { background: linear-gradient(145deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.12)); border: 1px solid rgba(255, 255, 255, 0.45); padding: 8px; border-radius: 14px; cursor: pointer; transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, opacity 0.2s ease-in-out; display: flex; align-items: center; justify-content: center; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 10px 20px rgba(35, 52, 94, 0.18); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } +.toolbar button svg { width: 24px; height: 24px; stroke: rgba(30, 40, 60, 0.85); pointer-events: none; } +.toolbar button:hover { transform: translateY(-2px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 16px 30px rgba(35, 52, 94, 0.25); } +.toolbar button:active { transform: translateY(0); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 12px 24px rgba(35, 52, 94, 0.22); } +.toolbar button.active { background: linear-gradient(135deg, rgba(0, 122, 255, 0.95), rgba(102, 172, 255, 0.6)); border-color: rgba(255, 255, 255, 0.75); color: #fff; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 18px 36px rgba(0, 122, 255, 0.35); } .toolbar button:disabled { cursor: not-allowed; opacity: 0.4; } .toolbar button:disabled:hover { background: transparent; } -.toolbar-separator { width: 1px; height: 24px; background-color: rgba(0, 0, 0, 0.15); margin: 0 2px; } +.toolbar-separator { width: 1px; height: 24px; background: linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.1)); margin: 0 2px; opacity: 0.8; } .tool-container { position: relative; } -.tool-options { display: none; position: absolute; bottom: calc(100% + 25px); left: 50%; transform: translateX(-50%); background: rgba(255, 255, 255, 0.9); backdrop-filter: blur(200px); border-radius: 8px; padding: 5px; box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37); min-width: 180px; z-index: 1001; } -.tool-options a { display: flex; align-items: center; gap: 8px; padding: 8px 12px; color: #333; text-decoration: none; border-radius: 6px; white-space: nowrap; } -.tool-options a svg { width: 20px; height: 20px; stroke: #333; } -.tool-options a:hover { background-color: rgba(0, 0, 0, 0.2); } +.tool-options { display: none; position: absolute; bottom: calc(100% + 25px); left: 50%; transform: translateX(-50%); background: var(--glass-surface); backdrop-filter: blur(32px) saturate(160%); -webkit-backdrop-filter: blur(32px) saturate(160%); border-radius: 18px; padding: 10px; box-shadow: var(--glass-shadow); min-width: 200px; z-index: 1001; overflow: hidden; border: 1px solid var(--glass-border); } +.tool-options::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.tool-options::after { content: ""; position: absolute; inset: 0; background: radial-gradient(120% 90% at 0% 0%, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0)); pointer-events: none; } +.tool-options a { display: flex; align-items: center; gap: 10px; padding: 10px 14px; color: rgba(35, 44, 70, 0.88); text-decoration: none; border-radius: 10px; white-space: nowrap; transition: background 0.2s ease, transform 0.2s ease; position: relative; z-index: 1; } +.tool-options a svg { width: 20px; height: 20px; stroke: rgba(30, 40, 60, 0.85); } +.tool-options a:hover { background-color: rgba(255, 255, 255, 0.35); transform: translateX(3px); } .tool-container.active .tool-options { display: block; } .sub-toolbar-container { width: 100%; display: flex; justify-content: center; } -.sub-toolbar { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 10px 18px; gap: 20px; background: rgba(255, 255, 255, 0.18); backdrop-filter: blur(14px); border-radius: 18px; border: 1px solid rgba(255, 255, 255, 0.35); box-shadow: 0 10px 36px 0 rgba(31, 38, 135, 0.35); box-sizing: border-box; margin-bottom: 12px; flex-wrap: nowrap; min-width: 0; } +.sub-toolbar { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 12px 20px; gap: 20px; background: var(--glass-surface); backdrop-filter: blur(30px) saturate(170%); -webkit-backdrop-filter: blur(30px) saturate(170%); border-radius: 24px; border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); box-sizing: border-box; margin-bottom: 14px; flex-wrap: nowrap; min-width: 0; position: relative; overflow: hidden; } +.sub-toolbar::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.sub-toolbar::after { content: ""; position: absolute; inset: 0; background: radial-gradient(140% 120% at -10% 0%, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0)); pointer-events: none; mix-blend-mode: screen; } .sub-toolbar.hidden { display: none; } -.color-palette { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: rgba(255, 255, 255, 0.55); border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.4); box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.4); flex: 0 1 auto; } -.color-dot { width: 16px; height: 16px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; transition: transform 0.15s ease, border-color 0.15s ease; box-sizing: border-box; } -.color-dot[data-color="#FFFFFF"] { border-color: #ccc; } -.color-dot:hover { transform: scale(1.15); } -.color-dot.active { border-color: #007AFF; transform: scale(1.1); } -.size-editor { display: flex; align-items: center; gap: 12px; padding: 6px 12px; background: rgba(255, 255, 255, 0.55); border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.4); box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.35); flex: 1 1 200px; justify-content: flex-end; } -.size-editor::before { content: ""; width: 18px; height: 18px; border-radius: 50%; border: 2px solid rgba(51, 51, 51, 0.8); box-shadow: inset 0 0 0 4px rgba(51, 51, 51, 0.15); display: inline-flex; flex-shrink: 0; pointer-events: none; } +.color-palette { display: flex; align-items: center; gap: 10px; padding: 8px 14px; background: linear-gradient(145deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.18)); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.55); box-shadow: inset 0 1px 3px rgba(255, 255, 255, 0.6), 0 12px 25px rgba(32, 40, 70, 0.12); flex: 0 1 auto; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); position: relative; overflow: hidden; } +.color-palette::after { content: ""; position: absolute; inset: 0; background: radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0)); pointer-events: none; } +.color-dot { width: 18px; height: 18px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; box-sizing: border-box; box-shadow: 0 6px 14px rgba(30, 42, 70, 0.15); } +.color-dot[data-color="#FFFFFF"] { border-color: rgba(180, 180, 180, 0.8); } +.color-dot:hover { transform: scale(1.15); box-shadow: 0 10px 18px rgba(30, 42, 70, 0.2); } +.color-dot.active { border-color: rgba(0, 122, 255, 0.8); transform: scale(1.1); box-shadow: 0 12px 24px rgba(0, 122, 255, 0.25); } +.size-editor { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: linear-gradient(145deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.18)); border-radius: 18px; border: 1px solid rgba(255, 255, 255, 0.55); box-shadow: inset 0 1px 3px rgba(255, 255, 255, 0.6), 0 15px 28px rgba(32, 40, 70, 0.12); flex: 1 1 200px; justify-content: flex-end; position: relative; overflow: hidden; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } +.size-editor::before { content: ""; width: 20px; height: 20px; border-radius: 50%; border: 2px solid rgba(51, 51, 51, 0.5); box-shadow: inset 0 0 0 5px rgba(51, 51, 51, 0.12), 0 6px 12px rgba(35, 45, 75, 0.25); display: inline-flex; flex-shrink: 0; pointer-events: none; background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(200, 200, 200, 0.3)); } +.size-editor::after { content: ""; position: absolute; inset: 0; background: radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0)); pointer-events: none; } .size-editor input[type="range"] { min-width: 0; } -input[type="range"] { width: 100%; max-width: 220px; accent-color: #007AFF; } +input[type="range"] { width: 100%; max-width: 220px; accent-color: var(--accent-color); background: transparent; } +input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(180, 207, 255, 0.6)); border: 1px solid rgba(0, 0, 0, 0.05); box-shadow: 0 6px 12px rgba(30, 45, 90, 0.2); margin-top: -7px; } +input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 999px; background: linear-gradient(90deg, rgba(0, 122, 255, 0.75), rgba(153, 204, 255, 0.35)); } +input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; border: none; border-radius: 50%; background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(180, 207, 255, 0.6)); box-shadow: 0 6px 12px rgba(30, 45, 90, 0.2); } +input[type="range"]::-moz-range-track { height: 4px; border-radius: 999px; background: linear-gradient(90deg, rgba(0, 122, 255, 0.75), rgba(153, 204, 255, 0.35)); } @media (max-width: 1024px) { .toolbar-wrapper { bottom: 16px; } @@ -113,37 +185,48 @@ input[type="range"] { width: 100%; max-width: 220px; accent-color: #007AFF; } .modal-footer { grid-column: 1; grid-row: 3; padding: 16px; } } -.toggle-toolbar { width: 32px; height: 32px; padding: 6px; border-radius: 50%; border: 1px solid rgba(0, 0, 0, 0.08); background: #f0f2f5; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); cursor: pointer; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; position: absolute; top: 10px; right: 16px; z-index: 1; } -.toggle-toolbar:hover { transform: scale(1.1); } -.toggle-toolbar svg { width: 20px; height: 20px; transition: transform 0.3s ease; } +.toggle-toolbar { width: 34px; height: 34px; padding: 6px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.45); background: linear-gradient(145deg, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0.18)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 10px 20px rgba(30, 45, 90, 0.2); cursor: pointer; transition: transform 0.3s ease, box-shadow 0.3s ease; display: flex; align-items: center; justify-content: center; position: absolute; top: 10px; right: 16px; z-index: 1; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } +.toggle-toolbar:hover { transform: translateY(-2px) scale(1.03); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 16px 30px rgba(30, 45, 90, 0.25); } +.toggle-toolbar svg { width: 20px; height: 20px; transition: transform 0.3s ease; } .toolbar-wrapper.collapsed .toolbar-content-slider { transform: translateX(calc(-100% - 20px)); opacity: 0; pointer-events: none; } .toolbar-wrapper.collapsed .toggle-toolbar svg { transform: rotate(180deg); } -.floating-toolbar { position: absolute; display: none; align-items: center; gap: 5px; padding: 5px; background: white; border-radius: 12px; box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.15); z-index: 1001; transition: opacity 0.1s ease-in-out; } -.floating-toolbar.visible { display: flex; } -.floating-toolbar button { background: transparent; border: none; padding: 6px; border-radius: 8px; cursor: pointer; transition: background 0.2s ease-in-out; display: flex; align-items: center; justify-content: center; position: relative; } -.floating-toolbar button svg { width: 20px; height: 20px; fill: #333; pointer-events: none; } -.floating-toolbar button:hover { background: #f0f2f5; } -.floating-toolbar button.active { background: #e0e8f5; } -.floating-toolbar .toolbar-select { -webkit-appearance: none; appearance: none; background: transparent; border: none; padding: 6px 8px; border-radius: 8px; color: #333; font-size: 14px; cursor: pointer; } -.floating-toolbar .toolbar-select:hover { background: #f0f2f5; } -.floating-toolbar .toolbar-font-size { width: 45px; background: transparent; border: none; border-radius: 8px; color: #333; text-align: center; font-size: 14px; padding: 6px 0; } -.floating-toolbar .toolbar-font-size:hover { background: #f0f2f5; } +.floating-toolbar { position: absolute; display: none; align-items: center; gap: 8px; padding: 8px; background: var(--glass-surface); border-radius: 18px; box-shadow: var(--glass-shadow); z-index: 1001; transition: opacity 0.1s ease-in-out; border: 1px solid var(--glass-border); overflow: hidden; backdrop-filter: blur(28px) saturate(170%); -webkit-backdrop-filter: blur(28px) saturate(170%); } +.floating-toolbar::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.floating-toolbar::after { content: ""; position: absolute; inset: 0; background: radial-gradient(130% 120% at -10% 0%, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0)); pointer-events: none; } +.floating-toolbar.visible { display: flex; } +.floating-toolbar button { background: linear-gradient(145deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.15)); border: 1px solid rgba(255, 255, 255, 0.45); padding: 6px; border-radius: 12px; cursor: pointer; transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; display: flex; align-items: center; justify-content: center; position: relative; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 10px 20px rgba(35, 52, 94, 0.18); } +.floating-toolbar button svg { width: 20px; height: 20px; fill: rgba(30, 40, 60, 0.85); pointer-events: none; } +.floating-toolbar button:hover { transform: translateY(-1px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 14px 26px rgba(35, 52, 94, 0.24); } +.floating-toolbar button.active { background: linear-gradient(135deg, rgba(0, 122, 255, 0.95), rgba(102, 172, 255, 0.6)); border-color: rgba(255, 255, 255, 0.75); color: #fff; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 18px 34px rgba(0, 122, 255, 0.32); } +.floating-toolbar .toolbar-select { -webkit-appearance: none; appearance: none; background: linear-gradient(145deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.18)); border: 1px solid rgba(255, 255, 255, 0.45); padding: 6px 10px; border-radius: 12px; color: rgba(30, 40, 60, 0.85); font-size: 14px; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); } +.floating-toolbar .toolbar-select:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 6px 14px rgba(35, 52, 94, 0.18); } +.floating-toolbar .toolbar-font-size { width: 45px; background: linear-gradient(145deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.15)); border: 1px solid rgba(255, 255, 255, 0.45); border-radius: 12px; color: rgba(30, 40, 60, 0.85); text-align: center; font-size: 14px; padding: 6px 0; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); } +.floating-toolbar .toolbar-font-size:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 6px 14px rgba(35, 52, 94, 0.18); } .floating-toolbar .color-picker-wrapper { position: relative; } .floating-toolbar .color-picker-wrapper > button svg circle { transition: fill 0.2s ease; } -.floating-palette { visibility: hidden; opacity: 0; position: absolute; top: calc(100% + 5px); left: 50%; transform: translateX(-50%); background: white; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); padding: 5px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; transition: opacity 0.2s ease, visibility 0.2s ease; - z-index: 1002; -} +.floating-palette { visibility: hidden; opacity: 0; position: absolute; top: calc(100% + 5px); left: 50%; transform: translateX(-50%); background: var(--glass-surface); border-radius: 14px; box-shadow: var(--glass-shadow); padding: 8px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; transition: opacity 0.2s ease, visibility 0.2s ease; + z-index: 1002; + border: 1px solid var(--glass-border); + backdrop-filter: blur(20px) saturate(160%); + -webkit-backdrop-filter: blur(20px) saturate(160%); + overflow: hidden; +} +.floating-palette::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } .color-picker-wrapper.active .floating-palette { visibility: visible; opacity: 1; } -.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.3); display: flex; justify-content: center; align-items: center; z-index: 2000; padding: 20px; box-sizing: border-box; } +.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.55)); display: flex; justify-content: center; align-items: center; z-index: 2000; padding: 20px; box-sizing: border-box; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); } .modal-overlay.hidden { display: none; } -.modal-photoshop { display: grid; grid-template-columns: 160px 1fr; grid-template-rows: 1fr auto; width: min(550px, 90vw); height: min(350px, 80vh); max-height: calc(100vh - 40px); background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(15px); -webkit-backdrop-filter: blur(15px); border: 1px solid rgba(255, 255, 255, 0.2); box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); color: #333; border-radius: 16px; font-size: 13px; overflow: hidden; } -.modal-sidebar { grid-row: 1 / 3; border-right: 1px solid rgba(255, 255, 255, 0.2); padding: 10px 0; border-radius: 16px 0 0 16px; } -.sidebar-button { display: block; width: 100%; background: none; border: none; color: #333; padding: 8px 15px; text-align: left; cursor: pointer; font-size: 13px; border-radius: 0; } -.sidebar-button:hover { background-color: rgba(255, 255, 255, 0.2); } -.sidebar-button.active { background-color: rgba(255, 255, 255, 0.3); font-weight: bold; } -.modal-main { padding: 20px; overflow-y: auto; } +.modal-photoshop { display: grid; grid-template-columns: 160px 1fr; grid-template-rows: 1fr auto; width: min(550px, 90vw); height: min(350px, 80vh); max-height: calc(100vh - 40px); background: var(--glass-surface); backdrop-filter: blur(36px) saturate(160%); -webkit-backdrop-filter: blur(36px) saturate(160%); border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); color: #333; border-radius: 22px; font-size: 13px; overflow: hidden; position: relative; } +.modal-photoshop::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.modal-photoshop::after { content: ""; position: absolute; inset: 0; background: radial-gradient(140% 140% at 120% -20%, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0)); pointer-events: none; } +.modal-sidebar { grid-row: 1 / 3; border-right: 1px solid rgba(255, 255, 255, 0.35); padding: 12px 0; border-radius: 22px 0 0 22px; backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px); background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0)); position: relative; } +.modal-sidebar::after { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.sidebar-button { display: block; width: 100%; background: linear-gradient(135deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0.08)); border: none; color: rgba(35, 44, 70, 0.85); padding: 10px 18px; text-align: left; cursor: pointer; font-size: 13px; border-radius: 12px; margin: 0 12px 6px; transition: transform 0.2s ease, box-shadow 0.2s ease; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 8px 18px rgba(35, 52, 94, 0.18); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); } +.sidebar-button:hover { transform: translateX(4px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 12px 24px rgba(35, 52, 94, 0.22); } +.sidebar-button.active { background: linear-gradient(135deg, rgba(0, 122, 255, 0.9), rgba(102, 172, 255, 0.6)); color: white; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 15px 28px rgba(0, 122, 255, 0.3); font-weight: 600; } +.modal-main { padding: 22px; overflow-y: auto; position: relative; } +.modal-main::after { content: ""; position: absolute; inset: 0; background: radial-gradient(120% 100% at 100% 0%, rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0)); pointer-events: none; } .modal-panel { display: none; } .modal-panel.active { display: block; } .modal-main h3 { margin-top: 0; font-size: 1.5em; font-weight: 400; border-bottom: 1px solid rgba(0, 0, 0, 0.1); padding-bottom: 10px; margin-bottom: 20px; } @@ -153,13 +236,14 @@ input[type="range"] { width: 100%; max-width: 220px; accent-color: #007AFF; } .slider-container input[type="range"] { width: 100px; } #smoothing-value { font-weight: bold; min-width: 2ch; text-align: center; } -.ps-select { -webkit-appearance: none; appearance: none; background-color: rgba(255, 255, 255, 0.3); color: #333; border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 8px; padding: 5px 25px 5px 10px; width: 150px; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23333333'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 5px center; background-size: 1.2em; } -.ps-select:focus { outline: none; border-color: #007AFF; } -.modal-footer { grid-column: 2; grid-row: 2; display: flex; justify-content: flex-end; align-items: center; padding: 20px; border-top: 1px solid rgba(255, 255, 255, 0.2); } -.ps-button { background-color: rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.3); color: #333; padding: 6px 15px; border-radius: 8px; cursor: pointer; margin-left: 10px; transition: background-color 0.2s; } -.ps-button:hover { background-color: rgba(255, 255, 255, 0.4); } -.ps-button.primary { background-color: rgba(0, 122, 255, 0.8); border: 1px solid transparent; color: #fff; } -.ps-button.primary:hover { background-color: rgba(0, 122, 255, 1); } +.ps-select { -webkit-appearance: none; appearance: none; background: linear-gradient(145deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.12)); color: rgba(35, 44, 70, 0.85); border: 1px solid rgba(255, 255, 255, 0.45); border-radius: 14px; padding: 8px 36px 8px 14px; width: 170px; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23333333'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 1.1em; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 10px 20px rgba(35, 52, 94, 0.18); } +.ps-select:focus { outline: none; border-color: rgba(0, 122, 255, 0.9); box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.15); } +.modal-footer { grid-column: 2; grid-row: 2; display: flex; justify-content: flex-end; align-items: center; padding: 20px; border-top: 1px solid rgba(255, 255, 255, 0.35); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); position: relative; } +.modal-footer::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0)); pointer-events: none; } +.ps-button { background: linear-gradient(145deg, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.12)); border: 1px solid rgba(255, 255, 255, 0.45); color: rgba(35, 44, 70, 0.88); padding: 8px 18px; border-radius: 14px; cursor: pointer; margin-left: 10px; transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 12px 24px rgba(35, 52, 94, 0.18); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } +.ps-button:hover { transform: translateY(-2px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 16px 32px rgba(35, 52, 94, 0.24); } +.ps-button.primary { background: linear-gradient(135deg, rgba(0, 122, 255, 0.92), rgba(102, 172, 255, 0.6)); border: 1px solid rgba(255, 255, 255, 0.7); color: #fff; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 20px 36px rgba(0, 122, 255, 0.3); } +.ps-button.primary:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 24px 40px rgba(0, 122, 255, 0.35); } .modal-panel h4 { margin-top: 1.2em; margin-bottom: 0.8em; font-size: 1.1em; border-bottom: 1px solid rgba(0,0,0,0.1); padding-bottom: 5px; } .modal-panel h4:first-child { margin-top: 0; } @@ -193,56 +277,36 @@ input[type="range"] { width: 100%; max-width: 220px; accent-color: #007AFF; } white-space: nowrap; } -body.dark-theme { background-color: #1a2633; color: #f0f2f5; } -body.dark-theme #backgroundCanvas { background-color: #2c3e50; } -body.dark-theme #logo svg, -body.dark-theme .toolbar button svg, -body.dark-theme .tool-options a svg, -body.dark-theme .toggle-toolbar svg, -body.dark-theme .zoom-controls button svg { stroke: #f0f2f5; } -body.dark-theme .settings-menu a, -body.dark-theme .tool-options a { color: #f0f2f5; } -body.dark-theme .settings-menu, -body.dark-theme .toolbar, -body.dark-theme .sub-toolbar { background: rgba(44, 62, 80, 0.5); border: 1px solid rgba(255, 255, 255, 0.1); } -body.dark-theme .size-editor::before { border-color: rgba(240, 242, 245, 0.85); box-shadow: inset 0 0 0 4px rgba(240, 242, 245, 0.18); } -body.dark-theme .tool-options { background: rgba(44, 62, 80, 0.9); } -body.dark-theme .settings-menu a:hover, -body.dark-theme .tool-options a:hover, -body.dark-theme .toolbar button:hover, -body.dark-theme .sidebar-button:hover, -body.dark-theme .zoom-controls button:hover { background-color: rgba(255, 255, 255, 0.1); } -body.dark-theme .toolbar button.active, -body.dark-theme .zoom-controls button.active { background: rgba(0, 122, 255, 0.7); } -body.dark-theme .toolbar-separator { background-color: rgba(255, 255, 255, 0.15); } -body.dark-theme .toggle-toolbar { background: #2c3e50; border-color: rgba(255, 255, 255, 0.1); } -body.dark-theme .modal-photoshop { background: rgba(44, 62, 80, 0.5); border: 1px solid rgba(255, 255, 255, 0.1); color: #f0f2f5; } -body.dark-theme .modal-sidebar { border-right-color: rgba(255, 255, 255, 0.1); border-bottom-color: rgba(255, 255, 255, 0.1); } -body.dark-theme .modal-main h3 { border-bottom-color: rgba(255, 255, 255, 0.1); } -body.dark-theme .sidebar-button { color: #f0f2f5; } -body.dark-theme .sidebar-button.active { background-color: rgba(255, 255, 255, 0.2); } -body.dark-theme .modal-footer { border-top-color: rgba(255, 255, 255, 0.1); } -body.dark-theme .ps-select { background-color: rgba(0, 0, 0, 0.3); color: #f0f2f5; border-color: rgba(255, 255, 255, 0.1); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23f0f2f5'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); } -body.dark-theme .ps-button { background-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.2); color: #f0f2f5; } -body.dark-theme .ps-button:hover { background-color: rgba(255, 255, 255, 0.2); } -body.dark-theme .ps-button.primary { background-color: rgba(0, 122, 255, 0.8); border: 1px solid transparent; color: #fff; } -body.dark-theme .ps-button.primary:hover { background-color: rgba(0, 122, 255, 1); } - -body.dark-theme .floating-toolbar { background: #2c3e50; box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); } -body.dark-theme .floating-toolbar button svg { fill: #f0f2f5; } -body.dark-theme .floating-toolbar button:hover { background: rgba(255, 255, 255, 0.1); } -body.dark-theme .floating-toolbar button.active { background: rgba(0, 122, 255, 0.4); } -body.dark-theme .floating-toolbar .toolbar-select, -body.dark-theme .floating-toolbar .toolbar-font-size { color: #f0f2f5; } -body.dark-theme .floating-toolbar .toolbar-select:hover, -body.dark-theme .floating-toolbar .toolbar-font-size:hover { background: rgba(255, 255, 255, 0.1); } -body.dark-theme .floating-palette { background: #2c3e50; } -body.dark-theme .floating-toolbar .color-picker-wrapper > button svg circle { stroke: #666; } - -body.dark-theme .modal-panel h4 { border-bottom-color: rgba(255,255,255,0.1); } -body.dark-theme .modal-panel li { border-bottom-color: rgba(255,255,255,0.08); } -body.dark-theme .modal-panel li > span:last-child { color: #aaa; } -body.dark-theme .modal-panel li kbd { background-color: #3e5165; border-color: #5f7387; color: #f0f2f5; } +body.dark-theme { background-color: transparent; color: #f0f4ff; } +body.dark-theme #backgroundCanvas { background: rgba(15, 22, 34, 0.85); } +body.dark-theme #logo svg, +body.dark-theme .toolbar button svg, +body.dark-theme .tool-options a svg, +body.dark-theme .toggle-toolbar svg, +body.dark-theme .zoom-controls button svg { stroke: #f5f8ff; } +body.dark-theme .floating-toolbar button svg { fill: #f5f8ff; } +body.dark-theme .floating-toolbar .color-picker-wrapper > button svg circle { stroke: rgba(240, 244, 255, 0.65); } +body.dark-theme .settings-menu a, +body.dark-theme .tool-options a, +body.dark-theme .context-menu-item, +body.dark-theme .sidebar-button, +body.dark-theme .ps-button, +body.dark-theme .modal-panel li > span:last-child { color: rgba(233, 240, 255, 0.92); } +body.dark-theme .sidebar-button.active, +body.dark-theme .ps-button.primary, +body.dark-theme .toolbar button.active, +body.dark-theme .zoom-controls button.active { color: #fff; } +body.dark-theme .size-editor::before { border-color: rgba(236, 240, 255, 0.85); box-shadow: inset 0 0 0 4px rgba(236, 240, 255, 0.18), 0 6px 12px rgba(12, 18, 32, 0.35); } +body.dark-theme .toolbar-separator { background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.08)); } +body.dark-theme .sidebar-button:hover, +body.dark-theme .settings-menu a:hover, +body.dark-theme .tool-options a:hover, +body.dark-theme .context-menu-item:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45); } +body.dark-theme .ps-select { color: rgba(233, 240, 255, 0.92); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23f3f6ff'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); } +body.dark-theme .modal-main h3 { border-bottom-color: rgba(255, 255, 255, 0.12); } +body.dark-theme .modal-panel h4 { border-bottom-color: rgba(255,255,255,0.15); } +body.dark-theme .modal-panel li { border-bottom-color: rgba(255,255,255,0.08); } +body.dark-theme .modal-panel li kbd { background-color: rgba(40, 58, 92, 0.55); border-color: rgba(102, 128, 170, 0.6); color: #f0f4ff; } #drawingBoard.cursor-brush { cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke='white' stroke-width='3.5' d='m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125'/%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke='black' stroke-width='1.5' d='m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125'/%3E%3C/svg%3E") 4 20, auto; } #drawingBoard.cursor-eraser { cursor: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"%3E%3Ccircle cx="12" cy="12" r="8" stroke="%23333" stroke-width="1.5" fill="rgba(255, 255, 255, 0.5)"/%3E%3C/svg%3E') 12 12, auto; } @@ -251,15 +315,17 @@ body.dark-theme #drawingBoard.cursor-eraser { cursor: url('data:image/svg+xml,%3 #lineWidthIndicator { position: fixed; background-color: #333; border-radius: 50%; transform: translate(-50%, calc(-100% - 10px)); pointer-events: none; opacity: 0; transition: opacity 0.2s; z-index: 9999; } #lineWidthIndicator.visible { opacity: 1; } body.dark-theme #lineWidthIndicator { background-color: #f0f2f5; } -.context-menu { position: fixed; z-index: 10000; display: none; background: rgba(255, 255, 255, 0.9); backdrop-filter: blur(10px); border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 8px; padding: 5px; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); min-width: 180px; font-size: 14px; } -.context-menu.visible { display: block; } -.context-menu-item { padding: 8px 12px; cursor: pointer; border-radius: 4px; color: #333; } -.context-menu-item:hover { background-color: #007AFF; color: white; } -.context-menu-separator { height: 1px; background-color: rgba(0, 0, 0, 0.1); margin: 4px 0; } -body.dark-theme .context-menu { background: rgba(44, 62, 80, 0.9); border-color: rgba(255, 255, 255, 0.1); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); } -body.dark-theme .context-menu-item { color: #f0f2f5; } -body.dark-theme .context-menu-item:hover { background-color: #007AFF; color: white; } -body.dark-theme .context-menu-separator { background-color: rgba(255, 255, 255, 0.15); } +.context-menu { position: fixed; z-index: 10000; display: none; background: var(--glass-surface); backdrop-filter: blur(30px) saturate(170%); -webkit-backdrop-filter: blur(30px) saturate(170%); border: 1px solid var(--glass-border); border-radius: 16px; padding: 8px; box-shadow: var(--glass-shadow); min-width: 200px; font-size: 14px; overflow: hidden; } +.context-menu::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.context-menu::after { content: ""; position: absolute; inset: 0; background: radial-gradient(110% 120% at -10% 0%, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0)); pointer-events: none; } +.context-menu.visible { display: block; } +.context-menu-item { padding: 10px 14px; cursor: pointer; border-radius: 10px; color: rgba(35, 44, 70, 0.85); transition: transform 0.2s ease, background 0.2s ease; position: relative; z-index: 1; } +.context-menu-item:hover { background: linear-gradient(135deg, rgba(0, 122, 255, 0.9), rgba(102, 172, 255, 0.6)); color: white; transform: translateX(3px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55); } +.context-menu-separator { height: 1px; background-color: rgba(255, 255, 255, 0.4); margin: 6px 0; opacity: 0.7; } +body.dark-theme .context-menu { border-color: var(--glass-border); box-shadow: var(--glass-shadow); } +body.dark-theme .context-menu-item { color: rgba(233, 240, 255, 0.92); } +body.dark-theme .context-menu-item:hover { color: #fff; } +body.dark-theme .context-menu-separator { background-color: rgba(255, 255, 255, 0.25); } #text-editor-textarea { border: none; padding: 0; From d5acfe9f97eccaff78070554f41e3767839a7f5b Mon Sep 17 00:00:00 2001 From: LDRoff Date: Wed, 1 Oct 2025 17:26:24 +0300 Subject: [PATCH 4/8] Preserve layer pivot after applying transformations --- js/utils.js | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/js/utils.js b/js/utils.js index aec2c56..588550f 100644 --- a/js/utils.js +++ b/js/utils.js @@ -49,11 +49,11 @@ export function applyTransformations(layer) { const centerX = box.x + box.width / 2; const centerY = box.y + box.height / 2; - const rotatedPivotOffset = rotatePoint(pivot, { x: 0, y: 0 }, rotation); - const pivotPoint = { - x: centerX + rotatedPivotOffset.x, - y: centerY + rotatedPivotOffset.y, - }; + const rotatedPivotOffset = rotatePoint(pivot, { x: 0, y: 0 }, rotation); + const pivotPoint = { + x: centerX + rotatedPivotOffset.x, + y: centerY + rotatedPivotOffset.y, + }; const rotate = (p) => rotatePoint(p, pivotPoint, rotation); @@ -99,9 +99,20 @@ export function applyTransformations(layer) { if(layer.topY) layer.topY += dy; } - layer.rotation = 0; - layer.pivot = { x: 0, y: 0 }; -} + layer.rotation = 0; + + const newBox = getBoundingBox(layer); + if (newBox) { + const newCenterX = newBox.x + newBox.width / 2; + const newCenterY = newBox.y + newBox.height / 2; + layer.pivot = { + x: pivotPoint.x - newCenterX, + y: pivotPoint.y - newCenterY, + }; + } else { + layer.pivot = { x: 0, y: 0 }; + } +} function perpendicularDistance(pt, p1, p2) { const dx = p2.x - p1.x; From fb7f16a3c8654082d61da7e8cf58edc5f292165b Mon Sep 17 00:00:00 2001 From: LDRoff Date: Wed, 1 Oct 2025 18:51:21 +0300 Subject: [PATCH 5/8] Delete css directory --- css/style.css | 440 -------------------------------------------------- 1 file changed, 440 deletions(-) delete mode 100644 css/style.css diff --git a/css/style.css b/css/style.css deleted file mode 100644 index ef17d7f..0000000 --- a/css/style.css +++ /dev/null @@ -1,440 +0,0 @@ -/* --- START OF FILE style.css --- */ -:root { - --app-background: radial-gradient(circle at 20% 20%, #fefefe 0%, #d0dcff 28%, #c1e4ff 45%, #f6d1ff 65%, #c6d9ff 85%, #d3ddff 100%); - --app-background-overlay: radial-gradient(90% 140% at 80% 10%, rgba(255, 255, 255, 0.7) 0%, rgba(255, 255, 255, 0.05) 45%, rgba(255, 255, 255, 0) 70%), radial-gradient(120% 160% at -10% 80%, rgba(126, 197, 255, 0.35) 0%, rgba(249, 173, 255, 0.25) 35%, rgba(104, 117, 255, 0) 70%); - --glass-surface: radial-gradient(circle at 0% 0%, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.08) 45%, rgba(255, 255, 255, 0.04) 65%), linear-gradient(135deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.1)); - --glass-border: rgba(255, 255, 255, 0.55); - --glass-border-strong: rgba(255, 255, 255, 0.7); - --glass-shadow: 0 25px 65px rgba(26, 43, 92, 0.25); - --glass-glow: 0 0 40px rgba(120, 162, 255, 0.35); - --glass-highlight: linear-gradient(125deg, rgba(255, 255, 255, 0.75) 0%, rgba(255, 255, 255, 0.35) 12%, rgba(255, 255, 255, 0) 45%); - --glass-inner-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2); - --glass-gradient-mask: radial-gradient(120% 100% at 50% -20%, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0)); - --accent-color: #007AFF; -} - -body.dark-theme { - --app-background: radial-gradient(circle at 15% 20%, #1b2337 0%, #1a2d46 30%, #111b2a 70%, #0a101a 100%); - --app-background-overlay: radial-gradient(110% 140% at 90% -10%, rgba(137, 200, 255, 0.18), rgba(137, 200, 255, 0)), radial-gradient(160% 140% at -15% 90%, rgba(126, 59, 255, 0.3), rgba(126, 59, 255, 0)); - --glass-surface: radial-gradient(circle at 0% 10%, rgba(74, 92, 128, 0.5), rgba(34, 48, 76, 0.45) 45%, rgba(18, 26, 39, 0.35) 70%), linear-gradient(135deg, rgba(91, 110, 150, 0.35), rgba(24, 34, 52, 0.5)); - --glass-border: rgba(255, 255, 255, 0.12); - --glass-border-strong: rgba(255, 255, 255, 0.25); - --glass-shadow: 0 30px 70px rgba(0, 0, 0, 0.5); - --glass-glow: 0 0 32px rgba(54, 94, 255, 0.35); - --glass-highlight: linear-gradient(130deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0.12) 15%, rgba(255, 255, 255, 0) 45%); - --glass-inner-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); - --glass-gradient-mask: radial-gradient(120% 90% at 50% -10%, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0)); -} - -body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: var(--app-background); font-family: sans-serif; position: relative; color: #1c2333; } - -body::before, -body::after { - content: ""; - position: fixed; - inset: -20% -10% auto; - height: 120vh; - background: var(--app-background-overlay); - filter: blur(40px) saturate(150%); - opacity: 0.9; - pointer-events: none; - z-index: 0; -} - -body::after { - inset: auto -5% -20%; - height: 100vh; - transform: translateY(10%); - opacity: 0.65; -} - -body.dark-theme, -body.dark-theme html { - color: #f0f4ff; -} -canvas { display: block; position: absolute; top: 0; left: 0; } -#backgroundCanvas { z-index: 1; background-color: #ffffff; } -#drawingBoard { z-index: 2; background-color: transparent; touch-action: none; } -.logo-container { position: absolute; top: 20px; left: 20px; z-index: 1001; } -#logo { width: 50px; height: 50px; border-radius: 50%; cursor: pointer; background: var(--glass-surface); box-shadow: var(--glass-shadow), var(--glass-glow); border: 1px solid var(--glass-border-strong); backdrop-filter: blur(24px) saturate(160%); -webkit-backdrop-filter: blur(24px) saturate(160%); transition: transform 0.2s, box-shadow 0.2s ease; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; } -#logo::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); mix-blend-mode: screen; opacity: 0.8; pointer-events: none; } -#logo::after { content: ""; position: absolute; inset: 0; background: var(--glass-gradient-mask); opacity: 0.6; pointer-events: none; } -#logo:hover { transform: scale(1.08); box-shadow: 0 20px 50px rgba(124, 144, 255, 0.35); } -#logo svg { width: 28px; height: 28px; stroke: #333; position: relative; z-index: 1; } -.settings-menu { display: none; position: absolute; top: 65px; left: 0; background: var(--glass-surface); backdrop-filter: blur(28px) saturate(165%); -webkit-backdrop-filter: blur(28px) saturate(165%); border: 1px solid var(--glass-border); border-radius: 16px; padding: 18px; box-shadow: var(--glass-shadow); min-width: 200px; overflow: hidden; } -.settings-menu::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } -.settings-menu::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)); mix-blend-mode: soft-light; pointer-events: none; } -.settings-menu a { display: block; padding: 10px 14px; color: rgba(35, 44, 70, 0.85); text-decoration: none; border-radius: 10px; transition: transform 0.2s ease, background 0.2s ease; position: relative; z-index: 1; } -.settings-menu a:hover { background-color: rgba(255, 255, 255, 0.35); transform: translateX(3px); } - -.zoom-controls { position: absolute; top: 20px; right: 20px; z-index: 1000; display: flex; flex-direction: column; gap: 8px; background: var(--glass-surface); backdrop-filter: blur(28px) saturate(165%); -webkit-backdrop-filter: blur(28px) saturate(165%); border-radius: 18px; border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); padding: 10px; overflow: hidden; } -.zoom-controls::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } -.zoom-controls::after { content: ""; position: absolute; inset: 0; background: radial-gradient(90% 140% at 100% 0%, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0)); pointer-events: none; mix-blend-mode: screen; } -.zoom-controls button { background: linear-gradient(135deg, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0.15)); border: 1px solid rgba(255, 255, 255, 0.45); width: 42px; height: 42px; border-radius: 14px; cursor: pointer; transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; display: flex; align-items: center; justify-content: center; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 8px 18px rgba(30, 45, 90, 0.2); } -.zoom-controls button svg { width: 24px; height: 24px; stroke: rgba(28, 35, 51, 0.85); pointer-events: none; } -.zoom-controls button:hover { transform: translateY(-2px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 15px 30px rgba(30, 45, 90, 0.28); } -.zoom-controls button:active { transform: translateY(0); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 10px 18px rgba(30, 45, 90, 0.25); } -.zoom-controls button.active { background: linear-gradient(135deg, rgba(0, 122, 255, 0.9), rgba(102, 172, 255, 0.6)); border-color: rgba(255, 255, 255, 0.7); color: white; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55), 0 18px 35px rgba(0, 122, 255, 0.35); } - -.toolbar-wrapper { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; display: flex; align-items: flex-end; padding-top: 52px; pointer-events: none; } -.toolbar-content-slider { display: flex; flex-direction: column; align-items: center; gap: 10px; transition: transform 0.3s ease, opacity 0.3s ease; position: relative; } -.toolbar-container { position: relative; padding-top: 10px; display: flex; flex-direction: column; align-items: center; pointer-events: auto; } -.toolbar-drag-handle { position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 50px; height: 6px; background: linear-gradient(90deg, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.2)); border-radius: 999px; cursor: move; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 6px 14px rgba(35, 52, 94, 0.15); } -.toolbar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: var(--glass-surface); backdrop-filter: blur(28px) saturate(165%); -webkit-backdrop-filter: blur(28px) saturate(165%); border-radius: 20px; border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); position: relative; overflow: hidden; } -.toolbar::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } -.toolbar::after { content: ""; position: absolute; inset: 0; background: radial-gradient(140% 120% at 120% -20%, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0)); pointer-events: none; } -.toolbar button { background: linear-gradient(145deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.12)); border: 1px solid rgba(255, 255, 255, 0.45); padding: 8px; border-radius: 14px; cursor: pointer; transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, opacity 0.2s ease-in-out; display: flex; align-items: center; justify-content: center; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 10px 20px rgba(35, 52, 94, 0.18); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } -.toolbar button svg { width: 24px; height: 24px; stroke: rgba(30, 40, 60, 0.85); pointer-events: none; } -.toolbar button:hover { transform: translateY(-2px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 16px 30px rgba(35, 52, 94, 0.25); } -.toolbar button:active { transform: translateY(0); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 12px 24px rgba(35, 52, 94, 0.22); } -.toolbar button.active { background: linear-gradient(135deg, rgba(0, 122, 255, 0.95), rgba(102, 172, 255, 0.6)); border-color: rgba(255, 255, 255, 0.75); color: #fff; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 18px 36px rgba(0, 122, 255, 0.35); } -.toolbar button:disabled { cursor: not-allowed; opacity: 0.4; } -.toolbar button:disabled:hover { background: transparent; } -.toolbar-separator { width: 1px; height: 24px; background: linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.1)); margin: 0 2px; opacity: 0.8; } -.tool-container { position: relative; } -.tool-options { display: none; position: absolute; bottom: calc(100% + 25px); left: 50%; transform: translateX(-50%); background: var(--glass-surface); backdrop-filter: blur(32px) saturate(160%); -webkit-backdrop-filter: blur(32px) saturate(160%); border-radius: 18px; padding: 10px; box-shadow: var(--glass-shadow); min-width: 200px; z-index: 1001; overflow: hidden; border: 1px solid var(--glass-border); } -.tool-options::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } -.tool-options::after { content: ""; position: absolute; inset: 0; background: radial-gradient(120% 90% at 0% 0%, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0)); pointer-events: none; } -.tool-options a { display: flex; align-items: center; gap: 10px; padding: 10px 14px; color: rgba(35, 44, 70, 0.88); text-decoration: none; border-radius: 10px; white-space: nowrap; transition: background 0.2s ease, transform 0.2s ease; position: relative; z-index: 1; } -.tool-options a svg { width: 20px; height: 20px; stroke: rgba(30, 40, 60, 0.85); } -.tool-options a:hover { background-color: rgba(255, 255, 255, 0.35); transform: translateX(3px); } -.tool-container.active .tool-options { display: block; } -.sub-toolbar-container { width: 100%; display: flex; justify-content: center; } -.sub-toolbar { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 12px 20px; gap: 20px; background: var(--glass-surface); backdrop-filter: blur(30px) saturate(170%); -webkit-backdrop-filter: blur(30px) saturate(170%); border-radius: 24px; border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); box-sizing: border-box; margin-bottom: 14px; flex-wrap: nowrap; min-width: 0; position: relative; overflow: hidden; } -.sub-toolbar::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } -.sub-toolbar::after { content: ""; position: absolute; inset: 0; background: radial-gradient(140% 120% at -10% 0%, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0)); pointer-events: none; mix-blend-mode: screen; } -.sub-toolbar.hidden { display: none; } -.color-palette { display: flex; align-items: center; gap: 10px; padding: 8px 14px; background: linear-gradient(145deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.18)); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.55); box-shadow: inset 0 1px 3px rgba(255, 255, 255, 0.6), 0 12px 25px rgba(32, 40, 70, 0.12); flex: 0 1 auto; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); position: relative; overflow: hidden; } -.color-palette::after { content: ""; position: absolute; inset: 0; background: radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0)); pointer-events: none; } -.color-dot { width: 18px; height: 18px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; box-sizing: border-box; box-shadow: 0 6px 14px rgba(30, 42, 70, 0.15); } -.color-dot[data-color="#FFFFFF"] { border-color: rgba(180, 180, 180, 0.8); } -.color-dot:hover { transform: scale(1.15); box-shadow: 0 10px 18px rgba(30, 42, 70, 0.2); } -.color-dot.active { border-color: rgba(0, 122, 255, 0.8); transform: scale(1.1); box-shadow: 0 12px 24px rgba(0, 122, 255, 0.25); } -.size-editor { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: linear-gradient(145deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.18)); border-radius: 18px; border: 1px solid rgba(255, 255, 255, 0.55); box-shadow: inset 0 1px 3px rgba(255, 255, 255, 0.6), 0 15px 28px rgba(32, 40, 70, 0.12); flex: 1 1 200px; justify-content: flex-end; position: relative; overflow: hidden; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } -.size-editor::before { content: ""; width: 20px; height: 20px; border-radius: 50%; border: 2px solid rgba(51, 51, 51, 0.5); box-shadow: inset 0 0 0 5px rgba(51, 51, 51, 0.12), 0 6px 12px rgba(35, 45, 75, 0.25); display: inline-flex; flex-shrink: 0; pointer-events: none; background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(200, 200, 200, 0.3)); } -.size-editor::after { content: ""; position: absolute; inset: 0; background: radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0)); pointer-events: none; } -.size-editor input[type="range"] { min-width: 0; } -input[type="range"] { width: 100%; max-width: 220px; accent-color: var(--accent-color); background: transparent; } -input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(180, 207, 255, 0.6)); border: 1px solid rgba(0, 0, 0, 0.05); box-shadow: 0 6px 12px rgba(30, 45, 90, 0.2); margin-top: -7px; } -input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 999px; background: linear-gradient(90deg, rgba(0, 122, 255, 0.75), rgba(153, 204, 255, 0.35)); } -input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; border: none; border-radius: 50%; background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(180, 207, 255, 0.6)); box-shadow: 0 6px 12px rgba(30, 45, 90, 0.2); } -input[type="range"]::-moz-range-track { height: 4px; border-radius: 999px; background: linear-gradient(90deg, rgba(0, 122, 255, 0.75), rgba(153, 204, 255, 0.35)); } - -@media (max-width: 1024px) { - .toolbar-wrapper { bottom: 16px; } - .toolbar { gap: 8px; } -} - -@media (max-width: 768px) { - .logo-container { top: 12px; left: 12px; } - #logo { width: 44px; height: 44px; } - .settings-menu { min-width: 160px; padding: 12px; } - - .zoom-controls { top: 12px; bottom: auto; right: 12px; flex-direction: row; align-items: center; gap: 6px; padding: 6px; } - .zoom-controls button { width: 32px; height: 32px; border-radius: 10px; } - .zoom-controls button svg { width: 18px; height: 18px; } - - .toolbar-wrapper { width: calc(100% - 32px); left: 50%; transform: translateX(-50%); padding-top: 48px; } - .toolbar-container { width: 100%; } - .toolbar-content-slider { width: 100%; } - .toolbar { flex-wrap: nowrap; justify-content: center; padding: 6px 8px; gap: 4px; } - .toolbar button { padding: 5px; } - .toolbar button svg { width: 18px; height: 18px; } - .toolbar-separator { display: none; } - - .sub-toolbar { width: 100%; padding: 8px 12px; gap: 14px; margin-bottom: 10px; } - .color-palette { gap: 6px; padding: 6px 8px; } - .size-editor { gap: 10px; padding: 6px 10px; } - .size-editor::before { margin-right: 0; width: 16px; height: 16px; border-width: 1.6px; } - input[type="range"] { max-width: 160px; } - - .toggle-toolbar { top: 4px; right: 12px; } - - .floating-toolbar { flex-wrap: wrap; gap: 4px; } - .floating-toolbar .toolbar-select { font-size: 13px; } - .floating-toolbar .toolbar-font-size { width: 40px; } -} - -@media (max-width: 480px) { - body, html { font-size: 14px; } - - .zoom-controls { top: 10px; right: 10px; } - .zoom-controls button { width: 28px; height: 28px; border-radius: 8px; } - .zoom-controls button svg { width: 16px; height: 16px; } - - .toolbar { gap: 4px; } - .toolbar button { padding: 4px; border-radius: 10px; } - .toolbar button svg { width: 16px; height: 16px; } - - .sub-toolbar { padding: 8px 10px; gap: 10px; margin-bottom: 8px; } - .color-palette, - .size-editor { padding: 6px 8px; } - .color-dot { width: 14px; height: 14px; } - .size-editor::before { width: 14px; height: 14px; border-width: 1.4px; } - input[type="range"] { max-width: 130px; } - - .toolbar-wrapper { padding-top: 44px; } - .toggle-toolbar { top: 2px; right: 8px; width: 30px; height: 30px; padding: 5px; } -} - -@media (max-width: 600px) { - .modal-photoshop { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; width: 100%; max-width: 420px; height: auto; max-height: calc(100vh - 40px); } - .modal-sidebar { grid-row: auto; grid-column: 1; display: flex; gap: 6px; padding: 10px 12px; border-right: none; border-bottom: 1px solid rgba(255, 255, 255, 0.2); border-radius: 16px 16px 0 0; overflow-x: auto; } - .sidebar-button { flex: 1 0 auto; text-align: center; } - .modal-main { padding: 16px; } - .modal-footer { grid-column: 1; grid-row: 3; padding: 16px; } -} - -.toggle-toolbar { width: 34px; height: 34px; padding: 6px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.45); background: linear-gradient(145deg, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0.18)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 10px 20px rgba(30, 45, 90, 0.2); cursor: pointer; transition: transform 0.3s ease, box-shadow 0.3s ease; display: flex; align-items: center; justify-content: center; position: absolute; top: 10px; right: 16px; z-index: 1; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } -.toggle-toolbar:hover { transform: translateY(-2px) scale(1.03); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 16px 30px rgba(30, 45, 90, 0.25); } -.toggle-toolbar svg { width: 20px; height: 20px; transition: transform 0.3s ease; } -.toolbar-wrapper.collapsed .toolbar-content-slider { transform: translateX(calc(-100% - 20px)); opacity: 0; pointer-events: none; } -.toolbar-wrapper.collapsed .toggle-toolbar svg { transform: rotate(180deg); } - -.floating-toolbar { position: absolute; display: none; align-items: center; gap: 8px; padding: 8px; background: var(--glass-surface); border-radius: 18px; box-shadow: var(--glass-shadow); z-index: 1001; transition: opacity 0.1s ease-in-out; border: 1px solid var(--glass-border); overflow: hidden; backdrop-filter: blur(28px) saturate(170%); -webkit-backdrop-filter: blur(28px) saturate(170%); } -.floating-toolbar::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } -.floating-toolbar::after { content: ""; position: absolute; inset: 0; background: radial-gradient(130% 120% at -10% 0%, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0)); pointer-events: none; } -.floating-toolbar.visible { display: flex; } -.floating-toolbar button { background: linear-gradient(145deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.15)); border: 1px solid rgba(255, 255, 255, 0.45); padding: 6px; border-radius: 12px; cursor: pointer; transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; display: flex; align-items: center; justify-content: center; position: relative; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 10px 20px rgba(35, 52, 94, 0.18); } -.floating-toolbar button svg { width: 20px; height: 20px; fill: rgba(30, 40, 60, 0.85); pointer-events: none; } -.floating-toolbar button:hover { transform: translateY(-1px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 14px 26px rgba(35, 52, 94, 0.24); } -.floating-toolbar button.active { background: linear-gradient(135deg, rgba(0, 122, 255, 0.95), rgba(102, 172, 255, 0.6)); border-color: rgba(255, 255, 255, 0.75); color: #fff; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 18px 34px rgba(0, 122, 255, 0.32); } -.floating-toolbar .toolbar-select { -webkit-appearance: none; appearance: none; background: linear-gradient(145deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.18)); border: 1px solid rgba(255, 255, 255, 0.45); padding: 6px 10px; border-radius: 12px; color: rgba(30, 40, 60, 0.85); font-size: 14px; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); } -.floating-toolbar .toolbar-select:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 6px 14px rgba(35, 52, 94, 0.18); } -.floating-toolbar .toolbar-font-size { width: 45px; background: linear-gradient(145deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.15)); border: 1px solid rgba(255, 255, 255, 0.45); border-radius: 12px; color: rgba(30, 40, 60, 0.85); text-align: center; font-size: 14px; padding: 6px 0; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); } -.floating-toolbar .toolbar-font-size:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 6px 14px rgba(35, 52, 94, 0.18); } -.floating-toolbar .color-picker-wrapper { position: relative; } -.floating-toolbar .color-picker-wrapper > button svg circle { transition: fill 0.2s ease; } -.floating-palette { visibility: hidden; opacity: 0; position: absolute; top: calc(100% + 5px); left: 50%; transform: translateX(-50%); background: var(--glass-surface); border-radius: 14px; box-shadow: var(--glass-shadow); padding: 8px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; transition: opacity 0.2s ease, visibility 0.2s ease; - z-index: 1002; - border: 1px solid var(--glass-border); - backdrop-filter: blur(20px) saturate(160%); - -webkit-backdrop-filter: blur(20px) saturate(160%); - overflow: hidden; -} -.floating-palette::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } -.color-picker-wrapper.active .floating-palette { visibility: visible; opacity: 1; } - -.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.55)); display: flex; justify-content: center; align-items: center; z-index: 2000; padding: 20px; box-sizing: border-box; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); } -.modal-overlay.hidden { display: none; } -.modal-photoshop { display: grid; grid-template-columns: 160px 1fr; grid-template-rows: 1fr auto; width: min(550px, 90vw); height: min(350px, 80vh); max-height: calc(100vh - 40px); background: var(--glass-surface); backdrop-filter: blur(36px) saturate(160%); -webkit-backdrop-filter: blur(36px) saturate(160%); border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); color: #333; border-radius: 22px; font-size: 13px; overflow: hidden; position: relative; } -.modal-photoshop::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } -.modal-photoshop::after { content: ""; position: absolute; inset: 0; background: radial-gradient(140% 140% at 120% -20%, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0)); pointer-events: none; } -.modal-sidebar { grid-row: 1 / 3; border-right: 1px solid rgba(255, 255, 255, 0.35); padding: 12px 0; border-radius: 22px 0 0 22px; backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px); background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0)); position: relative; } -.modal-sidebar::after { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } -.sidebar-button { display: block; width: 100%; background: linear-gradient(135deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0.08)); border: none; color: rgba(35, 44, 70, 0.85); padding: 10px 18px; text-align: left; cursor: pointer; font-size: 13px; border-radius: 12px; margin: 0 12px 6px; transition: transform 0.2s ease, box-shadow 0.2s ease; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 8px 18px rgba(35, 52, 94, 0.18); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); } -.sidebar-button:hover { transform: translateX(4px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 12px 24px rgba(35, 52, 94, 0.22); } -.sidebar-button.active { background: linear-gradient(135deg, rgba(0, 122, 255, 0.9), rgba(102, 172, 255, 0.6)); color: white; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 15px 28px rgba(0, 122, 255, 0.3); font-weight: 600; } -.modal-main { padding: 22px; overflow-y: auto; position: relative; } -.modal-main::after { content: ""; position: absolute; inset: 0; background: radial-gradient(120% 100% at 100% 0%, rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0)); pointer-events: none; } -.modal-panel { display: none; } -.modal-panel.active { display: block; } -.modal-main h3 { margin-top: 0; font-size: 1.5em; font-weight: 400; border-bottom: 1px solid rgba(0, 0, 0, 0.1); padding-bottom: 10px; margin-bottom: 20px; } -.setting-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; } -.setting-row label { margin-right: 10px; } -.slider-container { display: flex; align-items: center; gap: 8px; } -.slider-container input[type="range"] { width: 100px; } -#smoothing-value { font-weight: bold; min-width: 2ch; text-align: center; } - -.ps-select { -webkit-appearance: none; appearance: none; background: linear-gradient(145deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.12)); color: rgba(35, 44, 70, 0.85); border: 1px solid rgba(255, 255, 255, 0.45); border-radius: 14px; padding: 8px 36px 8px 14px; width: 170px; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23333333'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 1.1em; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 10px 20px rgba(35, 52, 94, 0.18); } -.ps-select:focus { outline: none; border-color: rgba(0, 122, 255, 0.9); box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.15); } -.modal-footer { grid-column: 2; grid-row: 2; display: flex; justify-content: flex-end; align-items: center; padding: 20px; border-top: 1px solid rgba(255, 255, 255, 0.35); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); position: relative; } -.modal-footer::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0)); pointer-events: none; } -.ps-button { background: linear-gradient(145deg, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.12)); border: 1px solid rgba(255, 255, 255, 0.45); color: rgba(35, 44, 70, 0.88); padding: 8px 18px; border-radius: 14px; cursor: pointer; margin-left: 10px; transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 12px 24px rgba(35, 52, 94, 0.18); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } -.ps-button:hover { transform: translateY(-2px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 16px 32px rgba(35, 52, 94, 0.24); } -.ps-button.primary { background: linear-gradient(135deg, rgba(0, 122, 255, 0.92), rgba(102, 172, 255, 0.6)); border: 1px solid rgba(255, 255, 255, 0.7); color: #fff; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 20px 36px rgba(0, 122, 255, 0.3); } -.ps-button.primary:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 24px 40px rgba(0, 122, 255, 0.35); } - -.modal-panel h4 { margin-top: 1.2em; margin-bottom: 0.8em; font-size: 1.1em; border-bottom: 1px solid rgba(0,0,0,0.1); padding-bottom: 5px; } -.modal-panel h4:first-child { margin-top: 0; } -.modal-panel p { line-height: 1.6; margin-bottom: 1.5em; } -.modal-panel ul { list-style: none; padding-left: 0; } -.modal-panel li { - display: grid; - grid-template-columns: max-content 1fr; - gap: 25px; - align-items: start; - padding: 10px 0; - border-bottom: 1px solid rgba(0,0,0,0.05); -} -.modal-panel li .keys { - text-align: right; -} -.modal-panel li > span:last-child { - text-align: left; - color: #555; - line-height: 1.5; -} -.modal-panel li kbd { - background-color: #eee; - border-radius: 3px; - border: 1px solid #b4b4b4; - color: #333; - display: inline-block; - font-family: monospace; - font-size: 0.9em; - padding: 3px 6px; - white-space: nowrap; -} - -body.dark-theme { background-color: transparent; color: #f0f4ff; } -body.dark-theme #backgroundCanvas { background: rgba(15, 22, 34, 0.85); } -body.dark-theme #logo svg, -body.dark-theme .toolbar button svg, -body.dark-theme .tool-options a svg, -body.dark-theme .toggle-toolbar svg, -body.dark-theme .zoom-controls button svg { stroke: #f5f8ff; } -body.dark-theme .floating-toolbar button svg { fill: #f5f8ff; } -body.dark-theme .floating-toolbar .color-picker-wrapper > button svg circle { stroke: rgba(240, 244, 255, 0.65); } -body.dark-theme .settings-menu a, -body.dark-theme .tool-options a, -body.dark-theme .context-menu-item, -body.dark-theme .sidebar-button, -body.dark-theme .ps-button, -body.dark-theme .modal-panel li > span:last-child { color: rgba(233, 240, 255, 0.92); } -body.dark-theme .sidebar-button.active, -body.dark-theme .ps-button.primary, -body.dark-theme .toolbar button.active, -body.dark-theme .zoom-controls button.active { color: #fff; } -body.dark-theme .size-editor::before { border-color: rgba(236, 240, 255, 0.85); box-shadow: inset 0 0 0 4px rgba(236, 240, 255, 0.18), 0 6px 12px rgba(12, 18, 32, 0.35); } -body.dark-theme .toolbar-separator { background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.08)); } -body.dark-theme .sidebar-button:hover, -body.dark-theme .settings-menu a:hover, -body.dark-theme .tool-options a:hover, -body.dark-theme .context-menu-item:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45); } -body.dark-theme .ps-select { color: rgba(233, 240, 255, 0.92); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23f3f6ff'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); } -body.dark-theme .modal-main h3 { border-bottom-color: rgba(255, 255, 255, 0.12); } -body.dark-theme .modal-panel h4 { border-bottom-color: rgba(255,255,255,0.15); } -body.dark-theme .modal-panel li { border-bottom-color: rgba(255,255,255,0.08); } -body.dark-theme .modal-panel li kbd { background-color: rgba(40, 58, 92, 0.55); border-color: rgba(102, 128, 170, 0.6); color: #f0f4ff; } - -#drawingBoard.cursor-brush { cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke='white' stroke-width='3.5' d='m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125'/%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke='black' stroke-width='1.5' d='m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125'/%3E%3C/svg%3E") 4 20, auto; } -#drawingBoard.cursor-eraser { cursor: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"%3E%3Ccircle cx="12" cy="12" r="8" stroke="%23333" stroke-width="1.5" fill="rgba(255, 255, 255, 0.5)"/%3E%3C/svg%3E') 12 12, auto; } -body.dark-theme #drawingBoard.cursor-brush { cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke='black' stroke-width='3.5' d='m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125'/%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke='white' stroke-width='1.5' d='m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125'/%3E%3C/svg%3E") 4 20, auto; } -body.dark-theme #drawingBoard.cursor-eraser { cursor: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"%3E%3Ccircle cx="12" cy="12" r="8" stroke="%23f0f2f5" stroke-width="1.5" fill="rgba(0, 0, 0, 0.5)"/%3E%3C/svg%3E') 12 12, auto; } -#lineWidthIndicator { position: fixed; background-color: #333; border-radius: 50%; transform: translate(-50%, calc(-100% - 10px)); pointer-events: none; opacity: 0; transition: opacity 0.2s; z-index: 9999; } -#lineWidthIndicator.visible { opacity: 1; } -body.dark-theme #lineWidthIndicator { background-color: #f0f2f5; } -.context-menu { position: fixed; z-index: 10000; display: none; background: var(--glass-surface); backdrop-filter: blur(30px) saturate(170%); -webkit-backdrop-filter: blur(30px) saturate(170%); border: 1px solid var(--glass-border); border-radius: 16px; padding: 8px; box-shadow: var(--glass-shadow); min-width: 200px; font-size: 14px; overflow: hidden; } -.context-menu::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } -.context-menu::after { content: ""; position: absolute; inset: 0; background: radial-gradient(110% 120% at -10% 0%, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0)); pointer-events: none; } -.context-menu.visible { display: block; } -.context-menu-item { padding: 10px 14px; cursor: pointer; border-radius: 10px; color: rgba(35, 44, 70, 0.85); transition: transform 0.2s ease, background 0.2s ease; position: relative; z-index: 1; } -.context-menu-item:hover { background: linear-gradient(135deg, rgba(0, 122, 255, 0.9), rgba(102, 172, 255, 0.6)); color: white; transform: translateX(3px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55); } -.context-menu-separator { height: 1px; background-color: rgba(255, 255, 255, 0.4); margin: 6px 0; opacity: 0.7; } -body.dark-theme .context-menu { border-color: var(--glass-border); box-shadow: var(--glass-shadow); } -body.dark-theme .context-menu-item { color: rgba(233, 240, 255, 0.92); } -body.dark-theme .context-menu-item:hover { color: #fff; } -body.dark-theme .context-menu-separator { background-color: rgba(255, 255, 255, 0.25); } -#text-editor-textarea { - border: none; - padding: 0; - margin: 0; - background: transparent; - outline: none; - resize: none; - overflow: hidden; - white-space: pre-wrap; -} - -#custom-tooltip { - position: fixed; - z-index: 9999; - background-color: #2c3e50; - color: #fff; - padding: 6px 12px; - border-radius: 6px; - font-size: 13px; - pointer-events: none; - opacity: 0; - transform: translate(-50%, calc(-100% - 8px)); /* Центрирование и отступ сверху */ - transition: opacity 0.15s ease, transform 0.15s ease; - white-space: nowrap; - display: none; /* Начальное состояние */ -} - -#custom-tooltip.visible { - display: block; /* Делаем видимым для JS */ - opacity: 1; - transform: translate(-50%, calc(-100% - 12px)); /* Сдвигаем чуть выше при появлении */ -} - -body.dark-theme #custom-tooltip { - background-color: #f0f2f5; - color: #1a2633; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); -} - -.modal-main::-webkit-scrollbar { - width: 8px; -} - -.modal-main::-webkit-scrollbar-track { - background: transparent; -} - -.modal-main::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.25); - border-radius: 4px; -} - -.modal-main::-webkit-scrollbar-thumb:hover { - background-color: rgba(0, 0, 0, 0.4); -} - -body.dark-theme .modal-main::-webkit-scrollbar-thumb { - background-color: rgba(255, 255, 255, 0.2); -} - -body.dark-theme .modal-main::-webkit-scrollbar-thumb:hover { - background-color: rgba(255, 255, 255, 0.35); -} - -/* --- НАЧАЛО ИЗМЕНЕНИЙ: Стили для адаптивности --- */ -@media (max-width: 800px) { - .modal-photoshop { - width: 95%; - height: 85vh; - max-width: 500px; - grid-template-columns: 120px 1fr; - } - - #helpModal .modal-photoshop { - max-width: 600px; - } - - .toolbar-wrapper { - width: 98%; - bottom: 10px; - } - .toolbar { - gap: 4px; - padding: 6px; - } - .toolbar button { - padding: 6px; - } - - .logo-container { - top: 10px; - left: 10px; - } - .modal-panel li { - display: block; - text-align: left; - } - .modal-panel li .keys { - display: block; - text-align: left; - margin-bottom: 8px; - } -} -/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */ -@media (max-width: 800px) and (min-width: 769px) { - .zoom-controls { - top: 10px; - right: 10px; - bottom: auto; - } -} -/* --- END OF FILE style.css --- */ From aa7d8f3c5602b07164f7629435022e1dd7e391d4 Mon Sep 17 00:00:00 2001 From: LDRoff Date: Wed, 1 Oct 2025 18:51:29 +0300 Subject: [PATCH 6/8] Delete js directory --- js/actions.js | 310 ------------ js/canvas.js | 929 ---------------------------------- js/geometry.js | 67 --- js/help-content.js | 96 ---- js/hitTest.js | 127 ----- js/layerManager.js | 87 ---- js/layers.js | 14 - js/main.js | 1101 ----------------------------------------- js/shapeRecognizer.js | 164 ------ js/text.js | 169 ------- js/toolbar.js | 246 --------- js/tools.js | 151 ------ js/utils.js | 157 ------ 13 files changed, 3618 deletions(-) delete mode 100644 js/actions.js delete mode 100644 js/canvas.js delete mode 100644 js/geometry.js delete mode 100644 js/help-content.js delete mode 100644 js/hitTest.js delete mode 100644 js/layerManager.js delete mode 100644 js/layers.js delete mode 100644 js/main.js delete mode 100644 js/shapeRecognizer.js delete mode 100644 js/text.js delete mode 100644 js/toolbar.js delete mode 100644 js/tools.js delete mode 100644 js/utils.js diff --git a/js/actions.js b/js/actions.js deleted file mode 100644 index 1a443b2..0000000 --- a/js/actions.js +++ /dev/null @@ -1,310 +0,0 @@ -// --- START OF FILE js/actions.js --- - -import * as geo from './geometry.js'; -import * as hitTest from './hitTest.js'; -import { snapToGrid } from './utils.js'; - -export function handleMove(state, pos, event) { - let dx = pos.x - state.dragStartPos.x; - let dy = pos.y - state.dragStartPos.y; - - if (event.altKey) { - state.snapPoint = null; - const SNAP_THRESHOLD = 10 / state.zoom; - - const getPointsForBox = (box) => { - if (!box) return []; - const { x, y, width, height } = box; - return [ - { x, y }, { x: x + width / 2, y }, { x: x + width, y }, - { x, y: y + height / 2 }, { x: x + width / 2, y: y + height / 2 }, { x: x + width, y: y + height / 2 }, - { x, y: y + height }, { x: x + width / 2, y: y + height }, { x: x + width, y: y + height } - ]; - }; - - const movingSelectionBox = geo.getGroupBoundingBox(state.selectedLayers); - if (movingSelectionBox) { - let currentMovingBox = { ...movingSelectionBox }; - currentMovingBox.x += dx; - currentMovingBox.y += dy; - const movingPoints = getPointsForBox(currentMovingBox); - - const staticLayers = state.layers.filter(l => !state.selectedLayers.some(sl => sl.id === l.id)); - let staticPoints = []; - staticLayers.forEach(layer => { - const box = geo.getBoundingBox(layer); - if (box) staticPoints.push(...getPointsForBox(box)); - }); - - let snapDX = 0; - let snapDY = 0; - let objectSnapped = false; - - for (const movingPoint of movingPoints) { - for (const staticPoint of staticPoints) { - const diffX = Math.abs(movingPoint.x - staticPoint.x); - const diffY = Math.abs(movingPoint.y - staticPoint.y); - - if (diffX < SNAP_THRESHOLD && snapDX === 0) { - snapDX = staticPoint.x - movingPoint.x; - state.snapPoint = { x: staticPoint.x, y: movingPoint.y + snapDY }; - objectSnapped = true; - } - if (diffY < SNAP_THRESHOLD && snapDY === 0) { - snapDY = staticPoint.y - movingPoint.y; - state.snapPoint = { x: (state.snapPoint?.x || movingPoint.x) + snapDX, y: staticPoint.y }; - objectSnapped = true; - } - } - } - - if (!objectSnapped) { - const snappedX = snapToGrid(currentMovingBox.x); - const snappedY = snapToGrid(currentMovingBox.y); - const diffX = snappedX - currentMovingBox.x; - const diffY = snappedY - currentMovingBox.y; - - if (Math.abs(diffX) < SNAP_THRESHOLD) { - snapDX = diffX; - state.snapPoint = { x: snappedX, y: currentMovingBox.y }; - } - if (Math.abs(diffY) < SNAP_THRESHOLD) { - snapDY = diffY; - state.snapPoint = { x: state.snapPoint ? state.snapPoint.x : currentMovingBox.x, y: snappedY }; - } - } - - dx += snapDX; - dy += snapDY; - } - } - - state.selectedLayers.forEach(layer => { - // --- НАЧАЛО ИЗМЕНЕНИЙ: Добавляем 'text' в список перемещаемых объектов --- - if (['rect', 'image', 'text', 'parallelogram', 'parallelepiped'].includes(layer.type)) { - layer.x = (layer.x || 0) + dx; - layer.y = (layer.y || 0) + dy; - } else if (['triangle', 'trapezoid', 'rhombus'].includes(layer.type)) { - layer.p1.x += dx; layer.p1.y += dy; - layer.p2.x += dx; layer.p2.y += dy; - layer.p3.x += dx; layer.p3.y += dy; - if (layer.p4) { layer.p4.x += dx; layer.p4.y += dy; } - } - // --- КОНЕЦ ИЗМЕНЕНИЙ --- - else if (layer.type === 'cone' || layer.type === 'frustum') { layer.cx += dx; layer.baseY += dy; if(layer.apex){layer.apex.x += dx; layer.apex.y += dy;} if(layer.topY){layer.topY += dy;} } - else if (layer.type === 'sphere' || layer.type === 'ellipse' || layer.type === 'truncated-sphere') { layer.cx += dx; layer.cy += dy; if(layer.cutY) layer.cutY += dy; } - else if (layer.type === 'path') { layer.points.forEach(p => { p.x += dx; p.y += dy; }); } - else if (layer.type === 'line') { layer.x1 += dx; layer.y1 += dy; layer.x2 += dx; layer.y2 += dy; } - else if (layer.type === 'pyramid' || layer.type === 'truncated-pyramid') { - if (layer.apex) { layer.apex.x += dx; layer.apex.y += dy; } - layer.base.p1.x += dx; layer.base.p1.y += dy; - layer.base.p2.x += dx; layer.base.p2.y += dy; - layer.base.p3.x += dx; layer.base.p3.y += dy; - layer.base.p4.x += dx; layer.base.p4.y += dy; - if (layer.top) { - layer.top.p1.x += dx; layer.top.p1.y += dy; - layer.top.p2.x += dx; layer.top.p2.y += dy; - layer.top.p3.x += dx; layer.top.p3.y += dy; - layer.top.p4.x += dx; layer.top.p4.y += dy; - } - } - }); - state.dragStartPos = { x: state.dragStartPos.x + dx, y: state.dragStartPos.y + dy }; -} - -export function handleScale(state, pos, event) { - if (event.altKey) { - pos = { x: snapToGrid(pos.x), y: snapToGrid(pos.y) }; - } - - const oBox = state.originalBox; - let nX = oBox.x, nY = oBox.y, nW = oBox.width, nH = oBox.height; - - const rotation = hitTest.getSelectionRotation(state.selectedLayers, state.groupRotation); - const world_dx = pos.x - state.dragStartPos.x; - const world_dy = pos.y - state.dragStartPos.y; - - const dx = world_dx * Math.cos(-rotation) - world_dy * Math.sin(-rotation); - const dy = world_dx * Math.sin(-rotation) + world_dy * Math.cos(-rotation); - - switch (state.scalingHandle) { - case 'top': nY = oBox.y + dy; nH = oBox.height - dy; break; - case 'bottom': nH = oBox.height + dy; break; - case 'left': nX = oBox.x + dx; nW = oBox.width - dx; break; - case 'right': nW = oBox.width + dx; break; - case 'topLeft': nX = oBox.x + dx; nY = oBox.y + dy; nW = oBox.width - dx; nH = oBox.height - dy; break; - case 'topRight': nY = oBox.y + dy; nW = oBox.width + dx; nH = oBox.height - dy; break; - case 'bottomLeft': nX = oBox.x + dx; nW = oBox.width - dx; nH = oBox.height + dy; break; - case 'bottomRight': nW = oBox.width + dx; nH = oBox.height + dy; break; - } - - if (event.shiftKey) { - const aspectRatio = oBox.height !== 0 ? oBox.width / oBox.height : 1; - const relWidthChange = Math.abs(nW - oBox.width) / (oBox.width || 1); - const relHeightChange = Math.abs(nH - oBox.height) / (oBox.height || 1); - - if (relWidthChange > relHeightChange) { - const newHeight = nW / aspectRatio; - if (state.scalingHandle.includes('top')) { - nY += nH - newHeight; - } - nH = newHeight; - } else { - const newWidth = nH * aspectRatio; - if (state.scalingHandle.includes('left')) { - nX += nW - newWidth; - } - nW = newWidth; - } - } - - if (nW < 1) nW = 1; if (nH < 1) nH = 1; - const scaleX = oBox.width > 0 ? nW / oBox.width : 1; - const scaleY = oBox.height > 0 ? nH / oBox.height : 1; - state.selectedLayers.forEach((layer, index) => { - const originalLayer = state.originalLayers[index]; - // --- НАЧАЛО ИЗМЕНЕНИЙ: Логика масштабирования текста --- - if (['rect', 'image', 'text'].includes(layer.type)) { - layer.x = nX + (originalLayer.x - oBox.x) * scaleX; - layer.y = nY + (originalLayer.y - oBox.y) * scaleY; - layer.width = originalLayer.width * scaleX; - layer.height = originalLayer.height * scaleY; - // Удалена строка, изменявшая размер шрифта - } - // --- КОНЕЦ ИЗМЕНЕНИЙ --- - else if (layer.type === 'parallelogram') { layer.x = nX + (originalLayer.x - oBox.x) * scaleX; layer.y = nY + (originalLayer.y - oBox.y) * scaleY; layer.width = originalLayer.width * scaleX; layer.height = originalLayer.height * scaleY; layer.slantOffset = originalLayer.slantOffset * scaleX; } - else if (layer.type === 'parallelepiped') { layer.x = nX + (originalLayer.x - oBox.x) * scaleX; layer.y = nY + (originalLayer.y - oBox.y) * scaleY; layer.width = originalLayer.width * scaleX; layer.height = originalLayer.height * scaleY; layer.depthOffset.x = originalLayer.depthOffset.x * scaleX; layer.depthOffset.y = originalLayer.depthOffset.y * scaleY; } - else if (layer.type === 'cone') { layer.cx = nX + (originalLayer.cx - oBox.x) * scaleX; layer.baseY = nY + (originalLayer.baseY - oBox.y) * scaleY; layer.rx = originalLayer.rx * scaleX; layer.ry = originalLayer.ry * scaleY; layer.apex.x = nX + (originalLayer.apex.x - oBox.x) * scaleX; layer.apex.y = nY + (originalLayer.apex.y - oBox.y) * scaleY; } - else if (layer.type === 'frustum') { layer.cx = nX + (originalLayer.cx - oBox.x) * scaleX; layer.baseY = nY + (originalLayer.baseY - oBox.y) * scaleY; layer.topY = nY + (originalLayer.topY - oBox.y) * scaleY; layer.rx1 = originalLayer.rx1 * scaleX; layer.ry1 = originalLayer.ry1 * scaleY; layer.rx2 = originalLayer.rx2 * scaleX; layer.ry2 = originalLayer.ry2 * scaleY; } - else if (layer.type === 'pyramid' || layer.type === 'truncated-pyramid') { - const scalePoint = p => ({ x: nX + (p.x - oBox.x) * scaleX, y: nY + (p.y - oBox.y) * scaleY }); - if(layer.apex) layer.apex = scalePoint(originalLayer.apex); - layer.base.p1 = scalePoint(originalLayer.base.p1); - layer.base.p2 = scalePoint(originalLayer.base.p2); - layer.base.p3 = scalePoint(originalLayer.base.p3); - layer.base.p4 = scalePoint(originalLayer.base.p4); - if(layer.top) { - layer.top.p1 = scalePoint(originalLayer.top.p1); - layer.top.p2 = scalePoint(originalLayer.top.p2); - layer.top.p3 = scalePoint(originalLayer.top.p3); - layer.top.p4 = scalePoint(originalLayer.top.p4); - } - } - else if (['triangle', 'trapezoid', 'rhombus'].includes(layer.type)) { layer.p1.x = nX + (originalLayer.p1.x - oBox.x) * scaleX; layer.p1.y = nY + (originalLayer.p1.y - oBox.y) * scaleY; layer.p2.x = nX + (originalLayer.p2.x - oBox.x) * scaleX; layer.p2.y = nY + (originalLayer.p2.y - oBox.y) * scaleY; layer.p3.x = nX + (originalLayer.p3.x - oBox.x) * scaleX; layer.p3.y = nY + (originalLayer.p3.y - oBox.y) * scaleY; if(layer.p4) { layer.p4.x = nX + (originalLayer.p4.x - oBox.x) * scaleX; layer.p4.y = nY + (originalLayer.p4.y - oBox.y) * scaleY;} } - else if (layer.type === 'ellipse') { layer.cx = nX + (originalLayer.cx - oBox.x) * scaleX; layer.cy = nY + (originalLayer.cy - oBox.y) * scaleY; layer.rx = originalLayer.rx * scaleX; layer.ry = originalLayer.ry * scaleY; } - else if (layer.type === 'sphere' || layer.type === 'truncated-sphere') { layer.cx = nX + (originalLayer.cx - oBox.x) * scaleX; layer.cy = nY + (originalLayer.cy - oBox.y) * scaleY; layer.r = originalLayer.r * ((scaleX + scaleY) / 2); if(layer.cutY) { layer.cutY = nY + (originalLayer.cutY - oBox.y) * scaleY; layer.cutR = originalLayer.cutR * scaleX; layer.cutRy = originalLayer.cutRy * scaleY; } } - else if (layer.type === 'path') { layer.points = originalLayer.points.map(p => ({ x: nX + (p.x - oBox.x) * scaleX, y: nY + (p.y - oBox.y) * scaleY, })); } - else if (layer.type === 'line') { layer.x1 = nX + (originalLayer.x1 - oBox.x) * scaleX; layer.y1 = nY + (originalLayer.y1 - oBox.y) * scaleY; layer.x2 = nX + (originalLayer.x2 - oBox.x) * scaleX; layer.y2 = nY + (originalLayer.y2 - oBox.y) * scaleY; } - }); -} - -export function handleRotate(state, pos, event) { - const currentAngle = Math.atan2(pos.y - state.groupPivot.y, pos.x - state.groupPivot.x); - let deltaAngle = currentAngle - state.rotationStartAngle; - - if (event.shiftKey) { - const snapAngle = 15 * (Math.PI / 180); - deltaAngle = Math.round(deltaAngle / snapAngle) * snapAngle; - } - - state.groupRotation = deltaAngle; - - state.selectedLayers.forEach((layer, index) => { - const originalLayer = state.originalLayers[index]; - const originalBox = geo.getBoundingBox(originalLayer); - if (!originalBox) return; - - const originalCenter = { x: originalBox.x + originalBox.width / 2, y: originalBox.y + originalBox.height / 2 }; - const newCenter = geo.rotatePoint(originalCenter, state.groupPivot, deltaAngle); - - const dx = newCenter.x - originalCenter.x; - const dy = newCenter.y - originalCenter.y; - - // --- НАЧАЛО ИЗМЕНЕНИЙ: Добавляем 'text' в список вращаемых объектов --- - if (['rect', 'image', 'text', 'parallelogram', 'parallelepiped'].includes(layer.type)) { - layer.x = originalLayer.x + dx; - layer.y = originalLayer.y + dy; - } else if (['ellipse', 'sphere', 'cone', 'frustum', 'truncated-sphere'].includes(layer.type)) { - layer.cx = originalLayer.cx + dx; - layer.cy = originalLayer.cy + dy; - if (layer.baseY !== undefined) layer.baseY = originalLayer.baseY + dy; - if (layer.topY !== undefined) layer.topY = originalLayer.topY + dy; - if (layer.apex) { - layer.apex.x = originalLayer.apex.x + dx; - layer.apex.y = originalLayer.apex.y + dy; - } - if (layer.cutY !== undefined) layer.cutY = originalLayer.cutY + dy; - } else if (layer.type === 'line') { - layer.x1 = originalLayer.x1 + dx; layer.y1 = originalLayer.y1 + dy; - layer.x2 = originalLayer.x2 + dx; layer.y2 = originalLayer.y2 + dy; - } else if (layer.type === 'path') { - layer.points = originalLayer.points.map(p => ({ x: p.x + dx, y: p.y + dy })); - } else if (layer.hasOwnProperty('p1')) { - for (let i = 1; i <= 4; i++) { - if (layer[`p${i}`]) { - layer[`p${i}`].x = originalLayer[`p${i}`].x + dx; - layer[`p${i}`].y = originalLayer[`p${i}`].y + dy; - } - } - } - if (layer.base) { - Object.keys(layer.base).forEach(key => { - layer.base[key].x = originalLayer.base[key].x + dx; - layer.base[key].y = originalLayer.base[key].y + dy; - }); - } - if (layer.top) { - Object.keys(layer.top).forEach(key => { - layer.top[key].x = originalLayer.top[key].x + dx; - layer.top[key].y = originalLayer.top[key].y + dy; - }); - } - if (layer.apex && !layer.baseY) { - layer.apex.x = originalLayer.apex.x + dx; - layer.apex.y = originalLayer.apex.y + dy; - } - - layer.rotation = (originalLayer.rotation || 0) + deltaAngle; - }); -} - -export function handleMovePivot(state, pos) { - const layer = state.selectedLayers[0]; - const box = geo.getBoundingBox(layer); - if (box) { - const centerX = box.x + box.width / 2; - const centerY = box.y + box.height / 2; - - const newPivotVector = { - x: pos.x - centerX, - y: pos.y - centerY - }; - - layer.pivot = geo.rotatePoint(newPivotVector, { x: 0, y: 0 }, -(layer.rotation || 0)); - } -} - -export function endSelectionBox(state, pos, event) { - const selBox = { - x: Math.min(pos.x, state.startPos.x), - y: Math.min(pos.y, state.startPos.y), - width: Math.abs(pos.x - state.startPos.x), - height: Math.abs(pos.y - state.startPos.y), - }; - const layersInBox = state.layers.filter(layer => geo.doBoxesIntersect(geo.getBoundingBox(layer), selBox)); - - if (event.ctrlKey || event.metaKey) { - const idsToDeselect = new Set(layersInBox.map(l => l.id)); - state.selectedLayers = state.selectedLayers.filter(layer => !idsToDeselect.has(layer.id)); - } else if (event.shiftKey) { - const existingIds = new Set(state.selectedLayers.map(l => l.id)); - layersInBox.forEach(layer => { - if (!existingIds.has(layer.id)) { - state.selectedLayers.push(layer); - } - }); - } else { - state.selectedLayers = layersInBox; - } -} -// --- END OF FILE actions.js --- \ No newline at end of file diff --git a/js/canvas.js b/js/canvas.js deleted file mode 100644 index 8b095c9..0000000 --- a/js/canvas.js +++ /dev/null @@ -1,929 +0,0 @@ -// --- START OF FILE canvas.js --- - -import * as geo from './geometry.js'; -import * as hitTest from './hitTest.js'; -import * as actions from './actions.js'; -import * as tools from './tools.js'; -import * as utils from './utils.js'; -import * as layerManager from './layerManager.js'; -import * as shapeRecognizer from './shapeRecognizer.js'; -import * as textTool from './text.js'; - -export function initializeCanvas(canvas, ctx, redrawCallback, saveState, updateToolbarCallback) { - const state = { - canvas, ctx, isDrawing: false, layers: [], activeTool: 'brush', previousTool: 'brush', - startPos: null, selectedLayers: [], currentAction: 'none', dragStartPos: null, - scalingHandle: null, activeColor: '#000000', - activeLineWidth: 3, - activeFontFamily: 'Arial', - activeFontSize: 30, - activeFontWeight: 'normal', - activeFontStyle: 'normal', - activeTextDecoration: 'none', - activeTextAlign: 'left', - lastClickTime: 0, clickCount: 0, lastClickPos: null, - originalLayers: [], originalBox: null, saveState, didErase: false, tempLayer: null, - panX: 0, panY: 0, zoom: 1.0, isPanning: false, panStartPos: { x: 0, y: 0 }, - rotationStartAngle: 0, - groupPivot: null, - groupRotation: 0, - snapPoint: null, - lastBrushTime: 0, - lastBrushPoint: null, - smoothingAmount: 2, - shapeRecognitionTimer: null, - shapeWasJustRecognized: false, - eraserTrailNodes: [], - eraserAnimationId: null, - lastEraserPos: { x: 0, y: 0 }, - isEditingText: false, - activePointers: new Map(), - isPinching: false, - initialPinchDistance: 0, - initialPinchZoom: 1, - initialPinchWorld: { x: 0, y: 0 }, - }; - - // --- НАЧАЛО ИЗМЕНЕНИЙ: Добавляем функцию обновления редактора в состояние холста --- - state.updateTextEditorStyle = textTool.updateEditorStyle; - // --- КОНЕЦ ИЗМЕНЕНИЙ --- - - const clamp = (value, min, max) => Math.min(Math.max(value, min), max); - - state.updateFloatingToolbar = () => { - const toolbar = document.getElementById('floating-text-toolbar'); - const isVisible = state.isEditingText || (state.selectedLayers.length === 1 && state.selectedLayers[0].type === 'text'); - - if (isVisible) { - toolbar.classList.add('visible'); - - const layer = state.isEditingText - ? state.layers.find(l => l.isEditing) - : state.selectedLayers[0]; - - if (!layer) { - toolbar.classList.remove('visible'); - return; - } - - const box = geo.getBoundingBox(layer); - if (!box) { - toolbar.classList.remove('visible'); - return; - } - - document.getElementById('fontFamilySelect').value = layer.fontFamily || 'Arial'; - document.getElementById('floatingFontSizeInput').value = layer.fontSize || 30; - const colorButtonCircle = toolbar.querySelector('[data-action="pick-color"] circle'); - if (colorButtonCircle) { - colorButtonCircle.style.fill = layer.color || '#000000'; - } - - toolbar.querySelector('[data-action="align-left"]').classList.toggle('active', !layer.align || layer.align === 'left'); - toolbar.querySelector('[data-action="align-center"]').classList.toggle('active', layer.align === 'center'); - toolbar.querySelector('[data-action="align-right"]').classList.toggle('active', layer.align === 'right'); - toolbar.querySelector('[data-action="font-bold"]').classList.toggle('active', layer.fontWeight === 'bold'); - toolbar.querySelector('[data-action="font-italic"]').classList.toggle('active', layer.fontStyle === 'italic'); - toolbar.querySelector('[data-action="font-underline"]').classList.toggle('active', layer.textDecoration === 'underline'); - - const screenX = (box.x * state.zoom) + state.panX; - const screenY = (box.y * state.zoom) + state.panY; - const screenHeight = box.height * state.zoom; - - toolbar.style.left = `${screenX}px`; - - const toolbarHeight = toolbar.offsetHeight; - const spaceAbove = screenY; - - if (spaceAbove > toolbarHeight + 10) { - toolbar.style.top = `${screenY - toolbarHeight - 10}px`; - } else { - toolbar.style.top = `${screenY + screenHeight + 10}px`; - } - - } else { - toolbar.classList.remove('visible'); - } - }; - - const NUM_TRAIL_NODES = 15; - const EASING_FACTOR = 0.2; - - function updateTouchPointer(e) { - if (e.pointerType !== 'touch') return; - const rect = canvas.getBoundingClientRect(); - state.activePointers.set(e.pointerId, { - x: e.clientX - rect.left, - y: e.clientY - rect.top, - }); - } - - function removeTouchPointer(e) { - if (e.pointerType !== 'touch') return; - state.activePointers.delete(e.pointerId); - } - - function beginPinchGesture() { - if (state.activePointers.size < 2) return false; - const pointers = Array.from(state.activePointers.values()).slice(0, 2); - const [first, second] = pointers; - const distance = Math.hypot(second.x - first.x, second.y - first.y) || 1; - - if (state.isDrawing || state.currentAction.startsWith('drawing')) { - state.isDrawing = false; - state.currentAction = 'none'; - state.tempLayer = null; - } - - if (state.eraserAnimationId) { - cancelAnimationFrame(state.eraserAnimationId); - state.eraserAnimationId = null; - } - - state.isPinching = true; - state.initialPinchDistance = distance; - state.initialPinchZoom = state.zoom; - const center = { - x: (first.x + second.x) / 2, - y: (first.y + second.y) / 2, - }; - state.initialPinchWorld = { - x: (center.x - state.panX) / state.zoom, - y: (center.y - state.panY) / state.zoom, - }; - return true; - } - - function updatePinchGesture() { - if (!state.isPinching || state.activePointers.size < 2) return; - const pointers = Array.from(state.activePointers.values()).slice(0, 2); - const [first, second] = pointers; - const distance = Math.hypot(second.x - first.x, second.y - first.y) || 1; - const scale = distance / (state.initialPinchDistance || distance); - const newZoom = clamp(state.initialPinchZoom * scale, 0.1, 10); - const center = { - x: (first.x + second.x) / 2, - y: (first.y + second.y) / 2, - }; - - state.zoom = newZoom; - state.panX = center.x - state.initialPinchWorld.x * newZoom; - state.panY = center.y - state.initialPinchWorld.y * newZoom; - - redrawCallback(); - state.updateFloatingToolbar(); - } - - function animateEraserTrail() { - state.eraserAnimationId = requestAnimationFrame(animateEraserTrail); - redrawCallback(); - - const { ctx, zoom, panX, panY, eraserTrailNodes, lastEraserPos } = state; - - let target = lastEraserPos; - for (const node of eraserTrailNodes) { - node.x += (target.x - node.x) * EASING_FACTOR; - node.y += (target.y - node.y) * EASING_FACTOR; - target = node; - } - - ctx.save(); - ctx.translate(panX, panY); - ctx.scale(zoom, zoom); - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - - for (let i = 1; i < eraserTrailNodes.length; i++) { - const p1 = eraserTrailNodes[i - 1]; - const p2 = eraserTrailNodes[i]; - - const ratio = i / eraserTrailNodes.length; - ctx.lineWidth = (1 - ratio) * 15 / zoom; - ctx.strokeStyle = `rgba(135, 206, 250, ${1 - ratio})`; - - ctx.beginPath(); - ctx.moveTo(p1.x, p1.y); - const midPoint = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; - ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y); - ctx.stroke(); - } - - ctx.restore(); - } - - function performZoom(direction, zoomCenter) { - const zoomFactor = 1.1; - const oldZoom = state.zoom; - let newZoom = (direction === 'in') ? oldZoom * zoomFactor : oldZoom / zoomFactor; - state.zoom = Math.max(0.1, Math.min(newZoom, 10)); - if (!zoomCenter) zoomCenter = { x: canvas.getBoundingClientRect().width / 2, y: canvas.getBoundingClientRect().height / 2 }; - state.panX = zoomCenter.x - (zoomCenter.x - state.panX) * (state.zoom / oldZoom); - state.panY = zoomCenter.y - (zoomCenter.y - state.panY) * (state.zoom / oldZoom); - redrawCallback(); - state.updateFloatingToolbar(); - } - - saveState(state.layers); - - const getMousePos = (e) => { const rect = canvas.getBoundingClientRect(); const screenX = e.clientX - rect.left; const screenY = e.clientY - rect.top; return { x: (screenX - state.panX) / state.zoom, y: (screenY - state.panY) / state.zoom, }; }; - - const contextMenu = document.getElementById('contextMenu'); - function hideContextMenu() { contextMenu.classList.remove('visible'); } - document.addEventListener('click', (e) => { if (!contextMenu.contains(e.target)) hideContextMenu(); }); - - contextMenu.addEventListener('click', (e) => { - const action = e.target.dataset.action; - if (!action || state.selectedLayers.length === 0) return; - let newLayers; - switch (action) { - case 'bringForward': newLayers = layerManager.bringForward(state.layers, state.selectedLayers); break; - case 'sendBackward': newLayers = layerManager.sendBackward(state.layers, state.selectedLayers); break; - case 'bringToFront': newLayers = layerManager.bringToFront(state.layers, state.selectedLayers); break; - case 'sendToBack': newLayers = layerManager.sendToBack(state.layers, state.selectedLayers); break; - } - if (newLayers) { - state.layers = newLayers; - saveState(state.layers); - redrawCallback(); - } - hideContextMenu(); - }); - - function updateCursor(handle) { - let cursor = ''; - if (handle) { - switch (handle) { - case 'pivot': cursor = 'grab'; break; - case 'rotate': cursor = 'crosshair'; break; - case 'topLeft': case 'bottomRight': cursor = 'nwse-resize'; break; - case 'topRight': case 'bottomLeft': cursor = 'nesw-resize'; break; - case 'top': case 'bottom': cursor = 'ns-resize'; break; - case 'left': case 'right': cursor = 'ew-resize'; break; - } - } - canvas.style.cursor = cursor; - } - - function handleTripleClick(pos) { const layer = hitTest.getLayerAtPosition(pos, state.layers); if (layer) { state.isDrawing = false; state.selectedLayers = [layer]; const selectButton = document.querySelector('button[data-tool="select"]'); if (selectButton && state.activeTool !== 'select') { selectButton.click(); } else { redrawCallback(); } updateToolbarCallback(); return true; } return false; } - - function startDrawing(e) { - if (state.isEditingText) return; - if (e.pointerType === 'touch') { - updateTouchPointer(e); - if (state.activePointers.size >= 2) { - if (!state.isPinching) { - beginPinchGesture(); - } - updatePinchGesture(); - return; - } else { - state.isPinching = false; - } - } - const pos = getMousePos(e); - - // --- НАЧАЛО ИЗМЕНЕНИЙ: Логика для быстрого редактирования текста --- - if (state.activeTool === 'text') { - const clickedLayer = hitTest.getLayerAtPosition(pos, state.layers); - if (clickedLayer && clickedLayer.type === 'text') { - // Если кликнули по существующему тексту, начинаем его редактирование - clickedLayer.isEditing = true; - state.isEditingText = true; - redrawCallback(); - state.updateFloatingToolbar(); - - textTool.startEditing(state, clickedLayer, (isIntermediate) => { - if (isIntermediate) { - redrawCallback(); - state.updateFloatingToolbar(); - return; - } - state.isEditingText = false; - const finishedLayer = state.layers.find(l => l.id === clickedLayer.id); - if (finishedLayer) { - finishedLayer.isEditing = false; - } - saveState(state.layers); - redrawCallback(); - state.updateFloatingToolbar(); - }); - return; // Важно! Прерываем выполнение функции, чтобы не создавать новый текст. - } - } - // --- КОНЕЦ ИЗМЕНЕНИЙ --- - - clearTimeout(state.shapeRecognitionTimer); - state.shapeWasJustRecognized = false; - - const isPanToolActive = state.activeTool === 'pan' && e.button === 0; - const isMiddleMouseButton = e.pointerType === 'mouse' && e.button === 1; - - if (isPanToolActive || isMiddleMouseButton) { - state.isPanning = true; - state.panStartPos = { x: e.clientX, y: e.clientY }; - canvas.style.cursor = 'grabbing'; - return; - } - if (e.pointerType === 'mouse' && e.button !== 0) return; - - hideContextMenu(); - - let finalPos = pos; - if (e.altKey) { - finalPos = { x: utils.snapToGrid(pos.x), y: utils.snapToGrid(pos.y) }; - } - - if (state.currentAction.startsWith('drawing')) { - if (state.currentAction === 'drawingParallelogramSlant') { - const finalSlant = finalPos.x - (state.tempLayer.x + state.tempLayer.width / 2); - state.tempLayer.slantOffset = finalSlant; - if (state.tempLayer.width > 5 || state.tempLayer.height > 5) { state.layers.push(state.tempLayer); } - } else if (state.currentAction === 'drawingTriangleApex') { - state.tempLayer.p3 = finalPos; - if (Math.abs(state.tempLayer.p1.x - state.tempLayer.p2.x) > 5 || Math.abs(state.tempLayer.p1.y - state.tempLayer.p2.y) > 5) { state.layers.push(state.tempLayer); } - } else if (state.currentAction === 'drawingParallelepipedDepth') { - state.tempLayer.depthOffset = { x: finalPos.x - (state.tempLayer.x + state.tempLayer.width), y: finalPos.y - tempLayer.y }; - if (state.tempLayer.width > 5 || state.tempLayer.height > 5) { state.layers.push(state.tempLayer); } - } else if (state.currentAction === 'drawingPyramidApex') { - state.tempLayer.apex = finalPos; - state.layers.push(state.tempLayer); - } else if (state.currentAction === 'drawingTruncatedPyramidApex') { - state.tempLayer.apex = finalPos; - state.currentAction = 'drawingTruncatedPyramidTop'; - return; - } else if (state.currentAction === 'drawingTruncatedPyramidTop') { - const { base, apex } = state.tempLayer; - const totalHeight = Math.abs(apex.y - base.p1.y); - const cutHeight = Math.abs(finalPos.y - base.p1.y); - const ratio = Math.max(0.05, Math.min(0.95, cutHeight / totalHeight)); - - const interpolate = (p1, p2) => ({ - x: p1.x + (p2.x - p1.x) * ratio, - y: p1.y + (p2.y - p1.y) * ratio - }); - - state.tempLayer.top = { - p1: interpolate(base.p1, apex), p2: interpolate(base.p2, apex), - p3: interpolate(base.p3, apex), p4: interpolate(base.p4, apex), - }; - state.layers.push(state.tempLayer); - } else if (state.currentAction === 'drawingTrapezoidP3') { - state.tempLayer.p3 = finalPos; - state.currentAction = 'drawingTrapezoidP4'; - return; - } else if (state.currentAction === 'drawingTrapezoidP4') { - state.tempLayer.p4 = finalPos; - if (Math.abs(state.tempLayer.p1.x - state.tempLayer.p2.x) > 5) { state.layers.push(state.tempLayer); } - } else if (state.currentAction === 'drawingFrustum') { - const { cx } = state.tempLayer; - state.tempLayer.topY = finalPos.y; - state.tempLayer.rx2 = Math.abs(finalPos.x - cx); - state.tempLayer.ry2 = state.tempLayer.rx2 * 0.3; - state.layers.push(state.tempLayer); - } else if (state.currentAction === 'drawingTruncatedSphere') { - const { cx, cy, r } = state.tempLayer; - const cutY = Math.max(cy - r, Math.min(cy + r, finalPos.y)); - const h = Math.abs(cutY - cy); - const cutRSquared = (r * r) - (h * h); - state.tempLayer.cutY = cutY; - state.tempLayer.cutR = cutRSquared > 0 ? Math.sqrt(cutRSquared) : 0; - state.tempLayer.cutRy = state.tempLayer.cutR * 0.3; - state.layers.push(state.tempLayer); - } - saveState(state.layers); state.currentAction = 'none'; state.tempLayer = null; redrawCallback(); return; - } - - const now = Date.now(); - const CLICK_SPEED = 400, CLICK_RADIUS = 10; - const timeDiff = now - state.lastClickTime; - if (state.lastClickPos && timeDiff < CLICK_SPEED && Math.abs(pos.x - state.lastClickPos.x) < CLICK_RADIUS && Math.abs(pos.y - state.lastClickPos.y) < CLICK_RADIUS) { state.clickCount++; } else { state.clickCount = 1; } - state.lastClickTime = now; state.lastClickPos = pos; - - if (state.activeTool === 'select' && state.clickCount === 2) { - const clickedLayer = hitTest.getLayerAtPosition(pos, state.layers); - if (clickedLayer && clickedLayer.type === 'text') { - clickedLayer.isEditing = true; - - state.isEditingText = true; - redrawCallback(); - state.updateFloatingToolbar(); - - textTool.startEditing(state, clickedLayer, (isIntermediate) => { - if (isIntermediate) { - redrawCallback(); - state.updateFloatingToolbar(); - return; - } - state.isEditingText = false; - const finishedLayer = state.layers.find(l => l.id === clickedLayer.id); - if (finishedLayer) { - finishedLayer.isEditing = false; - } - saveState(state.layers); - redrawCallback(); - state.updateFloatingToolbar(); - }); - return; - } - } - - if (state.clickCount === 3) { state.clickCount = 0; if (handleTripleClick(pos)) return; } - - state.dragStartPos = pos; - - if (state.activeTool === 'select') { - state.groupRotation = 0; - if (state.selectedLayers.length > 0) { - state.scalingHandle = hitTest.getHandleAtPosition(pos, state.selectedLayers, state.zoom, state.groupRotation); - if (state.scalingHandle === 'pivot') { - state.currentAction = 'movingPivot'; - canvas.style.cursor = 'none'; - return; - } - if (state.scalingHandle === 'rotate') { - state.currentAction = 'rotating'; - const box = geo.getGroupBoundingBox(state.selectedLayers); - const centerX = box.x + box.width / 2; - const centerY = box.y + box.height / 2; - let pivotX = centerX; - let pivotY = centerY; - - if (state.selectedLayers.length === 1) { - const layer = state.selectedLayers[0]; - const pivot = layer.pivot || { x: 0, y: 0 }; - const rotation = layer.rotation || 0; - const rotatedPivotOffset = geo.rotatePoint(pivot, {x: 0, y: 0}, rotation); - pivotX = centerX + rotatedPivotOffset.x; - pivotY = centerY + rotatedPivotOffset.y; - } - - state.groupPivot = { x: pivotX, y: pivotY }; - state.rotationStartAngle = Math.atan2(pos.y - state.groupPivot.y, pos.x - state.groupPivot.x); - state.originalLayers = state.selectedLayers.map(l => JSON.parse(JSON.stringify(l))); - return; - } - if (state.scalingHandle) { state.currentAction = 'scaling'; state.originalBox = geo.getGroupBoundingBox(state.selectedLayers); state.originalLayers = state.selectedLayers.map(l => JSON.parse(JSON.stringify(l))); return; } - } - const clickedLayer = hitTest.getLayerAtPosition(pos, state.layers); - if (clickedLayer) { - if (e.ctrlKey || e.metaKey) { - const index = state.selectedLayers.findIndex(l => l.id === clickedLayer.id); - if (index > -1) { - state.selectedLayers.splice(index, 1); - } - } else if (e.shiftKey) { - const index = state.selectedLayers.findIndex(l => l.id === clickedLayer.id); - if (index > -1) { - state.selectedLayers.splice(index, 1); - } else { - state.selectedLayers.push(clickedLayer); - } - } else { - if (!state.selectedLayers.some(l => l.id === clickedLayer.id)) { - state.selectedLayers = [clickedLayer]; - } - } - state.currentAction = 'moving'; - } else { - if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { - state.selectedLayers = []; - } - state.currentAction = 'selectionBox'; - state.startPos = pos; - } - redrawCallback(); - updateToolbarCallback(); - state.updateFloatingToolbar(); - return; - } - - state.selectedLayers = []; - updateToolbarCallback(); - state.updateFloatingToolbar(); - state.isDrawing = true; - state.startPos = pos; - if (state.activeTool === 'brush' || state.activeTool === 'smart-brush') { - const pressure = e.pressure > 0 ? e.pressure : 0.5; - state.layers.push({ type: 'path', points: [{...pos, pressure }], color: state.activeColor, lineWidth: state.activeLineWidth, id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } }); - state.lastBrushTime = Date.now(); - state.lastBrushPoint = pos; - } - else if (state.activeTool === 'eraser') { - state.didErase = false; - state.lastEraserPos = pos; - state.eraserTrailNodes = Array(NUM_TRAIL_NODES).fill(null).map(() => ({ ...pos })); - if (state.eraserAnimationId) { - cancelAnimationFrame(state.eraserAnimationId); - } - animateEraserTrail(); - if (tools.handleEraser(state, pos)) { - redrawCallback(); - } - } - } - - function draw(e) { - if (state.isEditingText) return; - - if (e.pointerType === 'touch') { - updateTouchPointer(e); - if (state.activePointers.size >= 2) { - if (!state.isPinching) { - beginPinchGesture(); - } - updatePinchGesture(); - return; - } - - if (state.isPinching) { - updatePinchGesture(); - return; - } - } - - if (state.isPinching) { - return; - } - - if (state.isPanning) { const dx = e.clientX - state.panStartPos.x; const dy = e.clientY - state.panStartPos.y; state.panX += dx; state.panY += dy; state.panStartPos = { x: e.clientX, y: e.clientY }; redrawCallback(); state.updateFloatingToolbar(); return; } - const pos = getMousePos(e); - - if (!state.isDrawing && state.currentAction === 'none') { - const layerAtPos = hitTest.getLayerAtPosition(pos, state.layers); - if (state.selectedLayers.length > 0) { - const handle = hitTest.getHandleAtPosition(pos, state.selectedLayers, state.zoom, state.groupRotation); - updateCursor(handle); - if (!handle && hitTest.getLayerAtPosition(pos, state.selectedLayers)) { canvas.style.cursor = 'move'; } - } else if (state.activeTool === 'select') { - if (layerAtPos && layerAtPos.type === 'text') { - canvas.style.cursor = 'text'; - } else { - canvas.style.cursor = layerAtPos ? 'pointer' : ''; - } - } else if (state.activeTool === 'pan') { - canvas.style.cursor = 'grab'; - } else { updateCursor(null); } - return; - } - - if (!e.altKey) { state.snapPoint = null; } - - switch(state.currentAction) { - case 'movingPivot': actions.handleMovePivot(state, pos); redrawCallback(); return; - case 'rotating': actions.handleRotate(state, pos, e); redrawCallback(); state.updateFloatingToolbar(); return; - case 'moving': actions.handleMove(state, pos, e); redrawCallback(); state.updateFloatingToolbar(); return; - case 'scaling': actions.handleScale(state, pos, e); redrawCallback(); state.updateFloatingToolbar(); return; - case 'selectionBox': - redrawCallback(); ctx.save(); ctx.translate(state.panX, state.panY); ctx.scale(state.zoom, state.zoom); - ctx.strokeStyle = 'rgba(0, 122, 255, 0.8)'; ctx.fillStyle = 'rgba(0, 122, 255, 0.1)'; - ctx.lineWidth = 1 / state.zoom; ctx.beginPath(); - ctx.rect(state.startPos.x, state.startPos.y, pos.x - state.startPos.x, pos.y - state.startPos.y); - ctx.fill(); ctx.stroke(); ctx.restore(); - return; - } - - if (state.currentAction.startsWith('drawing')) { - tools.handleMultiStepDrawing(state, pos, e, redrawCallback); - } else if (state.isDrawing) { - if (state.activeTool === 'brush' || state.activeTool === 'smart-brush') { - tools.handleBrush(state, pos, e); - - if (state.activeTool === 'smart-brush') { - clearTimeout(state.shapeRecognitionTimer); - const lastLayer = state.layers[state.layers.length - 1]; - if (lastLayer && lastLayer.type === 'path') { - state.shapeRecognitionTimer = setTimeout(() => { - const recognizedShape = shapeRecognizer.recognizeShape(lastLayer.points); - if (recognizedShape) { - if (state.layers[state.layers.length - 1] === lastLayer) { - state.layers.pop(); - recognizedShape.color = lastLayer.color; - recognizedShape.lineWidth = lastLayer.lineWidth; - state.layers.push(recognizedShape); - - state.isDrawing = false; - state.shapeWasJustRecognized = true; - - saveState(state.layers); - redrawCallback(); - } - } - }, 500); - } - } - redrawCallback(); - } else if (state.activeTool === 'eraser') { - state.lastEraserPos = pos; - tools.handleEraser(state, pos); - } else { - tools.handleShapeDrawing(state, pos, e, redrawCallback); - } - } - } - - function stopDrawing(e) { - if (state.isEditingText) return; - - if (e.pointerType === 'touch') { - removeTouchPointer(e); - if (state.isPinching) { - if (state.activePointers.size < 2) { - state.isPinching = false; - state.initialPinchDistance = 0; - if (state.activePointers.size === 0) { - state.initialPinchWorld = { x: 0, y: 0 }; - } - } - redrawCallback(); - state.updateFloatingToolbar(); - return; - } - } - - clearTimeout(state.shapeRecognitionTimer); - if (state.eraserAnimationId) { - cancelAnimationFrame(state.eraserAnimationId); - state.eraserAnimationId = null; - redrawCallback(); - } - - if (state.isPanning) { - state.isPanning = false; - if (state.activeTool === 'pan') { - canvas.style.cursor = 'grab'; - } else { - updateCursor(null); - } - return; - } - - if (!state.isDrawing && ['rotating', 'scaling', 'movingPivot'].includes(state.currentAction)) { - state.selectedLayers.forEach(utils.applyTransformations); - saveState(state.layers); - } - - const isMultiStep = state.currentAction.startsWith('drawing'); - if (!state.isDrawing) { - if (state.currentAction === 'movingPivot') { updateCursor(null); } - if (isMultiStep) return; - if (state.currentAction === 'moving') { saveState(state.layers); } - } else if (state.isDrawing) { - const rawEnd = getMousePos(e); - let finalStart = { ...state.startPos }; - let finalEnd = rawEnd; - - if (e.altKey) { - finalStart = { x: utils.snapToGrid(state.startPos.x), y: utils.snapToGrid(state.startPos.y) }; - finalEnd = { x: utils.snapToGrid(rawEnd.x), y: utils.snapToGrid(rawEnd.y) }; - } - - if (state.activeTool === 'line' && e.shiftKey) { - const dx = finalEnd.x - finalStart.x; - const dy = finalEnd.y - finalStart.y; - if (Math.abs(dx) > Math.abs(dy)) { finalEnd.y = finalStart.y; } else { finalEnd.x = finalStart.x; } - } - - if (state.activeTool === 'brush' || state.activeTool === 'smart-brush') { - const lastLayer = state.layers[state.layers.length - 1]; - if (lastLayer && lastLayer.type === 'path') { - if (state.smoothingAmount > 0) { - const tolerance = state.smoothingAmount * 0.5; - lastLayer.points = utils.simplifyPath(lastLayer.points, tolerance); - } - - if (state.activeTool === 'smart-brush' && !state.shapeWasJustRecognized) { - const recognizedShape = shapeRecognizer.recognizeShape(lastLayer.points); - if (recognizedShape) { - state.layers.pop(); - recognizedShape.color = lastLayer.color; - recognizedShape.lineWidth = lastLayer.lineWidth; - state.layers.push(recognizedShape); - } - } - } - saveState(state.layers); - } - else if (state.activeTool === 'eraser') { - if (state.didErase) { saveState(state.layers); } - } else if (['parallelogram', 'triangle', 'parallelepiped', 'pyramid', 'truncated-pyramid', 'trapezoid', 'frustum', 'truncated-sphere'].includes(state.activeTool)) { - state.isDrawing = false; - const commonProps = { color: state.activeColor, lineWidth: state.activeLineWidth, id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } }; - switch(state.activeTool) { - case 'parallelogram': { - state.currentAction = 'drawingParallelogramSlant'; - state.tempLayer = { type: 'parallelogram', x: Math.min(finalEnd.x, finalStart.x), y: Math.min(finalEnd.y, finalStart.y), width: Math.abs(finalEnd.x - finalStart.x), height: Math.abs(finalEnd.y - finalStart.y), slantOffset: 0, ...commonProps }; - break; - } - case 'triangle': { - state.currentAction = 'drawingTriangleApex'; - state.tempLayer = { type: 'triangle', p1: finalStart, p2: finalEnd, p3: finalEnd, ...commonProps }; - break; - } - case 'parallelepiped': { - state.currentAction = 'drawingParallelepipedDepth'; - const width = Math.abs(finalEnd.x - finalStart.x); - state.tempLayer = { type: 'parallelepiped', x: Math.min(finalEnd.x, finalStart.x), y: Math.min(finalEnd.y, finalStart.y), width, height: Math.abs(finalEnd.y - finalStart.y), depthOffset: { x: width * 0.3, y: -width * 0.3 }, ...commonProps }; - break; - } - case 'pyramid': case 'truncated-pyramid': { - state.currentAction = state.activeTool === 'pyramid' ? 'drawingPyramidApex' : 'drawingTruncatedPyramidApex'; - const x = Math.min(finalEnd.x, finalStart.x); - const y = Math.min(finalEnd.y, finalStart.y); - const w = Math.abs(finalEnd.x - finalStart.x); - const h = Math.abs(finalEnd.y - finalStart.y); - const d = {x: w * 0.3, y: -w * 0.2}; - state.tempLayer = { type: state.activeTool, base: { p1: { x: x, y: y + h }, p2: { x: x + w, y: y + h }, p3: { x: x + w + d.x, y: y + h + d.y }, p4: { x: x + d.x, y: y + h + d.y } }, apex: { x: x + w/2, y: y }, ...commonProps }; - break; - } - case 'trapezoid': { - state.currentAction = 'drawingTrapezoidP3'; - state.tempLayer = { type: 'trapezoid', p1: finalStart, p2: finalEnd, p3: finalEnd, p4: finalStart, ...commonProps }; - break; - } - case 'frustum': { - state.currentAction = 'drawingFrustum'; - const rx1 = Math.abs(finalEnd.x - finalStart.x) / 2; - const cx = finalStart.x + (finalEnd.x - finalStart.x) / 2; - const baseY = Math.max(finalEnd.y, finalStart.y); - state.tempLayer = { type: 'frustum', cx, baseY, rx1, ry1: rx1 * 0.3, topY: baseY, rx2: rx1, ry2: rx1 * 0.3, ...commonProps }; - break; - } - case 'truncated-sphere': { - state.currentAction = 'drawingTruncatedSphere'; - const r = Math.abs(finalEnd.x - finalStart.x) / 2; - const cenX = finalStart.x + (finalEnd.x - finalStart.x) / 2; - const cenY = finalStart.y + (finalEnd.y - finalStart.y)/2; - state.tempLayer = { type: 'truncated-sphere', cx: cenX, cy: cenY, r, cutY: cenY, cutR: r, cutRy: r * 0.3, ...commonProps }; - break; - } - } - return; - } else { - const commonProps = { color: state.activeColor, lineWidth: state.activeLineWidth, id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } }; - switch(state.activeTool) { - case 'rect': { - const rect = { type: 'rect', x: Math.min(finalEnd.x, finalStart.x), y: Math.min(finalEnd.y, finalStart.y), width: Math.abs(finalEnd.x - finalStart.x), height: Math.abs(finalEnd.y - finalStart.y), ...commonProps }; - if (rect.width > 5 || rect.height > 5) state.layers.push(rect); - break; - } - case 'text': { - const width = Math.abs(finalEnd.x - finalStart.x); - const height = Math.abs(finalEnd.y - finalStart.y); - if (width < 20 || height < 20) { - state.isDrawing = false; - redrawCallback(); - return; - } - - const newTextLayer = { - type: 'text', - x: Math.min(finalEnd.x, finalStart.x), - y: Math.min(finalEnd.y, finalStart.y), - width: width, - height: height, - content: '', - color: state.activeColor, - fontSize: state.activeFontSize, - fontFamily: state.activeFontFamily, - align: state.activeTextAlign, - fontWeight: state.activeFontWeight, - fontStyle: state.activeFontStyle, - textDecoration: state.activeTextDecoration, - id: Date.now(), - rotation: 0, - pivot: { x: 0, y: 0 } - }; - - state.layers.push(newTextLayer); - - newTextLayer.isEditing = true; - - state.isEditingText = true; - redrawCallback(); - state.updateFloatingToolbar(); - - textTool.startEditing(state, newTextLayer, (isIntermediate) => { - if (isIntermediate) { - redrawCallback(); - state.updateFloatingToolbar(); - return; - } - state.isEditingText = false; - const finishedLayer = state.layers.find(l => l.id === newTextLayer.id); - if (finishedLayer) { - finishedLayer.isEditing = false; - } - saveState(state.layers); - redrawCallback(); - state.updateFloatingToolbar(); - }); - state.isDrawing = false; - return; - } - case 'rhombus': { - const x = Math.min(finalEnd.x, finalStart.x); - const y = Math.min(finalEnd.y, finalStart.y); - const width = Math.abs(finalEnd.x - finalStart.x); - const height = Math.abs(finalEnd.y - finalStart.y); - if(width>5 && height>5) state.layers.push({ type: 'rhombus', p1: {x: x+width/2, y: y}, p2: {x: x+width, y: y+height/2}, p3: {x: x+width/2, y: y+height}, p4: {x: x, y: y+height/2}, ...commonProps }); - break; - } - case 'ellipse': case 'sphere': case 'cone': { - const width = Math.abs(finalEnd.x - finalStart.x); - const height = Math.abs(finalEnd.y - finalStart.y); - const cx = finalStart.x + (finalEnd.x - finalStart.x) / 2; - if (state.activeTool === 'sphere') { - const r = width / 2; - if (r > 5) state.layers.push({ type: 'sphere', cx, cy: finalStart.y + (finalEnd.y-finalStart.y)/2, r, ...commonProps }); - } else if (state.activeTool === 'cone') { - const rx = width / 2; - const apex = {x: cx, y: Math.min(finalEnd.y, finalStart.y)}; - const baseY = Math.max(finalEnd.y, finalStart.y); - if (rx > 5 && height > 5) state.layers.push({ type: 'cone', cx, baseY, rx, ry: rx * 0.3, apex, ...commonProps }); - } else { - const rx = width / 2; - const ry = height/2; - const cy = finalStart.y + (finalEnd.y - finalStart.y) / 2; - if (rx > 5 || ry > 5) state.layers.push({ type: 'ellipse', cx, cy, rx, ry, ...commonProps }); - } - break; - } - case 'line': { - const line = { type: 'line', x1: finalStart.x, y1: finalStart.y, x2: finalEnd.x, y2: finalEnd.y, ...commonProps }; - if (Math.abs(line.x1 - line.x2) > 5 || Math.abs(line.y1 - line.y2) > 5) state.layers.push(line); - break; - } - } - saveState(state.layers); - } - } - - updateCursor(null); - if (state.currentAction === 'selectionBox') { - actions.endSelectionBox(state, getMousePos(e), e); - updateToolbarCallback(); - state.updateFloatingToolbar(); - } - - state.isDrawing = false; state.currentAction = 'none'; state.scalingHandle = null; state.startPos = null; state.originalBox = null; state.originalLayers = []; state.groupPivot = null; state.didErase = false; state.groupRotation = 0; state.snapPoint = null; - redrawCallback(); - } - - function handleContextMenu(e) { - e.preventDefault(); - const pos = getMousePos(e); - const clickedLayer = hitTest.getLayerAtPosition(pos, state.layers); - - if (clickedLayer) { - if (!state.selectedLayers.some(l => l.id === clickedLayer.id)) { - state.selectedLayers = [clickedLayer]; - redrawCallback(); - updateToolbarCallback(); - state.updateFloatingToolbar(); - } - - contextMenu.style.top = `${e.clientY}px`; - contextMenu.style.left = `${e.clientX}px`; - contextMenu.classList.add('visible'); - } else { - hideContextMenu(); - } - } - - canvas.addEventListener('pointerdown', startDrawing); - canvas.addEventListener('pointermove', draw); - canvas.addEventListener('pointerup', stopDrawing); - canvas.addEventListener('pointerleave', (e) => { - if (state.isDrawing || state.currentAction !== 'none' || state.isPanning || state.isPinching || (e.pointerType === 'touch' && state.activePointers.size > 0)) { - stopDrawing(e); - } - state.isPanning = false; - }); - canvas.addEventListener('pointercancel', stopDrawing); - - canvas.addEventListener('wheel', (e) => { - e.preventDefault(); - const rect = canvas.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - - if (e.deltaY < 0) { - performZoom('in', { x: mouseX, y: mouseY }); - } else { - performZoom('out', { x: mouseX, y: mouseY }); - } - }); - - canvas.addEventListener('dragover', (e) => e.preventDefault()); - canvas.addEventListener('drop', (e) => { e.preventDefault(); const pos = getMousePos(e); if (e.dataTransfer.files.length > 0) utils.processImageFile(e.dataTransfer.files[0], pos, state, redrawCallback, saveState); }); - - canvas.addEventListener('contextmenu', handleContextMenu); - - const imageUploadInput = document.getElementById('imageUpload'); - imageUploadInput.addEventListener('change', (e) => { if (e.target.files.length > 0) { const centerPos = { x: canvas.width / 2, y: canvas.height / 2 }; utils.processImageFile(e.target.files[0], centerPos, state, redrawCallback, saveState); e.target.value = ''; } }); - - state.performZoom = performZoom; - - return state; -} -// --- END OF FILE canvas.js --- \ No newline at end of file diff --git a/js/geometry.js b/js/geometry.js deleted file mode 100644 index 9e84f44..0000000 --- a/js/geometry.js +++ /dev/null @@ -1,67 +0,0 @@ -// --- START OF FILE geometry.js --- - -// --- Funzioni ausiliarie (nessuna modifica) --- -export function getBoundingBox(layer) { - if (!layer) return null; - // --- НАЧАЛО ИЗМЕНЕНИЙ: Добавляем 'text' в список --- - if (layer.type === 'rect' || layer.type === 'image' || layer.type === 'text') { return { x: layer.x, y: layer.y, width: layer.width, height: layer.height }; } - // --- КОНЕЦ ИЗМЕНЕНИЙ --- - if (layer.type === 'ellipse') { return { x: layer.cx - layer.rx, y: layer.cy - layer.ry, width: layer.rx * 2, height: layer.ry * 2 }; } - if (layer.type === 'sphere' || layer.type === 'truncated-sphere') { return { x: layer.cx - layer.r, y: layer.cy - layer.r, width: layer.r * 2, height: layer.r * 2 }; } - if (layer.type === 'line') { return { x: Math.min(layer.x1, layer.x2), y: Math.min(layer.y1, layer.y2), width: Math.abs(layer.x1 - layer.x2), height: Math.abs(layer.y1 - layer.y2) }; } - if (layer.type === 'parallelogram') { const x_coords = [layer.x, layer.x + layer.width, layer.x + layer.slantOffset, layer.x + layer.width + layer.slantOffset]; const y_coords = [layer.y, layer.y + layer.height]; const minX = Math.min(...x_coords); const maxX = Math.max(...x_coords); const minY = Math.min(...y_coords); const maxY = Math.max(...y_coords); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } - if (layer.type === 'triangle' || layer.type === 'trapezoid' || layer.type === 'rhombus') { - const points = [layer.p1, layer.p2, layer.p3]; - if (layer.p4) { - points.push(layer.p4); - } - const x_coords = points.map(p => p.x); - const y_coords = points.map(p => p.y); - const minX = Math.min(...x_coords); - const maxX = Math.max(...x_coords); - const minY = Math.min(...y_coords); - const maxY = Math.max(...y_coords); - return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; - } - if (layer.type === 'cone') { const minX = Math.min(layer.cx - layer.rx, layer.apex.x); const maxX = Math.max(layer.cx + layer.rx, layer.apex.x); const minY = Math.min(layer.baseY, layer.apex.y); const maxY = Math.max(layer.baseY, layer.apex.y); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } - if (layer.type === 'parallelepiped') { const { x, y, width, height, depthOffset } = layer; const x_coords = [x, x + width, x + depthOffset.x, x + width + depthOffset.x]; const y_coords = [y, y + height, y + depthOffset.y, y + height + depthOffset.y]; const minX = Math.min(...x_coords); const maxX = Math.max(...x_coords); const minY = Math.min(...y_coords); const maxY = Math.max(...y_coords); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } - if (layer.type === 'pyramid') { const { base, apex } = layer; const x_coords = [base.p1.x, base.p2.x, base.p3.x, base.p4.x, apex.x]; const y_coords = [base.p1.y, base.p2.y, base.p3.y, base.p4.y, apex.y]; const minX = Math.min(...x_coords); const maxX = Math.max(...x_coords); const minY = Math.min(...y_coords); const maxY = Math.max(...y_coords); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } - if (layer.type === 'frustum') { const { cx, baseY, topY, rx1 } = layer; const minX = cx - rx1; const maxX = cx + rx1; const minY = Math.min(baseY, topY); const maxY = Math.max(baseY, topY); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } - if (layer.type === 'truncated-pyramid') { - const points = [...Object.values(layer.base), ...Object.values(layer.top)]; - const x_coords = points.map(p => p.x); - const y_coords = points.map(p => p.y); - const minX = Math.min(...x_coords); - const maxX = Math.max(...x_coords); - const minY = Math.min(...y_coords); - const maxY = Math.max(...y_coords); - return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; - } - if (layer.type === 'path') { if (layer.points.length === 0) return { x: 0, y: 0, width: 0, height: 0 }; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; layer.points.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } return null; } -export function isPointInRect(point, rect) { return rect && point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height; } -export function isPointInTriangle(pt, v1, v2, v3) { const d1 = sign(pt, v1, v2); const d2 = sign(pt, v2, v3); const d3 = sign(pt, v3, v1); const has_neg = (d1 < 0) || (d2 < 0) || (d3 < 0); const has_pos = (d1 > 0) || (d2 > 0) || (d3 > 0); return !(has_neg && has_pos); } -function sign(p1, p2, p3) { return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y); } -export function isPointInPolygon(point, vertices) { let isInside = false; for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) { const xi = vertices[i].x, yi = vertices[i].y; const xj = vertices[j].x, yj = vertices[j].y; const intersect = ((yi > point.y) !== (yj > point.y)) && (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi); if (intersect) isInside = !isInside; } return isInside; } -export function isPointInParallelogram(point, layer) { const p1 = { x: layer.x, y: layer.y + layer.height }; const p2 = { x: layer.x + layer.width, y: layer.y + layer.height }; const p3 = { x: layer.x + layer.width + layer.slantOffset, y: layer.y }; const p4 = { x: layer.x + layer.slantOffset, y: layer.y }; return isPointInTriangle(point, p1, p2, p3) || isPointInTriangle(point, p1, p3, p4); } -export function isPointInParallelepiped(point, layer) { const { x, y, width, height, depthOffset } = layer; const dx = depthOffset.x; const dy = depthOffset.y; const frontFace = [{x, y}, {x: x+width, y}, {x: x+width, y: y+height}, {x, y: y+height}]; const topFace = [{x, y}, {x: x+dx, y: y+dy}, {x: x+width+dx, y: y+dy}, {x: x+width, y}]; const rightFace = [{x: x+width, y}, {x: x+width+dx, y: y+dy}, {x: x+width+dx, y: y+height+dy}, {x: x+width, y: y+height}]; return isPointInTriangle(point, frontFace[0], frontFace[1], frontFace[2]) || isPointInTriangle(point, frontFace[0], frontFace[2], frontFace[3]) || isPointInTriangle(point, topFace[0], topFace[1], topFace[2]) || isPointInTriangle(point, topFace[0], topFace[2], topFace[3]) || isPointInTriangle(point, rightFace[0], rightFace[1], rightFace[2]) || isPointInTriangle(point, rightFace[0], rightFace[2], rightFace[3]); } -export function isPointInCone(point, layer) { const { cx, baseY, rx, ry, apex } = layer; const baseEllipse = { cx, cy: baseY, rx, ry }; if (isPointInEllipse(point, baseEllipse)) return true; const p1 = { x: cx - rx, y: baseY }; const p2 = { x: cx + rx, y: baseY }; const p3 = apex; return isPointInTriangle(point, p1, p2, p3); } -export function isPointInPyramid(point, layer) { const { base, apex } = layer; return isPointInTriangle(point, base.p1, base.p2, apex) || isPointInTriangle(point, base.p2, base.p3, apex) || isPointInTriangle(point, base.p3, base.p4, apex) || isPointInTriangle(point, base.p4, base.p1, apex); } -export function isPointInFrustum(point, layer) { const { cx, baseY, topY, rx1, ry1, rx2, ry2 } = layer; const baseEllipse = { cx, cy: baseY, rx: rx1, ry: ry1 }; const topEllipse = { cx, cy: topY, rx: rx2, ry: ry2 }; if (isPointInEllipse(point, baseEllipse) || isPointInEllipse(point, topEllipse)) return true; const p1 = {x: cx - rx1, y: baseY}; const p2 = {x: cx + rx1, y: baseY}; const p3 = {x: cx + rx2, y: topY}; const p4 = {x: cx - rx2, y: topY}; return isPointInPolygon(point, [p1, p2, p3, p4]); } -export function isPointOnPath(point, layer) { const threshold = (layer.lineWidth / 2) + 5; for (let i = 0; i < layer.points.length - 1; i++) { const p1 = layer.points[i], p2 = layer.points[i+1]; const dx = p2.x - p1.x, dy = p2.y - p1.y; const lenSq = dx * dx + dy * dy; if (lenSq === 0) { const distSq = (point.x - p1.x)**2 + (point.y - p1.y)**2; if (distSq < threshold**2) return true; continue; } let t = ((point.x - p1.x) * dx + (point.y - p1.y) * dy) / lenSq; t = Math.max(0, Math.min(1, t)); const closestX = p1.x + t * dx, closestY = p1.y + t * dy; const distSq = (point.x - closestX)**2 + (point.y - closestY)**2; if (distSq < threshold**2) return true; } return false; } -export function isPointOnLineSegment(point, layer) { const threshold = (layer.lineWidth / 2) + 5; const p1 = { x: layer.x1, y: layer.y1 }; const p2 = { x: layer.x2, y: layer.y2 }; const dx = p2.x - p1.x, dy = p2.y - p1.y; const lenSq = dx * dx + dy * dy; if (lenSq === 0) { const distSq = (point.x - p1.x)**2 + (point.y - p1.y)**2; return distSq < threshold**2; } let t = ((point.x - p1.x) * dx + (point.y - p1.y) * dy) / lenSq; t = Math.max(0, Math.min(1, t)); const closestX = p1.x + t * dx, closestY = p1.y + t * dy; const distSq = (point.x - closestX)**2 + (point.y - closestY)**2; return distSq < threshold**2; } -export function isPointInEllipse(point, layer) { const { cx, cy, rx, ry } = layer; if (rx <= 0 || ry <= 0) return false; const dx = point.x - cx, dy = point.y - cy; return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1; } -export function doBoxesIntersect(boxA, boxB) { if (!boxA || !boxB) return false; return !(boxB.x > boxA.x + boxA.width || boxB.x + boxB.width < boxA.x || boxB.y > boxA.y + boxA.height || boxB.y + boxB.height < boxA.y); } -export function getGroupBoundingBox(layers) { if (!layers || layers.length === 0) return null; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; layers.forEach(layer => { const box = getBoundingBox(layer); if (box) { minX = Math.min(minX, box.x); minY = Math.min(minY, box.y); maxX = Math.max(maxX, box.x + box.width); maxY = Math.max(maxY, box.y + box.height); } }); if (minX === Infinity) return null; return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } -export function rotatePoint(point, pivot, angle) { - const s = Math.sin(angle); - const c = Math.cos(angle); - const px = point.x - pivot.x; - const py = point.y - pivot.y; - const xnew = px * c - py * s; - const ynew = px * s + py * c; - return { - x: xnew + pivot.x, - y: ynew + pivot.y, - }; -} -// --- END OF FILE geometry.js --- \ No newline at end of file diff --git a/js/help-content.js b/js/help-content.js deleted file mode 100644 index 570537c..0000000 --- a/js/help-content.js +++ /dev/null @@ -1,96 +0,0 @@ -// --- START OF FILE js/help-content.js --- - -const helpContent = { - "general-panel": ` -

Добро пожаловать!

-

Это краткое руководство поможет вам освоить все возможности интерактивной доски. Используйте меню слева для навигации по разделам.

- `, - "hotkeys-panel": ` -

Горячие клавиши

-

Основные комбинации

-
    -
  • Ctrl + ZОтменить последнее действие
  • -
  • Ctrl + YПовторить отменённое действие
  • -
  • Ctrl + CКопировать выделенные объекты
  • -
  • Ctrl + XВырезать выделенные объекты
  • -
  • Ctrl + VВставить объекты или скриншот
  • -
  • Delete / BackspaceУдалить выделенные объекты
  • -
  • EscСбросить текущее действие или выделение
  • -
-

Инструменты

-
    -
  • VИнструмент "Выделить"
  • -
  • BПереключение между Кистью и Умной кистью
  • -
  • EЛастик
  • -
  • TТекст
  • -
  • SПереключение 2D фигур по кругу
  • -
  • DПереключение 3D фигур по кругу
  • -
  • IДобавить изображение
  • -
- `, - "navigation-panel": ` -

Навигация по доске

-

Масштаб (Приближение/Отдаление)

-

Вы можете изменять масштаб доски несколькими способами:

-
    -
  • Колесико мышиПлавное масштабирование.
  • -
  • Кнопки + и -Масштабирование по шагам.
  • -
-

Перемещение по доске (Панорамирование)

-

Для свободного перемещения по холсту:

-
    -
  • Средняя кнопка мышиЗажмите колесико и двигайте мышь.
  • -
  • Инструмент "Рука" (H)Выберите на панели, зажмите левую кнопку и двигайте.
  • -
- `, - "objects-panel": ` -

Работа с объектами

-

Выделение

-
    -
  • КликОдиночное выделение объекта.
  • -
  • Shift + КликДобавить или убрать объект из группы выделенных.
  • -
  • Ctrl + КликИсключить объект из выделения рамкой.
  • -
  • РамкаЗажмите ЛКМ на пустом месте и растяните рамку.
  • -
-

Трансформация

-

После выделения объекта (или группы) вокруг него появится рамка:

-
    -
  • ПеремещениеЗахватите объект мышкой и перетащите.
  • -
  • МасштабированиеПотяните за любой из восьми квадратных маркеров.
  • -
  • ВращениеПотяните за верхний круглый маркер.
  • -
-

Редактирование текста

-
    -
  • Двойной кликВ режиме "Выделить" (V).
  • -
  • Одиночный кликВ режиме "Текст" (T).
  • -
- `, - "tools-panel": ` -

Инструменты

-
    -
  • КистьРисует обычные линии.
  • -
  • Умная кистьПревращает нарисованную от руки фигуру в идеальную (линию, круг, прямоугольник).
    Совет: чтобы нарисовать прямую линию, задержите курсор на полсекунды в конце.
  • -
  • ТекстПозволяет создавать и редактировать текстовые блоки.
  • -
  • ЛастикУдаляет целый объект по клику на него.
  • -
  • Фигуры (2D/3D)Позволяют рисовать стандартизированные геометрические фигуры.
  • -
- `, - "advanced-panel": ` -

Продвинутые техники

-

Клавиши-модификаторы

-

Удерживайте эти клавиши во время рисования или трансформации:

-
    -
  • ShiftРисование линии: делает её строго прямой.
    Масштабирование: сохраняет пропорции.
    Вращение: вращает с шагом в 15 градусов.
  • -
  • AltРисование и перемещение: включает "умную" привязку к сетке и к другим объектам.
  • -
-

Вставка изображений

-

Вы можете добавить изображение на доску двумя способами:

-
    -
  • Кнопка (I)Выбрать файл с компьютера.
  • -
  • Ctrl + VВставить скопированный скриншот или картинку.
  • -
- ` -}; - -export default helpContent; -// --- END OF FILE js/help-content.js --- \ No newline at end of file diff --git a/js/hitTest.js b/js/hitTest.js deleted file mode 100644 index 9844c94..0000000 --- a/js/hitTest.js +++ /dev/null @@ -1,127 +0,0 @@ -// --- START OF FILE hitTest.js --- - -import * as geo from './geometry.js'; - -export function getLayerAtPosition(pos, layers) { - for (let i = layers.length - 1; i >= 0; i--) { - const layer = layers[i]; - const box = geo.getBoundingBox(layer); - if (!box) continue; - - const rotation = layer.rotation || 0; - const pivot = layer.pivot || { x: 0, y: 0 }; - const centerX = box.x + box.width / 2; - const centerY = box.y + box.height / 2; - - const s = Math.sin(rotation); - const c = Math.cos(rotation); - const rotatedPivotX = pivot.x * c - pivot.y * s; - const rotatedPivotY = pivot.x * s + pivot.y * c; - - const pivotX = centerX + rotatedPivotX; - const pivotY = centerY + rotatedPivotY; - - const cos = Math.cos(-rotation); - const sin = Math.sin(-rotation); - const dx = pos.x - pivotX; - const dy = pos.y - pivotY; - - const rotatedPos = { - x: dx * cos - dy * sin + pivotX, - y: dx * sin + dy * cos + pivotY - }; - - let hit = false; - if (layer.type === 'path') { hit = geo.isPointOnPath(rotatedPos, layer); } - // --- НАЧАЛО ИЗМЕНЕНИЙ: Добавляем логику обнаружения для текстовых блоков --- - else if (layer.type === 'text') { hit = geo.isPointInRect(rotatedPos, box); } - // --- КОНЕЦ ИЗМЕНЕНИЙ --- - else if (layer.type === 'sphere' || layer.type === 'truncated-sphere') { hit = geo.isPointInEllipse(rotatedPos, { cx: layer.cx, cy: layer.cy, rx: layer.r, ry: layer.r }); } - else if (layer.type === 'ellipse') { hit = geo.isPointInEllipse(rotatedPos, layer); } - else if (layer.type === 'line') { hit = geo.isPointOnLineSegment(rotatedPos, layer); } - else if (layer.type === 'parallelogram') { hit = geo.isPointInParallelogram(rotatedPos, layer); } - else if (layer.type === 'triangle') { hit = geo.isPointInTriangle(rotatedPos, layer.p1, layer.p2, layer.p3); } - else if (layer.type === 'cone') { hit = geo.isPointInCone(rotatedPos, layer); } - else if (layer.type === 'parallelepiped') { hit = geo.isPointInParallelepiped(rotatedPos, layer); } - else if (layer.type === 'pyramid') { hit = geo.isPointInPyramid(rotatedPos, layer); } - else if (layer.type === 'trapezoid' || layer.type === 'rhombus') { hit = geo.isPointInPolygon(rotatedPos, [layer.p1, layer.p2, layer.p3, layer.p4]); } - else if (layer.type === 'frustum') { hit = geo.isPointInFrustum(rotatedPos, layer); } - else if (layer.type === 'truncated-pyramid') { - const { base, top } = layer; - const faces = [ [base.p1, base.p2, base.p3, base.p4], [top.p1, top.p2, top.p3, top.p4], [base.p1, base.p2, top.p2, top.p1], [base.p2, base.p3, top.p3, top.p2], [base.p3, base.p4, top.p4, top.p3], [base.p4, base.p1, top.p1, top.p4] ]; - for (const face of faces) { - if (geo.isPointInPolygon(rotatedPos, face)) { hit = true; break; } - } - } else { hit = geo.isPointInRect(rotatedPos, geo.getBoundingBox(layer)); } - if (hit) return layer; - } - return null; -} - -export function getSelectionRotation(layers, groupRotation) { - if (layers.length > 1) { - return groupRotation; - } - if (layers.length === 1) { - return layers[0].rotation || 0; - } - return 0; -} - -export function getHandleAtPosition(pos, layers, zoom, groupRotation) { - if (!layers || layers.length === 0) return null; - - const box = geo.getGroupBoundingBox(layers); - if (!box) return null; - - const handleHitboxSize = 10 / zoom; - const halfHandle = handleHitboxSize / 2; - const centerX = box.x + box.width / 2; - const centerY = box.y + box.height / 2; - - const isSingleSelection = layers.length === 1; - const layer = isSingleSelection ? layers[0] : null; - - let pivotX = centerX; - let pivotY = centerY; - - if (isSingleSelection && layer && layer.pivot) { - const rotation = layer.rotation || 0; - const rotatedPivotOffset = geo.rotatePoint(layer.pivot, {x:0, y:0}, rotation); - pivotX = centerX + rotatedPivotOffset.x; - pivotY = centerY + rotatedPivotOffset.y; - } - - const rotation = getSelectionRotation(layers, groupRotation); - - if (isSingleSelection && layer) { - if (pos.x >= pivotX - halfHandle && pos.x <= pivotX + halfHandle && pos.y >= pivotY - halfHandle && pos.y <= pivotY + halfHandle) { - return 'pivot'; - } - } - - const rotationHandleY = box.y - 20 / zoom; - const rotationHandlePoint = { x: centerX, y: rotationHandleY }; - - const rotatedHandle = geo.rotatePoint(rotationHandlePoint, { x: pivotX, y: pivotY }, rotation); - - if (pos.x >= rotatedHandle.x - halfHandle && pos.x <= rotatedHandle.x + halfHandle && pos.y >= rotatedHandle.y - halfHandle && pos.y <= rotatedHandle.y + halfHandle) { - return 'rotate'; - } - - const handles = { - topLeft: { x: box.x, y: box.y }, top: { x: centerX, y: box.y }, topRight: { x: box.x + box.width, y: box.y }, - left: { x: box.x, y: centerY }, right: { x: box.x + box.width, y: centerY }, - bottomLeft: { x: box.x, y: box.y + box.height }, bottom: { x: centerX, y: box.y + box.height }, bottomRight: { x: box.x + box.width, y: box.y + box.height }, - }; - - for (const handleName in handles) { - const handlePos = handles[handleName]; - const rotatedHandle = geo.rotatePoint(handlePos, { x: pivotX, y: pivotY }, rotation); - if (pos.x >= rotatedHandle.x - halfHandle && pos.x <= rotatedHandle.x + halfHandle && pos.y >= rotatedHandle.y - halfHandle && pos.y <= rotatedHandle.y + halfHandle) { - return handleName; - } - } - return null; -} -// --- END OF FILE hitTest.js --- \ No newline at end of file diff --git a/js/layerManager.js b/js/layerManager.js deleted file mode 100644 index 9d5e184..0000000 --- a/js/layerManager.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Перемещает выбранные слои на один уровень вперед (выше). - * @param {Array} layers - Полный массив слоев. - * @param {Array} selectedLayers - Массив выбранных слоев. - * @returns {Array} - Новый, отсортированный массив слоев. - */ -export function bringForward(layers, selectedLayers) { - const selectedIds = new Set(selectedLayers.map(l => l.id)); - const newLayers = [...layers]; - - // Идем с конца, чтобы не нарушать индексы при перемещении - for (let i = newLayers.length - 2; i >= 0; i--) { - const currentLayer = newLayers[i]; - const nextLayer = newLayers[i + 1]; - if (selectedIds.has(currentLayer.id) && !selectedIds.has(nextLayer.id)) { - // Меняем местами - [newLayers[i], newLayers[i + 1]] = [newLayers[i + 1], newLayers[i]]; - } - } - return newLayers; -} - -/** - * Перемещает выбранные слои на один уровень назад (ниже). - * @param {Array} layers - Полный массив слоев. - * @param {Array} selectedLayers - Массив выбранных слоев. - * @returns {Array} - Новый, отсортированный массив слоев. - */ -export function sendBackward(layers, selectedLayers) { - const selectedIds = new Set(selectedLayers.map(l => l.id)); - const newLayers = [...layers]; - - // Идем с начала - for (let i = 1; i < newLayers.length; i++) { - const currentLayer = newLayers[i]; - const prevLayer = newLayers[i - 1]; - if (selectedIds.has(currentLayer.id) && !selectedIds.has(prevLayer.id)) { - // Меняем местами - [newLayers[i], newLayers[i - 1]] = [newLayers[i - 1], newLayers[i]]; - } - } - return newLayers; -} - -/** - * Перемещает выбранные слои на самый передний план. - * @param {Array} layers - Полный массив слоев. - * @param {Array} selectedLayers - Массив выбранных слоев. - * @returns {Array} - Новый, отсортированный массив слоев. - */ -export function bringToFront(layers, selectedLayers) { - const selectedIds = new Set(selectedLayers.map(l => l.id)); - const layersToMove = []; - const otherLayers = []; - - layers.forEach(layer => { - if (selectedIds.has(layer.id)) { - layersToMove.push(layer); - } else { - otherLayers.push(layer); - } - }); - - return [...otherLayers, ...layersToMove]; -} - -/** - * Перемещает выбранные слои на самый задний план. - * @param {Array} layers - Полный массив слоев. - * @param {Array} selectedLayers - Массив выбранных слоев. - * @returns {Array} - Новый, отсортированный массив слоев. - */ -export function sendToBack(layers, selectedLayers) { - const selectedIds = new Set(selectedLayers.map(l => l.id)); - const layersToMove = []; - const otherLayers = []; - - layers.forEach(layer => { - if (selectedIds.has(layer.id)) { - layersToMove.push(layer); - } else { - otherLayers.push(layer); - } - }); - - return [...layersToMove, ...otherLayers]; -} \ No newline at end of file diff --git a/js/layers.js b/js/layers.js deleted file mode 100644 index 32dcff7..0000000 --- a/js/layers.js +++ /dev/null @@ -1,14 +0,0 @@ -let layers = []; -let currentLayerIndex = 0; - -function addLayer() { - layers.push({ - elements: [], - visible: true - }); - currentLayerIndex = layers.length - 1; - console.log('Новый слой добавлен'); -} - -// Создадим первый слой -addLayer(); \ No newline at end of file diff --git a/js/main.js b/js/main.js deleted file mode 100644 index 6ec4a23..0000000 --- a/js/main.js +++ /dev/null @@ -1,1101 +0,0 @@ -// --- START OF FILE main.js --- - -import { initializeCanvas } from './canvas.js'; -import { getBoundingBox, getGroupBoundingBox } from './geometry.js'; -import { getSelectionRotation } from './hitTest.js'; -import { initializeToolbar } from './toolbar.js'; -import { getEditorTextarea } from './text.js'; -import helpContent from './help-content.js'; - -const history = []; let historyIndex = -1; -function cloneLayers(layers) { return layers.map(l => { const n = { ...l }; if (l.points) { n.points = l.points.map(p => ({ ...p })); } return n; }); } - -let clipboard = null; - -document.addEventListener('DOMContentLoaded', () => { - const backgroundCanvas = document.getElementById('backgroundCanvas'); const drawingCanvas = document.getElementById('drawingBoard'); const ctx = drawingCanvas.getContext('2d'); - const undoBtn = document.getElementById('undoBtn'); const redoBtn = document.getElementById('redoBtn'); - let canvasState; - function updateUndoRedoButtons() { undoBtn.disabled = historyIndex <= 0; redoBtn.disabled = historyIndex >= history.length - 1; } - - function saveState(layers) { - if (historyIndex < history.length - 1) { - history.splice(historyIndex + 1); - } - if (history.length > 50) { - history.shift(); - } - history.push(cloneLayers(layers)); - historyIndex = history.length - 1; - updateUndoRedoButtons(); - - try { - const serializableLayers = layers.map(layer => { - if (layer.type === 'image' && layer.image instanceof HTMLImageElement) { - const newLayer = { ...layer }; - if (!newLayer.src || !newLayer.src.startsWith('data:')) { - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = newLayer.image.naturalWidth; - tempCanvas.height = newLayer.image.naturalHeight; - const tempCtx = tempCanvas.getContext('2d'); - tempCtx.drawImage(newLayer.image, 0, 0); - newLayer.src = tempCanvas.toDataURL(); - } - delete newLayer.image; - return newLayer; - } - return layer; - }); - - const dataToSave = { - viewState: { - panX: canvasState.panX, - panY: canvasState.panY, - zoom: canvasState.zoom - }, - layers: serializableLayers - }; - - localStorage.setItem('drawingBoard', JSON.stringify(dataToSave)); - } catch (e) { - console.error("Не удалось сохранить состояние доски:", e); - } - } - - function undo() { if (historyIndex > 0) { historyIndex--; canvasState.layers = cloneLayers(history[historyIndex]); canvasState.selectedLayers = []; redraw(); updateUndoRedoButtons(); } } - function redo() { if (historyIndex < history.length - 1) { historyIndex++; canvasState.layers = cloneLayers(history[historyIndex]); canvasState.selectedLayers = []; redraw(); updateUndoRedoButtons(); } } - const setupCanvases = () => { const width = window.innerWidth, height = window.innerHeight;[backgroundCanvas, drawingCanvas].forEach(c => { c.width = width; c.height = height; }); drawBackground(backgroundCanvas, canvasState); if (canvasState) redraw(); }; - - const redraw = () => { - redrawCanvas(canvasState); - drawBackground(backgroundCanvas, canvasState); - }; - - const shapes2DOrder = ['rect', 'ellipse', 'line', 'parallelogram', 'triangle', 'trapezoid', 'rhombus']; - const shapes3DOrder = ['sphere', 'cone', 'parallelepiped', 'pyramid', 'frustum', 'truncated-pyramid', 'truncated-sphere']; - - function updateSubToolbarVisibility() { - if (!canvasState) return; - - const drawingSubToolbar = document.getElementById('drawingSubToolbar'); - drawingSubToolbar.classList.add('hidden'); - - const hasSelection = canvasState.selectedLayers.length > 0; - const activeTool = canvasState.activeTool; - - const drawableTools = ['brush', 'smart-brush', ...shapes2DOrder, ...shapes3DOrder]; - const isDrawingContext = drawableTools.includes(activeTool) || (hasSelection && canvasState.selectedLayers.some(l => l.type !== 'text')); - - if (isDrawingContext) { - drawingSubToolbar.classList.remove('hidden'); - if (hasSelection) { - const layer = canvasState.selectedLayers.find(l => l.hasOwnProperty('lineWidth')); - if (layer) { - document.getElementById('lineWidthSlider').value = layer.lineWidth; - } - - const colorLayer = canvasState.selectedLayers.find(l => l.hasOwnProperty('color')); - if(colorLayer) { - const colorPalette = document.getElementById('colorPalette'); - colorPalette.querySelectorAll('.active').forEach(el => el.classList.remove('active')); - const newActive = colorPalette.querySelector(`[data-color="${colorLayer.color}"]`); - if(newActive) newActive.classList.add('active'); - } - } - } - } - - initializeFloatingTextToolbar(); - - canvasState = initializeCanvas(drawingCanvas, ctx, redraw, saveState, updateSubToolbarVisibility); - initializeToolbar(canvasState, redraw, updateSubToolbarVisibility); - - initializeCustomTooltips(); - - function loadState(projectData = null) { - let dataToParse = projectData; - - if (!dataToParse) { - dataToParse = localStorage.getItem('drawingBoard') || localStorage.getItem('drawingBoardLayers'); - } - - if (!dataToParse) { - saveState([]); - return; - } - - try { - const loadedData = JSON.parse(dataToParse); - let layersToLoad; - let viewState = null; - - if (Array.isArray(loadedData)) { - layersToLoad = loadedData; - } else { - layersToLoad = loadedData.layers; - viewState = loadedData.viewState; - } - - if (viewState) { - canvasState.panX = viewState.panX || 0; - canvasState.panY = viewState.panY || 0; - canvasState.zoom = viewState.zoom || 1; - } - - const imageLoadPromises = []; - if (layersToLoad) { - layersToLoad.forEach(layer => { - if (layer.type === 'image' && layer.src) { - const img = new Image(); - const promise = new Promise((resolve, reject) => { - img.onload = () => { - layer.image = img; - delete layer.src; - resolve(); - }; - img.onerror = (err) => { - console.error('Не удалось загрузить изображение:', layer.src, err); - reject(err); - }; - }); - img.src = layer.src; - imageLoadPromises.push(promise); - } - }); - } - - Promise.all(imageLoadPromises).then(() => { - canvasState.layers = layersToLoad || []; - history.length = 0; - historyIndex = -1; - saveState(canvasState.layers); - redraw(); - }).catch(() => { - console.error("Не все изображения удалось загрузить."); - canvasState.layers = layersToLoad.filter(l => l.type !== 'image' || l.image); - saveState(canvasState.layers); - redraw(); - }); - - } catch (e) { - console.error("Не удалось загрузить состояние:", e); - saveState([]); - } - } - - if (canvasState.activeTool === 'brush') { - canvasState.canvas.classList.add('cursor-brush'); - } else if (canvasState.activeTool === 'eraser') { - canvasState.canvas.classList.add('cursor-eraser'); - } - - updateSubToolbarVisibility(); - - setupCanvases(); - undoBtn.addEventListener('click', undo); - redoBtn.addEventListener('click', redo); - - loadState(); - - const zoomInBtn = document.getElementById('zoomInBtn'); - const zoomOutBtn = document.getElementById('zoomOutBtn'); - - zoomInBtn.addEventListener('click', () => { - if (canvasState && typeof canvasState.performZoom === 'function') { - canvasState.performZoom('in'); - } - }); - - zoomOutBtn.addEventListener('click', () => { - if (canvasState && typeof canvasState.performZoom === 'function') { - canvasState.performZoom('out'); - } - }); - - document.getElementById('exportJpgBtn').addEventListener('click', (e) => { - e.preventDefault(); - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = drawingCanvas.width; - tempCanvas.height = drawingCanvas.height; - const tempCtx = tempCanvas.getContext('2d'); - - tempCtx.fillStyle = window.getComputedStyle(backgroundCanvas).backgroundColor || '#FFFFFF'; - tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); - - tempCtx.drawImage(backgroundCanvas, 0, 0); - tempCtx.drawImage(drawingCanvas, 0, 0); - const link = document.createElement('a'); - link.download = 'my-board.jpg'; - link.href = tempCanvas.toDataURL('image/jpeg', 0.95); - link.click(); - }); - - document.getElementById('saveProjectBtn').addEventListener('click', (e) => { - e.preventDefault(); - const serializableLayers = canvasState.layers.map(layer => { - if (layer.type === 'image' && layer.image instanceof HTMLImageElement) { - const newLayer = { ...layer }; - if (!newLayer.src || !newLayer.src.startsWith('data:')) { - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = newLayer.image.naturalWidth; - tempCanvas.height = newLayer.image.naturalHeight; - const tempCtx = tempCanvas.getContext('2d'); - tempCtx.drawImage(newLayer.image, 0, 0); - newLayer.src = tempCanvas.toDataURL(); - } - delete newLayer.image; - return newLayer; - } - return layer; - }); - - const projectData = { - viewState: { - panX: canvasState.panX, - panY: canvasState.panY, - zoom: canvasState.zoom - }, - layers: serializableLayers - }; - - const dataStr = JSON.stringify(projectData); - const blob = new Blob([dataStr], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.download = 'project.board'; - link.href = url; - link.click(); - URL.revokeObjectURL(url); - }); - - const projectUploadInput = document.getElementById('projectUpload'); - document.getElementById('openProjectBtn').addEventListener('click', (e) => { - e.preventDefault(); - projectUploadInput.click(); - }); - - projectUploadInput.addEventListener('change', (e) => { - const file = e.target.files[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = (event) => { - loadState(event.target.result); - }; - reader.onerror = () => { - console.error("Не удалось прочитать файл."); - alert("Ошибка при чтении файла."); - } - reader.readAsText(file); - e.target.value = null; - }); - - window.addEventListener('keydown', (e) => { - if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') { - return; - } - - if (e.ctrlKey || e.metaKey) { - switch(e.code) { - case 'KeyZ': e.preventDefault(); undo(); break; - case 'KeyY': e.preventDefault(); redo(); break; - case 'KeyC': - e.preventDefault(); - if (canvasState.selectedLayers.length > 0) { - clipboard = JSON.stringify(canvasState.selectedLayers.map(layer => { - const clonedLayer = {...layer}; - if (layer.type === 'image' && layer.image instanceof HTMLImageElement) { - clonedLayer.src = layer.image.src; - } - delete clonedLayer.image; - return clonedLayer; - })); - } - break; - case 'KeyX': - e.preventDefault(); - if (canvasState.selectedLayers.length > 0) { - clipboard = JSON.stringify(canvasState.selectedLayers.map(layer => { - const clonedLayer = {...layer}; - if (layer.type === 'image' && layer.image instanceof HTMLImageElement) { - clonedLayer.src = layer.image.src; - } - delete clonedLayer.image; - return clonedLayer; - })); - const idsToDelete = new Set(canvasState.selectedLayers.map(l => l.id)); - canvasState.layers = canvasState.layers.filter(layer => !idsToDelete.has(layer.id)); - canvasState.selectedLayers = []; - saveState(canvasState.layers); - redraw(); - canvasState.updateFloatingToolbar(); - } - break; - } - return; - } - - switch(e.code) { - case 'Escape': - e.preventDefault(); - if (canvasState.currentAction.startsWith('drawing')) { - canvasState.currentAction = 'none'; - canvasState.tempLayer = null; - redraw(); - } - else if (canvasState.isEditingText) { - const textarea = getEditorTextarea(); - if(textarea) textarea.blur(); - } - else if (canvasState.selectedLayers.length > 0) { - canvasState.selectedLayers = []; - redraw(); - canvasState.updateFloatingToolbar(); - } - break; - - case 'Delete': - case 'Backspace': - if (canvasState.selectedLayers.length > 0) { - e.preventDefault(); - const idsToDelete = new Set(canvasState.selectedLayers.map(l => l.id)); - canvasState.layers = canvasState.layers.filter(layer => !idsToDelete.has(layer.id)); - canvasState.selectedLayers = []; - saveState(canvasState.layers); - redraw(); - canvasState.updateFloatingToolbar(); - } - break; - - case 'KeyV': - e.preventDefault(); - document.querySelector('button[data-tool="select"]')?.click(); - drawingCanvas.focus({ preventScroll: true }); - break; - case 'KeyB': - e.preventDefault(); - const currentTool = canvasState.activeTool; - if (currentTool === 'brush') { - document.querySelector('button[data-tool="smart-brush"]')?.click(); - } else { - document.querySelector('button[data-tool="brush"]')?.click(); - } - drawingCanvas.focus({ preventScroll: true }); - break; - case 'KeyE': - e.preventDefault(); - document.querySelector('button[data-tool="eraser"]')?.click(); - drawingCanvas.focus({ preventScroll: true }); - break; - case 'KeyT': - e.preventDefault(); - document.querySelector('button[data-tool="text"]')?.click(); - drawingCanvas.focus({ preventScroll: true }); - break; - case 'KeyS': - e.preventDefault(); - const current2DIndex = shapes2DOrder.indexOf(canvasState.activeTool); - const next2DIndex = (current2DIndex === -1) ? 0 : (current2DIndex + 1) % shapes2DOrder.length; - const next2DTool = shapes2DOrder[next2DIndex]; - const shape2DLink = document.querySelector(`#shapes2DOptions a[data-tool="${next2DTool}"]`); - if (shape2DLink) { - shape2DLink.click(); - } - drawingCanvas.focus({ preventScroll: true }); - break; - case 'KeyD': - e.preventDefault(); - const current3DIndex = shapes3DOrder.indexOf(canvasState.activeTool); - const next3DIndex = (current3DIndex === -1) ? 0 : (current3DIndex + 1) % shapes3DOrder.length; - const next3DTool = shapes3DOrder[next3DIndex]; - const shape3DLink = document.querySelector(`#shapes3DOptions a[data-tool="${next3DTool}"]`); - if (shape3DLink) { - shape3DLink.click(); - } - drawingCanvas.focus({ preventScroll: true }); - break; - case 'KeyI': - e.preventDefault(); - document.getElementById('addImageBtn')?.click(); - drawingCanvas.focus({ preventScroll: true }); - break; - } - }); - - window.addEventListener('paste', (e) => { - if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') { - return; - } - e.preventDefault(); - - const items = e.clipboardData.items; - for (const item of items) { - if (item.kind === 'file' && item.type.startsWith('image/')) { - const file = item.getAsFile(); - const centerPos = { - x: (drawingCanvas.width / 2 - canvasState.panX) / canvasState.zoom, - y: (drawingCanvas.height / 2 - canvasState.panY) / canvasState.zoom - }; - const reader = new FileReader(); - reader.onload = (event) => { - const img = new Image(); - img.onload = () => { - const newLayer = { type: 'image', image: img, x: centerPos.x - img.width / 2, y: centerPos.y - img.height / 2, width: img.width, height: img.height, id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } }; - canvasState.layers.push(newLayer); - canvasState.selectedLayers = [newLayer]; - const selectButton = document.querySelector('button[data-tool="select"]'); - if (selectButton) { - selectButton.click(); - } - saveState(canvasState.layers); - redraw(); - canvasState.updateFloatingToolbar(); - }; - img.src = event.target.result; - }; - reader.readAsDataURL(file); - return; - } - } - - if (clipboard) { - try { - const layersToPaste = JSON.parse(clipboard); - const newLayers = []; - - layersToPaste.forEach(layer => { - const newLayer = { ...layer }; - newLayer.id = Date.now() + Math.random(); - - const offset = 20 / canvasState.zoom; - - if (newLayer.x !== undefined) { newLayer.x += offset; newLayer.y += offset; } - if (newLayer.cx !== undefined) { newLayer.cx += offset; newLayer.cy += offset; } - if (newLayer.x1 !== undefined) { newLayer.x1 += offset; newLayer.y1 += offset; newLayer.x2 += offset; newLayer.y2 += offset; } - if (newLayer.points) { newLayer.points.forEach(p => { p.x += offset; p.y += offset; }); } - if (newLayer.p1) { - const points = ['p1', 'p2', 'p3', 'p4', 'base', 'top', 'apex']; - for(const key of points){ - if(newLayer[key]?.x) { newLayer[key].x += offset; newLayer[key].y += offset;} - else if(typeof newLayer[key] === 'object'){ - for(const subKey in newLayer[key]){ - if(newLayer[key][subKey]?.x) { newLayer[key][subKey].x += offset; newLayer[key][subKey].y += offset;} - } - } - } - } - - if (newLayer.type === 'image' && newLayer.src) { - const img = new Image(); - img.onload = () => { - newLayer.image = img; - redraw(); - } - img.src = newLayer.src; - } - - canvasState.layers.push(newLayer); - newLayers.push(newLayer); - }); - - canvasState.selectedLayers = newLayers; - - const selectButton = document.querySelector('button[data-tool="select"]'); - if (selectButton) { - selectButton.click(); - } - - saveState(canvasState.layers); - redraw(); - canvasState.updateFloatingToolbar(); - - } catch (err) { - console.error("Не удалось вставить из буфера обмена:", err); - } - } - }); - - // --- НАЧАЛО ИЗМЕНЕНИЙ: Логика сброса позиции панели инструментов при изменении размера окна --- - window.addEventListener('resize', () => { - setupCanvases(); - if (canvasState) canvasState.updateFloatingToolbar(); - - // Сбрасываем инлайн-стили, чтобы CSS-правило центрирования снова сработало - const toolbarWrapper = document.getElementById('toolbarWrapper'); - if (toolbarWrapper) { - toolbarWrapper.style.left = ''; - toolbarWrapper.style.transform = ''; - } - }); - // --- КОНЕЦ ИЗМЕНЕНИЙ --- - - function initializeFloatingTextToolbar() { - const toolbar = document.getElementById('floating-text-toolbar'); - const colorPalette = document.getElementById('colorPalette'); - const floatingPalette = document.getElementById('floatingColorPalette'); - const colorPicker = document.getElementById('floating-color-picker'); - floatingPalette.innerHTML = colorPalette.innerHTML; - - toolbar.addEventListener('mousedown', (e) => { - if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') { - return; - } - e.preventDefault(); - const textarea = getEditorTextarea(); - if (textarea) { - textarea.style.pointerEvents = 'none'; - } - }); - - document.addEventListener('mouseup', () => { - const textarea = getEditorTextarea(); - if (textarea) { - textarea.style.pointerEvents = 'auto'; - } - }); - - const applyChange = (callback) => { - if (canvasState) { - const layer = canvasState.isEditingText - ? canvasState.layers.find(l => l.isEditing) - : (canvasState.selectedLayers.length === 1 && canvasState.selectedLayers[0].type === 'text' ? canvasState.selectedLayers[0] : null); - - if (layer) { - callback(layer); - canvasState.activeFontFamily = layer.fontFamily; - canvasState.activeFontSize = layer.fontSize; - canvasState.activeFontWeight = layer.fontWeight; - canvasState.activeFontStyle = layer.fontStyle; - canvasState.activeTextDecoration = layer.textDecoration; - canvasState.activeTextAlign = layer.align; - canvasState.activeColor = layer.color; - saveState(canvasState.layers); - redraw(); - - if (canvasState.isEditingText && canvasState.updateTextEditorStyle) { - canvasState.updateTextEditorStyle(layer); - } - - canvasState.updateFloatingToolbar(); - } - } - }; - - toolbar.addEventListener('click', e => { - const button = e.target.closest('button'); - if (button) { - const action = button.dataset.action; - if (!action || action === 'pick-color') return; - applyChange(layer => { - switch(action) { - case 'align-left': layer.align = 'left'; break; - case 'align-center': layer.align = 'center'; break; - case 'align-right': layer.align = 'right'; break; - case 'font-bold': layer.fontWeight = layer.fontWeight === 'bold' ? 'normal' : 'bold'; break; - case 'font-italic': layer.fontStyle = layer.fontStyle === 'italic' ? 'normal' : 'italic'; break; - case 'font-underline': layer.textDecoration = layer.textDecoration === 'underline' ? 'none' : 'underline'; break; - } - }); - } - }); - - colorPicker.addEventListener('click', (e) => { - e.stopPropagation(); - colorPicker.classList.toggle('active'); - }); - - floatingPalette.addEventListener('click', e => { - const colorDot = e.target.closest('.color-dot'); - if (colorDot) { - const newColor = colorDot.dataset.color; - applyChange(layer => { - layer.color = newColor; - const mainPalette = document.getElementById('colorPalette'); - mainPalette.querySelectorAll('.active').forEach(el => el.classList.remove('active')); - const newActive = mainPalette.querySelector(`[data-color="${newColor}"]`); - if (newActive) newActive.classList.add('active'); - }); - } - }); - - document.addEventListener('click', () => { - if(colorPicker.classList.contains('active')) { - colorPicker.classList.remove('active'); - } - }); - - document.getElementById('fontFamilySelect').addEventListener('change', e => { - applyChange(layer => layer.fontFamily = e.target.value); - }); - - document.getElementById('floatingFontSizeInput').addEventListener('input', e => { - applyChange(layer => layer.fontSize = parseInt(e.target.value, 10) || 30); - }); - } - - function initializeCustomTooltips() { - const tooltip = document.getElementById('custom-tooltip'); - if (!tooltip) return; - - document.body.addEventListener('mouseover', (e) => { - const target = e.target.closest('[title]'); - if (!target) return; - - const titleText = target.getAttribute('title'); - if (!titleText) return; - - target.dataset.originalTitle = titleText; - target.removeAttribute('title'); - - tooltip.textContent = titleText; - tooltip.classList.add('visible'); - - const targetRect = target.getBoundingClientRect(); - tooltip.style.left = `${targetRect.left + targetRect.width / 2}px`; - tooltip.style.top = `${targetRect.top}px`; - }); - - document.body.addEventListener('mouseout', (e) => { - const target = e.target.closest('[data-original-title]'); - if (!target) return; - - target.setAttribute('title', target.dataset.originalTitle); - target.removeAttribute('data-original-title'); - - tooltip.classList.remove('visible'); - }); - } - - function initializeHelpModal() { - const helpBtn = document.getElementById('helpBtn'); - const helpModal = document.getElementById('helpModal'); - const closeHelpBtn = document.getElementById('closeHelpBtn'); - - for (const panelId in helpContent) { - const panel = document.getElementById(panelId); - if (panel) { - panel.innerHTML = helpContent[panelId]; - } - } - - function openModal() { helpModal.classList.remove('hidden'); } - function closeModal() { helpModal.classList.add('hidden'); } - - helpBtn.addEventListener('click', (e) => { e.preventDefault(); openModal(); }); - closeHelpBtn.addEventListener('click', closeModal); - helpModal.addEventListener('click', (e) => { if (e.target === helpModal) { closeModal(); } }); - - const sidebarButtons = helpModal.querySelectorAll('.sidebar-button'); - const panels = helpModal.querySelectorAll('.modal-panel'); - sidebarButtons.forEach(button => { - button.addEventListener('click', () => { - sidebarButtons.forEach(btn => btn.classList.remove('active')); - panels.forEach(panel => panel.classList.remove('active')); - button.classList.add('active'); - const panelId = button.getAttribute('data-panel'); - document.getElementById(panelId).classList.add('active'); - }); - }); - } - initializeHelpModal(); - - const settingsBtn = document.getElementById('settingsBtn'); - const settingsModal = document.getElementById('settingsModal'); - const okBtn = document.getElementById('okSettings'); - const cancelBtn = document.getElementById('cancelSettings'); - const themeSelect = document.getElementById('theme-select'); - const backgroundStyleSelect = document.getElementById('background-style-select'); - const smoothingSlider = document.getElementById('smoothing-slider'); - const smoothingValue = document.getElementById('smoothing-value'); - - function applyAndSaveSettings() { - const theme = themeSelect.value; - const backgroundStyle = backgroundStyleSelect.value; - const smoothing = smoothingSlider.value; - - document.body.classList.toggle('dark-theme', theme === 'dark'); - - localStorage.setItem('boardTheme', theme); - localStorage.setItem('boardBackgroundStyle', backgroundStyle); - localStorage.setItem('boardSmoothing', smoothing); - - if (canvasState) { - canvasState.smoothingAmount = parseInt(smoothing, 10); - } - redraw(); - } - - function loadSettings() { - const savedTheme = localStorage.getItem('boardTheme') || 'light'; - const savedStyle = localStorage.getItem('boardBackgroundStyle') || 'dot'; - const savedSmoothing = localStorage.getItem('boardSmoothing') || '2'; - - themeSelect.value = savedTheme; - backgroundStyleSelect.value = savedStyle; - smoothingSlider.value = savedSmoothing; - smoothingValue.textContent = savedSmoothing; - - document.body.classList.toggle('dark-theme', savedTheme === 'dark'); - - if (canvasState) { - canvasState.smoothingAmount = parseInt(savedSmoothing, 10); - } - redraw(); - } - - smoothingSlider.addEventListener('input', () => { - smoothingValue.textContent = smoothingSlider.value; - }); - - function closeModal() { settingsModal.classList.add('hidden'); } - settingsBtn.addEventListener('click', (e) => { e.preventDefault(); loadSettings(); settingsModal.classList.remove('hidden'); }); - okBtn.addEventListener('click', () => { applyAndSaveSettings(); closeModal(); }); - cancelBtn.addEventListener('click', closeModal); - settingsModal.addEventListener('click', (e) => { if (e.target === settingsModal) { closeModal(); } }); - - const settingsSidebarButtons = settingsModal.querySelectorAll('.sidebar-button'); - const settingsPanels = settingsModal.querySelectorAll('.modal-panel'); - settingsSidebarButtons.forEach(button => { - button.addEventListener('click', () => { - settingsSidebarButtons.forEach(btn => btn.classList.remove('active')); - settingsPanels.forEach(panel => panel.classList.remove('active')); - button.classList.add('active'); - const panelId = button.getAttribute('data-panel'); - document.getElementById(panelId).classList.add('active'); - }); - }); - - loadSettings(); -}); - -function rotatePoint(point, pivot, angle) { - const s = Math.sin(angle); - const c = Math.cos(angle); - const px = point.x - pivot.x; - const py = point.y - pivot.y; - const xnew = px * c - py * s; - const ynew = px * s + py * c; - return { - x: xnew + pivot.x, - y: ynew + pivot.y, - }; -} - -function wrapText(ctx, text, maxWidth) { - const manualLines = text.split('\n'); - let allLines = []; - - manualLines.forEach(manualLine => { - if (manualLine === '') { - allLines.push(''); - return; - } - const words = manualLine.split(' '); - let currentLine = ''; - for (const word of words) { - const testLine = currentLine === '' ? word : `${currentLine} ${word}`; - const metrics = ctx.measureText(testLine); - - if (metrics.width > maxWidth && currentLine !== '') { - allLines.push(currentLine); - currentLine = word; - } else { - currentLine = testLine; - } - } - allLines.push(currentLine); - }); - - return allLines; -} - - -function drawLayer(ctx, layer) { - if (!layer || layer.isEditing) return; - ctx.save(); - - const rotation = layer.rotation || 0; - if (rotation) { - const box = getBoundingBox(layer); - if (box) { - const centerX = box.x + box.width / 2; - const centerY = box.y + box.height / 2; - - const pivot = layer.pivot || { x: 0, y: 0 }; - - const rotatedPivotOffset = rotatePoint(pivot, {x: 0, y: 0}, rotation); - - const pivotX = centerX + rotatedPivotOffset.x; - const pivotY = centerY + rotatedPivotOffset.y; - - ctx.translate(pivotX, pivotY); - ctx.rotate(rotation); - ctx.translate(-pivotX, -pivotY); - } - } - - ctx.strokeStyle = layer.color; - ctx.fillStyle = layer.color; - ctx.lineWidth = layer.lineWidth; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - - if (layer.type === 'path') { - if (layer.points.length < 1) { ctx.restore(); return; } - if (layer.points.length === 1) { - ctx.beginPath(); - const point = layer.points[0]; - const pressure = point.pressure || 0.5; - const radius = Math.max(0.5, (layer.lineWidth * pressure) / 2); - ctx.arc(point.x, point.y, radius, 0, 2 * Math.PI); - ctx.fill(); - } else { - const points = layer.points; - if (points.length < 3) { - ctx.beginPath(); - ctx.moveTo(points[0].x, points[0].y); - for (let i = 1; i < points.length; i++) { - const pressure = points[i-1].pressure || 0.5; - ctx.lineWidth = Math.max(1, layer.lineWidth * pressure); - ctx.lineTo(points[i].x, points[i].y); - } - ctx.stroke(); - } else { - for (let i = 0; i < points.length - 1; i++) { - const p0 = i > 0 ? points[i - 1] : points[i]; - const p1 = points[i]; - const p2 = points[i + 1]; - const p3 = i < points.length - 2 ? points[i + 2] : p2; - - const cp1 = { x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6 }; - const cp2 = { x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6 }; - - ctx.beginPath(); - ctx.moveTo(p1.x, p1.y); - - const pressure = p1.pressure || 0.5; - ctx.lineWidth = Math.max(1, layer.lineWidth * pressure); - - ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, p2.x, p2.y); - ctx.stroke(); - } - } - } - } - else if (layer.type === 'rect') { ctx.beginPath(); ctx.strokeRect(layer.x, layer.y, layer.width, layer.height); } - else if (layer.type === 'ellipse') { ctx.beginPath(); ctx.ellipse(layer.cx, layer.cy, layer.rx, layer.ry, 0, 0, 2 * Math.PI); ctx.stroke(); } - else if (layer.type === 'line') { ctx.beginPath(); ctx.moveTo(layer.x1, layer.y1); ctx.lineTo(layer.x2, layer.y2); ctx.stroke(); } - else if (layer.type === 'parallelogram') { ctx.beginPath(); ctx.moveTo(layer.x, layer.y + layer.height); ctx.lineTo(layer.x + layer.width, layer.y + layer.height); ctx.lineTo(layer.x + layer.width + layer.slantOffset, layer.y); ctx.lineTo(layer.x + layer.slantOffset, layer.y); ctx.closePath(); ctx.stroke(); } - else if (layer.type === 'triangle') { ctx.beginPath(); ctx.moveTo(layer.p1.x, layer.p1.y); ctx.lineTo(layer.p2.x, layer.p2.y); ctx.lineTo(layer.p3.x, layer.p3.y); ctx.closePath(); ctx.stroke(); } - else if (layer.type === 'text') { - const fontWeight = layer.fontWeight || 'normal'; - const fontStyle = layer.fontStyle || 'normal'; - ctx.font = `${fontStyle} ${fontWeight} ${layer.fontSize}px ${layer.fontFamily}`; - ctx.textBaseline = 'top'; - - const lines = wrapText(ctx, layer.content, layer.width); - - const align = layer.align || 'left'; - ctx.textAlign = align; - let x; - if (align === 'center') { - x = layer.x + layer.width / 2; - } else if (align === 'right') { - x = layer.x + layer.width; - } else { - x = layer.x; - } - - const lineHeight = layer.fontSize * 1.2; - lines.forEach((line, index) => { - const y = layer.y + index * lineHeight; - ctx.fillText(line, x, y); - - if (layer.textDecoration === 'underline') { - const metrics = ctx.measureText(line); - const lineY = y + layer.fontSize + 2; - - let startX, endX; - if (align === 'center') { - startX = x - metrics.width / 2; - endX = x + metrics.width / 2; - } else if (align === 'right') { - startX = x - metrics.width; - endX = x; - } else { // left - startX = x; - endX = x + metrics.width; - } - ctx.beginPath(); - ctx.moveTo(startX, lineY); - ctx.lineTo(endX, lineY); - ctx.strokeStyle = layer.color; - ctx.lineWidth = Math.max(1, layer.fontSize / 15); - ctx.stroke(); - } - }); - } - else if (layer.type === 'sphere') { const { cx, cy, r } = layer; const equatorRy = r * 0.3, meridianRx = r * 0.5; ctx.setLineDash([]); ctx.beginPath(); ctx.arc(cx, cy, r, 0, 2 * Math.PI); ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx, cy, r, equatorRy, 0, 0, Math.PI); ctx.stroke(); ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.ellipse(cx, cy, r, equatorRy, 0, Math.PI, 2 * Math.PI); ctx.stroke(); ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.ellipse(cx, cy, meridianRx, r, 0, -Math.PI / 2, Math.PI / 2); ctx.stroke(); ctx.setLineDash([]); ctx.beginPath(); ctx.ellipse(cx, cy, meridianRx, r, 0, Math.PI / 2, 3 * Math.PI / 2); ctx.stroke(); ctx.setLineDash([]); } - else if (layer.type === 'cone') { const { cx, baseY, rx, ry, apex } = layer; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(cx - rx, baseY); ctx.lineTo(apex.x, apex.y); ctx.lineTo(cx + rx, baseY); ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx, baseY, rx, ry, 0, 0, Math.PI); ctx.stroke(); ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.ellipse(cx, baseY, rx, ry, 0, Math.PI, 2 * Math.PI); ctx.stroke(); ctx.setLineDash([]); } - else if (layer.type === 'parallelepiped') { const { x, y, width, height, depthOffset } = layer; const dx = depthOffset.x, dy = depthOffset.y; const p = [ {x, y}, {x: x + width, y}, {x: x + width, y: y + height}, {x, y: y + height}, {x: x + dx, y: y + dy}, {x: x + width + dx, y: y + dy}, {x: x + width + dx, y: y + height + dy}, {x: x + dx, y: y + height + dy} ]; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[1].x, p[1].y); ctx.lineTo(p[2].x, p[2].y); ctx.lineTo(p[3].x, p[3].y); ctx.closePath(); ctx.moveTo(p[1].x, p[1].y); ctx.lineTo(p[5].x, p[5].y); ctx.lineTo(p[6].x, p[6].y); ctx.lineTo(p[2].x, p[2].y); ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[4].x, p[4].y); ctx.lineTo(p[5].x, p[5].y); ctx.stroke(); ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.moveTo(p[3].x, p[3].y); ctx.lineTo(p[7].x, p[7].y); ctx.lineTo(p[4].x, p[4].y); ctx.moveTo(p[6].x, p[6].y); ctx.lineTo(p[7].x, p[7].y); ctx.stroke(); ctx.setLineDash([]); } - else if (layer.type === 'pyramid') { - const { base, apex } = layer; - const p = [ base.p1, base.p2, base.p3, base.p4 ]; - - ctx.setLineDash([5, 5]); - ctx.beginPath(); - ctx.moveTo(p[3].x, p[3].y); ctx.lineTo(p[0].x, p[0].y); - ctx.moveTo(p[3].x, p[3].y); ctx.lineTo(p[2].x, p[2].y); - ctx.moveTo(p[3].x, p[3].y); ctx.lineTo(apex.x, apex.y); - ctx.stroke(); - - ctx.setLineDash([]); - ctx.beginPath(); - ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[1].x, p[1].y); - ctx.moveTo(p[1].x, p[1].y); ctx.lineTo(p[2].x, p[2].y); - ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(apex.x, apex.y); - ctx.moveTo(p[1].x, p[1].y); ctx.lineTo(apex.x, apex.y); - ctx.moveTo(p[2].x, p[2].y); ctx.lineTo(apex.x, apex.y); - ctx.stroke(); - } - else if (layer.type === 'trapezoid' || layer.type === 'rhombus') { ctx.beginPath(); ctx.moveTo(layer.p1.x, layer.p1.y); ctx.lineTo(layer.p2.x, layer.p2.y); ctx.lineTo(layer.p3.x, layer.p3.y); ctx.lineTo(layer.p4.x, layer.p4.y); ctx.closePath(); ctx.stroke(); } - else if (layer.type === 'frustum') { const { cx, baseY, topY, rx1, ry1, rx2, ry2 } = layer; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(cx - rx1, baseY); ctx.lineTo(cx - rx2, topY); ctx.moveTo(cx + rx1, baseY); ctx.lineTo(cx + rx2, topY); ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx, baseY, rx1, ry1, 0, 0, Math.PI); ctx.stroke(); ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.ellipse(cx, baseY, rx1, ry1, 0, Math.PI, 2 * Math.PI); ctx.stroke(); ctx.setLineDash([]); ctx.beginPath(); ctx.ellipse(cx, topY, rx2, ry2, 0, 0, 2 * Math.PI); ctx.stroke(); ctx.setLineDash([]); } - else if (layer.type === 'truncated-sphere') { const { cx, cy, r, cutY, cutR, cutRy } = layer; const sinAngle = (cutY - cy) / r; const clampedSinAngle = Math.max(-1, Math.min(1, sinAngle)); const angle = Math.asin(clampedSinAngle); ctx.setLineDash([]); ctx.beginPath(); ctx.arc(cx, cy, r, angle, Math.PI - angle); ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx, cutY, cutR, cutRy, 0, 0, Math.PI); ctx.stroke(); ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.ellipse(cx, cutY, cutR, cutRy, 0, Math.PI, 2 * Math.PI); ctx.stroke(); ctx.setLineDash([]); } - else if (layer.type === 'truncated-pyramid') { - const { base, top } = layer; - const b = [ base.p1, base.p2, base.p3, base.p4 ]; - const t = [ top.p1, top.p2, top.p3, top.p4 ]; - - ctx.setLineDash([5, 5]); - ctx.beginPath(); - ctx.moveTo(b[3].x, b[3].y); ctx.lineTo(b[0].x, b[0].y); - ctx.moveTo(b[3].x, b[3].y); ctx.lineTo(b[2].x, b[2].y); - ctx.moveTo(b[3].x, b[3].y); ctx.lineTo(t[3].x, t[3].y); - ctx.stroke(); - - ctx.setLineDash([]); - ctx.beginPath(); - ctx.moveTo(b[0].x, b[0].y); ctx.lineTo(b[1].x, b[1].y); - ctx.lineTo(b[2].x, b[2].y); - ctx.moveTo(t[0].x, t[0].y); ctx.lineTo(t[1].x, t[1].y); - ctx.lineTo(t[2].x, t[2].y); - ctx.moveTo(b[0].x, b[0].y); ctx.lineTo(t[0].x, t[0].y); - ctx.moveTo(b[1].x, b[1].y); ctx.lineTo(t[1].x, t[1].y); - ctx.moveTo(b[2].x, b[2].y); ctx.lineTo(t[2].x, t[2].y); - ctx.moveTo(t[3].x, t[3].y); ctx.lineTo(t[2].x, t[2].y); - ctx.moveTo(t[3].x, t[3].y); ctx.lineTo(t[0].x, t[0].y); - ctx.stroke(); - } - else if (layer.type === 'image' && layer.image instanceof HTMLImageElement && layer.image.complete) { - ctx.drawImage(layer.image, layer.x, layer.y, layer.width, layer.height); - } - ctx.restore(); -} - -function redrawCanvas(canvasState) { - if(!canvasState) return; - const { ctx, layers, canvas } = canvasState; - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.save(); - ctx.translate(canvasState.panX, canvasState.panY); - ctx.scale(canvasState.zoom, canvasState.zoom); - layers.forEach(layer => drawLayer(ctx, layer)); - drawSelectionBox(ctx, canvasState.selectedLayers, canvasState); - ctx.restore(); -} - -function drawBackground(bgCanvas, canvasState) { - const bgCtx = bgCanvas.getContext('2d'); - const style = localStorage.getItem('boardBackgroundStyle') || 'dot'; - const theme = localStorage.getItem('boardTheme') || 'light'; - const color = theme === 'light' ? '#d1d1d1' : '#5a5a5a'; - const spacing = 20; - bgCtx.clearRect(0, 0, bgCanvas.width, bgCanvas.height); - if (!canvasState) { if (style === 'dot') { for (let x = 0; x < bgCanvas.width; x += spacing) { for (let y = 0; y < bgCanvas.height; y += spacing) { bgCtx.fillStyle = color; bgCtx.beginPath(); bgCtx.arc(x, y, 1, 0, 2 * Math.PI, false); bgCtx.fill(); } } } else { bgCtx.strokeStyle = color; bgCtx.lineWidth = 0.5; for (let x = 0; x < bgCanvas.width; x += spacing) { bgCtx.beginPath(); bgCtx.moveTo(x, 0); bgCtx.lineTo(x, bgCanvas.height); bgCtx.stroke(); } for (let y = 0; y < bgCanvas.height; y += spacing) { bgCtx.beginPath(); bgCtx.moveTo(0, y); bgCtx.lineTo(bgCanvas.width, y); bgCtx.stroke(); } } return; } - const { panX, panY, zoom } = canvasState; - const visualSpacing = spacing * zoom; - if (visualSpacing < 5) return; - const startX = panX % visualSpacing; - const startY = panY % visualSpacing; - if (style === 'dot') { bgCtx.fillStyle = color; for (let x = startX; x < bgCanvas.width; x += visualSpacing) { for (let y = startY; y < bgCanvas.height; y += visualSpacing) { bgCtx.beginPath(); bgCtx.arc(x, y, 1, 0, 2 * Math.PI, false); bgCtx.fill(); } } } - else { bgCtx.strokeStyle = color; bgCtx.lineWidth = 0.5; for (let x = startX; x < bgCanvas.width; x += visualSpacing) { bgCtx.beginPath(); bgCtx.moveTo(x, 0); bgCtx.lineTo(x, bgCanvas.height); bgCtx.stroke(); } for (let y = startY; y < bgCanvas.height; y += visualSpacing) { bgCtx.beginPath(); bgCtx.moveTo(0, y); bgCtx.lineTo(bgCanvas.width, y); bgCtx.stroke(); } } -} - -function drawSelectionBox(ctx, selectedLayers, canvasState) { - if (!selectedLayers || selectedLayers.length === 0 || !canvasState) return; - if (selectedLayers.some(l => l.isEditing)) return; - - const box = selectedLayers.length > 1 ? getGroupBoundingBox(selectedLayers) : getBoundingBox(selectedLayers[0]); - if (!box) return; - - const isSingleSelection = selectedLayers.length === 1; - const layer = isSingleSelection ? selectedLayers[0] : null; - - const zoom = canvasState.zoom; - const scaledLineWidth = 1 / zoom; - const scaledHandleSize = 8 / zoom; - const scaledHalfHandle = scaledHandleSize / 2; - const scaledDash = [5 / zoom, 5 / zoom]; - - const rotation = getSelectionRotation(selectedLayers, canvasState.groupRotation); - const centerX = box.x + box.width / 2; - const centerY = box.y + box.height / 2; - - let pivotX = centerX; - let pivotY = centerY; - - if (isSingleSelection && layer && layer.pivot) { - const rotatedPivotOffset = rotatePoint(layer.pivot, {x:0, y:0}, rotation); - pivotX = centerX + rotatedPivotOffset.x; - pivotY = centerY + rotatedPivotOffset.y; - } - - ctx.save(); - ctx.translate(pivotX, pivotY); - ctx.rotate(rotation); - ctx.translate(-pivotX, -pivotY); - - ctx.strokeStyle = '#007AFF'; - ctx.lineWidth = scaledLineWidth; - ctx.setLineDash(scaledDash); - ctx.strokeRect(box.x, box.y, box.width, box.height); - ctx.setLineDash([]); - ctx.fillStyle = '#007AFF'; - - const handles = [ - { x: box.x, y: box.y }, { x: centerX, y: box.y }, { x: box.x + box.width, y: box.y }, - { x: box.x, y: centerY }, { x: box.x + box.width, y: centerY }, - { x: box.x, y: box.y + box.height }, { x: centerX, y: box.y + box.height }, { x: box.x + box.width, y: box.y + box.height } - ]; - handles.forEach(handle => { - ctx.fillRect(handle.x - scaledHalfHandle, handle.y - scaledHalfHandle, scaledHandleSize, scaledHandleSize); - }); - - const rotationHandleY = box.y - 20 / zoom; - ctx.beginPath(); - ctx.moveTo(centerX, box.y); - ctx.lineTo(centerX, rotationHandleY); - ctx.stroke(); - ctx.beginPath(); - ctx.arc(centerX, rotationHandleY, scaledHalfHandle, 0, 2 * Math.PI); - ctx.fill(); - - ctx.restore(); - - if (isSingleSelection) { - ctx.save(); - ctx.strokeStyle = '#007AFF'; - ctx.lineWidth = scaledLineWidth; - ctx.beginPath(); - ctx.arc(pivotX, pivotY, scaledHalfHandle, 0, 2 * Math.PI); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(pivotX - scaledHalfHandle, pivotY); - ctx.lineTo(pivotX + scaledHalfHandle, pivotY); - ctx.moveTo(pivotX, pivotY - scaledHalfHandle); - ctx.lineTo(pivotX, pivotY + scaledHalfHandle); - ctx.stroke(); - ctx.restore(); - } -} -// --- END OF FILE main.js --- \ No newline at end of file diff --git a/js/shapeRecognizer.js b/js/shapeRecognizer.js deleted file mode 100644 index 85e902f..0000000 --- a/js/shapeRecognizer.js +++ /dev/null @@ -1,164 +0,0 @@ -// --- START OF FILE js/shapeRecognizer.js --- - -import { simplifyPath } from './utils.js'; - -/** - * Получает ограничительную рамку для массива точек. - */ -function getBoundingBox(points) { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - points.forEach(p => { - minX = Math.min(minX, p.x); - minY = Math.min(minY, p.y); - maxX = Math.max(maxX, p.x); - maxY = Math.max(maxY, p.y); - }); - return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; -} - -/** - * Проверяет, является ли путь "почти замкнутым". - * Допуск зависит от размера фигуры. - */ -function isPathClosed(points, box) { - if (points.length < 3) return false; - const tolerance = Math.hypot(box.width, box.height) * 0.25; // 25% от диагонали - const first = points[0]; - const last = points[points.length - 1]; - return Math.hypot(first.x - last.x, first.y - last.y) < tolerance; -} - -/** - * Вспомогательная функция: вычисляет перпендикулярное расстояние от точки до отрезка. - */ -function perpendicularDistance(pt, p1, p2) { - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; - const lenSq = dx * dx + dy * dy; - if (lenSq === 0) return Math.hypot(pt.x - p1.x, pt.y - p1.y); - return Math.abs(dy * pt.x - dx * pt.y + p2.x * p1.y - p2.y * p1.x) / Math.sqrt(lenSq); -} - -/** - * Пытается распознать прямую линию. - */ -function recognizeLine(points) { - const p1 = points[0]; - const p2 = points[points.length - 1]; - const directDistance = Math.hypot(p1.x - p2.x, p1.y - p2.y); - - if (directDistance < 30) return null; - - let maxDeviation = 0; - for (let i = 1; i < points.length - 1; i++) { - const deviation = perpendicularDistance(points[i], p1, p2); - if (deviation > maxDeviation) { - maxDeviation = deviation; - } - } - - if (maxDeviation < directDistance * 0.08) { // Допуск 8% от длины - return { - type: 'line', - x1: p1.x, y1: p1.y, - x2: p2.x, y2: p2.y, - id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } - }; - } - - return null; -} - -/** - * Пытается распознать эллипс или круг. - */ -function recognizeEllipse(points) { - const box = getBoundingBox(points); - const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; - - let totalError = 0; - points.forEach(p => { - const dx = p.x - center.x; - const dy = p.y - center.y; - if (box.width > 0 && box.height > 0) { - const error = Math.abs(1 - ((dx * dx) / (box.width / 2) ** 2 + (dy * dy) / (box.height / 2) ** 2)); - totalError += error; - } - }); - const averageError = totalError / points.length; - - if (averageError < 0.4) { - return { - type: 'ellipse', - cx: center.x, cy: center.y, - rx: box.width / 2, ry: box.height / 2, - id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } - }; - } - return null; -} - -/** - * Пытается распознать многоугольник (треугольник или прямоугольник). - */ -function recognizePolygon(points) { - const box = getBoundingBox(points); - const tolerance = Math.hypot(box.width, box.height) * 0.1; - const simplified = simplifyPath(points, tolerance); - - // Если 3 или 4 точки (для треугольника) - if (simplified.length === 3 || (simplified.length === 4 && isPathClosed(simplified, box))) { - return { - type: 'triangle', - p1: simplified[0], p2: simplified[1], p3: simplified[2], - id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } - }; - } - - // Если 4 или 5 точек (для прямоугольника) - if (simplified.length === 4 || (simplified.length === 5 && isPathClosed(simplified, box))) { - return { - type: 'rect', - x: box.x, y: box.y, - width: box.width, height: box.height, - id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } - }; - } - return null; -} - - -/** - * Главная функция, которая пытается распознать фигуру. - */ -export function recognizeShape(points) { - if (points.length < 10) return null; - const box = getBoundingBox(points); - if (box.width < 20 && box.height < 20) return null; - - // --- НАЧАЛО НОВОЙ ЛОГИКИ С ПРАВИЛЬНЫМ ПОРЯДКОМ --- - - // 1. Проверяем, замкнута ли фигура. - if (isPathClosed(points, box)) { - // Если да, то это НЕ линия. Ищем среди замкнутых фигур. - const analysisPoints = [...points, points[0]]; - - // 2. Сначала ищем многоугольники, так как у них более строгие критерии (углы). - let shape = recognizePolygon(analysisPoints); - if (shape) return shape; - - // 3. Если это не многоугольник, проверяем на эллипс. - shape = recognizeEllipse(analysisPoints); - if (shape) return shape; - - } else { - // 4. Если фигура НЕ замкнута, это может быть только линия. - let shape = recognizeLine(points); - if (shape) return shape; - } - - // --- КОНЕЦ НОВОЙ ЛОГИКИ --- - - return null; -} -// --- END OF FILE js/shapeRecognizer.js --- \ No newline at end of file diff --git a/js/text.js b/js/text.js deleted file mode 100644 index 060b355..0000000 --- a/js/text.js +++ /dev/null @@ -1,169 +0,0 @@ -// --- START OF FILE js/text.js --- - -let editorTextarea = null; -let currentEditingLayer = null; -let canvasStateRef = null; -let onFinishCallback = null; - -// --- НАЧАЛО ИЗМЕНЕНИЙ: Новая функция для доступа к полю ввода из других файлов --- -export function getEditorTextarea() { - return editorTextarea; -} -// --- КОНЕЦ ИЗМЕНЕНИЙ --- - -function initializeTextEditor() { - if (editorTextarea) return; - - editorTextarea = document.createElement('textarea'); - editorTextarea.id = 'text-editor-textarea'; - editorTextarea.wrap = 'soft'; - - document.body.appendChild(editorTextarea); - - // --- НАЧАЛО ИЗМЕНЕНИЙ: Исправлена логика закрытия редактора --- - // Используем 'focusout', чтобы определить, куда переместился фокус - editorTextarea.addEventListener('focusout', (e) => { - const toolbar = document.getElementById('floating-text-toolbar'); - // e.relatedTarget — это элемент, который ПОЛУЧАЕТ фокус. - // Если этот элемент находится внутри нашей плавающей панели, значит, - // пользователь хочет изменить настройки, а не закончить редактирование. - if (e.relatedTarget && toolbar.contains(e.relatedTarget)) { - // В этом случае просто ничего не делаем и оставляем редактор активным. - return; - } - // Если же фокус ушел в любое другое место, завершаем редактирование. - finishEditing(); - }); - // --- КОНЕЦ ИЗМЕНЕНИЙ --- - - editorTextarea.addEventListener('input', updateEditorSizeAndLayer); - editorTextarea.addEventListener('keydown', (e) => { - if (e.key === 'Escape' || (e.key === 'Enter' && (e.shiftKey || e.ctrlKey || e.metaKey))) { - e.preventDefault(); - finishEditing(); - } - }); -} - -function wrapText(ctx, text, maxWidth) { - const manualLines = text.split('\n'); - let allLines = []; - - manualLines.forEach(manualLine => { - if (manualLine === '') { - allLines.push(''); - return; - } - const words = manualLine.split(' '); - let currentLine = ''; - for (const word of words) { - const testLine = currentLine === '' ? word : `${currentLine} ${word}`; - const metrics = ctx.measureText(testLine); - - if (metrics.width > maxWidth && currentLine !== '') { - allLines.push(currentLine); - currentLine = word; - } else { - currentLine = testLine; - } - } - allLines.push(currentLine); - }); - - return allLines; -} - -/** - * Обновляет CSS-стили текстового редактора в соответствии со свойствами слоя. - * @param {object} layer - Слой текста, чьи свойства нужно применить. - */ -export function updateEditorStyle(layer) { - if (!editorTextarea || !layer || !canvasStateRef) return; - - const { zoom } = canvasStateRef; - const fontWeight = layer.fontWeight || 'normal'; - const fontStyle = layer.fontStyle || 'normal'; - - editorTextarea.style.fontSize = `${layer.fontSize * zoom}px`; - editorTextarea.style.fontFamily = layer.fontFamily; - editorTextarea.style.fontWeight = fontWeight; - editorTextarea.style.fontStyle = fontStyle; - editorTextarea.style.textAlign = layer.align || 'left'; - editorTextarea.style.textDecoration = layer.textDecoration || 'none'; - editorTextarea.style.color = layer.color; - editorTextarea.style.lineHeight = `${layer.fontSize * 1.2 * zoom}px`; - - updateEditorSizeAndLayer(); -} - -function updateEditorSizeAndLayer() { - if (!currentEditingLayer || !canvasStateRef) return; - - currentEditingLayer.content = editorTextarea.value; - - const { ctx, zoom } = canvasStateRef; - const fontWeight = currentEditingLayer.fontWeight || 'normal'; - const fontStyle = currentEditingLayer.fontStyle || 'normal'; - ctx.font = `${fontStyle} ${fontWeight} ${currentEditingLayer.fontSize}px ${currentEditingLayer.fontFamily}`; - - const lines = wrapText(ctx, currentEditingLayer.content, currentEditingLayer.width); - const newHeight = lines.length * (currentEditingLayer.fontSize * 1.2); - currentEditingLayer.height = newHeight > 0 ? newHeight : (currentEditingLayer.fontSize * 1.2); - - editorTextarea.style.height = `${currentEditingLayer.height * zoom}px`; - - if(onFinishCallback) { - onFinishCallback(true); - } -} - -export function startEditing(canvasState, layer, onFinish) { - initializeTextEditor(); - - currentEditingLayer = layer; - canvasStateRef = canvasState; - onFinishCallback = onFinish; - - const { panX, panY, zoom } = canvasState; - - editorTextarea.style.width = `${layer.width * zoom}px`; - - editorTextarea.style.position = 'fixed'; - editorTextarea.style.zIndex = '1000'; - editorTextarea.style.display = 'block'; - editorTextarea.style.border = `1px dashed #007AFF`; - editorTextarea.style.left = `${(layer.x * zoom) + panX}px`; - editorTextarea.style.top = `${(layer.y * zoom) + panY}px`; - - editorTextarea.value = layer.content; - - updateEditorStyle(layer); - - setTimeout(() => { - editorTextarea.focus(); - if (layer.content === '') { - editorTextarea.select(); - } - }, 0); -} - -function finishEditing() { - if (!currentEditingLayer) return; - - updateEditorSizeAndLayer(); - - if (currentEditingLayer.content.trim() === '') { - const index = canvasStateRef.layers.findIndex(l => l.id === currentEditingLayer.id); - if (index > -1) { - canvasStateRef.layers.splice(index, 1); - } - } - - editorTextarea.style.display = 'none'; - currentEditingLayer = null; - - if (onFinishCallback) { - onFinishCallback(false); - } -} -// --- END OF FILE js/text.js --- \ No newline at end of file diff --git a/js/toolbar.js b/js/toolbar.js deleted file mode 100644 index bdcd7a8..0000000 --- a/js/toolbar.js +++ /dev/null @@ -1,246 +0,0 @@ -// --- START OF FILE js/toolbar.js --- - -export function initializeToolbar(canvasState, redrawCallback, updateToolbarCallback) { - const toolbarWrapper = document.getElementById('toolbarWrapper'); - const toolbar = document.getElementById('toolbar'); - - // --- НАЧАЛО ИЗМЕНЕНИЙ: Удалены ссылки на старую текстовую панель --- - const drawingSubToolbar = document.getElementById('drawingSubToolbar'); - const colorPalette = document.getElementById('colorPalette'); - const lineWidthSlider = document.getElementById('lineWidthSlider'); - // --- КОНЕЦ ИЗМЕНЕНИЙ --- - - const lineWidthIndicator = document.getElementById('lineWidthIndicator'); - - const shapes2DBtn = document.getElementById('shapes2DBtn'); - const shapes2DOptions = document.getElementById('shapes2DOptions'); - const shapes2DToolContainer = document.getElementById('shapes-2d-tool-container'); - - const shapes3DBtn = document.getElementById('shapes3DBtn'); - const shapes3DOptions = document.getElementById('shapes3DOptions'); - const shapes3DToolContainer = document.getElementById('shapes-3d-tool-container'); - - const zoomControls = document.getElementById('zoomControls'); - - function cancelInProgressActions() { - const multiStepActions = [ - 'drawingParallelogramSlant', 'drawingTriangleApex', 'drawingParallelepipedDepth', - 'drawingPyramidApex', 'drawingTrapezoidP3', 'drawingTrapezoidP4', - 'drawingFrustum', 'drawingTruncatedSphere', 'drawingTruncatedPyramidApex', 'drawingTruncatedPyramidTop' - ]; - if (multiStepActions.includes(canvasState.currentAction)) { - canvasState.currentAction = 'none'; - canvasState.tempLayer = null; - redrawCallback(); - } - } - - shapes2DBtn.addEventListener('click', (e) => { - e.stopPropagation(); - shapes2DToolContainer.classList.toggle('active'); - shapes3DToolContainer.classList.remove('active'); - }); - - shapes3DBtn.addEventListener('click', (e) => { - e.stopPropagation(); - shapes3DToolContainer.classList.toggle('active'); - shapes2DToolContainer.classList.remove('active'); - }); - - function handleShapeSelection(e, mainButton, container) { - e.preventDefault(); - const option = e.target.closest('a'); - if (!option) return; - const tool = option.dataset.tool; - if (!tool) return; - - cancelInProgressActions(); - mainButton.innerHTML = option.querySelector('svg').outerHTML; - canvasState.activeTool = tool; - if (canvasState.activeTool !== 'select') { - canvasState.previousTool = canvasState.activeTool; - } - - toolbar.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); - zoomControls.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); - mainButton.classList.add('active'); - - canvasState.selectedLayers = []; - redrawCallback(); - container.classList.remove('active'); - - const canvas = canvasState.canvas; - canvas.classList.remove('cursor-brush', 'cursor-eraser'); - canvas.style.cursor = 'crosshair'; - - updateToolbarCallback(); - } - - shapes2DOptions.addEventListener('click', (e) => handleShapeSelection(e, shapes2DBtn, shapes2DToolContainer)); - shapes3DOptions.addEventListener('click', (e) => handleShapeSelection(e, shapes3DBtn, shapes3DToolContainer)); - - toolbar.addEventListener('click', (e) => { - const button = e.target.closest('button'); - if (!button || button.dataset.toolGroup === 'shapes') return; - if (button.id === 'addImageBtn' || button.id === 'undoBtn' || button.id === 'redoBtn') { - if (button.id === 'addImageBtn') document.getElementById('imageUpload').click(); - return; - } - const tool = button.dataset.tool; - if (!tool) return; - cancelInProgressActions(); - if (canvasState.activeTool !== tool && canvasState.activeTool !== 'select') { - canvasState.previousTool = canvasState.activeTool; - } - canvasState.activeTool = tool; - - toolbar.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); - zoomControls.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); - if (button.dataset.tool) button.classList.add('active'); - - if (tool !== 'select') { - canvasState.selectedLayers = []; - redrawCallback(); - canvasState.updateFloatingToolbar(); - } - - const canvas = canvasState.canvas; - canvas.classList.remove('cursor-brush', 'cursor-eraser'); - canvas.style.cursor = ''; - - if (tool === 'brush' || tool === 'smart-brush') { - canvas.classList.add('cursor-brush'); - } else if (tool === 'eraser') { - canvas.classList.add('cursor-eraser'); - } else if (tool === 'text') { - canvas.style.cursor = 'text'; - } else { - canvas.style.cursor = 'default'; - } - - updateToolbarCallback(); - }); - - zoomControls.addEventListener('click', (e) => { - const button = e.target.closest('button'); - if (!button) return; - - const tool = button.dataset.tool; - if (tool === 'pan') { - cancelInProgressActions(); - canvasState.activeTool = 'pan'; - - toolbar.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); - zoomControls.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); - - button.classList.add('active'); - - canvasState.selectedLayers = []; - redrawCallback(); - const canvas = canvasState.canvas; - canvas.classList.remove('cursor-brush', 'cursor-eraser'); - canvas.style.cursor = 'grab'; - - updateToolbarCallback(); - } - }); - - function handleColorChange(newColor) { - if (canvasState.selectedLayers.length > 0) { - canvasState.selectedLayers.forEach(layer => { - if (layer.hasOwnProperty('color') && layer.type !== 'text') { // Не меняем цвет текста здесь - layer.color = newColor; - } - }); - redrawCallback(); - canvasState.saveState(canvasState.layers); - } - - canvasState.activeColor = newColor; - - colorPalette.querySelectorAll('.active').forEach(el => el.classList.remove('active')); - const activeDot = colorPalette.querySelector(`[data-color="${newColor}"]`); - if (activeDot) activeDot.classList.add('active'); - } - - colorPalette.addEventListener('click', (e) => { - const target = e.target.closest('[data-color]'); - if (target) { - handleColorChange(target.dataset.color); - } - }); - - lineWidthSlider.addEventListener('input', (e) => { - const newWidth = parseInt(e.target.value, 10); - - if (canvasState.activeTool === 'select' && canvasState.selectedLayers.length > 0) { - canvasState.selectedLayers.forEach(layer => { - if (layer.hasOwnProperty('lineWidth')) { - layer.lineWidth = newWidth; - } - }); - redrawCallback(); - } - - canvasState.activeLineWidth = newWidth; - }); - - lineWidthSlider.addEventListener('change', () => { - if (canvasState.activeTool === 'select' && canvasState.selectedLayers.length > 0) { - canvasState.saveState(canvasState.layers); - } - }); - - document.getElementById('toggleToolbar').addEventListener('click', () => { toolbarWrapper.classList.toggle('collapsed'); }); - const logo = document.getElementById('logo'), settingsMenu = document.getElementById('settingsMenu'), clearCanvasBtn = document.getElementById('clearCanvas'), dragHandle = document.querySelector('.toolbar-drag-handle'); - logo.addEventListener('click', (e) => { e.stopPropagation(); settingsMenu.style.display = settingsMenu.style.display === 'block' ? 'none' : 'block'; }); - clearCanvasBtn.addEventListener('click', (e) => { e.preventDefault(); if (confirm('Вы уверены, что хотите очистить всю доску?')) { canvasState.layers = []; canvasState.selectedLayers = []; const externalSaveState = canvasState.saveState; if(externalSaveState) externalSaveState(canvasState.layers); redrawCallback(); } }); - - document.addEventListener('click', (e) => { - if (!settingsMenu.contains(e.target) && e.target !== logo) { settingsMenu.style.display = 'none'; } - if (!shapes2DToolContainer.contains(e.target)) { shapes2DToolContainer.classList.remove('active'); } - if (!shapes3DToolContainer.contains(e.target)) { shapes3DToolContainer.classList.remove('active'); } - }); - - let isDragging = false, offsetX; - dragHandle.addEventListener('mousedown', (e) => { isDragging = true; const rect = toolbarWrapper.getBoundingClientRect(); offsetX = e.clientX - rect.left; document.body.style.userSelect = 'none'; }); - document.addEventListener('mousemove', (e) => { if (isDragging) { const toolbarWidth = toolbarWrapper.offsetWidth; const windowWidth = window.innerWidth; let newLeft = e.clientX - offsetX; if (newLeft < 0) newLeft = 0; if (newLeft + toolbarWidth > windowWidth) newLeft = windowWidth - toolbarWidth; toolbarWrapper.style.left = `${newLeft}px`; toolbarWrapper.style.transform = 'none'; } }); - document.addEventListener('mouseup', () => { isDragging = false; document.body.style.userSelect = 'auto'; }); - - function updateIndicatorPosition() { - const slider = lineWidthSlider; - const indicator = lineWidthIndicator; - - const min = parseFloat(slider.min); - const max = parseFloat(slider.max); - const val = parseFloat(slider.value); - - const percentage = (val - min) / (max - min); - - const sliderRect = slider.getBoundingClientRect(); - const thumbX = sliderRect.left + (sliderRect.width * percentage); - const thumbY = sliderRect.top; - - indicator.style.width = `${val}px`; - indicator.style.height = `${val}px`; - indicator.style.left = `${thumbX}px`; - indicator.style.top = `${thumbY}px`; - } - - function showIndicator(e) { - if (e.pointerType === 'touch') { - e.preventDefault(); - } - lineWidthIndicator.classList.add('visible'); - updateIndicatorPosition(); - } - - function hideIndicator() { - lineWidthIndicator.classList.remove('visible'); - } - - lineWidthSlider.addEventListener('input', updateIndicatorPosition); - lineWidthSlider.addEventListener('pointerdown', showIndicator); - document.addEventListener('pointerup', hideIndicator); -} -// --- END OF FILE toolbar.js --- \ No newline at end of file diff --git a/js/tools.js b/js/tools.js deleted file mode 100644 index 486e63f..0000000 --- a/js/tools.js +++ /dev/null @@ -1,151 +0,0 @@ -// --- START OF FILE js/tools.js --- - -import { snapToGrid } from './utils.js'; -import * as hitTest from './hitTest.js'; - -export function handleBrush(state, pos, e) { - const pressure = e.pressure > 0 ? e.pressure : 0.5; - state.layers[state.layers.length - 1].points.push({ ...pos, pressure }); -} - -export function handleEraser(state, pos) { - const layerToDelete = hitTest.getLayerAtPosition(pos, state.layers); - if (layerToDelete) { - state.layers = state.layers.filter(l => l.id !== layerToDelete.id); - state.didErase = true; - return true; - } - return false; -} - -export function handleShapeDrawing(state, pos, event, redrawCallback) { - redrawCallback(); - const { ctx, zoom } = state; - ctx.save(); - ctx.translate(state.panX, state.panY); - ctx.scale(zoom, zoom); - ctx.strokeStyle = 'rgba(0,0,0,0.5)'; - ctx.lineWidth = 2 / zoom; - ctx.setLineDash([5 / zoom, 5 / zoom]); - - if (state.isDrawing) { - let start = { ...state.startPos }; - let end = pos; - - if (event.altKey) { - start = { x: snapToGrid(state.startPos.x), y: snapToGrid(state.startPos.y) }; - end = { x: snapToGrid(pos.x), y: snapToGrid(pos.y) }; - } - - if (state.activeTool === 'line' && event.shiftKey) { - const dx = end.x - start.x; - const dy = end.y - start.y; - if (Math.abs(dx) > Math.abs(dy)) { - end.y = start.y; - } else { - end.x = start.x; - } - } - - if (['rect', 'text', 'parallelogram', 'cone', 'parallelepiped', 'pyramid', 'frustum', 'truncated-pyramid'].includes(state.activeTool)) { - ctx.beginPath(); - ctx.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y); - } - else if (state.activeTool === 'rhombus') { - const x = Math.min(end.x, start.x); - const y = Math.min(end.y, start.y); - const width = Math.abs(end.x - start.x); - const height = Math.abs(end.y - start.y); - const p1 = {x: x + width / 2, y: y}; - const p2 = {x: x + width, y: y + height / 2}; - const p3 = {x: x + width / 2, y: y + height}; - const p4 = {x: x, y: y + height / 2}; - ctx.beginPath(); - ctx.moveTo(p1.x, p1.y); - ctx.lineTo(p2.x, p2.y); - ctx.lineTo(p3.x, p3.y); - ctx.lineTo(p4.x, p4.y); - ctx.closePath(); - ctx.stroke(); - } - else if (['line', 'triangle', 'trapezoid'].includes(state.activeTool)) { - ctx.beginPath(); - ctx.moveTo(start.x, start.y); - ctx.lineTo(end.x, end.y); - ctx.stroke(); - } else if (state.activeTool === 'ellipse' || state.activeTool === 'sphere' || state.activeTool === 'truncated-sphere') { - const rx = Math.abs(end.x - start.x) / 2; - const ry = Math.abs(end.y - start.y) / 2; - const cx = start.x + (end.x - start.x) / 2; - const cy = start.y + (end.y - start.y) / 2; - ctx.beginPath(); - ctx.ellipse(cx, cy, rx, ry, 0, 0, 2 * Math.PI); - ctx.stroke(); - } - } - - ctx.restore(); -} - -export function handleMultiStepDrawing(state, pos, event, redrawCallback) { - redrawCallback(); - const { ctx, zoom } = state; - ctx.save(); - ctx.translate(state.panX, state.panY); - ctx.scale(zoom, zoom); - ctx.strokeStyle = 'rgba(0,0,0,0.5)'; - ctx.lineWidth = 2 / zoom; - ctx.setLineDash([5 / zoom, 5 / zoom]); - - let previewPos = pos; - if (event.altKey) { - previewPos = { x: snapToGrid(pos.x), y: snapToGrid(pos.y) }; - } - - if (state.tempLayer) { - if (state.currentAction === 'drawingParallelogramSlant') { const tempLayer = state.tempLayer; const slant = previewPos.x - (tempLayer.x + tempLayer.width / 2); ctx.beginPath(); ctx.moveTo(tempLayer.x, tempLayer.y + tempLayer.height); ctx.lineTo(tempLayer.x + tempLayer.width, tempLayer.y + tempLayer.height); ctx.lineTo(tempLayer.x + tempLayer.width + slant, tempLayer.y); ctx.lineTo(tempLayer.x + slant, tempLayer.y); ctx.closePath(); ctx.stroke(); } - else if (state.currentAction === 'drawingTriangleApex') { const tempLayer = state.tempLayer; ctx.beginPath(); ctx.moveTo(tempLayer.p1.x, tempLayer.p1.y); ctx.lineTo(tempLayer.p2.x, tempLayer.p2.y); ctx.lineTo(previewPos.x, previewPos.y); ctx.closePath(); ctx.stroke(); } - else if (state.currentAction === 'drawingParallelepipedDepth') { const tempLayer = state.tempLayer; const depth = { x: previewPos.x - (tempLayer.x + tempLayer.width), y: previewPos.y - tempLayer.y }; const p = [ {x: tempLayer.x, y: tempLayer.y}, {x: tempLayer.x + tempLayer.width, y: tempLayer.y}, {x: tempLayer.x + tempLayer.width, y: tempLayer.y + tempLayer.height}, {x: tempLayer.x, y: tempLayer.y + tempLayer.height}, {x: tempLayer.x + depth.x, y: tempLayer.y + depth.y}, {x: tempLayer.x + tempLayer.width + depth.x, y: tempLayer.y + depth.y}, {x: tempLayer.x + tempLayer.width + depth.x, y: tempLayer.y + tempLayer.height + depth.y}, {x: tempLayer.x + depth.x, y: tempLayer.y + tempLayer.height + depth.y} ]; ctx.beginPath(); ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[1].x, p[1].y); ctx.lineTo(p[2].x, p[2].y); ctx.lineTo(p[3].x, p[3].y); ctx.closePath(); ctx.moveTo(p[1].x, p[1].y); ctx.lineTo(p[5].x, p[5].y); ctx.moveTo(p[2].x, p[2].y); ctx.lineTo(p[6].x, p[6].y); ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[4].x, p[4].y); ctx.moveTo(p[4].x, p[4].y); ctx.lineTo(p[5].x, p[5].y); ctx.lineTo(p[6].x, p[6].y); ctx.lineTo(p[7].x, p[7].y); ctx.closePath(); ctx.stroke(); } - else if (state.currentAction === 'drawingPyramidApex' || state.currentAction === 'drawingTruncatedPyramidApex') { const { base } = state.tempLayer; const p = [ base.p1, base.p2, base.p3, base.p4 ]; ctx.beginPath(); ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[1].x, p[1].y); ctx.lineTo(p[2].x, p[2].y); ctx.lineTo(p[3].x, p[3].y); ctx.closePath(); ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(previewPos.x, previewPos.y); ctx.moveTo(p[1].x, p[1].y); ctx.lineTo(previewPos.x, previewPos.y); ctx.moveTo(p[2].x, p[2].y); ctx.lineTo(previewPos.x, previewPos.y); ctx.moveTo(p[3].x, p[3].y); ctx.lineTo(previewPos.x, previewPos.y); ctx.stroke(); } - else if (state.currentAction === 'drawingTruncatedPyramidTop') { - const { base, apex } = state.tempLayer; - const totalHeight = Math.abs(apex.y - base.p1.y); - const cutHeight = Math.abs(previewPos.y - base.p1.y); - const ratio = Math.max(0.05, Math.min(0.95, cutHeight / totalHeight)); - - const interpolate = (p1, p2) => ({ x: p1.x + (p2.x - p1.x) * ratio, y: p1.y + (p2.y - p1.y) * ratio }); - const t = [ interpolate(base.p1, apex), interpolate(base.p2, apex), interpolate(base.p3, apex), interpolate(base.p4, apex) ]; - const b = [ base.p1, base.p2, base.p3, base.p4 ]; - - ctx.beginPath(); ctx.moveTo(b[0].x, b[0].y); ctx.lineTo(b[1].x, b[1].y); ctx.lineTo(b[2].x, b[2].y); ctx.lineTo(b[3].x, b[3].y); ctx.closePath(); ctx.stroke(); - ctx.beginPath(); ctx.moveTo(t[0].x, t[0].y); ctx.lineTo(t[1].x, t[1].y); ctx.lineTo(t[2].x, t[2].y); ctx.lineTo(t[3].x, t[3].y); ctx.closePath(); ctx.stroke(); - for(let i = 0; i < 4; i++) { ctx.beginPath(); ctx.moveTo(b[i].x, b[i].y); ctx.lineTo(t[i].x, t[i].y); ctx.stroke(); } - } - else if (state.currentAction === 'drawingTrapezoidP3') { const { p1, p2 } = state.tempLayer; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.lineTo(previewPos.x, previewPos.y); ctx.stroke(); } - else if (state.currentAction === 'drawingTrapezoidP4') { const { p1, p2, p3 } = state.tempLayer; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.lineTo(p3.x, p3.y); ctx.lineTo(previewPos.x, previewPos.y); ctx.closePath(); ctx.stroke(); } - else if (state.currentAction === 'drawingFrustum') { const { cx, baseY, rx1, ry1 } = state.tempLayer; const rx2 = Math.abs(previewPos.x - cx); const ry2 = rx2 * 0.3; ctx.beginPath(); ctx.moveTo(cx - rx1, baseY); ctx.lineTo(cx - rx2, previewPos.y); ctx.moveTo(cx + rx1, baseY); ctx.lineTo(cx + rx2, previewPos.y); ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx, baseY, rx1, ry1, 0, 0, 2 * Math.PI); ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx, previewPos.y, rx2, ry2, 0, 0, 2 * Math.PI); ctx.stroke(); } - else if (state.currentAction === 'drawingTruncatedSphere') { - const { cx, cy, r } = state.tempLayer; - const cutY = Math.max(cy - r, Math.min(cy + r, previewPos.y)); - const h = Math.abs(cutY - cy); - const cutRSquared = (r * r) - (h * h); - const cutR = cutRSquared > 0 ? Math.sqrt(cutRSquared) : 0; - const cutRy = cutR * 0.3; - - const sinAngle = (cutY - cy) / r; - const clampedSinAngle = Math.max(-1, Math.min(1, sinAngle)); - const angle = Math.asin(clampedSinAngle); - - ctx.beginPath(); - ctx.arc(cx, cy, r, angle, Math.PI - angle); - ctx.stroke(); - - ctx.beginPath(); - ctx.ellipse(cx, cutY, cutR, cutRy, 0, 0, 2 * Math.PI); - ctx.stroke(); - } - } - - ctx.restore(); -} -// --- END OF FILE js/tools.js --- \ No newline at end of file diff --git a/js/utils.js b/js/utils.js deleted file mode 100644 index 588550f..0000000 --- a/js/utils.js +++ /dev/null @@ -1,157 +0,0 @@ -// --- START OF FILE js/utils.js --- - -import { getBoundingBox, rotatePoint } from './geometry.js'; - -const GRID_SPACING = 20; - -export function snapToGrid(value) { - return Math.round(value / GRID_SPACING) * GRID_SPACING; -} - -export function processImageFile(file, position, canvasState, redrawCallback, saveState) { - if (!file.type.startsWith('image/')) return; - const reader = new FileReader(); - reader.onload = (e) => { - const img = new Image(); - img.onload = () => { - const newLayer = { type: 'image', image: img, x: position.x - img.width / 2, y: position.y - img.height / 2, width: img.width, height: img.height, id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } }; - canvasState.layers.push(newLayer); - canvasState.selectedLayers = [newLayer]; - - // --- НАЧАЛО ИЗМЕНЕНИЙ: Автоматически переключаемся на инструмент "Выделить" --- - const selectButton = document.querySelector('button[data-tool="select"]'); - if (selectButton) { - selectButton.click(); - } - // --- КОНЕЦ ИЗМЕНЕНИЙ --- - - saveState(canvasState.layers); - redrawCallback(); - if (canvasState.updateFloatingToolbar) { - canvasState.updateFloatingToolbar(); - } - }; - img.src = e.target.result; - }; - reader.readAsDataURL(file); -} - -export function applyTransformations(layer) { - if (!layer || (!layer.rotation && (!layer.pivot || (layer.pivot.x === 0 && layer.pivot.y === 0)))) { - return; - } - - const rotation = layer.rotation || 0; - const pivot = layer.pivot || { x: 0, y: 0 }; - const box = getBoundingBox(layer); - if (!box) return; - - const centerX = box.x + box.width / 2; - const centerY = box.y + box.height / 2; - - const rotatedPivotOffset = rotatePoint(pivot, { x: 0, y: 0 }, rotation); - const pivotPoint = { - x: centerX + rotatedPivotOffset.x, - y: centerY + rotatedPivotOffset.y, - }; - - const rotate = (p) => rotatePoint(p, pivotPoint, rotation); - - const layerProps = Object.keys(layer); - for (const prop of layerProps) { - if (layer[prop] && typeof layer[prop] === 'object' && layer[prop].hasOwnProperty('x') && layer[prop].hasOwnProperty('y')) { - const newPoint = rotate(layer[prop]); - layer[prop].x = newPoint.x; - layer[prop].y = newPoint.y; - } - } - - if (layer.points) { - layer.points.forEach(p => { - const newPoint = rotate(p); - p.x = newPoint.x; - p.y = newPoint.y; - }); - } - - if (layer.hasOwnProperty('x') && layer.hasOwnProperty('y')) { - const newCenter = rotate({ x: centerX, y: centerY }); - const dx = newCenter.x - centerX; - const dy = newCenter.y - centerY; - layer.x += dx; - layer.y += dy; - } - if (layer.hasOwnProperty('cx') && layer.hasOwnProperty('cy')) { - const newCenter = rotate({ x: layer.cx, y: layer.cy }); - layer.cx = newCenter.x; - layer.cy = newCenter.y; - } - if (layer.hasOwnProperty('x1') && layer.hasOwnProperty('y1')) { - const newP1 = rotate({ x: layer.x1, y: layer.y1 }); - const newP2 = rotate({ x: layer.x2, y: layer.y2 }); - layer.x1 = newP1.x; layer.y1 = newP1.y; - layer.x2 = newP2.x; layer.y2 = newP2.y; - } - if (layer.baseY) { - const newBaseCenter = rotate({ x: layer.cx, y: layer.baseY }); - const dy = newBaseCenter.y - layer.baseY; - layer.baseY += dy; - if(layer.topY) layer.topY += dy; - } - - layer.rotation = 0; - - const newBox = getBoundingBox(layer); - if (newBox) { - const newCenterX = newBox.x + newBox.width / 2; - const newCenterY = newBox.y + newBox.height / 2; - layer.pivot = { - x: pivotPoint.x - newCenterX, - y: pivotPoint.y - newCenterY, - }; - } else { - layer.pivot = { x: 0, y: 0 }; - } -} - -function perpendicularDistance(pt, p1, p2) { - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; - const lenSq = dx * dx + dy * dy; - if (lenSq === 0) { - return Math.hypot(pt.x - p1.x, pt.y - p1.y); - } - const t = ((pt.x - p1.x) * dx + (pt.y - p1.y) * dy) / lenSq; - const clampedT = Math.max(0, Math.min(1, t)); - const closestX = p1.x + clampedT * dx; - const closestY = p1.y + clampedT * dy; - return Math.hypot(pt.x - closestX, pt.y - closestY); -} - -export function simplifyPath(points, tolerance) { - if (points.length < 3) { - return points; - } - - let dmax = 0; - let index = 0; - const end = points.length - 1; - - for (let i = 1; i < end; i++) { - const d = perpendicularDistance(points[i], points[0], points[end]); - if (d > dmax) { - index = i; - dmax = d; - } - } - - if (dmax > tolerance) { - const recResults1 = simplifyPath(points.slice(0, index + 1), tolerance); - const recResults2 = simplifyPath(points.slice(index), tolerance); - - return recResults1.slice(0, recResults1.length - 1).concat(recResults2); - } else { - return [points[0], points[end]]; - } -} -// --- END OF FILE js/utils.js --- \ No newline at end of file From 94e97e18d2f3220d077b73eae1062f87d8b79eab Mon Sep 17 00:00:00 2001 From: LDRoff Date: Wed, 1 Oct 2025 18:51:41 +0300 Subject: [PATCH 7/8] Delete index.html --- index.html | 254 ----------------------------------------------------- 1 file changed, 254 deletions(-) delete mode 100644 index.html diff --git a/index.html b/index.html deleted file mode 100644 index 1415352..0000000 --- a/index.html +++ /dev/null @@ -1,254 +0,0 @@ -// --- START OF FILE index.html --- - - - - - - - - Интерактивная доска - - - - - - - - -
- - -
- - - -
- - - -
-
- -
- -
-
-
- -
- - - -
- - - - - - - -
- -
-
Переместить вперед
-
Переместить назад
-
-
На передний план
-
На задний план
-
- -
- - - - -// --- END OF FILE index.html --- \ No newline at end of file From 10f9568b9f471f324f6c7c8483385fee1eafb205 Mon Sep 17 00:00:00 2001 From: LDRoff Date: Wed, 1 Oct 2025 18:53:19 +0300 Subject: [PATCH 8/8] Add files via upload --- css/style.css | 423 ++++++++++++++++ index.html | 254 ++++++++++ js/actions.js | 310 ++++++++++++ js/canvas.js | 850 +++++++++++++++++++++++++++++++ js/geometry.js | 67 +++ js/help-content.js | 96 ++++ js/hitTest.js | 124 +++++ js/layerManager.js | 87 ++++ js/layers.js | 14 + js/main.js | 1113 +++++++++++++++++++++++++++++++++++++++++ js/shapeRecognizer.js | 164 ++++++ js/text.js | 175 +++++++ js/toolbar.js | 246 +++++++++ js/tools.js | 151 ++++++ js/utils.js | 146 ++++++ 15 files changed, 4220 insertions(+) create mode 100644 css/style.css create mode 100644 index.html create mode 100644 js/actions.js create mode 100644 js/canvas.js create mode 100644 js/geometry.js create mode 100644 js/help-content.js create mode 100644 js/hitTest.js create mode 100644 js/layerManager.js create mode 100644 js/layers.js create mode 100644 js/main.js create mode 100644 js/shapeRecognizer.js create mode 100644 js/text.js create mode 100644 js/toolbar.js create mode 100644 js/tools.js create mode 100644 js/utils.js diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..7bacd3d --- /dev/null +++ b/css/style.css @@ -0,0 +1,423 @@ +/* --- START OF FILE style.css --- */ +:root { + --app-background: radial-gradient(circle at 20% 20%, #fefefe 0%, #d0dcff 28%, #c1e4ff 45%, #f6d1ff 65%, #c6d9ff 85%, #d3ddff 100%); + --app-background-overlay: radial-gradient(90% 140% at 80% 10%, rgba(255, 255, 255, 0.7) 0%, rgba(255, 255, 255, 0.05) 45%, rgba(255, 255, 255, 0) 70%), radial-gradient(120% 160% at -10% 80%, rgba(126, 197, 255, 0.35) 0%, rgba(249, 173, 255, 0.25) 35%, rgba(104, 117, 255, 0) 70%); + --glass-surface: radial-gradient(circle at 0% 0%, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.08) 45%, rgba(255, 255, 255, 0.04) 65%), linear-gradient(135deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.1)); + --glass-surface-opaque: radial-gradient(circle at 0% 0%, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.5) 45%, rgba(255, 255, 255, 0.4) 65%), linear-gradient(135deg, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.4)); + --glass-border: rgba(255, 255, 255, 0.55); + --glass-border-strong: rgba(255, 255, 255, 0.7); + --glass-shadow: 0 25px 65px rgba(26, 43, 92, 0.25); + --glass-glow: 0 0 40px rgba(120, 162, 255, 0.35); + --glass-highlight: linear-gradient(125deg, rgba(255, 255, 255, 0.75) 0%, rgba(255, 255, 255, 0.35) 12%, rgba(255, 255, 255, 0) 45%); + --glass-inner-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2); + --glass-gradient-mask: radial-gradient(120% 100% at 50% -20%, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0)); + --accent-color: #007AFF; +} + +body.dark-theme { + --app-background: radial-gradient(circle at 15% 20%, #1b2337 0%, #1a2d46 30%, #111b2a 70%, #0a101a 100%); + --app-background-overlay: radial-gradient(110% 140% at 90% -10%, rgba(137, 200, 255, 0.18), rgba(137, 200, 255, 0)), radial-gradient(160% 140% at -15% 90%, rgba(126, 59, 255, 0.3), rgba(126, 59, 255, 0)); + --glass-surface: radial-gradient(circle at 0% 10%, rgba(74, 92, 128, 0.5), rgba(34, 48, 76, 0.45) 45%, rgba(18, 26, 39, 0.35) 70%), linear-gradient(135deg, rgba(91, 110, 150, 0.35), rgba(24, 34, 52, 0.5)); + --glass-surface-opaque: radial-gradient(circle at 0% 10%, rgba(74, 92, 128, 0.8), rgba(34, 48, 76, 0.75) 45%, rgba(18, 26, 39, 0.7) 70%), linear-gradient(135deg, rgba(91, 110, 150, 0.65), rgba(24, 34, 52, 0.8)); + --glass-border: rgba(255, 255, 255, 0.12); + --glass-border-strong: rgba(255, 255, 255, 0.25); + --glass-shadow: 0 30px 70px rgba(0, 0, 0, 0.5); + --glass-glow: 0 0 32px rgba(54, 94, 255, 0.35); + --glass-highlight: linear-gradient(130deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0.12) 15%, rgba(255, 255, 255, 0) 45%); + --glass-inner-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + --glass-gradient-mask: radial-gradient(120% 90% at 50% -10%, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0)); +} + +body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: var(--app-background); font-family: sans-serif; position: relative; color: #1c2333; } + +body::before, +body::after { + content: ""; + position: fixed; + inset: -20% -10% auto; + height: 120vh; + background: var(--app-background-overlay); + filter: blur(40px) saturate(150%); + opacity: 0.9; + pointer-events: none; + z-index: 0; +} + +body::after { + inset: auto -5% -20%; + height: 100vh; + transform: translateY(10%); + opacity: 0.65; +} + +body.dark-theme, +body.dark-theme html { + color: #f0f4ff; +} +canvas { display: block; position: absolute; top: 0; left: 0; } +#backgroundCanvas { z-index: 1; background-color: #ffffff; } +#drawingBoard { z-index: 2; background-color: transparent; touch-action: none; } +.logo-container { position: absolute; top: 20px; left: 20px; z-index: 1001; } +#logo { width: 50px; height: 50px; border-radius: 50%; cursor: pointer; background: var(--glass-surface); box-shadow: var(--glass-shadow), var(--glass-glow); border: 1px solid var(--glass-border-strong); backdrop-filter: blur(24px) saturate(160%); -webkit-backdrop-filter: blur(24px) saturate(160%); transition: transform 0.2s, box-shadow 0.2s ease; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; } +#logo::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); mix-blend-mode: screen; opacity: 0.8; pointer-events: none; } +#logo::after { content: ""; position: absolute; inset: 0; background: var(--glass-gradient-mask); opacity: 0.6; pointer-events: none; } +#logo:hover { transform: scale(1.08); box-shadow: 0 20px 50px rgba(124, 144, 255, 0.35); } +#logo svg { width: 28px; height: 28px; stroke: #333; position: relative; z-index: 1; } +.settings-menu { display: none; position: absolute; top: 65px; left: 0; background: var(--glass-surface-opaque); backdrop-filter: blur(28px) saturate(165%); -webkit-backdrop-filter: blur(28px) saturate(165%); border: 1px solid var(--glass-border); border-radius: 16px; padding: 18px; box-shadow: var(--glass-shadow); min-width: 200px; overflow: hidden; } +.settings-menu::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.settings-menu::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)); mix-blend-mode: soft-light; pointer-events: none; } +.settings-menu a { display: block; padding: 10px 14px; color: rgba(35, 44, 70, 0.85); text-decoration: none; border-radius: 10px; transition: transform 0.2s ease, background 0.2s ease; position: relative; z-index: 1; } +.settings-menu a:hover { background-color: rgba(255, 255, 255, 0.35); transform: translateX(3px); } + +.zoom-controls { position: absolute; top: 20px; right: 20px; z-index: 1000; display: flex; flex-direction: column; gap: 8px; background: var(--glass-surface); backdrop-filter: blur(28px) saturate(165%); -webkit-backdrop-filter: blur(28px) saturate(165%); border-radius: 18px; border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); padding: 10px; overflow: hidden; } +.zoom-controls::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.zoom-controls::after { content: ""; position: absolute; inset: 0; background: radial-gradient(90% 140% at 100% 0%, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0)); pointer-events: none; mix-blend-mode: screen; } +.zoom-controls button { background: linear-gradient(135deg, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0.15)); border: 1px solid rgba(255, 255, 255, 0.45); width: 42px; height: 42px; border-radius: 14px; cursor: pointer; transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; display: flex; align-items: center; justify-content: center; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 8px 18px rgba(30, 45, 90, 0.2); } +.zoom-controls button svg { width: 24px; height: 24px; stroke: rgba(28, 35, 51, 0.85); pointer-events: none; } +.zoom-controls button:hover { transform: translateY(-2px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 15px 30px rgba(30, 45, 90, 0.28); } +.zoom-controls button:active { transform: translateY(0); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 10px 18px rgba(30, 45, 90, 0.25); } +.zoom-controls button.active { background: linear-gradient(135deg, rgba(0, 122, 255, 0.9), rgba(102, 172, 255, 0.6)); border-color: rgba(255, 255, 255, 0.7); color: white; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55), 0 18px 35px rgba(0, 122, 255, 0.35); } + +.toolbar-wrapper { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; display: flex; align-items: flex-end; } +.toolbar-content-slider { display: flex; flex-direction: column; align-items: center; gap: 10px; transition: transform 0.3s ease, opacity 0.3s ease; } +.toolbar-container { position: relative; padding-top: 10px; } +.toolbar-drag-handle { position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 50px; height: 6px; background: linear-gradient(90deg, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.2)); border-radius: 999px; cursor: move; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 6px 14px rgba(35, 52, 94, 0.15); } +.toolbar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: var(--glass-surface); backdrop-filter: blur(28px) saturate(165%); -webkit-backdrop-filter: blur(28px) saturate(165%); border-radius: 20px; border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); position: relative; } +.toolbar::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.toolbar::after { content: ""; position: absolute; inset: 0; background: radial-gradient(140% 120% at 120% -20%, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0)); pointer-events: none; } +.toolbar button { background: linear-gradient(145deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.12)); border: 1px solid rgba(255, 255, 255, 0.45); padding: 8px; border-radius: 14px; cursor: pointer; transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, opacity 0.2s ease-in-out; display: flex; align-items: center; justify-content: center; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 10px 20px rgba(35, 52, 94, 0.18); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } +.toolbar button svg { width: 24px; height: 24px; stroke: rgba(30, 40, 60, 0.85); pointer-events: none; } +.toolbar button:hover { transform: translateY(-2px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 16px 30px rgba(35, 52, 94, 0.25); } +.toolbar button:active { transform: translateY(0); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 12px 24px rgba(35, 52, 94, 0.22); } +.toolbar button.active { background: linear-gradient(135deg, rgba(0, 122, 255, 0.95), rgba(102, 172, 255, 0.6)); border-color: rgba(255, 255, 255, 0.75); color: #fff; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 18px 36px rgba(0, 122, 255, 0.35); } +.toolbar button:disabled { cursor: not-allowed; opacity: 0.4; } +.toolbar button:disabled:hover { background: transparent; } +.toolbar-separator { width: 1px; height: 24px; background: linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.1)); margin: 0 2px; opacity: 0.8; } +.tool-container { position: relative; } +.tool-options { display: none; position: absolute; bottom: calc(100% + 25px); left: 50%; transform: translateX(-50%); background: var(--glass-surface-opaque); backdrop-filter: blur(32px) saturate(160%); -webkit-backdrop-filter: blur(32px) saturate(160%); border-radius: 18px; padding: 10px; box-shadow: var(--glass-shadow); min-width: 200px; z-index: 1001; overflow: hidden; border: 1px solid var(--glass-border); } +.tool-options::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.tool-options::after { content: ""; position: absolute; inset: 0; background: radial-gradient(120% 90% at 0% 0%, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0)); pointer-events: none; } +.tool-options a { display: flex; align-items: center; gap: 10px; padding: 10px 14px; color: rgba(35, 44, 70, 0.88); text-decoration: none; border-radius: 10px; white-space: nowrap; transition: background 0.2s ease, transform 0.2s ease; position: relative; z-index: 1; } +.tool-options a svg { width: 20px; height: 20px; stroke: rgba(30, 40, 60, 0.85); } +.tool-options a:hover { background-color: rgba(255, 255, 255, 0.35); transform: translateX(3px); } +.tool-container.active .tool-options { display: block; } +.sub-toolbar-container { position: relative; height: 50px; } +.sub-toolbar { display: flex; align-items: center; justify-content: space-between; width: auto; padding: 8px 15px; gap: 15px; background: var(--glass-surface); backdrop-filter: blur(30px) saturate(170%); -webkit-backdrop-filter: blur(30px) saturate(170%); border-radius: 20px; border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); transition: opacity 0.2s ease, transform 0.2s ease; box-sizing: border-box; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); overflow: hidden; } +.sub-toolbar::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.sub-toolbar::after { content: ""; position: absolute; inset: 0; background: radial-gradient(140% 120% at -10% 0%, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0)); pointer-events: none; mix-blend-mode: screen; } +.sub-toolbar.hidden { opacity: 0; pointer-events: none; transform: translateX(-50%) translateY(10px); } +.color-palette { display: flex; align-items: center; gap: 10px; padding: 8px 14px; background: linear-gradient(145deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.18)); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.55); box-shadow: inset 0 1px 3px rgba(255, 255, 255, 0.6), 0 12px 25px rgba(32, 40, 70, 0.12); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); position: relative; overflow: hidden; } +.color-palette::after { content: ""; position: absolute; inset: 0; background: radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0)); pointer-events: none; } +.color-dot { width: 18px; height: 18px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; box-sizing: border-box; box-shadow: 0 6px 14px rgba(30, 42, 70, 0.15); } +.color-dot[data-color="#FFFFFF"] { border-color: rgba(180, 180, 180, 0.8); } +.color-dot:hover { transform: scale(1.15); box-shadow: 0 10px 18px rgba(30, 42, 70, 0.2); } +.color-dot.active { border-color: rgba(0, 122, 255, 0.8); transform: scale(1.1); box-shadow: 0 12px 24px rgba(0, 122, 255, 0.25); } +.size-editor { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: linear-gradient(145deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.18)); border-radius: 18px; border: 1px solid rgba(255, 255, 255, 0.55); box-shadow: inset 0 1px 3px rgba(255, 255, 255, 0.6), 0 15px 28px rgba(32, 40, 70, 0.12); justify-content: flex-end; position: relative; overflow: hidden; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } +.size-editor::before { content: ""; width: 20px; height: 20px; border-radius: 50%; border: 2px solid rgba(51, 51, 51, 0.5); box-shadow: inset 0 0 0 5px rgba(51, 51, 51, 0.12), 0 6px 12px rgba(35, 45, 75, 0.25); display: inline-flex; flex-shrink: 0; pointer-events: none; background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(200, 200, 200, 0.3)); } +.size-editor::after { content: ""; position: absolute; inset: 0; background: radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0)); pointer-events: none; } +input[type="range"] { width: 120px; accent-color: var(--accent-color); background: transparent; } +input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(180, 207, 255, 0.6)); border: 1px solid rgba(0, 0, 0, 0.05); box-shadow: 0 6px 12px rgba(30, 45, 90, 0.2); margin-top: -7px; } +input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 999px; background: linear-gradient(90deg, rgba(0, 122, 255, 0.75), rgba(153, 204, 255, 0.35)); } +input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; border: none; border-radius: 50%; background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(180, 207, 255, 0.6)); box-shadow: 0 6px 12px rgba(30, 45, 90, 0.2); } +input[type="range"]::-moz-range-track { height: 4px; border-radius: 999px; background: linear-gradient(90deg, rgba(0, 122, 255, 0.75), rgba(153, 204, 255, 0.35)); } + +.toggle-toolbar { width: 34px; height: 34px; padding: 6px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.45); background: linear-gradient(145deg, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0.18)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 10px 20px rgba(30, 45, 90, 0.2); cursor: pointer; transition: transform 0.3s ease, box-shadow 0.3s ease; display: flex; align-items: center; justify-content: center; margin-bottom: 15px; margin-right: 10px; z-index: 1; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } +.toggle-toolbar:hover { transform: translateY(-2px) scale(1.03); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 16px 30px rgba(30, 45, 90, 0.25); } +.toggle-toolbar svg { width: 20px; height: 20px; transition: transform 0.3s ease; } +.toolbar-wrapper.collapsed .toolbar-content-slider { transform: translateX(calc(-100% - 20px)); opacity: 0; pointer-events: none; } +.toolbar-wrapper.collapsed .toggle-toolbar svg { transform: rotate(180deg); } + +.floating-toolbar { position: absolute; display: none; align-items: center; gap: 8px; padding: 8px; background: var(--glass-surface); border-radius: 18px; box-shadow: var(--glass-shadow); z-index: 1001; transition: opacity 0.1s ease-in-out; border: 1px solid var(--glass-border); overflow: hidden; backdrop-filter: blur(28px) saturate(170%); -webkit-backdrop-filter: blur(28px) saturate(170%); } +.floating-toolbar::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.floating-toolbar::after { content: ""; position: absolute; inset: 0; background: radial-gradient(130% 120% at -10% 0%, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0)); pointer-events: none; } +.floating-toolbar.visible { display: flex; } +.floating-toolbar button { background: linear-gradient(145deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.15)); border: 1px solid rgba(255, 255, 255, 0.45); padding: 6px; border-radius: 12px; cursor: pointer; transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; display: flex; align-items: center; justify-content: center; position: relative; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 10px 20px rgba(35, 52, 94, 0.18); } +.floating-toolbar button svg { width: 20px; height: 20px; fill: rgba(30, 40, 60, 0.85); pointer-events: none; } +.floating-toolbar button:hover { transform: translateY(-1px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 14px 26px rgba(35, 52, 94, 0.24); } +.floating-toolbar button.active { background: linear-gradient(135deg, rgba(0, 122, 255, 0.95), rgba(102, 172, 255, 0.6)); border-color: rgba(255, 255, 255, 0.75); color: #fff; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 18px 34px rgba(0, 122, 255, 0.32); } +.floating-toolbar .toolbar-select { -webkit-appearance: none; appearance: none; background: linear-gradient(145deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.18)); border: 1px solid rgba(255, 255, 255, 0.45); padding: 6px 10px; border-radius: 12px; color: rgba(30, 40, 60, 0.85); font-size: 14px; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); } +.floating-toolbar .toolbar-select:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 6px 14px rgba(35, 52, 94, 0.18); } +.floating-toolbar .toolbar-font-size { width: 45px; background: linear-gradient(145deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.15)); border: 1px solid rgba(255, 255, 255, 0.45); border-radius: 12px; color: rgba(30, 40, 60, 0.85); text-align: center; font-size: 14px; padding: 6px 0; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); } +.floating-toolbar .toolbar-font-size:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 6px 14px rgba(35, 52, 94, 0.18); } +.floating-toolbar .color-picker-wrapper { position: relative; } +.floating-toolbar .color-picker-wrapper > button svg circle { transition: fill 0.2s ease; } +.floating-palette { visibility: hidden; opacity: 0; position: absolute; top: calc(100% + 5px); left: 50%; transform: translateX(-50%); background: var(--glass-surface); border-radius: 14px; box-shadow: var(--glass-shadow); padding: 8px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; transition: opacity 0.2s ease, visibility 0.2s ease; + z-index: 1002; + border: 1px solid var(--glass-border); + backdrop-filter: blur(20px) saturate(160%); + -webkit-backdrop-filter: blur(20px) saturate(160%); + overflow: hidden; +} +.floating-palette::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.color-picker-wrapper.active .floating-palette { visibility: visible; opacity: 1; } + +.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.55)); display: flex; justify-content: center; align-items: center; z-index: 2000; padding: 20px; box-sizing: border-box; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); } +.modal-overlay.hidden { display: none; } +.modal-photoshop { display: grid; grid-template-columns: 160px 1fr; grid-template-rows: 1fr auto; width: min(550px, 90vw); height: min(350px, 80vh); max-height: calc(100vh - 40px); background: var(--glass-surface); backdrop-filter: blur(36px) saturate(160%); -webkit-backdrop-filter: blur(36px) saturate(160%); border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); color: #333; border-radius: 22px; font-size: 13px; overflow: hidden; position: relative; } +.modal-photoshop::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.modal-photoshop::after { content: ""; position: absolute; inset: 0; background: radial-gradient(140% 140% at 120% -20%, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0)); pointer-events: none; } +.modal-sidebar { grid-row: 1 / 3; border-right: 1px solid rgba(255, 255, 255, 0.35); padding: 12px 0; border-radius: 22px 0 0 22px; backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px); background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0)); position: relative; } +.modal-sidebar::after { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.sidebar-button { display: block; width: 100%; background: linear-gradient(135deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0.08)); border: none; color: rgba(35, 44, 70, 0.85); padding: 10px 18px; text-align: left; cursor: pointer; font-size: 13px; border-radius: 12px; margin: 0 12px 6px; transition: transform 0.2s ease, box-shadow 0.2s ease; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 8px 18px rgba(35, 52, 94, 0.18); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); } +.sidebar-button:hover { transform: translateX(4px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 12px 24px rgba(35, 52, 94, 0.22); } +.sidebar-button.active { background: linear-gradient(135deg, rgba(0, 122, 255, 0.9), rgba(102, 172, 255, 0.6)); color: white; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 15px 28px rgba(0, 122, 255, 0.3); font-weight: 600; } +.modal-main { padding: 22px; overflow-y: auto; position: relative; } +.modal-main::after { content: ""; position: absolute; inset: 0; background: radial-gradient(120% 100% at 100% 0%, rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0)); pointer-events: none; } +.modal-panel { display: none; } +.modal-panel.active { display: block; } +.modal-main h3 { margin-top: 0; font-size: 1.5em; font-weight: 400; border-bottom: 1px solid rgba(0, 0, 0, 0.1); padding-bottom: 10px; margin-bottom: 20px; } +.setting-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; } +.setting-row label { margin-right: 10px; } +.slider-container { display: flex; align-items: center; gap: 8px; } +.slider-container input[type="range"] { width: 100px; } +#smoothing-value { font-weight: bold; min-width: 2ch; text-align: center; } + +.ps-select { -webkit-appearance: none; appearance: none; background: linear-gradient(145deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.12)); color: rgba(35, 44, 70, 0.85); border: 1px solid rgba(255, 255, 255, 0.45); border-radius: 14px; padding: 8px 36px 8px 14px; width: 170px; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23333333'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 1.1em; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 10px 20px rgba(35, 52, 94, 0.18); } +.ps-select:focus { outline: none; border-color: rgba(0, 122, 255, 0.9); box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.15); } +.modal-footer { grid-column: 2; grid-row: 2; display: flex; justify-content: flex-end; align-items: center; padding: 20px; border-top: 1px solid rgba(255, 255, 255, 0.35); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); position: relative; } +.modal-footer::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0)); pointer-events: none; } +.ps-button { background: linear-gradient(145deg, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.12)); border: 1px solid rgba(255, 255, 255, 0.45); color: rgba(35, 44, 70, 0.88); padding: 8px 18px; border-radius: 14px; cursor: pointer; margin-left: 10px; transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 12px 24px rgba(35, 52, 94, 0.18); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } +.ps-button:hover { transform: translateY(-2px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 16px 32px rgba(35, 52, 94, 0.24); } +.ps-button.primary { background: linear-gradient(135deg, rgba(0, 122, 255, 0.92), rgba(102, 172, 255, 0.6)); border: 1px solid rgba(255, 255, 255, 0.7); color: #fff; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 20px 36px rgba(0, 122, 255, 0.3); } +.ps-button.primary:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 24px 40px rgba(0, 122, 255, 0.35); } + +.modal-panel h4 { margin-top: 1.2em; margin-bottom: 0.8em; font-size: 1.1em; border-bottom: 1px solid rgba(0,0,0,0.1); padding-bottom: 5px; } +.modal-panel h4:first-child { margin-top: 0; } +.modal-panel p { line-height: 1.6; margin-bottom: 1.5em; } +.modal-panel ul { list-style: none; padding-left: 0; } +.modal-panel li { + display: grid; + grid-template-columns: max-content 1fr; + gap: 25px; + align-items: start; + padding: 10px 0; + border-bottom: 1px solid rgba(0,0,0,0.05); +} +.modal-panel li .keys { + text-align: right; +} +.modal-panel li > span:last-child { + text-align: left; + color: #555; + line-height: 1.5; +} +.modal-panel li kbd { + background-color: #eee; + border-radius: 3px; + border: 1px solid #b4b4b4; + color: #333; + display: inline-block; + font-family: monospace; + font-size: 0.9em; + padding: 3px 6px; + white-space: nowrap; +} + +body.dark-theme { background-color: transparent; color: #f0f4ff; } +body.dark-theme #backgroundCanvas { background-color: #2c3e50; } +body.dark-theme #logo svg, +body.dark-theme .toolbar button svg, +body.dark-theme .tool-options a svg, +body.dark-theme .toggle-toolbar svg, +body.dark-theme .zoom-controls button svg { stroke: #f5f8ff; } +body.dark-theme .floating-toolbar button svg { fill: #f5f8ff; } +body.dark-theme .floating-toolbar .color-picker-wrapper > button svg circle { stroke: rgba(240, 244, 255, 0.65); } +body.dark-theme .settings-menu a, +body.dark-theme .tool-options a, +body.dark-theme .context-menu-item, +body.dark-theme .sidebar-button, +body.dark-theme .ps-button, +body.dark-theme .modal-panel li > span:last-child { color: rgba(233, 240, 255, 0.92); } +body.dark-theme .sidebar-button.active, +body.dark-theme .ps-button.primary, +body.dark-theme .toolbar button.active, +body.dark-theme .zoom-controls button.active { color: #fff; } +body.dark-theme .size-editor::before { border-color: rgba(236, 240, 255, 0.85); box-shadow: inset 0 0 0 4px rgba(236, 240, 255, 0.18), 0 6px 12px rgba(12, 18, 32, 0.35); } +body.dark-theme .toolbar-separator { background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.08)); } +body.dark-theme .sidebar-button:hover, +body.dark-theme .settings-menu a:hover, +body.dark-theme .tool-options a:hover, +body.dark-theme .context-menu-item:hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45); } +body.dark-theme .ps-select { color: rgba(233, 240, 255, 0.92); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23f3f6ff'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); } +body.dark-theme .modal-main h3 { border-bottom-color: rgba(255, 255, 255, 0.12); } +body.dark-theme .modal-panel h4 { border-bottom-color: rgba(255,255,255,0.15); } +body.dark-theme .modal-panel li { border-bottom-color: rgba(255,255,255,0.08); } +body.dark-theme .modal-panel li kbd { background-color: rgba(40, 58, 92, 0.55); border-color: rgba(102, 128, 170, 0.6); color: #f0f4ff; } + +#drawingBoard.cursor-brush { cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke='white' stroke-width='3.5' d='m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125'/%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke='black' stroke-width='1.5' d='m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125'/%3E%3C/svg%3E") 4 20, auto; } +#drawingBoard.cursor-eraser { cursor: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"%3E%3Ccircle cx="12" cy="12" r="8" stroke="%23333" stroke-width="1.5" fill="rgba(255, 255, 255, 0.5)"/%3E%3C/svg%3E') 12 12, auto; } +body.dark-theme #drawingBoard.cursor-brush { cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke='black' stroke-width='3.5' d='m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125'/%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke='white' stroke-width='1.5' d='m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125'/%3E%3C/svg%3E") 4 20, auto; } +body.dark-theme #drawingBoard.cursor-eraser { cursor: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"%3E%3Ccircle cx="12" cy="12" r="8" stroke="%23f0f2f5" stroke-width="1.5" fill="rgba(0, 0, 0, 0.5)"/%3E%3C/svg%3E') 12 12, auto; } +#lineWidthIndicator { position: fixed; background-color: #333; border-radius: 50%; transform: translate(-50%, calc(-100% - 10px)); pointer-events: none; opacity: 0; transition: opacity 0.2s; z-index: 9999; } +#lineWidthIndicator.visible { opacity: 1; } +body.dark-theme #lineWidthIndicator { background-color: #f0f2f5; } +.context-menu { position: fixed; z-index: 10000; display: none; background: var(--glass-surface-opaque); backdrop-filter: blur(30px) saturate(170%); -webkit-backdrop-filter: blur(30px) saturate(170%); border: 1px solid var(--glass-border); border-radius: 16px; padding: 8px; box-shadow: var(--glass-shadow); min-width: 200px; font-size: 14px; overflow: hidden; } +.context-menu::before { content: ""; position: absolute; inset: 0; background: var(--glass-highlight); pointer-events: none; } +.context-menu::after { content: ""; position: absolute; inset: 0; background: radial-gradient(110% 120% at -10% 0%, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0)); pointer-events: none; } +.context-menu.visible { display: block; } +.context-menu-item { padding: 10px 14px; cursor: pointer; border-radius: 10px; color: rgba(35, 44, 70, 0.85); transition: transform 0.2s ease, background 0.2s ease; position: relative; z-index: 1; } +.context-menu-item:hover { background: linear-gradient(135deg, rgba(0, 122, 255, 0.9), rgba(102, 172, 255, 0.6)); color: white; transform: translateX(3px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55); } +.context-menu-separator { height: 1px; background-color: rgba(255, 255, 255, 0.4); margin: 6px 0; opacity: 0.7; } +body.dark-theme .context-menu { border-color: var(--glass-border); box-shadow: var(--glass-shadow); } +body.dark-theme .context-menu-item { color: rgba(233, 240, 255, 0.92); } +body.dark-theme .context-menu-item:hover { color: #fff; } +body.dark-theme .context-menu-separator { background-color: rgba(255, 255, 255, 0.25); } +#text-editor-textarea { + border: none; + padding: 0; + margin: 0; + background: transparent; + outline: none; + resize: none; + overflow: hidden; + white-space: pre-wrap; +} + +#custom-tooltip { + position: fixed; + z-index: 9999; + background-color: #2c3e50; + color: #fff; + padding: 6px 12px; + border-radius: 6px; + font-size: 13px; + pointer-events: none; + opacity: 0; + transform: translate(-50%, calc(-100% - 8px)); /* Центрирование и отступ сверху */ + transition: opacity 0.15s ease, transform 0.15s ease; + white-space: nowrap; + display: none; /* Начальное состояние */ +} + +#custom-tooltip.visible { + display: block; /* Делаем видимым для JS */ + opacity: 1; + transform: translate(-50%, calc(-100% - 12px)); /* Сдвигаем чуть выше при появлении */ +} + +body.dark-theme #custom-tooltip { + background-color: #f0f2f5; + color: #1a2633; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +.modal-main::-webkit-scrollbar { + width: 8px; +} + +.modal-main::-webkit-scrollbar-track { + background: transparent; +} + +.modal-main::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.25); + border-radius: 4px; +} + +.modal-main::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.4); +} + +body.dark-theme .modal-main::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.2); +} + +body.dark-theme .modal-main::-webkit-scrollbar-thumb:hover { + background-color: rgba(255, 255, 255, 0.35); +} + +/* --- Стили для адаптивности --- */ +@media (max-width: 800px) { + .modal-photoshop { + width: 95%; + height: 85vh; + max-width: 500px; + grid-template-columns: 120px 1fr; + } + + #helpModal .modal-photoshop { + max-width: 600px; + } + + .toolbar-wrapper { + width: 98%; + bottom: 10px; + } + .toolbar { + gap: 4px; + padding: 6px; + } + .toolbar button { + padding: 6px; + } + + .logo-container { + top: 10px; + left: 10px; + } + .modal-panel li { + display: block; + text-align: left; + } + .modal-panel li .keys { + display: block; + text-align: left; + margin-bottom: 8px; + } +} +@media (max-width: 800px) and (min-width: 769px) { + .zoom-controls { + top: 10px; + right: 10px; + bottom: auto; + } +} + +@media (max-width: 768px) { + .logo-container { top: 12px; left: 12px; } + #logo { width: 44px; height: 44px; } + .settings-menu { min-width: 160px; padding: 12px; } + + .zoom-controls { top: 12px; bottom: auto; right: 12px; flex-direction: row; align-items: center; gap: 6px; padding: 6px; } + .zoom-controls button { width: 32px; height: 32px; border-radius: 10px; } + .zoom-controls button svg { width: 18px; height: 18px; } + + .toolbar-wrapper { width: calc(100% - 32px); left: 50%; transform: translateX(-50%); } + .toolbar-container { width: 100%; } + .toolbar-content-slider { width: 100%; } + .toolbar { flex-wrap: nowrap; justify-content: center; padding: 6px 8px; gap: 4px; } + .toolbar button { padding: 5px; } + .toolbar button svg { width: 18px; height: 18px; } + .toolbar-separator { display: none; } + + .toggle-toolbar { margin: 0 0 10px 10px; align-self: flex-end; } +} + +@media (max-width: 600px) { + .modal-photoshop { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; width: 100%; max-width: 420px; height: auto; max-height: calc(100vh - 40px); } + .modal-sidebar { grid-row: auto; grid-column: 1; display: flex; gap: 6px; padding: 10px 12px; border-right: none; border-bottom: 1px solid rgba(255, 255, 255, 0.2); border-radius: 16px 16px 0 0; overflow-x: auto; } + .sidebar-button { flex: 1 0 auto; text-align: center; } + .modal-main { padding: 16px; } + .modal-footer { grid-column: 1; grid-row: 3; padding: 16px; } +} + +@media (max-width: 480px) { + body, html { font-size: 14px; } + + .zoom-controls { top: 10px; right: 10px; } + .zoom-controls button { width: 28px; height: 28px; border-radius: 8px; } + .zoom-controls button svg { width: 16px; height: 16px; } + + .toolbar { gap: 4px; } + .toolbar button { padding: 4px; border-radius: 10px; } + .toolbar button svg { width: 16px; height: 16px; } + + .sub-toolbar { padding: 8px 10px; gap: 10px; margin-bottom: 8px; } + .color-palette, + .size-editor { padding: 6px 8px; } + .color-dot { width: 14px; height: 14px; } + .size-editor::before { width: 14px; height: 14px; border-width: 1.4px; } + input[type="range"] { max-width: 130px; } + + .toggle-toolbar { width: 30px; height: 30px; padding: 5px; } +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..1415352 --- /dev/null +++ b/index.html @@ -0,0 +1,254 @@ +// --- START OF FILE index.html --- + + + + + + + + Интерактивная доска + + + + + + + + +
+ + +
+ + + +
+ + + +
+
+ +
+ +
+
+
+ +
+ + + +
+ + + + + + + +
+ +
+
Переместить вперед
+
Переместить назад
+
+
На передний план
+
На задний план
+
+ +
+ + + + +// --- END OF FILE index.html --- \ No newline at end of file diff --git a/js/actions.js b/js/actions.js new file mode 100644 index 0000000..1a443b2 --- /dev/null +++ b/js/actions.js @@ -0,0 +1,310 @@ +// --- START OF FILE js/actions.js --- + +import * as geo from './geometry.js'; +import * as hitTest from './hitTest.js'; +import { snapToGrid } from './utils.js'; + +export function handleMove(state, pos, event) { + let dx = pos.x - state.dragStartPos.x; + let dy = pos.y - state.dragStartPos.y; + + if (event.altKey) { + state.snapPoint = null; + const SNAP_THRESHOLD = 10 / state.zoom; + + const getPointsForBox = (box) => { + if (!box) return []; + const { x, y, width, height } = box; + return [ + { x, y }, { x: x + width / 2, y }, { x: x + width, y }, + { x, y: y + height / 2 }, { x: x + width / 2, y: y + height / 2 }, { x: x + width, y: y + height / 2 }, + { x, y: y + height }, { x: x + width / 2, y: y + height }, { x: x + width, y: y + height } + ]; + }; + + const movingSelectionBox = geo.getGroupBoundingBox(state.selectedLayers); + if (movingSelectionBox) { + let currentMovingBox = { ...movingSelectionBox }; + currentMovingBox.x += dx; + currentMovingBox.y += dy; + const movingPoints = getPointsForBox(currentMovingBox); + + const staticLayers = state.layers.filter(l => !state.selectedLayers.some(sl => sl.id === l.id)); + let staticPoints = []; + staticLayers.forEach(layer => { + const box = geo.getBoundingBox(layer); + if (box) staticPoints.push(...getPointsForBox(box)); + }); + + let snapDX = 0; + let snapDY = 0; + let objectSnapped = false; + + for (const movingPoint of movingPoints) { + for (const staticPoint of staticPoints) { + const diffX = Math.abs(movingPoint.x - staticPoint.x); + const diffY = Math.abs(movingPoint.y - staticPoint.y); + + if (diffX < SNAP_THRESHOLD && snapDX === 0) { + snapDX = staticPoint.x - movingPoint.x; + state.snapPoint = { x: staticPoint.x, y: movingPoint.y + snapDY }; + objectSnapped = true; + } + if (diffY < SNAP_THRESHOLD && snapDY === 0) { + snapDY = staticPoint.y - movingPoint.y; + state.snapPoint = { x: (state.snapPoint?.x || movingPoint.x) + snapDX, y: staticPoint.y }; + objectSnapped = true; + } + } + } + + if (!objectSnapped) { + const snappedX = snapToGrid(currentMovingBox.x); + const snappedY = snapToGrid(currentMovingBox.y); + const diffX = snappedX - currentMovingBox.x; + const diffY = snappedY - currentMovingBox.y; + + if (Math.abs(diffX) < SNAP_THRESHOLD) { + snapDX = diffX; + state.snapPoint = { x: snappedX, y: currentMovingBox.y }; + } + if (Math.abs(diffY) < SNAP_THRESHOLD) { + snapDY = diffY; + state.snapPoint = { x: state.snapPoint ? state.snapPoint.x : currentMovingBox.x, y: snappedY }; + } + } + + dx += snapDX; + dy += snapDY; + } + } + + state.selectedLayers.forEach(layer => { + // --- НАЧАЛО ИЗМЕНЕНИЙ: Добавляем 'text' в список перемещаемых объектов --- + if (['rect', 'image', 'text', 'parallelogram', 'parallelepiped'].includes(layer.type)) { + layer.x = (layer.x || 0) + dx; + layer.y = (layer.y || 0) + dy; + } else if (['triangle', 'trapezoid', 'rhombus'].includes(layer.type)) { + layer.p1.x += dx; layer.p1.y += dy; + layer.p2.x += dx; layer.p2.y += dy; + layer.p3.x += dx; layer.p3.y += dy; + if (layer.p4) { layer.p4.x += dx; layer.p4.y += dy; } + } + // --- КОНЕЦ ИЗМЕНЕНИЙ --- + else if (layer.type === 'cone' || layer.type === 'frustum') { layer.cx += dx; layer.baseY += dy; if(layer.apex){layer.apex.x += dx; layer.apex.y += dy;} if(layer.topY){layer.topY += dy;} } + else if (layer.type === 'sphere' || layer.type === 'ellipse' || layer.type === 'truncated-sphere') { layer.cx += dx; layer.cy += dy; if(layer.cutY) layer.cutY += dy; } + else if (layer.type === 'path') { layer.points.forEach(p => { p.x += dx; p.y += dy; }); } + else if (layer.type === 'line') { layer.x1 += dx; layer.y1 += dy; layer.x2 += dx; layer.y2 += dy; } + else if (layer.type === 'pyramid' || layer.type === 'truncated-pyramid') { + if (layer.apex) { layer.apex.x += dx; layer.apex.y += dy; } + layer.base.p1.x += dx; layer.base.p1.y += dy; + layer.base.p2.x += dx; layer.base.p2.y += dy; + layer.base.p3.x += dx; layer.base.p3.y += dy; + layer.base.p4.x += dx; layer.base.p4.y += dy; + if (layer.top) { + layer.top.p1.x += dx; layer.top.p1.y += dy; + layer.top.p2.x += dx; layer.top.p2.y += dy; + layer.top.p3.x += dx; layer.top.p3.y += dy; + layer.top.p4.x += dx; layer.top.p4.y += dy; + } + } + }); + state.dragStartPos = { x: state.dragStartPos.x + dx, y: state.dragStartPos.y + dy }; +} + +export function handleScale(state, pos, event) { + if (event.altKey) { + pos = { x: snapToGrid(pos.x), y: snapToGrid(pos.y) }; + } + + const oBox = state.originalBox; + let nX = oBox.x, nY = oBox.y, nW = oBox.width, nH = oBox.height; + + const rotation = hitTest.getSelectionRotation(state.selectedLayers, state.groupRotation); + const world_dx = pos.x - state.dragStartPos.x; + const world_dy = pos.y - state.dragStartPos.y; + + const dx = world_dx * Math.cos(-rotation) - world_dy * Math.sin(-rotation); + const dy = world_dx * Math.sin(-rotation) + world_dy * Math.cos(-rotation); + + switch (state.scalingHandle) { + case 'top': nY = oBox.y + dy; nH = oBox.height - dy; break; + case 'bottom': nH = oBox.height + dy; break; + case 'left': nX = oBox.x + dx; nW = oBox.width - dx; break; + case 'right': nW = oBox.width + dx; break; + case 'topLeft': nX = oBox.x + dx; nY = oBox.y + dy; nW = oBox.width - dx; nH = oBox.height - dy; break; + case 'topRight': nY = oBox.y + dy; nW = oBox.width + dx; nH = oBox.height - dy; break; + case 'bottomLeft': nX = oBox.x + dx; nW = oBox.width - dx; nH = oBox.height + dy; break; + case 'bottomRight': nW = oBox.width + dx; nH = oBox.height + dy; break; + } + + if (event.shiftKey) { + const aspectRatio = oBox.height !== 0 ? oBox.width / oBox.height : 1; + const relWidthChange = Math.abs(nW - oBox.width) / (oBox.width || 1); + const relHeightChange = Math.abs(nH - oBox.height) / (oBox.height || 1); + + if (relWidthChange > relHeightChange) { + const newHeight = nW / aspectRatio; + if (state.scalingHandle.includes('top')) { + nY += nH - newHeight; + } + nH = newHeight; + } else { + const newWidth = nH * aspectRatio; + if (state.scalingHandle.includes('left')) { + nX += nW - newWidth; + } + nW = newWidth; + } + } + + if (nW < 1) nW = 1; if (nH < 1) nH = 1; + const scaleX = oBox.width > 0 ? nW / oBox.width : 1; + const scaleY = oBox.height > 0 ? nH / oBox.height : 1; + state.selectedLayers.forEach((layer, index) => { + const originalLayer = state.originalLayers[index]; + // --- НАЧАЛО ИЗМЕНЕНИЙ: Логика масштабирования текста --- + if (['rect', 'image', 'text'].includes(layer.type)) { + layer.x = nX + (originalLayer.x - oBox.x) * scaleX; + layer.y = nY + (originalLayer.y - oBox.y) * scaleY; + layer.width = originalLayer.width * scaleX; + layer.height = originalLayer.height * scaleY; + // Удалена строка, изменявшая размер шрифта + } + // --- КОНЕЦ ИЗМЕНЕНИЙ --- + else if (layer.type === 'parallelogram') { layer.x = nX + (originalLayer.x - oBox.x) * scaleX; layer.y = nY + (originalLayer.y - oBox.y) * scaleY; layer.width = originalLayer.width * scaleX; layer.height = originalLayer.height * scaleY; layer.slantOffset = originalLayer.slantOffset * scaleX; } + else if (layer.type === 'parallelepiped') { layer.x = nX + (originalLayer.x - oBox.x) * scaleX; layer.y = nY + (originalLayer.y - oBox.y) * scaleY; layer.width = originalLayer.width * scaleX; layer.height = originalLayer.height * scaleY; layer.depthOffset.x = originalLayer.depthOffset.x * scaleX; layer.depthOffset.y = originalLayer.depthOffset.y * scaleY; } + else if (layer.type === 'cone') { layer.cx = nX + (originalLayer.cx - oBox.x) * scaleX; layer.baseY = nY + (originalLayer.baseY - oBox.y) * scaleY; layer.rx = originalLayer.rx * scaleX; layer.ry = originalLayer.ry * scaleY; layer.apex.x = nX + (originalLayer.apex.x - oBox.x) * scaleX; layer.apex.y = nY + (originalLayer.apex.y - oBox.y) * scaleY; } + else if (layer.type === 'frustum') { layer.cx = nX + (originalLayer.cx - oBox.x) * scaleX; layer.baseY = nY + (originalLayer.baseY - oBox.y) * scaleY; layer.topY = nY + (originalLayer.topY - oBox.y) * scaleY; layer.rx1 = originalLayer.rx1 * scaleX; layer.ry1 = originalLayer.ry1 * scaleY; layer.rx2 = originalLayer.rx2 * scaleX; layer.ry2 = originalLayer.ry2 * scaleY; } + else if (layer.type === 'pyramid' || layer.type === 'truncated-pyramid') { + const scalePoint = p => ({ x: nX + (p.x - oBox.x) * scaleX, y: nY + (p.y - oBox.y) * scaleY }); + if(layer.apex) layer.apex = scalePoint(originalLayer.apex); + layer.base.p1 = scalePoint(originalLayer.base.p1); + layer.base.p2 = scalePoint(originalLayer.base.p2); + layer.base.p3 = scalePoint(originalLayer.base.p3); + layer.base.p4 = scalePoint(originalLayer.base.p4); + if(layer.top) { + layer.top.p1 = scalePoint(originalLayer.top.p1); + layer.top.p2 = scalePoint(originalLayer.top.p2); + layer.top.p3 = scalePoint(originalLayer.top.p3); + layer.top.p4 = scalePoint(originalLayer.top.p4); + } + } + else if (['triangle', 'trapezoid', 'rhombus'].includes(layer.type)) { layer.p1.x = nX + (originalLayer.p1.x - oBox.x) * scaleX; layer.p1.y = nY + (originalLayer.p1.y - oBox.y) * scaleY; layer.p2.x = nX + (originalLayer.p2.x - oBox.x) * scaleX; layer.p2.y = nY + (originalLayer.p2.y - oBox.y) * scaleY; layer.p3.x = nX + (originalLayer.p3.x - oBox.x) * scaleX; layer.p3.y = nY + (originalLayer.p3.y - oBox.y) * scaleY; if(layer.p4) { layer.p4.x = nX + (originalLayer.p4.x - oBox.x) * scaleX; layer.p4.y = nY + (originalLayer.p4.y - oBox.y) * scaleY;} } + else if (layer.type === 'ellipse') { layer.cx = nX + (originalLayer.cx - oBox.x) * scaleX; layer.cy = nY + (originalLayer.cy - oBox.y) * scaleY; layer.rx = originalLayer.rx * scaleX; layer.ry = originalLayer.ry * scaleY; } + else if (layer.type === 'sphere' || layer.type === 'truncated-sphere') { layer.cx = nX + (originalLayer.cx - oBox.x) * scaleX; layer.cy = nY + (originalLayer.cy - oBox.y) * scaleY; layer.r = originalLayer.r * ((scaleX + scaleY) / 2); if(layer.cutY) { layer.cutY = nY + (originalLayer.cutY - oBox.y) * scaleY; layer.cutR = originalLayer.cutR * scaleX; layer.cutRy = originalLayer.cutRy * scaleY; } } + else if (layer.type === 'path') { layer.points = originalLayer.points.map(p => ({ x: nX + (p.x - oBox.x) * scaleX, y: nY + (p.y - oBox.y) * scaleY, })); } + else if (layer.type === 'line') { layer.x1 = nX + (originalLayer.x1 - oBox.x) * scaleX; layer.y1 = nY + (originalLayer.y1 - oBox.y) * scaleY; layer.x2 = nX + (originalLayer.x2 - oBox.x) * scaleX; layer.y2 = nY + (originalLayer.y2 - oBox.y) * scaleY; } + }); +} + +export function handleRotate(state, pos, event) { + const currentAngle = Math.atan2(pos.y - state.groupPivot.y, pos.x - state.groupPivot.x); + let deltaAngle = currentAngle - state.rotationStartAngle; + + if (event.shiftKey) { + const snapAngle = 15 * (Math.PI / 180); + deltaAngle = Math.round(deltaAngle / snapAngle) * snapAngle; + } + + state.groupRotation = deltaAngle; + + state.selectedLayers.forEach((layer, index) => { + const originalLayer = state.originalLayers[index]; + const originalBox = geo.getBoundingBox(originalLayer); + if (!originalBox) return; + + const originalCenter = { x: originalBox.x + originalBox.width / 2, y: originalBox.y + originalBox.height / 2 }; + const newCenter = geo.rotatePoint(originalCenter, state.groupPivot, deltaAngle); + + const dx = newCenter.x - originalCenter.x; + const dy = newCenter.y - originalCenter.y; + + // --- НАЧАЛО ИЗМЕНЕНИЙ: Добавляем 'text' в список вращаемых объектов --- + if (['rect', 'image', 'text', 'parallelogram', 'parallelepiped'].includes(layer.type)) { + layer.x = originalLayer.x + dx; + layer.y = originalLayer.y + dy; + } else if (['ellipse', 'sphere', 'cone', 'frustum', 'truncated-sphere'].includes(layer.type)) { + layer.cx = originalLayer.cx + dx; + layer.cy = originalLayer.cy + dy; + if (layer.baseY !== undefined) layer.baseY = originalLayer.baseY + dy; + if (layer.topY !== undefined) layer.topY = originalLayer.topY + dy; + if (layer.apex) { + layer.apex.x = originalLayer.apex.x + dx; + layer.apex.y = originalLayer.apex.y + dy; + } + if (layer.cutY !== undefined) layer.cutY = originalLayer.cutY + dy; + } else if (layer.type === 'line') { + layer.x1 = originalLayer.x1 + dx; layer.y1 = originalLayer.y1 + dy; + layer.x2 = originalLayer.x2 + dx; layer.y2 = originalLayer.y2 + dy; + } else if (layer.type === 'path') { + layer.points = originalLayer.points.map(p => ({ x: p.x + dx, y: p.y + dy })); + } else if (layer.hasOwnProperty('p1')) { + for (let i = 1; i <= 4; i++) { + if (layer[`p${i}`]) { + layer[`p${i}`].x = originalLayer[`p${i}`].x + dx; + layer[`p${i}`].y = originalLayer[`p${i}`].y + dy; + } + } + } + if (layer.base) { + Object.keys(layer.base).forEach(key => { + layer.base[key].x = originalLayer.base[key].x + dx; + layer.base[key].y = originalLayer.base[key].y + dy; + }); + } + if (layer.top) { + Object.keys(layer.top).forEach(key => { + layer.top[key].x = originalLayer.top[key].x + dx; + layer.top[key].y = originalLayer.top[key].y + dy; + }); + } + if (layer.apex && !layer.baseY) { + layer.apex.x = originalLayer.apex.x + dx; + layer.apex.y = originalLayer.apex.y + dy; + } + + layer.rotation = (originalLayer.rotation || 0) + deltaAngle; + }); +} + +export function handleMovePivot(state, pos) { + const layer = state.selectedLayers[0]; + const box = geo.getBoundingBox(layer); + if (box) { + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + + const newPivotVector = { + x: pos.x - centerX, + y: pos.y - centerY + }; + + layer.pivot = geo.rotatePoint(newPivotVector, { x: 0, y: 0 }, -(layer.rotation || 0)); + } +} + +export function endSelectionBox(state, pos, event) { + const selBox = { + x: Math.min(pos.x, state.startPos.x), + y: Math.min(pos.y, state.startPos.y), + width: Math.abs(pos.x - state.startPos.x), + height: Math.abs(pos.y - state.startPos.y), + }; + const layersInBox = state.layers.filter(layer => geo.doBoxesIntersect(geo.getBoundingBox(layer), selBox)); + + if (event.ctrlKey || event.metaKey) { + const idsToDeselect = new Set(layersInBox.map(l => l.id)); + state.selectedLayers = state.selectedLayers.filter(layer => !idsToDeselect.has(layer.id)); + } else if (event.shiftKey) { + const existingIds = new Set(state.selectedLayers.map(l => l.id)); + layersInBox.forEach(layer => { + if (!existingIds.has(layer.id)) { + state.selectedLayers.push(layer); + } + }); + } else { + state.selectedLayers = layersInBox; + } +} +// --- END OF FILE actions.js --- \ No newline at end of file diff --git a/js/canvas.js b/js/canvas.js new file mode 100644 index 0000000..e25a9d6 --- /dev/null +++ b/js/canvas.js @@ -0,0 +1,850 @@ +// --- START OF FILE canvas.js --- + +import * as geo from './geometry.js'; +import * as hitTest from './hitTest.js'; +import * as actions from './actions.js'; +import * as tools from './tools.js'; +import * as utils from './utils.js'; +import * as layerManager from './layerManager.js'; +import * as shapeRecognizer from './shapeRecognizer.js'; +import * as textTool from './text.js'; + +export function initializeCanvas(canvas, ctx, redrawCallback, saveState, updateToolbarCallback) { + const state = { + canvas, ctx, isDrawing: false, layers: [], activeTool: 'brush', previousTool: 'brush', + startPos: null, selectedLayers: [], currentAction: 'none', dragStartPos: null, + scalingHandle: null, activeColor: '#000000', + activeLineWidth: 3, + activeFontFamily: 'Arial', + activeFontSize: 30, + activeFontWeight: 'normal', + activeFontStyle: 'normal', + activeTextDecoration: 'none', + activeTextAlign: 'left', + lastClickTime: 0, clickCount: 0, lastClickPos: null, + originalLayers: [], originalBox: null, saveState, didErase: false, tempLayer: null, + panX: 0, panY: 0, zoom: 1.0, isPanning: false, panStartPos: { x: 0, y: 0 }, + rotationStartAngle: 0, + groupPivot: null, + groupRotation: 0, + snapPoint: null, + lastBrushTime: 0, + lastBrushPoint: null, + smoothingAmount: 2, + shapeRecognitionTimer: null, + shapeWasJustRecognized: false, + eraserTrailNodes: [], + eraserAnimationId: null, + lastEraserPos: { x: 0, y: 0 }, + isEditingText: false, + }; + + state.updateTextEditorStyle = textTool.updateEditorStyle; + state.updateTextEditorTransform = textTool.updateEditorTransform; + + state.updateFloatingToolbar = () => { + const toolbar = document.getElementById('floating-text-toolbar'); + const isVisible = state.isEditingText || (state.selectedLayers.length === 1 && state.selectedLayers[0].type === 'text'); + + if (isVisible) { + toolbar.classList.add('visible'); + + const layer = state.isEditingText + ? state.layers.find(l => l.isEditing) + : state.selectedLayers[0]; + + if (!layer) { + toolbar.classList.remove('visible'); + return; + } + + const box = geo.getBoundingBox(layer); + if (!box) { + toolbar.classList.remove('visible'); + return; + } + + document.getElementById('fontFamilySelect').value = layer.fontFamily || 'Arial'; + document.getElementById('floatingFontSizeInput').value = layer.fontSize || 30; + const colorButtonCircle = toolbar.querySelector('[data-action="pick-color"] circle'); + if (colorButtonCircle) { + colorButtonCircle.style.fill = layer.color || '#000000'; + } + + toolbar.querySelector('[data-action="align-left"]').classList.toggle('active', !layer.align || layer.align === 'left'); + toolbar.querySelector('[data-action="align-center"]').classList.toggle('active', layer.align === 'center'); + toolbar.querySelector('[data-action="align-right"]').classList.toggle('active', layer.align === 'right'); + toolbar.querySelector('[data-action="font-bold"]').classList.toggle('active', layer.fontWeight === 'bold'); + toolbar.querySelector('[data-action="font-italic"]').classList.toggle('active', layer.fontStyle === 'italic'); + toolbar.querySelector('[data-action="font-underline"]').classList.toggle('active', layer.textDecoration === 'underline'); + + const screenX = (box.x * state.zoom) + state.panX; + const screenY = (box.y * state.zoom) + state.panY; + const screenHeight = box.height * state.zoom; + + toolbar.style.left = `${screenX}px`; + + const toolbarHeight = toolbar.offsetHeight; + const spaceAbove = screenY; + + if (spaceAbove > toolbarHeight + 10) { + toolbar.style.top = `${screenY - toolbarHeight - 10}px`; + } else { + toolbar.style.top = `${screenY + screenHeight + 10}px`; + } + + } else { + toolbar.classList.remove('visible'); + } + }; + + const NUM_TRAIL_NODES = 15; + const EASING_FACTOR = 0.2; + + function animateEraserTrail() { + state.eraserAnimationId = requestAnimationFrame(animateEraserTrail); + redrawCallback(); + + const { ctx, zoom, panX, panY, eraserTrailNodes, lastEraserPos } = state; + + let target = lastEraserPos; + for (const node of eraserTrailNodes) { + node.x += (target.x - node.x) * EASING_FACTOR; + node.y += (target.y - node.y) * EASING_FACTOR; + target = node; + } + + ctx.save(); + ctx.translate(panX, panY); + ctx.scale(zoom, zoom); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + for (let i = 1; i < eraserTrailNodes.length; i++) { + const p1 = eraserTrailNodes[i - 1]; + const p2 = eraserTrailNodes[i]; + + const ratio = i / eraserTrailNodes.length; + ctx.lineWidth = (1 - ratio) * 15 / zoom; + ctx.strokeStyle = `rgba(135, 206, 250, ${1 - ratio})`; + + ctx.beginPath(); + ctx.moveTo(p1.x, p1.y); + const midPoint = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; + ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y); + ctx.stroke(); + } + + ctx.restore(); + } + + function performZoom(direction, zoomCenter) { + const zoomFactor = 1.1; + const oldZoom = state.zoom; + let newZoom = (direction === 'in') ? oldZoom * zoomFactor : oldZoom / zoomFactor; + state.zoom = Math.max(0.1, Math.min(newZoom, 10)); + if (!zoomCenter) zoomCenter = { x: canvas.getBoundingClientRect().width / 2, y: canvas.getBoundingClientRect().height / 2 }; + state.panX = zoomCenter.x - (zoomCenter.x - state.panX) * (state.zoom / oldZoom); + state.panY = zoomCenter.y - (zoomCenter.y - state.panY) * (state.zoom / oldZoom); + redrawCallback(); + state.updateFloatingToolbar(); + } + + saveState(state.layers); + + const getMousePos = (e) => { const rect = canvas.getBoundingClientRect(); const screenX = e.clientX - rect.left; const screenY = e.clientY - rect.top; return { x: (screenX - state.panX) / state.zoom, y: (screenY - state.panY) / state.zoom, }; }; + + const contextMenu = document.getElementById('contextMenu'); + function hideContextMenu() { contextMenu.classList.remove('visible'); } + document.addEventListener('click', (e) => { if (!contextMenu.contains(e.target)) hideContextMenu(); }); + + contextMenu.addEventListener('click', (e) => { + const action = e.target.dataset.action; + if (!action || state.selectedLayers.length === 0) return; + let newLayers; + switch (action) { + case 'bringForward': newLayers = layerManager.bringForward(state.layers, state.selectedLayers); break; + case 'sendBackward': newLayers = layerManager.sendBackward(state.layers, state.selectedLayers); break; + case 'bringToFront': newLayers = layerManager.bringToFront(state.layers, state.selectedLayers); break; + case 'sendToBack': newLayers = layerManager.sendToBack(state.layers, state.selectedLayers); break; + } + if (newLayers) { + state.layers = newLayers; + saveState(state.layers); + redrawCallback(); + } + hideContextMenu(); + }); + + function updateCursor(handle) { + let cursor = ''; + if (handle) { + switch (handle) { + case 'pivot': cursor = 'grab'; break; + case 'rotate': cursor = 'crosshair'; break; + case 'topLeft': case 'bottomRight': cursor = 'nwse-resize'; break; + case 'topRight': case 'bottomLeft': cursor = 'nesw-resize'; break; + case 'top': case 'bottom': cursor = 'ns-resize'; break; + case 'left': case 'right': cursor = 'ew-resize'; break; + } + } + canvas.style.cursor = cursor; + } + + function handleTripleClick(pos) { const layer = hitTest.getLayerAtPosition(pos, state.layers); if (layer) { state.isDrawing = false; state.selectedLayers = [layer]; const selectButton = document.querySelector('button[data-tool="select"]'); if (selectButton && state.activeTool !== 'select') { selectButton.click(); } else { redrawCallback(); } updateToolbarCallback(); return true; } return false; } + + function startDrawing(e) { + if (e.target.id !== 'drawingBoard') return; + + const pos = getMousePos(e); + + if (state.activeTool === 'text' && !state.isEditingText) { + const clickedLayer = hitTest.getLayerAtPosition(pos, state.layers); + if (clickedLayer && clickedLayer.type === 'text') { + state.selectedLayers = [clickedLayer]; + clickedLayer.isEditing = true; + state.isEditingText = true; + redrawCallback(); + state.updateFloatingToolbar(); + + textTool.startEditing(state, clickedLayer, (isIntermediate) => { + if (isIntermediate) { + redrawCallback(); + state.updateFloatingToolbar(); + if(state.updateTextEditorTransform) state.updateTextEditorTransform(clickedLayer, state); + return; + } + state.isEditingText = false; + const finishedLayer = state.layers.find(l => l.id === clickedLayer.id); + if (finishedLayer) { + finishedLayer.isEditing = false; + } + saveState(state.layers); + redrawCallback(); + state.updateFloatingToolbar(); + }); + return; + } + } + + if (state.isEditingText) { + const handle = hitTest.getHandleAtPosition(pos, state.selectedLayers, state.zoom, state.groupRotation); + if (!handle) { + return; + } + } + + clearTimeout(state.shapeRecognitionTimer); + state.shapeWasJustRecognized = false; + + const isPanToolActive = state.activeTool === 'pan' && e.button === 0; + const isMiddleMouseButton = e.pointerType === 'mouse' && e.button === 1; + + if (isPanToolActive || isMiddleMouseButton) { + state.isPanning = true; + state.panStartPos = { x: e.clientX, y: e.clientY }; + canvas.style.cursor = 'grabbing'; + return; + } + if (e.pointerType === 'mouse' && e.button !== 0) return; + + hideContextMenu(); + + let finalPos = pos; + if (e.altKey) { + finalPos = { x: utils.snapToGrid(pos.x), y: utils.snapToGrid(pos.y) }; + } + + if (state.currentAction.startsWith('drawing')) { + if (state.currentAction === 'drawingParallelogramSlant') { + const finalSlant = finalPos.x - (state.tempLayer.x + state.tempLayer.width / 2); + state.tempLayer.slantOffset = finalSlant; + if (state.tempLayer.width > 5 || state.tempLayer.height > 5) { state.layers.push(state.tempLayer); } + } else if (state.currentAction === 'drawingTriangleApex') { + state.tempLayer.p3 = finalPos; + if (Math.abs(state.tempLayer.p1.x - state.tempLayer.p2.x) > 5 || Math.abs(state.tempLayer.p1.y - state.tempLayer.p2.y) > 5) { state.layers.push(state.tempLayer); } + } else if (state.currentAction === 'drawingParallelepipedDepth') { + state.tempLayer.depthOffset = { x: finalPos.x - (state.tempLayer.x + state.tempLayer.width), y: finalPos.y - state.tempLayer.y }; + if (state.tempLayer.width > 5 || state.tempLayer.height > 5) { state.layers.push(state.tempLayer); } + } else if (state.currentAction === 'drawingPyramidApex') { + state.tempLayer.apex = finalPos; + state.layers.push(state.tempLayer); + } else if (state.currentAction === 'drawingTruncatedPyramidApex') { + state.tempLayer.apex = finalPos; + state.currentAction = 'drawingTruncatedPyramidTop'; + return; + } else if (state.currentAction === 'drawingTruncatedPyramidTop') { + const { base, apex } = state.tempLayer; + const totalHeight = Math.abs(apex.y - base.p1.y); + const cutHeight = Math.abs(finalPos.y - base.p1.y); + const ratio = Math.max(0.05, Math.min(0.95, cutHeight / totalHeight)); + + const interpolate = (p1, p2) => ({ + x: p1.x + (p2.x - p1.x) * ratio, + y: p1.y + (p2.y - p1.y) * ratio + }); + + state.tempLayer.top = { + p1: interpolate(base.p1, apex), p2: interpolate(base.p2, apex), + p3: interpolate(base.p3, apex), p4: interpolate(base.p4, apex), + }; + state.layers.push(state.tempLayer); + } else if (state.currentAction === 'drawingTrapezoidP3') { + state.tempLayer.p3 = finalPos; + state.currentAction = 'drawingTrapezoidP4'; + return; + } else if (state.currentAction === 'drawingTrapezoidP4') { + state.tempLayer.p4 = finalPos; + if (Math.abs(state.tempLayer.p1.x - state.tempLayer.p2.x) > 5) { state.layers.push(state.tempLayer); } + } else if (state.currentAction === 'drawingFrustum') { + const { cx } = state.tempLayer; + state.tempLayer.topY = finalPos.y; + state.tempLayer.rx2 = Math.abs(finalPos.x - cx); + state.tempLayer.ry2 = state.tempLayer.rx2 * 0.3; + state.layers.push(state.tempLayer); + } else if (state.currentAction === 'drawingTruncatedSphere') { + const { cx, cy, r } = state.tempLayer; + const cutY = Math.max(cy - r, Math.min(cy + r, finalPos.y)); + const h = Math.abs(cutY - cy); + const cutRSquared = (r * r) - (h * h); + state.tempLayer.cutY = cutY; + state.tempLayer.cutR = cutRSquared > 0 ? Math.sqrt(cutRSquared) : 0; + state.tempLayer.cutRy = state.tempLayer.cutR * 0.3; + state.layers.push(state.tempLayer); + } + saveState(state.layers); state.currentAction = 'none'; state.tempLayer = null; redrawCallback(); return; + } + + const now = Date.now(); + const CLICK_SPEED = 400, CLICK_RADIUS = 10; + const timeDiff = now - state.lastClickTime; + if (state.lastClickPos && timeDiff < CLICK_SPEED && Math.abs(pos.x - state.lastClickPos.x) < CLICK_RADIUS && Math.abs(pos.y - state.lastClickPos.y) < CLICK_RADIUS) { state.clickCount++; } else { state.clickCount = 1; } + state.lastClickTime = now; state.lastClickPos = pos; + + if (state.activeTool === 'select' && state.clickCount === 2) { + const clickedLayer = hitTest.getLayerAtPosition(pos, state.layers); + if (clickedLayer && clickedLayer.type === 'text') { + state.selectedLayers = [clickedLayer]; + clickedLayer.isEditing = true; + + state.isEditingText = true; + redrawCallback(); + state.updateFloatingToolbar(); + + textTool.startEditing(state, clickedLayer, (isIntermediate) => { + if (isIntermediate) { + redrawCallback(); + state.updateFloatingToolbar(); + if(state.updateTextEditorTransform) state.updateTextEditorTransform(clickedLayer, state); + return; + } + state.isEditingText = false; + const finishedLayer = state.layers.find(l => l.id === clickedLayer.id); + if (finishedLayer) { + finishedLayer.isEditing = false; + } + saveState(state.layers); + redrawCallback(); + state.updateFloatingToolbar(); + }); + return; + } + } + + if (state.clickCount === 3) { state.clickCount = 0; if (handleTripleClick(pos)) return; } + + state.dragStartPos = pos; + + if (state.activeTool === 'select') { + state.groupRotation = 0; + const handle = hitTest.getHandleAtPosition(pos, state.selectedLayers, state.zoom, state.groupRotation); + + if (handle) { + state.scalingHandle = handle; + if (handle === 'pivot') { + state.currentAction = 'movingPivot'; + canvas.style.cursor = 'none'; + } else if (handle === 'rotate') { + state.currentAction = 'rotating'; + const box = geo.getGroupBoundingBox(state.selectedLayers); + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + let pivotX = centerX; + let pivotY = centerY; + + if (state.selectedLayers.length === 1) { + const layer = state.selectedLayers[0]; + const pivot = layer.pivot || { x: 0, y: 0 }; + const rotation = layer.rotation || 0; + const rotatedPivotOffset = geo.rotatePoint(pivot, {x: 0, y: 0}, rotation); + pivotX = centerX + rotatedPivotOffset.x; + pivotY = centerY + rotatedPivotOffset.y; + } + + state.groupPivot = { x: pivotX, y: pivotY }; + state.rotationStartAngle = Math.atan2(pos.y - state.groupPivot.y, pos.x - state.groupPivot.x); + state.originalLayers = state.selectedLayers.map(l => JSON.parse(JSON.stringify(l))); + } else { + state.currentAction = 'scaling'; + state.originalBox = geo.getGroupBoundingBox(state.selectedLayers); + state.originalLayers = state.selectedLayers.map(l => JSON.parse(JSON.stringify(l))); + } + return; + } + + const clickedLayer = hitTest.getLayerAtPosition(pos, state.layers); + if (clickedLayer) { + const isAlreadySelected = state.selectedLayers.some(l => l.id === clickedLayer.id); + + if (e.shiftKey) { + if (isAlreadySelected) { + state.selectedLayers = state.selectedLayers.filter(l => l.id !== clickedLayer.id); + } else { + state.selectedLayers.push(clickedLayer); + } + } else { + if (!isAlreadySelected) { + state.selectedLayers = [clickedLayer]; + } + state.currentAction = 'moving'; + } + } else { + if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { + state.selectedLayers = []; + } + state.currentAction = 'selectionBox'; + state.startPos = pos; + } + + redrawCallback(); + updateToolbarCallback(); + state.updateFloatingToolbar(); + return; + } + + state.selectedLayers = []; + updateToolbarCallback(); + state.updateFloatingToolbar(); + state.isDrawing = true; + state.startPos = pos; + if (state.activeTool === 'brush' || state.activeTool === 'smart-brush') { + const pressure = e.pressure > 0 ? e.pressure : 0.5; + state.layers.push({ type: 'path', points: [{...pos, pressure }], color: state.activeColor, lineWidth: state.activeLineWidth, id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } }); + state.lastBrushTime = Date.now(); + state.lastBrushPoint = pos; + } + else if (state.activeTool === 'eraser') { + state.didErase = false; + state.lastEraserPos = pos; + state.eraserTrailNodes = Array(NUM_TRAIL_NODES).fill(null).map(() => ({ ...pos })); + if (state.eraserAnimationId) { + cancelAnimationFrame(state.eraserAnimationId); + } + animateEraserTrail(); + if (tools.handleEraser(state, pos)) { + redrawCallback(); + } + } + } + + function draw(e) { + if (e.target.id !== 'drawingBoard' && state.currentAction === 'none' && !state.isPanning) return; + + if (state.isEditingText && (state.currentAction === 'moving' || state.currentAction === 'scaling')) { + const textarea = textTool.getEditorTextarea(); + if (textarea) textarea.style.pointerEvents = 'none'; + } + + if (state.isPanning) { const dx = e.clientX - state.panStartPos.x; const dy = e.clientY - state.panStartPos.y; state.panX += dx; state.panY += dy; state.panStartPos = { x: e.clientX, y: e.clientY }; redrawCallback(); state.updateFloatingToolbar(); return; } + const pos = getMousePos(e); + + if (!state.isDrawing && state.currentAction === 'none') { + const layerAtPos = hitTest.getLayerAtPosition(pos, state.layers); + if (state.selectedLayers.length > 0) { + const handle = hitTest.getHandleAtPosition(pos, state.selectedLayers, state.zoom, state.groupRotation); + updateCursor(handle); + if (!handle && hitTest.getLayerAtPosition(pos, state.selectedLayers)) { + canvas.style.cursor = 'move'; + } + } else if (state.activeTool === 'select') { + if (layerAtPos && layerAtPos.type === 'text') { + canvas.style.cursor = 'text'; + } else { + canvas.style.cursor = layerAtPos ? 'pointer' : ''; + } + } else if (state.activeTool === 'pan') { + canvas.style.cursor = 'grab'; + } else { updateCursor(null); } + return; + } + + if (!e.altKey) { state.snapPoint = null; } + + switch(state.currentAction) { + case 'movingPivot': actions.handleMovePivot(state, pos); redrawCallback(); return; + case 'rotating': actions.handleRotate(state, pos, e); redrawCallback(); state.updateFloatingToolbar(); return; + case 'moving': + actions.handleMove(state, pos, e); + if (state.isEditingText && state.updateTextEditorTransform) { + state.updateTextEditorTransform(state.selectedLayers[0], state); + } + redrawCallback(); + state.updateFloatingToolbar(); + return; + case 'scaling': + actions.handleScale(state, pos, e); + if (state.isEditingText && state.updateTextEditorTransform) { + state.updateTextEditorTransform(state.selectedLayers[0], state); + } + redrawCallback(); + state.updateFloatingToolbar(); + return; + case 'selectionBox': + redrawCallback(); ctx.save(); ctx.translate(state.panX, state.panY); ctx.scale(state.zoom, state.zoom); + ctx.strokeStyle = 'rgba(0, 122, 255, 0.8)'; ctx.fillStyle = 'rgba(0, 122, 255, 0.1)'; + ctx.lineWidth = 1 / state.zoom; ctx.beginPath(); + ctx.rect(state.startPos.x, state.startPos.y, pos.x - state.startPos.x, pos.y - state.startPos.y); + ctx.fill(); ctx.stroke(); ctx.restore(); + return; + } + + if (state.currentAction.startsWith('drawing')) { + tools.handleMultiStepDrawing(state, pos, e, redrawCallback); + } else if (state.isDrawing) { + if (state.activeTool === 'brush' || state.activeTool === 'smart-brush') { + tools.handleBrush(state, pos, e); + + if (state.activeTool === 'smart-brush') { + clearTimeout(state.shapeRecognitionTimer); + const lastLayer = state.layers[state.layers.length - 1]; + if (lastLayer && lastLayer.type === 'path') { + state.shapeRecognitionTimer = setTimeout(() => { + const recognizedShape = shapeRecognizer.recognizeShape(lastLayer.points); + if (recognizedShape) { + if (state.layers[state.layers.length - 1] === lastLayer) { + state.layers.pop(); + recognizedShape.color = lastLayer.color; + recognizedShape.lineWidth = lastLayer.lineWidth; + state.layers.push(recognizedShape); + + state.isDrawing = false; + state.shapeWasJustRecognized = true; + + saveState(state.layers); + redrawCallback(); + } + } + }, 500); + } + } + redrawCallback(); + } else if (state.activeTool === 'eraser') { + state.lastEraserPos = pos; + tools.handleEraser(state, pos); + } else { + tools.handleShapeDrawing(state, pos, e, redrawCallback); + } + } + } + + function stopDrawing(e) { + if (state.isEditingText) { + const textarea = textTool.getEditorTextarea(); + if (textarea) textarea.style.pointerEvents = 'auto'; + } + + if (state.isEditingText && (state.currentAction === 'moving' || state.currentAction === 'scaling' || state.currentAction === 'rotating' || state.currentAction === 'movingPivot')) { + saveState(state.layers); + state.currentAction = 'none'; + state.scalingHandle = null; + state.originalBox = null; + state.originalLayers = []; + return; + } + + if (state.isEditingText) return; + + clearTimeout(state.shapeRecognitionTimer); + if (state.eraserAnimationId) { + cancelAnimationFrame(state.eraserAnimationId); + state.eraserAnimationId = null; + redrawCallback(); + } + + if (state.isPanning) { + state.isPanning = false; + if (state.activeTool === 'pan') { + canvas.style.cursor = 'grab'; + } else { + updateCursor(null); + } + return; + } + + if (!state.isDrawing && ['rotating', 'scaling', 'movingPivot', 'moving'].includes(state.currentAction)) { + if (state.currentAction === 'rotating' || state.currentAction === 'scaling' || state.currentAction === 'movingPivot') { + state.selectedLayers.forEach(utils.applyTransformations); + } + if (state.currentAction === 'moving') { + saveState(state.layers); + } + } + + const isMultiStep = state.currentAction.startsWith('drawing'); + if (!state.isDrawing) { + if (state.currentAction === 'movingPivot') { updateCursor(null); } + if (isMultiStep) return; + } else if (state.isDrawing) { + const rawEnd = getMousePos(e); + let finalStart = { ...state.startPos }; + let finalEnd = rawEnd; + + if (e.altKey) { + finalStart = { x: utils.snapToGrid(state.startPos.x), y: utils.snapToGrid(state.startPos.y) }; + finalEnd = { x: utils.snapToGrid(rawEnd.x), y: utils.snapToGrid(rawEnd.y) }; + } + + if (state.activeTool === 'line' && e.shiftKey) { + const dx = finalEnd.x - finalStart.x; + const dy = finalEnd.y - finalStart.y; + if (Math.abs(dx) > Math.abs(dy)) { finalEnd.y = finalStart.y; } else { finalEnd.x = finalStart.x; } + } + + if (state.activeTool === 'brush' || state.activeTool === 'smart-brush') { + const lastLayer = state.layers[state.layers.length - 1]; + if (lastLayer && lastLayer.type === 'path') { + if (state.smoothingAmount > 0) { + const tolerance = state.smoothingAmount * 0.5; + lastLayer.points = utils.simplifyPath(lastLayer.points, tolerance); + } + + if (state.activeTool === 'smart-brush' && !state.shapeWasJustRecognized) { + const recognizedShape = shapeRecognizer.recognizeShape(lastLayer.points); + if (recognizedShape) { + state.layers.pop(); + recognizedShape.color = lastLayer.color; + recognizedShape.lineWidth = lastLayer.lineWidth; + state.layers.push(recognizedShape); + } + } + } + saveState(state.layers); + } + else if (state.activeTool === 'eraser') { + if (state.didErase) { saveState(state.layers); } + } else if (['parallelogram', 'triangle', 'parallelepiped', 'pyramid', 'truncated-pyramid', 'trapezoid', 'frustum', 'truncated-sphere'].includes(state.activeTool)) { + state.isDrawing = false; + const commonProps = { color: state.activeColor, lineWidth: state.activeLineWidth, id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } }; + switch(state.activeTool) { + case 'parallelogram': { + state.currentAction = 'drawingParallelogramSlant'; + state.tempLayer = { type: 'parallelogram', x: Math.min(finalEnd.x, finalStart.x), y: Math.min(finalEnd.y, finalStart.y), width: Math.abs(finalEnd.x - finalStart.x), height: Math.abs(finalEnd.y - finalStart.y), slantOffset: 0, ...commonProps }; + break; + } + case 'triangle': { + state.currentAction = 'drawingTriangleApex'; + state.tempLayer = { type: 'triangle', p1: finalStart, p2: finalEnd, p3: finalEnd, ...commonProps }; + break; + } + case 'parallelepiped': { + state.currentAction = 'drawingParallelepipedDepth'; + const width = Math.abs(finalEnd.x - finalStart.x); + state.tempLayer = { type: 'parallelepiped', x: Math.min(finalEnd.x, finalStart.x), y: Math.min(finalEnd.y, finalStart.y), width, height: Math.abs(finalEnd.y - finalStart.y), depthOffset: { x: width * 0.3, y: -width * 0.3 }, ...commonProps }; + break; + } + case 'pyramid': case 'truncated-pyramid': { + state.currentAction = state.activeTool === 'pyramid' ? 'drawingPyramidApex' : 'drawingTruncatedPyramidApex'; + const x = Math.min(finalEnd.x, finalStart.x); + const y = Math.min(finalEnd.y, finalStart.y); + const w = Math.abs(finalEnd.x - finalStart.x); + const h = Math.abs(finalEnd.y - finalStart.y); + const d = {x: w * 0.3, y: -w * 0.2}; + state.tempLayer = { type: state.activeTool, base: { p1: { x: x, y: y + h }, p2: { x: x + w, y: y + h }, p3: { x: x + w + d.x, y: y + h + d.y }, p4: { x: x + d.x, y: y + h + d.y } }, apex: { x: x + w/2, y: y }, ...commonProps }; + break; + } + case 'trapezoid': { + state.currentAction = 'drawingTrapezoidP3'; + state.tempLayer = { type: 'trapezoid', p1: finalStart, p2: finalEnd, p3: finalEnd, p4: finalStart, ...commonProps }; + break; + } + case 'frustum': { + state.currentAction = 'drawingFrustum'; + const rx1 = Math.abs(finalEnd.x - finalStart.x) / 2; + const cx = finalStart.x + (finalEnd.x - finalStart.x) / 2; + const baseY = Math.max(finalEnd.y, finalStart.y); + state.tempLayer = { type: 'frustum', cx, baseY, rx1, ry1: rx1 * 0.3, topY: baseY, rx2: rx1, ry2: rx1 * 0.3, ...commonProps }; + break; + } + case 'truncated-sphere': { + state.currentAction = 'drawingTruncatedSphere'; + const r = Math.abs(finalEnd.x - finalStart.x) / 2; + const cenX = finalStart.x + (finalEnd.x - finalStart.x) / 2; + const cenY = finalStart.y + (finalEnd.y - finalStart.y)/2; + state.tempLayer = { type: 'truncated-sphere', cx: cenX, cy: cenY, r, cutY: cenY, cutR: r, cutRy: r * 0.3, ...commonProps }; + break; + } + } + return; + } else { + const commonProps = { color: state.activeColor, lineWidth: state.activeLineWidth, id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } }; + switch(state.activeTool) { + case 'rect': { + const rect = { type: 'rect', x: Math.min(finalEnd.x, finalStart.x), y: Math.min(finalEnd.y, finalStart.y), width: Math.abs(finalEnd.x - finalStart.x), height: Math.abs(finalEnd.y - finalStart.y), ...commonProps }; + if (rect.width > 5 || rect.height > 5) state.layers.push(rect); + break; + } + case 'text': { + const width = Math.abs(finalEnd.x - finalStart.x); + const height = Math.abs(finalEnd.y - finalStart.y); + if (width < 20 || height < 20) { + state.isDrawing = false; + redrawCallback(); + return; + } + + const newTextLayer = { + type: 'text', + x: Math.min(finalEnd.x, finalStart.x), + y: Math.min(finalEnd.y, finalStart.y), + width: width, + height: height, + content: '', + color: state.activeColor, + fontSize: state.activeFontSize, + fontFamily: state.activeFontFamily, + align: state.activeTextAlign, + fontWeight: state.activeFontWeight, + fontStyle: state.activeFontStyle, + textDecoration: state.activeTextDecoration, + id: Date.now(), + rotation: 0, + pivot: { x: 0, y: 0 } + }; + + state.layers.push(newTextLayer); + state.selectedLayers = [newTextLayer]; + newTextLayer.isEditing = true; + + state.isEditingText = true; + redrawCallback(); + state.updateFloatingToolbar(); + + textTool.startEditing(state, newTextLayer, (isIntermediate) => { + if (isIntermediate) { + redrawCallback(); + state.updateFloatingToolbar(); + if(state.updateTextEditorTransform) state.updateTextEditorTransform(newTextLayer, state); + return; + } + state.isEditingText = false; + const finishedLayer = state.layers.find(l => l.id === newTextLayer.id); + if (finishedLayer) { + finishedLayer.isEditing = false; + } + saveState(state.layers); + redrawCallback(); + state.updateFloatingToolbar(); + }); + state.isDrawing = false; + return; + } + case 'rhombus': { + const x = Math.min(finalEnd.x, finalStart.x); + const y = Math.min(finalEnd.y, finalStart.y); + const width = Math.abs(finalEnd.x - finalStart.x); + const height = Math.abs(finalEnd.y - finalStart.y); + if(width>5 && height>5) state.layers.push({ type: 'rhombus', p1: {x: x+width/2, y: y}, p2: {x: x+width, y: y+height/2}, p3: {x: x+width/2, y: y+height}, p4: {x: x, y: y+height/2}, ...commonProps }); + break; + } + case 'ellipse': case 'sphere': case 'cone': { + const width = Math.abs(finalEnd.x - finalStart.x); + const height = Math.abs(finalEnd.y - finalStart.y); + const cx = finalStart.x + (finalEnd.x - finalStart.x) / 2; + if (state.activeTool === 'sphere') { + const r = width / 2; + if (r > 5) state.layers.push({ type: 'sphere', cx, cy: finalStart.y + (finalEnd.y-finalStart.y)/2, r, ...commonProps }); + } else if (state.activeTool === 'cone') { + const rx = width / 2; + const apex = {x: cx, y: Math.min(finalEnd.y, finalStart.y)}; + const baseY = Math.max(finalEnd.y, finalStart.y); + if (rx > 5 && height > 5) state.layers.push({ type: 'cone', cx, baseY, rx, ry: rx * 0.3, apex, ...commonProps }); + } else { + const rx = width / 2; + const ry = height/2; + const cy = finalStart.y + (finalEnd.y - finalStart.y) / 2; + if (rx > 5 || ry > 5) state.layers.push({ type: 'ellipse', cx, cy, rx, ry, ...commonProps }); + } + break; + } + case 'line': { + const line = { type: 'line', x1: finalStart.x, y1: finalStart.y, x2: finalEnd.x, y2: finalEnd.y, ...commonProps }; + if (Math.abs(line.x1 - line.x2) > 5 || Math.abs(line.y1 - line.y2) > 5) state.layers.push(line); + break; + } + } + saveState(state.layers); + } + } + + updateCursor(null); + if (state.currentAction === 'selectionBox') { + actions.endSelectionBox(state, getMousePos(e), e); + updateToolbarCallback(); + state.updateFloatingToolbar(); + } + + state.isDrawing = false; state.currentAction = 'none'; state.scalingHandle = null; state.startPos = null; state.originalBox = null; state.originalLayers = []; state.groupPivot = null; state.didErase = false; state.groupRotation = 0; state.snapPoint = null; + redrawCallback(); + } + + function handleContextMenu(e) { + e.preventDefault(); + const pos = getMousePos(e); + const clickedLayer = hitTest.getLayerAtPosition(pos, state.layers); + + if (clickedLayer) { + if (!state.selectedLayers.some(l => l.id === clickedLayer.id)) { + state.selectedLayers = [clickedLayer]; + redrawCallback(); + updateToolbarCallback(); + state.updateFloatingToolbar(); + } + + contextMenu.style.top = `${e.clientY}px`; + contextMenu.style.left = `${e.clientX}px`; + contextMenu.classList.add('visible'); + } else { + hideContextMenu(); + } + } + + canvas.addEventListener('pointerdown', startDrawing); + document.addEventListener('pointermove', draw); + document.addEventListener('pointerup', stopDrawing); + canvas.addEventListener('pointerleave', (e) => { if (state.isDrawing || state.currentAction !== 'none' || state.isPanning) { stopDrawing(e); } state.isPanning = false; }); + + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + if (e.deltaY < 0) { + performZoom('in', { x: mouseX, y: mouseY }); + } else { + performZoom('out', { x: mouseX, y: mouseY }); + } + }); + + canvas.addEventListener('dragover', (e) => e.preventDefault()); + canvas.addEventListener('drop', (e) => { e.preventDefault(); const pos = getMousePos(e); if (e.dataTransfer.files.length > 0) utils.processImageFile(e.dataTransfer.files[0], pos, state, redrawCallback, saveState); }); + + canvas.addEventListener('contextmenu', handleContextMenu); + + const imageUploadInput = document.getElementById('imageUpload'); + imageUploadInput.addEventListener('change', (e) => { if (e.target.files.length > 0) { const centerPos = { x: canvas.width / 2, y: canvas.height / 2 }; utils.processImageFile(e.target.files[0], centerPos, state, redrawCallback, saveState); e.target.value = ''; } }); + + state.performZoom = performZoom; + + return state; +} \ No newline at end of file diff --git a/js/geometry.js b/js/geometry.js new file mode 100644 index 0000000..9e84f44 --- /dev/null +++ b/js/geometry.js @@ -0,0 +1,67 @@ +// --- START OF FILE geometry.js --- + +// --- Funzioni ausiliarie (nessuna modifica) --- +export function getBoundingBox(layer) { + if (!layer) return null; + // --- НАЧАЛО ИЗМЕНЕНИЙ: Добавляем 'text' в список --- + if (layer.type === 'rect' || layer.type === 'image' || layer.type === 'text') { return { x: layer.x, y: layer.y, width: layer.width, height: layer.height }; } + // --- КОНЕЦ ИЗМЕНЕНИЙ --- + if (layer.type === 'ellipse') { return { x: layer.cx - layer.rx, y: layer.cy - layer.ry, width: layer.rx * 2, height: layer.ry * 2 }; } + if (layer.type === 'sphere' || layer.type === 'truncated-sphere') { return { x: layer.cx - layer.r, y: layer.cy - layer.r, width: layer.r * 2, height: layer.r * 2 }; } + if (layer.type === 'line') { return { x: Math.min(layer.x1, layer.x2), y: Math.min(layer.y1, layer.y2), width: Math.abs(layer.x1 - layer.x2), height: Math.abs(layer.y1 - layer.y2) }; } + if (layer.type === 'parallelogram') { const x_coords = [layer.x, layer.x + layer.width, layer.x + layer.slantOffset, layer.x + layer.width + layer.slantOffset]; const y_coords = [layer.y, layer.y + layer.height]; const minX = Math.min(...x_coords); const maxX = Math.max(...x_coords); const minY = Math.min(...y_coords); const maxY = Math.max(...y_coords); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } + if (layer.type === 'triangle' || layer.type === 'trapezoid' || layer.type === 'rhombus') { + const points = [layer.p1, layer.p2, layer.p3]; + if (layer.p4) { + points.push(layer.p4); + } + const x_coords = points.map(p => p.x); + const y_coords = points.map(p => p.y); + const minX = Math.min(...x_coords); + const maxX = Math.max(...x_coords); + const minY = Math.min(...y_coords); + const maxY = Math.max(...y_coords); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + if (layer.type === 'cone') { const minX = Math.min(layer.cx - layer.rx, layer.apex.x); const maxX = Math.max(layer.cx + layer.rx, layer.apex.x); const minY = Math.min(layer.baseY, layer.apex.y); const maxY = Math.max(layer.baseY, layer.apex.y); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } + if (layer.type === 'parallelepiped') { const { x, y, width, height, depthOffset } = layer; const x_coords = [x, x + width, x + depthOffset.x, x + width + depthOffset.x]; const y_coords = [y, y + height, y + depthOffset.y, y + height + depthOffset.y]; const minX = Math.min(...x_coords); const maxX = Math.max(...x_coords); const minY = Math.min(...y_coords); const maxY = Math.max(...y_coords); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } + if (layer.type === 'pyramid') { const { base, apex } = layer; const x_coords = [base.p1.x, base.p2.x, base.p3.x, base.p4.x, apex.x]; const y_coords = [base.p1.y, base.p2.y, base.p3.y, base.p4.y, apex.y]; const minX = Math.min(...x_coords); const maxX = Math.max(...x_coords); const minY = Math.min(...y_coords); const maxY = Math.max(...y_coords); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } + if (layer.type === 'frustum') { const { cx, baseY, topY, rx1 } = layer; const minX = cx - rx1; const maxX = cx + rx1; const minY = Math.min(baseY, topY); const maxY = Math.max(baseY, topY); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } + if (layer.type === 'truncated-pyramid') { + const points = [...Object.values(layer.base), ...Object.values(layer.top)]; + const x_coords = points.map(p => p.x); + const y_coords = points.map(p => p.y); + const minX = Math.min(...x_coords); + const maxX = Math.max(...x_coords); + const minY = Math.min(...y_coords); + const maxY = Math.max(...y_coords); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + if (layer.type === 'path') { if (layer.points.length === 0) return { x: 0, y: 0, width: 0, height: 0 }; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; layer.points.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } return null; } +export function isPointInRect(point, rect) { return rect && point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height; } +export function isPointInTriangle(pt, v1, v2, v3) { const d1 = sign(pt, v1, v2); const d2 = sign(pt, v2, v3); const d3 = sign(pt, v3, v1); const has_neg = (d1 < 0) || (d2 < 0) || (d3 < 0); const has_pos = (d1 > 0) || (d2 > 0) || (d3 > 0); return !(has_neg && has_pos); } +function sign(p1, p2, p3) { return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y); } +export function isPointInPolygon(point, vertices) { let isInside = false; for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) { const xi = vertices[i].x, yi = vertices[i].y; const xj = vertices[j].x, yj = vertices[j].y; const intersect = ((yi > point.y) !== (yj > point.y)) && (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi); if (intersect) isInside = !isInside; } return isInside; } +export function isPointInParallelogram(point, layer) { const p1 = { x: layer.x, y: layer.y + layer.height }; const p2 = { x: layer.x + layer.width, y: layer.y + layer.height }; const p3 = { x: layer.x + layer.width + layer.slantOffset, y: layer.y }; const p4 = { x: layer.x + layer.slantOffset, y: layer.y }; return isPointInTriangle(point, p1, p2, p3) || isPointInTriangle(point, p1, p3, p4); } +export function isPointInParallelepiped(point, layer) { const { x, y, width, height, depthOffset } = layer; const dx = depthOffset.x; const dy = depthOffset.y; const frontFace = [{x, y}, {x: x+width, y}, {x: x+width, y: y+height}, {x, y: y+height}]; const topFace = [{x, y}, {x: x+dx, y: y+dy}, {x: x+width+dx, y: y+dy}, {x: x+width, y}]; const rightFace = [{x: x+width, y}, {x: x+width+dx, y: y+dy}, {x: x+width+dx, y: y+height+dy}, {x: x+width, y: y+height}]; return isPointInTriangle(point, frontFace[0], frontFace[1], frontFace[2]) || isPointInTriangle(point, frontFace[0], frontFace[2], frontFace[3]) || isPointInTriangle(point, topFace[0], topFace[1], topFace[2]) || isPointInTriangle(point, topFace[0], topFace[2], topFace[3]) || isPointInTriangle(point, rightFace[0], rightFace[1], rightFace[2]) || isPointInTriangle(point, rightFace[0], rightFace[2], rightFace[3]); } +export function isPointInCone(point, layer) { const { cx, baseY, rx, ry, apex } = layer; const baseEllipse = { cx, cy: baseY, rx, ry }; if (isPointInEllipse(point, baseEllipse)) return true; const p1 = { x: cx - rx, y: baseY }; const p2 = { x: cx + rx, y: baseY }; const p3 = apex; return isPointInTriangle(point, p1, p2, p3); } +export function isPointInPyramid(point, layer) { const { base, apex } = layer; return isPointInTriangle(point, base.p1, base.p2, apex) || isPointInTriangle(point, base.p2, base.p3, apex) || isPointInTriangle(point, base.p3, base.p4, apex) || isPointInTriangle(point, base.p4, base.p1, apex); } +export function isPointInFrustum(point, layer) { const { cx, baseY, topY, rx1, ry1, rx2, ry2 } = layer; const baseEllipse = { cx, cy: baseY, rx: rx1, ry: ry1 }; const topEllipse = { cx, cy: topY, rx: rx2, ry: ry2 }; if (isPointInEllipse(point, baseEllipse) || isPointInEllipse(point, topEllipse)) return true; const p1 = {x: cx - rx1, y: baseY}; const p2 = {x: cx + rx1, y: baseY}; const p3 = {x: cx + rx2, y: topY}; const p4 = {x: cx - rx2, y: topY}; return isPointInPolygon(point, [p1, p2, p3, p4]); } +export function isPointOnPath(point, layer) { const threshold = (layer.lineWidth / 2) + 5; for (let i = 0; i < layer.points.length - 1; i++) { const p1 = layer.points[i], p2 = layer.points[i+1]; const dx = p2.x - p1.x, dy = p2.y - p1.y; const lenSq = dx * dx + dy * dy; if (lenSq === 0) { const distSq = (point.x - p1.x)**2 + (point.y - p1.y)**2; if (distSq < threshold**2) return true; continue; } let t = ((point.x - p1.x) * dx + (point.y - p1.y) * dy) / lenSq; t = Math.max(0, Math.min(1, t)); const closestX = p1.x + t * dx, closestY = p1.y + t * dy; const distSq = (point.x - closestX)**2 + (point.y - closestY)**2; if (distSq < threshold**2) return true; } return false; } +export function isPointOnLineSegment(point, layer) { const threshold = (layer.lineWidth / 2) + 5; const p1 = { x: layer.x1, y: layer.y1 }; const p2 = { x: layer.x2, y: layer.y2 }; const dx = p2.x - p1.x, dy = p2.y - p1.y; const lenSq = dx * dx + dy * dy; if (lenSq === 0) { const distSq = (point.x - p1.x)**2 + (point.y - p1.y)**2; return distSq < threshold**2; } let t = ((point.x - p1.x) * dx + (point.y - p1.y) * dy) / lenSq; t = Math.max(0, Math.min(1, t)); const closestX = p1.x + t * dx, closestY = p1.y + t * dy; const distSq = (point.x - closestX)**2 + (point.y - closestY)**2; return distSq < threshold**2; } +export function isPointInEllipse(point, layer) { const { cx, cy, rx, ry } = layer; if (rx <= 0 || ry <= 0) return false; const dx = point.x - cx, dy = point.y - cy; return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1; } +export function doBoxesIntersect(boxA, boxB) { if (!boxA || !boxB) return false; return !(boxB.x > boxA.x + boxA.width || boxB.x + boxB.width < boxA.x || boxB.y > boxA.y + boxA.height || boxB.y + boxB.height < boxA.y); } +export function getGroupBoundingBox(layers) { if (!layers || layers.length === 0) return null; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; layers.forEach(layer => { const box = getBoundingBox(layer); if (box) { minX = Math.min(minX, box.x); minY = Math.min(minY, box.y); maxX = Math.max(maxX, box.x + box.width); maxY = Math.max(maxY, box.y + box.height); } }); if (minX === Infinity) return null; return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } +export function rotatePoint(point, pivot, angle) { + const s = Math.sin(angle); + const c = Math.cos(angle); + const px = point.x - pivot.x; + const py = point.y - pivot.y; + const xnew = px * c - py * s; + const ynew = px * s + py * c; + return { + x: xnew + pivot.x, + y: ynew + pivot.y, + }; +} +// --- END OF FILE geometry.js --- \ No newline at end of file diff --git a/js/help-content.js b/js/help-content.js new file mode 100644 index 0000000..570537c --- /dev/null +++ b/js/help-content.js @@ -0,0 +1,96 @@ +// --- START OF FILE js/help-content.js --- + +const helpContent = { + "general-panel": ` +

Добро пожаловать!

+

Это краткое руководство поможет вам освоить все возможности интерактивной доски. Используйте меню слева для навигации по разделам.

+ `, + "hotkeys-panel": ` +

Горячие клавиши

+

Основные комбинации

+
    +
  • Ctrl + ZОтменить последнее действие
  • +
  • Ctrl + YПовторить отменённое действие
  • +
  • Ctrl + CКопировать выделенные объекты
  • +
  • Ctrl + XВырезать выделенные объекты
  • +
  • Ctrl + VВставить объекты или скриншот
  • +
  • Delete / BackspaceУдалить выделенные объекты
  • +
  • EscСбросить текущее действие или выделение
  • +
+

Инструменты

+
    +
  • VИнструмент "Выделить"
  • +
  • BПереключение между Кистью и Умной кистью
  • +
  • EЛастик
  • +
  • TТекст
  • +
  • SПереключение 2D фигур по кругу
  • +
  • DПереключение 3D фигур по кругу
  • +
  • IДобавить изображение
  • +
+ `, + "navigation-panel": ` +

Навигация по доске

+

Масштаб (Приближение/Отдаление)

+

Вы можете изменять масштаб доски несколькими способами:

+
    +
  • Колесико мышиПлавное масштабирование.
  • +
  • Кнопки + и -Масштабирование по шагам.
  • +
+

Перемещение по доске (Панорамирование)

+

Для свободного перемещения по холсту:

+
    +
  • Средняя кнопка мышиЗажмите колесико и двигайте мышь.
  • +
  • Инструмент "Рука" (H)Выберите на панели, зажмите левую кнопку и двигайте.
  • +
+ `, + "objects-panel": ` +

Работа с объектами

+

Выделение

+
    +
  • КликОдиночное выделение объекта.
  • +
  • Shift + КликДобавить или убрать объект из группы выделенных.
  • +
  • Ctrl + КликИсключить объект из выделения рамкой.
  • +
  • РамкаЗажмите ЛКМ на пустом месте и растяните рамку.
  • +
+

Трансформация

+

После выделения объекта (или группы) вокруг него появится рамка:

+
    +
  • ПеремещениеЗахватите объект мышкой и перетащите.
  • +
  • МасштабированиеПотяните за любой из восьми квадратных маркеров.
  • +
  • ВращениеПотяните за верхний круглый маркер.
  • +
+

Редактирование текста

+
    +
  • Двойной кликВ режиме "Выделить" (V).
  • +
  • Одиночный кликВ режиме "Текст" (T).
  • +
+ `, + "tools-panel": ` +

Инструменты

+
    +
  • КистьРисует обычные линии.
  • +
  • Умная кистьПревращает нарисованную от руки фигуру в идеальную (линию, круг, прямоугольник).
    Совет: чтобы нарисовать прямую линию, задержите курсор на полсекунды в конце.
  • +
  • ТекстПозволяет создавать и редактировать текстовые блоки.
  • +
  • ЛастикУдаляет целый объект по клику на него.
  • +
  • Фигуры (2D/3D)Позволяют рисовать стандартизированные геометрические фигуры.
  • +
+ `, + "advanced-panel": ` +

Продвинутые техники

+

Клавиши-модификаторы

+

Удерживайте эти клавиши во время рисования или трансформации:

+
    +
  • ShiftРисование линии: делает её строго прямой.
    Масштабирование: сохраняет пропорции.
    Вращение: вращает с шагом в 15 градусов.
  • +
  • AltРисование и перемещение: включает "умную" привязку к сетке и к другим объектам.
  • +
+

Вставка изображений

+

Вы можете добавить изображение на доску двумя способами:

+
    +
  • Кнопка (I)Выбрать файл с компьютера.
  • +
  • Ctrl + VВставить скопированный скриншот или картинку.
  • +
+ ` +}; + +export default helpContent; +// --- END OF FILE js/help-content.js --- \ No newline at end of file diff --git a/js/hitTest.js b/js/hitTest.js new file mode 100644 index 0000000..2d48288 --- /dev/null +++ b/js/hitTest.js @@ -0,0 +1,124 @@ +// --- START OF FILE hitTest.js --- + +import * as geo from './geometry.js'; + +export function getLayerAtPosition(pos, layers) { + for (let i = layers.length - 1; i >= 0; i--) { + const layer = layers[i]; + const box = geo.getBoundingBox(layer); + if (!box) continue; + + const rotation = layer.rotation || 0; + const pivot = layer.pivot || { x: 0, y: 0 }; + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + + const s = Math.sin(rotation); + const c = Math.cos(rotation); + const rotatedPivotX = pivot.x * c - pivot.y * s; + const rotatedPivotY = pivot.x * s + pivot.y * c; + + const pivotX = centerX + rotatedPivotX; + const pivotY = centerY + rotatedPivotY; + + const cos = Math.cos(-rotation); + const sin = Math.sin(-rotation); + const dx = pos.x - pivotX; + const dy = pos.y - pivotY; + + const rotatedPos = { + x: dx * cos - dy * sin + pivotX, + y: dx * sin + dy * cos + pivotY + }; + + let hit = false; + if (layer.type === 'path') { hit = geo.isPointOnPath(rotatedPos, layer); } + else if (layer.type === 'text') { hit = geo.isPointInRect(rotatedPos, box); } + else if (layer.type === 'sphere' || layer.type === 'truncated-sphere') { hit = geo.isPointInEllipse(rotatedPos, { cx: layer.cx, cy: layer.cy, rx: layer.r, ry: layer.r }); } + else if (layer.type === 'ellipse') { hit = geo.isPointInEllipse(rotatedPos, layer); } + else if (layer.type === 'line') { hit = geo.isPointOnLineSegment(rotatedPos, layer); } + else if (layer.type === 'parallelogram') { hit = geo.isPointInParallelogram(rotatedPos, layer); } + else if (layer.type === 'triangle') { hit = geo.isPointInTriangle(rotatedPos, layer.p1, layer.p2, layer.p3); } + else if (layer.type === 'cone') { hit = geo.isPointInCone(rotatedPos, layer); } + else if (layer.type === 'parallelepiped') { hit = geo.isPointInParallelepiped(rotatedPos, layer); } + else if (layer.type === 'pyramid') { hit = geo.isPointInPyramid(rotatedPos, layer); } + else if (layer.type === 'trapezoid' || layer.type === 'rhombus') { hit = geo.isPointInPolygon(rotatedPos, [layer.p1, layer.p2, layer.p3, layer.p4]); } + else if (layer.type === 'frustum') { hit = geo.isPointInFrustum(rotatedPos, layer); } + else if (layer.type === 'truncated-pyramid') { + const { base, top } = layer; + const faces = [ [base.p1, base.p2, base.p3, base.p4], [top.p1, top.p2, top.p3, top.p4], [base.p1, base.p2, top.p2, top.p1], [base.p2, base.p3, top.p3, top.p2], [base.p3, base.p4, top.p4, top.p3], [base.p4, base.p1, top.p1, top.p4] ]; + for (const face of faces) { + if (geo.isPointInPolygon(rotatedPos, face)) { hit = true; break; } + } + } else { hit = geo.isPointInRect(rotatedPos, geo.getBoundingBox(layer)); } + if (hit) return layer; + } + return null; +} + +export function getSelectionRotation(layers, groupRotation) { + if (layers.length > 1) { + return groupRotation; + } + if (layers.length === 1) { + return layers[0].rotation || 0; + } + return 0; +} + +export function getHandleAtPosition(pos, layers, zoom, groupRotation) { + if (!layers || layers.length === 0) return null; + + const box = geo.getGroupBoundingBox(layers); + if (!box) return null; + + const handleHitboxSize = 10 / zoom; + const halfHandle = handleHitboxSize / 2; + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + + const isSingleSelection = layers.length === 1; + const layer = isSingleSelection ? layers[0] : null; + + let pivotX = centerX; + let pivotY = centerY; + + if (isSingleSelection && layer && layer.pivot) { + const rotation = layer.rotation || 0; + const rotatedPivotOffset = geo.rotatePoint(layer.pivot, {x:0, y:0}, rotation); + pivotX = centerX + rotatedPivotOffset.x; + pivotY = centerY + rotatedPivotOffset.y; + } + + const rotation = getSelectionRotation(layers, groupRotation); + + if (isSingleSelection && layer) { + if (pos.x >= pivotX - halfHandle && pos.x <= pivotX + halfHandle && pos.y >= pivotY - halfHandle && pos.y <= pivotY + halfHandle) { + return 'pivot'; + } + } + + const rotationHandleY = box.y - 20 / zoom; + const rotationHandlePoint = { x: centerX, y: rotationHandleY }; + + const rotatedHandle = geo.rotatePoint(rotationHandlePoint, { x: pivotX, y: pivotY }, rotation); + + if (pos.x >= rotatedHandle.x - halfHandle && pos.x <= rotatedHandle.x + halfHandle && pos.y >= rotatedHandle.y - halfHandle && pos.y <= rotatedHandle.y + halfHandle) { + return 'rotate'; + } + + const handles = { + topLeft: { x: box.x, y: box.y }, top: { x: centerX, y: box.y }, topRight: { x: box.x + box.width, y: box.y }, + left: { x: box.x, y: centerY }, right: { x: box.x + box.width, y: centerY }, + bottomLeft: { x: box.x, y: box.y + box.height }, bottom: { x: centerX, y: box.y + box.height }, bottomRight: { x: box.x + box.width, y: box.y + box.height }, + }; + + for (const handleName in handles) { + const handlePos = handles[handleName]; + const rotatedHandle = geo.rotatePoint(handlePos, { x: pivotX, y: pivotY }, rotation); + if (pos.x >= rotatedHandle.x - halfHandle && pos.x <= rotatedHandle.x + halfHandle && pos.y >= rotatedHandle.y - halfHandle && pos.y <= rotatedHandle.y + halfHandle) { + return handleName; + } + } + return null; +} \ No newline at end of file diff --git a/js/layerManager.js b/js/layerManager.js new file mode 100644 index 0000000..9d5e184 --- /dev/null +++ b/js/layerManager.js @@ -0,0 +1,87 @@ +/** + * Перемещает выбранные слои на один уровень вперед (выше). + * @param {Array} layers - Полный массив слоев. + * @param {Array} selectedLayers - Массив выбранных слоев. + * @returns {Array} - Новый, отсортированный массив слоев. + */ +export function bringForward(layers, selectedLayers) { + const selectedIds = new Set(selectedLayers.map(l => l.id)); + const newLayers = [...layers]; + + // Идем с конца, чтобы не нарушать индексы при перемещении + for (let i = newLayers.length - 2; i >= 0; i--) { + const currentLayer = newLayers[i]; + const nextLayer = newLayers[i + 1]; + if (selectedIds.has(currentLayer.id) && !selectedIds.has(nextLayer.id)) { + // Меняем местами + [newLayers[i], newLayers[i + 1]] = [newLayers[i + 1], newLayers[i]]; + } + } + return newLayers; +} + +/** + * Перемещает выбранные слои на один уровень назад (ниже). + * @param {Array} layers - Полный массив слоев. + * @param {Array} selectedLayers - Массив выбранных слоев. + * @returns {Array} - Новый, отсортированный массив слоев. + */ +export function sendBackward(layers, selectedLayers) { + const selectedIds = new Set(selectedLayers.map(l => l.id)); + const newLayers = [...layers]; + + // Идем с начала + for (let i = 1; i < newLayers.length; i++) { + const currentLayer = newLayers[i]; + const prevLayer = newLayers[i - 1]; + if (selectedIds.has(currentLayer.id) && !selectedIds.has(prevLayer.id)) { + // Меняем местами + [newLayers[i], newLayers[i - 1]] = [newLayers[i - 1], newLayers[i]]; + } + } + return newLayers; +} + +/** + * Перемещает выбранные слои на самый передний план. + * @param {Array} layers - Полный массив слоев. + * @param {Array} selectedLayers - Массив выбранных слоев. + * @returns {Array} - Новый, отсортированный массив слоев. + */ +export function bringToFront(layers, selectedLayers) { + const selectedIds = new Set(selectedLayers.map(l => l.id)); + const layersToMove = []; + const otherLayers = []; + + layers.forEach(layer => { + if (selectedIds.has(layer.id)) { + layersToMove.push(layer); + } else { + otherLayers.push(layer); + } + }); + + return [...otherLayers, ...layersToMove]; +} + +/** + * Перемещает выбранные слои на самый задний план. + * @param {Array} layers - Полный массив слоев. + * @param {Array} selectedLayers - Массив выбранных слоев. + * @returns {Array} - Новый, отсортированный массив слоев. + */ +export function sendToBack(layers, selectedLayers) { + const selectedIds = new Set(selectedLayers.map(l => l.id)); + const layersToMove = []; + const otherLayers = []; + + layers.forEach(layer => { + if (selectedIds.has(layer.id)) { + layersToMove.push(layer); + } else { + otherLayers.push(layer); + } + }); + + return [...layersToMove, ...otherLayers]; +} \ No newline at end of file diff --git a/js/layers.js b/js/layers.js new file mode 100644 index 0000000..32dcff7 --- /dev/null +++ b/js/layers.js @@ -0,0 +1,14 @@ +let layers = []; +let currentLayerIndex = 0; + +function addLayer() { + layers.push({ + elements: [], + visible: true + }); + currentLayerIndex = layers.length - 1; + console.log('Новый слой добавлен'); +} + +// Создадим первый слой +addLayer(); \ No newline at end of file diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..e5b5fc1 --- /dev/null +++ b/js/main.js @@ -0,0 +1,1113 @@ +// --- START OF FILE main.js --- + +import { initializeCanvas } from './canvas.js'; +import { getBoundingBox, getGroupBoundingBox } from './geometry.js'; +import { getSelectionRotation } from './hitTest.js'; +import { initializeToolbar } from './toolbar.js'; +import { getEditorTextarea } from './text.js'; +import helpContent from './help-content.js'; + +const history = []; let historyIndex = -1; +function cloneLayers(layers) { return layers.map(l => { const n = { ...l }; if (l.points) { n.points = l.points.map(p => ({ ...p })); } return n; }); } + +let clipboard = null; + +document.addEventListener('DOMContentLoaded', () => { + const backgroundCanvas = document.getElementById('backgroundCanvas'); const drawingCanvas = document.getElementById('drawingBoard'); const ctx = drawingCanvas.getContext('2d'); + const undoBtn = document.getElementById('undoBtn'); const redoBtn = document.getElementById('redoBtn'); + let canvasState; + function updateUndoRedoButtons() { undoBtn.disabled = historyIndex <= 0; redoBtn.disabled = historyIndex >= history.length - 1; } + + function saveState(layers) { + if (historyIndex < history.length - 1) { + history.splice(historyIndex + 1); + } + if (history.length > 50) { + history.shift(); + } + history.push(cloneLayers(layers)); + historyIndex = history.length - 1; + updateUndoRedoButtons(); + + try { + const serializableLayers = layers.map(layer => { + if (layer.type === 'image' && layer.image instanceof HTMLImageElement) { + const newLayer = { ...layer }; + if (!newLayer.src || !newLayer.src.startsWith('data:')) { + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = newLayer.image.naturalWidth; + tempCanvas.height = newLayer.image.naturalHeight; + const tempCtx = tempCanvas.getContext('2d'); + tempCtx.drawImage(newLayer.image, 0, 0); + newLayer.src = tempCanvas.toDataURL(); + } + delete newLayer.image; + return newLayer; + } + return layer; + }); + + const dataToSave = { + viewState: { + panX: canvasState ? canvasState.panX : 0, + panY: canvasState ? canvasState.panY : 0, + zoom: canvasState ? canvasState.zoom : 1 + }, + layers: serializableLayers + }; + + localStorage.setItem('drawingBoard', JSON.stringify(dataToSave)); + } catch (e) { + console.error("Не удалось сохранить состояние доски:", e); + } + } + + function undo() { if (historyIndex > 0) { historyIndex--; canvasState.layers = cloneLayers(history[historyIndex]); canvasState.selectedLayers = []; redraw(); updateUndoRedoButtons(); } } + function redo() { if (historyIndex < history.length - 1) { historyIndex++; canvasState.layers = cloneLayers(history[historyIndex]); canvasState.selectedLayers = []; redraw(); updateUndoRedoButtons(); } } + const setupCanvases = () => { const width = window.innerWidth, height = window.innerHeight;[backgroundCanvas, drawingCanvas].forEach(c => { c.width = width; c.height = height; }); drawBackground(backgroundCanvas, canvasState); if (canvasState) redraw(); }; + + const redraw = () => { + redrawCanvas(canvasState); + drawBackground(backgroundCanvas, canvasState); + }; + + const shapes2DOrder = ['rect', 'ellipse', 'line', 'parallelogram', 'triangle', 'trapezoid', 'rhombus']; + const shapes3DOrder = ['sphere', 'cone', 'parallelepiped', 'pyramid', 'frustum', 'truncated-pyramid', 'truncated-sphere']; + + function updateSubToolbarVisibility() { + if (!canvasState) return; + + const drawingSubToolbar = document.getElementById('drawingSubToolbar'); + drawingSubToolbar.classList.add('hidden'); + + const hasSelection = canvasState.selectedLayers.length > 0; + const activeTool = canvasState.activeTool; + + const drawableTools = ['brush', 'smart-brush', ...shapes2DOrder, ...shapes3DOrder]; + const isDrawingContext = drawableTools.includes(activeTool) || (hasSelection && canvasState.selectedLayers.some(l => l.type !== 'text')); + + if (isDrawingContext) { + drawingSubToolbar.classList.remove('hidden'); + if (hasSelection) { + const layer = canvasState.selectedLayers.find(l => l.hasOwnProperty('lineWidth')); + if (layer) { + document.getElementById('lineWidthSlider').value = layer.lineWidth; + } + + const colorLayer = canvasState.selectedLayers.find(l => l.hasOwnProperty('color')); + if(colorLayer) { + const colorPalette = document.getElementById('colorPalette'); + colorPalette.querySelectorAll('.active').forEach(el => el.classList.remove('active')); + const newActive = colorPalette.querySelector(`[data-color="${colorLayer.color}"]`); + if(newActive) newActive.classList.add('active'); + } + } + } + } + + initializeFloatingTextToolbar(); + + canvasState = initializeCanvas(drawingCanvas, ctx, redraw, saveState, updateSubToolbarVisibility); + initializeToolbar(canvasState, redraw, updateSubToolbarVisibility); + + initializeCustomTooltips(); + + function loadState(projectData = null) { + let dataToParse = projectData; + + if (!dataToParse) { + dataToParse = localStorage.getItem('drawingBoard') || localStorage.getItem('drawingBoardLayers'); + } + + if (!dataToParse) { + saveState([]); + return; + } + + try { + const loadedData = JSON.parse(dataToParse); + let layersToLoad; + let viewState = null; + + if (Array.isArray(loadedData)) { + layersToLoad = loadedData; + } else { + layersToLoad = loadedData.layers; + viewState = loadedData.viewState; + } + + if (viewState) { + canvasState.panX = viewState.panX || 0; + canvasState.panY = viewState.panY || 0; + canvasState.zoom = viewState.zoom || 1; + } + + const imageLoadPromises = []; + if (layersToLoad) { + layersToLoad.forEach(layer => { + if (layer.type === 'image' && layer.src) { + const img = new Image(); + const promise = new Promise((resolve, reject) => { + img.onload = () => { + layer.image = img; + delete layer.src; + resolve(); + }; + img.onerror = (err) => { + console.error('Не удалось загрузить изображение:', layer.src, err); + reject(err); + }; + }); + img.src = layer.src; + imageLoadPromises.push(promise); + } + }); + } + + Promise.all(imageLoadPromises).then(() => { + canvasState.layers = layersToLoad || []; + history.length = 0; + historyIndex = -1; + saveState(canvasState.layers); + redraw(); + }).catch(() => { + console.error("Не все изображения удалось загрузить."); + canvasState.layers = layersToLoad.filter(l => l.type !== 'image' || l.image); + saveState(canvasState.layers); + redraw(); + }); + + } catch (e) { + console.error("Не удалось загрузить состояние:", e); + saveState([]); + } + } + + if (canvasState.activeTool === 'brush') { + canvasState.canvas.classList.add('cursor-brush'); + } else if (canvasState.activeTool === 'eraser') { + canvasState.canvas.classList.add('cursor-eraser'); + } + + updateSubToolbarVisibility(); + + setupCanvases(); + undoBtn.addEventListener('click', undo); + redoBtn.addEventListener('click', redo); + + loadState(); + + const zoomInBtn = document.getElementById('zoomInBtn'); + const zoomOutBtn = document.getElementById('zoomOutBtn'); + + zoomInBtn.addEventListener('click', () => { + if (canvasState && typeof canvasState.performZoom === 'function') { + canvasState.performZoom('in'); + } + }); + + zoomOutBtn.addEventListener('click', () => { + if (canvasState && typeof canvasState.performZoom === 'function') { + canvasState.performZoom('out'); + } + }); + + document.getElementById('exportJpgBtn').addEventListener('click', (e) => { + e.preventDefault(); + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = drawingCanvas.width; + tempCanvas.height = drawingCanvas.height; + const tempCtx = tempCanvas.getContext('2d'); + + tempCtx.fillStyle = window.getComputedStyle(backgroundCanvas).backgroundColor || '#FFFFFF'; + tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); + + tempCtx.drawImage(backgroundCanvas, 0, 0); + tempCtx.drawImage(drawingCanvas, 0, 0); + const link = document.createElement('a'); + link.download = 'my-board.jpg'; + link.href = tempCanvas.toDataURL('image/jpeg', 0.95); + link.click(); + }); + + document.getElementById('saveProjectBtn').addEventListener('click', (e) => { + e.preventDefault(); + const serializableLayers = canvasState.layers.map(layer => { + if (layer.type === 'image' && layer.image instanceof HTMLImageElement) { + const newLayer = { ...layer }; + if (!newLayer.src || !newLayer.src.startsWith('data:')) { + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = newLayer.image.naturalWidth; + tempCanvas.height = newLayer.image.naturalHeight; + const tempCtx = tempCanvas.getContext('2d'); + tempCtx.drawImage(newLayer.image, 0, 0); + newLayer.src = tempCanvas.toDataURL(); + } + delete newLayer.image; + return newLayer; + } + return layer; + }); + + const projectData = { + viewState: { + panX: canvasState.panX, + panY: canvasState.panY, + zoom: canvasState.zoom + }, + layers: serializableLayers + }; + + const dataStr = JSON.stringify(projectData); + const blob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.download = 'project.board'; + link.href = url; + link.click(); + URL.revokeObjectURL(url); + }); + + const projectUploadInput = document.getElementById('projectUpload'); + document.getElementById('openProjectBtn').addEventListener('click', (e) => { + e.preventDefault(); + projectUploadInput.click(); + }); + + projectUploadInput.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + loadState(event.target.result); + }; + reader.onerror = () => { + console.error("Не удалось прочитать файл."); + alert("Ошибка при чтении файла."); + } + reader.readAsText(file); + e.target.value = null; + }); + + window.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') { + return; + } + + if (e.ctrlKey || e.metaKey) { + switch(e.code) { + case 'KeyZ': e.preventDefault(); undo(); break; + case 'KeyY': e.preventDefault(); redo(); break; + case 'KeyC': + e.preventDefault(); + if (canvasState.selectedLayers.length > 0) { + clipboard = JSON.stringify(canvasState.selectedLayers.map(layer => { + const clonedLayer = {...layer}; + if (layer.type === 'image' && layer.image instanceof HTMLImageElement) { + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = layer.image.naturalWidth; + tempCanvas.height = layer.image.naturalHeight; + const tempCtx = tempCanvas.getContext('2d'); + tempCtx.drawImage(layer.image, 0, 0); + clonedLayer.src = tempCanvas.toDataURL(); + } + delete clonedLayer.image; + return clonedLayer; + })); + } + break; + case 'KeyX': + e.preventDefault(); + if (canvasState.selectedLayers.length > 0) { + clipboard = JSON.stringify(canvasState.selectedLayers.map(layer => { + const clonedLayer = {...layer}; + if (layer.type === 'image' && layer.image instanceof HTMLImageElement) { + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = layer.image.naturalWidth; + tempCanvas.height = layer.image.naturalHeight; + const tempCtx = tempCanvas.getContext('2d'); + tempCtx.drawImage(layer.image, 0, 0); + clonedLayer.src = tempCanvas.toDataURL(); + } + delete clonedLayer.image; + return clonedLayer; + })); + const idsToDelete = new Set(canvasState.selectedLayers.map(l => l.id)); + canvasState.layers = canvasState.layers.filter(layer => !idsToDelete.has(layer.id)); + canvasState.selectedLayers = []; + saveState(canvasState.layers); + redraw(); + canvasState.updateFloatingToolbar(); + } + break; + } + return; + } + + switch(e.code) { + case 'Escape': + e.preventDefault(); + if (canvasState.currentAction.startsWith('drawing')) { + canvasState.currentAction = 'none'; + canvasState.tempLayer = null; + redraw(); + } + else if (canvasState.isEditingText) { + const textarea = getEditorTextarea(); + if(textarea) textarea.blur(); + } + else if (canvasState.selectedLayers.length > 0) { + canvasState.selectedLayers = []; + redraw(); + canvasState.updateFloatingToolbar(); + } + break; + + case 'Delete': + case 'Backspace': + if (canvasState.selectedLayers.length > 0) { + e.preventDefault(); + const idsToDelete = new Set(canvasState.selectedLayers.map(l => l.id)); + canvasState.layers = canvasState.layers.filter(layer => !idsToDelete.has(layer.id)); + canvasState.selectedLayers = []; + saveState(canvasState.layers); + redraw(); + canvasState.updateFloatingToolbar(); + } + break; + + case 'KeyV': + e.preventDefault(); + document.querySelector('button[data-tool="select"]')?.click(); + drawingCanvas.focus({ preventScroll: true }); + break; + case 'KeyB': + e.preventDefault(); + const currentTool = canvasState.activeTool; + if (currentTool === 'brush') { + document.querySelector('button[data-tool="smart-brush"]')?.click(); + } else { + document.querySelector('button[data-tool="brush"]')?.click(); + } + drawingCanvas.focus({ preventScroll: true }); + break; + case 'KeyE': + e.preventDefault(); + document.querySelector('button[data-tool="eraser"]')?.click(); + drawingCanvas.focus({ preventScroll: true }); + break; + case 'KeyT': + e.preventDefault(); + document.querySelector('button[data-tool="text"]')?.click(); + drawingCanvas.focus({ preventScroll: true }); + break; + case 'KeyS': + e.preventDefault(); + const current2DIndex = shapes2DOrder.indexOf(canvasState.activeTool); + const next2DIndex = (current2DIndex === -1) ? 0 : (current2DIndex + 1) % shapes2DOrder.length; + const next2DTool = shapes2DOrder[next2DIndex]; + const shape2DLink = document.querySelector(`#shapes2DOptions a[data-tool="${next2DTool}"]`); + if (shape2DLink) { + shape2DLink.click(); + } + drawingCanvas.focus({ preventScroll: true }); + break; + case 'KeyD': + e.preventDefault(); + const current3DIndex = shapes3DOrder.indexOf(canvasState.activeTool); + const next3DIndex = (current3DIndex === -1) ? 0 : (current3DIndex + 1) % shapes3DOrder.length; + const next3DTool = shapes3DOrder[next3DIndex]; + const shape3DLink = document.querySelector(`#shapes3DOptions a[data-tool="${next3DTool}"]`); + if (shape3DLink) { + shape3DLink.click(); + } + drawingCanvas.focus({ preventScroll: true }); + break; + case 'KeyI': + e.preventDefault(); + document.getElementById('addImageBtn')?.click(); + drawingCanvas.focus({ preventScroll: true }); + break; + } + }); + + window.addEventListener('paste', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') { + return; + } + e.preventDefault(); + + let imagePasted = false; + const items = e.clipboardData.items; + + for (const item of items) { + if (item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile(); + const centerPos = { + x: (drawingCanvas.width / 2 - canvasState.panX) / canvasState.zoom, + y: (drawingCanvas.height / 2 - canvasState.panY) / canvasState.zoom + }; + const reader = new FileReader(); + reader.onload = (event) => { + const img = new Image(); + img.onload = () => { + const newLayer = { type: 'image', image: img, x: centerPos.x - img.width / 2, y: centerPos.y - img.height / 2, width: img.width, height: img.height, id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } }; + canvasState.layers.push(newLayer); + canvasState.selectedLayers = [newLayer]; + const selectButton = document.querySelector('button[data-tool="select"]'); + if (selectButton) { + selectButton.click(); + } + saveState(canvasState.layers); + redraw(); + canvasState.updateFloatingToolbar(); + }; + img.src = event.target.result; + }; + reader.readAsDataURL(file); + imagePasted = true; + break; + } + } + + if (!imagePasted && clipboard) { + try { + const layersToPaste = JSON.parse(clipboard); + const newLayers = []; + + layersToPaste.forEach(layer => { + const newLayer = { ...layer }; + newLayer.id = Date.now() + Math.random(); + + const offset = 20 / canvasState.zoom; + + if (newLayer.x !== undefined) { newLayer.x += offset; newLayer.y += offset; } + if (newLayer.cx !== undefined) { newLayer.cx += offset; newLayer.cy += offset; } + if (newLayer.x1 !== undefined) { newLayer.x1 += offset; newLayer.y1 += offset; newLayer.x2 += offset; newLayer.y2 += offset; } + if (newLayer.points) { newLayer.points.forEach(p => { p.x += offset; p.y += offset; }); } + if (newLayer.p1) { + const points = ['p1', 'p2', 'p3', 'p4', 'base', 'top', 'apex']; + for(const key of points){ + if(newLayer[key]?.x) { newLayer[key].x += offset; newLayer[key].y += offset;} + else if(typeof newLayer[key] === 'object'){ + for(const subKey in newLayer[key]){ + if(newLayer[key][subKey]?.x) { newLayer[key][subKey].x += offset; newLayer[key][subKey].y += offset;} + } + } + } + } + + if (newLayer.type === 'image' && newLayer.src) { + const img = new Image(); + img.onload = () => { + newLayer.image = img; + redraw(); + } + img.src = newLayer.src; + } + + canvasState.layers.push(newLayer); + newLayers.push(newLayer); + }); + + canvasState.selectedLayers = newLayers; + + const selectButton = document.querySelector('button[data-tool="select"]'); + if (selectButton) { + selectButton.click(); + } + + saveState(canvasState.layers); + redraw(); + canvasState.updateFloatingToolbar(); + + } catch (err) { + console.error("Не удалось вставить из буфера обмена:", err); + } + } + }); + + window.addEventListener('resize', () => { + setupCanvases(); + if (canvasState) canvasState.updateFloatingToolbar(); + + const toolbarWrapper = document.getElementById('toolbarWrapper'); + if (toolbarWrapper) { + toolbarWrapper.style.left = ''; + toolbarWrapper.style.transform = ''; + } + }); + + function initializeFloatingTextToolbar() { + const toolbar = document.getElementById('floating-text-toolbar'); + const colorPalette = document.getElementById('colorPalette'); + const floatingPalette = document.getElementById('floatingColorPalette'); + const colorPicker = document.getElementById('floating-color-picker'); + floatingPalette.innerHTML = colorPalette.innerHTML; + + toolbar.addEventListener('mousedown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') { + return; + } + e.preventDefault(); + const textarea = getEditorTextarea(); + if (textarea) { + textarea.style.pointerEvents = 'none'; + } + }); + + document.addEventListener('mouseup', () => { + const textarea = getEditorTextarea(); + if (textarea) { + textarea.style.pointerEvents = 'auto'; + } + }); + + const applyChange = (callback) => { + if (canvasState) { + const layer = canvasState.isEditingText + ? canvasState.layers.find(l => l.isEditing) + : (canvasState.selectedLayers.length === 1 && canvasState.selectedLayers[0].type === 'text' ? canvasState.selectedLayers[0] : null); + + if (layer) { + callback(layer); + canvasState.activeFontFamily = layer.fontFamily; + canvasState.activeFontSize = layer.fontSize; + canvasState.activeFontWeight = layer.fontWeight; + canvasState.activeFontStyle = layer.fontStyle; + canvasState.activeTextDecoration = layer.textDecoration; + canvasState.activeTextAlign = layer.align; + canvasState.activeColor = layer.color; + saveState(canvasState.layers); + redraw(); + + if (canvasState.isEditingText && canvasState.updateTextEditorStyle) { + canvasState.updateTextEditorStyle(layer); + } + + canvasState.updateFloatingToolbar(); + } + } + }; + + toolbar.addEventListener('click', e => { + const button = e.target.closest('button'); + if (button) { + const action = button.dataset.action; + if (!action || action === 'pick-color') return; + applyChange(layer => { + switch(action) { + case 'align-left': layer.align = 'left'; break; + case 'align-center': layer.align = 'center'; break; + case 'align-right': layer.align = 'right'; break; + case 'font-bold': layer.fontWeight = layer.fontWeight === 'bold' ? 'normal' : 'bold'; break; + case 'font-italic': layer.fontStyle = layer.fontStyle === 'italic' ? 'normal' : 'italic'; break; + case 'font-underline': layer.textDecoration = layer.textDecoration === 'underline' ? 'none' : 'underline'; break; + } + }); + } + }); + + colorPicker.addEventListener('click', (e) => { + e.stopPropagation(); + colorPicker.classList.toggle('active'); + }); + + floatingPalette.addEventListener('click', e => { + const colorDot = e.target.closest('.color-dot'); + if (colorDot) { + const newColor = colorDot.dataset.color; + applyChange(layer => { + layer.color = newColor; + const mainPalette = document.getElementById('colorPalette'); + mainPalette.querySelectorAll('.active').forEach(el => el.classList.remove('active')); + const newActive = mainPalette.querySelector(`[data-color="${newColor}"]`); + if (newActive) newActive.classList.add('active'); + }); + } + }); + + document.addEventListener('click', () => { + if(colorPicker.classList.contains('active')) { + colorPicker.classList.remove('active'); + } + }); + + document.getElementById('fontFamilySelect').addEventListener('change', e => { + applyChange(layer => layer.fontFamily = e.target.value); + }); + + document.getElementById('floatingFontSizeInput').addEventListener('input', e => { + applyChange(layer => layer.fontSize = parseInt(e.target.value, 10) || 30); + }); + } + + function initializeCustomTooltips() { + const tooltip = document.getElementById('custom-tooltip'); + if (!tooltip) return; + + document.body.addEventListener('mouseover', (e) => { + const target = e.target.closest('[title]'); + if (!target) return; + + const titleText = target.getAttribute('title'); + if (!titleText) return; + + target.dataset.originalTitle = titleText; + target.removeAttribute('title'); + + tooltip.textContent = titleText; + tooltip.classList.add('visible'); + + const targetRect = target.getBoundingClientRect(); + tooltip.style.left = `${targetRect.left + targetRect.width / 2}px`; + tooltip.style.top = `${targetRect.top}px`; + }); + + document.body.addEventListener('mouseout', (e) => { + const target = e.target.closest('[data-original-title]'); + if (!target) return; + + target.setAttribute('title', target.dataset.originalTitle); + target.removeAttribute('data-original-title'); + + tooltip.classList.remove('visible'); + }); + } + + function initializeHelpModal() { + const helpBtn = document.getElementById('helpBtn'); + const helpModal = document.getElementById('helpModal'); + const closeHelpBtn = document.getElementById('closeHelpBtn'); + + for (const panelId in helpContent) { + const panel = document.getElementById(panelId); + if (panel) { + panel.innerHTML = helpContent[panelId]; + } + } + + function openModal() { helpModal.classList.remove('hidden'); } + function closeModal() { helpModal.classList.add('hidden'); } + + helpBtn.addEventListener('click', (e) => { e.preventDefault(); openModal(); }); + closeHelpBtn.addEventListener('click', closeModal); + helpModal.addEventListener('click', (e) => { if (e.target === helpModal) { closeModal(); } }); + + const sidebarButtons = helpModal.querySelectorAll('.sidebar-button'); + const panels = helpModal.querySelectorAll('.modal-panel'); + sidebarButtons.forEach(button => { + button.addEventListener('click', () => { + sidebarButtons.forEach(btn => btn.classList.remove('active')); + panels.forEach(panel => panel.classList.remove('active')); + button.classList.add('active'); + const panelId = button.getAttribute('data-panel'); + document.getElementById(panelId).classList.add('active'); + }); + }); + } + initializeHelpModal(); + + const settingsBtn = document.getElementById('settingsBtn'); + const settingsModal = document.getElementById('settingsModal'); + const okBtn = document.getElementById('okSettings'); + const cancelBtn = document.getElementById('cancelSettings'); + const themeSelect = document.getElementById('theme-select'); + const backgroundStyleSelect = document.getElementById('background-style-select'); + const smoothingSlider = document.getElementById('smoothing-slider'); + const smoothingValue = document.getElementById('smoothing-value'); + + function applyAndSaveSettings() { + const theme = themeSelect.value; + const backgroundStyle = backgroundStyleSelect.value; + const smoothing = smoothingSlider.value; + + document.body.classList.toggle('dark-theme', theme === 'dark'); + + localStorage.setItem('boardTheme', theme); + localStorage.setItem('boardBackgroundStyle', backgroundStyle); + localStorage.setItem('boardSmoothing', smoothing); + + if (canvasState) { + canvasState.smoothingAmount = parseInt(smoothing, 10); + } + redraw(); + } + + function loadSettings() { + const savedTheme = localStorage.getItem('boardTheme') || 'light'; + const savedStyle = localStorage.getItem('boardBackgroundStyle') || 'dot'; + const savedSmoothing = localStorage.getItem('boardSmoothing') || '2'; + + themeSelect.value = savedTheme; + backgroundStyleSelect.value = savedStyle; + smoothingSlider.value = savedSmoothing; + smoothingValue.textContent = savedSmoothing; + + document.body.classList.toggle('dark-theme', savedTheme === 'dark'); + + if (canvasState) { + canvasState.smoothingAmount = parseInt(savedSmoothing, 10); + } + redraw(); + } + + smoothingSlider.addEventListener('input', () => { + smoothingValue.textContent = smoothingSlider.value; + }); + + function closeModal() { settingsModal.classList.add('hidden'); } + settingsBtn.addEventListener('click', (e) => { e.preventDefault(); loadSettings(); settingsModal.classList.remove('hidden'); }); + okBtn.addEventListener('click', () => { applyAndSaveSettings(); closeModal(); }); + cancelBtn.addEventListener('click', closeModal); + settingsModal.addEventListener('click', (e) => { if (e.target === settingsModal) { closeModal(); } }); + + const settingsSidebarButtons = settingsModal.querySelectorAll('.sidebar-button'); + const settingsPanels = settingsModal.querySelectorAll('.modal-panel'); + settingsSidebarButtons.forEach(button => { + button.addEventListener('click', () => { + settingsSidebarButtons.forEach(btn => btn.classList.remove('active')); + settingsPanels.forEach(panel => panel.classList.remove('active')); + button.classList.add('active'); + const panelId = button.getAttribute('data-panel'); + document.getElementById(panelId).classList.add('active'); + }); + }); + + loadSettings(); +}); + +function rotatePoint(point, pivot, angle) { + const s = Math.sin(angle); + const c = Math.cos(angle); + const px = point.x - pivot.x; + const py = point.y - pivot.y; + const xnew = px * c - py * s; + const ynew = px * s + py * c; + return { + x: xnew + pivot.x, + y: ynew + pivot.y, + }; +} + +function wrapText(ctx, text, maxWidth) { + const manualLines = text.split('\n'); + let allLines = []; + + manualLines.forEach(manualLine => { + if (manualLine === '') { + allLines.push(''); + return; + } + const words = manualLine.split(' '); + let currentLine = ''; + for (const word of words) { + const testLine = currentLine === '' ? word : `${currentLine} ${word}`; + const metrics = ctx.measureText(testLine); + + if (metrics.width > maxWidth && currentLine !== '') { + allLines.push(currentLine); + currentLine = word; + } else { + currentLine = testLine; + } + } + allLines.push(currentLine); + }); + + return allLines; +} + + +function drawLayer(ctx, layer) { + if (!layer) return; + ctx.save(); + + const rotation = layer.rotation || 0; + if (rotation) { + const box = getBoundingBox(layer); + if (box) { + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + + const pivot = layer.pivot || { x: 0, y: 0 }; + + const rotatedPivotOffset = rotatePoint(pivot, {x: 0, y: 0}, rotation); + + const pivotX = centerX + rotatedPivotOffset.x; + const pivotY = centerY + rotatedPivotOffset.y; + + ctx.translate(pivotX, pivotY); + ctx.rotate(rotation); + ctx.translate(-pivotX, -pivotY); + } + } + + ctx.strokeStyle = layer.color; + ctx.fillStyle = layer.color; + ctx.lineWidth = layer.lineWidth; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + if (layer.isEditing) { + ctx.globalAlpha = 0; + } + + if (layer.type === 'path') { + if (layer.points.length < 1) { ctx.restore(); return; } + if (layer.points.length === 1) { + ctx.beginPath(); + const point = layer.points[0]; + const pressure = point.pressure || 0.5; + const radius = Math.max(0.5, (layer.lineWidth * pressure) / 2); + ctx.arc(point.x, point.y, radius, 0, 2 * Math.PI); + ctx.fill(); + } else { + const points = layer.points; + if (points.length < 3) { + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i++) { + const pressure = points[i-1].pressure || 0.5; + ctx.lineWidth = Math.max(1, layer.lineWidth * pressure); + ctx.lineTo(points[i].x, points[i].y); + } + ctx.stroke(); + } else { + for (let i = 0; i < points.length - 1; i++) { + const p0 = i > 0 ? points[i - 1] : points[i]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = i < points.length - 2 ? points[i + 2] : p2; + + const cp1 = { x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6 }; + const cp2 = { x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6 }; + + ctx.beginPath(); + ctx.moveTo(p1.x, p1.y); + + const pressure = p1.pressure || 0.5; + ctx.lineWidth = Math.max(1, layer.lineWidth * pressure); + + ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, p2.x, p2.y); + ctx.stroke(); + } + } + } + } + else if (layer.type === 'rect') { ctx.beginPath(); ctx.strokeRect(layer.x, layer.y, layer.width, layer.height); } + else if (layer.type === 'ellipse') { ctx.beginPath(); ctx.ellipse(layer.cx, layer.cy, layer.rx, layer.ry, 0, 0, 2 * Math.PI); ctx.stroke(); } + else if (layer.type === 'line') { ctx.beginPath(); ctx.moveTo(layer.x1, layer.y1); ctx.lineTo(layer.x2, layer.y2); ctx.stroke(); } + else if (layer.type === 'parallelogram') { ctx.beginPath(); ctx.moveTo(layer.x, layer.y + layer.height); ctx.lineTo(layer.x + layer.width, layer.y + layer.height); ctx.lineTo(layer.x + layer.width + layer.slantOffset, layer.y); ctx.lineTo(layer.x + layer.slantOffset, layer.y); ctx.closePath(); ctx.stroke(); } + else if (layer.type === 'triangle') { ctx.beginPath(); ctx.moveTo(layer.p1.x, layer.p1.y); ctx.lineTo(layer.p2.x, layer.p2.y); ctx.lineTo(layer.p3.x, layer.p3.y); ctx.closePath(); ctx.stroke(); } + else if (layer.type === 'text') { + const fontWeight = layer.fontWeight || 'normal'; + const fontStyle = layer.fontStyle || 'normal'; + ctx.font = `${fontStyle} ${fontWeight} ${layer.fontSize}px ${layer.fontFamily}`; + ctx.textBaseline = 'top'; + + const lines = wrapText(ctx, layer.content, layer.width); + + const align = layer.align || 'left'; + ctx.textAlign = align; + let x; + if (align === 'center') { + x = layer.x + layer.width / 2; + } else if (align === 'right') { + x = layer.x + layer.width; + } else { + x = layer.x; + } + + const lineHeight = layer.fontSize * 1.2; + lines.forEach((line, index) => { + const y = layer.y + index * lineHeight; + ctx.fillText(line, x, y); + + if (layer.textDecoration === 'underline') { + const metrics = ctx.measureText(line); + const lineY = y + layer.fontSize + 2; + + let startX, endX; + if (align === 'center') { + startX = x - metrics.width / 2; + endX = x + metrics.width / 2; + } else if (align === 'right') { + startX = x - metrics.width; + endX = x; + } else { // left + startX = x; + endX = x + metrics.width; + } + ctx.beginPath(); + ctx.moveTo(startX, lineY); + ctx.lineTo(endX, lineY); + ctx.strokeStyle = layer.color; + ctx.lineWidth = Math.max(1, layer.fontSize / 15); + ctx.stroke(); + } + }); + } + else if (layer.type === 'sphere') { const { cx, cy, r } = layer; const equatorRy = r * 0.3, meridianRx = r * 0.5; ctx.setLineDash([]); ctx.beginPath(); ctx.arc(cx, cy, r, 0, 2 * Math.PI); ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx, cy, r, equatorRy, 0, 0, Math.PI); ctx.stroke(); ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.ellipse(cx, cy, r, equatorRy, 0, Math.PI, 2 * Math.PI); ctx.stroke(); ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.ellipse(cx, cy, meridianRx, r, 0, -Math.PI / 2, Math.PI / 2); ctx.stroke(); ctx.setLineDash([]); ctx.beginPath(); ctx.ellipse(cx, cy, meridianRx, r, 0, Math.PI / 2, 3 * Math.PI / 2); ctx.stroke(); ctx.setLineDash([]); } + else if (layer.type === 'cone') { const { cx, baseY, rx, ry, apex } = layer; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(cx - rx, baseY); ctx.lineTo(apex.x, apex.y); ctx.lineTo(cx + rx, baseY); ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx, baseY, rx, ry, 0, 0, Math.PI); ctx.stroke(); ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.ellipse(cx, baseY, rx, ry, 0, Math.PI, 2 * Math.PI); ctx.stroke(); ctx.setLineDash([]); } + else if (layer.type === 'parallelepiped') { const { x, y, width, height, depthOffset } = layer; const dx = depthOffset.x, dy = depthOffset.y; const p = [ {x, y}, {x: x + width, y}, {x: x + width, y: y + height}, {x, y: y + height}, {x: x + dx, y: y + dy}, {x: x + width + dx, y: y + dy}, {x: x + width + dx, y: y + height + dy}, {x: x + dx, y: y + height + dy} ]; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[1].x, p[1].y); ctx.lineTo(p[2].x, p[2].y); ctx.lineTo(p[3].x, p[3].y); ctx.closePath(); ctx.moveTo(p[1].x, p[1].y); ctx.lineTo(p[5].x, p[5].y); ctx.lineTo(p[6].x, p[6].y); ctx.lineTo(p[2].x, p[2].y); ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[4].x, p[4].y); ctx.lineTo(p[5].x, p[5].y); ctx.stroke(); ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.moveTo(p[3].x, p[3].y); ctx.lineTo(p[7].x, p[7].y); ctx.lineTo(p[4].x, p[4].y); ctx.moveTo(p[6].x, p[6].y); ctx.lineTo(p[7].x, p[7].y); ctx.stroke(); ctx.setLineDash([]); } + else if (layer.type === 'pyramid') { + const { base, apex } = layer; + const p = [ base.p1, base.p2, base.p3, base.p4 ]; + + ctx.setLineDash([5, 5]); + ctx.beginPath(); + ctx.moveTo(p[3].x, p[3].y); ctx.lineTo(p[0].x, p[0].y); + ctx.moveTo(p[3].x, p[3].y); ctx.lineTo(p[2].x, p[2].y); + ctx.moveTo(p[3].x, p[3].y); ctx.lineTo(apex.x, apex.y); + ctx.stroke(); + + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[1].x, p[1].y); + ctx.moveTo(p[1].x, p[1].y); ctx.lineTo(p[2].x, p[2].y); + ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(apex.x, apex.y); + ctx.moveTo(p[1].x, p[1].y); ctx.lineTo(apex.x, apex.y); + ctx.moveTo(p[2].x, p[2].y); ctx.lineTo(apex.x, apex.y); + ctx.stroke(); + } + else if (layer.type === 'trapezoid' || layer.type === 'rhombus') { ctx.beginPath(); ctx.moveTo(layer.p1.x, layer.p1.y); ctx.lineTo(layer.p2.x, layer.p2.y); ctx.lineTo(layer.p3.x, layer.p3.y); ctx.lineTo(layer.p4.x, layer.p4.y); ctx.closePath(); ctx.stroke(); } + else if (layer.type === 'frustum') { const { cx, baseY, topY, rx1, ry1, rx2, ry2 } = layer; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(cx - rx1, baseY); ctx.lineTo(cx - rx2, topY); ctx.moveTo(cx + rx1, baseY); ctx.lineTo(cx + rx2, topY); ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx, baseY, rx1, ry1, 0, 0, Math.PI); ctx.stroke(); ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.ellipse(cx, baseY, rx1, ry1, 0, Math.PI, 2 * Math.PI); ctx.stroke(); ctx.setLineDash([]); ctx.beginPath(); ctx.ellipse(cx, topY, rx2, ry2, 0, 0, 2 * Math.PI); ctx.stroke(); ctx.setLineDash([]); } + else if (layer.type === 'truncated-sphere') { const { cx, cy, r, cutY, cutR, cutRy } = layer; const sinAngle = (cutY - cy) / r; const clampedSinAngle = Math.max(-1, Math.min(1, sinAngle)); const angle = Math.asin(clampedSinAngle); ctx.setLineDash([]); ctx.beginPath(); ctx.arc(cx, cy, r, angle, Math.PI - angle); ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx, cutY, cutR, cutRy, 0, 0, Math.PI); ctx.stroke(); ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.ellipse(cx, cutY, cutR, cutRy, 0, Math.PI, 2 * Math.PI); ctx.stroke(); ctx.setLineDash([]); } + else if (layer.type === 'truncated-pyramid') { + const { base, top } = layer; + const b = [ base.p1, base.p2, base.p3, base.p4 ]; + const t = [ top.p1, top.p2, top.p3, top.p4 ]; + + ctx.setLineDash([5, 5]); + ctx.beginPath(); + ctx.moveTo(b[3].x, b[3].y); ctx.lineTo(b[0].x, b[0].y); + ctx.moveTo(b[3].x, b[3].y); ctx.lineTo(b[2].x, b[2].y); + ctx.moveTo(b[3].x, b[3].y); ctx.lineTo(t[3].x, t[3].y); + ctx.stroke(); + + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(b[0].x, b[0].y); ctx.lineTo(b[1].x, b[1].y); + ctx.lineTo(b[2].x, b[2].y); + ctx.moveTo(t[0].x, t[0].y); ctx.lineTo(t[1].x, t[1].y); + ctx.lineTo(t[2].x, t[2].y); + ctx.moveTo(b[0].x, b[0].y); ctx.lineTo(t[0].x, t[0].y); + ctx.moveTo(b[1].x, b[1].y); ctx.lineTo(t[1].x, t[1].y); + ctx.moveTo(b[2].x, b[2].y); ctx.lineTo(t[2].x, t[2].y); + ctx.moveTo(t[3].x, t[3].y); ctx.lineTo(t[2].x, t[2].y); + ctx.moveTo(t[3].x, t[3].y); ctx.lineTo(t[0].x, t[0].y); + ctx.stroke(); + } + else if (layer.type === 'image' && layer.image instanceof HTMLImageElement && layer.image.complete) { + ctx.drawImage(layer.image, layer.x, layer.y, layer.width, layer.height); + } + ctx.restore(); +} + +function redrawCanvas(canvasState) { + if(!canvasState) return; + const { ctx, layers, canvas } = canvasState; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + ctx.translate(canvasState.panX, canvasState.panY); + ctx.scale(canvasState.zoom, canvasState.zoom); + layers.forEach(layer => drawLayer(ctx, layer)); + drawSelectionBox(ctx, canvasState.selectedLayers, canvasState); + ctx.restore(); +} + +function drawBackground(bgCanvas, canvasState) { + const bgCtx = bgCanvas.getContext('2d'); + const style = localStorage.getItem('boardBackgroundStyle') || 'dot'; + const theme = localStorage.getItem('boardTheme') || 'light'; + const color = theme === 'light' ? '#d1d1d1' : '#5a5a5a'; + const spacing = 20; + bgCtx.clearRect(0, 0, bgCanvas.width, bgCanvas.height); + if (!canvasState) { if (style === 'dot') { for (let x = 0; x < bgCanvas.width; x += spacing) { for (let y = 0; y < bgCanvas.height; y += spacing) { bgCtx.fillStyle = color; bgCtx.beginPath(); bgCtx.arc(x, y, 1, 0, 2 * Math.PI, false); bgCtx.fill(); } } } else { bgCtx.strokeStyle = color; bgCtx.lineWidth = 0.5; for (let x = 0; x < bgCanvas.width; x += spacing) { bgCtx.beginPath(); bgCtx.moveTo(x, 0); bgCtx.lineTo(x, bgCanvas.height); bgCtx.stroke(); } for (let y = 0; y < bgCanvas.height; y += spacing) { bgCtx.beginPath(); bgCtx.moveTo(0, y); bgCtx.lineTo(bgCanvas.width, y); bgCtx.stroke(); } } return; } + const { panX, panY, zoom } = canvasState; + const visualSpacing = spacing * zoom; + if (visualSpacing < 5) return; + const startX = panX % visualSpacing; + const startY = panY % visualSpacing; + if (style === 'dot') { bgCtx.fillStyle = color; for (let x = startX; x < bgCanvas.width; x += visualSpacing) { for (let y = startY; y < bgCanvas.height; y += visualSpacing) { bgCtx.beginPath(); bgCtx.arc(x, y, 1, 0, 2 * Math.PI, false); bgCtx.fill(); } } } + else { bgCtx.strokeStyle = color; bgCtx.lineWidth = 0.5; for (let x = startX; x < bgCanvas.width; x += visualSpacing) { bgCtx.beginPath(); bgCtx.moveTo(x, 0); bgCtx.lineTo(x, bgCanvas.height); bgCtx.stroke(); } for (let y = startY; y < bgCanvas.height; y += visualSpacing) { bgCtx.beginPath(); bgCtx.moveTo(0, y); bgCtx.lineTo(bgCanvas.width, y); bgCtx.stroke(); } } +} + +function drawSelectionBox(ctx, selectedLayers, canvasState) { + if (!selectedLayers || selectedLayers.length === 0 || !canvasState) return; + + const box = selectedLayers.length > 1 ? getGroupBoundingBox(selectedLayers) : getBoundingBox(selectedLayers[0]); + if (!box) return; + + const isSingleSelection = selectedLayers.length === 1; + const layer = isSingleSelection ? selectedLayers[0] : null; + + const zoom = canvasState.zoom; + const scaledLineWidth = 1 / zoom; + const scaledHandleSize = 8 / zoom; + const scaledHalfHandle = scaledHandleSize / 2; + const scaledDash = [5 / zoom, 5 / zoom]; + + const rotation = getSelectionRotation(selectedLayers, canvasState.groupRotation); + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + + let pivotX = centerX; + let pivotY = centerY; + + if (isSingleSelection && layer && layer.pivot) { + const rotatedPivotOffset = rotatePoint(layer.pivot, {x:0, y:0}, rotation); + pivotX = centerX + rotatedPivotOffset.x; + pivotY = centerY + rotatedPivotOffset.y; + } + + ctx.save(); + ctx.translate(pivotX, pivotY); + ctx.rotate(rotation); + ctx.translate(-pivotX, -pivotY); + + ctx.strokeStyle = '#007AFF'; + ctx.lineWidth = scaledLineWidth; + ctx.setLineDash(scaledDash); + ctx.strokeRect(box.x, box.y, box.width, box.height); + ctx.setLineDash([]); + ctx.fillStyle = '#007AFF'; + + const handles = [ + { x: box.x, y: box.y }, { x: centerX, y: box.y }, { x: box.x + box.width, y: box.y }, + { x: box.x, y: centerY }, { x: box.x + box.width, y: centerY }, + { x: box.x, y: box.y + box.height }, { x: centerX, y: box.y + box.height }, { x: box.x + box.width, y: box.y + box.height } + ]; + handles.forEach(handle => { + ctx.fillRect(handle.x - scaledHalfHandle, handle.y - scaledHalfHandle, scaledHandleSize, scaledHandleSize); + }); + + const rotationHandleY = box.y - 20 / zoom; + ctx.beginPath(); + ctx.moveTo(centerX, box.y); + ctx.lineTo(centerX, rotationHandleY); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(centerX, rotationHandleY, scaledHalfHandle, 0, 2 * Math.PI); + ctx.fill(); + + ctx.restore(); + + if (isSingleSelection) { + ctx.save(); + ctx.strokeStyle = '#007AFF'; + ctx.lineWidth = scaledLineWidth; + ctx.beginPath(); + ctx.arc(pivotX, pivotY, scaledHalfHandle, 0, 2 * Math.PI); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(pivotX - scaledHalfHandle, pivotY); + ctx.lineTo(pivotX + scaledHalfHandle, pivotY); + ctx.moveTo(pivotX, pivotY - scaledHalfHandle); + ctx.lineTo(pivotX, pivotY + scaledHalfHandle); + ctx.stroke(); + ctx.restore(); + } +} \ No newline at end of file diff --git a/js/shapeRecognizer.js b/js/shapeRecognizer.js new file mode 100644 index 0000000..85e902f --- /dev/null +++ b/js/shapeRecognizer.js @@ -0,0 +1,164 @@ +// --- START OF FILE js/shapeRecognizer.js --- + +import { simplifyPath } from './utils.js'; + +/** + * Получает ограничительную рамку для массива точек. + */ +function getBoundingBox(points) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + points.forEach(p => { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + }); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + +/** + * Проверяет, является ли путь "почти замкнутым". + * Допуск зависит от размера фигуры. + */ +function isPathClosed(points, box) { + if (points.length < 3) return false; + const tolerance = Math.hypot(box.width, box.height) * 0.25; // 25% от диагонали + const first = points[0]; + const last = points[points.length - 1]; + return Math.hypot(first.x - last.x, first.y - last.y) < tolerance; +} + +/** + * Вспомогательная функция: вычисляет перпендикулярное расстояние от точки до отрезка. + */ +function perpendicularDistance(pt, p1, p2) { + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return Math.hypot(pt.x - p1.x, pt.y - p1.y); + return Math.abs(dy * pt.x - dx * pt.y + p2.x * p1.y - p2.y * p1.x) / Math.sqrt(lenSq); +} + +/** + * Пытается распознать прямую линию. + */ +function recognizeLine(points) { + const p1 = points[0]; + const p2 = points[points.length - 1]; + const directDistance = Math.hypot(p1.x - p2.x, p1.y - p2.y); + + if (directDistance < 30) return null; + + let maxDeviation = 0; + for (let i = 1; i < points.length - 1; i++) { + const deviation = perpendicularDistance(points[i], p1, p2); + if (deviation > maxDeviation) { + maxDeviation = deviation; + } + } + + if (maxDeviation < directDistance * 0.08) { // Допуск 8% от длины + return { + type: 'line', + x1: p1.x, y1: p1.y, + x2: p2.x, y2: p2.y, + id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } + }; + } + + return null; +} + +/** + * Пытается распознать эллипс или круг. + */ +function recognizeEllipse(points) { + const box = getBoundingBox(points); + const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + + let totalError = 0; + points.forEach(p => { + const dx = p.x - center.x; + const dy = p.y - center.y; + if (box.width > 0 && box.height > 0) { + const error = Math.abs(1 - ((dx * dx) / (box.width / 2) ** 2 + (dy * dy) / (box.height / 2) ** 2)); + totalError += error; + } + }); + const averageError = totalError / points.length; + + if (averageError < 0.4) { + return { + type: 'ellipse', + cx: center.x, cy: center.y, + rx: box.width / 2, ry: box.height / 2, + id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } + }; + } + return null; +} + +/** + * Пытается распознать многоугольник (треугольник или прямоугольник). + */ +function recognizePolygon(points) { + const box = getBoundingBox(points); + const tolerance = Math.hypot(box.width, box.height) * 0.1; + const simplified = simplifyPath(points, tolerance); + + // Если 3 или 4 точки (для треугольника) + if (simplified.length === 3 || (simplified.length === 4 && isPathClosed(simplified, box))) { + return { + type: 'triangle', + p1: simplified[0], p2: simplified[1], p3: simplified[2], + id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } + }; + } + + // Если 4 или 5 точек (для прямоугольника) + if (simplified.length === 4 || (simplified.length === 5 && isPathClosed(simplified, box))) { + return { + type: 'rect', + x: box.x, y: box.y, + width: box.width, height: box.height, + id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } + }; + } + return null; +} + + +/** + * Главная функция, которая пытается распознать фигуру. + */ +export function recognizeShape(points) { + if (points.length < 10) return null; + const box = getBoundingBox(points); + if (box.width < 20 && box.height < 20) return null; + + // --- НАЧАЛО НОВОЙ ЛОГИКИ С ПРАВИЛЬНЫМ ПОРЯДКОМ --- + + // 1. Проверяем, замкнута ли фигура. + if (isPathClosed(points, box)) { + // Если да, то это НЕ линия. Ищем среди замкнутых фигур. + const analysisPoints = [...points, points[0]]; + + // 2. Сначала ищем многоугольники, так как у них более строгие критерии (углы). + let shape = recognizePolygon(analysisPoints); + if (shape) return shape; + + // 3. Если это не многоугольник, проверяем на эллипс. + shape = recognizeEllipse(analysisPoints); + if (shape) return shape; + + } else { + // 4. Если фигура НЕ замкнута, это может быть только линия. + let shape = recognizeLine(points); + if (shape) return shape; + } + + // --- КОНЕЦ НОВОЙ ЛОГИКИ --- + + return null; +} +// --- END OF FILE js/shapeRecognizer.js --- \ No newline at end of file diff --git a/js/text.js b/js/text.js new file mode 100644 index 0000000..40cf3ab --- /dev/null +++ b/js/text.js @@ -0,0 +1,175 @@ +// --- START OF FILE js/text.js --- + +let editorTextarea = null; +let currentEditingLayer = null; +let canvasStateRef = null; +let onFinishCallback = null; + +export function getEditorTextarea() { + return editorTextarea; +} + +function initializeTextEditor() { + if (editorTextarea) return; + + editorTextarea = document.createElement('textarea'); + editorTextarea.id = 'text-editor-textarea'; + editorTextarea.wrap = 'soft'; + + document.body.appendChild(editorTextarea); + + editorTextarea.addEventListener('focusout', (e) => { + const toolbar = document.getElementById('floating-text-toolbar'); + if (e.relatedTarget && toolbar.contains(e.relatedTarget)) { + return; + } + finishEditing(); + }); + + editorTextarea.addEventListener('input', updateEditorSizeAndLayer); + editorTextarea.addEventListener('keydown', (e) => { + if (e.key === 'Escape' || (e.key === 'Enter' && (e.shiftKey || e.ctrlKey || e.metaKey))) { + e.preventDefault(); + finishEditing(); + } + }); + + // --- НАЧАЛО ИЗМЕНЕНИЙ: Добавляем прослушиватель для перенаправления кликов на холст --- + editorTextarea.addEventListener('pointerdown', (e) => { + // Если идёт редактирование, перенаправляем событие нажатия мыши на холст. + // Это позволяет холсту обрабатывать трансформации (перемещение, масштабирование) текстового блока, + // даже если нажатие начинается внутри самого текстового поля. + if (currentEditingLayer && canvasStateRef && canvasStateRef.canvas) { + // Диспетчеризация этого события запускает функцию 'startDrawing' в canvas.js, + // которая содержит логику для инициации действия 'переместить' или 'масштабировать'. + canvasStateRef.canvas.dispatchEvent(new PointerEvent('pointerdown', e)); + } + }); + // --- КОНЕЦ ИЗМЕНЕНИЙ --- +} + +function wrapText(ctx, text, maxWidth) { + const manualLines = text.split('\n'); + let allLines = []; + + manualLines.forEach(manualLine => { + if (manualLine === '') { + allLines.push(''); + return; + } + const words = manualLine.split(' '); + let currentLine = ''; + for (const word of words) { + const testLine = currentLine === '' ? word : `${currentLine} ${word}`; + const metrics = ctx.measureText(testLine); + + if (metrics.width > maxWidth && currentLine !== '') { + allLines.push(currentLine); + currentLine = word; + } else { + currentLine = testLine; + } + } + allLines.push(currentLine); + }); + + return allLines; +} + +export function updateEditorStyle(layer) { + if (!editorTextarea || !layer || !canvasStateRef) return; + + const { zoom } = canvasStateRef; + const fontWeight = layer.fontWeight || 'normal'; + const fontStyle = layer.fontStyle || 'normal'; + + editorTextarea.style.fontSize = `${layer.fontSize * zoom}px`; + editorTextarea.style.fontFamily = layer.fontFamily; + editorTextarea.style.fontWeight = fontWeight; + editorTextarea.style.fontStyle = fontStyle; + editorTextarea.style.textAlign = layer.align || 'left'; + editorTextarea.style.textDecoration = layer.textDecoration || 'none'; + editorTextarea.style.color = layer.color; + editorTextarea.style.lineHeight = `${layer.fontSize * 1.2 * zoom}px`; + + updateEditorSizeAndLayer(); +} + +export function updateEditorTransform(layer, canvasState) { + if (!editorTextarea || !layer || !canvasState) return; + const { panX, panY, zoom } = canvasState; + editorTextarea.style.left = `${(layer.x * zoom) + panX}px`; + editorTextarea.style.top = `${(layer.y * zoom) + panY}px`; + editorTextarea.style.width = `${layer.width * zoom}px`; + editorTextarea.style.height = `${layer.height * zoom}px`; +} + +function updateEditorSizeAndLayer() { + if (!currentEditingLayer || !canvasStateRef) return; + + currentEditingLayer.content = editorTextarea.value; + + const { ctx, zoom } = canvasStateRef; + const fontWeight = currentEditingLayer.fontWeight || 'normal'; + const fontStyle = currentEditingLayer.fontStyle || 'normal'; + ctx.font = `${fontStyle} ${fontWeight} ${currentEditingLayer.fontSize}px ${currentEditingLayer.fontFamily}`; + + const lines = wrapText(ctx, currentEditingLayer.content, currentEditingLayer.width); + const newHeight = lines.length * (currentEditingLayer.fontSize * 1.2); + currentEditingLayer.height = newHeight > 0 ? newHeight : (currentEditingLayer.fontSize * 1.2); + + editorTextarea.style.height = `${currentEditingLayer.height * zoom}px`; + + if(onFinishCallback) { + onFinishCallback(true); + } +} + +export function startEditing(canvasState, layer, onFinish) { + initializeTextEditor(); + + currentEditingLayer = layer; + canvasStateRef = canvasState; + onFinishCallback = onFinish; + + editorTextarea.style.position = 'fixed'; + editorTextarea.style.zIndex = '1000'; + editorTextarea.style.display = 'block'; + editorTextarea.style.border = `none`; + editorTextarea.style.pointerEvents = 'auto'; + + editorTextarea.value = layer.content; + + updateEditorStyle(layer); + updateEditorTransform(layer, canvasState); + + setTimeout(() => { + editorTextarea.focus(); + if (layer.content === '') { + editorTextarea.select(); + } + }, 0); +} + +function finishEditing() { + if (!currentEditingLayer) return; + + updateEditorSizeAndLayer(); + + if (currentEditingLayer.content.trim() === '') { + const index = canvasStateRef.layers.findIndex(l => l.id === currentEditingLayer.id); + if (index > -1) { + canvasStateRef.layers.splice(index, 1); + } + } + + editorTextarea.style.display = 'none'; + + if (onFinishCallback) { + onFinishCallback(false); + } + currentEditingLayer = null; + canvasStateRef = null; + onFinishCallback = null; +} +// --- END OF FILE js/text.js --- \ No newline at end of file diff --git a/js/toolbar.js b/js/toolbar.js new file mode 100644 index 0000000..bdcd7a8 --- /dev/null +++ b/js/toolbar.js @@ -0,0 +1,246 @@ +// --- START OF FILE js/toolbar.js --- + +export function initializeToolbar(canvasState, redrawCallback, updateToolbarCallback) { + const toolbarWrapper = document.getElementById('toolbarWrapper'); + const toolbar = document.getElementById('toolbar'); + + // --- НАЧАЛО ИЗМЕНЕНИЙ: Удалены ссылки на старую текстовую панель --- + const drawingSubToolbar = document.getElementById('drawingSubToolbar'); + const colorPalette = document.getElementById('colorPalette'); + const lineWidthSlider = document.getElementById('lineWidthSlider'); + // --- КОНЕЦ ИЗМЕНЕНИЙ --- + + const lineWidthIndicator = document.getElementById('lineWidthIndicator'); + + const shapes2DBtn = document.getElementById('shapes2DBtn'); + const shapes2DOptions = document.getElementById('shapes2DOptions'); + const shapes2DToolContainer = document.getElementById('shapes-2d-tool-container'); + + const shapes3DBtn = document.getElementById('shapes3DBtn'); + const shapes3DOptions = document.getElementById('shapes3DOptions'); + const shapes3DToolContainer = document.getElementById('shapes-3d-tool-container'); + + const zoomControls = document.getElementById('zoomControls'); + + function cancelInProgressActions() { + const multiStepActions = [ + 'drawingParallelogramSlant', 'drawingTriangleApex', 'drawingParallelepipedDepth', + 'drawingPyramidApex', 'drawingTrapezoidP3', 'drawingTrapezoidP4', + 'drawingFrustum', 'drawingTruncatedSphere', 'drawingTruncatedPyramidApex', 'drawingTruncatedPyramidTop' + ]; + if (multiStepActions.includes(canvasState.currentAction)) { + canvasState.currentAction = 'none'; + canvasState.tempLayer = null; + redrawCallback(); + } + } + + shapes2DBtn.addEventListener('click', (e) => { + e.stopPropagation(); + shapes2DToolContainer.classList.toggle('active'); + shapes3DToolContainer.classList.remove('active'); + }); + + shapes3DBtn.addEventListener('click', (e) => { + e.stopPropagation(); + shapes3DToolContainer.classList.toggle('active'); + shapes2DToolContainer.classList.remove('active'); + }); + + function handleShapeSelection(e, mainButton, container) { + e.preventDefault(); + const option = e.target.closest('a'); + if (!option) return; + const tool = option.dataset.tool; + if (!tool) return; + + cancelInProgressActions(); + mainButton.innerHTML = option.querySelector('svg').outerHTML; + canvasState.activeTool = tool; + if (canvasState.activeTool !== 'select') { + canvasState.previousTool = canvasState.activeTool; + } + + toolbar.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); + zoomControls.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); + mainButton.classList.add('active'); + + canvasState.selectedLayers = []; + redrawCallback(); + container.classList.remove('active'); + + const canvas = canvasState.canvas; + canvas.classList.remove('cursor-brush', 'cursor-eraser'); + canvas.style.cursor = 'crosshair'; + + updateToolbarCallback(); + } + + shapes2DOptions.addEventListener('click', (e) => handleShapeSelection(e, shapes2DBtn, shapes2DToolContainer)); + shapes3DOptions.addEventListener('click', (e) => handleShapeSelection(e, shapes3DBtn, shapes3DToolContainer)); + + toolbar.addEventListener('click', (e) => { + const button = e.target.closest('button'); + if (!button || button.dataset.toolGroup === 'shapes') return; + if (button.id === 'addImageBtn' || button.id === 'undoBtn' || button.id === 'redoBtn') { + if (button.id === 'addImageBtn') document.getElementById('imageUpload').click(); + return; + } + const tool = button.dataset.tool; + if (!tool) return; + cancelInProgressActions(); + if (canvasState.activeTool !== tool && canvasState.activeTool !== 'select') { + canvasState.previousTool = canvasState.activeTool; + } + canvasState.activeTool = tool; + + toolbar.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); + zoomControls.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); + if (button.dataset.tool) button.classList.add('active'); + + if (tool !== 'select') { + canvasState.selectedLayers = []; + redrawCallback(); + canvasState.updateFloatingToolbar(); + } + + const canvas = canvasState.canvas; + canvas.classList.remove('cursor-brush', 'cursor-eraser'); + canvas.style.cursor = ''; + + if (tool === 'brush' || tool === 'smart-brush') { + canvas.classList.add('cursor-brush'); + } else if (tool === 'eraser') { + canvas.classList.add('cursor-eraser'); + } else if (tool === 'text') { + canvas.style.cursor = 'text'; + } else { + canvas.style.cursor = 'default'; + } + + updateToolbarCallback(); + }); + + zoomControls.addEventListener('click', (e) => { + const button = e.target.closest('button'); + if (!button) return; + + const tool = button.dataset.tool; + if (tool === 'pan') { + cancelInProgressActions(); + canvasState.activeTool = 'pan'; + + toolbar.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); + zoomControls.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); + + button.classList.add('active'); + + canvasState.selectedLayers = []; + redrawCallback(); + const canvas = canvasState.canvas; + canvas.classList.remove('cursor-brush', 'cursor-eraser'); + canvas.style.cursor = 'grab'; + + updateToolbarCallback(); + } + }); + + function handleColorChange(newColor) { + if (canvasState.selectedLayers.length > 0) { + canvasState.selectedLayers.forEach(layer => { + if (layer.hasOwnProperty('color') && layer.type !== 'text') { // Не меняем цвет текста здесь + layer.color = newColor; + } + }); + redrawCallback(); + canvasState.saveState(canvasState.layers); + } + + canvasState.activeColor = newColor; + + colorPalette.querySelectorAll('.active').forEach(el => el.classList.remove('active')); + const activeDot = colorPalette.querySelector(`[data-color="${newColor}"]`); + if (activeDot) activeDot.classList.add('active'); + } + + colorPalette.addEventListener('click', (e) => { + const target = e.target.closest('[data-color]'); + if (target) { + handleColorChange(target.dataset.color); + } + }); + + lineWidthSlider.addEventListener('input', (e) => { + const newWidth = parseInt(e.target.value, 10); + + if (canvasState.activeTool === 'select' && canvasState.selectedLayers.length > 0) { + canvasState.selectedLayers.forEach(layer => { + if (layer.hasOwnProperty('lineWidth')) { + layer.lineWidth = newWidth; + } + }); + redrawCallback(); + } + + canvasState.activeLineWidth = newWidth; + }); + + lineWidthSlider.addEventListener('change', () => { + if (canvasState.activeTool === 'select' && canvasState.selectedLayers.length > 0) { + canvasState.saveState(canvasState.layers); + } + }); + + document.getElementById('toggleToolbar').addEventListener('click', () => { toolbarWrapper.classList.toggle('collapsed'); }); + const logo = document.getElementById('logo'), settingsMenu = document.getElementById('settingsMenu'), clearCanvasBtn = document.getElementById('clearCanvas'), dragHandle = document.querySelector('.toolbar-drag-handle'); + logo.addEventListener('click', (e) => { e.stopPropagation(); settingsMenu.style.display = settingsMenu.style.display === 'block' ? 'none' : 'block'; }); + clearCanvasBtn.addEventListener('click', (e) => { e.preventDefault(); if (confirm('Вы уверены, что хотите очистить всю доску?')) { canvasState.layers = []; canvasState.selectedLayers = []; const externalSaveState = canvasState.saveState; if(externalSaveState) externalSaveState(canvasState.layers); redrawCallback(); } }); + + document.addEventListener('click', (e) => { + if (!settingsMenu.contains(e.target) && e.target !== logo) { settingsMenu.style.display = 'none'; } + if (!shapes2DToolContainer.contains(e.target)) { shapes2DToolContainer.classList.remove('active'); } + if (!shapes3DToolContainer.contains(e.target)) { shapes3DToolContainer.classList.remove('active'); } + }); + + let isDragging = false, offsetX; + dragHandle.addEventListener('mousedown', (e) => { isDragging = true; const rect = toolbarWrapper.getBoundingClientRect(); offsetX = e.clientX - rect.left; document.body.style.userSelect = 'none'; }); + document.addEventListener('mousemove', (e) => { if (isDragging) { const toolbarWidth = toolbarWrapper.offsetWidth; const windowWidth = window.innerWidth; let newLeft = e.clientX - offsetX; if (newLeft < 0) newLeft = 0; if (newLeft + toolbarWidth > windowWidth) newLeft = windowWidth - toolbarWidth; toolbarWrapper.style.left = `${newLeft}px`; toolbarWrapper.style.transform = 'none'; } }); + document.addEventListener('mouseup', () => { isDragging = false; document.body.style.userSelect = 'auto'; }); + + function updateIndicatorPosition() { + const slider = lineWidthSlider; + const indicator = lineWidthIndicator; + + const min = parseFloat(slider.min); + const max = parseFloat(slider.max); + const val = parseFloat(slider.value); + + const percentage = (val - min) / (max - min); + + const sliderRect = slider.getBoundingClientRect(); + const thumbX = sliderRect.left + (sliderRect.width * percentage); + const thumbY = sliderRect.top; + + indicator.style.width = `${val}px`; + indicator.style.height = `${val}px`; + indicator.style.left = `${thumbX}px`; + indicator.style.top = `${thumbY}px`; + } + + function showIndicator(e) { + if (e.pointerType === 'touch') { + e.preventDefault(); + } + lineWidthIndicator.classList.add('visible'); + updateIndicatorPosition(); + } + + function hideIndicator() { + lineWidthIndicator.classList.remove('visible'); + } + + lineWidthSlider.addEventListener('input', updateIndicatorPosition); + lineWidthSlider.addEventListener('pointerdown', showIndicator); + document.addEventListener('pointerup', hideIndicator); +} +// --- END OF FILE toolbar.js --- \ No newline at end of file diff --git a/js/tools.js b/js/tools.js new file mode 100644 index 0000000..486e63f --- /dev/null +++ b/js/tools.js @@ -0,0 +1,151 @@ +// --- START OF FILE js/tools.js --- + +import { snapToGrid } from './utils.js'; +import * as hitTest from './hitTest.js'; + +export function handleBrush(state, pos, e) { + const pressure = e.pressure > 0 ? e.pressure : 0.5; + state.layers[state.layers.length - 1].points.push({ ...pos, pressure }); +} + +export function handleEraser(state, pos) { + const layerToDelete = hitTest.getLayerAtPosition(pos, state.layers); + if (layerToDelete) { + state.layers = state.layers.filter(l => l.id !== layerToDelete.id); + state.didErase = true; + return true; + } + return false; +} + +export function handleShapeDrawing(state, pos, event, redrawCallback) { + redrawCallback(); + const { ctx, zoom } = state; + ctx.save(); + ctx.translate(state.panX, state.panY); + ctx.scale(zoom, zoom); + ctx.strokeStyle = 'rgba(0,0,0,0.5)'; + ctx.lineWidth = 2 / zoom; + ctx.setLineDash([5 / zoom, 5 / zoom]); + + if (state.isDrawing) { + let start = { ...state.startPos }; + let end = pos; + + if (event.altKey) { + start = { x: snapToGrid(state.startPos.x), y: snapToGrid(state.startPos.y) }; + end = { x: snapToGrid(pos.x), y: snapToGrid(pos.y) }; + } + + if (state.activeTool === 'line' && event.shiftKey) { + const dx = end.x - start.x; + const dy = end.y - start.y; + if (Math.abs(dx) > Math.abs(dy)) { + end.y = start.y; + } else { + end.x = start.x; + } + } + + if (['rect', 'text', 'parallelogram', 'cone', 'parallelepiped', 'pyramid', 'frustum', 'truncated-pyramid'].includes(state.activeTool)) { + ctx.beginPath(); + ctx.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y); + } + else if (state.activeTool === 'rhombus') { + const x = Math.min(end.x, start.x); + const y = Math.min(end.y, start.y); + const width = Math.abs(end.x - start.x); + const height = Math.abs(end.y - start.y); + const p1 = {x: x + width / 2, y: y}; + const p2 = {x: x + width, y: y + height / 2}; + const p3 = {x: x + width / 2, y: y + height}; + const p4 = {x: x, y: y + height / 2}; + ctx.beginPath(); + ctx.moveTo(p1.x, p1.y); + ctx.lineTo(p2.x, p2.y); + ctx.lineTo(p3.x, p3.y); + ctx.lineTo(p4.x, p4.y); + ctx.closePath(); + ctx.stroke(); + } + else if (['line', 'triangle', 'trapezoid'].includes(state.activeTool)) { + ctx.beginPath(); + ctx.moveTo(start.x, start.y); + ctx.lineTo(end.x, end.y); + ctx.stroke(); + } else if (state.activeTool === 'ellipse' || state.activeTool === 'sphere' || state.activeTool === 'truncated-sphere') { + const rx = Math.abs(end.x - start.x) / 2; + const ry = Math.abs(end.y - start.y) / 2; + const cx = start.x + (end.x - start.x) / 2; + const cy = start.y + (end.y - start.y) / 2; + ctx.beginPath(); + ctx.ellipse(cx, cy, rx, ry, 0, 0, 2 * Math.PI); + ctx.stroke(); + } + } + + ctx.restore(); +} + +export function handleMultiStepDrawing(state, pos, event, redrawCallback) { + redrawCallback(); + const { ctx, zoom } = state; + ctx.save(); + ctx.translate(state.panX, state.panY); + ctx.scale(zoom, zoom); + ctx.strokeStyle = 'rgba(0,0,0,0.5)'; + ctx.lineWidth = 2 / zoom; + ctx.setLineDash([5 / zoom, 5 / zoom]); + + let previewPos = pos; + if (event.altKey) { + previewPos = { x: snapToGrid(pos.x), y: snapToGrid(pos.y) }; + } + + if (state.tempLayer) { + if (state.currentAction === 'drawingParallelogramSlant') { const tempLayer = state.tempLayer; const slant = previewPos.x - (tempLayer.x + tempLayer.width / 2); ctx.beginPath(); ctx.moveTo(tempLayer.x, tempLayer.y + tempLayer.height); ctx.lineTo(tempLayer.x + tempLayer.width, tempLayer.y + tempLayer.height); ctx.lineTo(tempLayer.x + tempLayer.width + slant, tempLayer.y); ctx.lineTo(tempLayer.x + slant, tempLayer.y); ctx.closePath(); ctx.stroke(); } + else if (state.currentAction === 'drawingTriangleApex') { const tempLayer = state.tempLayer; ctx.beginPath(); ctx.moveTo(tempLayer.p1.x, tempLayer.p1.y); ctx.lineTo(tempLayer.p2.x, tempLayer.p2.y); ctx.lineTo(previewPos.x, previewPos.y); ctx.closePath(); ctx.stroke(); } + else if (state.currentAction === 'drawingParallelepipedDepth') { const tempLayer = state.tempLayer; const depth = { x: previewPos.x - (tempLayer.x + tempLayer.width), y: previewPos.y - tempLayer.y }; const p = [ {x: tempLayer.x, y: tempLayer.y}, {x: tempLayer.x + tempLayer.width, y: tempLayer.y}, {x: tempLayer.x + tempLayer.width, y: tempLayer.y + tempLayer.height}, {x: tempLayer.x, y: tempLayer.y + tempLayer.height}, {x: tempLayer.x + depth.x, y: tempLayer.y + depth.y}, {x: tempLayer.x + tempLayer.width + depth.x, y: tempLayer.y + depth.y}, {x: tempLayer.x + tempLayer.width + depth.x, y: tempLayer.y + tempLayer.height + depth.y}, {x: tempLayer.x + depth.x, y: tempLayer.y + tempLayer.height + depth.y} ]; ctx.beginPath(); ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[1].x, p[1].y); ctx.lineTo(p[2].x, p[2].y); ctx.lineTo(p[3].x, p[3].y); ctx.closePath(); ctx.moveTo(p[1].x, p[1].y); ctx.lineTo(p[5].x, p[5].y); ctx.moveTo(p[2].x, p[2].y); ctx.lineTo(p[6].x, p[6].y); ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[4].x, p[4].y); ctx.moveTo(p[4].x, p[4].y); ctx.lineTo(p[5].x, p[5].y); ctx.lineTo(p[6].x, p[6].y); ctx.lineTo(p[7].x, p[7].y); ctx.closePath(); ctx.stroke(); } + else if (state.currentAction === 'drawingPyramidApex' || state.currentAction === 'drawingTruncatedPyramidApex') { const { base } = state.tempLayer; const p = [ base.p1, base.p2, base.p3, base.p4 ]; ctx.beginPath(); ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(p[1].x, p[1].y); ctx.lineTo(p[2].x, p[2].y); ctx.lineTo(p[3].x, p[3].y); ctx.closePath(); ctx.moveTo(p[0].x, p[0].y); ctx.lineTo(previewPos.x, previewPos.y); ctx.moveTo(p[1].x, p[1].y); ctx.lineTo(previewPos.x, previewPos.y); ctx.moveTo(p[2].x, p[2].y); ctx.lineTo(previewPos.x, previewPos.y); ctx.moveTo(p[3].x, p[3].y); ctx.lineTo(previewPos.x, previewPos.y); ctx.stroke(); } + else if (state.currentAction === 'drawingTruncatedPyramidTop') { + const { base, apex } = state.tempLayer; + const totalHeight = Math.abs(apex.y - base.p1.y); + const cutHeight = Math.abs(previewPos.y - base.p1.y); + const ratio = Math.max(0.05, Math.min(0.95, cutHeight / totalHeight)); + + const interpolate = (p1, p2) => ({ x: p1.x + (p2.x - p1.x) * ratio, y: p1.y + (p2.y - p1.y) * ratio }); + const t = [ interpolate(base.p1, apex), interpolate(base.p2, apex), interpolate(base.p3, apex), interpolate(base.p4, apex) ]; + const b = [ base.p1, base.p2, base.p3, base.p4 ]; + + ctx.beginPath(); ctx.moveTo(b[0].x, b[0].y); ctx.lineTo(b[1].x, b[1].y); ctx.lineTo(b[2].x, b[2].y); ctx.lineTo(b[3].x, b[3].y); ctx.closePath(); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(t[0].x, t[0].y); ctx.lineTo(t[1].x, t[1].y); ctx.lineTo(t[2].x, t[2].y); ctx.lineTo(t[3].x, t[3].y); ctx.closePath(); ctx.stroke(); + for(let i = 0; i < 4; i++) { ctx.beginPath(); ctx.moveTo(b[i].x, b[i].y); ctx.lineTo(t[i].x, t[i].y); ctx.stroke(); } + } + else if (state.currentAction === 'drawingTrapezoidP3') { const { p1, p2 } = state.tempLayer; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.lineTo(previewPos.x, previewPos.y); ctx.stroke(); } + else if (state.currentAction === 'drawingTrapezoidP4') { const { p1, p2, p3 } = state.tempLayer; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.lineTo(p3.x, p3.y); ctx.lineTo(previewPos.x, previewPos.y); ctx.closePath(); ctx.stroke(); } + else if (state.currentAction === 'drawingFrustum') { const { cx, baseY, rx1, ry1 } = state.tempLayer; const rx2 = Math.abs(previewPos.x - cx); const ry2 = rx2 * 0.3; ctx.beginPath(); ctx.moveTo(cx - rx1, baseY); ctx.lineTo(cx - rx2, previewPos.y); ctx.moveTo(cx + rx1, baseY); ctx.lineTo(cx + rx2, previewPos.y); ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx, baseY, rx1, ry1, 0, 0, 2 * Math.PI); ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx, previewPos.y, rx2, ry2, 0, 0, 2 * Math.PI); ctx.stroke(); } + else if (state.currentAction === 'drawingTruncatedSphere') { + const { cx, cy, r } = state.tempLayer; + const cutY = Math.max(cy - r, Math.min(cy + r, previewPos.y)); + const h = Math.abs(cutY - cy); + const cutRSquared = (r * r) - (h * h); + const cutR = cutRSquared > 0 ? Math.sqrt(cutRSquared) : 0; + const cutRy = cutR * 0.3; + + const sinAngle = (cutY - cy) / r; + const clampedSinAngle = Math.max(-1, Math.min(1, sinAngle)); + const angle = Math.asin(clampedSinAngle); + + ctx.beginPath(); + ctx.arc(cx, cy, r, angle, Math.PI - angle); + ctx.stroke(); + + ctx.beginPath(); + ctx.ellipse(cx, cutY, cutR, cutRy, 0, 0, 2 * Math.PI); + ctx.stroke(); + } + } + + ctx.restore(); +} +// --- END OF FILE js/tools.js --- \ No newline at end of file diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..aec2c56 --- /dev/null +++ b/js/utils.js @@ -0,0 +1,146 @@ +// --- START OF FILE js/utils.js --- + +import { getBoundingBox, rotatePoint } from './geometry.js'; + +const GRID_SPACING = 20; + +export function snapToGrid(value) { + return Math.round(value / GRID_SPACING) * GRID_SPACING; +} + +export function processImageFile(file, position, canvasState, redrawCallback, saveState) { + if (!file.type.startsWith('image/')) return; + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = () => { + const newLayer = { type: 'image', image: img, x: position.x - img.width / 2, y: position.y - img.height / 2, width: img.width, height: img.height, id: Date.now(), rotation: 0, pivot: { x: 0, y: 0 } }; + canvasState.layers.push(newLayer); + canvasState.selectedLayers = [newLayer]; + + // --- НАЧАЛО ИЗМЕНЕНИЙ: Автоматически переключаемся на инструмент "Выделить" --- + const selectButton = document.querySelector('button[data-tool="select"]'); + if (selectButton) { + selectButton.click(); + } + // --- КОНЕЦ ИЗМЕНЕНИЙ --- + + saveState(canvasState.layers); + redrawCallback(); + if (canvasState.updateFloatingToolbar) { + canvasState.updateFloatingToolbar(); + } + }; + img.src = e.target.result; + }; + reader.readAsDataURL(file); +} + +export function applyTransformations(layer) { + if (!layer || (!layer.rotation && (!layer.pivot || (layer.pivot.x === 0 && layer.pivot.y === 0)))) { + return; + } + + const rotation = layer.rotation || 0; + const pivot = layer.pivot || { x: 0, y: 0 }; + const box = getBoundingBox(layer); + if (!box) return; + + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + + const rotatedPivotOffset = rotatePoint(pivot, { x: 0, y: 0 }, rotation); + const pivotPoint = { + x: centerX + rotatedPivotOffset.x, + y: centerY + rotatedPivotOffset.y, + }; + + const rotate = (p) => rotatePoint(p, pivotPoint, rotation); + + const layerProps = Object.keys(layer); + for (const prop of layerProps) { + if (layer[prop] && typeof layer[prop] === 'object' && layer[prop].hasOwnProperty('x') && layer[prop].hasOwnProperty('y')) { + const newPoint = rotate(layer[prop]); + layer[prop].x = newPoint.x; + layer[prop].y = newPoint.y; + } + } + + if (layer.points) { + layer.points.forEach(p => { + const newPoint = rotate(p); + p.x = newPoint.x; + p.y = newPoint.y; + }); + } + + if (layer.hasOwnProperty('x') && layer.hasOwnProperty('y')) { + const newCenter = rotate({ x: centerX, y: centerY }); + const dx = newCenter.x - centerX; + const dy = newCenter.y - centerY; + layer.x += dx; + layer.y += dy; + } + if (layer.hasOwnProperty('cx') && layer.hasOwnProperty('cy')) { + const newCenter = rotate({ x: layer.cx, y: layer.cy }); + layer.cx = newCenter.x; + layer.cy = newCenter.y; + } + if (layer.hasOwnProperty('x1') && layer.hasOwnProperty('y1')) { + const newP1 = rotate({ x: layer.x1, y: layer.y1 }); + const newP2 = rotate({ x: layer.x2, y: layer.y2 }); + layer.x1 = newP1.x; layer.y1 = newP1.y; + layer.x2 = newP2.x; layer.y2 = newP2.y; + } + if (layer.baseY) { + const newBaseCenter = rotate({ x: layer.cx, y: layer.baseY }); + const dy = newBaseCenter.y - layer.baseY; + layer.baseY += dy; + if(layer.topY) layer.topY += dy; + } + + layer.rotation = 0; + layer.pivot = { x: 0, y: 0 }; +} + +function perpendicularDistance(pt, p1, p2) { + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) { + return Math.hypot(pt.x - p1.x, pt.y - p1.y); + } + const t = ((pt.x - p1.x) * dx + (pt.y - p1.y) * dy) / lenSq; + const clampedT = Math.max(0, Math.min(1, t)); + const closestX = p1.x + clampedT * dx; + const closestY = p1.y + clampedT * dy; + return Math.hypot(pt.x - closestX, pt.y - closestY); +} + +export function simplifyPath(points, tolerance) { + if (points.length < 3) { + return points; + } + + let dmax = 0; + let index = 0; + const end = points.length - 1; + + for (let i = 1; i < end; i++) { + const d = perpendicularDistance(points[i], points[0], points[end]); + if (d > dmax) { + index = i; + dmax = d; + } + } + + if (dmax > tolerance) { + const recResults1 = simplifyPath(points.slice(0, index + 1), tolerance); + const recResults2 = simplifyPath(points.slice(index), tolerance); + + return recResults1.slice(0, recResults1.length - 1).concat(recResults2); + } else { + return [points[0], points[end]]; + } +} +// --- END OF FILE js/utils.js --- \ No newline at end of file