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") { const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) viewArea.text = rendered.trim().length === 0 ? "

" + qsTr("Пустой файл — начните писать в режиме редактирования") + "

" : rendered } else if (viewMode === "live") { const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) livePreview.text = rendered.trim().length === 0 ? "

" + qsTr("Предпросмотр пуст") + "

" : rendered } } 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") { const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) if (rendered.trim().length === 0) { viewArea.text = "

" + qsTr("Пустой файл — начните писать в режиме редактирования") + "

" } else { viewArea.text = rendered } } else if (viewMode === "live") { const r = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) livePreview.text = r.trim().length === 0 ? "

" + qsTr("Предпросмотр пуст") + "

" : r if (livePreview.text.trim().length === 0) livePreview.text = "

" + qsTr("Предпросмотр пуст") + "

" } } 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" ? 1 : 0 implicitHeight: viewMode === "live" ? 1 : 0 color: Theme.divider visible: viewMode === "live" } // Редактор — виден в 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 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 // Для подсветки скобок 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() } 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" } } } } // Превью — виден в preview и live ScrollView { id: viewScroll SplitView.fillWidth: true SplitView.minimumWidth: 200 visible: viewMode !== "edit" && documentManager.activeIndex >= 0 clip: true ScrollBar.vertical.policy: ScrollBar.AsNeeded TextArea { id: viewArea visible: viewMode === "preview" readOnly: true textFormat: Text.RichText color: Theme.text selectionColor: Theme.selectionBg font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace" font.pixelSize: 14 wrapMode: TextArea.Wrap background: Rectangle { color: Theme.editorBg } onLinkActivated: (href) => handleLink(href) } // Live preview отдельный TextArea чтобы не конфликтовать с обычным preview TextArea { id: livePreview visible: viewMode === "live" readOnly: true textFormat: Text.RichText color: Theme.text selectionColor: Theme.selectionBg font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace" font.pixelSize: 14 wrapMode: TextArea.Wrap background: Rectangle { color: Theme.editorBg } 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.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) else if (viewMode === "live") livePreview.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) } } } 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= 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") { const rendered = mdParser.render(c, fileStore.vault, activeFileDir()) viewArea.text = rendered.trim().length === 0 ? "

" + qsTr("Пустой файл — начните писать в режиме редактирования") + "

" : rendered } else if (viewMode === "live") { const rendered = mdParser.render(c, fileStore.vault, activeFileDir()) livePreview.text = rendered.trim().length === 0 ? "

" + qsTr("Предпросмотр пуст") + "

" : rendered } } else { textArea.text = "" viewArea.text = "" livePreview.text = "" } 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('', '') else applyInline('', '') } function applySubSup(kind) { if (kind === "sup") applyInline("", "") else applyInline("", "") } // --- Нижняя панель --- 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.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) } } ToolButton { text: "👁" checkable: true checked: viewMode === "preview" ButtonGroup.group: viewGroup ToolTip.text: qsTr("Просмотр (Ctrl+E)") ToolTip.visible: hovered onClicked: { const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) viewArea.text = rendered.trim().length === 0 ? "

" + qsTr("Пустой файл") + "

" : rendered 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") { const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) viewArea.text = rendered.trim().length === 0 ? "

" + qsTr("Пустой файл") + "

" : rendered 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 } } }