Блоки кода как в Telegram: мессенджер-пузыри + редактор

- 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 для { [ ( : > и внутри скобок), тег-автозакрытие <div>→</div>, подсветка парной скобки (o ↔ m) с таймером 800мс
- Кнопка копирования: hover/active через inline style + toast 'Скопировано!', lastCopiedId
This commit is contained in:
2026-09-01 20:12:02 +03:00
parent afc15680e1
commit a24f782205
2 changed files with 265 additions and 14 deletions
+245 -2
View File
@@ -274,11 +274,67 @@ Item {
color: Theme.editorText color: Theme.editorText
selectionColor: Theme.selectionBg selectionColor: Theme.selectionBg
selectedTextColor: Theme.text selectedTextColor: Theme.text
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace" font.family: "JetBrains Mono, Fira Code, Menlo, Consolas, 'DejaVu Sans Mono', monospace"
font.pixelSize: 14 font.pixelSize: 13
wrapMode: TextArea.NoWrap wrapMode: TextArea.NoWrap
selectByMouse: true selectByMouse: true
background: Rectangle { color: Theme.editorBg } 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: { onTextChanged: {
if (activeFocus && text !== documentManager.contentAt(documentManager.activeIndex)) { if (activeFocus && text !== documentManager.contentAt(documentManager.activeIndex)) {
editDebounce.restart() editDebounce.restart()
@@ -286,6 +342,193 @@ Item {
if (viewMode === "live") liveDebounce.restart() if (viewMode === "live") liveDebounce.restart()
} }
function insertText(t) { insert(cursorPosition, t) } 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
}
// Тег автозакрытие при вводе > — ДО проверки перепрыгивания, чтобы вставить </tag>
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) {
// Если следующий символ уже > (от авто-закрытия <), просто перепрыгнем и вставим </tag>
if (pos < text.length && text[pos] === ">") {
event.accepted = true
cursorPosition = pos + 1
insert(cursorPosition, "</" + tag + ">")
cursorPosition = pos + 1
if (documentManager.activeIndex >=0) documentManager.updateContent(documentManager.activeIndex, text)
return
} else {
event.accepted = true
insert(pos, ">" + "</" + tag + ">")
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"
}
}
} }
} }
+20 -12
View File
@@ -486,29 +486,37 @@ private:
m_impl->codeBlocks.insert(codeId, code); m_impl->codeBlocks.insert(codeId, code);
} }
// мессенджер-стиль: отдельный блок-рамка с закруглениями, без внешней границы, фон на весь блок // Telegram-стиль: пузырь сообщения с кодом, как в мессенджере
QSettings s; QSettings s;
const bool isDark = s.value("ui/theme").toString() == "dark"; const bool isDark = s.value("ui/theme").toString() == "dark";
const QString bg = isDark ? "#2d333f" : "#f8f9fb"; // Telegram-like цвета: светлая — #f5f5f7, тёмная — #1a1a1a / #212121
const QString headerBg = isDark ? "#252a33" : "#eef1f3"; const QString bg = isDark ? "#212121" : "#f5f5f7";
const QString headerText = isDark ? "#8b949e" : "#656d76"; 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 langDisplay = lang.isEmpty() ? "text" : esc(lang);
const QString cls = lang.isEmpty() ? QString() : QStringLiteral(" class=\"language-%1\"").arg(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() const QString copyLink = codeId.isEmpty() ? QString()
: QStringLiteral("<a href=\"flora://copycode/%1\" style=\"color:#8b949e;text-decoration:none;font-size:14px\">📋</a>").arg(codeId); : QStringLiteral("<a href=\"flora://copycode/%1\" style=\"%2\" title=\"Копировать\">📋 Копировать</a>").arg(codeId, copyBtnStyle);
// рамка как сообщение: фон на весь блок, закругления 12px, без бордера, overflow:hidden // Пузырь как в Telegram: скругления 12px, тень, граница, header отделен
return QStringLiteral( return QStringLiteral(
"<div style=\"background:%1;border-radius:12px;overflow:hidden;margin:16px 0\">" "<div style=\"background:%1;border:1px solid %2;border-radius:12px;overflow:hidden;margin:16px 0;box-shadow:0 1px 2px rgba(0,0,0,0.08)\">"
"<div style=\"background:%2;padding:8px 14px\">" "<div style=\"background:%3;padding:7px 12px;border-bottom:1px solid %2\">"
"<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\"><tr>" "<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\"><tr>"
"<td style=\"text-align:left;color:%3;font-size:11px\">%4</td>" "<td style=\"text-align:left;color:%4;font-size:11px;font-weight:600;letter-spacing:0.3px;text-transform:uppercase;\">%5</td>"
"<td style=\"text-align:right\">%5</td>" "<td style=\"text-align:right\">%6</td>"
"</tr></table>" "</tr></table>"
"</div>" "</div>"
"<div style=\"background:%1;padding:14px\"><pre style=\"margin:0\"><code%7>%6</code></pre></div>" "<div style=\"background:%1;padding:12px 14px;overflow-x:auto\"><pre style=\"margin:0;font-family:'JetBrains Mono','Fira Code','Menlo','Consolas',monospace;font-size:13px;line-height:1.5;tab-size:4;color:%7;white-space:pre-wrap;word-wrap:break-word\">"
"<code%9>%8</code></pre></div>"
"</div>" "</div>"
).arg(bg, headerBg, headerText, langDisplay, copyLink, highlighted, cls); ).arg(bg, headerBorder, headerBg, headerText, langDisplay, copyLink, codeTextColor, highlighted, cls);
} }
QString inlineAll(cmark_node *n) QString inlineAll(cmark_node *n)