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:
+174
-65
@@ -1,6 +1,7 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Flora
|
||||
|
||||
Item {
|
||||
id: pane
|
||||
@@ -13,17 +14,22 @@ Item {
|
||||
signal openBeside()
|
||||
signal closePane()
|
||||
|
||||
function insertText(t) {
|
||||
textDisplay.insert(textDisplay.cursorPosition, t)
|
||||
}
|
||||
function insertText(t) { textDisplay.insert(textDisplay.cursorPosition, t) }
|
||||
|
||||
function refresh() {
|
||||
const idx = pane.docIndex
|
||||
if (idx >= 0) {
|
||||
const c = documentManager.contentAt(idx)
|
||||
textDisplay.text = c
|
||||
if (textDisplay.text !== c && !textDisplay.activeFocus)
|
||||
textDisplay.text = c
|
||||
if (!pane.editMode) {
|
||||
viewDisplay.text = mdParser.render(c, fileStore.vault, filePathDir(documentManager.pathAt(idx)))
|
||||
} else {
|
||||
viewDisplay.text = ""
|
||||
}
|
||||
} else {
|
||||
textDisplay.text = ""
|
||||
viewDisplay.text = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,102 +43,205 @@ Item {
|
||||
|
||||
Connections {
|
||||
target: documentManager
|
||||
function onActiveIndexChanged() {
|
||||
if (documentManager.activeIndex === pane.docIndex)
|
||||
refresh()
|
||||
}
|
||||
function onContentChanged(idx) { if (idx === pane.docIndex && !textDisplay.activeFocus) refresh() }
|
||||
function onActiveIndexChanged() { if (documentManager.activeIndex === pane.docIndex) refresh() }
|
||||
}
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
// --- Текстовый редактор (сверху до низу, без шапки) ---
|
||||
TextEdit {
|
||||
id: textDisplay
|
||||
function applyInline(pre, post) {
|
||||
if (!pane.editMode) return
|
||||
textDisplay.forceActiveFocus()
|
||||
const s = textDisplay.selectionStart, e = textDisplay.selectionEnd
|
||||
if (s !== e) {
|
||||
const sel = textDisplay.selectedText
|
||||
textDisplay.remove(s, e)
|
||||
textDisplay.insert(s, pre + sel + post)
|
||||
textDisplay.select(s, s + pre.length + sel.length + post.length)
|
||||
} else {
|
||||
const pos = textDisplay.cursorPosition
|
||||
textDisplay.insert(pos, pre + post)
|
||||
textDisplay.cursorPosition = pos + pre.length
|
||||
}
|
||||
documentManager.updateContent(pane.docIndex, textDisplay.text)
|
||||
}
|
||||
function applyHeading(level) {
|
||||
if (!pane.editMode) return
|
||||
textDisplay.forceActiveFocus()
|
||||
const txt = textDisplay.text
|
||||
const pos = textDisplay.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 newLine = "#".repeat(level) + " " + line
|
||||
textDisplay.remove(start, end)
|
||||
textDisplay.insert(start, newLine)
|
||||
textDisplay.cursorPosition = start + newLine.length
|
||||
documentManager.updateContent(pane.docIndex, textDisplay.text)
|
||||
}
|
||||
function applyLinePrefix(prefix) {
|
||||
if (!pane.editMode) return
|
||||
textDisplay.forceActiveFocus()
|
||||
const txt = textDisplay.text
|
||||
const s = textDisplay.selectionStart, e = textDisplay.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 newBlock = block.split("\n").map(l => prefix + l).join("\n")
|
||||
textDisplay.remove(start, finish)
|
||||
textDisplay.insert(start, newBlock)
|
||||
textDisplay.select(start, start + newBlock.length)
|
||||
documentManager.updateContent(pane.docIndex, textDisplay.text)
|
||||
}
|
||||
function applyTaskToggle() { applyLinePrefix("- [ ] ") }
|
||||
function applyCallout(kind) { applyLinePrefix("> [!" + kind + "] ") }
|
||||
function applyFloatWrap() {
|
||||
if (!pane.editMode) return
|
||||
textDisplay.forceActiveFocus()
|
||||
textDisplay.insert(textDisplay.cursorPosition, "![[image.png|float:left|width:40%]]")
|
||||
documentManager.updateContent(pane.docIndex, textDisplay.text)
|
||||
}
|
||||
function applyColor(color, highlightMode) {
|
||||
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>")
|
||||
}
|
||||
|
||||
// --- Текстовый редактор ---
|
||||
ScrollView {
|
||||
id: editScroll
|
||||
anchors.fill: parent
|
||||
anchors.bottomMargin: 32
|
||||
anchors.bottomMargin: 36
|
||||
visible: pane.editMode
|
||||
clip: true
|
||||
text: documentManager.activeIndex >= 0
|
||||
? 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(pane.docIndex, text)
|
||||
}
|
||||
|
||||
// --- Режим просмотра ---
|
||||
TextEdit {
|
||||
id: viewDisplay
|
||||
anchors.fill: parent
|
||||
anchors.bottomMargin: 32
|
||||
visible: !pane.editMode
|
||||
readOnly: true
|
||||
clip: true
|
||||
text: pane.editMode ? "" : mdParser.render(
|
||||
documentManager.contentAt(pane.docIndex),
|
||||
fileStore.vault,
|
||||
filePathDir(documentManager.pathAt(pane.docIndex))
|
||||
)
|
||||
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://task/")) {
|
||||
const line = Number(href.substring(14))
|
||||
if (line > 0) {
|
||||
const lines = textDisplay.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]", "[ ]")
|
||||
textDisplay.text = lines.join("\n")
|
||||
documentManager.updateContent(pane.docIndex, textDisplay.text)
|
||||
}
|
||||
}
|
||||
} else if (href.startsWith("flora://open/")) {
|
||||
documentManager.openDocument(decodeURIComponent(href.substring(13)))
|
||||
}
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
TextArea {
|
||||
id: textDisplay
|
||||
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(pane.docIndex)) editDebounce.restart()
|
||||
}
|
||||
}
|
||||
Timer { id: editDebounce; interval: 250; onTriggered: documentManager.updateContent(pane.docIndex, textDisplay.text) }
|
||||
|
||||
// --- Серая панель ВНИЗУ: кнопки ✎👁 × ---
|
||||
ScrollView {
|
||||
id: viewScroll
|
||||
anchors.fill: parent
|
||||
anchors.bottomMargin: 36
|
||||
visible: !pane.editMode
|
||||
clip: true
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
TextArea {
|
||||
id: viewDisplay
|
||||
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) => {
|
||||
if (href.startsWith("flora://copycode/")) {
|
||||
const id = href.substring(17)
|
||||
const code = mdParser.getCodeBlock(id)
|
||||
if (code.length > 0) { mdParser.copyToClipboard(code); copyFeedback.visible = true; copyTimer.restart() }
|
||||
return
|
||||
}
|
||||
if (href.startsWith("flora://task/")) {
|
||||
const line = Number(href.substring(14))
|
||||
if (line > 0) {
|
||||
const lines = textDisplay.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]", "[ ]")
|
||||
textDisplay.text = lines.join("\n")
|
||||
documentManager.updateContent(pane.docIndex, textDisplay.text)
|
||||
}
|
||||
}
|
||||
} else if (href.startsWith("flora://open/")) {
|
||||
documentManager.openDocument(decodeURIComponent(href.substring(13)))
|
||||
} else {
|
||||
Qt.openUrlExternally(href)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Тост копирования
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.topMargin: 8
|
||||
visible: copyFeedback.visible
|
||||
color: "#333"
|
||||
radius: 6
|
||||
width: copyFeedback.implicitWidth + 16
|
||||
height: copyFeedback.implicitHeight + 10
|
||||
z: 11
|
||||
Label { id: copyFeedback; anchors.centerIn: parent; visible: false; text: qsTr("Скопировано!"); color: "white"; font.pixelSize: 11 }
|
||||
}
|
||||
Timer { id: copyTimer; interval: 1500; onTriggered: copyFeedback.visible = false }
|
||||
}
|
||||
|
||||
// --- Нижняя панель ---
|
||||
Rectangle {
|
||||
id: toolbar
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 32
|
||||
height: 36
|
||||
z: 10
|
||||
color: "#e0e0e0"
|
||||
border.color: "#ccc"
|
||||
color: Theme.toolbarBg
|
||||
border.color: Theme.divider
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: 4
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 4
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Label {
|
||||
visible: pane.docIndex >= 0
|
||||
text: pane.docIndex >= 0 ? documentManager.titleAt(pane.docIndex) : ""
|
||||
color: Theme.textMuted
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
ToolButton {
|
||||
text: "✎"
|
||||
checkable: true
|
||||
checked: pane.editMode
|
||||
ToolTip.text: qsTr("Редактирование")
|
||||
ToolTip.visible: hovered
|
||||
onClicked: pane.editMode = true
|
||||
}
|
||||
ToolButton {
|
||||
text: "👁"
|
||||
checkable: true
|
||||
checked: !pane.editMode
|
||||
ToolTip.text: qsTr("Просмотр")
|
||||
ToolTip.visible: hovered
|
||||
onClicked: pane.editMode = false
|
||||
}
|
||||
ToolButton {
|
||||
text: "\u2715"
|
||||
visible: pane.closable
|
||||
ToolTip.text: qsTr("Закрыть")
|
||||
ToolTip.visible: hovered
|
||||
onClicked: pane.closePane()
|
||||
}
|
||||
}
|
||||
|
||||
+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 } }
|
||||
}
|
||||
|
||||
+200
-161
@@ -1,10 +1,8 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Flora
|
||||
|
||||
// Левая панель: файловое дерево хранилища (Фаза 3).
|
||||
// Создание/переименование/удаление/перемещение, иконки, стрелки раскрытия,
|
||||
// отступы подпапок, ПКМ, drag&drop. Кнопка хранилища внизу слева.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
@@ -16,26 +14,33 @@ Item {
|
||||
return idx >= 0 ? p.substring(idx + 1) : p
|
||||
}
|
||||
|
||||
// ----- Заголовок: «+» + название -----
|
||||
property string filterText: ""
|
||||
|
||||
// ----- Заголовок -----
|
||||
Rectangle {
|
||||
id: header
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 36
|
||||
color: "whitesmoke"
|
||||
height: 44
|
||||
color: Theme.headerBg
|
||||
border.color: Theme.divider
|
||||
border.width: 0; // нижняя линия отдельно
|
||||
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
|
||||
anchors.rightMargin: 4
|
||||
spacing: 4
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
anchors.topMargin: 6
|
||||
anchors.bottomMargin: 6
|
||||
spacing: 6
|
||||
|
||||
Button {
|
||||
id: addButton
|
||||
text: "+"
|
||||
implicitWidth: 28
|
||||
implicitHeight: 26
|
||||
implicitWidth: 32
|
||||
implicitHeight: 28
|
||||
enabled: fileStore.ready
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: qsTr("Создать файл или папку")
|
||||
@@ -46,38 +51,99 @@ Item {
|
||||
text: fileStore.ready ? qsTr("Файлы") : qsTr("Хранилище не выбрано")
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
color: "#555"
|
||||
color: Theme.textSecondary
|
||||
font.bold: true
|
||||
font.pixelSize: 13
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
id: searchToggle
|
||||
checkable: true
|
||||
text: "\u2315"
|
||||
font.pixelSize: 14
|
||||
ToolTip.text: qsTr("Поиск")
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
ToolButton {
|
||||
text: "\u2630"
|
||||
font.pixelSize: 12
|
||||
ToolTip.text: qsTr("Развернуть / свернуть")
|
||||
ToolTip.visible: hovered
|
||||
onClicked: expandMenu.popup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Поле поиска -----
|
||||
Rectangle {
|
||||
id: searchBar
|
||||
anchors.top: header.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: vaultButton.top
|
||||
color: "#fafafa"
|
||||
height: searchToggle.checked ? 38 : 0
|
||||
visible: height > 0
|
||||
clip: true
|
||||
color: Theme.bgAlt
|
||||
border.color: Theme.divider
|
||||
Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
|
||||
|
||||
// ПКМ по пустому месту дерева — то же меню, что и «+»
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 6
|
||||
spacing: 6
|
||||
TextField {
|
||||
id: searchField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: qsTr("Поиск файлов…")
|
||||
font.pixelSize: 12
|
||||
onTextChanged: root.filterText = text.trim()
|
||||
Keys.onEscapePressed: { clear(); searchToggle.checked = false }
|
||||
function clear() { text = ""; root.filterText = "" }
|
||||
}
|
||||
ToolButton {
|
||||
text: "\u2715"
|
||||
visible: searchField.text.length > 0
|
||||
onClicked: searchField.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Меню развернуть/свернуть
|
||||
Menu {
|
||||
id: expandMenu
|
||||
MenuItem { text: qsTr("Развернуть всё"); onClicked: fileStore.expandAll() }
|
||||
MenuItem { text: qsTr("Свернуть всё"); onClicked: fileStore.collapseAll() }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: treeContainer
|
||||
anchors.top: searchBar.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: vaultButton.top
|
||||
color: Theme.bg
|
||||
|
||||
// ПКМ по пустому месту
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.RightButton)
|
||||
addMenu.x = mouse.x; addMenu.y = mouse.y; addMenu.popup()
|
||||
addMenu.popup()
|
||||
}
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: treeView
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: 4
|
||||
clip: true
|
||||
model: fileStore
|
||||
currentIndex: -1
|
||||
focus: true
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
spacing: 1
|
||||
|
||||
// Перемещение на пустое место = в корень хранилища
|
||||
DropArea {
|
||||
anchors.fill: parent
|
||||
onDropped: (drop) => {
|
||||
@@ -99,31 +165,46 @@ Item {
|
||||
required property string icon
|
||||
required property int index
|
||||
|
||||
// Фильтрация: показываем только совпадающие; папки с детьми не показываем автоматически чтобы не захламлять
|
||||
readonly property bool matchesFilter: root.filterText.length === 0 || display.toLowerCase().indexOf(root.filterText.toLowerCase()) !== -1
|
||||
visible: matchesFilter
|
||||
height: matchesFilter ? 32 : 0
|
||||
width: treeView.width
|
||||
height: 30
|
||||
color: rowRect.dragging ? "#d6e4ff"
|
||||
: (treeView.currentIndex === index ? "#e6f0ff"
|
||||
: (index % 2 === 0 ? "#fafafa" : "#f4f4f4"))
|
||||
color: {
|
||||
if (dragging) return Theme.accentMuted
|
||||
if (treeView.currentIndex === index) return Theme.surfaceSelected
|
||||
if (hover.hovered) return Theme.surfaceHover
|
||||
return "transparent"
|
||||
}
|
||||
border.color: treeView.currentIndex === index ? Theme.accent : "transparent"
|
||||
border.width: treeView.currentIndex === index ? 1 : 0
|
||||
radius: 4
|
||||
|
||||
property string dragPath: ""
|
||||
property bool dragging: false
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 4 + depth * 16
|
||||
anchors.rightMargin: 6
|
||||
spacing: 4
|
||||
anchors.leftMargin: 6 + depth * 14
|
||||
anchors.rightMargin: 8
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: isDir ? (expanded ? "\u25BE" : "\u25B8") : ""
|
||||
font.pixelSize: 12
|
||||
Layout.preferredWidth: isDir ? 16 : 8
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: "#666"
|
||||
opacity: hasChildren ? 1 : 0.35
|
||||
// Стрелка
|
||||
Item {
|
||||
Layout.preferredWidth: isDir ? 16 : 0
|
||||
Layout.preferredHeight: 16
|
||||
visible: isDir
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: expanded ? "\u25BE" : "\u25B8"
|
||||
font.pixelSize: 12
|
||||
color: Theme.textMuted
|
||||
opacity: hasChildren ? 1 : 0.25
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
visible: hasChildren
|
||||
enabled: hasChildren
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: fileStore.toggleExpand(index)
|
||||
}
|
||||
}
|
||||
@@ -137,30 +218,45 @@ Item {
|
||||
text: display
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
color: isDir ? "#1a1a1a" : "#333"
|
||||
color: isDir ? Theme.text : Theme.textSecondary
|
||||
font.bold: isDir
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
// быстрый бейдж для папок с детьми
|
||||
Rectangle {
|
||||
visible: isDir && hasChildren
|
||||
implicitWidth: countLabel.implicitWidth + 8
|
||||
implicitHeight: 16
|
||||
radius: 8
|
||||
color: Theme.tagChipBg
|
||||
Label {
|
||||
id: countLabel
|
||||
anchors.centerIn: parent
|
||||
text: "" // можно показать число детей, но пока стрелка
|
||||
font.pixelSize: 9
|
||||
color: Theme.textMuted
|
||||
visible: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler { id: hover }
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
hoverEnabled: true
|
||||
onPressed: {
|
||||
treeView.currentIndex = index
|
||||
}
|
||||
onPressed: treeView.currentIndex = index
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
rowMenu.x = mouse.x; rowMenu.y = mouse.y
|
||||
rowMenu.openFor(path, isDir)
|
||||
}
|
||||
}
|
||||
onDoubleClicked: (mouse) => {
|
||||
if (mouse.button !== Qt.LeftButton) return
|
||||
if (isDir)
|
||||
fileStore.toggleExpand(index)
|
||||
else
|
||||
root.openFileRequested(path)
|
||||
if (isDir) fileStore.toggleExpand(index)
|
||||
else root.openFileRequested(path)
|
||||
}
|
||||
onPressAndHold: (mouse) => {
|
||||
if (mouse.button !== Qt.LeftButton || rowRect.dragPath.length > 0) return
|
||||
@@ -169,20 +265,15 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Перетаскивание элемента
|
||||
Drag.active: rowRect.dragging
|
||||
Drag.source: rowRect
|
||||
Drag.hotSpot: Qt.point(width / 2, height / 2)
|
||||
Drag.keys: [ "application/x-flora" ]
|
||||
Drag.keys: ["application/x-flora"]
|
||||
Drag.onDragFinished: rowRect.finishDrag()
|
||||
Drag.onActiveChanged: if (!Drag.active) rowRect.finishDrag()
|
||||
|
||||
function finishDrag() {
|
||||
dragging = false
|
||||
dragPath = ""
|
||||
}
|
||||
function finishDrag() { dragging = false; dragPath = "" }
|
||||
|
||||
// Приём перетаскивания в папку
|
||||
DropArea {
|
||||
anchors.fill: parent
|
||||
onEntered: (drag) => { drag.accepted = rowRect.isDir }
|
||||
@@ -209,88 +300,92 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
visible: !fileStore.ready
|
||||
text: qsTr("Откройте хранилище,\nчтобы увидеть файлы")
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: "#bbb"
|
||||
spacing: 8
|
||||
Label {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: "\uD83D\uDCC1"
|
||||
font.pixelSize: 32
|
||||
opacity: 0.5
|
||||
}
|
||||
Label {
|
||||
text: qsTr("Откройте хранилище,\nчтобы увидеть файлы")
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: Theme.textMuted
|
||||
lineHeight: 1.2
|
||||
}
|
||||
Button {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Выбрать хранилище…")
|
||||
onClicked: root.openVaultRequested()
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
visible: fileStore.ready && treeView.count === 0
|
||||
text: root.filterText.length > 0 ? qsTr("Ничего не найдено") : qsTr("Папка пуста")
|
||||
color: Theme.textMuted
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопка хранилища внизу слева
|
||||
Button {
|
||||
id: vaultButton
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: 6
|
||||
height: 36
|
||||
text: qsTr("▨ Хранилище…")
|
||||
onClicked: root.openVaultRequested()
|
||||
}
|
||||
|
||||
// Статус-строка
|
||||
property string statusText: ""
|
||||
Timer { id: statusTimer; interval: 4000; onTriggered: root.statusText = "" }
|
||||
function showStatus(text, ok) {
|
||||
root.statusText = (ok ? "✓ " : "✕ ") + text
|
||||
statusTimer.restart()
|
||||
}
|
||||
function showStatus(text, ok) { root.statusText = (ok ? "✓ " : "✕ ") + text; statusTimer.restart() }
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: vaultButton.top
|
||||
height: 24
|
||||
anchors.bottom: vaultButton.top
|
||||
anchors.bottomMargin: 6
|
||||
height: 28
|
||||
visible: statusText.length > 0
|
||||
color: statusText.startsWith("✓") ? "#e5f6e5" : "#fdeaea"
|
||||
color: statusText.startsWith("✓") ? Theme.successBg : Theme.errorBg
|
||||
border.color: statusText.startsWith("✓") ? Theme.successText : Theme.errorText
|
||||
radius: 6
|
||||
Label {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 6
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
text: root.statusText
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: 11
|
||||
color: statusText.startsWith("✓") ? "#256029" : "#9c1c1c"
|
||||
color: statusText.startsWith("✓") ? Theme.successText : Theme.errorText
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Меню «+» (файл/папка) -----
|
||||
Menu {
|
||||
id: addMenu
|
||||
width: 150
|
||||
width: 180
|
||||
MenuItem {
|
||||
text: qsTr("Файл")
|
||||
onClicked: {
|
||||
nameDialog.dialogTitle = qsTr("Новый файл")
|
||||
nameDialog.actionLabel = qsTr("Создать")
|
||||
nameDialog.initialText = ""
|
||||
nameDialog.open()
|
||||
pendingCreate.kind = "file"
|
||||
}
|
||||
onClicked: { nameDialog.dialogTitle = qsTr("Новый файл"); nameDialog.actionLabel = qsTr("Создать"); nameDialog.initialText = ""; nameDialog.open(); pendingCreate.kind = "file" }
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Папка")
|
||||
onClicked: {
|
||||
nameDialog.dialogTitle = qsTr("Новая папка")
|
||||
nameDialog.actionLabel = qsTr("Создать")
|
||||
nameDialog.initialText = ""
|
||||
nameDialog.open()
|
||||
pendingCreate.kind = "folder"
|
||||
}
|
||||
onClicked: { nameDialog.dialogTitle = qsTr("Новая папка"); nameDialog.actionLabel = qsTr("Создать"); nameDialog.initialText = ""; nameDialog.open(); pendingCreate.kind = "folder" }
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Контекстное меню строки -----
|
||||
Menu {
|
||||
id: rowMenu
|
||||
property string targetPath: ""
|
||||
property bool targetIsDir: false
|
||||
|
||||
function openFor(path, isDir) {
|
||||
targetPath = path
|
||||
targetIsDir = isDir
|
||||
popup()
|
||||
}
|
||||
|
||||
function openFor(path, isDir) { targetPath = path; targetIsDir = isDir; popup() }
|
||||
MenuItem {
|
||||
text: qsTr("Открыть")
|
||||
onClicked: {
|
||||
@@ -302,71 +397,28 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Создать файл внутри")
|
||||
visible: rowMenu.targetIsDir
|
||||
onClicked: {
|
||||
nameDialog.dialogTitle = qsTr("Новый файл")
|
||||
nameDialog.actionLabel = qsTr("Создать")
|
||||
nameDialog.initialText = ""
|
||||
nameDialog.open()
|
||||
pendingCreate.kind = "file"
|
||||
pendingCreate.parent = rowMenu.targetPath
|
||||
}
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Создать папку внутри")
|
||||
visible: rowMenu.targetIsDir
|
||||
onClicked: {
|
||||
nameDialog.dialogTitle = qsTr("Новая папка")
|
||||
nameDialog.actionLabel = qsTr("Создать")
|
||||
nameDialog.initialText = ""
|
||||
nameDialog.open()
|
||||
pendingCreate.kind = "folder"
|
||||
pendingCreate.parent = rowMenu.targetPath
|
||||
}
|
||||
}
|
||||
MenuItem { text: qsTr("Создать файл внутри"); visible: rowMenu.targetIsDir; onClicked: { nameDialog.dialogTitle = qsTr("Новый файл"); nameDialog.actionLabel = qsTr("Создать"); nameDialog.initialText = ""; nameDialog.open(); pendingCreate.kind = "file"; pendingCreate.parent = rowMenu.targetPath } }
|
||||
MenuItem { text: qsTr("Создать папку внутри"); visible: rowMenu.targetIsDir; onClicked: { nameDialog.dialogTitle = qsTr("Новая папка"); nameDialog.actionLabel = qsTr("Создать"); nameDialog.initialText = ""; nameDialog.open(); pendingCreate.kind = "folder"; pendingCreate.parent = rowMenu.targetPath } }
|
||||
MenuSeparator { }
|
||||
MenuItem {
|
||||
text: qsTr("Переименовать")
|
||||
onClicked: {
|
||||
renameTarget.path = rowMenu.targetPath
|
||||
nameDialog.dialogTitle = qsTr("Переименовать")
|
||||
nameDialog.actionLabel = qsTr("Переименовать")
|
||||
nameDialog.initialText = root.baseName(rowMenu.targetPath)
|
||||
nameDialog.open()
|
||||
pendingCreate.kind = "rename"
|
||||
}
|
||||
onClicked: { renameTarget.path = rowMenu.targetPath; nameDialog.dialogTitle = qsTr("Переименовать"); nameDialog.actionLabel = qsTr("Переименовать"); nameDialog.initialText = root.baseName(rowMenu.targetPath); nameDialog.open(); pendingCreate.kind = "rename" }
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Удалить")
|
||||
onClicked: {
|
||||
const p = rowMenu.targetPath
|
||||
const isDir = rowMenu.targetIsDir
|
||||
const p = rowMenu.targetPath; const isDir = rowMenu.targetIsDir
|
||||
deleteTarget.path = p
|
||||
deleteDialog.doubleConfirm = isDir && fileStore.itemHasChildren(rowIndexOf(p))
|
||||
if (isDir)
|
||||
deleteDialog.message = fileStore.itemHasChildren(rowIndexOf(p))
|
||||
? qsTr("Удалить папку «%1» со всем её содержимым?").arg(root.baseName(p))
|
||||
: qsTr("Удалить пустую папку «%1»?").arg(root.baseName(p))
|
||||
else
|
||||
deleteDialog.message = qsTr("Удалить файл «%1»?").arg(root.baseName(p))
|
||||
if (isDir) deleteDialog.message = fileStore.itemHasChildren(rowIndexOf(p)) ? qsTr("Удалить папку «%1» со всем её содержимым?").arg(root.baseName(p)) : qsTr("Удалить пустую папку «%1»?").arg(root.baseName(p))
|
||||
else deleteDialog.message = qsTr("Удалить файл «%1»?").arg(root.baseName(p))
|
||||
deleteDialog.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rowIndexOf(p) {
|
||||
const n = fileStore.rowCount()
|
||||
for (let i = 0; i < n; ++i)
|
||||
if (fileStore.itemPath(i) === p) return i
|
||||
return -1
|
||||
}
|
||||
|
||||
function parentForCreate() {
|
||||
if (treeView.currentIndex >= 0 && fileStore.itemIsDir(treeView.currentIndex))
|
||||
return fileStore.itemPath(treeView.currentIndex)
|
||||
return fileStore.vault
|
||||
}
|
||||
function rowIndexOf(p) { const n = fileStore.rowCount(); for (let i=0;i<n;++i) if (fileStore.itemPath(i)===p) return i; return -1 }
|
||||
function parentForCreate() { if (treeView.currentIndex>=0 && fileStore.itemIsDir(treeView.currentIndex)) return fileStore.itemPath(treeView.currentIndex); return fileStore.vault }
|
||||
|
||||
property var renameTarget: ({ path: "" })
|
||||
property var deleteTarget: ({ path: "" })
|
||||
@@ -376,26 +428,13 @@ Item {
|
||||
id: nameDialog
|
||||
onConfirmed: (name) => {
|
||||
let err = ""
|
||||
const createIn = pendingCreate.parent.length > 0
|
||||
? pendingCreate.parent
|
||||
: parentForCreate()
|
||||
if (pendingCreate.kind === "file") {
|
||||
err = fileStore.createFile(createIn, name)
|
||||
} else if (pendingCreate.kind === "folder") {
|
||||
err = fileStore.createFolder(createIn, name)
|
||||
} else if (pendingCreate.kind === "rename") {
|
||||
err = fileStore.renameItem(renameTarget.path, name)
|
||||
}
|
||||
if (err.length > 0) showStatus(err, false)
|
||||
pendingCreate.parent = ""
|
||||
const createIn = pendingCreate.parent.length>0 ? pendingCreate.parent : parentForCreate()
|
||||
if (pendingCreate.kind==="file") err=fileStore.createFile(createIn,name)
|
||||
else if (pendingCreate.kind==="folder") err=fileStore.createFolder(createIn,name)
|
||||
else if (pendingCreate.kind==="rename") err=fileStore.renameItem(renameTarget.path,name)
|
||||
if (err.length>0) showStatus(err,false); else if (pendingCreate.kind!=="rename") showStatus(qsTr("Создано: %1").arg(name), true)
|
||||
pendingCreate.parent=""
|
||||
}
|
||||
}
|
||||
|
||||
ConfirmDialog {
|
||||
id: deleteDialog
|
||||
onConfirmed: {
|
||||
const err = fileStore.removeItem(deleteTarget.path)
|
||||
if (err.length > 0) showStatus(err, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
ConfirmDialog { id: deleteDialog; onConfirmed: { const err=fileStore.removeItem(deleteTarget.path); if(err.length>0) showStatus(err,false); else showStatus(qsTr("Удалено"), true) } }
|
||||
}
|
||||
|
||||
@@ -1,68 +1,69 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Flora
|
||||
|
||||
// Панель быстрого форматирования текста (видна в режиме редактирования).
|
||||
// Работает с выделенным текстом в активной панели (targetPane).
|
||||
Item {
|
||||
id: toolbar
|
||||
|
||||
property var targetPane: null // EditorPane
|
||||
readonly property bool usable: targetPane && targetPane.docIndex >= 0
|
||||
property var targetPane: null
|
||||
readonly property bool usable: targetPane !== null
|
||||
|
||||
signal tableRequested()
|
||||
|
||||
implicitHeight: 34
|
||||
implicitHeight: 38
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "#f3f3f3"
|
||||
border.color: "#ddd"
|
||||
color: Theme.toolbarBg
|
||||
border.color: Theme.divider
|
||||
|
||||
RowLayout {
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 4
|
||||
anchors.rightMargin: 4
|
||||
spacing: 2
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
clip: true
|
||||
ScrollBar.horizontal.policy: ScrollBar.AsNeeded
|
||||
ScrollBar.vertical.policy: ScrollBar.AlwaysOff
|
||||
|
||||
TbButton { text: "B"; tip: qsTr("Жирный"); onTapped: targetPane.applyInline("**", "**") }
|
||||
TbButton { text: "I"; tip: qsTr("Курсив"); onTapped: targetPane.applyInline("*", "*") }
|
||||
TbButton { text: "U"; tip: qsTr("Подчёркнутый"); onTapped: targetPane.applyInline("<u>", "</u>") }
|
||||
TbButton { text: "S"; tip: qsTr("Зачёркнутый"); onTapped: targetPane.applyInline("~~", "~~") }
|
||||
TbButton { text: "=="; tip: qsTr("Выделение текста"); onTapped: targetPane.applyInline("==", "==") }
|
||||
RowLayout {
|
||||
height: parent.height
|
||||
spacing: 2
|
||||
|
||||
TbButton {
|
||||
text: "A"
|
||||
tip: qsTr("Цвет текста")
|
||||
bold: true
|
||||
onTapped: { colorPopup.highlightMode = false; colorPopup.openAt(this) }
|
||||
TbButton { enabled: usable; opacity: enabled?1:0.4; text: "B"; tip: qsTr("Жирный (Ctrl+B)"); onTapped: targetPane.applyInline("**", "**") }
|
||||
TbButton { enabled: usable; opacity: enabled?1:0.4; text: "I"; tip: qsTr("Курсив (Ctrl+I)"); onTapped: targetPane.applyInline("*", "*") }
|
||||
TbButton { enabled: usable; opacity: enabled?1:0.4; text: "U"; tip: qsTr("Подчёркнутый"); onTapped: targetPane.applyInline("<u>", "</u>") }
|
||||
TbButton { enabled: usable; opacity: enabled?1:0.4; text: "S"; tip: qsTr("Зачёркнутый"); onTapped: targetPane.applyInline("~~", "~~") }
|
||||
TbButton { enabled: usable; opacity: enabled?1:0.4; text: "=="; tip: qsTr("Выделение"); onTapped: targetPane.applyInline("==", "==") }
|
||||
|
||||
TbButton {
|
||||
enabled: usable; opacity: enabled?1:0.4
|
||||
text: "A"; tip: qsTr("Цвет текста"); bold: true
|
||||
onTapped: { colorPopup.highlightMode = false; colorPopup.openAt(this) }
|
||||
}
|
||||
TbButton {
|
||||
enabled: usable; opacity: enabled?1:0.4
|
||||
text: "A"; tip: qsTr("Цвет выделения"); accent: true
|
||||
onTapped: { colorPopup.highlightMode = true; colorPopup.openAt(this) }
|
||||
}
|
||||
|
||||
TbButton { enabled: usable; opacity: enabled?1:0.4; text: "x²"; tip: qsTr("Верхний индекс"); onTapped: targetPane.applySubSup("sup") }
|
||||
TbButton { enabled: usable; opacity: enabled?1:0.4; text: "x₂"; tip: qsTr("Нижний индекс"); onTapped: targetPane.applySubSup("sub") }
|
||||
|
||||
Item { width: 8 }
|
||||
Rectangle { width: 1; height: 20; color: Theme.divider }
|
||||
Item { width: 8 }
|
||||
|
||||
TbButton { enabled: usable; opacity: enabled?1:0.4; text: "H#"; tip: qsTr("Заголовок"); onTapped: headingMenu.popup(this) }
|
||||
TbButton { enabled: usable; opacity: enabled?1:0.4; text: "☰"; tip: qsTr("Списки"); onTapped: listMenu.popup(this) }
|
||||
TbButton { enabled: usable; opacity: enabled?1:0.4; text: "❝"; tip: qsTr("Цитата"); onTapped: targetPane.applyLinePrefix("> ") }
|
||||
TbButton { enabled: usable; opacity: enabled?1:0.4; text: "⚠"; tip: qsTr("Выноска"); onTapped: calloutMenu.popup(this) }
|
||||
TbButton { enabled: usable; opacity: enabled?1:0.4; text: "▦"; tip: qsTr("Таблица"); onTapped: toolbar.tableRequested() }
|
||||
TbButton { enabled: usable; opacity: enabled?1:0.4; text: "⊡"; tip: qsTr("Обтекание"); onTapped: targetPane.applyFloatWrap() }
|
||||
}
|
||||
TbButton {
|
||||
text: "A"
|
||||
tip: qsTr("Цвет выделения")
|
||||
accent: true
|
||||
onTapped: { colorPopup.highlightMode = true; colorPopup.openAt(this) }
|
||||
}
|
||||
|
||||
TbButton { text: "x²"; tip: qsTr("Верхний индекс"); onTapped: targetPane.applySubSup("sup") }
|
||||
TbButton { text: "x₂"; tip: qsTr("Нижний индекс"); onTapped: targetPane.applySubSup("sub") }
|
||||
|
||||
Item { width: 6 }
|
||||
Rectangle { width: 1; height: 20; color: "#ccc" }
|
||||
Item { width: 6 }
|
||||
|
||||
TbButton { text: "H#"; tip: qsTr("Заголовок 1–6"); onTapped: headingMenu.popup() }
|
||||
TbButton { text: "☰"; tip: qsTr("Списки"); onTapped: listMenu.popup() }
|
||||
TbButton { text: "❝"; tip: qsTr("Цитата"); onTapped: targetPane.applyLinePrefix("> ") }
|
||||
TbButton { text: "⚠"; tip: qsTr("Выноска"); onTapped: calloutMenu.popup() }
|
||||
TbButton { text: "▦"; tip: qsTr("Таблица"); onTapped: toolbar.tableRequested() }
|
||||
TbButton { text: "⊡"; tip: qsTr("Обтекание картинки/таблицы"); onTapped: targetPane.applyFloatWrap() }
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Меню: заголовки ----------
|
||||
Menu {
|
||||
id: headingMenu
|
||||
title: qsTr("Заголовок")
|
||||
@@ -70,43 +71,28 @@ Item {
|
||||
model: [1, 2, 3, 4, 5, 6]
|
||||
MenuItem {
|
||||
text: "H" + modelData + " — " + "######".slice(0, modelData)
|
||||
onClicked: targetPane.applyHeading(modelData)
|
||||
onTriggered: targetPane.applyHeading(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Меню: списки ----------
|
||||
Menu {
|
||||
id: listMenu
|
||||
title: qsTr("Список")
|
||||
MenuItem {
|
||||
text: qsTr("Маркированный")
|
||||
onClicked: targetPane.applyLinePrefix("- ")
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Нумерованный")
|
||||
onClicked: targetPane.applyLinePrefix("1. ")
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Задачи (- [ ])")
|
||||
onClicked: targetPane.applyTaskToggle()
|
||||
}
|
||||
MenuItem { text: qsTr("Маркированный"); onTriggered: targetPane.applyLinePrefix("- ") }
|
||||
MenuItem { text: qsTr("Нумерованный"); onTriggered: targetPane.applyLinePrefix("1. ") }
|
||||
MenuItem { text: qsTr("Задачи (- [ ])"); onTriggered: targetPane.applyTaskToggle() }
|
||||
}
|
||||
|
||||
// ---------- Меню: выноски ----------
|
||||
Menu {
|
||||
id: calloutMenu
|
||||
title: qsTr("Выноска")
|
||||
Repeater {
|
||||
model: ["NOTE", "INFO", "WARN", "ERROR"]
|
||||
MenuItem {
|
||||
text: modelData
|
||||
onClicked: targetPane.applyCallout(modelData)
|
||||
}
|
||||
MenuItem { text: modelData; onTriggered: targetPane.applyCallout(modelData) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Палитра цветов ----------
|
||||
property var colorList: [
|
||||
"#000000", "#ffffff", "#9e9e9e", "#795548", "#607d8b",
|
||||
"#f44336", "#ff9800", "#ffeb3b", "#4caf50", "#00bcd4",
|
||||
@@ -116,35 +102,29 @@ Item {
|
||||
Popup {
|
||||
id: colorPopup
|
||||
property bool highlightMode: false
|
||||
function openAt(btn) {
|
||||
x = btn.mapToItem(toolbar, 0, 0).x
|
||||
y = btn.mapToItem(toolbar, 0, 0).y + btn.height
|
||||
open()
|
||||
}
|
||||
width: grid.width + 12
|
||||
height: grid.height + 12
|
||||
function openAt(btn) { x = btn.mapToItem(toolbar, 0, 0).x; y = btn.mapToItem(toolbar, 0, 0).y + btn.height + 4; open() }
|
||||
width: grid.width + 16
|
||||
height: grid.height + 16
|
||||
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 8 }
|
||||
Grid {
|
||||
id: grid
|
||||
anchors.centerIn: parent
|
||||
columns: 7
|
||||
spacing: 3
|
||||
spacing: 4
|
||||
Repeater {
|
||||
model: toolbar.colorList
|
||||
delegate: Rectangle {
|
||||
width: 22
|
||||
height: 22
|
||||
radius: 3
|
||||
width: 24; height: 24; radius: 6
|
||||
color: modelData
|
||||
border.color: modelData === "#ffffff" ? "#bbb" : "transparent"
|
||||
border.color: modelData === "#ffffff" ? Theme.borderStrong : "transparent"
|
||||
border.width: 1
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: {
|
||||
toolbar.targetPane.applyColor(modelData, colorPopup.highlightMode)
|
||||
colorPopup.close()
|
||||
}
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: { toolbar.targetPane.applyColor(modelData, colorPopup.highlightMode); colorPopup.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+98
-31
@@ -1,21 +1,21 @@
|
||||
import QtQuick
|
||||
import Flora
|
||||
|
||||
// Небольшое полотно графа связей: узлы-файлы (синие) и теги (зелёные),
|
||||
// рёбра — связи; узлы можно перетаскивать, клик по файлу открывает его.
|
||||
Canvas {
|
||||
id: g
|
||||
|
||||
property var nodes: [] // [{key,name,type}]
|
||||
property var edges: [] // [{from,to}]
|
||||
property var nodes: []
|
||||
property var edges: []
|
||||
signal fileClicked(string path)
|
||||
|
||||
property var pos: ({}) // key -> {x,y}
|
||||
property var pos: ({})
|
||||
property string dragKey: ""
|
||||
property real zoom: 1.0
|
||||
property real panX: 0
|
||||
property real panY: 0
|
||||
property real lastMouseX: 0
|
||||
property real lastMouseY: 0
|
||||
property bool isEmpty: nodes.length === 0
|
||||
|
||||
function nodeForKey(key) {
|
||||
for (let i = 0; i < nodes.length; ++i)
|
||||
@@ -24,15 +24,71 @@ Canvas {
|
||||
}
|
||||
|
||||
function relayout() {
|
||||
pos = {}
|
||||
const n = nodes.length
|
||||
if (n === 0) { requestPaint(); return }
|
||||
if (n === 0) { pos = {}; requestPaint(); return }
|
||||
if (n > 600) {
|
||||
// Слишком много узлов — показываем упрощённо без симуляции чтобы не заморозить UI
|
||||
const cx = width / 2, cy = height / 2
|
||||
const rad = Math.min(width, height) / 2 - 36
|
||||
let newPos = {}
|
||||
for (let i = 0; i < n; ++i) {
|
||||
const a = (i / n) * 2 * Math.PI - Math.PI / 2
|
||||
newPos[nodes[i].key] = { x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) }
|
||||
}
|
||||
pos = newPos; zoom = 1.0; panX = 0; panY = 0; requestPaint(); return
|
||||
}
|
||||
const cx = width / 2, cy = height / 2
|
||||
const rad = Math.min(width, height) / 2 - 30
|
||||
const rad = Math.min(width, height) / 2 - 36
|
||||
let newPos = {}
|
||||
for (let i = 0; i < n; ++i) {
|
||||
const a = (i / n) * 2 * Math.PI - Math.PI / 2
|
||||
pos[nodes[i].key] = { x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) }
|
||||
// небольшой джиттер чтобы не накладывались ровно
|
||||
const jitter = (Math.random() - 0.5) * 8
|
||||
newPos[nodes[i].key] = { x: cx + rad * Math.cos(a) + jitter, y: cy + rad * Math.sin(a) + jitter }
|
||||
}
|
||||
const iterCount = n > 200 ? 25 : (n > 100 ? 40 : 80)
|
||||
// простая force-симуляция для разведения узлов (репульсия + притяжение по рёбрам)
|
||||
for (let iter = 0; iter < iterCount; ++iter) {
|
||||
// репульсия между всеми парами
|
||||
for (let i = 0; i < n; ++i) {
|
||||
for (let j = i + 1; j < n; ++j) {
|
||||
const a = newPos[nodes[i].key]
|
||||
const b = newPos[nodes[j].key]
|
||||
let dx = a.x - b.x, dy = a.y - b.y
|
||||
let dist = Math.sqrt(dx*dx + dy*dy)
|
||||
if (dist < 1) dist = 1
|
||||
if (dist < 70) {
|
||||
const force = (70 - dist) * 0.08
|
||||
const nx = dx / dist * force
|
||||
const ny = dy / dist * force
|
||||
a.x += nx; a.y += ny
|
||||
b.x -= nx; b.y -= ny
|
||||
}
|
||||
}
|
||||
}
|
||||
// притяжение по рёбрам
|
||||
for (let e = 0; e < edges.length; ++e) {
|
||||
const f = newPos[edges[e].from]
|
||||
const t = newPos[edges[e].to]
|
||||
if (!f || !t) continue
|
||||
let dx = t.x - f.x, dy = t.y - f.y
|
||||
let dist = Math.sqrt(dx*dx + dy*dy)
|
||||
if (dist < 1) continue
|
||||
const target = 90
|
||||
const force = (dist - target) * 0.02
|
||||
const nx = dx / dist * force
|
||||
const ny = dy / dist * force
|
||||
f.x += nx; f.y += ny
|
||||
t.x -= nx; t.y -= ny
|
||||
}
|
||||
// удержание в границах
|
||||
for (let k in newPos) {
|
||||
const p = newPos[k]
|
||||
p.x = Math.max(30, Math.min(width - 30, p.x))
|
||||
p.y = Math.max(30, Math.min(height - 30, p.y))
|
||||
}
|
||||
}
|
||||
pos = newPos
|
||||
zoom = 1.0; panX = 0; panY = 0
|
||||
requestPaint()
|
||||
}
|
||||
@@ -41,6 +97,17 @@ Canvas {
|
||||
onWidthChanged: relayout()
|
||||
onHeightChanged: relayout()
|
||||
|
||||
// пустое состояние
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: g.isEmpty
|
||||
text: qsTr("Нет связей\nДобавьте [[ссылки]] или #теги")
|
||||
color: Theme.textMuted
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.pixelSize: 11
|
||||
lineHeight: 1.3
|
||||
}
|
||||
|
||||
function paintCanvas() {
|
||||
const ctx = getContext("2d")
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
@@ -51,9 +118,8 @@ Canvas {
|
||||
ctx.scale(zoom, zoom)
|
||||
ctx.translate(-width / 2, -height / 2)
|
||||
|
||||
// рёбра
|
||||
ctx.lineWidth = 1 / zoom
|
||||
ctx.strokeStyle = "#b9c6d6"
|
||||
ctx.strokeStyle = Theme.graphEdge
|
||||
for (let i = 0; i < edges.length; ++i) {
|
||||
const f = pos[edges[i].from]
|
||||
const t = pos[edges[i].to]
|
||||
@@ -64,7 +130,6 @@ Canvas {
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// узлы
|
||||
ctx.textAlign = "center"
|
||||
for (let i = 0; i < nodes.length; ++i) {
|
||||
const nd = nodes[i]
|
||||
@@ -74,13 +139,16 @@ Canvas {
|
||||
const r = isFile ? 6 : 5
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, r, 0, 2 * Math.PI)
|
||||
ctx.fillStyle = isFile ? (nd.key === g.dragKey ? "#1a6ce0" : "#3f7fd0")
|
||||
: "#3aa06a"
|
||||
if (isFile) {
|
||||
ctx.fillStyle = (nd.key === g.dragKey) ? Theme.accentHover : Theme.graphNodeFile
|
||||
} else {
|
||||
ctx.fillStyle = Theme.graphNodeTag
|
||||
}
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = "#ffffff"
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeStyle = Theme.bg
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = "#5a6a7d"
|
||||
ctx.fillStyle = Theme.graphLabel
|
||||
ctx.font = (9 / zoom) + "px sans-serif"
|
||||
const txt = nd.name.length > 14 ? nd.name.substring(0, 13) + "…" : nd.name
|
||||
ctx.fillText(txt, p.x, p.y + r + 10 / zoom)
|
||||
@@ -94,6 +162,8 @@ Canvas {
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton
|
||||
hoverEnabled: true
|
||||
cursorShape: g.dragKey.length > 0 ? Qt.ClosedHandCursor : Qt.ArrowCursor
|
||||
onWheel: (w) => {
|
||||
const factor = w.angleDelta.y > 0 ? 1.15 : 0.87
|
||||
g.zoom = Math.max(0.2, Math.min(5.0, g.zoom * factor))
|
||||
@@ -108,18 +178,16 @@ Canvas {
|
||||
for (const key in g.pos) {
|
||||
const p = g.pos[key]
|
||||
const dx = gx - p.x, dy = gy - p.y
|
||||
if (dx * dx + dy * dy <= 256) {
|
||||
g.dragKey = key
|
||||
break
|
||||
}
|
||||
if (dx * dx + dy * dy <= 256) { g.dragKey = key; break }
|
||||
}
|
||||
}
|
||||
onPositionChanged: (m) => {
|
||||
if (!pressed) return
|
||||
if (g.dragKey.length > 0 && g.pos[g.dragKey]) {
|
||||
const gx = (m.x - width / 2 - panX) / zoom + width / 2
|
||||
const gy = (m.y - height / 2 - panY) / zoom + height / 2
|
||||
g.pos[g.dragKey] = { x: gx, y: gy }
|
||||
} else {
|
||||
} else if (pressed) {
|
||||
g.panX += m.x - g.lastMouseX
|
||||
g.panY += m.y - g.lastMouseY
|
||||
}
|
||||
@@ -128,24 +196,23 @@ Canvas {
|
||||
g.requestPaint()
|
||||
}
|
||||
onReleased: (m) => {
|
||||
if (g.dragKey.length === 0) {
|
||||
const gx = (m.x - width / 2 - panX) / zoom + width / 2
|
||||
const gy = (m.y - height / 2 - panY) / zoom + height / 2
|
||||
const wasDrag = g.dragKey.length > 0
|
||||
const gx = (m.x - width / 2 - panX) / zoom + width / 2
|
||||
const gy = (m.y - height / 2 - panY) / zoom + height / 2
|
||||
if (!wasDrag) {
|
||||
for (const key in g.pos) {
|
||||
const p = g.pos[key]
|
||||
const dx = gx - p.x, dy = gy - p.y
|
||||
if (dx * dx + dy * dy <= 144) {
|
||||
const nd = g.nodeForKey(key)
|
||||
if (nd && nd.type === "file")
|
||||
g.fileClicked(nd.key)
|
||||
if (nd && nd.type === "file") g.fileClicked(nd.key)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
g.dragKey = ""
|
||||
g.requestPaint()
|
||||
}
|
||||
onDoubleClicked: {
|
||||
g.zoom = 1.0; g.panX = 0; g.panY = 0; g.relayout()
|
||||
}
|
||||
onDoubleClicked: { g.zoom = 1.0; g.panX = 0; g.panY = 0; g.relayout() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Flora
|
||||
|
||||
// Простой календарь: шапка (месяц/год, ←/→), сетка дней.
|
||||
// Клик по дню → onDaySelected("YYYY-MM-DD").
|
||||
Item {
|
||||
id: cal
|
||||
|
||||
@@ -11,7 +10,7 @@ Item {
|
||||
|
||||
property date currentMonth: new Date()
|
||||
readonly property int year: currentMonth.getFullYear()
|
||||
readonly property int month: currentMonth.getMonth() // 0..11
|
||||
readonly property int month: currentMonth.getMonth()
|
||||
|
||||
function fmtDate(d) {
|
||||
function pad(n) { return n < 10 ? "0" + n : "" + n }
|
||||
@@ -20,36 +19,47 @@ Item {
|
||||
|
||||
function gridModel() {
|
||||
const first = new Date(year, month, 1)
|
||||
const startDow = first.getDay() // 0=вс
|
||||
const dowSun0 = first.getDay()
|
||||
const startDow = (dowSun0 + 6) % 7
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
const list = []
|
||||
for (let i = 0; i < startDow; ++i)
|
||||
list.push(0)
|
||||
for (let d = 1; d <= daysInMonth; ++d)
|
||||
list.push(d)
|
||||
for (let i = 0; i < startDow; ++i) list.push(0)
|
||||
for (let d = 1; d <= daysInMonth; ++d) list.push(d)
|
||||
while (list.length % 7 !== 0) list.push(0)
|
||||
while (list.length < 42) list.push(0)
|
||||
return list
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 2
|
||||
spacing: 4
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
ToolButton {
|
||||
text: "<"
|
||||
text: "‹"
|
||||
font.pixelSize: 16
|
||||
onClicked: cal.currentMonth = new Date(year, month - 1, 1)
|
||||
}
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.bold: true
|
||||
color: Theme.text
|
||||
text: Qt.locale().monthName(month, Locale.LongFormat) + " " + year
|
||||
}
|
||||
ToolButton {
|
||||
text: ">"
|
||||
text: "›"
|
||||
font.pixelSize: 16
|
||||
onClicked: cal.currentMonth = new Date(year, month + 1, 1)
|
||||
}
|
||||
ToolButton {
|
||||
text: "\u2302"
|
||||
font.pixelSize: 12
|
||||
ToolTip.text: qsTr("Сегодня")
|
||||
ToolTip.visible: hovered
|
||||
onClicked: cal.currentMonth = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
@@ -60,7 +70,8 @@ Item {
|
||||
Layout.fillWidth: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.pixelSize: 10
|
||||
color: "#888"
|
||||
font.bold: true
|
||||
color: Theme.textMuted
|
||||
text: modelData
|
||||
}
|
||||
}
|
||||
@@ -70,57 +81,61 @@ Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
columns: 7
|
||||
rowSpacing: 1
|
||||
columnSpacing: 1
|
||||
rowSpacing: 2
|
||||
columnSpacing: 2
|
||||
|
||||
Repeater {
|
||||
model: cal.gridModel()
|
||||
delegate: Rectangle {
|
||||
required property int modelData
|
||||
width: cal.width / 7 - 2
|
||||
height: cal.height / 10 - 1
|
||||
radius: 3
|
||||
color: modelData > 0 && modelData === now.day ? "#dbeafe"
|
||||
: (modelData > 0 ? "#ffffff" : "transparent")
|
||||
|
||||
readonly property var now: {
|
||||
const t = new Date()
|
||||
return { day: t.getDate(), month: t.getMonth(), year: t.getFullYear() }
|
||||
width: (cal.width - 12) / 7
|
||||
height: (cal.height - 50) / 6
|
||||
radius: 6
|
||||
border.width: isToday ? 1 : 0
|
||||
border.color: Theme.accent
|
||||
color: {
|
||||
if (modelData <= 0) return "transparent"
|
||||
if (isToday) return Theme.calendarToday
|
||||
if (isSelected) return Theme.accentMuted
|
||||
if (hasNote) return Theme.isDark ? "#252a33" : "#f8f9fb"
|
||||
return Theme.surface
|
||||
}
|
||||
|
||||
// Проверяем, есть ли заметка за этот день
|
||||
readonly property var now: { const t = new Date(); return { day: t.getDate(), month: t.getMonth(), year: t.getFullYear() } }
|
||||
readonly property bool isToday: modelData > 0 && now.day === modelData && now.month === cal.month && now.year === cal.year
|
||||
readonly property string ds: modelData > 0 ? Qt.formatDate(new Date(cal.year, cal.month, modelData), "yyyy-MM-dd") : ""
|
||||
readonly property bool hasNote: {
|
||||
// Явная зависимость от currentMonth — пересчитываем при смене месяца
|
||||
const _ = cal.currentMonth
|
||||
if (modelData <= 0 || !fileStore.ready) return false
|
||||
const ds = Qt.formatDate(new Date(cal.year, cal.month, modelData), "yyyy-MM-dd")
|
||||
return fileStore.fileExists(fileStore.vault + "/Журнал/" + ds + ".md")
|
||||
}
|
||||
readonly property bool isSelected: false // можно подсветить выбранный день
|
||||
|
||||
Label {
|
||||
id: dayLabel
|
||||
anchors.centerIn: parent
|
||||
anchors.verticalCenterOffset: hasNote ? -3 : 0
|
||||
visible: modelData > 0
|
||||
text: modelData
|
||||
font.pixelSize: 11
|
||||
color: modelData > 0 ? "#222" : "#ddd"
|
||||
font.bold: isToday
|
||||
color: modelData > 0 ? (isToday ? Theme.accentText : Theme.text) : "transparent"
|
||||
}
|
||||
|
||||
// Точка — есть заметка за этот день (под цифрой)
|
||||
Rectangle {
|
||||
visible: modelData > 0 && parent.hasNote
|
||||
width: 5
|
||||
height: 5
|
||||
radius: 3
|
||||
color: "#3b82f6"
|
||||
width: 5; height: 5; radius: 2.5
|
||||
color: Theme.calendarDot
|
||||
anchors.top: dayLabel.bottom
|
||||
anchors.topMargin: 1
|
||||
anchors.topMargin: 2
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
visible: modelData > 0
|
||||
enabled: modelData > 0
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
const d = new Date(cal.year, cal.month, modelData)
|
||||
cal.daySelected(fmtDate(d))
|
||||
@@ -129,5 +144,13 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
// подпись
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("Клик — заметка дня")
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: 9
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+237
-149
@@ -1,221 +1,309 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Flora
|
||||
|
||||
// Правая панель: календарь (заметки дня), «Ссылки и теги» активного файла,
|
||||
// граф связей (файлы + теги, рёбра по ссылкам).
|
||||
Item {
|
||||
id: root
|
||||
|
||||
function activePath() {
|
||||
return documentManager.pathAt(documentManager.activeIndex)
|
||||
}
|
||||
function activePath() { return documentManager.pathAt(documentManager.activeIndex) }
|
||||
|
||||
function refreshLinksTags() {
|
||||
const p = root.activePath()
|
||||
linksModel.clear()
|
||||
if (p.length > 0) {
|
||||
const links = vaultIndex.linksOfFile(p)
|
||||
for (let i = 0; i < links.length; ++i) {
|
||||
linksModel.append({ text: links[i].text, path: links[i].path })
|
||||
}
|
||||
for (let i = 0; i < links.length; ++i) linksModel.append({ text: links[i].text, path: links[i].path })
|
||||
}
|
||||
tagsModel.clear()
|
||||
const tags = p.length > 0 ? vaultIndex.tagsOfFile(p) : []
|
||||
for (let i = 0; i < tags.length; ++i)
|
||||
tagsModel.append({ tag: tags[i] })
|
||||
for (let i = 0; i < tags.length; ++i) tagsModel.append({ tag: tags[i] })
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (fileStore.ready)
|
||||
vaultIndex.rebuild(fileStore.vault)
|
||||
}
|
||||
Component.onCompleted: { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||||
|
||||
Connections {
|
||||
target: fileStore
|
||||
function onVaultChanged() {
|
||||
if (fileStore.ready)
|
||||
vaultIndex.rebuild(fileStore.vault)
|
||||
}
|
||||
function onReadyChanged() {
|
||||
if (fileStore.ready)
|
||||
vaultIndex.rebuild(fileStore.vault)
|
||||
}
|
||||
function onVaultChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||||
function onReadyChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||||
}
|
||||
Connections {
|
||||
target: documentManager
|
||||
function onActiveIndexChanged() { root.refreshLinksTags() }
|
||||
function onOpenTabsChanged() { root.refreshLinksTags() }
|
||||
function onContentChanged(i) {
|
||||
if (i === documentManager.activeIndex) root.refreshLinksTags()
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: vaultIndex
|
||||
function onIndexChanged() { root.refreshLinksTags() }
|
||||
function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); rebuildDebounce.restart() } }
|
||||
function onDocumentSaved() { rebuildDebounce.restart() }
|
||||
}
|
||||
Connections { target: vaultIndex; function onIndexChanged() { root.refreshLinksTags() } }
|
||||
Timer { id: rebuildDebounce; interval: 800; onTriggered: if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||||
|
||||
ColumnLayout {
|
||||
// Делаем панели ресайзабельными внутри правой колонки
|
||||
SplitView {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
orientation: Qt.Vertical
|
||||
handle: Rectangle { implicitHeight: 1; color: Theme.divider }
|
||||
|
||||
// ---------- Календарь ----------
|
||||
// Календарь
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 218
|
||||
color: "#fafafa"
|
||||
id: calBox
|
||||
SplitView.preferredHeight: 240
|
||||
SplitView.minimumHeight: 180
|
||||
SplitView.maximumHeight: 360
|
||||
color: Theme.bgAlt
|
||||
border.color: Theme.divider
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
Label {
|
||||
text: qsTr("Календарь")
|
||||
font.bold: true
|
||||
color: "#555"
|
||||
padding: 6
|
||||
}
|
||||
MonthCalendar {
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
onDaySelected: (ds) => {
|
||||
const path = fileStore.dailyNotePath(ds)
|
||||
if (path.length > 0)
|
||||
documentManager.openDocument(path)
|
||||
}
|
||||
} }
|
||||
}
|
||||
|
||||
// ---------- Ссылки и теги активного файла ----------
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: "#f4f4f4"
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 6
|
||||
spacing: 4
|
||||
|
||||
Label {
|
||||
text: qsTr("Ссылки и теги")
|
||||
font.bold: true
|
||||
color: "#555"
|
||||
}
|
||||
Label {
|
||||
text: root.activePath().length > 0
|
||||
? qsTr("Файл: %1").arg(root.baseName(activePath()))
|
||||
: qsTr("Нет активного файла")
|
||||
elide: Text.ElideMiddle
|
||||
color: "#888"
|
||||
Layout.fillWidth: true
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
Label { text: qsTr("Ссылки"); color: "#666"; font.pixelSize: 11 }
|
||||
ListView {
|
||||
id: linksList
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(120, linksModel.count * 22)
|
||||
model: ListModel { id: linksModel }
|
||||
clip: true
|
||||
delegate: ItemDelegate {
|
||||
width: ListView.view.width
|
||||
height: 22
|
||||
text: model.text
|
||||
font.pixelSize: 12
|
||||
onClicked: documentManager.openDocument(model.path)
|
||||
}
|
||||
}
|
||||
Label {
|
||||
visible: linksModel.count === 0
|
||||
text: qsTr("— нет ссылок —")
|
||||
color: "#bbb"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
Label { text: qsTr("Теги"); color: "#666"; font.pixelSize: 11 }
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
readonly property var m: ListModel { id: tagsModel }
|
||||
Repeater {
|
||||
model: tagsModel
|
||||
delegate: TagChip {
|
||||
text: "#" + model.tag
|
||||
onClicked: {
|
||||
tagFilesModel.clear()
|
||||
const files = vaultIndex.filesWithTag(model.tag)
|
||||
for (let i = 0; i < files.length; ++i)
|
||||
tagFilesModel.append({ path: files[i], name: baseName(files[i]) })
|
||||
tagFilesTitle.text = qsTr("Файлы с тегом #%1 (%2)").arg(model.tag).arg(files.length)
|
||||
tagFilesPopup.open()
|
||||
}
|
||||
height: 28
|
||||
color: "transparent"
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8; anchors.rightMargin: 4
|
||||
Label { text: qsTr("Календарь"); font.bold: true; color: Theme.textSecondary; font.pixelSize: 12 }
|
||||
Item { Layout.fillWidth: true }
|
||||
ToolButton {
|
||||
text: "\u2637"
|
||||
font.pixelSize: 12
|
||||
ToolTip.text: qsTr("Сегодня")
|
||||
ToolTip.visible: hovered
|
||||
onClicked: cal.currentMonth = new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { Layout.fillHeight: true }
|
||||
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.divider }
|
||||
MonthCalendar {
|
||||
id: cal
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.margins: 4
|
||||
onDaySelected: (ds) => {
|
||||
const path = fileStore.dailyNotePath(ds)
|
||||
if (path.length > 0) documentManager.openDocument(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Граф связей ----------
|
||||
// Ссылки и теги
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: "#eef1f5"
|
||||
id: linksBox
|
||||
SplitView.preferredHeight: 260
|
||||
SplitView.minimumHeight: 120
|
||||
color: Theme.bg
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
ColumnLayout {
|
||||
width: parent.width - 12
|
||||
x: 6
|
||||
y: 6
|
||||
spacing: 8
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Label { text: qsTr("Ссылки и теги"); font.bold: true; color: Theme.textSecondary; font.pixelSize: 12 }
|
||||
Item { Layout.fillWidth: true }
|
||||
Label {
|
||||
text: vaultIndex.fileCount + " " + qsTr("файлов")
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: 10
|
||||
}
|
||||
}
|
||||
Label {
|
||||
text: root.activePath().length > 0 ? qsTr("Файл: %1").arg(root.baseName(activePath())) : qsTr("Нет активного файла")
|
||||
elide: Text.ElideMiddle
|
||||
color: Theme.textMuted
|
||||
Layout.fillWidth: true
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
// Ссылки
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
color: Theme.bgAlt
|
||||
radius: 8
|
||||
border.color: Theme.border
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 4
|
||||
Label { text: qsTr("Ссылки"); color: Theme.textSecondary; font.pixelSize: 11; font.bold: true }
|
||||
Repeater {
|
||||
model: ListModel { id: linksModel }
|
||||
// placeholder, используем ListView внутри
|
||||
}
|
||||
ListView {
|
||||
id: linksList
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(140, contentHeight)
|
||||
model: linksModel
|
||||
clip: true
|
||||
delegate: ItemDelegate {
|
||||
width: ListView.view.width
|
||||
height: 28
|
||||
text: model.text
|
||||
font.pixelSize: 12
|
||||
palette.text: Theme.text
|
||||
background: Rectangle { color: hovered ? Theme.surfaceHover : "transparent"; radius: 6 }
|
||||
onClicked: documentManager.openDocument(model.path)
|
||||
}
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
}
|
||||
Label {
|
||||
visible: linksModel.count === 0
|
||||
text: qsTr("— нет ссылок —")
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: 11
|
||||
font.italic: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Теги
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
color: Theme.bgAlt
|
||||
radius: 8
|
||||
border.color: Theme.border
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 6
|
||||
Label { text: qsTr("Теги"); color: Theme.textSecondary; font.pixelSize: 11; font.bold: true }
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
Repeater {
|
||||
model: tagsModel
|
||||
delegate: TagChip {
|
||||
text: "#" + model.tag
|
||||
onClicked: {
|
||||
tagFilesModel.clear()
|
||||
const files = vaultIndex.filesWithTag(model.tag)
|
||||
for (let i=0;i<files.length;++i) tagFilesModel.append({ path: files[i], name: baseName(files[i]) })
|
||||
tagFilesTitle.text = qsTr("Файлы с тегом #%1 (%2)").arg(model.tag).arg(files.length)
|
||||
tagFilesPopup.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
Label {
|
||||
visible: tagsModel.count === 0
|
||||
text: qsTr("— нет тегов —")
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: 11
|
||||
font.italic: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Граф
|
||||
Rectangle {
|
||||
id: graphBox
|
||||
SplitView.fillHeight: true
|
||||
SplitView.minimumHeight: 160
|
||||
color: Theme.graphBg
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
RowLayout {
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Label {
|
||||
text: qsTr("Граф связей")
|
||||
font.bold: true
|
||||
color: "#555"
|
||||
padding: 6
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Button {
|
||||
text: qsTr("Обновить")
|
||||
font.pixelSize: 11
|
||||
implicitHeight: 22
|
||||
onClicked: vaultIndex.rebuild(fileStore.vault)
|
||||
height: 32
|
||||
color: "transparent"
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8; anchors.rightMargin: 4
|
||||
Label { text: qsTr("Граф связей"); font.bold: true; color: Theme.textSecondary; font.pixelSize: 12 }
|
||||
Item { Layout.fillWidth: true }
|
||||
Label { text: vaultIndex.nodes.length + "·" + vaultIndex.edges.length; color: Theme.textFaint; font.pixelSize: 10 }
|
||||
ToolButton {
|
||||
text: "\u21BB"
|
||||
font.pixelSize: 12
|
||||
ToolTip.text: qsTr("Обновить")
|
||||
ToolTip.visible: hovered
|
||||
onClicked: vaultIndex.rebuild(fileStore.vault)
|
||||
}
|
||||
ToolButton {
|
||||
text: "\u2630"
|
||||
ToolTip.text: qsTr("Справка")
|
||||
ToolTip.visible: hovered
|
||||
onClicked: graphHelp.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.divider }
|
||||
GraphView {
|
||||
id: graph
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
nodes: vaultIndex.nodes
|
||||
edges: vaultIndex.edges
|
||||
onFileClicked: (path) => documentManager.openDocument(path)
|
||||
}
|
||||
// легенда
|
||||
Row {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 6
|
||||
spacing: 12
|
||||
Row { spacing: 4; Rectangle { width: 10; height: 10; radius: 5; color: Theme.graphNodeFile; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("Файл"); color: Theme.textMuted; font.pixelSize: 10 } }
|
||||
Row { spacing: 4; Rectangle { width: 10; height: 10; radius: 5; color: Theme.graphNodeTag; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("Тег"); color: Theme.textMuted; font.pixelSize: 10 } }
|
||||
Label { text: qsTr("Колесо — зум, перетаскивание — перемещение"); color: Theme.textFaint; font.pixelSize: 9; elide: Text.ElideRight; Layout.fillWidth: true; horizontalAlignment: Text.AlignRight }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function baseName(p) {
|
||||
const idx = p.lastIndexOf("/")
|
||||
return idx >= 0 ? p.substring(idx + 1) : p
|
||||
}
|
||||
function baseName(p) { const idx = p.lastIndexOf("/"); return idx >= 0 ? p.substring(idx+1) : p }
|
||||
|
||||
Popup {
|
||||
id: tagFilesPopup
|
||||
modal: true
|
||||
anchors.centerIn: parent
|
||||
width: 300
|
||||
width: 360
|
||||
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
Label { id: tagFilesTitle; font.bold: true }
|
||||
ListView {
|
||||
Layout.preferredHeight: Math.min(220, contentHeight)
|
||||
width: parent.width - 16
|
||||
x: 8; y: 8
|
||||
Label { id: tagFilesTitle; font.bold: true; color: Theme.text }
|
||||
ScrollView {
|
||||
Layout.preferredHeight: Math.min(260, contentHeight)
|
||||
Layout.fillWidth: true
|
||||
model: ListModel { id: tagFilesModel }
|
||||
clip: true
|
||||
delegate: ItemDelegate {
|
||||
width: ListView.view.width
|
||||
text: model.name
|
||||
onClicked: {
|
||||
documentManager.openDocument(model.path)
|
||||
tagFilesPopup.close()
|
||||
ListView {
|
||||
model: ListModel { id: tagFilesModel }
|
||||
delegate: ItemDelegate {
|
||||
width: ListView.view.width
|
||||
text: model.name
|
||||
onClicked: { documentManager.openDocument(model.path); tagFilesPopup.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
Button { text: qsTr("Закрыть"); Layout.alignment: Qt.AlignRight; onClicked: tagFilesPopup.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Popup {
|
||||
id: graphHelp
|
||||
anchors.centerIn: parent
|
||||
width: 320
|
||||
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
|
||||
ColumnLayout {
|
||||
width: parent.width - 16
|
||||
x: 8; y: 8
|
||||
spacing: 8
|
||||
Label { text: qsTr("Граф связей"); font.bold: true; color: Theme.text }
|
||||
Label { text: qsTr("• Синие узлы — файлы, зелёные — теги\n• Клик по файлу — открыть\n• Перетаскивание — переместить узел\n• Колесо — зум, перетаскивание фона — панорама\n• Двойной клик — сброс"); color: Theme.textSecondary; wrapMode: Text.Wrap; Layout.fillWidth: true; font.pixelSize: 11 }
|
||||
Button { text: qsTr("Понятно"); Layout.alignment: Qt.AlignRight; onClicked: graphHelp.close() }
|
||||
}
|
||||
}
|
||||
|
||||
// скрытая модель тегов (для Flow)
|
||||
ListModel { id: tagsModel }
|
||||
}
|
||||
|
||||
+12
-8
@@ -1,35 +1,39 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Flora
|
||||
|
||||
// Кликабельный «чип» тега.
|
||||
Item {
|
||||
id: chip
|
||||
|
||||
property string text: ""
|
||||
signal clicked()
|
||||
|
||||
implicitWidth: label.implicitWidth + 16
|
||||
implicitHeight: 20
|
||||
implicitWidth: label.implicitWidth + 18
|
||||
implicitHeight: 22
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
color: mouse.containsMouse ? "#dbeafe" : "#e8eef7"
|
||||
border.color: "#b7c9e0"
|
||||
color: mouse.containsMouse ? Theme.tagChipBgHover : Theme.tagChipBg
|
||||
border.color: mouse.pressed ? Theme.accent : Theme.tagChipBorder
|
||||
border.width: 1
|
||||
Behavior on color { ColorAnimation { duration: 120 } }
|
||||
|
||||
Label {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: chip.text
|
||||
font.pixelSize: 10
|
||||
color: "#2b4b74"
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
color: Theme.tagChipText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: chip.clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
-5
@@ -1,7 +1,7 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Flora
|
||||
|
||||
// Компактная кнопка тулбара.
|
||||
Button {
|
||||
id: b
|
||||
|
||||
@@ -10,14 +10,50 @@ Button {
|
||||
property bool accent: false
|
||||
signal tapped()
|
||||
|
||||
implicitWidth: b.text.length > 1 ? 34 : 28
|
||||
implicitWidth: b.text.length > 2 ? 36 : 30
|
||||
implicitHeight: 26
|
||||
font.bold: bold
|
||||
highlighted: accent
|
||||
font.pixelSize: 13
|
||||
palette.button: Theme.toolbarBg
|
||||
palette.buttonText: Theme.text
|
||||
palette.highlight: Theme.accent
|
||||
|
||||
ToolTip.visible: hovered
|
||||
// фон под тему
|
||||
background: Rectangle {
|
||||
color: b.pressed ? Theme.surfaceSelected : b.hovered ? Theme.surfaceHover : "transparent"
|
||||
radius: 6
|
||||
border.color: b.pressed ? Theme.accent : "transparent"
|
||||
}
|
||||
contentItem: Label {
|
||||
text: b.text
|
||||
font: b.font
|
||||
color: b.accent ? Theme.accent : (b.enabled ? Theme.text : Theme.textFaint)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
// особый стиль для цветных А
|
||||
Rectangle {
|
||||
visible: b.accent
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: parent.width - 6; height: 3
|
||||
radius: 1
|
||||
color: Theme.warningText
|
||||
opacity: 0.9
|
||||
}
|
||||
Rectangle {
|
||||
visible: b.bold && !b.accent
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: parent.width - 6; height: 3
|
||||
radius: 1
|
||||
color: Theme.accent
|
||||
opacity: 0.9
|
||||
}
|
||||
}
|
||||
|
||||
ToolTip.visible: hovered && tip.length > 0
|
||||
ToolTip.text: tip
|
||||
ToolTip.delay: 600
|
||||
|
||||
onClicked: b.tapped()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user