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