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" ) {
const rendered = mdParser . render ( textArea . text , fileStore . vault , activeFileDir ( ) )
viewArea . text = rendered . trim ( ) . length === 0 ? "<p style='color:" + Theme . textFaint + "'><i>" + qsTr ( "Пустой файл — начните писать в режиме редактирования" ) + "</i></p>" : rendered
} else if ( viewMode === "live" ) {
const rendered = mdParser . render ( textArea . text , fileStore . vault , activeFileDir ( ) )
livePreview . text = rendered . trim ( ) . length === 0 ? "<p style='color:" + Theme . textFaint + "'><i>" + qsTr ( "Предпросмотр пуст" ) + "</i></p>" : 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 = "<p style='color:" + Theme . textFaint + "'><i>" + qsTr ( "Пустой файл — начните писать в режиме редактирования" ) + "</i></p>"
} else {
viewArea . text = rendered
}
} else if ( viewMode === "live" ) {
const r = mdParser . render ( textArea . text , fileStore . vault , activeFileDir ( ) )
livePreview . text = r . trim ( ) . length === 0 ? "<p style='color:" + Theme . textFaint + "'><i>" + qsTr ( "Предпросмотр пуст" ) + "</i></p>" : r
if ( livePreview . text . trim ( ) . length === 0 ) livePreview . text = "<p style='color:" + Theme . textFaint + "'><i>" + qsTr ( "Предпросмотр пуст" ) + "</i></p>"
}
}
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 {
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
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
}
}
// Превью — виден в 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 < 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 . text = mdParser . render ( textArea . text , fileStore . vault , activeFileDir ( ) ) } }
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" ) {
const rendered = mdParser . render ( c , fileStore . vault , activeFileDir ( ) )
viewArea . text = rendered . trim ( ) . length === 0 ? "<p style='color:" + Theme . textFaint + "'><i>" + qsTr ( "Пустой файл — начните писать в режиме редактирования" ) + "</i></p>" : rendered
} else if ( viewMode === "live" ) {
const rendered = mdParser . render ( c , fileStore . vault , activeFileDir ( ) )
livePreview . text = rendered . trim ( ) . length === 0 ? "<p style='color:" + Theme . textFaint + "'><i>" + qsTr ( "Предпросмотр пуст" ) + "</i></p>" : 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 ( ) }
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"
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 ? "<p style='color:" + Theme . textFaint + "'><i>" + qsTr ( "Пустой файл" ) + "</i></p>" : rendered
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" ) {
const rendered = mdParser . render ( textArea . text , fileStore . vault , activeFileDir ( ) )
viewArea . text = rendered . trim ( ) . length === 0 ? "<p style='color:" + Theme . textFaint + "'><i>" + qsTr ( "Пустой файл" ) + "</i></p>" : 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 } }
}