import QtQuick import QtQuick.Controls import QtQuick.Layouts import Flora 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 ? "
" + qsTr("Пустой файл — начните писать в режиме редактирования") + "
" : rendered } else if (viewMode === "live") { const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) livePreview.text = rendered.trim().length === 0 ? "" + qsTr("Предпросмотр пуст") + "
" : rendered } } function saveViewModeForCurrentTab() { if (_loadingViewMode) return if (documentManager.activeIndex < 0 || !fileStore.ready) return const path = documentManager.pathAt(documentManager.activeIndex) if (!path) return settingsStore.setViewModeForFile(fileStore.vault, path, viewMode) } onViewModeChanged: { if (_loadingViewMode) return saveViewModeForCurrentTab() if (viewMode === "preview") { const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) if (rendered.trim().length === 0) { viewArea.text = "" + qsTr("Пустой файл — начните писать в режиме редактирования") + "
" } else { viewArea.text = rendered } } else if (viewMode === "live") { const r = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) livePreview.text = r.trim().length === 0 ? "" + qsTr("Предпросмотр пуст") + "
" : r if (livePreview.text.trim().length === 0) livePreview.text = "" + qsTr("Предпросмотр пуст") + "
" } } function baseName(p) { const i = p.lastIndexOf("/") return i >= 0 ? p.substring(i + 1) : p } function dirName(p) { const i = p.lastIndexOf("/") return i >= 0 ? p.substring(0, i) : "" } // --- Вкладки с перетаскиванием --- Rectangle { id: tabRow anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right height: 38 color: Theme.bgAlt border.color: Theme.divider Rectangle { anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right; height: 1; color: Theme.divider } ScrollView { id: tabScroll anchors.fill: parent anchors.leftMargin: 6 anchors.rightMargin: 6 clip: true ScrollBar.horizontal.policy: ScrollBar.AsNeeded ScrollBar.vertical.policy: ScrollBar.AlwaysOff Row { id: tabRowLayout spacing: 4 height: tabScroll.height Repeater { model: documentManager.openTabs delegate: Rectangle { id: tab required property string modelData required property int index readonly property bool active: index === documentManager.activeIndex y: 5 width: Math.max(120, tabLabel.implicitWidth + 40) height: 28; radius: 6 color: active ? Theme.tabActiveBg : Theme.tabInactiveBg border.color: active ? Theme.accent : (dropArea.containsDrag ? Theme.accent : "transparent") border.width: active || dropArea.containsDrag ? 1 : 0 opacity: Drag.active ? 0.6 : 1.0 // Drag для переупорядочивания Drag.active: dragHandler.active Drag.source: tab Drag.hotSpot.x: width/2 Drag.hotSpot.y: height/2 Drag.keys: ["tab"] Rectangle { visible: active anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right height: 2 color: Theme.accent radius: 1 } RowLayout { anchors.fill: parent anchors.leftMargin: 8 anchors.rightMargin: 4 spacing: 4 Label { id: tabLabel Layout.fillWidth: true text: root.baseName(modelData) + (documentManager.isDirty(index) ? " •" : "") font.pixelSize: 12 color: tab.active ? Theme.text : Theme.textSecondary elide: Text.ElideMiddle } ToolButton { text: "\u2715" implicitWidth: 20 implicitHeight: 20 font.pixelSize: 10 opacity: tab.active || hovered ? 0.9 : 0 visible: opacity > 0 onClicked: documentManager.closeTab(index) ToolTip.text: qsTr("Закрыть") ToolTip.visible: hovered } } HoverHandler { id: hover } // Клик — смена активной вкладки, средняя кнопка — закрыть TapHandler { id: tapHandler acceptedButtons: Qt.LeftButton | Qt.MiddleButton onTapped: (eventPoint, button) => { if (button === Qt.MiddleButton) documentManager.closeTab(index) else documentManager.activeIndex = index } } // Перетаскивание вкладок DragHandler { id: dragHandler target: null acceptedButtons: Qt.LeftButton } DropArea { id: dropArea anchors.fill: parent keys: ["tab"] onEntered: (drag) => { if (drag.source && drag.source !== tab) drag.accepted = true } onDropped: (drop) => { if (drop.source && drop.source.index !== undefined) { const from = drop.source.index const to = index if (from !== to) documentManager.moveTab(from, to) } } } } } } } Label { visible: documentManager.openTabs.length === 0 anchors.centerIn: parent text: qsTr("Нет открытых файлов") color: Theme.textMuted font.pixelSize: 12 font.italic: true } ToolButton { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.rightMargin: 6 visible: false // старый sideBySide убран в пользу live } } // Хлебные крошки Rectangle { id: crumbs anchors.top: tabRow.bottom anchors.left: parent.left anchors.right: parent.right height: visible ? 24 : 0 visible: documentManager.activeIndex >= 0 color: Theme.bg Label { anchors.fill: parent anchors.leftMargin: 10 verticalAlignment: Text.AlignVCenter text: { const p = documentManager.pathAt(documentManager.activeIndex) if (!p) return "" const vault = fileStore.vault let rel = p if (vault && p.startsWith(vault)) rel = p.substring(vault.length + 1) return rel } color: Theme.textMuted font.pixelSize: 11 elide: Text.ElideMiddle } Rectangle { anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right; height: 1; color: Theme.divider; visible: viewMode !== "live" } } FormatToolbar { id: fmtBar anchors.top: crumbs.bottom anchors.left: parent.left anchors.right: parent.right height: 36 visible: documentManager.activeIndex >= 0 && viewMode !== "preview" targetPane: root onTableRequested: { tableEditor.resetDialog(); tableEditor.open() } } // --- Контент --- SplitView { id: contentSplit anchors.top: fmtBar.visible ? fmtBar.bottom : crumbs.bottom anchors.left: parent.left anchors.right: parent.right anchors.bottom: toolbar.top orientation: Qt.Horizontal handle: Rectangle { implicitWidth: viewMode === "live" ? 1 : 0 implicitHeight: viewMode === "live" ? 1 : 0 color: Theme.divider visible: viewMode === "live" } // Редактор — виден в edit и live ScrollView { id: editorScroll SplitView.fillWidth: true SplitView.minimumWidth: 200 visible: viewMode !== "preview" && documentManager.activeIndex >= 0 clip: true ScrollBar.vertical.policy: ScrollBar.AsNeeded ScrollBar.horizontal.policy: ScrollBar.AsNeeded TextArea { id: textArea placeholderText: qsTr("Начните писать…") placeholderTextColor: Theme.textFaint color: Theme.editorText selectionColor: Theme.selectionBg selectedTextColor: Theme.text font.family: "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) } } } 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" + qsTr("Пустой файл — начните писать в режиме редактирования") + "
" : rendered } else if (viewMode === "live") { const rendered = mdParser.render(c, fileStore.vault, activeFileDir()) livePreview.text = rendered.trim().length === 0 ? "" + qsTr("Предпросмотр пуст") + "
" : rendered } } else { textArea.text = "" viewArea.text = "" livePreview.text = "" } refreshGitStatus() } Connections { target: documentManager function onActiveIndexChanged() { root.loadContent() } function onContentChanged(idx) { if (idx === documentManager.activeIndex && !textArea.activeFocus) root.loadContent(); refreshGitStatus() } function onOpenTabsChanged() { root.loadContent() } function onDirtyFlagChanged() { refreshGitStatus() } function onDocumentSaved() { 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('', '') else applyInline('', '') } function applySubSup(kind) { if (kind === "sup") applyInline("", "") else applyInline("", "") } // --- Нижняя панель --- Rectangle { id: toolbar anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right height: 36 color: Theme.toolbarBg border.color: Theme.divider RowLayout { anchors.fill: parent; anchors.leftMargin: 8; anchors.rightMargin: 8; spacing: 8 Label { visible: documentManager.activeIndex >= 0 text: { const t = textArea.text const words = t.trim().length === 0 ? 0 : t.trim().split(/\s+/).length const chars = t.length return qsTr("%1 слов, %2 симв.").arg(words).arg(chars) } color: Theme.textMuted font.pixelSize: 11 elide: Text.ElideRight } Item { Layout.fillWidth: true } Row { visible: documentManager.activeIndex >= 0 spacing: 8 Label { text: documentManager.isDirty(documentManager.activeIndex) ? qsTr("● Редактируется") : qsTr("✓ Сохранено") color: documentManager.isDirty(documentManager.activeIndex) ? Theme.warningText : Theme.successText font.pixelSize: 11 font.bold: documentManager.isDirty(documentManager.activeIndex) anchors.verticalCenter: parent.verticalCenter } Rectangle { width: 1; height: 12; color: Theme.divider; anchors.verticalCenter: parent.verticalCenter; visible: gitStatusText.length>0 } Label { visible: gitStatusText.length>0 text: "Git: " + gitStatusText color: { if (gitStatusText === "чисто") return Theme.textMuted if (gitStatusText === "изменён" || gitStatusText === "неотслеживается" || gitStatusText === "новый") return Theme.warningText if (gitStatusText === "конфликт") return Theme.errorText return Theme.textMuted } font.pixelSize: 11 anchors.verticalCenter: parent.verticalCenter } } 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 ? "" + qsTr("Пустой файл") + "
" : rendered viewMode = "preview" } } } } function activeFileDir() { const p = documentManager.pathAt(documentManager.activeIndex) const i = p.lastIndexOf("/") return i >= 0 ? p.substring(0, i) : "" } TableEditor { id: tableEditor; onMarkdownReady: (md) => { textArea.insert(textArea.cursorPosition, md); documentManager.updateContent(documentManager.activeIndex, textArea.text) } } Shortcut { sequence: "Ctrl+S"; onActivated: { documentManager.saveAll(); refreshGitStatus() } } Shortcut { sequence: "Ctrl+W"; onActivated: documentManager.closeTab(documentManager.activeIndex) } Shortcut { sequence: "Ctrl+E"; onActivated: { if (viewMode === "edit") { const rendered = mdParser.render(textArea.text, fileStore.vault, activeFileDir()) viewArea.text = rendered.trim().length === 0 ? "" + qsTr("Пустой файл") + "
" : rendered viewMode = "preview" } else if (viewMode === "preview") { viewMode = "edit" } else { viewMode = "edit" } } } Shortcut { sequence: "Ctrl+Tab"; onActivated: { const n=documentManager.openTabs.length; if(n>0) documentManager.activeIndex = (documentManager.activeIndex+1)%n } } Shortcut { sequence: "Ctrl+Shift+Tab"; onActivated: { const n=documentManager.openTabs.length; if(n>0) documentManager.activeIndex = (documentManager.activeIndex-1+n)%n } } }