Fix: полностью переписан EditorPane — простая TextArea anchors.fill: parent без ColumnLayout, без промежуточных контейнеров; шапка как overlay z:10; viewer с handleViewLink; refresh при смене файла/режима

This commit is contained in:
Flora (opencode)
2026-08-17 19:22:41 +03:00
parent a1c78cf5e3
commit 6bf8e3ea15
+68 -273
View File
@@ -2,185 +2,23 @@ import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
// qml-disable-aot
// Панель одного документа: шапка (заголовок, режим, действия) + текст.
// Режимы: редактирование (TextArea) / просмотр (read-only TextArea).
Item { Item {
id: pane id: pane
property int docIndex: -1 property int docIndex: -1
property bool editMode: true property bool editMode: true
property bool canOpenBeside: false // кнопка «открыть рядом» (заглавная панель) property bool canOpenBeside: false
property bool closable: false // кнопка «закрыть панель» (вторая панель) property bool closable: false
property string editorContent: "" // явное содержимое для TextArea (не биндинг на функцию)
signal openBeside() signal openBeside()
signal closePane() signal closePane()
function refreshHeader() {
const title = documentManager.titleAt(pane.docIndex)
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) { function insertText(t) {
editor.insert(editor.cursorPosition, 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"))
} }
// ---------- TextArea (заполняет ВСЁ пространство) ---------- // Шапка файла — плавающий оверлей
TextArea {
id: editor
anchors.fill: parent
visible: pane.editMode
color: "black"
onTextEdited: documentManager.updateContent(pane.docIndex, text)
selectByMouse: true
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
font.pixelSize: 14
wrapMode: TextEdit.NoWrap
padding: 10
placeholderText: qsTr("Документ не выбран")
}
TextArea {
id: viewer
anchors.fill: parent
visible: !pane.editMode
textFormat: TextEdit.RichText
text: ""
readOnly: true
selectByMouse: true
wrapMode: TextEdit.Wrap
padding: 12
onLinkActivated: (href) => pane.handleViewLink(href)
placeholderText: qsTr("Документ не выбран")
}
// ---------- Шапка файла (плавающий оверлей сверху) ----------
Rectangle { Rectangle {
id: headerBar
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
@@ -196,144 +34,101 @@ Item {
spacing: 6 spacing: 6
Label { Label {
id: headerTitle
Layout.fillWidth: true Layout.fillWidth: true
elide: Text.ElideMiddle elide: Text.ElideMiddle
color: "#444" color: "#444"
text: "" text: documentManager.titleAt(pane.docIndex)
} }
ToolButton { ToolButton {
text: "✎" text: "✎"
checkable: true checkable: true
checked: pane.editMode checked: pane.editMode
ToolTip.text: qsTr("Редактирование") onClicked: pane.editMode = true
onClicked: { pane.editMode = true; editBtn.checked = true; viewBtn.checked = false }
} }
ToolButton { ToolButton {
id: viewBtn
text: "👁" text: "👁"
checkable: true checkable: true
checked: !pane.editMode checked: !pane.editMode
ToolTip.text: qsTr("Просмотр") onClicked: pane.editMode = false
onClicked: { pane.editMode = false; editBtn.checked = false; viewBtn.checked = true }
}
ToolButton {
id: editBtn
visible: false
checkable: true
checked: true
onClicked: { pane.editMode = true; viewBtn.checked = false }
}
ToolButton {
text: "\u25B8\u25CB\u25C0"
visible: pane.canOpenBeside && !documentManager.sideBySide
ToolTip.text: qsTr("Открыть рядом")
onClicked: pane.openBeside()
} }
ToolButton { ToolButton {
text: "\u2715" text: "\u2715"
visible: pane.closable visible: pane.closable
ToolTip.text: qsTr("Закрыть панель")
onClicked: pane.closePane() onClicked: pane.closePane()
} }
} }
} }
// ---------- Функции ---------- // TextArea — заполняет ВСЁ пространство
function loadContent() { TextArea {
if (pane.docIndex >= 0) { id: editor
const c = documentManager.contentAt(pane.docIndex) anchors.fill: parent
// Принудительное обновление TextArea: сначала очищаем, затем ставим контент visible: pane.editMode
editor.text = "" color: "black"
editor.text = c font.family: "monospace"
editor.cursorPosition = c.length font.pixelSize: 14
pane.editorContent = c padding: 10
} else { onTextEdited: documentManager.updateContent(pane.docIndex, text)
editor.text = ""
pane.editorContent = ""
}
}
function pathDir(p) {
const idx = p.lastIndexOf("/")
return idx >= 0 ? p.substring(0, idx) : p
}
function renderView() {
if (pane.editMode || pane.docIndex < 0)
return
viewer.text = mdParser.render(documentManager.contentAt(pane.docIndex),
fileStore.vault,
pathDir(documentManager.pathAt(pane.docIndex)))
}
function handleViewLink(href) {
if (href.startsWith("flora://task/")) {
const line = Number(href.substring("flora://task/".length))
if (line > 0) pane.toggleTaskLine(line)
return
}
if (href.startsWith("flora://open/")) {
const path = decodeURIComponent(href.substring("flora://open/".length))
if (path.length > 0) documentManager.openDocument(path)
return
}
if (href.startsWith("flora://wiki/")) {
// Фаза 8: создание через базу связей; пока — статус.
console.log("wiki:", decodeURIComponent(href))
return
}
console.log("внешняя ссылка:", href)
}
function toggleTaskLine(line) {
const text = documentManager.contentAt(pane.docIndex)
const lines = text.split("\n")
const ln = lines[line - 1]
if (ln === undefined) return
if (/\[ \]/.test(ln))
lines[line - 1] = ln.replace("[ ]", "[x]")
else if (/\[x\]/.test(ln))
lines[line - 1] = ln.replace("[x]", "[ ]")
documentManager.updateContent(pane.docIndex, lines.join("\n"))
pane.renderView()
} }
// Свежий заголовок при смене признака «изменено» // Просмотр
Connections { TextArea {
target: documentManager id: viewer
function onDirtyFlagChanged(i) { anchors.fill: parent
if (i === pane.docIndex) visible: !pane.editMode
pane.refreshHeader() readOnly: true
} color: "black"
} font.family: "monospace"
// Просмотр обновляется при изменении контента font.pixelSize: 14
Connections { padding: 10
target: documentManager onLinkActivated: (href) => {
function onActiveIndexChanged() { if (href.startsWith("flora://task/")) {
pane.loadContent() const line = Number(href.substring(14))
} if (line > 0) {
function onContentChanged(i) { const lines = editor.text.split("\n")
if (i === pane.docIndex) { const ln = lines[line - 1]
pane.loadContent() if (ln !== undefined) {
if (!pane.editMode) if (/\[ \]/.test(ln)) lines[line - 1] = ln.replace("[ ]", "[x]")
pane.renderView() else if (/\[x\]/.test(ln)) lines[line - 1] = ln.replace("[x]", "[ ]")
editor.text = lines.join("\n")
documentManager.updateContent(pane.docIndex, editor.text)
}
}
} else if (href.startsWith("flora://open/")) {
documentManager.openDocument(decodeURIComponent(href.substring(13)))
} }
} }
} }
onDocIndexChanged: { // Загрузка контента при смене файла
pane.refreshHeader() function refresh() {
pane.renderView() const idx = pane.docIndex
pane.loadContent() if (idx >= 0) {
loadTimer.start() const c = documentManager.contentAt(idx)
editor.text = c
viewer.text = mdParser.render(c, fileStore.vault, filePathDir(documentManager.pathAt(idx)))
} else {
editor.text = ""
viewer.text = ""
}
} }
Timer { function filePathDir(p) {
id: loadTimer const i = p.lastIndexOf("/")
interval: 50 return i >= 0 ? p.substring(0, i) : p
onTriggered: pane.loadContent()
} }
onEditModeChanged: {
if (!pane.editMode) onDocIndexChanged: refresh()
pane.renderView() onEditModeChanged: { if (!editMode) refresh() }
Connections {
target: documentManager
function onActiveIndexChanged() {
if (documentManager.activeIndex === pane.docIndex)
refresh()
}
} }
Component.onCompleted: pane.refreshHeader()
} Component.onCompleted: refresh()
}