diff --git a/CMakeLists.txt b/CMakeLists.txt
index 85ede92..9107521 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -100,11 +100,14 @@ qt_add_qml_module(flora
qml/panels/FileTreePanel.qml
qml/panels/EditorPanel.qml
qml/panels/EditorPane.qml
+ qml/panels/FormatToolbar.qml
+ qml/panels/TbButton.qml
qml/panels/RightPanel.qml
qml/wizard/WelcomeWizard.qml
qml/dialogs/ProviderSetupGuide.qml
qml/dialogs/NameDialog.qml
qml/dialogs/ConfirmDialog.qml
+ qml/dialogs/TableEditor.qml
)
# --- Тесты ---
diff --git a/qml/dialogs/TableEditor.qml b/qml/dialogs/TableEditor.qml
new file mode 100644
index 0000000..b9bb692
--- /dev/null
+++ b/qml/dialogs/TableEditor.qml
@@ -0,0 +1,207 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+// Редактор markdown-таблицы. По умолчанию 2×1.
+// Справа — столбец «+» (добавить строку под строкой), снизу — ряд «+»
+// (добавить столбец правее столбца). Полосы «+» растут вместе с таблицей.
+Dialog {
+ id: root
+
+ signal markdownReady(string md)
+
+ title: qsTr("Таблица")
+ modal: true
+ width: 560
+ anchors.centerIn: parent
+ closePolicy: Dialog.NoAutoClose
+ standardButtons: Dialog.NoButton
+
+ property int rows: 1
+ property int cols: 2
+ property var cells: [] // плоский список строк (rows*cols)
+
+ readonly property int cellW: 120
+ readonly property int cellH: 34
+
+ function ensureCells() {
+ while (cells.length < rows * cols)
+ cells.push("")
+ while (cells.length > rows * cols)
+ cells.pop()
+ }
+
+ function cell(r, c) {
+ const i = r * cols + c
+ return i < cells.length ? cells[i] : ""
+ }
+ function setCell(r, c, v) {
+ const i = r * cols + c
+ if (i < cells.length) cells[i] = v
+ }
+
+ function addRowAfter(r) {
+ const src = []
+ for (let c = 0; c < cols; ++c) src.push(cell(r, c))
+ const ins = r + 1
+ const flat = []
+ for (let rr = 0; rr <= rows; ++rr) {
+ if (rr === ins) for (const v of src) flat.push(v)
+ for (let c = 0; c < cols; ++c) {
+ if (rr < rows) flat.push(cell(rr, c))
+ }
+ }
+ cells = flat
+ rows += 1
+ rebuildGrid()
+ }
+
+ function addColAfter(c) {
+ const src = []
+ for (let r = 0; r < rows; ++r) src.push(cell(r, c))
+ const ins = c + 1
+ const flat = []
+ for (let rr = 0; rr < rows; ++rr) {
+ for (let cc = 0; cc <= cols; ++cc) {
+ if (cc === ins) flat.push("")
+ if (cc < cols) flat.push(cell(rr, cc))
+ }
+ }
+ cells = flat
+ cols += 1
+ rebuildGrid()
+ }
+
+ function buildMarkdown() {
+ let out = ""
+ for (let c = 0; c < cols; ++c)
+ out += (c === 0 ? "| " : " | ") + cell(0, c)
+ out += " |\n"
+ for (let c = 0; c < cols; ++c)
+ out += (c === 0 ? "| " : " | ") + "---"
+ out += " |\n"
+ for (let r = 1; r < rows; ++r) {
+ for (let c = 0; c < cols; ++c)
+ out += (c === 0 ? "| " : " | ") + cell(r, c)
+ out += " |\n"
+ }
+ return out
+ }
+
+ function rebuildGrid() {
+ // Repeater-ы привязаны к root.rows/root.cols напрямую — ничего не делаем.
+ }
+
+ function resetDialog() {
+ rows = 1
+ cols = 2
+ cells = []
+ for (let i = 0; i < rows * cols; ++i) cells.push("")
+ }
+
+ Component.onCompleted: {
+ resetDialog()
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 8
+
+ Flickable {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ contentWidth: cellGrid.width + 26
+ contentHeight: cellGrid.height + 30
+ clip: true
+ ScrollBar.horizontal: ScrollBar {}
+ ScrollBar.vertical: ScrollBar {}
+
+ Row {
+ id: tableRow
+ spacing: 0
+
+ Grid {
+ id: cellGrid
+ columns: root.cols
+ columnSpacing: 2
+ rowSpacing: 2
+
+ Repeater {
+ model: root.rows * root.cols
+ delegate: TextField {
+ width: root.cellW
+ height: root.cellH
+ text: root.cell(Math.floor(model / root.cols), model % root.cols)
+ onTextEdited: root.setCell(Math.floor(model / root.cols), model % root.cols, text)
+ }
+ }
+ }
+
+ // Полоса «+» справа: добавить строку под строкой (20px шириной)
+ Grid {
+ columns: 1
+ columnSpacing: 2
+ rowSpacing: 2
+ anchors.leftMargin: 4
+ Repeater {
+ id: rowAddStrip
+ model: root.rows
+ delegate: Button {
+ width: 20
+ height: root.cellH
+ text: "+"
+ font.pixelSize: 12
+ ToolTip.visible: hovered
+ ToolTip.text: qsTr("Добавить строку под этой")
+ onClicked: root.addRowAfter(model)
+ }
+ }
+ }
+ }
+
+ Row {
+ anchors.top: tableRow.bottom
+ anchors.topMargin: 4
+ Grid {
+ columns: root.cols
+ columnSpacing: 2
+ rowSpacing: 2
+ Repeater {
+ id: colAddStrip
+ model: root.cols
+ delegate: Button {
+ width: root.cellW
+ height: 20
+ text: "+"
+ font.pixelSize: 12
+ ToolTip.visible: hovered
+ ToolTip.text: qsTr("Добавить столбец правее этого")
+ onClicked: root.addColAfter(model)
+ }
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Item { Layout.fillWidth: true }
+ Label {
+ text: qsTr("Строк: %1 Столбцов: %2").arg(root.rows).arg(root.cols)
+ color: "#888"
+ }
+ Button {
+ text: qsTr("Отмена")
+ onClicked: root.reject()
+ }
+ Button {
+ text: qsTr("Вставить")
+ highlighted: true
+ onClicked: {
+ root.markdownReady(buildMarkdown())
+ root.accept()
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/qml/panels/EditorPane.qml b/qml/panels/EditorPane.qml
index e40846f..025d8fd 100644
--- a/qml/panels/EditorPane.qml
+++ b/qml/panels/EditorPane.qml
@@ -20,6 +20,132 @@ Item {
headerTitle.text = title + (documentManager.isDirty(pane.docIndex) ? " •" : "")
}
+ readonly property bool hasFocus: editor.activeFocus || viewer.activeFocus
+
+ // ---------- Функции форматирования (используются FormatToolbar) ----------
+ function syncText() {
+ documentManager.updateContent(pane.docIndex, editor.text)
+ }
+ function lineStart(pos) {
+ const t = editor.text
+ let i = Math.min(Math.max(pos, 0), t.length)
+ while (i > 0 && t[i - 1] !== "\n") --i
+ return i
+ }
+ function lineEnd(pos) {
+ const t = editor.text
+ let i = Math.min(Math.max(pos, 0), t.length)
+ while (i < t.length && t[i] !== "\n") ++i
+ return i
+ }
+ function replaceRange(start, end, newText) {
+ editor.remove(start, end)
+ editor.insert(start, newText)
+ editor.cursorPosition = start + newText.length
+ syncText()
+ editor.forceActiveFocus()
+ }
+ // Обёртка выделения pre…post (жирный, курсив и т.п.)
+ function applyInline(pre, post) {
+ const s = editor.selectionStart, e = editor.selectionEnd
+ const sel = editor.selectedText
+ if (s === e) {
+ const cp = editor.cursorPosition
+ editor.insert(cp, pre + post)
+ editor.cursorPosition = cp + pre.length
+ } else {
+ const t = pre + sel + post
+ editor.remove(s, e)
+ editor.insert(s, t)
+ editor.cursorPosition = s + pre.length + sel.length
+ }
+ syncText()
+ editor.forceActiveFocus()
+ }
+ // Префикс к каждой строке выделения (списки, цитата)
+ function applyLinePrefix(prefix) {
+ let s = editor.selectionStart, e = editor.selectionEnd
+ if (s === e) { s = lineStart(editor.cursorPosition); e = lineEnd(editor.cursorPosition) }
+ else { s = lineStart(s); e = lineEnd(e) }
+ const lines = editor.text.substring(s, e).split("\n")
+ for (let i = 0; i < lines.length; ++i)
+ lines[i] = prefix + lines[i]
+ replaceRange(s, e, lines.join("\n"))
+ }
+ // Переключение задач: "- [ ]"/"- [x]" ⇄ "- "
+ function applyTaskToggle() {
+ let s = editor.selectionStart, e = editor.selectionEnd
+ if (s === e) { s = lineStart(editor.cursorPosition); e = lineEnd(editor.cursorPosition) }
+ else { s = lineStart(s); e = lineEnd(e) }
+ const lines = editor.text.substring(s, e).split("\n")
+ for (let i = 0; i < lines.length; ++i) {
+ const ln = lines[i]
+ if (/^[-*]\s+\[[ x]\]\s+/.test(ln))
+ lines[i] = ln.replace(/^([-*])\s+\[[ x]\]\s+/, "$1 ")
+ else
+ lines[i] = "- [ ] " + ln
+ }
+ replaceRange(s, e, lines.join("\n"))
+ }
+ // Заголовок уровня 1..6
+ function applyHeading(level) {
+ let s = editor.selectionStart, e = editor.selectionEnd
+ if (s === e) { s = lineStart(editor.cursorPosition); e = lineEnd(editor.cursorPosition) }
+ else { s = lineStart(s); e = lineEnd(e) }
+ const lines = editor.text.substring(s, e).split("\n")
+ for (let i = 0; i < lines.length; ++i)
+ lines[i] = "#".repeat(level) + " " + lines[i].replace(/^#+\s*/, "")
+ replaceRange(s, e, lines.join("\n"))
+ }
+ function applyColor(hex, isHighlight) {
+ if (isHighlight)
+ applyInline("", "")
+ else
+ applyInline("", "")
+ }
+ function applySubSup(kind) {
+ applyInline(kind === "sup" ? "" : "", kind === "sup" ? "" : "")
+ }
+ function insertText(t) {
+ editor.insert(editor.cursorPosition, t)
+ syncText()
+ editor.forceActiveFocus()
+ }
+ // Обтекание картинок/таблиц директивой ![[...|float:left|width:40%]]
+ function applyFloatWrap() {
+ const s = editor.selectionStart, e = editor.selectionEnd
+ const sel = editor.selectedText.trim()
+ if (sel.length > 0) {
+ let inner = sel
+ if (/^!\[\[.+\]\]$/.test(sel))
+ inner = sel.slice(3, -2)
+ const text = "![[" + inner
+ + (inner.includes("|") ? "" : "|float:left|width:40%") + "]]"
+ editor.remove(s, e)
+ editor.insert(s, text)
+ editor.cursorPosition = s + text.length
+ } else {
+ const t = "![[image.png|float:left|width:40%]]"
+ editor.insert(editor.cursorPosition, t)
+ editor.cursorPosition += t.length
+ }
+ syncText()
+ editor.forceActiveFocus()
+ }
+ // Выноска: > [!TYPE] + строки выделения
+ function applyCallout(type) {
+ let s = editor.selectionStart, e = editor.selectionEnd
+ if (s === e) { s = lineStart(editor.cursorPosition); e = lineEnd(editor.cursorPosition) }
+ else { s = lineStart(s); e = lineEnd(e) }
+ const lines = editor.text.substring(s, e).split("\n").filter(l => l.trim().length > 0)
+ const out = ["> [!NOTE]".replace("NOTE", type)]
+ if (lines.length > 0)
+ for (const l of lines) out.push("> " + l)
+ else
+ out.push("> Текст выноски")
+ replaceRange(s, e, out.join("\n"))
+ }
+
ColumnLayout {
anchors.fill: parent
spacing: 0
diff --git a/qml/panels/EditorPanel.qml b/qml/panels/EditorPanel.qml
index bf7f088..40436bd 100644
--- a/qml/panels/EditorPanel.qml
+++ b/qml/panels/EditorPanel.qml
@@ -103,6 +103,18 @@ Item {
}
}
+ // ----- Панель форматирования (Фаза 5) -----
+ FormatToolbar {
+ id: formatToolbar
+ Layout.fillWidth: true
+ visible: documentManager.activeIndex >= 0 && toolbarShouldShow()
+ targetPane: activePane()
+ onTableRequested: {
+ tableEditor.resetDialog()
+ tableEditor.open()
+ }
+ }
+
// ----- Область редактора (одна или две панели) -----
RowLayout {
Layout.fillWidth: true
@@ -143,6 +155,35 @@ Item {
}
}
+ // ----- Функции активной панели и таблица -----
+ function activePane() {
+ if (secondaryPane.visible && secondaryPane.hasFocus)
+ return secondaryPane
+ return primaryPane
+ }
+ function toolbarShouldShow() {
+ const p = primaryPane
+ const s = secondaryPane
+ return (p.hasFocus && p.editMode)
+ || (s.visible && s.hasFocus && s.editMode)
+ }
+
+ Connections {
+ target: primaryPane
+ function onActiveFocusChanged() { formatToolbar.targetPane = root.activePane() }
+ }
+ Connections {
+ target: secondaryPane
+ function onActiveFocusChanged() { formatToolbar.targetPane = root.activePane() }
+ }
+
+ TableEditor {
+ id: tableEditor
+ onMarkdownReady: (md) => {
+ root.activePane().insertText(md)
+ }
+ }
+
// ----- Горячие клавиши -----
Shortcut {
sequence: "Ctrl+S"
diff --git a/qml/panels/FormatToolbar.qml b/qml/panels/FormatToolbar.qml
new file mode 100644
index 0000000..93185f1
--- /dev/null
+++ b/qml/panels/FormatToolbar.qml
@@ -0,0 +1,150 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+// Панель быстрого форматирования текста (видна в режиме редактирования).
+// Работает с выделенным текстом в активной панели (targetPane).
+Item {
+ id: toolbar
+
+ property var targetPane: null // EditorPane
+ readonly property bool usable: targetPane && targetPane.docIndex >= 0
+
+ signal tableRequested()
+
+ implicitHeight: 34
+
+ Rectangle {
+ anchors.fill: parent
+ color: "#f3f3f3"
+ border.color: "#ddd"
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 4
+ anchors.rightMargin: 4
+ spacing: 2
+
+ TbButton { text: "B"; tip: qsTr("Жирный"); onTapped: targetPane.applyInline("**", "**") }
+ TbButton { text: "I"; tip: qsTr("Курсив"); onTapped: targetPane.applyInline("*", "*") }
+ TbButton { text: "U"; tip: qsTr("Подчёркнутый"); onTapped: targetPane.applyInline("", "") }
+ TbButton { text: "S"; tip: qsTr("Зачёркнутый"); onTapped: targetPane.applyInline("~~", "~~") }
+ TbButton { text: "=="; tip: qsTr("Выделение текста"); onTapped: targetPane.applyInline("==", "==") }
+
+ TbButton {
+ text: "A"
+ tip: qsTr("Цвет текста")
+ bold: true
+ onTapped: { colorPopup.highlightMode = false; colorPopup.openAt(this) }
+ }
+ 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("Заголовок")
+ Repeater {
+ model: [1, 2, 3, 4, 5, 6]
+ MenuItem {
+ text: "H" + modelData + " — " + "######".slice(0, modelData)
+ onClicked: 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()
+ }
+ }
+
+ // ---------- Меню: выноски ----------
+ Menu {
+ id: calloutMenu
+ title: qsTr("Выноска")
+ Repeater {
+ model: ["NOTE", "INFO", "WARN", "ERROR"]
+ MenuItem {
+ text: modelData
+ onClicked: targetPane.applyCallout(modelData)
+ }
+ }
+ }
+
+ // ---------- Палитра цветов ----------
+ property var colorList: [
+ "#000000", "#ffffff", "#9e9e9e", "#795548", "#607d8b",
+ "#f44336", "#ff9800", "#ffeb3b", "#4caf50", "#00bcd4",
+ "#2196f3", "#3f51b5", "#9c27b0", "#e91e63"
+ ]
+
+ 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
+ Grid {
+ id: grid
+ anchors.centerIn: parent
+ columns: 7
+ spacing: 3
+ Repeater {
+ model: toolbar.colorList
+ delegate: Rectangle {
+ width: 22
+ height: 22
+ radius: 3
+ color: modelData
+ border.color: modelData === "#ffffff" ? "#bbb" : "transparent"
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ toolbar.targetPane.applyColor(modelData, colorPopup.highlightMode)
+ colorPopup.close()
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/qml/panels/TbButton.qml b/qml/panels/TbButton.qml
new file mode 100644
index 0000000..b572eea
--- /dev/null
+++ b/qml/panels/TbButton.qml
@@ -0,0 +1,23 @@
+import QtQuick
+import QtQuick.Controls
+
+// Компактная кнопка тулбара.
+Button {
+ id: b
+
+ property string tip: ""
+ property bool bold: false
+ property bool accent: false
+ signal tapped()
+
+ implicitWidth: b.text.length > 1 ? 34 : 28
+ implicitHeight: 26
+ font.bold: bold
+ highlighted: accent
+ font.pixelSize: 13
+
+ ToolTip.visible: hovered
+ ToolTip.text: tip
+
+ onClicked: b.tapped()
+}
\ No newline at end of file