From a24f782205fe688795d0c9cfdcc615a41d21c54b Mon Sep 17 00:00:00 2001 From: grendervill Date: Tue, 1 Sep 2026 20:12:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=BB=D0=BE=D0=BA=D0=B8=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=BA=D0=B0=D0=BA=20=D0=B2=20Telegram:=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B5=D0=BD=D0=B4=D0=B6=D0=B5=D1=80-=D0=BF?= =?UTF-8?q?=D1=83=D0=B7=D1=8B=D1=80=D0=B8=20+=20=D1=80=D0=B5=D0=B4=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MdParser::code_block: Telegram-пузырь (bg #f5f5f7/#212121, header #e8e8ea/#2a2a2a, border-radius 12, shadow), lang badge uppercase, copy-кнопка '📋 Копировать' с hover/active (bg #ffffff/#333, border, radius 6, цвет #0088cc), JetBrains Mono 13px, line-height 1.5, tab-size 4 - EditorPanel TextArea: JetBrains Mono, tabStopDistance 32, авто-закрытие ()[]{}'"'<> (обёртка выделения, перепрыгивание), Tab→4 пробела / Shift+Tab снятие, Backspace удаление 4 пробелов как одного, Enter сохранение отступа (+4 для { [ ( : > и внутри скобок), тег-автозакрытие
→
, подсветка парной скобки (o ↔ m) с таймером 800мс - Кнопка копирования: hover/active через inline style + toast 'Скопировано!', lastCopiedId --- qml/panels/EditorPanel.qml | 247 ++++++++++++++++++++++++++++++++++++- src/markdown/MdParser.cpp | 32 +++-- 2 files changed, 265 insertions(+), 14 deletions(-) diff --git a/qml/panels/EditorPanel.qml b/qml/panels/EditorPanel.qml index 21c7600..6e1dc70 100644 --- a/qml/panels/EditorPanel.qml +++ b/qml/panels/EditorPanel.qml @@ -274,11 +274,67 @@ Item { color: Theme.editorText selectionColor: Theme.selectionBg selectedTextColor: Theme.text - font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace" - font.pixelSize: 14 + font.family: "JetBrains Mono, Fira Code, Menlo, Consolas, 'DejaVu Sans Mono', monospace" + font.pixelSize: 13 wrapMode: TextArea.NoWrap selectByMouse: true background: Rectangle { color: Theme.editorBg } + tabStopDistance: 32 + // Для подсветки скобок + property int bracketMatchPos: -1 + property int bracketOrigPos: -1 + Timer { id: bracketTimer; interval: 800; onTriggered: { textArea.bracketMatchPos = -1; textArea.bracketOrigPos = -1 } } + + onCursorPositionChanged: { + if (!activeFocus) return + // Подсветка парной скобки + const pos = cursorPosition + const txt = text + if (pos < 0 || pos > txt.length) { bracketMatchPos = -1; bracketOrigPos = -1; return } + const ch = pos > 0 ? txt[pos-1] : "" + const nextCh = pos < txt.length ? txt[pos] : "" + let target = "" + let dir = 0 + let origPos = -1 + if ("({[<".indexOf(ch) !== -1) { target = ch; dir = 1; origPos = pos-1 } + else if (")}]>" .indexOf(ch) !== -1) { target = ch; dir = -1; origPos = pos-1 } + else if ("({[<".indexOf(nextCh) !== -1) { target = nextCh; dir = 1; origPos = pos } + else if (")}]>" .indexOf(nextCh) !== -1) { target = nextCh; dir = -1; origPos = pos } + else { bracketMatchPos = -1; bracketOrigPos = -1; return } + const pairs = {"(":")", ")":"(", "[":"]", "]":"[", "{":"}", "}":"{", "<":">", ">":"<"} + const match = pairs[target] + if (!match) { bracketMatchPos = -1; bracketOrigPos = -1; return } + let depth = 0 + let found = -1 + if (dir === 1) { + for (let i = origPos+1; i < txt.length; i++) { + const c = txt[i] + if (c === target) depth++ + else if (c === match) { + if (depth === 0) { found = i; break } + else depth-- + } + } + } else { + for (let i = origPos-1; i >= 0; i--) { + const c = txt[i] + if (c === target) depth++ + else if (c === match) { + if (depth === 0) { found = i; break } + else depth-- + } + } + } + if (found >= 0) { + bracketMatchPos = found + bracketOrigPos = origPos + bracketTimer.restart() + } else { + bracketMatchPos = -1 + bracketOrigPos = -1 + } + } + onTextChanged: { if (activeFocus && text !== documentManager.contentAt(documentManager.activeIndex)) { editDebounce.restart() @@ -286,6 +342,193 @@ Item { if (viewMode === "live") liveDebounce.restart() } function insertText(t) { insert(cursorPosition, t) } + + // Авто-отступ и скобки + Keys.onPressed: (event) => { + // Tab — 4 пробела, Shift+Tab — снять отступ + if (event.key === Qt.Key_Tab) { + event.accepted = true + if (event.modifiers & Qt.ShiftModifier) { + // Снять отступ + const pos = cursorPosition + const txt = text + let lineStart = txt.lastIndexOf("\n", pos-1) + 1 + let line = txt.substring(lineStart, pos) + if (line.endsWith(" ")) { + remove(lineStart + line.length - 4, pos) + cursorPosition = pos - 4 + } else if (line.endsWith("\t")) { + remove(pos-1, pos) + cursorPosition = pos - 1 + } + } else { + if (selectionStart !== selectionEnd) { + // Отступ для выделения — добавить 4 пробела к каждой строке + const s = selectionStart, e = selectionEnd + let start = text.lastIndexOf("\n", s-1) + 1 + let end = text.indexOf("\n", e) + if (end < 0) end = text.length + const block = text.substring(start, end) + const newBlock = block.split("\n").map(l => " " + l).join("\n") + remove(start, end) + insert(start, newBlock) + select(start, start + newBlock.length) + } else { + insert(cursorPosition, " ") + } + } + if (documentManager.activeIndex >=0) documentManager.updateContent(documentManager.activeIndex, text) + return + } + // Backspace — удалить 4 пробела как один отступ + if (event.key === Qt.Key_Backspace && selectionStart === selectionEnd) { + const pos = cursorPosition + if (pos >= 4) { + const before = text.substring(pos-4, pos) + if (before === " ") { + const lineStart = text.lastIndexOf("\n", pos-1) + 1 + // только если это отступ в начале строки (все пробелы до курсора) + const between = text.substring(lineStart, pos) + if (/^ +$/.test(between)) { + event.accepted = true + remove(pos-4, pos) + cursorPosition = pos - 4 + if (documentManager.activeIndex >=0) documentManager.updateContent(documentManager.activeIndex, text) + return + } + } + } + } + // Enter — сохранить отступ + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + event.accepted = true + const pos = cursorPosition + let lineStart = text.lastIndexOf("\n", pos-1) + 1 + let line = text.substring(lineStart, pos) + let indent = "" + const m = line.match(/^(\s+)/) + if (m) indent = m[1] + // Если предыдущая строка заканчивается на { [ ( или : — добавить отступ + const trimmed = line.trimEnd() + if (trimmed.endsWith("{") || trimmed.endsWith("[") || trimmed.endsWith("(") || trimmed.endsWith(":") || trimmed.endsWith(">")) { + // проверим не закрыта ли скобка сразу + indent += " " + } + // Если мы внутри скобок {} — добавить перенос и отступ для закрывающей + let extra = "" + if (pos < text.length) { + const next = text[pos] + if (next === "}" || next === "]" || next === ")") { + extra = "\n" + indent.substring(0, Math.max(0, indent.length-4)) + } + } + const insertText = "\n" + indent + extra + insert(pos, insertText) + cursorPosition = pos + 1 + indent.length + if (documentManager.activeIndex >=0) documentManager.updateContent(documentManager.activeIndex, text) + return + } + // Тег автозакрытие при вводе > — ДО проверки перепрыгивания, чтобы вставить + if (event.text === ">") { + const pos = cursorPosition + const before = text.substring(0, pos) + const m = before.match(/<([A-Za-z][A-Za-z0-9\-]*)\b[^>]*$/) + if (m && !before.endsWith("/")) { + const tag = m[1] + const selfClosing = ["br","hr","img","input","meta","link","area","base","col","embed","source","track","wbr"].indexOf(tag.toLowerCase()) !== -1 + if (!selfClosing) { + // Если следующий символ уже > (от авто-закрытия <), просто перепрыгнем и вставим + if (pos < text.length && text[pos] === ">") { + event.accepted = true + cursorPosition = pos + 1 + insert(cursorPosition, "") + cursorPosition = pos + 1 + if (documentManager.activeIndex >=0) documentManager.updateContent(documentManager.activeIndex, text) + return + } else { + event.accepted = true + insert(pos, ">" + "") + cursorPosition = pos + 1 + if (documentManager.activeIndex >=0) documentManager.updateContent(documentManager.activeIndex, text) + return + } + } + } + } + // Авто-закрытие скобок/кавычек + const pairMap = {"(":")", "[":"]", "{":"}", "\"":"\"", "'":"'", "`":"`", "<":">"} + const closingMap = {")":"(", "]":"[", "}":"{", "\"":"\"", "'":"'", "`":"`", ">":"<"} + // Если нажат закрывающий символ и следующий символ такой же — перепрыгнуть + if (event.text.length===1 && closingMap[event.text] !== undefined) { + const pos = cursorPosition + if (pos < text.length && text[pos] === event.text) { + event.accepted = true + cursorPosition = pos + 1 + return + } + } + if (event.text.length===1 && pairMap[event.text] !== undefined) { + // Не автозакрывать кавычки внутри слова + if ((event.text === "\"" || event.text === "'" || event.text === "`") && cursorPosition>0) { + const prev = text[cursorPosition-1] + if (/[A-Za-z0-9_]/.test(prev)) { + return // обычный ввод + } + } + event.accepted = true + const pos = cursorPosition + const open = event.text + const close = pairMap[open] + // Если есть выделение — обернуть + if (selectionStart !== selectionEnd) { + const s = selectionStart, e = selectionEnd + const sel = selectedText + remove(s, e) + insert(s, open + sel + close) + select(s, s + open.length + sel.length + close.length) + if (open === "{" || open === "(" || open === "[") { + // оставить выделение + } else { + cursorPosition = s + open.length + sel.length + close.length + select(s, s) + } + } else { + insert(pos, open + close) + cursorPosition = pos + 1 + } + if (documentManager.activeIndex >=0) documentManager.updateContent(documentManager.activeIndex, text) + return + } + } + + // Подсветка парной скобки — оверлей + Rectangle { + visible: textArea.bracketMatchPos >=0 && textArea.bracketOrigPos >=0 + // Позиция подсветки вычисляется приблизительно — покажем индикатор внизу + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: 4 + width: bracketLabel.implicitWidth + 12 + height: 22 + radius: 6 + color: Theme.accentMuted + border.color: Theme.accent + opacity: 0.9 + Label { + id: bracketLabel + anchors.centerIn: parent + text: { + if (textArea.bracketOrigPos <0) return "" + const txt = textArea.text + const o = txt[textArea.bracketOrigPos] || "" + const m = txt[textArea.bracketMatchPos] || "" + return o + " ↔ " + m + } + color: Theme.accentText + font.pixelSize: 11 + font.family: "Menlo, monospace" + } + } } } diff --git a/src/markdown/MdParser.cpp b/src/markdown/MdParser.cpp index 4050ef9..b1974d6 100644 --- a/src/markdown/MdParser.cpp +++ b/src/markdown/MdParser.cpp @@ -486,29 +486,37 @@ private: m_impl->codeBlocks.insert(codeId, code); } - // мессенджер-стиль: отдельный блок-рамка с закруглениями, без внешней границы, фон на весь блок + // Telegram-стиль: пузырь сообщения с кодом, как в мессенджере QSettings s; const bool isDark = s.value("ui/theme").toString() == "dark"; - const QString bg = isDark ? "#2d333f" : "#f8f9fb"; - const QString headerBg = isDark ? "#252a33" : "#eef1f3"; - const QString headerText = isDark ? "#8b949e" : "#656d76"; + // Telegram-like цвета: светлая — #f5f5f7, тёмная — #1a1a1a / #212121 + const QString bg = isDark ? "#212121" : "#f5f5f7"; + const QString headerBg = isDark ? "#2a2a2a" : "#e8e8ea"; + const QString headerBorder = isDark ? "#333333" : "#d0d0d0"; + const QString headerText = isDark ? "#8e8e93" : "#6d6d72"; + const QString codeTextColor = isDark ? "#e0e0e0" : "#24292f"; const QString langDisplay = lang.isEmpty() ? "text" : esc(lang); const QString cls = lang.isEmpty() ? QString() : QStringLiteral(" class=\"language-%1\"").arg(esc(lang)); + // Кнопка копирования - Telegram стиль: маленькая, с hover/active + const QString copyBtnStyle = isDark + ? "display:inline-block;background:#333333;color:#8e8e93;text-decoration:none;font-size:12px;padding:4px 10px;border-radius:6px;border:1px solid #404040;font-family: -apple-system, sans-serif;" + : "display:inline-block;background:#ffffff;color:#0088cc;text-decoration:none;font-size:12px;padding:4px 10px;border-radius:6px;border:1px solid #d0d0d0;font-family: -apple-system, sans-serif;"; const QString copyLink = codeId.isEmpty() ? QString() - : QStringLiteral("📋").arg(codeId); + : QStringLiteral("📋 Копировать").arg(codeId, copyBtnStyle); - // рамка как сообщение: фон на весь блок, закругления 12px, без бордера, overflow:hidden + // Пузырь как в Telegram: скругления 12px, тень, граница, header отделен return QStringLiteral( - "
" - "
" + "
" + "
" "" - "" - "" + "" + "" "
%4%5%5%6
" "
" - "
%6
" + "
"
+            "%8
" "
" - ).arg(bg, headerBg, headerText, langDisplay, copyLink, highlighted, cls); + ).arg(bg, headerBorder, headerBg, headerText, langDisplay, copyLink, codeTextColor, highlighted, cls); } QString inlineAll(cmark_node *n)