Фаза 5: панель форматирования — FormatToolbar/TbButton, JS-форматирование в EditorPane (инлайн/списки/задачи/заголовки/цвета/индексы/цитата/выноска/обтекание), TableEditor (2×1, полосы «+» 20px)

This commit is contained in:
Flora (opencode)
2026-08-17 12:34:03 +03:00
parent e7d873903b
commit 3317aa169a
6 changed files with 550 additions and 0 deletions
+126
View File
@@ -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("<mark style=\"background:" + hex + "\">", "</mark>")
else
applyInline("<span style=\"color:" + hex + "\">", "</span>")
}
function applySubSup(kind) {
applyInline(kind === "sup" ? "<sup>" : "<sub>", kind === "sup" ? "</sup>" : "</sub>")
}
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
+41
View File
@@ -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"
+150
View File
@@ -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("<u>", "</u>") }
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()
}
}
}
}
}
}
}
+23
View File
@@ -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()
}