Prod: полный аудит и стабилизация — исправлены критические баги, расширено покрытие тестами, подготовлен бандл
- FileStore: защита от выхода за пределы хранилища (isInsideVault), валидация имён (запрет скрытых, зарезервированных, управляющих символов), проверка destDir, лимит watcher, dailyNote валидация, openVault проверка isDir - DocumentManager: лимит 10МБ, кламп автосейва 0.5-120с, защита от OOM, бинарный детект, проверка путей - VaultIndex: исправлен парсинг тегов (заголовки, инлайн-код), case-insensitive поиск, дедупликация рёбер, защита от traversal, лимит глубины, проверка vaultExists - MdParser: блокировка всех on* событий, javascript:/data:, esc кавычек, MIME fallback, normalizeLang расширен (c/cpp/py/js и кириллица), wiki глобальный поиск, image лимит 3МБ, защита clipboard - GitManager: commitAll с GIT_INDEX_ADD_DEFAULT и поддержкой пустых коммитов/удалений, fileGitStatus с cleanPath и проверкой префикса, repoExists/hasCommits с cleanPath, initRepo валидация - SettingsStore: encodeKey/encodeRel для QSettings Ini, flat хранение viewMode, sync после записи, кламп ширин панелей, миграция старых ключей - SyncManager: счётчик busy, обработка httpStatus при network error, валидация токена/провайдера/URL, GitHub Enterprise URL, таймаут - VaultManager: валидация путей (корень, файл vs папка, права), проверка вложенности - WindowController: per-window FileStore/DocumentManager/VaultIndex, cleanPath, закрытие окна через QQuickWindow::closing, обработка ошибок компонента - QML: TableEditor пустая строка, WelcomeWizard QUrl парсинг, FileTree фильтр, GraphView лимит 600 узлов, RightPanel debounce rebuild, Theme singleton, MonthCalendar и др. - Тесты: добавлены flora_settings_test, flora_sync_test, flora_extended_test, flora_integration_test — покрытие всех модулей, 20/20 зелёных - Сборка: обновлён Flora.app (4.6М бинарь -> 173М бандл, ad-hoc подпись, проверен offscreen запуск, версия 0.0.1 + cmark 0.29 + libgit2 1.9) - .gitignore: добавлены Testing/, coverage, gcda/gcov, __pycache__, .tmp, test-vault и др.
This commit is contained in:
+597
-94
@@ -1,127 +1,619 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Flora
|
||||
|
||||
// Центральная панель: вкладки сверху, тулбар, редактор-текст, панель внизу.
|
||||
// Всё — anchors, без ColumnLayout/RowLayout (SplitView не работает с ними).
|
||||
Item {
|
||||
id: root
|
||||
|
||||
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 ? "<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>"
|
||||
}
|
||||
}
|
||||
|
||||
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: 32
|
||||
color: "#ececec"
|
||||
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 }
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent; anchors.leftMargin: 4; spacing: 2
|
||||
Repeater {
|
||||
model: documentManager.openTabs
|
||||
delegate: Rectangle {
|
||||
id: tab
|
||||
required property string modelData
|
||||
required property int index
|
||||
readonly property bool active: index === documentManager.activeIndex
|
||||
width: Math.max(90, tabLabel.implicitWidth + 32)
|
||||
height: 26; radius: 4
|
||||
color: active ? "white" : "#e0e0e0"
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: documentManager.setActiveIndex(index)
|
||||
onDoubleClicked: documentManager.closeTab(index)
|
||||
}
|
||||
Label {
|
||||
id: tabLabel
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left; anchors.leftMargin: 8
|
||||
text: root.baseName(modelData) + (documentManager.isDirty(index) ? " •" : "")
|
||||
font.pixelSize: 12; color: tab.active ? "#111" : "#555"
|
||||
}
|
||||
ToolButton {
|
||||
text: "\u2715"; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter
|
||||
font.pixelSize: 9; width: 16
|
||||
opacity: tab.active || hovered ? 1 : 0
|
||||
onClicked: documentManager.closeTab(index)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Label { visible: documentManager.openTabs.length === 0; text: qsTr("Откройте файл"); color: "#999"; font.pixelSize: 12 }
|
||||
ToolButton {
|
||||
text: "\u25A1\u25A1"; checkable: true; checked: documentManager.sideBySide
|
||||
enabled: documentManager.activeIndex >= 0
|
||||
onClicked: { documentManager.setSecondaryIndex(documentManager.activeIndex); documentManager.setSideBySide(checked) }
|
||||
}
|
||||
|
||||
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: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
||||
font.pixelSize: 14
|
||||
wrapMode: TextArea.NoWrap
|
||||
selectByMouse: true
|
||||
background: Rectangle { color: Theme.editorBg }
|
||||
onTextChanged: {
|
||||
if (activeFocus && text !== documentManager.contentAt(documentManager.activeIndex)) {
|
||||
editDebounce.restart()
|
||||
}
|
||||
if (viewMode === "live") liveDebounce.restart()
|
||||
}
|
||||
function insertText(t) { insert(cursorPosition, t) }
|
||||
}
|
||||
}
|
||||
|
||||
// Превью — виден в 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Тулбар (под вкладками) ---
|
||||
FormatToolbar {
|
||||
id: fmtBar
|
||||
anchors.top: tabRow.bottom; anchors.left: parent.left; anchors.right: parent.right
|
||||
height: 34
|
||||
visible: documentManager.activeIndex >= 0
|
||||
onTableRequested: { tableEditor.resetDialog(); tableEditor.open() }
|
||||
}
|
||||
|
||||
// --- Текстовый редактор (основная область) ---
|
||||
TextEdit {
|
||||
id: textArea
|
||||
anchors.top: fmtBar.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: toolbar.top
|
||||
clip: true
|
||||
visible: documentManager.activeIndex >= 0
|
||||
text: documentManager.contentAt(documentManager.activeIndex)
|
||||
color: "black"
|
||||
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
||||
font.pixelSize: 14
|
||||
wrapMode: TextEdit.NoWrap
|
||||
selectByMouse: true
|
||||
onTextEdited: documentManager.updateContent(documentManager.activeIndex, text)
|
||||
}
|
||||
|
||||
// --- Режим просмотра ---
|
||||
TextEdit {
|
||||
id: viewArea
|
||||
anchors.top: fmtBar.bottom; anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: toolbar.top
|
||||
visible: false
|
||||
readOnly: true; clip: true
|
||||
textFormat: TextEdit.RichText
|
||||
color: "black"
|
||||
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
||||
font.pixelSize: 14; wrapMode: TextEdit.Wrap
|
||||
onLinkActivated: (href) => {
|
||||
if (href.startsWith("flora://open/"))
|
||||
documentManager.openDocument(decodeURIComponent(href.substring(13)))
|
||||
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: ""
|
||||
function refreshGitStatus() {
|
||||
if (documentManager.activeIndex < 0 || !fileStore.ready) { gitStatusText = ""; return }
|
||||
const p = documentManager.pathAt(documentManager.activeIndex)
|
||||
if (!p) { gitStatusText = ""; return }
|
||||
try { gitStatusText = gitManager.fileGitStatus(fileStore.vault, p) } catch(e) { gitStatusText = "" }
|
||||
}
|
||||
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() }
|
||||
function onDocumentSaved() { refreshGitStatus() }
|
||||
}
|
||||
Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } }
|
||||
Timer { id: gitPoll; interval: 2000; running: documentManager.activeIndex >= 0; repeat: true; onTriggered: refreshGitStatus() }
|
||||
Component.onCompleted: loadContent()
|
||||
|
||||
function applyInline(pre, post) {
|
||||
if (viewMode === "preview") return
|
||||
textArea.forceActiveFocus()
|
||||
const s = textArea.selectionStart, e = textArea.selectionEnd
|
||||
if (s !== e) {
|
||||
const sel = textArea.selectedText
|
||||
textArea.remove(s, e)
|
||||
textArea.insert(s, pre + sel + post)
|
||||
textArea.select(s, s + pre.length + sel.length + post.length)
|
||||
} else {
|
||||
const pos = textArea.cursorPosition
|
||||
textArea.insert(pos, pre + post)
|
||||
textArea.cursorPosition = pos + pre.length
|
||||
}
|
||||
documentManager.updateContent(documentManager.activeIndex, textArea.text)
|
||||
if (viewMode === "live") liveDebounce.restart()
|
||||
}
|
||||
function applyHeading(level) {
|
||||
if (viewMode === "preview") return
|
||||
textArea.forceActiveFocus()
|
||||
const txt = textArea.text
|
||||
const pos = textArea.cursorPosition
|
||||
let start = txt.lastIndexOf("\n", pos - 1) + 1
|
||||
let end = txt.indexOf("\n", pos)
|
||||
if (end < 0) end = txt.length
|
||||
let line = txt.substring(start, end)
|
||||
line = line.replace(/^#{1,6}\s*/, "")
|
||||
const prefix = "#".repeat(level) + " "
|
||||
const newLine = prefix + line
|
||||
textArea.remove(start, end)
|
||||
textArea.insert(start, newLine)
|
||||
textArea.cursorPosition = start + newLine.length
|
||||
documentManager.updateContent(documentManager.activeIndex, textArea.text)
|
||||
}
|
||||
function applyLinePrefix(prefix) {
|
||||
if (viewMode === "preview") return
|
||||
textArea.forceActiveFocus()
|
||||
const txt = textArea.text
|
||||
const s = textArea.selectionStart, e = textArea.selectionEnd
|
||||
let start = txt.lastIndexOf("\n", s - 1) + 1
|
||||
let finish = txt.indexOf("\n", e)
|
||||
if (finish < 0) finish = txt.length
|
||||
const block = txt.substring(start, finish)
|
||||
const lines = block.split("\n")
|
||||
const newBlock = lines.map(l => prefix + l).join("\n")
|
||||
textArea.remove(start, finish)
|
||||
textArea.insert(start, newBlock)
|
||||
textArea.select(start, start + newBlock.length)
|
||||
documentManager.updateContent(documentManager.activeIndex, textArea.text)
|
||||
}
|
||||
function applyTaskToggle() { applyLinePrefix("- [ ] ") }
|
||||
function applyCallout(kind) { applyLinePrefix("> [!" + kind + "] ") }
|
||||
function applyFloatWrap() {
|
||||
if (viewMode === "preview") return
|
||||
textArea.forceActiveFocus()
|
||||
textArea.insert(textArea.cursorPosition, "![[image.png|float:left|width:40%]]")
|
||||
documentManager.updateContent(documentManager.activeIndex, textArea.text)
|
||||
}
|
||||
function applyColor(color, highlightMode) {
|
||||
if (viewMode === "preview") return
|
||||
if (highlightMode) applyInline('<span style="background-color:' + color + '">', '</span>')
|
||||
else applyInline('<span style="color:' + color + '">', '</span>')
|
||||
}
|
||||
function applySubSup(kind) {
|
||||
if (kind === "sup") applyInline("<sup>", "</sup>")
|
||||
else applyInline("<sub>", "</sub>")
|
||||
}
|
||||
|
||||
// --- Нижняя панель ---
|
||||
Rectangle {
|
||||
id: toolbar
|
||||
anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right
|
||||
height: 32; color: "#e0e0e0"; border.color: "#ccc"
|
||||
height: 36
|
||||
color: Theme.toolbarBg
|
||||
border.color: Theme.divider
|
||||
RowLayout {
|
||||
anchors.fill: parent; anchors.rightMargin: 4; spacing: 4
|
||||
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 }
|
||||
ToolButton { text: "✎"; checkable: true; checked: documentManager.activeIndex >= 0 && !viewArea.visible; onClicked: { viewArea.visible = false; textArea.visible = true } }
|
||||
ToolButton { text: "👁"; checkable: true; checked: viewArea.visible; onClicked: {
|
||||
viewArea.visible = true; textArea.visible = false
|
||||
viewArea.text = mdParser.render(textArea.text, fileStore.vault, activeFileDir())
|
||||
}}
|
||||
ToolButton { text: "\u2715"; visible: false }
|
||||
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
|
||||
}
|
||||
}
|
||||
Item { width: 8 }
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,10 +623,21 @@ Item {
|
||||
return i >= 0 ? p.substring(0, i) : ""
|
||||
}
|
||||
|
||||
TableEditor { id: tableEditor; onMarkdownReady: (md) => textArea.insert(textArea.cursorPosition, md) }
|
||||
TableEditor { id: tableEditor; onMarkdownReady: (md) => { textArea.insert(textArea.cursorPosition, md); documentManager.updateContent(documentManager.activeIndex, textArea.text) } }
|
||||
|
||||
Shortcut { sequence: "Ctrl+S"; onActivated: documentManager.saveAll() }
|
||||
Shortcut { sequence: "Ctrl+S"; onActivated: { documentManager.saveAll(); refreshGitStatus() } }
|
||||
Shortcut { sequence: "Ctrl+W"; onActivated: documentManager.closeTab(documentManager.activeIndex) }
|
||||
Shortcut { sequence: "Ctrl+E"; onActivated: { viewArea.visible = !viewArea.visible; textArea.visible = !viewArea.visible } }
|
||||
Shortcut { sequence: "Ctrl+Tab"; onActivated: { const n = documentManager.openTabs.length; if (n > 0) documentManager.setActiveIndex((documentManager.activeIndex + 1) % n) } }
|
||||
}
|
||||
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 } }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user