Блоки кода как в 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:
+245
-2
@@ -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
|
||||
}
|
||||
// Тег автозакрытие при вводе > — ДО проверки перепрыгивания, чтобы вставить </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
@@ -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("<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(
|
||||
"<div style=\"background:%1;border-radius:12px;overflow:hidden;margin:16px 0\">"
|
||||
"<div style=\"background:%2;padding:8px 14px\">"
|
||||
"<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:%3;padding:7px 12px;border-bottom:1px solid %2\">"
|
||||
"<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\"><tr>"
|
||||
"<td style=\"text-align:left;color:%3;font-size:11px\">%4</td>"
|
||||
"<td style=\"text-align:right\">%5</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\">%6</td>"
|
||||
"</tr></table>"
|
||||
"</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>"
|
||||
).arg(bg, headerBg, headerText, langDisplay, copyLink, highlighted, cls);
|
||||
).arg(bg, headerBorder, headerBg, headerText, langDisplay, copyLink, codeTextColor, highlighted, cls);
|
||||
}
|
||||
|
||||
QString inlineAll(cmark_node *n)
|
||||
|
||||
Reference in New Issue
Block a user