2026-08-17 00:05:20 +03:00
|
|
|
import QtQuick
|
|
|
|
|
import QtQuick.Controls
|
|
|
|
|
import QtQuick.Layouts
|
2026-09-01 19:25:07 +03:00
|
|
|
import Flora
|
2026-08-17 00:05:20 +03:00
|
|
|
|
2026-08-17 18:22:56 +03:00
|
|
|
Item {
|
2026-08-17 00:05:20 +03:00
|
|
|
id: root
|
|
|
|
|
|
2026-09-01 19:40:55 +03:00
|
|
|
signal historyRequested(string path)
|
|
|
|
|
|
2026-08-18 00:27:27 +03:00
|
|
|
property bool sideBySide: false
|
2026-09-01 19:25:07 +03:00
|
|
|
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") {
|
2026-09-01 21:07:55 +03:00
|
|
|
viewArea.markdown = textArea.text
|
2026-09-01 19:25:07 +03:00
|
|
|
} else if (viewMode === "live") {
|
2026-09-01 21:07:55 +03:00
|
|
|
livePreview.markdown = textArea.text
|
2026-09-01 19:25:07 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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") {
|
2026-09-01 21:07:55 +03:00
|
|
|
viewArea.markdown = textArea.text
|
2026-09-01 19:25:07 +03:00
|
|
|
} else if (viewMode === "live") {
|
2026-09-01 21:07:55 +03:00
|
|
|
livePreview.markdown = textArea.text
|
2026-09-01 19:25:07 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 00:05:20 +03:00
|
|
|
|
2026-08-18 00:27:27 +03:00
|
|
|
function baseName(p) {
|
|
|
|
|
const i = p.lastIndexOf("/")
|
|
|
|
|
return i >= 0 ? p.substring(i + 1) : p
|
2026-08-17 12:34:03 +03:00
|
|
|
}
|
2026-09-01 19:25:07 +03:00
|
|
|
function dirName(p) {
|
|
|
|
|
const i = p.lastIndexOf("/")
|
|
|
|
|
return i >= 0 ? p.substring(0, i) : ""
|
|
|
|
|
}
|
2026-08-17 18:15:22 +03:00
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
// --- Вкладки с перетаскиванием ---
|
2026-08-17 18:15:22 +03:00
|
|
|
Rectangle {
|
2026-08-18 00:27:27 +03:00
|
|
|
id: tabRow
|
2026-08-17 18:22:56 +03:00
|
|
|
anchors.top: parent.top
|
|
|
|
|
anchors.left: parent.left
|
|
|
|
|
anchors.right: parent.right
|
2026-09-01 19:25:07 +03:00
|
|
|
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 }
|
2026-08-17 18:15:22 +03:00
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 18:15:22 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-01 19:25:07 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-08-17 18:15:22 +03:00
|
|
|
}
|
2026-09-01 19:25:07 +03:00
|
|
|
color: Theme.textMuted
|
|
|
|
|
font.pixelSize: 11
|
|
|
|
|
elide: Text.ElideMiddle
|
2026-08-17 12:34:03 +03:00
|
|
|
}
|
2026-09-01 19:25:07 +03:00
|
|
|
Rectangle { anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right; height: 1; color: Theme.divider; visible: viewMode !== "live" }
|
2026-08-17 12:34:03 +03:00
|
|
|
}
|
|
|
|
|
|
2026-08-17 18:15:22 +03:00
|
|
|
FormatToolbar {
|
2026-08-18 00:27:27 +03:00
|
|
|
id: fmtBar
|
2026-09-01 19:25:07 +03:00
|
|
|
anchors.top: crumbs.bottom
|
|
|
|
|
anchors.left: parent.left
|
|
|
|
|
anchors.right: parent.right
|
|
|
|
|
height: 36
|
|
|
|
|
visible: documentManager.activeIndex >= 0 && viewMode !== "preview"
|
|
|
|
|
targetPane: root
|
2026-08-17 18:22:56 +03:00
|
|
|
onTableRequested: { tableEditor.resetDialog(); tableEditor.open() }
|
2026-08-17 18:15:22 +03:00
|
|
|
}
|
|
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
// --- Контент ---
|
|
|
|
|
SplitView {
|
|
|
|
|
id: contentSplit
|
|
|
|
|
anchors.top: fmtBar.visible ? fmtBar.bottom : crumbs.bottom
|
2026-08-17 18:22:56 +03:00
|
|
|
anchors.left: parent.left
|
|
|
|
|
anchors.right: parent.right
|
2026-08-18 00:27:27 +03:00
|
|
|
anchors.bottom: toolbar.top
|
2026-09-01 19:25:07 +03:00
|
|
|
orientation: Qt.Horizontal
|
|
|
|
|
handle: Rectangle {
|
2026-09-01 22:23:09 +03:00
|
|
|
implicitWidth: viewMode === "live" ? 6 : 0
|
|
|
|
|
implicitHeight: viewMode === "live" ? 6 : 0
|
2026-09-01 19:25:07 +03:00
|
|
|
color: Theme.divider
|
|
|
|
|
visible: viewMode === "live"
|
2026-09-01 22:23:09 +03:00
|
|
|
Rectangle {
|
|
|
|
|
anchors.centerIn: parent
|
|
|
|
|
width: 1; height: parent.height
|
|
|
|
|
color: Theme.divider
|
|
|
|
|
visible: parent.visible
|
|
|
|
|
}
|
|
|
|
|
Rectangle {
|
|
|
|
|
anchors.centerIn: parent
|
|
|
|
|
width: 16; height: parent.height
|
|
|
|
|
color: "transparent"
|
|
|
|
|
visible: parent.visible
|
|
|
|
|
MouseArea {
|
|
|
|
|
anchors.fill: parent
|
|
|
|
|
cursorShape: Qt.SplitHCursor
|
|
|
|
|
hoverEnabled: true
|
|
|
|
|
onPressed: mouse.accepted = false
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-01 19:25:07 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Редактор — виден в 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
|
2026-09-01 20:12:02 +03:00
|
|
|
font.family: "JetBrains Mono, Fira Code, Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
|
|
|
|
font.pixelSize: 13
|
2026-09-01 19:25:07 +03:00
|
|
|
wrapMode: TextArea.NoWrap
|
|
|
|
|
selectByMouse: true
|
|
|
|
|
background: Rectangle { color: Theme.editorBg }
|
2026-09-01 20:12:02 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
onTextChanged: {
|
|
|
|
|
if (activeFocus && text !== documentManager.contentAt(documentManager.activeIndex)) {
|
|
|
|
|
editDebounce.restart()
|
|
|
|
|
}
|
|
|
|
|
if (viewMode === "live") liveDebounce.restart()
|
|
|
|
|
}
|
|
|
|
|
function insertText(t) { insert(cursorPosition, t) }
|
2026-09-01 20:12:02 +03:00
|
|
|
|
|
|
|
|
// Авто-отступ и скобки
|
|
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-01 19:25:07 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 21:07:55 +03:00
|
|
|
// Превью — виден в preview и live (отдельный компонент для блоков кода)
|
2026-09-01 19:25:07 +03:00
|
|
|
ScrollView {
|
|
|
|
|
id: viewScroll
|
|
|
|
|
SplitView.fillWidth: true
|
|
|
|
|
SplitView.minimumWidth: 200
|
|
|
|
|
visible: viewMode !== "edit" && documentManager.activeIndex >= 0
|
|
|
|
|
clip: true
|
|
|
|
|
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
2026-09-01 21:07:55 +03:00
|
|
|
background: Rectangle { color: Theme.editorBg }
|
2026-09-01 19:25:07 +03:00
|
|
|
|
2026-09-01 21:07:55 +03:00
|
|
|
MarkdownView {
|
2026-09-01 19:25:07 +03:00
|
|
|
id: viewArea
|
|
|
|
|
visible: viewMode === "preview"
|
2026-09-01 21:07:55 +03:00
|
|
|
width: viewScroll.width
|
|
|
|
|
vaultRoot: fileStore.vault
|
|
|
|
|
fileDir: activeFileDir()
|
2026-09-01 19:25:07 +03:00
|
|
|
onLinkActivated: (href) => handleLink(href)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 21:07:55 +03:00
|
|
|
MarkdownView {
|
2026-09-01 19:25:07 +03:00
|
|
|
id: livePreview
|
|
|
|
|
visible: viewMode === "live"
|
2026-09-01 21:07:55 +03:00
|
|
|
width: viewScroll.width
|
|
|
|
|
vaultRoot: fileStore.vault
|
|
|
|
|
fileDir: activeFileDir()
|
2026-09-01 19:25:07 +03:00
|
|
|
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)
|
2026-09-01 21:07:55 +03:00
|
|
|
if (viewMode === "preview") viewArea.markdown = textArea.text
|
|
|
|
|
else if (viewMode === "live") livePreview.markdown = textArea.text
|
2026-09-01 19:25:07 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} 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) }
|
2026-09-01 21:07:55 +03:00
|
|
|
Timer { id: liveDebounce; interval: 150; onTriggered: { if (viewMode === "live") livePreview.markdown = textArea.text } }
|
2026-09-01 19:25:07 +03:00
|
|
|
|
|
|
|
|
property string gitStatusText: ""
|
2026-09-01 19:40:55 +03:00
|
|
|
property string lastModifiedStr: ""
|
2026-09-01 19:25:07 +03:00
|
|
|
function refreshGitStatus() {
|
2026-09-01 19:40:55 +03:00
|
|
|
if (documentManager.activeIndex < 0 || !fileStore.ready) { gitStatusText = ""; lastModifiedStr = ""; return }
|
2026-09-01 19:25:07 +03:00
|
|
|
const p = documentManager.pathAt(documentManager.activeIndex)
|
2026-09-01 19:40:55 +03:00
|
|
|
if (!p) { gitStatusText = ""; lastModifiedStr = ""; return }
|
2026-09-01 19:25:07 +03:00
|
|
|
try { gitStatusText = gitManager.fileGitStatus(fileStore.vault, p) } catch(e) { gitStatusText = "" }
|
2026-09-01 19:40:55 +03:00
|
|
|
try { lastModifiedStr = gitManager.lastModifiedTimeString(fileStore.vault, p) } catch(e) { lastModifiedStr = "" }
|
2026-09-01 19:25:07 +03:00
|
|
|
}
|
|
|
|
|
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") {
|
2026-09-01 21:07:55 +03:00
|
|
|
viewArea.markdown = c
|
2026-09-01 19:25:07 +03:00
|
|
|
} else if (viewMode === "live") {
|
2026-09-01 21:07:55 +03:00
|
|
|
livePreview.markdown = c
|
2026-09-01 19:25:07 +03:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
textArea.text = ""
|
2026-09-01 21:07:55 +03:00
|
|
|
viewArea.markdown = ""
|
|
|
|
|
livePreview.markdown = ""
|
2026-09-01 19:25:07 +03:00
|
|
|
}
|
|
|
|
|
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() }
|
2026-09-01 19:40:55 +03:00
|
|
|
function onDocumentSaved(idx) {
|
|
|
|
|
refreshGitStatus()
|
|
|
|
|
// Авто-коммит для простого пользователя — каждый сохранённый файл попадает в историю
|
|
|
|
|
if (fileStore.ready && idx >= 0) {
|
|
|
|
|
const p = documentManager.pathAt(idx)
|
|
|
|
|
if (p && p.length>0) {
|
|
|
|
|
// Небольшая задержка чтобы не спамить на каждый автосейв
|
|
|
|
|
autoCommitDebounce.filePath = p
|
|
|
|
|
autoCommitDebounce.restart()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-01 19:25:07 +03:00
|
|
|
}
|
|
|
|
|
Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } }
|
2026-09-01 19:40:55 +03:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-01 19:25:07 +03:00
|
|
|
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>")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Нижняя панель ---
|
2026-08-18 00:27:27 +03:00
|
|
|
Rectangle {
|
|
|
|
|
id: toolbar
|
|
|
|
|
anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right
|
2026-09-01 19:25:07 +03:00
|
|
|
height: 36
|
|
|
|
|
color: Theme.toolbarBg
|
|
|
|
|
border.color: Theme.divider
|
2026-08-18 00:27:27 +03:00
|
|
|
RowLayout {
|
2026-09-01 19:25:07 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-18 00:27:27 +03:00
|
|
|
Item { Layout.fillWidth: true }
|
2026-09-01 19:25:07 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-01 19:40:55 +03:00
|
|
|
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 }
|
2026-09-01 19:25:07 +03:00
|
|
|
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"
|
2026-09-01 21:07:55 +03:00
|
|
|
livePreview.markdown = textArea.text
|
2026-09-01 19:25:07 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ToolButton {
|
|
|
|
|
text: "👁"
|
|
|
|
|
checkable: true
|
|
|
|
|
checked: viewMode === "preview"
|
|
|
|
|
ButtonGroup.group: viewGroup
|
|
|
|
|
ToolTip.text: qsTr("Просмотр (Ctrl+E)")
|
|
|
|
|
ToolTip.visible: hovered
|
|
|
|
|
onClicked: {
|
2026-09-01 21:07:55 +03:00
|
|
|
viewArea.markdown = textArea.text
|
2026-09-01 19:25:07 +03:00
|
|
|
viewMode = "preview"
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 18:15:22 +03:00
|
|
|
}
|
2026-08-18 00:27:27 +03:00
|
|
|
}
|
2026-08-17 18:15:22 +03:00
|
|
|
|
2026-08-18 00:27:27 +03:00
|
|
|
function activeFileDir() {
|
|
|
|
|
const p = documentManager.pathAt(documentManager.activeIndex)
|
|
|
|
|
const i = p.lastIndexOf("/")
|
|
|
|
|
return i >= 0 ? p.substring(0, i) : ""
|
2026-08-17 18:15:22 +03:00
|
|
|
}
|
|
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
TableEditor { id: tableEditor; onMarkdownReady: (md) => { textArea.insert(textArea.cursorPosition, md); documentManager.updateContent(documentManager.activeIndex, textArea.text) } }
|
2026-08-17 18:22:56 +03:00
|
|
|
|
2026-09-01 19:25:07 +03:00
|
|
|
Shortcut { sequence: "Ctrl+S"; onActivated: { documentManager.saveAll(); refreshGitStatus() } }
|
2026-08-17 18:22:56 +03:00
|
|
|
Shortcut { sequence: "Ctrl+W"; onActivated: documentManager.closeTab(documentManager.activeIndex) }
|
2026-09-01 19:40:55 +03:00
|
|
|
Shortcut { sequence: "Ctrl+H"; onActivated: {
|
|
|
|
|
const p = documentManager.pathAt(documentManager.activeIndex)
|
|
|
|
|
if (p) root.historyRequested(p)
|
|
|
|
|
} }
|
2026-09-01 19:25:07 +03:00
|
|
|
Shortcut { sequence: "Ctrl+E"; onActivated: {
|
|
|
|
|
if (viewMode === "edit") {
|
2026-09-01 21:07:55 +03:00
|
|
|
viewArea.markdown = textArea.text
|
2026-09-01 19:25:07 +03:00
|
|
|
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 } }
|
|
|
|
|
}
|