Files
Flora/qml/panels/EditorPanel.qml
T
grendervill d7ca84863c Enter: надёжная обработка новой строки + hit-зона по всей высоте
- TextArea focus:true, handleEnter() с effectiveLine для пустой строки, _enterHandled флаг, onTextChanged авто-отступ для \n, Keys.onReturnPressed/onEnterPressed + onPressed Tab/Backspace, trim() вместо trimEnd()
- SplitView handle: implicit 12 + fill parent + MouseArea 16 по всей высоте/ширине (файловое дерево, живой просмотр, граф/календарь)
- MonthCalendar: 28x28 круглые opaque, month/year раздельно (70/110 + 42/38, font 11, elide) — год не пропадает для 'сентябрь 2026', RightPanel удалён дубль ☷
2026-09-01 23:36:04 +03:00

1014 lines
48 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Flora
Item {
id: root
signal historyRequested(string path)
property bool sideBySide: false
property string viewMode: "edit" // edit, preview, live — per-tab, persisted
property bool _loadingViewMode: false
function loadViewModeForCurrentTab() {
if (documentManager.activeIndex < 0 || !fileStore.ready) return
const path = documentManager.pathAt(documentManager.activeIndex)
if (!path) return
const saved = settingsStore.viewModeForFile(fileStore.vault, path)
_loadingViewMode = true
viewMode = saved
_loadingViewMode = false
// обновить превью если нужно
if (viewMode === "preview") {
viewArea.markdown = textArea.text
} else if (viewMode === "live") {
livePreview.markdown = textArea.text
}
}
function saveViewModeForCurrentTab() {
if (_loadingViewMode) return
if (documentManager.activeIndex < 0 || !fileStore.ready) return
const path = documentManager.pathAt(documentManager.activeIndex)
if (!path) return
settingsStore.setViewModeForFile(fileStore.vault, path, viewMode)
}
onViewModeChanged: {
if (_loadingViewMode) return
saveViewModeForCurrentTab()
if (viewMode === "preview") {
viewArea.markdown = textArea.text
} else if (viewMode === "live") {
livePreview.markdown = textArea.text
}
}
function baseName(p) {
const i = p.lastIndexOf("/")
return i >= 0 ? p.substring(i + 1) : p
}
function dirName(p) {
const i = p.lastIndexOf("/")
return i >= 0 ? p.substring(0, i) : ""
}
// --- Вкладки с перетаскиванием ---
Rectangle {
id: tabRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 38
color: Theme.bgAlt
border.color: Theme.divider
Rectangle { anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right; height: 1; color: Theme.divider }
ScrollView {
id: tabScroll
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
clip: true
ScrollBar.horizontal.policy: ScrollBar.AsNeeded
ScrollBar.vertical.policy: ScrollBar.AlwaysOff
Row {
id: tabRowLayout
spacing: 4
height: tabScroll.height
Repeater {
model: documentManager.openTabs
delegate: Rectangle {
id: tab
required property string modelData
required property int index
readonly property bool active: index === documentManager.activeIndex
y: 5
width: Math.max(120, tabLabel.implicitWidth + 40)
height: 28; radius: 6
color: active ? Theme.tabActiveBg : Theme.tabInactiveBg
border.color: active ? Theme.accent : (dropArea.containsDrag ? Theme.accent : "transparent")
border.width: active || dropArea.containsDrag ? 1 : 0
opacity: Drag.active ? 0.6 : 1.0
// Drag для переупорядочивания
Drag.active: dragHandler.active
Drag.source: tab
Drag.hotSpot.x: width/2
Drag.hotSpot.y: height/2
Drag.keys: ["tab"]
Rectangle {
visible: active
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 2
color: Theme.accent
radius: 1
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 4
spacing: 4
Label {
id: tabLabel
Layout.fillWidth: true
text: root.baseName(modelData) + (documentManager.isDirty(index) ? " •" : "")
font.pixelSize: 12
color: tab.active ? Theme.text : Theme.textSecondary
elide: Text.ElideMiddle
}
ToolButton {
text: "\u2715"
implicitWidth: 20
implicitHeight: 20
font.pixelSize: 10
opacity: tab.active || hovered ? 0.9 : 0
visible: opacity > 0
onClicked: documentManager.closeTab(index)
ToolTip.text: qsTr("Закрыть")
ToolTip.visible: hovered
}
}
HoverHandler { id: hover }
// Клик — смена активной вкладки, средняя кнопка — закрыть
TapHandler {
id: tapHandler
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onTapped: (eventPoint, button) => {
if (button === Qt.MiddleButton) documentManager.closeTab(index)
else documentManager.activeIndex = index
}
}
// Перетаскивание вкладок
DragHandler {
id: dragHandler
target: null
acceptedButtons: Qt.LeftButton
}
DropArea {
id: dropArea
anchors.fill: parent
keys: ["tab"]
onEntered: (drag) => {
if (drag.source && drag.source !== tab) drag.accepted = true
}
onDropped: (drop) => {
if (drop.source && drop.source.index !== undefined) {
const from = drop.source.index
const to = index
if (from !== to) documentManager.moveTab(from, to)
}
}
}
}
}
}
}
Label {
visible: documentManager.openTabs.length === 0
anchors.centerIn: parent
text: qsTr("Нет открытых файлов")
color: Theme.textMuted
font.pixelSize: 12
font.italic: true
}
ToolButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: 6
visible: false // старый sideBySide убран в пользу live
}
}
// Хлебные крошки
Rectangle {
id: crumbs
anchors.top: tabRow.bottom
anchors.left: parent.left
anchors.right: parent.right
height: visible ? 24 : 0
visible: documentManager.activeIndex >= 0
color: Theme.bg
Label {
anchors.fill: parent
anchors.leftMargin: 10
verticalAlignment: Text.AlignVCenter
text: {
const p = documentManager.pathAt(documentManager.activeIndex)
if (!p) return ""
const vault = fileStore.vault
let rel = p
if (vault && p.startsWith(vault)) rel = p.substring(vault.length + 1)
return rel
}
color: Theme.textMuted
font.pixelSize: 11
elide: Text.ElideMiddle
}
Rectangle { anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right; height: 1; color: Theme.divider; visible: viewMode !== "live" }
}
FormatToolbar {
id: fmtBar
anchors.top: crumbs.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 36
visible: documentManager.activeIndex >= 0 && viewMode !== "preview"
targetPane: root
onTableRequested: { tableEditor.resetDialog(); tableEditor.open() }
}
// --- Контент ---
SplitView {
id: contentSplit
anchors.top: fmtBar.visible ? fmtBar.bottom : crumbs.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: toolbar.top
orientation: Qt.Horizontal
handle: Rectangle {
implicitWidth: viewMode === "live" ? 12 : 0
color: "transparent"
visible: viewMode === "live"
Rectangle {
anchors.centerIn: parent
width: 1; height: parent.height
color: Theme.divider
visible: parent.visible
}
Rectangle {
anchors.fill: parent
color: "transparent"
visible: parent.visible
MouseArea {
anchors.fill: parent
cursorShape: Qt.SplitHCursor
hoverEnabled: true
onPressed: mouse.accepted = false
}
}
}
// Редактор — виден в edit и live
ScrollView {
id: editorScroll
SplitView.fillWidth: true
SplitView.minimumWidth: 200
visible: viewMode !== "preview" && documentManager.activeIndex >= 0
clip: true
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ScrollBar.horizontal.policy: ScrollBar.AsNeeded
TextArea {
id: textArea
focus: true
placeholderText: qsTr("Начните писать…")
placeholderTextColor: Theme.textFaint
color: Theme.editorText
selectionColor: Theme.selectionBg
selectedTextColor: Theme.text
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
Component.onCompleted: forceActiveFocus()
onActiveFocusChanged: console.log("TextArea activeFocus", activeFocus, "focus", focus)
// Для подсветки скобок
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
}
}
property string _prevText: ""
property int _prevCursor: 0
property bool _enterHandled: false
onTextChanged: {
// Авто-отступ при Enter: если был вставлен \n, добавляем indent (если не обработано через Keys)
if (_enterHandled) {
_enterHandled = false
_prevText = text
_prevCursor = cursorPosition
if (activeFocus && text !== documentManager.contentAt(documentManager.activeIndex)) {
editDebounce.restart()
}
if (viewMode === "live") liveDebounce.restart()
return
}
if (activeFocus && _prevText.length > 0) {
const pos = cursorPosition
// Проверяем был ли вставлен один \n
if (text.length === _prevText.length + 1 && text[pos-1] === "\n") {
// Найдём отступ предыдущей строки
let lineStart = _prevText.lastIndexOf("\n", _prevCursor-1) + 1
let line = _prevText.substring(lineStart, _prevCursor)
let indent = ""
const m = line.match(/^(\s+)/)
if (m) indent = m[1]
const trimmed = line.trim()
if (trimmed.endsWith("{") || trimmed.endsWith("[") || trimmed.endsWith("(") || trimmed.endsWith(":") || trimmed.endsWith(">")) {
indent += " "
}
if (indent.length > 0) {
// Вставляем indent после \n
const before = text.substring(0, pos)
const after = text.substring(pos)
// Проверяем что indent ещё не вставлен (чтобы не дублировать)
if (!after.startsWith(indent.substring(0,4))) {
const newText = before + indent + after
// Блокируем рекурсию
_prevText = newText
text = newText
cursorPosition = pos + indent.length
if (documentManager.activeIndex >=0) documentManager.updateContent(documentManager.activeIndex, text)
_prevText = text
_prevCursor = cursorPosition
if (viewMode === "live") liveDebounce.restart()
if (activeFocus && text !== documentManager.contentAt(documentManager.activeIndex)) {
editDebounce.restart()
}
return
}
}
}
}
_prevText = text
_prevCursor = cursorPosition
if (activeFocus && text !== documentManager.contentAt(documentManager.activeIndex)) {
editDebounce.restart()
}
if (viewMode === "live") liveDebounce.restart()
}
function insertText(t) { insert(cursorPosition, t) }
function handleEnter() {
_enterHandled = 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]
// Если текущая строка пуста (курсор в начале), берём отступ предыдущей строки
let effectiveLine = line
if (line.length === 0 && pos > 0) {
let prevEnd = pos - 1
if (prevEnd >=0 && text[prevEnd] === "\n") prevEnd--
let prevStart = text.lastIndexOf("\n", prevEnd-1) + 1
if (prevStart < 0) prevStart = 0
effectiveLine = text.substring(prevStart, prevEnd+1)
const m2 = effectiveLine.match(/^(\s+)/)
if (m2) indent = m2[1]
else indent = ""
}
const trimmed = effectiveLine.trim()
if (trimmed.endsWith("{") || trimmed.endsWith("[") || trimmed.endsWith("(") || trimmed.endsWith(":") || trimmed.endsWith(">")) {
indent += " "
}
// Если курсор в начале строки с } (line пуста и next === }), то вставляем indent + "\n" перед }, а не "\n" + indent
if (line.length === 0 && pos < text.length) {
const next = text[pos]
if (next === "}" || next === "]" || next === ")") {
const ins2 = indent + "\n"
insert(pos, ins2)
cursorPosition = pos + indent.length
if (documentManager.activeIndex >=0) documentManager.updateContent(documentManager.activeIndex, text)
return
}
}
let extra = ""
if (pos < text.length) {
const next = text[pos]
if ((next === "}" || next === "]" || next === ")") && line.length > 0) {
extra = "\n" + indent.substring(0, Math.max(0, indent.length-4))
}
}
const ins = "\n" + indent + extra
insert(pos, ins)
cursorPosition = pos + 1 + indent.length
if (documentManager.activeIndex >=0) documentManager.updateContent(documentManager.activeIndex, text)
// _enterHandled сбросится в onTextChanged
}
// Авто-отступ и скобки
Keys.onPressed: (event) => {
console.log("Keys.onPressed", event.key, event.text, event.modifiers, "cursor", cursorPosition)
// Enter — дублируем обработку здесь на случай если onReturnPressed не сработает
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
console.log("Enter via onPressed")
event.accepted = true
handleEnter()
return
}
// 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
}
}
}
}
// Тег автозакрытие при вводе > — ДО проверки перепрыгивания, чтобы вставить </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"
}
}
}
}
// Превью — виден в preview и live (отдельный компонент для блоков кода)
ScrollView {
id: viewScroll
SplitView.fillWidth: true
SplitView.minimumWidth: 200
visible: viewMode !== "edit" && documentManager.activeIndex >= 0
clip: true
ScrollBar.vertical.policy: ScrollBar.AsNeeded
background: Rectangle { color: Theme.editorBg }
MarkdownView {
id: viewArea
visible: viewMode === "preview"
width: viewScroll.width
vaultRoot: fileStore.vault
fileDir: activeFileDir()
onLinkActivated: (href) => handleLink(href)
}
MarkdownView {
id: livePreview
visible: viewMode === "live"
width: viewScroll.width
vaultRoot: fileStore.vault
fileDir: activeFileDir()
onLinkActivated: (href) => handleLink(href)
}
}
}
function handleLink(href) {
if (href.startsWith("flora://copycode/")) {
const id = href.substring(17)
const code = mdParser.getCodeBlock(id)
if (code.length > 0) {
mdParser.copyToClipboard(code)
copyFeedback.text = qsTr("Скопировано!")
copyFeedback.visible = true
copyTimer.restart()
}
return
}
if (href.startsWith("flora://task/")) {
const line = Number(href.substring(13))
if (line > 0) {
const lines = textArea.text.split("\n")
const ln = lines[line - 1]
if (ln !== undefined) {
if (/\[ \]/.test(ln)) lines[line - 1] = ln.replace("[ ]", "[x]")
else if (/\[x\]/.test(ln)) lines[line - 1] = ln.replace("[x]", "[ ]")
textArea.text = lines.join("\n")
documentManager.updateContent(documentManager.activeIndex, textArea.text)
if (viewMode === "preview") viewArea.markdown = textArea.text
else if (viewMode === "live") livePreview.markdown = textArea.text
}
}
} else if (href.startsWith("flora://open/")) {
documentManager.openDocument(decodeURIComponent(href.substring(13)))
} else if (href.startsWith("flora://wiki/")) {
const name = decodeURIComponent(href.substring(12))
const p = fileStore.vault + "/" + name + ".md"
documentManager.openDocument(p)
} else {
Qt.openUrlExternally(href)
}
}
// Тост "Скопировано"
Rectangle {
id: copyToast
anchors.top: contentSplit.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: 8
visible: copyFeedback.visible
color: "#333"
radius: 6
width: copyFeedback.implicitWidth + 16
height: copyFeedback.implicitHeight + 10
z: 10
Label {
id: copyFeedback
anchors.centerIn: parent
visible: false
text: qsTr("Скопировано!")
color: "white"
font.pixelSize: 11
}
}
Timer { id: copyTimer; interval: 1500; onTriggered: copyFeedback.visible = false }
// Пустое состояние
Column {
anchors.centerIn: contentSplit
visible: documentManager.activeIndex < 0
spacing: 12
Label { anchors.horizontalCenter: parent.horizontalCenter; text: "\uD83D\uDCC4"; font.pixelSize: 36; opacity: 0.6 }
Label { text: qsTr("Откройте файл из дерева слева"); color: Theme.textMuted; font.pixelSize: 13 }
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Создать заметку")
enabled: fileStore.ready
onClicked: {
const err = fileStore.createFile(fileStore.vault, "Новая заметка")
if (err.length === 0) {
for (let i=0;i<fileStore.rowCount();++i) if (fileStore.itemPath(i).endsWith("Новая заметка.md")) { documentManager.openDocument(fileStore.itemPath(i)); break }
}
}
}
}
Timer { id: editDebounce; interval: 250; onTriggered: documentManager.updateContent(documentManager.activeIndex, textArea.text) }
Timer { id: liveDebounce; interval: 150; onTriggered: { if (viewMode === "live") livePreview.markdown = textArea.text } }
property string gitStatusText: ""
property string lastModifiedStr: ""
function refreshGitStatus() {
if (documentManager.activeIndex < 0 || !fileStore.ready) { gitStatusText = ""; lastModifiedStr = ""; return }
const p = documentManager.pathAt(documentManager.activeIndex)
if (!p) { gitStatusText = ""; lastModifiedStr = ""; return }
try { gitStatusText = gitManager.fileGitStatus(fileStore.vault, p) } catch(e) { gitStatusText = "" }
try { lastModifiedStr = gitManager.lastModifiedTimeString(fileStore.vault, p) } catch(e) { lastModifiedStr = "" }
}
function loadContent() {
const idx = documentManager.activeIndex
// загружаем режим для текущей вкладки перед обновлением содержимого
if (idx >= 0 && fileStore.ready) {
const path = documentManager.pathAt(idx)
if (path) {
const saved = settingsStore.viewModeForFile(fileStore.vault, path)
if (saved !== viewMode) {
_loadingViewMode = true
viewMode = saved
_loadingViewMode = false
}
}
}
if (idx >= 0) {
const c = documentManager.contentAt(idx)
if (textArea.text !== c) {
const pos = textArea.cursorPosition
textArea.text = c
textArea.cursorPosition = Math.min(pos, c.length)
}
if (viewMode === "preview") {
viewArea.markdown = c
} else if (viewMode === "live") {
livePreview.markdown = c
}
} else {
textArea.text = ""
viewArea.markdown = ""
livePreview.markdown = ""
}
refreshGitStatus()
}
Connections {
target: documentManager
function onActiveIndexChanged() { root.loadContent() }
function onContentChanged(idx) { if (idx === documentManager.activeIndex && !textArea.activeFocus) root.loadContent(); refreshGitStatus() }
function onOpenTabsChanged() { root.loadContent() }
function onDirtyFlagChanged() { refreshGitStatus() }
function onDocumentSaved(idx) {
refreshGitStatus()
// Авто-коммит для простого пользователя — каждый сохранённый файл попадает в историю
if (fileStore.ready && idx >= 0) {
const p = documentManager.pathAt(idx)
if (p && p.length>0) {
// Небольшая задержка чтобы не спамить на каждый автосейв
autoCommitDebounce.filePath = p
autoCommitDebounce.restart()
}
}
}
}
Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } }
Timer { id: gitPoll; interval: 3000; running: documentManager.activeIndex >= 0; repeat: true; onTriggered: refreshGitStatus() }
Timer {
id: autoCommitDebounce
property string filePath: ""
interval: 2000
onTriggered: {
if (fileStore.ready && filePath.length>0) {
gitManager.autoCommit(fileStore.vault, filePath)
refreshGitStatus()
}
}
}
Component.onCompleted: loadContent()
function applyInline(pre, post) {
if (viewMode === "preview") return
textArea.forceActiveFocus()
const s = textArea.selectionStart, e = textArea.selectionEnd
if (s !== e) {
const sel = textArea.selectedText
textArea.remove(s, e)
textArea.insert(s, pre + sel + post)
textArea.select(s, s + pre.length + sel.length + post.length)
} else {
const pos = textArea.cursorPosition
textArea.insert(pos, pre + post)
textArea.cursorPosition = pos + pre.length
}
documentManager.updateContent(documentManager.activeIndex, textArea.text)
if (viewMode === "live") liveDebounce.restart()
}
function applyHeading(level) {
if (viewMode === "preview") return
textArea.forceActiveFocus()
const txt = textArea.text
const pos = textArea.cursorPosition
let start = txt.lastIndexOf("\n", pos - 1) + 1
let end = txt.indexOf("\n", pos)
if (end < 0) end = txt.length
let line = txt.substring(start, end)
line = line.replace(/^#{1,6}\s*/, "")
const prefix = "#".repeat(level) + " "
const newLine = prefix + line
textArea.remove(start, end)
textArea.insert(start, newLine)
textArea.cursorPosition = start + newLine.length
documentManager.updateContent(documentManager.activeIndex, textArea.text)
}
function applyLinePrefix(prefix) {
if (viewMode === "preview") return
textArea.forceActiveFocus()
const txt = textArea.text
const s = textArea.selectionStart, e = textArea.selectionEnd
let start = txt.lastIndexOf("\n", s - 1) + 1
let finish = txt.indexOf("\n", e)
if (finish < 0) finish = txt.length
const block = txt.substring(start, finish)
const lines = block.split("\n")
const newBlock = lines.map(l => prefix + l).join("\n")
textArea.remove(start, finish)
textArea.insert(start, newBlock)
textArea.select(start, start + newBlock.length)
documentManager.updateContent(documentManager.activeIndex, textArea.text)
}
function applyTaskToggle() { applyLinePrefix("- [ ] ") }
function applyCallout(kind) { applyLinePrefix("> [!" + kind + "] ") }
function applyFloatWrap() {
if (viewMode === "preview") return
textArea.forceActiveFocus()
textArea.insert(textArea.cursorPosition, "![[image.png|float:left|width:40%]]")
documentManager.updateContent(documentManager.activeIndex, textArea.text)
}
function applyColor(color, highlightMode) {
if (viewMode === "preview") return
if (highlightMode) applyInline('<span style="background-color:' + color + '">', '</span>')
else applyInline('<span style="color:' + color + '">', '</span>')
}
function applySubSup(kind) {
if (kind === "sup") applyInline("<sup>", "</sup>")
else applyInline("<sub>", "</sub>")
}
// --- Нижняя панель ---
Rectangle {
id: toolbar
anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right
height: 36
color: Theme.toolbarBg
border.color: Theme.divider
RowLayout {
anchors.fill: parent; anchors.leftMargin: 8; anchors.rightMargin: 8; spacing: 8
Label {
visible: documentManager.activeIndex >= 0
text: {
const t = textArea.text
const words = t.trim().length === 0 ? 0 : t.trim().split(/\s+/).length
const chars = t.length
return qsTr("%1 слов, %2 симв.").arg(words).arg(chars)
}
color: Theme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
Item { Layout.fillWidth: true }
Row {
visible: documentManager.activeIndex >= 0
spacing: 8
Label {
text: documentManager.isDirty(documentManager.activeIndex) ? qsTr("● Редактируется") : qsTr("✓ Сохранено")
color: documentManager.isDirty(documentManager.activeIndex) ? Theme.warningText : Theme.successText
font.pixelSize: 11
font.bold: documentManager.isDirty(documentManager.activeIndex)
anchors.verticalCenter: parent.verticalCenter
}
Rectangle { width: 1; height: 12; color: Theme.divider; anchors.verticalCenter: parent.verticalCenter; visible: gitStatusText.length>0 }
Label {
visible: gitStatusText.length>0
text: "Git: " + gitStatusText
color: {
if (gitStatusText === "чисто") return Theme.textMuted
if (gitStatusText === "изменён" || gitStatusText === "неотслеживается" || gitStatusText === "новый") return Theme.warningText
if (gitStatusText === "конфликт") return Theme.errorText
return Theme.textMuted
}
font.pixelSize: 11
anchors.verticalCenter: parent.verticalCenter
}
}
ToolButton {
text: "🕘"
enabled: documentManager.activeIndex >= 0
ToolTip.text: lastModifiedStr.length>0 ? qsTr("История — последнее изменение: %1").arg(lastModifiedStr) : qsTr("История файла (Ctrl+H)")
ToolTip.visible: hovered
onClicked: {
const p = documentManager.pathAt(documentManager.activeIndex)
if (p) root.historyRequested(p)
}
}
Label {
visible: lastModifiedStr.length>0 && documentManager.activeIndex>=0
text: lastModifiedStr
color: Theme.textMuted
font.pixelSize: 10
Layout.alignment: Qt.AlignVCenter
ToolTip.text: qsTr("Последнее изменение")
HoverHandler { id: lastModHover }
ToolTip.visible: lastModHover.hovered
}
Item { width: 4 }
ButtonGroup { id: viewGroup }
ToolButton {
text: "✎"
checkable: true
checked: viewMode === "edit"
ButtonGroup.group: viewGroup
ToolTip.text: qsTr("Редактирование (Ctrl+E)")
ToolTip.visible: hovered
onClicked: viewMode = "edit"
}
ToolButton {
text: "\u25A8"
checkable: true
checked: viewMode === "live"
ButtonGroup.group: viewGroup
ToolTip.text: qsTr("Живой просмотр (редактор | превью)")
ToolTip.visible: hovered
onClicked: {
viewMode = "live"
livePreview.markdown = textArea.text
}
}
ToolButton {
text: "👁"
checkable: true
checked: viewMode === "preview"
ButtonGroup.group: viewGroup
ToolTip.text: qsTr("Просмотр (Ctrl+E)")
ToolTip.visible: hovered
onClicked: {
viewArea.markdown = textArea.text
viewMode = "preview"
}
}
}
}
function activeFileDir() {
const p = documentManager.pathAt(documentManager.activeIndex)
const i = p.lastIndexOf("/")
return i >= 0 ? p.substring(0, i) : ""
}
TableEditor { id: tableEditor; onMarkdownReady: (md) => { textArea.insert(textArea.cursorPosition, md); documentManager.updateContent(documentManager.activeIndex, textArea.text) } }
Shortcut { sequence: "Ctrl+S"; onActivated: { documentManager.saveAll(); refreshGitStatus() } }
Shortcut { sequence: "Ctrl+W"; onActivated: documentManager.closeTab(documentManager.activeIndex) }
Shortcut { sequence: "Ctrl+H"; onActivated: {
const p = documentManager.pathAt(documentManager.activeIndex)
if (p) root.historyRequested(p)
} }
Shortcut { sequence: "Ctrl+E"; onActivated: {
if (viewMode === "edit") {
viewArea.markdown = textArea.text
viewMode = "preview"
} else if (viewMode === "preview") {
viewMode = "edit"
} else {
viewMode = "edit"
}
} }
Shortcut { sequence: "Ctrl+Tab"; onActivated: { const n=documentManager.openTabs.length; if(n>0) documentManager.activeIndex = (documentManager.activeIndex+1)%n } }
Shortcut { sequence: "Ctrl+Shift+Tab"; onActivated: { const n=documentManager.openTabs.length; if(n>0) documentManager.activeIndex = (documentManager.activeIndex-1+n)%n } }
}