Блоки кода как в 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
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"
}
}
}
}